Compare commits

..

4 Commits

Author SHA1 Message Date
Josh Lehman
53ac244ec6 Merge branch 'main' into codex/plugin-command-scope-auth 2026-03-26 16:15:50 -07:00
Josh Lehman
c94f10b915 Plugins: harden dynamic scope resolution
Catch dynamic gateway-scope resolver failures in the dispatcher, narrow
forwarded gateway scope strings with an explicit operator-scope guard, add
regression coverage for admin bypass and resolver-throw behavior, and
refresh bundled plugin metadata after main-branch drift.

Regeneration-Prompt: |
  Follow up on review feedback for the centralized plugin command auth
  change. Keep the scope tightly limited to the three review items:
  catch exceptions from `resolveRequiredGatewayScopes`, replace the raw
  `GatewayClientScopes` cast with explicit operator-scope narrowing, and
  add dispatcher-level tests for the `operator.admin` bypass plus the safe
  failure path when dynamic scope resolution throws.

  While landing that patch, the repo hook may report stale bundled plugin
  metadata generated files because main advanced. Regenerate those standard
  outputs with the repo generator so the branch is consistent enough to
  rebase, but do not chase unrelated CI or Discord test failures here.
2026-03-26 16:11:09 -07:00
Josh Lehman
70b43319ff Plugin SDK: refresh API baselines for auth context change
Update the generated Plugin SDK API baseline files after extending plugin
command types for centralized owner and gateway-scope authorization.

Regeneration-Prompt: |
  The prior commit intentionally changed exported plugin SDK types in
  `src/plugins/types.ts` by adding richer plugin command auth context and
  declarative command requirement fields. CI reported plugin SDK API drift,
  which means the generated baseline files under `docs/.generated/` no
  longer matched the exported surface.

  Regenerate only the plugin SDK API baseline artifacts with the repo's
  standard generator, verify `pnpm plugin-sdk:api:check` passes, and keep
  this follow-up scoped to those generated files. Do not fold in unrelated
  failing tests from untouched surfaces.
2026-03-26 16:11:09 -07:00
Josh Lehman
487f752754 Plugins: centralize plugin command auth requirements
Move plugin command authorization toward the GHSA's long-term model by
preserving richer auth context, supporting declarative owner and gateway
scope requirements, and enforcing them in the shared dispatcher. Convert
`/pair approve` to use the centralized requirement path and add regression
coverage for dispatcher-level auth behavior.

Regeneration-Prompt: |
  This follow-up hardening is for the plugin command auth gap described in
  GHSA-9gwp-pxfh-w6r5. The immediate exploit path was already fixed by
  plumbing gateway scopes into the device-pair plugin and checking `/pair
  approve` inline, but the longer-term goal is to stop relying on lossy,
  plugin-specific auth checks.

  Preserve the existing plugin command flow and keep the change additive.
  Carry richer authorization context into plugin execution, including owner
  status and command surface, and let commands declare owner or internal
  gateway-scope requirements that the central dispatcher enforces. Internal
  callers should fail closed when required scopes are missing, with admin
  scope still satisfying narrower operator requirements, while non-internal
  chat surfaces should keep their current auth behavior.

  Because `/pair` mixes low-risk actions like `qr` and `status` with the
  privileged `approve` action, use a context-sensitive requirement instead
  of making the whole command require pairing scope. Add focused regression
  tests around dispatcher enforcement and update any command-context test
  helpers that now need the richer fields.
2026-03-26 16:10:38 -07:00
101 changed files with 1595 additions and 1222 deletions

View File

@@ -21,7 +21,6 @@ Docs: https://docs.openclaw.ai
- Agents/compaction: surface safeguard-specific cancel reasons and relabel benign manual `/compact` no-op cases as skipped instead of failed. (#51072) Thanks @afurm.
- Plugins/CLI backends: move bundled Claude CLI, Codex CLI, and Gemini CLI inference defaults onto the plugin surface, add bundled Gemini CLI backend support, and replace `gateway run --claude-cli-logs` with generic `--cli-backend-logs` while keeping the old flag as a compatibility alias.
- Plugins/startup: auto-load bundled provider and CLI-backend plugins from explicit config refs, so bundled Claude CLI, Codex CLI, and Gemini CLI message-provider setups no longer need manual `plugins.allow` entries.
- Config/TTS: auto-migrate legacy speech config on normal reads and secret resolution, keep legacy diagnostics for Doctor, and remove regular-mode runtime fallback for old bundled `tts.<provider>` API-key shapes.
### Fixes
@@ -38,7 +37,6 @@ Docs: https://docs.openclaw.ai
- CLI/onboarding: show the Kimi Code API key option again in the Moonshot setup menu so the interactive picker includes all Kimi setup paths together. Fixes #54412 Thanks @sparkyrider
- Agents/status: use provider-aware context window lookup for fresh Anthropic 4.6 model overrides so `/status` shows the correct 1.0m window instead of an underreported shared-cache minimum. (#54796) Thanks @neeravmakwana.
- Agents/errors: surface provider quota/reset details when available, but keep HTML/Cloudflare rate-limit pages on the generic fallback so raw error pages are not shown to users. (#54512) Thanks @bugkill3r.
- CLI/agents: stop injecting a fake "Tools are disabled" system-prompt line into CLI backend runs, so Claude CLI native tools can decide tool usage themselves. (#46214) Thanks @Dhi13man.
- Agents/embedded replies: surface mid-turn 429 and overload failures when embedded runs end without a user-visible reply, while preserving successful media-only replies that still use legacy `mediaUrl`. (#50930) Thanks @infichen.
- WhatsApp/allowFrom: show a specific allowFrom policy error for valid blocked targets instead of the misleading `<E.164|group JID>` format hint. Thanks @mcaxtr.
- Agents/cooldowns: scope rate-limit cooldowns per model so one 429 no longer blocks every model on the same auth profile, replace the exponential 1 min -> 1 h escalation with a stepped 30 s / 1 min / 5 min ladder, and surface a user-facing countdown message when all models are rate-limited. (#49834) Thanks @kiranvk-2011.

View File

@@ -244,7 +244,7 @@
"exportName": "CliBackendPlugin",
"kind": "type",
"source": {
"line": 1316,
"line": 1346,
"path": "src/plugins/types.ts"
}
},
@@ -406,7 +406,7 @@
"exportName": "OpenClawPluginApi",
"kind": "type",
"source": {
"line": 1360,
"line": 1390,
"path": "src/plugins/types.ts"
}
},
@@ -3423,7 +3423,7 @@
"exportName": "OpenClawPluginApi",
"kind": "type",
"source": {
"line": 1360,
"line": 1390,
"path": "src/plugins/types.ts"
}
},
@@ -3432,7 +3432,7 @@
"exportName": "OpenClawPluginCommandDefinition",
"kind": "type",
"source": {
"line": 1086,
"line": 1108,
"path": "src/plugins/types.ts"
}
},
@@ -3450,7 +3450,7 @@
"exportName": "OpenClawPluginDefinition",
"kind": "type",
"source": {
"line": 1342,
"line": 1372,
"path": "src/plugins/types.ts"
}
},
@@ -3459,7 +3459,7 @@
"exportName": "OpenClawPluginService",
"kind": "type",
"source": {
"line": 1309,
"line": 1339,
"path": "src/plugins/types.ts"
}
},
@@ -3468,7 +3468,7 @@
"exportName": "OpenClawPluginServiceContext",
"kind": "type",
"source": {
"line": 1301,
"line": 1331,
"path": "src/plugins/types.ts"
}
},
@@ -3504,7 +3504,7 @@
"exportName": "PluginInteractiveTelegramHandlerContext",
"kind": "type",
"source": {
"line": 1115,
"line": 1145,
"path": "src/plugins/types.ts"
}
},
@@ -3929,7 +3929,7 @@
"exportName": "OpenClawPluginApi",
"kind": "type",
"source": {
"line": 1360,
"line": 1390,
"path": "src/plugins/types.ts"
}
},
@@ -3938,7 +3938,7 @@
"exportName": "OpenClawPluginCommandDefinition",
"kind": "type",
"source": {
"line": 1086,
"line": 1108,
"path": "src/plugins/types.ts"
}
},
@@ -3956,7 +3956,7 @@
"exportName": "OpenClawPluginDefinition",
"kind": "type",
"source": {
"line": 1342,
"line": 1372,
"path": "src/plugins/types.ts"
}
},
@@ -3965,7 +3965,7 @@
"exportName": "OpenClawPluginService",
"kind": "type",
"source": {
"line": 1309,
"line": 1339,
"path": "src/plugins/types.ts"
}
},
@@ -3974,7 +3974,7 @@
"exportName": "OpenClawPluginServiceContext",
"kind": "type",
"source": {
"line": 1301,
"line": 1331,
"path": "src/plugins/types.ts"
}
},
@@ -4010,7 +4010,7 @@
"exportName": "PluginInteractiveTelegramHandlerContext",
"kind": "type",
"source": {
"line": 1115,
"line": 1145,
"path": "src/plugins/types.ts"
}
},

View File

@@ -25,7 +25,7 @@
{"declaration":"export type ChannelStatusIssue = ChannelStatusIssue;","entrypoint":"index","exportName":"ChannelStatusIssue","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":100,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"index","exportName":"ClawdbotConfig","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"}
{"declaration":"export type CliBackendConfig = CliBackendConfig;","entrypoint":"index","exportName":"CliBackendConfig","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":47,"sourcePath":"src/config/types.agent-defaults.ts"}
{"declaration":"export type CliBackendPlugin = CliBackendPlugin;","entrypoint":"index","exportName":"CliBackendPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1316,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type CliBackendPlugin = CliBackendPlugin;","entrypoint":"index","exportName":"CliBackendPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1346,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type CompiledConfiguredBinding = CompiledConfiguredBinding;","entrypoint":"index","exportName":"CompiledConfiguredBinding","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":38,"sourcePath":"src/channels/plugins/binding-types.ts"}
{"declaration":"export type ConfiguredBindingConversation = ConversationRef;","entrypoint":"index","exportName":"ConfiguredBindingConversation","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":13,"sourcePath":"src/channels/plugins/binding-types.ts"}
{"declaration":"export type ConfiguredBindingResolution = ConfiguredBindingResolution;","entrypoint":"index","exportName":"ConfiguredBindingResolution","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":49,"sourcePath":"src/channels/plugins/binding-types.ts"}
@@ -43,7 +43,7 @@
{"declaration":"export type ImageGenerationSourceImage = ImageGenerationSourceImage;","entrypoint":"index","exportName":"ImageGenerationSourceImage","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":14,"sourcePath":"src/image-generation/types.ts"}
{"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"index","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":969,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"index","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"index","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1360,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"index","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1390,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"index","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":95,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginLogger = PluginLogger;","entrypoint":"index","exportName":"PluginLogger","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":66,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginRuntime = PluginRuntime;","entrypoint":"index","exportName":"PluginRuntime","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":54,"sourcePath":"src/plugins/runtime/types.ts"}
@@ -376,16 +376,16 @@
{"declaration":"export type GatewayRequestHandlerOptions = GatewayRequestHandlerOptions;","entrypoint":"core","exportName":"GatewayRequestHandlerOptions","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":112,"sourcePath":"src/gateway/server-methods/types.ts"}
{"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"core","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":969,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"core","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1360,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"core","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1086,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1390,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"core","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1108,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"core","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":95,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"core","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1342,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginService = OpenClawPluginService;","entrypoint":"core","exportName":"OpenClawPluginService","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1309,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginServiceContext = OpenClawPluginServiceContext;","entrypoint":"core","exportName":"OpenClawPluginServiceContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1301,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"core","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1372,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginService = OpenClawPluginService;","entrypoint":"core","exportName":"OpenClawPluginService","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1339,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginServiceContext = OpenClawPluginServiceContext;","entrypoint":"core","exportName":"OpenClawPluginServiceContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1331,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginToolContext = OpenClawPluginToolContext;","entrypoint":"core","exportName":"OpenClawPluginToolContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":110,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginToolFactory = OpenClawPluginToolFactory;","entrypoint":"core","exportName":"OpenClawPluginToolFactory","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":131,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginCommandContext = PluginCommandContext;","entrypoint":"core","exportName":"PluginCommandContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":984,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginInteractiveTelegramHandlerContext = PluginInteractiveTelegramHandlerContext;","entrypoint":"core","exportName":"PluginInteractiveTelegramHandlerContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1115,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginInteractiveTelegramHandlerContext = PluginInteractiveTelegramHandlerContext;","entrypoint":"core","exportName":"PluginInteractiveTelegramHandlerContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1145,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginLogger = PluginLogger;","entrypoint":"core","exportName":"PluginLogger","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":66,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginRuntime = PluginRuntime;","entrypoint":"core","exportName":"PluginRuntime","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":54,"sourcePath":"src/plugins/runtime/types.ts"}
{"declaration":"export type ProviderAugmentModelCatalogContext = ProviderAugmentModelCatalogContext;","entrypoint":"core","exportName":"ProviderAugmentModelCatalogContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":582,"sourcePath":"src/plugins/types.ts"}
@@ -432,16 +432,16 @@
{"declaration":"export type AnyAgentTool = AnyAgentTool;","entrypoint":"plugin-entry","exportName":"AnyAgentTool","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":9,"sourcePath":"src/agents/tools/common.ts"}
{"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"plugin-entry","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":969,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"plugin-entry","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"plugin-entry","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1360,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1086,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"plugin-entry","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1390,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1108,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"plugin-entry","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":95,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1342,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginService = OpenClawPluginService;","entrypoint":"plugin-entry","exportName":"OpenClawPluginService","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1309,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginServiceContext = OpenClawPluginServiceContext;","entrypoint":"plugin-entry","exportName":"OpenClawPluginServiceContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1301,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1372,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginService = OpenClawPluginService;","entrypoint":"plugin-entry","exportName":"OpenClawPluginService","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1339,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginServiceContext = OpenClawPluginServiceContext;","entrypoint":"plugin-entry","exportName":"OpenClawPluginServiceContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1331,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginToolContext = OpenClawPluginToolContext;","entrypoint":"plugin-entry","exportName":"OpenClawPluginToolContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":110,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginToolFactory = OpenClawPluginToolFactory;","entrypoint":"plugin-entry","exportName":"OpenClawPluginToolFactory","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":131,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginCommandContext = PluginCommandContext;","entrypoint":"plugin-entry","exportName":"PluginCommandContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":984,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginInteractiveTelegramHandlerContext = PluginInteractiveTelegramHandlerContext;","entrypoint":"plugin-entry","exportName":"PluginInteractiveTelegramHandlerContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1115,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginInteractiveTelegramHandlerContext = PluginInteractiveTelegramHandlerContext;","entrypoint":"plugin-entry","exportName":"PluginInteractiveTelegramHandlerContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1145,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginLogger = PluginLogger;","entrypoint":"plugin-entry","exportName":"PluginLogger","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":66,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type ProviderAugmentModelCatalogContext = ProviderAugmentModelCatalogContext;","entrypoint":"plugin-entry","exportName":"ProviderAugmentModelCatalogContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":582,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type ProviderAuthContext = ProviderAuthContext;","entrypoint":"plugin-entry","exportName":"ProviderAuthContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":166,"sourcePath":"src/plugins/types.ts"}

View File

@@ -15,7 +15,7 @@ It works anywhere OpenClaw can send audio.
## Supported services
- **ElevenLabs** (primary or fallback provider)
- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`)
- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`, default when no API keys)
- **OpenAI** (primary or fallback provider; also used for summaries)
### Microsoft speech notes
@@ -38,7 +38,9 @@ If you want OpenAI or ElevenLabs:
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
- `OPENAI_API_KEY`
Microsoft speech does **not** require an API key.
Microsoft speech does **not** require an API key. If no API keys are found,
OpenClaw defaults to Microsoft (unless disabled via
`messages.tts.microsoft.enabled=false` or `messages.tts.edge.enabled=false`).
If multiple providers are configured, the selected provider is used first and the others are fallback options.
Auto-summary uses the configured `summaryModel` (or `agents.defaults.model.primary`),
@@ -58,8 +60,8 @@ so that provider must also be authenticated if you enable summaries.
No. AutoTTS is **off** by default. Enable it in config with
`messages.tts.auto` or per session with `/tts always` (alias: `/tts on`).
When `messages.tts.provider` is unset, OpenClaw picks the first configured
speech provider in registry auto-select order.
Microsoft speech **is** enabled by default once TTS is on, and is used automatically
when no OpenAI or ElevenLabs API keys are available.
## Config
@@ -91,28 +93,26 @@ Full schema is in [Gateway configuration](/gateway/configuration).
modelOverrides: {
enabled: true,
},
providers: {
openai: {
apiKey: "openai_api_key",
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini-tts",
voice: "alloy",
},
elevenlabs: {
apiKey: "elevenlabs_api_key",
baseUrl: "https://api.elevenlabs.io",
voiceId: "voice_id",
modelId: "eleven_multilingual_v2",
seed: 42,
applyTextNormalization: "auto",
languageCode: "en",
voiceSettings: {
stability: 0.5,
similarityBoost: 0.75,
style: 0.0,
useSpeakerBoost: true,
speed: 1.0,
},
openai: {
apiKey: "openai_api_key",
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini-tts",
voice: "alloy",
},
elevenlabs: {
apiKey: "elevenlabs_api_key",
baseUrl: "https://api.elevenlabs.io",
voiceId: "voice_id",
modelId: "eleven_multilingual_v2",
seed: 42,
applyTextNormalization: "auto",
languageCode: "en",
voiceSettings: {
stability: 0.5,
similarityBoost: 0.75,
style: 0.0,
useSpeakerBoost: true,
speed: 1.0,
},
},
},
@@ -128,15 +128,13 @@ Full schema is in [Gateway configuration](/gateway/configuration).
tts: {
auto: "always",
provider: "microsoft",
providers: {
microsoft: {
enabled: true,
voice: "en-US-MichelleNeural",
lang: "en-US",
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
rate: "+10%",
pitch: "-5%",
},
microsoft: {
enabled: true,
voice: "en-US-MichelleNeural",
lang: "en-US",
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
rate: "+10%",
pitch: "-5%",
},
},
},
@@ -149,10 +147,8 @@ Full schema is in [Gateway configuration](/gateway/configuration).
{
messages: {
tts: {
providers: {
microsoft: {
enabled: false,
},
microsoft: {
enabled: false,
},
},
},
@@ -212,37 +208,37 @@ Then run:
- `enabled`: legacy toggle (doctor migrates this to `auto`).
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
- `provider`: speech provider id such as `"elevenlabs"`, `"microsoft"`, or `"openai"` (fallback is automatic).
- If `provider` is **unset**, OpenClaw uses the first configured speech provider in registry auto-select order.
- If `provider` is **unset**, OpenClaw prefers `openai` (if key), then `elevenlabs` (if key),
otherwise `microsoft`.
- Legacy `provider: "edge"` still works and is normalized to `microsoft`.
- `summaryModel`: optional cheap model for auto-summary; defaults to `agents.defaults.model.primary`.
- Accepts `provider/model` or a configured model alias.
- `modelOverrides`: allow the model to emit TTS directives (on by default).
- `allowProvider` defaults to `false` (provider switching is opt-in).
- `providers.<id>`: provider-owned settings keyed by speech provider id.
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
- `timeoutMs`: request timeout (ms).
- `prefsPath`: override the local prefs JSON path (provider/limit/summary).
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `OPENAI_API_KEY`).
- `providers.elevenlabs.baseUrl`: override ElevenLabs API base URL.
- `providers.openai.baseUrl`: override the OpenAI TTS endpoint.
- Resolution order: `messages.tts.providers.openai.baseUrl` -> `OPENAI_TTS_BASE_URL` -> `https://api.openai.com/v1`
- `elevenlabs.baseUrl`: override ElevenLabs API base URL.
- `openai.baseUrl`: override the OpenAI TTS endpoint.
- Resolution order: `messages.tts.openai.baseUrl` -> `OPENAI_TTS_BASE_URL` -> `https://api.openai.com/v1`
- Non-default values are treated as OpenAI-compatible TTS endpoints, so custom model and voice names are accepted.
- `providers.elevenlabs.voiceSettings`:
- `elevenlabs.voiceSettings`:
- `stability`, `similarityBoost`, `style`: `0..1`
- `useSpeakerBoost`: `true|false`
- `speed`: `0.5..2.0` (1.0 = normal)
- `providers.elevenlabs.applyTextNormalization`: `auto|on|off`
- `providers.elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
- `providers.elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
- `providers.microsoft.enabled`: allow Microsoft speech usage (default `true`; no API key).
- `providers.microsoft.voice`: Microsoft neural voice name (e.g. `en-US-MichelleNeural`).
- `providers.microsoft.lang`: language code (e.g. `en-US`).
- `providers.microsoft.outputFormat`: Microsoft output format (e.g. `audio-24khz-48kbitrate-mono-mp3`).
- `elevenlabs.applyTextNormalization`: `auto|on|off`
- `elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
- `elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
- `microsoft.enabled`: allow Microsoft speech usage (default `true`; no API key).
- `microsoft.voice`: Microsoft neural voice name (e.g. `en-US-MichelleNeural`).
- `microsoft.lang`: language code (e.g. `en-US`).
- `microsoft.outputFormat`: Microsoft output format (e.g. `audio-24khz-48kbitrate-mono-mp3`).
- See Microsoft Speech output formats for valid values; not all formats are supported by the bundled Edge-backed transport.
- `providers.microsoft.rate` / `providers.microsoft.pitch` / `providers.microsoft.volume`: percent strings (e.g. `+10%`, `-5%`).
- `providers.microsoft.saveSubtitles`: write JSON subtitles alongside the audio file.
- `providers.microsoft.proxy`: proxy URL for Microsoft speech requests.
- `providers.microsoft.timeoutMs`: request timeout override (ms).
- `microsoft.rate` / `microsoft.pitch` / `microsoft.volume`: percent strings (e.g. `+10%`, `-5%`).
- `microsoft.saveSubtitles`: write JSON subtitles alongside the audio file.
- `microsoft.proxy`: proxy URL for Microsoft speech requests.
- `microsoft.timeoutMs`: request timeout override (ms).
- `edge.*`: legacy alias for the same Microsoft settings.
## Model-driven overrides (default on)

View File

@@ -15,7 +15,7 @@ It works anywhere OpenClaw can send audio.
## Supported services
- **ElevenLabs** (primary or fallback provider)
- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`)
- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`, default when no API keys)
- **OpenAI** (primary or fallback provider; also used for summaries)
### Microsoft speech notes
@@ -38,7 +38,9 @@ If you want OpenAI or ElevenLabs:
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
- `OPENAI_API_KEY`
Microsoft speech does **not** require an API key.
Microsoft speech does **not** require an API key. If no API keys are found,
OpenClaw defaults to Microsoft (unless disabled via
`messages.tts.microsoft.enabled=false` or `messages.tts.edge.enabled=false`).
If multiple providers are configured, the selected provider is used first and the others are fallback options.
Auto-summary uses the configured `summaryModel` (or `agents.defaults.model.primary`),
@@ -58,8 +60,8 @@ so that provider must also be authenticated if you enable summaries.
No. AutoTTS is **off** by default. Enable it in config with
`messages.tts.auto` or per session with `/tts always` (alias: `/tts on`).
When `messages.tts.provider` is unset, OpenClaw picks the first configured
speech provider in registry auto-select order.
Microsoft speech **is** enabled by default once TTS is on, and is used automatically
when no OpenAI or ElevenLabs API keys are available.
## Config
@@ -91,28 +93,26 @@ Full schema is in [Gateway configuration](/gateway/configuration).
modelOverrides: {
enabled: true,
},
providers: {
openai: {
apiKey: "openai_api_key",
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini-tts",
voice: "alloy",
},
elevenlabs: {
apiKey: "elevenlabs_api_key",
baseUrl: "https://api.elevenlabs.io",
voiceId: "voice_id",
modelId: "eleven_multilingual_v2",
seed: 42,
applyTextNormalization: "auto",
languageCode: "en",
voiceSettings: {
stability: 0.5,
similarityBoost: 0.75,
style: 0.0,
useSpeakerBoost: true,
speed: 1.0,
},
openai: {
apiKey: "openai_api_key",
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini-tts",
voice: "alloy",
},
elevenlabs: {
apiKey: "elevenlabs_api_key",
baseUrl: "https://api.elevenlabs.io",
voiceId: "voice_id",
modelId: "eleven_multilingual_v2",
seed: 42,
applyTextNormalization: "auto",
languageCode: "en",
voiceSettings: {
stability: 0.5,
similarityBoost: 0.75,
style: 0.0,
useSpeakerBoost: true,
speed: 1.0,
},
},
},
@@ -128,15 +128,13 @@ Full schema is in [Gateway configuration](/gateway/configuration).
tts: {
auto: "always",
provider: "microsoft",
providers: {
microsoft: {
enabled: true,
voice: "en-US-MichelleNeural",
lang: "en-US",
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
rate: "+10%",
pitch: "-5%",
},
microsoft: {
enabled: true,
voice: "en-US-MichelleNeural",
lang: "en-US",
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
rate: "+10%",
pitch: "-5%",
},
},
},
@@ -149,10 +147,8 @@ Full schema is in [Gateway configuration](/gateway/configuration).
{
messages: {
tts: {
providers: {
microsoft: {
enabled: false,
},
microsoft: {
enabled: false,
},
},
},
@@ -212,37 +208,37 @@ Then run:
- `enabled`: legacy toggle (doctor migrates this to `auto`).
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
- `provider`: speech provider id such as `"elevenlabs"`, `"microsoft"`, or `"openai"` (fallback is automatic).
- If `provider` is **unset**, OpenClaw uses the first configured speech provider in registry auto-select order.
- If `provider` is **unset**, OpenClaw prefers `openai` (if key), then `elevenlabs` (if key),
otherwise `microsoft`.
- Legacy `provider: "edge"` still works and is normalized to `microsoft`.
- `summaryModel`: optional cheap model for auto-summary; defaults to `agents.defaults.model.primary`.
- Accepts `provider/model` or a configured model alias.
- `modelOverrides`: allow the model to emit TTS directives (on by default).
- `allowProvider` defaults to `false` (provider switching is opt-in).
- `providers.<id>`: provider-owned settings keyed by speech provider id.
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
- `timeoutMs`: request timeout (ms).
- `prefsPath`: override the local prefs JSON path (provider/limit/summary).
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `OPENAI_API_KEY`).
- `providers.elevenlabs.baseUrl`: override ElevenLabs API base URL.
- `providers.openai.baseUrl`: override the OpenAI TTS endpoint.
- Resolution order: `messages.tts.providers.openai.baseUrl` -> `OPENAI_TTS_BASE_URL` -> `https://api.openai.com/v1`
- `elevenlabs.baseUrl`: override ElevenLabs API base URL.
- `openai.baseUrl`: override the OpenAI TTS endpoint.
- Resolution order: `messages.tts.openai.baseUrl` -> `OPENAI_TTS_BASE_URL` -> `https://api.openai.com/v1`
- Non-default values are treated as OpenAI-compatible TTS endpoints, so custom model and voice names are accepted.
- `providers.elevenlabs.voiceSettings`:
- `elevenlabs.voiceSettings`:
- `stability`, `similarityBoost`, `style`: `0..1`
- `useSpeakerBoost`: `true|false`
- `speed`: `0.5..2.0` (1.0 = normal)
- `providers.elevenlabs.applyTextNormalization`: `auto|on|off`
- `providers.elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
- `providers.elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
- `providers.microsoft.enabled`: allow Microsoft speech usage (default `true`; no API key).
- `providers.microsoft.voice`: Microsoft neural voice name (e.g. `en-US-MichelleNeural`).
- `providers.microsoft.lang`: language code (e.g. `en-US`).
- `providers.microsoft.outputFormat`: Microsoft output format (e.g. `audio-24khz-48kbitrate-mono-mp3`).
- `elevenlabs.applyTextNormalization`: `auto|on|off`
- `elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
- `elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
- `microsoft.enabled`: allow Microsoft speech usage (default `true`; no API key).
- `microsoft.voice`: Microsoft neural voice name (e.g. `en-US-MichelleNeural`).
- `microsoft.lang`: language code (e.g. `en-US`).
- `microsoft.outputFormat`: Microsoft output format (e.g. `audio-24khz-48kbitrate-mono-mp3`).
- See Microsoft Speech output formats for valid values; not all formats are supported by the bundled Edge-backed transport.
- `providers.microsoft.rate` / `providers.microsoft.pitch` / `providers.microsoft.volume`: percent strings (e.g. `+10%`, `-5%`).
- `providers.microsoft.saveSubtitles`: write JSON subtitles alongside the audio file.
- `providers.microsoft.proxy`: proxy URL for Microsoft speech requests.
- `providers.microsoft.timeoutMs`: request timeout override (ms).
- `microsoft.rate` / `microsoft.pitch` / `microsoft.volume`: percent strings (e.g. `+10%`, `-5%`).
- `microsoft.saveSubtitles`: write JSON subtitles alongside the audio file.
- `microsoft.proxy`: proxy URL for Microsoft speech requests.
- `microsoft.timeoutMs`: request timeout override (ms).
- `edge.*`: legacy alias for the same Microsoft settings.
## Model-driven overrides (default on)

View File

@@ -6,6 +6,7 @@ import type {
PluginCommandContext,
} from "openclaw/plugin-sdk/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { executePluginCommand } from "../../src/plugins/commands.js";
import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js";
import type { OpenClawPluginApi } from "./api.js";
import type { PendingPairingRequest } from "./notify.ts";
@@ -120,9 +121,12 @@ function createChannelRuntime(
}
function createCommandContext(params?: Partial<PluginCommandContext>): PluginCommandContext {
const surface = params?.surface ?? params?.channel ?? "webchat";
return {
channel: "webchat",
surface,
channel: surface,
isAuthorizedSender: true,
senderIsOwner: false,
commandBody: "/pair qr",
args: "qr",
config: {},
@@ -446,14 +450,20 @@ describe("device-pair /pair approve", () => {
});
const command = registerPairCommand();
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "approve latest",
commandBody: "/pair approve latest",
gatewayClientScopes: ["operator.write"],
}),
);
const result = await executePluginCommand({
command: {
...command,
pluginId: "device-pair",
},
senderId: "writer-1",
surface: "webchat",
channel: "webchat",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.write"],
args: "approve latest",
commandBody: "/pair approve latest",
config: {} as never,
});
expect(vi.mocked(approveDevicePairing)).not.toHaveBeenCalled();
expect(result).toEqual({
@@ -501,14 +511,20 @@ describe("device-pair /pair approve", () => {
});
const command = registerPairCommand();
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "approve latest",
commandBody: "/pair approve latest",
gatewayClientScopes: ["operator.write", "operator.pairing"],
}),
);
const result = await executePluginCommand({
command: {
...command,
pluginId: "device-pair",
},
senderId: "pairing-1",
surface: "webchat",
channel: "webchat",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.write", "operator.pairing"],
args: "approve latest",
commandBody: "/pair approve latest",
config: {} as never,
});
expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1");
expect(result).toEqual({ text: "✅ Paired Victim Phone (ios)." });

View File

@@ -546,13 +546,14 @@ export default definePluginEntry({
name: "pair",
description: "Generate setup codes and approve device pairing requests.",
acceptsArgs: true,
resolveRequiredGatewayScopes: (ctx) => {
const action = ctx.args?.trim().split(/\s+/, 1)[0]?.toLowerCase();
return action === "approve" ? ["operator.pairing"] : undefined;
},
handler: async (ctx) => {
const args = ctx.args?.trim() ?? "";
const tokens = args.split(/\s+/).filter(Boolean);
const action = tokens[0]?.toLowerCase() ?? "";
const gatewayClientScopes = Array.isArray(ctx.gatewayClientScopes)
? ctx.gatewayClientScopes
: null;
api.logger.info?.(
`device-pair: /pair invoked channel=${ctx.channel} sender=${ctx.senderId ?? "unknown"} action=${
action || "new"
@@ -574,15 +575,6 @@ export default definePluginEntry({
}
if (action === "approve") {
if (
gatewayClientScopes &&
!gatewayClientScopes.includes("operator.pairing") &&
!gatewayClientScopes.includes("operator.admin")
) {
return {
text: "⚠️ This command requires operator.pairing for internal gateway callers.",
};
}
const requested = tokens[1]?.trim();
const list = await listDevicePairing();
if (list.pending.length === 0) {

View File

@@ -1,6 +1,6 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
createResolvedDirectoryEntriesLister,
listResolvedDirectoryEntriesFromSources,
type DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-runtime";
import { mergeDiscordAccountConfig, resolveDefaultDiscordAccountId } from "./accounts.js";
@@ -18,36 +18,38 @@ function resolveDiscordDirectoryConfigAccount(
};
}
export const listDiscordDirectoryPeersFromConfig = createResolvedDirectoryEntriesLister<
ReturnType<typeof resolveDiscordDirectoryConfigAccount>
>({
kind: "user",
resolveAccount: (cfg, accountId) => resolveDiscordDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) => {
const allowFrom = account.config.allowFrom ?? account.config.dm?.allowFrom ?? [];
const guildUsers = Object.values(account.config.guilds ?? {}).flatMap((guild) => [
...(guild.users ?? []),
...Object.values(guild.channels ?? {}).flatMap((channel) => channel.users ?? []),
]);
return [allowFrom, Object.keys(account.config.dms ?? {}), guildUsers];
},
normalizeId: (raw) => {
const mention = raw.match(/^<@!?(\d+)>$/);
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|user):/i, "").trim();
return /^\d+$/.test(cleaned) ? `user:${cleaned}` : null;
},
});
export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) {
return listResolvedDirectoryEntriesFromSources({
...params,
kind: "user",
resolveAccount: (cfg, accountId) => resolveDiscordDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) => {
const allowFrom = account.config.allowFrom ?? account.config.dm?.allowFrom ?? [];
const guildUsers = Object.values(account.config.guilds ?? {}).flatMap((guild) => [
...(guild.users ?? []),
...Object.values(guild.channels ?? {}).flatMap((channel) => channel.users ?? []),
]);
return [allowFrom, Object.keys(account.config.dms ?? {}), guildUsers];
},
normalizeId: (raw) => {
const mention = raw.match(/^<@!?(\d+)>$/);
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|user):/i, "").trim();
return /^\d+$/.test(cleaned) ? `user:${cleaned}` : null;
},
});
}
export const listDiscordDirectoryGroupsFromConfig = createResolvedDirectoryEntriesLister<
ReturnType<typeof resolveDiscordDirectoryConfigAccount>
>({
kind: "group",
resolveAccount: (cfg, accountId) => resolveDiscordDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) =>
Object.values(account.config.guilds ?? {}).map((guild) => Object.keys(guild.channels ?? {})),
normalizeId: (raw) => {
const mention = raw.match(/^<#(\d+)>$/);
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|channel|group):/i, "").trim();
return /^\d+$/.test(cleaned) ? `channel:${cleaned}` : null;
},
});
export async function listDiscordDirectoryGroupsFromConfig(params: DirectoryConfigParams) {
return listResolvedDirectoryEntriesFromSources({
...params,
kind: "group",
resolveAccount: (cfg, accountId) => resolveDiscordDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) =>
Object.values(account.config.guilds ?? {}).map((guild) => Object.keys(guild.channels ?? {})),
normalizeId: (raw) => {
const mention = raw.match(/^<#(\d+)>$/);
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|channel|group):/i, "").trim();
return /^\d+$/.test(cleaned) ? `channel:${cleaned}` : null;
},
});
}

View File

@@ -40,7 +40,9 @@ describe("createDiscordGatewaySupervisor", () => {
error: vi.fn(),
};
const supervisor = createDiscordGatewaySupervisor({
gateway: { emitter },
client: {
getPlugin: vi.fn(() => ({ emitter })),
} as never,
isDisallowedIntentsError: (err) => String(err).includes("4014"),
runtime: runtime as never,
});
@@ -70,7 +72,9 @@ describe("createDiscordGatewaySupervisor", () => {
it("is idempotent on dispose and noops without an emitter", () => {
const supervisor = createDiscordGatewaySupervisor({
gateway: undefined,
client: {
getPlugin: vi.fn(() => undefined),
} as never,
isDisallowedIntentsError: () => false,
runtime: { error: vi.fn() } as never,
});
@@ -86,7 +90,9 @@ describe("createDiscordGatewaySupervisor", () => {
const emitter = new EventEmitter();
const runtime = { error: vi.fn() };
const supervisor = createDiscordGatewaySupervisor({
gateway: { emitter },
client: {
getPlugin: vi.fn(() => ({ emitter })),
} as never,
isDisallowedIntentsError: () => false,
runtime: runtime as never,
});

View File

@@ -1,4 +1,5 @@
import type { EventEmitter } from "node:events";
import type { Client } from "@buape/carbon";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { getDiscordGatewayEmitter } from "../monitor.gateway.js";
@@ -66,11 +67,12 @@ export function classifyDiscordGatewayEvent(params: {
}
export function createDiscordGatewaySupervisor(params: {
gateway?: unknown;
client: Client;
isDisallowedIntentsError: (err: unknown) => boolean;
runtime: RuntimeEnv;
}): DiscordGatewaySupervisor {
const emitter = getDiscordGatewayEmitter(params.gateway);
const gateway = params.client.getPlugin("gateway");
const emitter = getDiscordGatewayEmitter(gateway);
const pending: DiscordGatewayEvent[] = [];
if (!emitter) {
return {
@@ -84,36 +86,39 @@ export function createDiscordGatewaySupervisor(params: {
let lifecycleHandler: ((event: DiscordGatewayEvent) => void) | undefined;
let phase: GatewaySupervisorPhase = "buffering";
const logLateEvent =
(state: Extract<GatewaySupervisorPhase, "disposed" | "teardown">) =>
(event: DiscordGatewayEvent) => {
params.runtime.error?.(
danger(
`discord: suppressed late gateway ${event.type} error ${
state === "disposed" ? "after dispose" : "during teardown"
}: ${event.message}`,
),
);
};
let disposed = false;
const logLateTeardownEvent = (event: DiscordGatewayEvent) => {
params.runtime.error?.(
danger(
`discord: suppressed late gateway ${event.type} error during teardown: ${event.message}`,
),
);
};
const logLateDisposedEvent = (event: DiscordGatewayEvent) => {
params.runtime.error?.(
danger(
`discord: suppressed late gateway ${event.type} error after dispose: ${event.message}`,
),
);
};
const onGatewayError = (err: unknown) => {
const event = classifyDiscordGatewayEvent({
err,
isDisallowedIntentsError: params.isDisallowedIntentsError,
});
switch (phase) {
case "disposed":
logLateEvent("disposed")(event);
return;
case "active":
lifecycleHandler?.(event);
return;
case "teardown":
logLateEvent("teardown")(event);
return;
case "buffering":
pending.push(event);
return;
if (phase === "disposed") {
logLateDisposedEvent(event);
return;
}
if (phase === "active" && lifecycleHandler) {
lifecycleHandler(event);
return;
}
if (phase === "teardown") {
logLateTeardownEvent(event);
return;
}
pending.push(event);
};
emitter.on("error", onGatewayError);
@@ -141,9 +146,10 @@ export function createDiscordGatewaySupervisor(params: {
return "continue";
},
dispose: () => {
if (phase === "disposed") {
if (disposed) {
return;
}
disposed = true;
lifecycleHandler = undefined;
phase = "disposed";
pending.length = 0;

View File

@@ -1,4 +1,5 @@
import { EventEmitter } from "node:events";
import type { Client } from "@buape/carbon";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../../../../src/runtime.js";
import type { WaitForDiscordGatewayStopParams } from "../monitor.gateway.js";
@@ -42,7 +43,6 @@ vi.mock("./gateway-registry.js", () => ({
describe("runDiscordGatewayLifecycle", () => {
beforeEach(() => {
vi.resetModules();
attachDiscordGatewayLoggingMock.mockClear();
getDiscordGatewayEmitterMock.mockClear();
waitForDiscordGatewayStopMock.mockClear();
@@ -72,13 +72,6 @@ describe("runDiscordGatewayLifecycle", () => {
ws?: EventEmitter & { terminate?: () => void };
};
}) => {
const gateway =
params?.gateway ??
(() => {
const defaultGateway = createGatewayHarness().gateway;
defaultGateway.isConnected = true;
return defaultGateway;
})();
const start = vi.fn(params?.start ?? (async () => undefined));
const stop = vi.fn(params?.stop ?? (async () => undefined));
const threadStop = vi.fn();
@@ -103,7 +96,7 @@ describe("runDiscordGatewayLifecycle", () => {
return "continue";
}),
dispose: vi.fn(),
emitter: gateway.emitter,
emitter: params?.gateway?.emitter,
};
const statusSink = vi.fn();
const runtime: RuntimeEnv = {
@@ -121,7 +114,9 @@ describe("runDiscordGatewayLifecycle", () => {
statusSink,
lifecycleParams: {
accountId: params?.accountId ?? "default",
gateway,
client: {
getPlugin: vi.fn((name: string) => (name === "gateway" ? params?.gateway : undefined)),
} as unknown as Client,
runtime,
isDisallowedIntentsError: params?.isDisallowedIntentsError ?? (() => false),
voiceManager: null,

View File

@@ -1,3 +1,4 @@
import type { Client } from "@buape/carbon";
import type { GatewayPlugin } from "@buape/carbon/gateway";
import { createArmableStallWatchdog } from "openclaw/plugin-sdk/channel-lifecycle";
import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime";
@@ -69,7 +70,7 @@ async function waitForDiscordGatewayReady(params: {
export async function runDiscordGatewayLifecycle(params: {
accountId: string;
gateway?: GatewayPlugin;
client: Client;
runtime: RuntimeEnv;
abortSignal?: AbortSignal;
isDisallowedIntentsError: (err: unknown) => boolean;
@@ -84,7 +85,7 @@ export async function runDiscordGatewayLifecycle(params: {
const HELLO_CONNECTED_POLL_MS = 250;
const MAX_CONSECUTIVE_HELLO_STALLS = 3;
const RECONNECT_STALL_TIMEOUT_MS = 5 * 60_000;
const gateway = params.gateway;
const gateway = params.client.getPlugin<GatewayPlugin>("gateway");
if (gateway) {
registerGateway(params.accountId, gateway);
}

View File

@@ -947,15 +947,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
},
clientPlugins,
);
lifecycleGateway = client.getPlugin<GatewayPlugin>("gateway");
gatewaySupervisor = (
createDiscordGatewaySupervisorForTesting ?? createDiscordGatewaySupervisor
)({
gateway: lifecycleGateway,
client,
isDisallowedIntentsError: isDiscordDisallowedIntentsError,
runtime,
});
lifecycleGateway = client.getPlugin<GatewayPlugin>("gateway");
earlyGatewayEmitter = gatewaySupervisor.emitter;
onEarlyGatewayDebug = (msg: unknown) => {
if (!(isVerboseForTesting ?? isVerbose)()) {
@@ -1164,7 +1164,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
onEarlyGatewayDebug = undefined;
await (runDiscordGatewayLifecycleForTesting ?? runDiscordGatewayLifecycle)({
accountId: account.accountId,
gateway: lifecycleGateway,
client,
runtime,
abortSignal: opts.abortSignal,
statusSink: opts.setStatus,

View File

@@ -3,16 +3,17 @@ import {
createDelegatedSetupWizardProxy,
createDelegatedTextInputShouldPrompt,
createPatchedAccountSetupAdapter,
createTopLevelChannelDmPolicy,
parseSetupEntriesAllowingWildcard,
promptParsedAllowFromForAccount,
setAccountAllowFromForChannel,
setChannelDmPolicyWithAllowFrom,
setSetupChannelEnabled,
type OpenClawConfig,
type WizardPrompter,
} from "openclaw/plugin-sdk/setup";
import type {
ChannelSetupAdapter,
ChannelSetupDmPolicy,
ChannelSetupWizard,
ChannelSetupWizardTextInput,
} from "openclaw/plugin-sdk/setup";
@@ -105,14 +106,20 @@ export async function promptIMessageAllowFrom(params: {
});
}
export const imessageDmPolicy = createTopLevelChannelDmPolicy({
export const imessageDmPolicy: ChannelSetupDmPolicy = {
label: "iMessage",
channel,
policyKey: "channels.imessage.dmPolicy",
allowFromKey: "channels.imessage.allowFrom",
getCurrent: (cfg: OpenClawConfig) => cfg.channels?.imessage?.dmPolicy ?? "pairing",
setPolicy: (cfg: OpenClawConfig, policy) =>
setChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy: policy,
}),
promptAllowFrom: promptIMessageAllowFrom,
});
};
function resolveIMessageCliPath(params: { cfg: OpenClawConfig; accountId: string }) {
return resolveIMessageAccount(params).config.cliPath ?? "imsg";

View File

@@ -13,7 +13,7 @@ import {
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
import {
createChannelDirectoryAdapter,
createResolvedDirectoryEntriesLister,
listResolvedDirectoryEntriesFromSources,
} from "openclaw/plugin-sdk/directory-runtime";
import { runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared";
import {
@@ -61,30 +61,6 @@ function normalizePairingTarget(raw: string): string {
return normalized.split(/[!@]/, 1)[0]?.trim() ?? "";
}
const listIrcDirectoryPeersFromConfig = createResolvedDirectoryEntriesLister<ResolvedIrcAccount>({
kind: "user",
resolveAccount: adaptScopedAccountAccessor(resolveIrcAccount),
resolveSources: (account) => [
account.config.allowFrom ?? [],
account.config.groupAllowFrom ?? [],
...Object.values(account.config.groups ?? {}).map((group) => group.allowFrom ?? []),
],
normalizeId: (entry) => normalizePairingTarget(entry) || null,
});
const listIrcDirectoryGroupsFromConfig = createResolvedDirectoryEntriesLister<ResolvedIrcAccount>({
kind: "group",
resolveAccount: adaptScopedAccountAccessor(resolveIrcAccount),
resolveSources: (account) => [
account.config.channels ?? [],
Object.keys(account.config.groups ?? {}),
],
normalizeId: (entry) => {
const normalized = normalizeIrcMessagingTarget(entry);
return normalized && isChannelTarget(normalized) ? normalized : null;
},
});
const ircConfigAdapter = createScopedChannelConfigAdapter<
ResolvedIrcAccount,
ResolvedIrcAccount,
@@ -251,9 +227,32 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = createChat
},
},
directory: createChannelDirectoryAdapter({
listPeers: async (params) => listIrcDirectoryPeersFromConfig(params),
listPeers: async (params) =>
listResolvedDirectoryEntriesFromSources<ResolvedIrcAccount>({
...params,
kind: "user",
resolveAccount: adaptScopedAccountAccessor(resolveIrcAccount),
resolveSources: (account) => [
account.config.allowFrom ?? [],
account.config.groupAllowFrom ?? [],
...Object.values(account.config.groups ?? {}).map((group) => group.allowFrom ?? []),
],
normalizeId: (entry) => normalizePairingTarget(entry) || null,
}),
listGroups: async (params) => {
const entries = await listIrcDirectoryGroupsFromConfig(params);
const entries = listResolvedDirectoryEntriesFromSources<ResolvedIrcAccount>({
...params,
kind: "group",
resolveAccount: adaptScopedAccountAccessor(resolveIrcAccount),
resolveSources: (account) => [
account.config.channels ?? [],
Object.keys(account.config.groups ?? {}),
],
normalizeId: (entry) => {
const normalized = normalizeIrcMessagingTarget(entry);
return normalized && isChannelTarget(normalized) ? normalized : null;
},
});
return entries.map((entry) => ({ ...entry, name: entry.id }));
},
}),

View File

@@ -16,8 +16,8 @@ import { createScopedAccountReplyToModeResolver } from "openclaw/plugin-sdk/conv
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
import {
createChannelDirectoryAdapter,
createResolvedDirectoryEntriesLister,
createRuntimeDirectoryLiveAdapter,
listResolvedDirectoryEntriesFromSources,
} from "openclaw/plugin-sdk/directory-runtime";
import { buildTrafficStatusSummary } from "openclaw/plugin-sdk/extension-shared";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
@@ -78,46 +78,6 @@ const meta = {
quickstartAllowFrom: true,
};
const listMatrixDirectoryPeersFromConfig =
createResolvedDirectoryEntriesLister<ResolvedMatrixAccount>({
kind: "user",
resolveAccount: adaptScopedAccountAccessor(resolveMatrixAccount),
resolveSources: (account) => [
account.config.dm?.allowFrom ?? [],
account.config.groupAllowFrom ?? [],
...Object.values(account.config.groups ?? account.config.rooms ?? {}).map(
(room) => room.users ?? [],
),
],
normalizeId: (entry) => {
const raw = entry.replace(/^matrix:/i, "").trim();
if (!raw || raw === "*") {
return null;
}
const lowered = raw.toLowerCase();
const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw;
return cleaned.startsWith("@") ? `user:${cleaned}` : cleaned;
},
});
const listMatrixDirectoryGroupsFromConfig =
createResolvedDirectoryEntriesLister<ResolvedMatrixAccount>({
kind: "group",
resolveAccount: adaptScopedAccountAccessor(resolveMatrixAccount),
resolveSources: (account) => [Object.keys(account.config.groups ?? account.config.rooms ?? {})],
normalizeId: (entry) => {
const raw = entry.replace(/^matrix:/i, "").trim();
if (!raw || raw === "*") {
return null;
}
const lowered = raw.toLowerCase();
if (lowered.startsWith("room:") || lowered.startsWith("channel:")) {
return raw;
}
return raw.startsWith("!") ? `room:${raw}` : raw;
},
});
const matrixConfigAdapter = createScopedChannelConfigAdapter<
ResolvedMatrixAccount,
ReturnType<typeof resolveMatrixAccountConfig>,
@@ -286,14 +246,53 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
},
directory: createChannelDirectoryAdapter({
listPeers: async (params) => {
const entries = await listMatrixDirectoryPeersFromConfig(params);
const entries = listResolvedDirectoryEntriesFromSources<ResolvedMatrixAccount>({
...params,
kind: "user",
resolveAccount: adaptScopedAccountAccessor(resolveMatrixAccount),
resolveSources: (account) => [
account.config.dm?.allowFrom ?? [],
account.config.groupAllowFrom ?? [],
...Object.values(account.config.groups ?? account.config.rooms ?? {}).map(
(room) => room.users ?? [],
),
],
normalizeId: (entry) => {
const raw = entry.replace(/^matrix:/i, "").trim();
if (!raw || raw === "*") {
return null;
}
const lowered = raw.toLowerCase();
const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw;
return cleaned.startsWith("@") ? `user:${cleaned}` : cleaned;
},
});
return entries.map((entry) => {
const raw = entry.id.startsWith("user:") ? entry.id.slice("user:".length) : entry.id;
const incomplete = !raw.startsWith("@") || !raw.includes(":");
return incomplete ? { ...entry, name: "incomplete id; expected @user:server" } : entry;
});
},
listGroups: async (params) => await listMatrixDirectoryGroupsFromConfig(params),
listGroups: async (params) =>
listResolvedDirectoryEntriesFromSources<ResolvedMatrixAccount>({
...params,
kind: "group",
resolveAccount: adaptScopedAccountAccessor(resolveMatrixAccount),
resolveSources: (account) => [
Object.keys(account.config.groups ?? account.config.rooms ?? {}),
],
normalizeId: (entry) => {
const raw = entry.replace(/^matrix:/i, "").trim();
if (!raw || raw === "*") {
return null;
}
const lowered = raw.toLowerCase();
if (lowered.startsWith("room:") || lowered.startsWith("channel:")) {
return raw;
}
return raw.startsWith("!") ? `room:${raw}` : raw;
},
}),
...createRuntimeDirectoryLiveAdapter({
getRuntime: loadMatrixChannelRuntime,
listPeersLive: (runtime) => runtime.listMatrixDirectoryPeersLive,

View File

@@ -28,7 +28,6 @@ vi.mock("../../../../../src/infra/backup-create.js", async (importOriginal) => {
});
let maybeMigrateLegacyStorage: typeof import("./storage.js").maybeMigrateLegacyStorage;
let resolveMatrixStateFilePath: typeof import("./storage.js").resolveMatrixStateFilePath;
let resolveMatrixStoragePaths: typeof import("./storage.js").resolveMatrixStoragePaths;
describe("matrix client storage paths", () => {
@@ -40,8 +39,7 @@ describe("matrix client storage paths", () => {
};
beforeAll(async () => {
({ maybeMigrateLegacyStorage, resolveMatrixStateFilePath, resolveMatrixStoragePaths } =
await import("./storage.js"));
({ maybeMigrateLegacyStorage, resolveMatrixStoragePaths } = await import("./storage.js"));
});
afterEach(() => {
@@ -113,26 +111,6 @@ describe("matrix client storage paths", () => {
});
}
it("resolves state file paths inside the selected storage root", () => {
setupStateDir();
const filePath = resolveMatrixStateFilePath({
auth: {
...defaultStorageAuth,
accountId: "ops",
deviceId: "DEVICE1",
},
filename: "thread-bindings.json",
env: {},
});
expect(filePath).toBe(
path.join(
resolveDefaultStoragePaths({ accountId: "ops", deviceId: "DEVICE1" }).rootDir,
"thread-bindings.json",
),
);
});
function writeLegacyMatrixStorage(
stateDir: string,
params: {

View File

@@ -11,7 +11,6 @@ import {
resolveMatrixAccountStorageRoot,
resolveMatrixLegacyFlatStoragePaths,
} from "../../storage-paths.js";
import type { MatrixAuth } from "./types.js";
import type { MatrixStoragePaths } from "./types.js";
export const DEFAULT_ACCOUNT_KEY = "default";
@@ -294,25 +293,6 @@ export function resolveMatrixStoragePaths(params: {
};
}
export function resolveMatrixStateFilePath(params: {
auth: MatrixAuth;
filename: string;
accountId?: string | null;
env?: NodeJS.ProcessEnv;
stateDir?: string;
}): string {
const storagePaths = resolveMatrixStoragePaths({
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
accountId: params.accountId ?? params.auth.accountId,
deviceId: params.auth.deviceId,
env: params.env,
stateDir: params.stateDir,
});
return path.join(storagePaths.rootDir, params.filename);
}
export async function maybeMigrateLegacyStorage(params: {
storagePaths: MatrixStoragePaths;
env?: NodeJS.ProcessEnv;

View File

@@ -1,6 +1,7 @@
import path from "node:path";
import { readJsonFileWithFallback, writeJsonFileAtomically } from "../../runtime-api.js";
import { createAsyncLock } from "../async-lock.js";
import { resolveMatrixStateFilePath } from "../client/storage.js";
import { resolveMatrixStoragePaths } from "../client/storage.js";
import type { MatrixAuth } from "../client/types.js";
import { LogService } from "../sdk/logger.js";
@@ -43,12 +44,16 @@ function resolveInboundDedupeStatePath(params: {
env?: NodeJS.ProcessEnv;
stateDir?: string;
}): string {
return resolveMatrixStateFilePath({
auth: params.auth,
const storagePaths = resolveMatrixStoragePaths({
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
accountId: params.auth.accountId,
deviceId: params.auth.deviceId,
env: params.env,
stateDir: params.stateDir,
filename: INBOUND_DEDUPE_FILENAME,
});
return path.join(storagePaths.rootDir, INBOUND_DEDUPE_FILENAME);
}
function normalizeTimestamp(raw: unknown): number | null {

View File

@@ -8,7 +8,7 @@ import {
} from "../../../../src/infra/outbound/session-binding-service.js";
import type { PluginRuntime } from "../../runtime-api.js";
import { setMatrixRuntime } from "../runtime.js";
import { resolveMatrixStateFilePath, resolveMatrixStoragePaths } from "./client/storage.js";
import { resolveMatrixStoragePaths } from "./client/storage.js";
import {
createMatrixThreadBindingManager,
resetMatrixThreadBindingsForTests,
@@ -87,12 +87,14 @@ describe("matrix thread bindings", () => {
}
function resolveBindingsFilePath(customStateDir?: string) {
return resolveMatrixStateFilePath({
auth,
env: process.env,
...(customStateDir ? { stateDir: customStateDir } : {}),
filename: "thread-bindings.json",
});
return path.join(
resolveMatrixStoragePaths({
...auth,
env: process.env,
...(customStateDir ? { stateDir: customStateDir } : {}),
}).rootDir,
"thread-bindings.json",
);
}
async function readPersistedLastActivityAt(bindingsPath: string) {

View File

@@ -1,3 +1,4 @@
import path from "node:path";
import type { SessionBindingAdapter } from "openclaw/plugin-sdk/conversation-runtime";
import {
readJsonFileWithFallback,
@@ -7,7 +8,7 @@ import {
unregisterSessionBindingAdapter,
writeJsonFileAtomically,
} from "../runtime-api.js";
import { resolveMatrixStateFilePath } from "./client/storage.js";
import { resolveMatrixStoragePaths } from "./client/storage.js";
import type { MatrixAuth } from "./client/types.js";
import type { MatrixClient } from "./sdk.js";
import { sendMessageMatrix } from "./send.js";
@@ -61,13 +62,16 @@ function resolveBindingsPath(params: {
env?: NodeJS.ProcessEnv;
stateDir?: string;
}): string {
return resolveMatrixStateFilePath({
auth: params.auth,
const storagePaths = resolveMatrixStoragePaths({
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
accountId: params.accountId,
deviceId: params.auth.deviceId,
env: params.env,
stateDir: params.stateDir,
filename: "thread-bindings.json",
});
return path.join(storagePaths.rootDir, "thread-bindings.json");
}
async function loadBindingsFromDisk(filePath: string, accountId: string) {

View File

@@ -6,6 +6,7 @@ import {
colorize,
defaultRuntime,
formatErrorMessage,
getMemorySearchManager,
isRich,
listMemoryFiles,
loadConfig,
@@ -24,7 +25,6 @@ import {
type OpenClawConfig,
} from "./api.js";
import type { MemoryCommandOptions, MemorySearchCommandOptions } from "./cli.types.js";
import { getMemorySearchManager } from "./runtime-api.js";
type MemoryManager = NonNullable<Awaited<ReturnType<typeof getMemorySearchManager>>["manager"]>;
type MemoryManagerPurpose = Parameters<typeof getMemorySearchManager>[0]["purpose"];

View File

@@ -24,20 +24,13 @@ vi.mock("./api.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./api.js")>();
return {
...actual,
getMemorySearchManager,
loadConfig,
resolveDefaultAgentId,
resolveCommandSecretRefsViaGateway,
};
});
vi.mock("./runtime-api.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./runtime-api.js")>();
return {
...actual,
getMemorySearchManager,
};
});
let registerMemoryCli: typeof import("./cli.js").registerMemoryCli;
let defaultRuntime: typeof import("./api.js").defaultRuntime;
let isVerbose: typeof import("./api.js").isVerbose;

View File

@@ -1,2 +0,0 @@
export * from "./api.js";
export * from "./memory/index.js";

View File

@@ -1,5 +1,8 @@
import { resolveMemoryBackendConfig, type MemoryPluginRuntime } from "./api.js";
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./runtime-api.js";
import {
getMemorySearchManager,
resolveMemoryBackendConfig,
type MemoryPluginRuntime,
} from "./api.js";
export const memoryRuntime: MemoryPluginRuntime = {
async getMemorySearchManager(params) {
@@ -12,7 +15,4 @@ export const memoryRuntime: MemoryPluginRuntime = {
resolveMemoryBackendConfig(params) {
return resolveMemoryBackendConfig(params);
},
async closeAllMemorySearchManagers() {
await closeAllMemorySearchManagers();
},
};

View File

@@ -1,2 +1 @@
export { readAgentMemoryFile, resolveMemoryBackendConfig } from "./api.js";
export { getMemorySearchManager } from "./runtime-api.js";
export { getMemorySearchManager, readAgentMemoryFile, resolveMemoryBackendConfig } from "./api.js";

View File

@@ -8,7 +8,7 @@ import {
type MemoryToolRuntime = typeof import("./tools.runtime.js");
type MemorySearchManagerResult = Awaited<
ReturnType<(typeof import("./runtime-api.js"))["getMemorySearchManager"]>
ReturnType<(typeof import("./api.js"))["getMemorySearchManager"]>
>;
let memoryToolRuntimePromise: Promise<MemoryToolRuntime> | null = null;

View File

@@ -40,8 +40,10 @@ function createApi(params: {
function createCommandContext(args: string): PluginCommandContext {
return {
surface: "test",
channel: "test",
isAuthorizedSender: true,
senderIsOwner: false,
commandBody: `/phone ${args}`,
args,
config: {},

View File

@@ -3,17 +3,18 @@ import {
createDelegatedSetupWizardProxy,
createDelegatedTextInputShouldPrompt,
createPatchedAccountSetupAdapter,
createTopLevelChannelDmPolicy,
normalizeE164,
parseSetupEntriesAllowingWildcard,
promptParsedAllowFromForAccount,
setAccountAllowFromForChannel,
setChannelDmPolicyWithAllowFrom,
setSetupChannelEnabled,
type OpenClawConfig,
type WizardPrompter,
} from "openclaw/plugin-sdk/setup";
import type {
ChannelSetupAdapter,
ChannelSetupDmPolicy,
ChannelSetupWizard,
ChannelSetupWizardTextInput,
} from "openclaw/plugin-sdk/setup";
@@ -121,14 +122,20 @@ export async function promptSignalAllowFrom(params: {
});
}
export const signalDmPolicy = createTopLevelChannelDmPolicy({
export const signalDmPolicy: ChannelSetupDmPolicy = {
label: "Signal",
channel,
policyKey: "channels.signal.dmPolicy",
allowFromKey: "channels.signal.allowFrom",
getCurrent: (cfg: OpenClawConfig) => cfg.channels?.signal?.dmPolicy ?? "pairing",
setPolicy: (cfg: OpenClawConfig, policy) =>
setChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy: policy,
}),
promptAllowFrom: promptSignalAllowFrom,
});
};
function resolveSignalCliPath(params: {
cfg: OpenClawConfig;

View File

@@ -1,6 +1,6 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/account-resolution";
import {
createResolvedDirectoryEntriesLister,
listResolvedDirectoryEntriesFromSources,
type DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-runtime";
import { mergeSlackAccountConfig, resolveDefaultSlackAccountId } from "./accounts.js";
@@ -19,38 +19,40 @@ function resolveSlackDirectoryConfigAccount(
};
}
export const listSlackDirectoryPeersFromConfig = createResolvedDirectoryEntriesLister<
ReturnType<typeof resolveSlackDirectoryConfigAccount>
>({
kind: "user",
resolveAccount: (cfg, accountId) => resolveSlackDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) => {
const allowFrom = account.config.allowFrom ?? account.dm?.allowFrom ?? [];
const channelUsers = Object.values(account.config.channels ?? {}).flatMap(
(channel) => channel.users ?? [],
);
return [allowFrom, Object.keys(account.config.dms ?? {}), channelUsers];
},
normalizeId: (raw) => {
const mention = raw.match(/^<@([A-Z0-9]+)>$/i);
const normalizedUserId = (mention?.[1] ?? raw).replace(/^(slack|user):/i, "").trim();
if (!normalizedUserId) {
return null;
}
const target = `user:${normalizedUserId}`;
const normalized = parseSlackTarget(target, { defaultKind: "user" });
return normalized?.kind === "user" ? `user:${normalized.id.toLowerCase()}` : null;
},
});
export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigParams) {
return listResolvedDirectoryEntriesFromSources({
...params,
kind: "user",
resolveAccount: (cfg, accountId) => resolveSlackDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) => {
const allowFrom = account.config.allowFrom ?? account.dm?.allowFrom ?? [];
const channelUsers = Object.values(account.config.channels ?? {}).flatMap(
(channel) => channel.users ?? [],
);
return [allowFrom, Object.keys(account.config.dms ?? {}), channelUsers];
},
normalizeId: (raw) => {
const mention = raw.match(/^<@([A-Z0-9]+)>$/i);
const normalizedUserId = (mention?.[1] ?? raw).replace(/^(slack|user):/i, "").trim();
if (!normalizedUserId) {
return null;
}
const target = `user:${normalizedUserId}`;
const normalized = parseSlackTarget(target, { defaultKind: "user" });
return normalized?.kind === "user" ? `user:${normalized.id.toLowerCase()}` : null;
},
});
}
export const listSlackDirectoryGroupsFromConfig = createResolvedDirectoryEntriesLister<
ReturnType<typeof resolveSlackDirectoryConfigAccount>
>({
kind: "group",
resolveAccount: (cfg, accountId) => resolveSlackDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) => [Object.keys(account.config.channels ?? {})],
normalizeId: (raw) => {
const normalized = parseSlackTarget(raw, { defaultKind: "channel" });
return normalized?.kind === "channel" ? `channel:${normalized.id.toLowerCase()}` : null;
},
});
export async function listSlackDirectoryGroupsFromConfig(params: DirectoryConfigParams) {
return listResolvedDirectoryEntriesFromSources({
...params,
kind: "group",
resolveAccount: (cfg, accountId) => resolveSlackDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) => [Object.keys(account.config.channels ?? {})],
normalizeId: (raw) => {
const normalized = parseSlackTarget(raw, { defaultKind: "channel" });
return normalized?.kind === "channel" ? `channel:${normalized.id.toLowerCase()}` : null;
},
});
}

View File

@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { OperatorScope } from "../../src/gateway/method-scopes.js";
import type { OpenClawPluginCommandDefinition } from "../../test/helpers/extensions/plugin-command.js";
import { createPluginRuntimeMock } from "../../test/helpers/extensions/plugin-runtime-mock.js";
import register from "./index.js";
@@ -30,13 +31,15 @@ function createHarness(config: Record<string, unknown>) {
function createCommandContext(
args: string,
channel: string = "discord",
gatewayClientScopes?: string[],
gatewayClientScopes?: OperatorScope[],
) {
return {
args,
surface: channel,
channel,
channelId: channel,
isAuthorizedSender: true,
senderIsOwner: false,
gatewayClientScopes,
commandBody: args ? `/voice ${args}` : "/voice",
config: {},
@@ -47,7 +50,7 @@ function createCommandContext(
}
describe("talk-voice plugin", () => {
function createElevenlabsVoiceSetHarness(channel = "webchat", scopes?: string[]) {
function createElevenlabsVoiceSetHarness(channel = "webchat", scopes?: OperatorScope[]) {
const { command, runtime } = createHarness({
talk: {
provider: "elevenlabs",

View File

@@ -1,9 +1,13 @@
import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
import { createInspectedDirectoryEntriesLister } from "openclaw/plugin-sdk/directory-runtime";
import {
listInspectedDirectoryEntriesFromSources,
type DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-runtime";
import { inspectTelegramAccount, type InspectedTelegramAccount } from "./account-inspect.js";
export const listTelegramDirectoryPeersFromConfig =
createInspectedDirectoryEntriesLister<InspectedTelegramAccount>({
export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConfigParams) {
return listInspectedDirectoryEntriesFromSources({
...params,
kind: "user",
inspectAccount: (cfg, accountId) =>
inspectTelegramAccount({ cfg, accountId }) as InspectedTelegramAccount | null,
@@ -22,12 +26,15 @@ export const listTelegramDirectoryPeersFromConfig =
return trimmed.startsWith("@") ? trimmed : `@${trimmed}`;
},
});
}
export const listTelegramDirectoryGroupsFromConfig =
createInspectedDirectoryEntriesLister<InspectedTelegramAccount>({
export async function listTelegramDirectoryGroupsFromConfig(params: DirectoryConfigParams) {
return listInspectedDirectoryEntriesFromSources({
...params,
kind: "group",
inspectAccount: (cfg, accountId) =>
inspectTelegramAccount({ cfg, accountId }) as InspectedTelegramAccount | null,
resolveSources: (account) => [Object.keys(account.config.groups ?? {})],
normalizeId: (entry) => entry.trim() || null,
});
}

View File

@@ -1,15 +1,15 @@
import {
createAllowFromSection,
createTopLevelChannelDmPolicy,
createStandardChannelSetupStatus,
DEFAULT_ACCOUNT_ID,
hasConfiguredSecretInput,
type OpenClawConfig,
patchChannelConfigForAccount,
setChannelDmPolicyWithAllowFrom,
setSetupChannelEnabled,
splitSetupEntries,
} from "openclaw/plugin-sdk/setup";
import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
import type { ChannelSetupDmPolicy, ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
import { inspectTelegramAccount } from "./account-inspect.js";
import {
@@ -76,14 +76,20 @@ function buildTelegramDmAccessWarningLines(accountId: string): string[] {
];
}
const dmPolicy = createTopLevelChannelDmPolicy({
const dmPolicy: ChannelSetupDmPolicy = {
label: "Telegram",
channel,
policyKey: "channels.telegram.dmPolicy",
allowFromKey: "channels.telegram.allowFrom",
getCurrent: (cfg) => cfg.channels?.telegram?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) =>
setChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy: policy,
}),
promptAllowFrom: promptTelegramAllowFromForAccount,
});
};
export const telegramSetupWizard: ChannelSetupWizard = {
channel,

View File

@@ -5,21 +5,27 @@
import fs from "node:fs";
import path from "node:path";
import { ensureDirectory, logVerboseCopy, resolveBuildCopyContext } from "./lib/copy-assets.ts";
import { fileURLToPath } from "node:url";
const context = resolveBuildCopyContext(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const verbose = process.env.OPENCLAW_BUILD_VERBOSE === "1";
const srcDir = path.join(context.projectRoot, "src", "auto-reply", "reply", "export-html");
const distDir = path.join(context.projectRoot, "dist", "export-html");
const srcDir = path.join(projectRoot, "src", "auto-reply", "reply", "export-html");
const distDir = path.join(projectRoot, "dist", "export-html");
function copyExportHtmlTemplates() {
if (!fs.existsSync(srcDir)) {
console.warn(`${context.prefix} Source directory not found:`, srcDir);
console.warn("[copy-export-html-templates] Source directory not found:", srcDir);
return;
}
ensureDirectory(distDir);
// Create dist directory
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir, { recursive: true });
}
// Copy main template files
const templateFiles = ["template.html", "template.css", "template.js"];
let copiedCount = 0;
for (const file of templateFiles) {
@@ -28,14 +34,19 @@ function copyExportHtmlTemplates() {
if (fs.existsSync(srcFile)) {
fs.copyFileSync(srcFile, distFile);
copiedCount += 1;
logVerboseCopy(context, `Copied ${file}`);
if (verbose) {
console.log(`[copy-export-html-templates] Copied ${file}`);
}
}
}
// Copy vendor files
const srcVendor = path.join(srcDir, "vendor");
const distVendor = path.join(distDir, "vendor");
if (fs.existsSync(srcVendor)) {
ensureDirectory(distVendor);
if (!fs.existsSync(distVendor)) {
fs.mkdirSync(distVendor, { recursive: true });
}
const vendorFiles = fs.readdirSync(srcVendor);
for (const file of vendorFiles) {
const srcFile = path.join(srcVendor, file);
@@ -43,12 +54,14 @@ function copyExportHtmlTemplates() {
if (fs.statSync(srcFile).isFile()) {
fs.copyFileSync(srcFile, distFile);
copiedCount += 1;
logVerboseCopy(context, `Copied vendor/${file}`);
if (verbose) {
console.log(`[copy-export-html-templates] Copied vendor/${file}`);
}
}
}
}
console.log(`${context.prefix} Copied ${copiedCount} export-html assets.`);
console.log(`[copy-export-html-templates] Copied ${copiedCount} export-html assets.`);
}
copyExportHtmlTemplates();

View File

@@ -5,20 +5,24 @@
import fs from "node:fs";
import path from "node:path";
import { ensureDirectory, logVerboseCopy, resolveBuildCopyContext } from "./lib/copy-assets.ts";
import { fileURLToPath } from "node:url";
const context = resolveBuildCopyContext(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const verbose = process.env.OPENCLAW_BUILD_VERBOSE === "1";
const srcBundled = path.join(context.projectRoot, "src", "hooks", "bundled");
const distBundled = path.join(context.projectRoot, "dist", "bundled");
const srcBundled = path.join(projectRoot, "src", "hooks", "bundled");
const distBundled = path.join(projectRoot, "dist", "bundled");
function copyHookMetadata() {
if (!fs.existsSync(srcBundled)) {
console.warn(`${context.prefix} Source directory not found:`, srcBundled);
console.warn("[copy-hook-metadata] Source directory not found:", srcBundled);
return;
}
ensureDirectory(distBundled);
if (!fs.existsSync(distBundled)) {
fs.mkdirSync(distBundled, { recursive: true });
}
const entries = fs.readdirSync(srcBundled, { withFileTypes: true });
let copiedCount = 0;
@@ -35,18 +39,22 @@ function copyHookMetadata() {
const distHookMd = path.join(distHookDir, "HOOK.md");
if (!fs.existsSync(srcHookMd)) {
console.warn(`${context.prefix} No HOOK.md found for ${hookName}`);
console.warn(`[copy-hook-metadata] No HOOK.md found for ${hookName}`);
continue;
}
ensureDirectory(distHookDir);
if (!fs.existsSync(distHookDir)) {
fs.mkdirSync(distHookDir, { recursive: true });
}
fs.copyFileSync(srcHookMd, distHookMd);
copiedCount += 1;
logVerboseCopy(context, `Copied ${hookName}/HOOK.md`);
if (verbose) {
console.log(`[copy-hook-metadata] Copied ${hookName}/HOOK.md`);
}
}
console.log(`${context.prefix} Copied ${copiedCount} hook metadata files.`);
console.log(`[copy-hook-metadata] Copied ${copiedCount} hook metadata files.`);
}
copyHookMetadata();

View File

@@ -1,30 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export type BuildCopyContext = {
prefix: string;
projectRoot: string;
verbose: boolean;
};
export function resolveBuildCopyContext(importMetaUrl: string): BuildCopyContext {
const filePath = fileURLToPath(importMetaUrl);
return {
prefix: `[${path.basename(filePath, path.extname(filePath))}]`,
projectRoot: path.resolve(path.dirname(filePath), ".."),
verbose: process.env.OPENCLAW_BUILD_VERBOSE === "1",
};
}
export function ensureDirectory(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
export function logVerboseCopy(context: BuildCopyContext, message: string): void {
if (context.verbose) {
console.log(`${context.prefix} ${message}`);
}
}

View File

@@ -231,47 +231,6 @@ describe("runCliAgent with process supervisor", () => {
await loadFreshCliRunnerModuleForTest();
});
it("does not inject hardcoded 'Tools are disabled' text into CLI arguments", async () => {
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "ok",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
}),
);
await runCliAgent({
sessionId: "s1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
prompt: "Run: node script.mjs",
provider: "claude-cli",
model: "sonnet",
timeoutMs: 1_000,
runId: "run-no-tools-disabled",
extraSystemPrompt: "You are a helpful assistant.",
});
expect(supervisorSpawnMock).toHaveBeenCalledTimes(1);
const input = supervisorSpawnMock.mock.calls[0]?.[0] as {
argv?: string[];
input?: string;
};
// Use claude-cli because it defines systemPromptArg ("--append-system-prompt"),
// so the system prompt is serialized into argv. The codex-cli backend lacks
// systemPromptArg, meaning the prompt is dropped before reaching argv —
// making the assertion vacuous. See: openclaw/openclaw#44135
const allArgs = (input.argv ?? []).join("\n");
expect(allArgs).not.toContain("Tools are disabled in this session");
// Verify the user-supplied system prompt IS present (proves the arg path works)
expect(allArgs).toContain("You are a helpful assistant.");
});
it("runs CLI through supervisor and returns payload", async () => {
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun({

View File

@@ -128,7 +128,12 @@ export async function runCliAgent(params: {
const normalizedModel = normalizeCliModel(modelId, backend);
const modelDisplay = `${params.provider}/${modelId}`;
const extraSystemPrompt = params.extraSystemPrompt?.trim() ?? "";
const extraSystemPrompt = [
params.extraSystemPrompt?.trim(),
"Tools are disabled in this session. Do not call tools.",
]
.filter(Boolean)
.join("\n");
const sessionLabel = params.sessionKey ?? params.sessionId;
const { bootstrapFiles, contextFiles } = await resolveBootstrapContextForRun({

View File

@@ -5,9 +5,24 @@
* This handler is called before built-in command handlers.
*/
import { isOperatorScope, type OperatorScope } from "../../gateway/method-scopes.js";
import { logVerbose } from "../../globals.js";
import { matchPluginCommand, executePluginCommand } from "../../plugins/commands.js";
import type { CommandHandler, CommandHandlerResult } from "./commands-types.js";
function narrowGatewayClientScopes(
scopes: readonly string[] | undefined,
): OperatorScope[] | undefined {
if (!scopes) {
return undefined;
}
const narrowed = scopes.filter((scope) => isOperatorScope(scope));
if (narrowed.length !== scopes.length) {
logVerbose("Plugin command handler ignored unknown gateway scope values");
}
return narrowed.length > 0 ? narrowed : undefined;
}
/**
* Handle plugin-registered commands.
* Returns a result if a plugin command was matched and executed,
@@ -34,10 +49,12 @@ export const handlePluginCommand: CommandHandler = async (
command: match.command,
args: match.args,
senderId: command.senderId,
surface: command.surface,
channel: command.channel,
channelId: command.channelId,
isAuthorizedSender: command.isAuthorizedSender,
gatewayClientScopes: params.ctx.GatewayClientScopes,
senderIsOwner: command.senderIsOwner,
gatewayClientScopes: narrowGatewayClientScopes(params.ctx.GatewayClientScopes),
commandBody: command.commandBodyNormalized,
config: cfg,
from: command.from,

View File

@@ -1,11 +1,11 @@
import fs from "node:fs";
import path from "node:path";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveOpenClawUserDataDir } from "../../extensions/browser/src/browser/chrome.js";
import type {
BrowserRouteContext,
BrowserServerState,
} from "../../extensions/browser/src/browser/server-context.js";
import { resolveOpenClawUserDataDir } from "../../extensions/browser/src/browser/chrome.js";
import { movePathToTrash } from "../../extensions/browser/src/browser/trash.js";
import { loadConfig, writeConfigFile } from "../../extensions/browser/src/config/config.js";

View File

@@ -1,7 +1,5 @@
import { describe, expect, it } from "vitest";
import {
createInspectedDirectoryEntriesLister,
createResolvedDirectoryEntriesLister,
listDirectoryEntriesFromSources,
listInspectedDirectoryEntriesFromSources,
listDirectoryGroupEntriesFromMapKeysAndAllowFrom,
@@ -130,21 +128,6 @@ describe("listInspectedDirectoryEntriesFromSources", () => {
});
});
describe("createInspectedDirectoryEntriesLister", () => {
it("builds a reusable inspected-account lister", async () => {
const listGroups = createInspectedDirectoryEntriesLister({
kind: "group",
inspectAccount: () => ({ ids: [["room:a"], ["room:b", "room:a"]] }),
resolveSources: (account) => account.ids,
normalizeId: (entry) => entry.replace(/^room:/i, ""),
});
await expect(listGroups({ cfg: {} as never, query: "a" })).resolves.toEqual([
{ kind: "group", id: "a" },
]);
});
});
describe("resolved account directory helpers", () => {
const cfg = {} as never;
const resolveAccount = () => ({
@@ -191,18 +174,4 @@ describe("resolved account directory helpers", () => {
expectUserDirectoryEntries(entries);
});
it("builds a reusable resolved-account lister", async () => {
const listUsers = createResolvedDirectoryEntriesLister({
kind: "user",
resolveAccount,
resolveSources: (account) => [account.allowFrom, ["user:carla", "user:alice"]],
normalizeId: (entry) => entry.replace(/^user:/i, ""),
});
await expect(listUsers({ cfg, query: "a", limit: 2 })).resolves.toEqual([
{ kind: "user", id: "alice" },
{ kind: "user", id: "carla" },
]);
});
});

View File

@@ -121,22 +121,6 @@ export function listInspectedDirectoryEntriesFromSources<InspectedAccount>(
});
}
export function createInspectedDirectoryEntriesLister<InspectedAccount>(params: {
kind: "user" | "group";
inspectAccount: (
cfg: OpenClawConfig,
accountId?: string | null,
) => InspectedAccount | null | undefined;
resolveSources: (account: InspectedAccount) => Iterable<unknown>[];
normalizeId: (entry: string) => string | null | undefined;
}) {
return async (configParams: DirectoryConfigParams): Promise<ChannelDirectoryEntry[]> =>
listInspectedDirectoryEntriesFromSources({
...configParams,
...params,
});
}
export function listResolvedDirectoryEntriesFromSources<ResolvedAccount>(
params: DirectoryConfigParams & {
kind: "user" | "group";
@@ -155,19 +139,6 @@ export function listResolvedDirectoryEntriesFromSources<ResolvedAccount>(
});
}
export function createResolvedDirectoryEntriesLister<ResolvedAccount>(params: {
kind: "user" | "group";
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount;
resolveSources: (account: ResolvedAccount) => Iterable<unknown>[];
normalizeId: (entry: string) => string | null | undefined;
}) {
return async (configParams: DirectoryConfigParams): Promise<ChannelDirectoryEntry[]> =>
listResolvedDirectoryEntriesFromSources({
...configParams,
...params,
});
}
export function listDirectoryUserEntriesFromAllowFrom(params: {
allowFrom?: readonly unknown[];
query?: string | null;

View File

@@ -6,7 +6,7 @@ const loadDotEnvMock = vi.hoisted(() => vi.fn());
const normalizeEnvMock = vi.hoisted(() => vi.fn());
const ensurePathMock = vi.hoisted(() => vi.fn());
const assertRuntimeMock = vi.hoisted(() => vi.fn());
const closeActiveMemorySearchManagersMock = vi.hoisted(() => vi.fn(async () => {}));
const closeAllMemorySearchManagersMock = vi.hoisted(() => vi.fn(async () => {}));
const outputRootHelpMock = vi.hoisted(() => vi.fn());
const buildProgramMock = vi.hoisted(() => vi.fn());
const maybeRunCliInContainerMock = vi.hoisted(() =>
@@ -40,8 +40,8 @@ vi.mock("../infra/runtime-guard.js", () => ({
assertSupportedRuntime: assertRuntimeMock,
}));
vi.mock("../plugins/memory-runtime.js", () => ({
closeActiveMemorySearchManagers: closeActiveMemorySearchManagersMock,
vi.mock("../memory/search-manager.js", () => ({
closeAllMemorySearchManagers: closeAllMemorySearchManagersMock,
}));
vi.mock("./program/root-help.js", () => ({
@@ -69,7 +69,7 @@ describe("runCli exit behavior", () => {
expect(maybeRunCliInContainerMock).toHaveBeenCalledWith(["node", "openclaw", "status"]);
expect(tryRouteCliMock).toHaveBeenCalledWith(["node", "openclaw", "status"]);
expect(closeActiveMemorySearchManagersMock).toHaveBeenCalledTimes(1);
expect(closeAllMemorySearchManagersMock).toHaveBeenCalledTimes(1);
expect(exitSpy).not.toHaveBeenCalled();
exitSpy.mockRestore();
});
@@ -85,7 +85,7 @@ describe("runCli exit behavior", () => {
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(outputRootHelpMock).toHaveBeenCalledTimes(1);
expect(buildProgramMock).not.toHaveBeenCalled();
expect(closeActiveMemorySearchManagersMock).toHaveBeenCalledTimes(1);
expect(closeAllMemorySearchManagersMock).toHaveBeenCalledTimes(1);
expect(exitSpy).not.toHaveBeenCalled();
exitSpy.mockRestore();
});
@@ -104,7 +104,7 @@ describe("runCli exit behavior", () => {
]);
expect(loadDotEnvMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(closeActiveMemorySearchManagersMock).not.toHaveBeenCalled();
expect(closeAllMemorySearchManagersMock).not.toHaveBeenCalled();
});
it("propagates a handled container-target exit code", async () => {

View File

@@ -20,8 +20,8 @@ import { normalizeWindowsArgv } from "./windows-argv.js";
async function closeCliMemoryManagers(): Promise<void> {
try {
const { closeActiveMemorySearchManagers } = await import("../plugins/memory-runtime.js");
await closeActiveMemorySearchManagers();
const { closeAllMemorySearchManagers } = await import("../memory/search-manager.js");
await closeAllMemorySearchManagers();
} catch {
// Best-effort teardown for short-lived CLI processes.
}

View File

@@ -523,6 +523,7 @@ describe("normalizeCompatibilityConfigValues", () => {
});
expect(res.config.talk).toEqual({
provider: "elevenlabs",
providers: {
elevenlabs: {
voiceId: "voice-123",
@@ -544,7 +545,9 @@ describe("normalizeCompatibilityConfigValues", () => {
interruptOnSpeech: false,
silenceTimeoutMs: 1500,
});
expect(res.changes).toEqual(["Moved legacy talk flat fields → talk.providers.elevenlabs."]);
expect(res.changes).toEqual([
"Moved legacy talk flat fields → talk.provider/talk.providers.elevenlabs.",
]);
});
it("normalizes talk provider ids without overriding explicit provider config", () => {

View File

@@ -11,7 +11,7 @@ import {
resolveTelegramPreviewStreamMode,
} from "../config/discord-preview-streaming.js";
import { migrateLegacyWebSearchConfig } from "../config/legacy-web-search.js";
import { LEGACY_TALK_PROVIDER_ID, normalizeTalkSection } from "../config/talk.js";
import { DEFAULT_TALK_PROVIDER, normalizeTalkSection } from "../config/talk.js";
import { DEFAULT_GOOGLE_API_BASE_URL } from "../infra/google-api-base-url.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
@@ -651,7 +651,9 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
return;
}
changes.push(`Moved legacy talk flat fields → talk.providers.${LEGACY_TALK_PROVIDER_ID}.`);
changes.push(
`Moved legacy talk flat fields → talk.provider/talk.providers.${DEFAULT_TALK_PROVIDER}.`,
);
};
const normalizeLegacyCrossContextMessageConfig = () => {

View File

@@ -482,7 +482,7 @@ describe("config strict validation", () => {
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.valid).toBe(false);
expect(snap.legacyIssues).not.toHaveLength(0);
});
});
@@ -517,7 +517,7 @@ describe("config strict validation", () => {
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.valid).toBe(false);
expect(snap.legacyIssues.some((issue) => issue.path === "gateway.bind")).toBe(true);
});
});

View File

@@ -68,7 +68,7 @@ function expectRoutingAllowFromLegacySnapshot(
ctx: { snapshot: ConfigSnapshot; parsed: unknown },
expectedAllowFrom: string[],
) {
expect(ctx.snapshot.valid).toBe(true);
expect(ctx.snapshot.valid).toBe(false);
expect(ctx.snapshot.legacyIssues.some((issue) => issue.path === "routing.allowFrom")).toBe(true);
const parsed = ctx.parsed as {
routing?: { allowFrom?: string[] };
@@ -269,7 +269,7 @@ describe("legacy config detection", () => {
await withSnapshotForConfig(
{ memorySearch: { provider: "local", fallback: "none" } },
async (ctx) => {
expect(ctx.snapshot.valid).toBe(true);
expect(ctx.snapshot.valid).toBe(false);
expect(ctx.snapshot.legacyIssues.some((issue) => issue.path === "memorySearch")).toBe(true);
},
);
@@ -278,14 +278,14 @@ describe("legacy config detection", () => {
await withSnapshotForConfig(
{ heartbeat: { model: "anthropic/claude-3-5-haiku-20241022", every: "30m" } },
async (ctx) => {
expect(ctx.snapshot.valid).toBe(true);
expect(ctx.snapshot.valid).toBe(false);
expect(ctx.snapshot.legacyIssues.some((issue) => issue.path === "heartbeat")).toBe(true);
},
);
});
it("flags legacy provider sections in snapshot", async () => {
await withSnapshotForConfig({ whatsapp: { allowFrom: ["+1555"] } }, async (ctx) => {
expect(ctx.snapshot.valid).toBe(true);
expect(ctx.snapshot.valid).toBe(false);
expect(ctx.snapshot.legacyIssues.some((issue) => issue.path === "whatsapp")).toBe(true);
const parsed = ctx.parsed as {

View File

@@ -3,7 +3,7 @@ import { normalizeProviderId, parseModelRef } from "../agents/model-selection.js
import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js";
import { resolveAgentModelPrimaryValue } from "./model-input.js";
import {
LEGACY_TALK_PROVIDER_ID,
DEFAULT_TALK_PROVIDER,
normalizeTalkConfig,
resolveActiveTalkProviderConfig,
resolveTalkApiKey,
@@ -204,7 +204,7 @@ export function applyTalkApiKey(config: OpenClawConfig): OpenClawConfig {
const talk = normalized.talk;
const active = resolveActiveTalkProviderConfig(talk);
if (!active || active.provider !== LEGACY_TALK_PROVIDER_ID) {
if (active?.provider && active.provider !== DEFAULT_TALK_PROVIDER) {
return normalized;
}
@@ -214,7 +214,7 @@ export function applyTalkApiKey(config: OpenClawConfig): OpenClawConfig {
return normalized;
}
const providerId = active.provider;
const providerId = active?.provider ?? DEFAULT_TALK_PROVIDER;
const providers = { ...talk?.providers };
const providerConfig = { ...providers[providerId], apiKey: resolved };
providers[providerId] = providerConfig;
@@ -222,6 +222,7 @@ export function applyTalkApiKey(config: OpenClawConfig): OpenClawConfig {
const nextTalk = {
...talk,
apiKey: resolved,
provider: talk?.provider ?? providerId,
providers,
};

View File

@@ -41,7 +41,6 @@ import {
readConfigIncludeFileWithGuards,
resolveConfigIncludes,
} from "./includes.js";
import { migrateLegacyConfig } from "./legacy-migrate.js";
import { findLegacyConfigIssues } from "./legacy.js";
import { applyMergePatch } from "./merge-patch.js";
import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin.js";
@@ -1186,11 +1185,6 @@ type ConfigReadResolution = {
envWarnings: EnvSubstitutionWarning[];
};
type LegacyMigrationResolution = {
effectiveConfigRaw: unknown;
sourceLegacyIssues: LegacyConfigIssue[];
};
function resolveConfigIncludesForRead(
parsed: unknown,
configPath: string,
@@ -1231,21 +1225,6 @@ function resolveConfigForRead(
};
}
function resolveLegacyConfigForRead(
resolvedConfigRaw: unknown,
sourceRaw: unknown,
): LegacyMigrationResolution {
const sourceLegacyIssues = findLegacyConfigIssues(resolvedConfigRaw, sourceRaw);
if (sourceLegacyIssues.length === 0) {
return { effectiveConfigRaw: resolvedConfigRaw, sourceLegacyIssues };
}
const migrated = migrateLegacyConfig(resolvedConfigRaw);
return {
effectiveConfigRaw: migrated.config ?? resolvedConfigRaw,
sourceLegacyIssues,
};
}
type ReadConfigFileSnapshotInternalResult = {
snapshot: ConfigFileSnapshot;
envSnapshotForRestore?: Record<string, string | undefined>;
@@ -1296,15 +1275,13 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
deps.env,
);
const resolvedConfig = readResolution.resolvedConfigRaw;
const legacyResolution = resolveLegacyConfigForRead(resolvedConfig, parsed);
const effectiveConfigRaw = legacyResolution.effectiveConfigRaw;
for (const w of readResolution.envWarnings) {
deps.logger.warn(
`Config (${configPath}): missing env var "${w.varName}" at ${w.configPath} — feature using this value will be unavailable`,
);
}
warnOnConfigMiskeys(effectiveConfigRaw, deps.logger);
if (typeof effectiveConfigRaw !== "object" || effectiveConfigRaw === null) {
warnOnConfigMiskeys(resolvedConfig, deps.logger);
if (typeof resolvedConfig !== "object" || resolvedConfig === null) {
observeLoadConfigSnapshot({
path: configPath,
exists: true,
@@ -1316,31 +1293,31 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
hash,
issues: [],
warnings: [],
legacyIssues: legacyResolution.sourceLegacyIssues,
legacyIssues: [],
});
return {};
}
const preValidationDuplicates = findDuplicateAgentDirs(effectiveConfigRaw as OpenClawConfig, {
const preValidationDuplicates = findDuplicateAgentDirs(resolvedConfig as OpenClawConfig, {
env: deps.env,
homedir: deps.homedir,
});
if (preValidationDuplicates.length > 0) {
throw new DuplicateAgentDirError(preValidationDuplicates);
}
const validated = validateConfigObjectWithPlugins(effectiveConfigRaw, { env: deps.env });
const validated = validateConfigObjectWithPlugins(resolvedConfig, { env: deps.env });
if (!validated.ok) {
observeLoadConfigSnapshot({
path: configPath,
exists: true,
raw,
parsed,
resolved: coerceConfig(effectiveConfigRaw),
resolved: coerceConfig(resolvedConfig),
valid: false,
config: coerceConfig(effectiveConfigRaw),
config: coerceConfig(resolvedConfig),
hash,
issues: validated.issues,
warnings: validated.warnings,
legacyIssues: legacyResolution.sourceLegacyIssues,
legacyIssues: findLegacyConfigIssues(resolvedConfig, parsed),
});
const details = validated.issues
.map(
@@ -1385,13 +1362,13 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
exists: true,
raw,
parsed,
resolved: coerceConfig(effectiveConfigRaw),
resolved: coerceConfig(resolvedConfig),
valid: true,
config: cfg,
hash,
issues: [],
warnings: validated.warnings,
legacyIssues: legacyResolution.sourceLegacyIssues,
legacyIssues: findLegacyConfigIssues(resolvedConfig, parsed),
});
const duplicates = findDuplicateAgentDirs(cfg, {
@@ -1559,10 +1536,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
}));
const resolvedConfigRaw = readResolution.resolvedConfigRaw;
const legacyResolution = resolveLegacyConfigForRead(resolvedConfigRaw, parsedRes.parsed);
const effectiveConfigRaw = legacyResolution.effectiveConfigRaw;
// Detect legacy keys on resolved config, but only mark source-literal legacy
// entries (for auto-migration) when they are present in the parsed source.
const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw, parsedRes.parsed);
const validated = validateConfigObjectWithPlugins(effectiveConfigRaw, { env: deps.env });
const validated = validateConfigObjectWithPlugins(resolvedConfigRaw, { env: deps.env });
if (!validated.ok) {
return await finalizeReadConfigSnapshotInternalResult(deps, {
snapshot: {
@@ -1570,13 +1548,13 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
exists: true,
raw,
parsed: parsedRes.parsed,
resolved: coerceConfig(effectiveConfigRaw),
resolved: coerceConfig(resolvedConfigRaw),
valid: false,
config: coerceConfig(effectiveConfigRaw),
config: coerceConfig(resolvedConfigRaw),
hash,
issues: validated.issues,
warnings: [...validated.warnings, ...envVarWarnings],
legacyIssues: legacyResolution.sourceLegacyIssues,
legacyIssues,
},
});
}
@@ -1602,13 +1580,13 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
parsed: parsedRes.parsed,
// Use resolvedConfigRaw (after $include and ${ENV} substitution but BEFORE runtime defaults)
// for config set/unset operations (issue #6070)
resolved: coerceConfig(effectiveConfigRaw),
resolved: coerceConfig(resolvedConfigRaw),
valid: true,
config: snapshotConfig,
hash,
issues: [],
warnings: [...validated.warnings, ...envVarWarnings],
legacyIssues: legacyResolution.sourceLegacyIssues,
legacyIssues,
},
envSnapshotForRestore: readResolution.envSnapshotForRestore,
});

View File

@@ -36,6 +36,7 @@ describe("talk normalization", () => {
});
expect(normalized).toEqual({
provider: "elevenlabs",
providers: {
elevenlabs: {
voiceId: "voice-123",
@@ -148,7 +149,7 @@ describe("talk normalization", () => {
async (configPath) => {
const io = createConfigIO({ configPath });
const snapshot = await io.readConfigFileSnapshot();
expect(snapshot.config.talk?.provider).toBeUndefined();
expect(snapshot.config.talk?.provider).toBe("elevenlabs");
expect(snapshot.config.talk?.providers?.elevenlabs?.voiceId).toBe("voice-123");
expect(snapshot.config.talk?.providers?.elevenlabs?.apiKey).toBe(elevenLabsApiKey);
expect(snapshot.config.talk?.apiKey).toBe(elevenLabsApiKey);

View File

@@ -16,7 +16,7 @@ type TalkApiKeyDeps = {
path?: typeof path;
};
export const LEGACY_TALK_PROVIDER_ID = "elevenlabs";
export const DEFAULT_TALK_PROVIDER = "elevenlabs";
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -225,13 +225,19 @@ export function normalizeTalkSection(value: TalkConfig | undefined): TalkConfig
}
if (provider) {
normalized.provider = provider;
} else if (providers) {
const ids = Object.keys(providers);
if (ids.length === 1) {
normalized.provider = ids[0];
}
}
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
const legacyProviderConfig = legacyProviderConfigFromTalk(source);
if (legacyProviderConfig) {
normalized.providers = { [LEGACY_TALK_PROVIDER_ID]: legacyProviderConfig };
normalized.provider = DEFAULT_TALK_PROVIDER;
normalized.providers = { [DEFAULT_TALK_PROVIDER]: legacyProviderConfig };
}
return Object.keys(normalized).length > 0 ? normalized : undefined;
}

View File

@@ -27,8 +27,22 @@ export type TtsModelOverrideConfig = {
export type TtsProviderConfigMap = Record<string, Record<string, unknown>>;
export type LegacyTtsConfigCompat = {
/** Legacy ElevenLabs configuration. Prefer providers.elevenlabs. */
export type TtsConfig = {
/** Auto-TTS mode (preferred). */
auto?: TtsAutoMode;
/** Legacy: enable auto-TTS when `auto` is not set. */
enabled?: boolean;
/** Apply TTS to final replies only or to all replies (tool/block/final). */
mode?: TtsMode;
/** Primary TTS provider (fallbacks are automatic). */
provider?: TtsProvider;
/** Optional model override for TTS auto-summary (provider/model or alias). */
summaryModel?: string;
/** Allow the model to override TTS parameters. */
modelOverrides?: TtsModelOverrideConfig;
/** Provider-specific TTS settings keyed by speech provider id. */
providers?: TtsProviderConfigMap;
/** ElevenLabs configuration. */
elevenlabs?: {
apiKey?: SecretInput;
baseUrl?: string;
@@ -45,7 +59,7 @@ export type LegacyTtsConfigCompat = {
speed?: number;
};
};
/** Legacy OpenAI configuration. Prefer providers.openai. */
/** OpenAI configuration. */
openai?: {
apiKey?: SecretInput;
baseUrl?: string;
@@ -56,7 +70,7 @@ export type LegacyTtsConfigCompat = {
/** System-level instructions for the TTS model (gpt-4o-mini-tts only). */
instructions?: string;
};
/** Legacy alias for Microsoft speech configuration. Prefer providers.microsoft. */
/** Legacy alias for Microsoft speech configuration. */
edge?: {
/** Explicitly allow Microsoft speech usage (no API key required). */
enabled?: boolean;
@@ -70,7 +84,7 @@ export type LegacyTtsConfigCompat = {
proxy?: string;
timeoutMs?: number;
};
/** Legacy Microsoft speech configuration. Prefer providers.microsoft. */
/** Preferred alias for Microsoft speech configuration. */
microsoft?: {
enabled?: boolean;
voice?: string;
@@ -83,23 +97,6 @@ export type LegacyTtsConfigCompat = {
proxy?: string;
timeoutMs?: number;
};
};
export type TtsConfig = LegacyTtsConfigCompat & {
/** Auto-TTS mode (preferred). */
auto?: TtsAutoMode;
/** Legacy: enable auto-TTS when `auto` is not set. */
enabled?: boolean;
/** Apply TTS to final replies only or to all replies (tool/block/final). */
mode?: TtsMode;
/** Primary TTS provider (fallbacks are automatic). */
provider?: TtsProvider;
/** Optional model override for TTS auto-summary (provider/model or alias). */
summaryModel?: string;
/** Allow the model to override TTS parameters. */
modelOverrides?: TtsModelOverrideConfig;
/** Provider-specific TTS settings keyed by speech provider id. */
providers?: TtsProviderConfigMap;
/** Optional path for local TTS user preferences JSON. */
prefsPath?: string;
/** Hard cap for text sent to TTS (chars). */

View File

@@ -6,6 +6,14 @@ export const WRITE_SCOPE = "operator.write" as const;
export const APPROVALS_SCOPE = "operator.approvals" as const;
export const PAIRING_SCOPE = "operator.pairing" as const;
const ALL_OPERATOR_SCOPES = [
ADMIN_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
APPROVALS_SCOPE,
PAIRING_SCOPE,
] as const;
export type OperatorScope =
| typeof ADMIN_SCOPE
| typeof READ_SCOPE
@@ -13,13 +21,14 @@ export type OperatorScope =
| typeof APPROVALS_SCOPE
| typeof PAIRING_SCOPE;
export const CLI_DEFAULT_OPERATOR_SCOPES: OperatorScope[] = [
ADMIN_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
APPROVALS_SCOPE,
PAIRING_SCOPE,
];
const OPERATOR_SCOPE_SET = new Set<string>(ALL_OPERATOR_SCOPES);
export const CLI_DEFAULT_OPERATOR_SCOPES: OperatorScope[] = [...ALL_OPERATOR_SCOPES];
/** Narrow an arbitrary scope string to a known operator scope. */
export function isOperatorScope(scope: string): scope is OperatorScope {
return OPERATOR_SCOPE_SET.has(scope);
}
const NODE_ROLE_METHODS = new Set([
"node.invoke.result",

View File

@@ -2,14 +2,11 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, expect, vi, type Mock } from "vitest";
import type {
MemoryIndexManager,
MemorySearchManager,
} from "../../extensions/memory-core/src/memory/index.js";
import type { OpenClawConfig } from "../config/config.js";
import type { MemoryIndexManager, MemorySearchManager } from "./index.js";
type EmbeddingTestMocksModule = typeof import("./embedding.test-mocks.js");
type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js");
type MemoryIndexModule = typeof import("./index.js");
export function installEmbeddingManagerFixture(opts: {
fixturePrefix: string;
@@ -64,7 +61,7 @@ export function installEmbeddingManagerFixture(opts: {
const embeddingMocks = await import("./embedding.test-mocks.js");
embedBatch = embeddingMocks.getEmbedBatchMock();
resetEmbeddingMocks = embeddingMocks.resetEmbeddingMocks;
({ getMemorySearchManager } = await import("../../extensions/memory-core/src/memory/index.js"));
({ getMemorySearchManager } = await import("./index.js"));
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), opts.fixturePrefix));
workspaceDir = path.join(fixtureRoot, "workspace");
memoryDir = path.join(workspaceDir, "memory");

View File

@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest";
import {
bm25RankToScore,
buildFtsQuery,
mergeHybridResults,
} from "../../extensions/memory-core/src/memory/hybrid.js";
import { bm25RankToScore, buildFtsQuery, mergeHybridResults } from "./hybrid.js";
describe("memory hybrid helpers", () => {
it("buildFtsQuery tokenizes and AND-joins", () => {

View File

@@ -5,9 +5,9 @@ import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import "./test-runtime-mocks.js";
import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js";
import type { MemoryIndexManager } from "./index.js";
type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js");
type MemoryIndexModule = typeof import("./index.js");
let getMemorySearchManager: MemoryIndexModule["getMemorySearchManager"];
let closeAllMemorySearchManagers: MemoryIndexModule["closeAllMemorySearchManagers"];
@@ -131,8 +131,7 @@ describe("memory index", () => {
beforeAll(async () => {
vi.resetModules();
await import("./test-runtime-mocks.js");
({ getMemorySearchManager, closeAllMemorySearchManagers } =
await import("../../extensions/memory-core/src/memory/index.js"));
({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js"));
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-fixtures-"));
workspaceDir = path.join(fixtureRoot, "workspace");
memoryDir = path.join(workspaceDir, "memory");

View File

@@ -3,7 +3,7 @@ export type {
MemoryEmbeddingProbeResult,
MemorySearchManager,
MemorySearchResult,
} from "../api.js";
} from "./types.js";
export {
closeAllMemorySearchManagers,
getMemorySearchManager,

View File

@@ -1,30 +1,31 @@
import fs from "node:fs/promises";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { runGeminiEmbeddingBatches, type GeminiBatchRequest } from "./batch-gemini.js";
import {
OPENAI_BATCH_ENDPOINT,
type OpenAiBatchRequest,
runOpenAiEmbeddingBatches,
} from "./batch-openai.js";
import { type VoyageBatchRequest, runVoyageEmbeddingBatches } from "./batch-voyage.js";
import { enforceEmbeddingMaxInputTokens } from "./embedding-chunk-limits.js";
import {
buildGeminiEmbeddingRequest,
buildMultimodalChunkForIndexing,
chunkMarkdown,
createSubsystemLogger,
enforceEmbeddingMaxInputTokens,
estimateStructuredEmbeddingInputBytes,
estimateUtf8Bytes,
hasNonTextEmbeddingParts,
} from "./embedding-input-limits.js";
import { type EmbeddingInput, hasNonTextEmbeddingParts } from "./embedding-inputs.js";
import { buildGeminiEmbeddingRequest } from "./embeddings-gemini.js";
import {
buildMultimodalChunkForIndexing,
chunkMarkdown,
hashText,
parseEmbedding,
remapChunkLines,
runGeminiEmbeddingBatches,
runOpenAiEmbeddingBatches,
runVoyageEmbeddingBatches,
type EmbeddingInput,
type GeminiBatchRequest,
type MemoryChunk,
type MemoryFileEntry,
type MemorySource,
type OpenAiBatchRequest,
type SessionFileEntry,
type VoyageBatchRequest,
OPENAI_BATCH_ENDPOINT,
} from "../api.js";
} from "./internal.js";
import { MemoryManagerSyncOps } from "./manager-sync-ops.js";
import type { SessionFileEntry } from "./session-files.js";
import type { MemorySource } from "./types.js";
const VECTOR_TABLE = "chunks_vec";
const FTS_TABLE = "chunks_fts";

View File

@@ -1,5 +1,6 @@
import type { DatabaseSync } from "node:sqlite";
import { cosineSimilarity, parseEmbedding, truncateUtf16Safe } from "../api.js";
import { truncateUtf16Safe } from "../utils.js";
import { cosineSimilarity, parseEmbedding } from "./internal.js";
const vectorToBlob = (embedding: number[]): Buffer =>
Buffer.from(new Float32Array(embedding).buffer);

View File

@@ -4,47 +4,52 @@ import fs from "node:fs/promises";
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import chokidar, { FSWatcher } from "chokidar";
import { resolveAgentDir } from "../agents/agent-scope.js";
import { ResolvedMemorySearchConfig } from "../agents/memory-search.js";
import { type OpenClawConfig } from "../config/config.js";
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { onSessionTranscriptUpdate } from "../sessions/transcript-events.js";
import { resolveUserPath } from "../utils.js";
import { DEFAULT_GEMINI_EMBEDDING_MODEL } from "./embeddings-gemini.js";
import { DEFAULT_MISTRAL_EMBEDDING_MODEL } from "./embeddings-mistral.js";
import { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "./embeddings-ollama.js";
import { DEFAULT_OPENAI_EMBEDDING_MODEL } from "./embeddings-openai.js";
import { DEFAULT_VOYAGE_EMBEDDING_MODEL } from "./embeddings-voyage.js";
import {
DEFAULT_GEMINI_EMBEDDING_MODEL,
DEFAULT_MISTRAL_EMBEDDING_MODEL,
DEFAULT_OLLAMA_EMBEDDING_MODEL,
DEFAULT_OPENAI_EMBEDDING_MODEL,
DEFAULT_VOYAGE_EMBEDDING_MODEL,
buildCaseInsensitiveExtensionGlob,
buildFileEntry,
classifyMemoryMultimodalPath,
createEmbeddingProvider,
createSubsystemLogger,
ensureDir,
ensureMemoryIndexSchema,
getMemoryMultimodalExtensions,
hashText,
isFileMissingError,
listMemoryFiles,
listSessionFilesForAgent,
loadSqliteVecExtension,
normalizeExtraMemoryPaths,
onSessionTranscriptUpdate,
requireNodeSqlite,
resolveAgentDir,
resolveSessionTranscriptsDirForAgent,
resolveUserPath,
runWithConcurrency,
sessionPathForFile,
type MemoryFileEntry,
type MemorySource,
type MemorySyncProgressUpdate,
type OpenClawConfig,
type ResolvedMemorySearchConfig,
type SessionFileEntry,
type EmbeddingProvider,
type GeminiEmbeddingClient,
type MistralEmbeddingClient,
type OllamaEmbeddingClient,
type OpenAiEmbeddingClient,
type VoyageEmbeddingClient,
} from "./embeddings.js";
import { isFileMissingError } from "./fs-utils.js";
import {
buildFileEntry,
ensureDir,
hashText,
listMemoryFiles,
normalizeExtraMemoryPaths,
runWithConcurrency,
} from "./internal.js";
import { type MemoryFileEntry } from "./internal.js";
import { ensureMemoryIndexSchema } from "./memory-schema.js";
import {
buildCaseInsensitiveExtensionGlob,
classifyMemoryMultimodalPath,
getMemoryMultimodalExtensions,
} from "./multimodal.js";
import type { SessionFileEntry } from "./session-files.js";
import {
buildSessionEntry,
} from "../api.js";
listSessionFilesForAgent,
sessionPathForFile,
} from "./session-files.js";
import { loadSqliteVecExtension } from "./sqlite-vec.js";
import { requireNodeSqlite } from "./sqlite.js";
import type { MemorySource, MemorySyncProgressUpdate } from "./types.js";
type MemoryIndexMeta = {
model: string;

View File

@@ -2,9 +2,9 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js";
import { closeAllMemorySearchManagers } from "../../extensions/memory-core/src/memory/index.js";
import type { OpenClawConfig } from "../config/config.js";
import type { MemoryIndexManager } from "./index.js";
import { closeAllMemorySearchManagers } from "./index.js";
import { createOpenAIEmbeddingProviderMock } from "./test-embeddings-mock.js";
import { createMemoryManagerOrThrow } from "./test-manager.js";

View File

@@ -2,14 +2,14 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js";
import type { OpenClawConfig } from "../config/config.js";
import type { MemoryIndexManager } from "./index.js";
let shouldFail = false;
type EmbeddingTestMocksModule = typeof import("./embedding.test-mocks.js");
type TestManagerHelpersModule = typeof import("./test-manager-helpers.js");
type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js");
type MemoryIndexModule = typeof import("./index.js");
describe("memory manager atomic reindex", () => {
let fixtureRoot = "";
@@ -32,8 +32,7 @@ describe("memory manager atomic reindex", () => {
embedBatch = embeddingMocks.getEmbedBatchMock();
resetEmbeddingMocks = embeddingMocks.resetEmbeddingMocks;
({ getRequiredMemoryIndexManager } = await import("./test-manager-helpers.js"));
({ closeAllMemorySearchManagers } =
await import("../../extensions/memory-core/src/memory/index.js"));
({ closeAllMemorySearchManagers } = await import("./index.js"));
vi.stubEnv("OPENCLAW_TEST_MEMORY_UNSAFE_REINDEX", "0");
resetEmbeddingMocks();
shouldFail = false;

View File

@@ -7,9 +7,8 @@ import type { OpenClawConfig } from "../config/config.js";
import { createOpenAIEmbeddingProviderMock } from "./test-embeddings-mock.js";
import { mockPublicPinnedHostname } from "./test-helpers/ssrf.js";
type MemoryIndexManager =
import("../../extensions/memory-core/src/memory/index.js").MemoryIndexManager;
type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js");
type MemoryIndexManager = import("./index.js").MemoryIndexManager;
type MemoryIndexModule = typeof import("./index.js");
const embedBatch = vi.fn(async (_texts: string[]) => [] as number[][]);
const embedQuery = vi.fn(async () => [0.5, 0.5, 0.5]);
@@ -122,7 +121,7 @@ describe("memory indexing with OpenAI batches", () => {
}),
}));
await import("./test-runtime-mocks.js");
({ getMemorySearchManager } = await import("../../extensions/memory-core/src/memory/index.js"));
({ getMemorySearchManager } = await import("./index.js"));
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-batch-"));
workspaceDir = path.join(fixtureRoot, "workspace");

View File

@@ -3,12 +3,12 @@ import os from "node:os";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js";
import "./test-runtime-mocks.js";
import type { OpenClawConfig } from "../config/config.js";
import "./test-runtime-mocks.js";
import type { MemoryIndexManager } from "./index.js";
type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js");
type ManagerModule = typeof import("../../extensions/memory-core/src/memory/manager.js");
type MemoryIndexModule = typeof import("./index.js");
type ManagerModule = typeof import("./manager.js");
const hoisted = vi.hoisted(() => ({
providerCreateCalls: 0,
@@ -43,10 +43,9 @@ describe("memory manager cache hydration", () => {
let workspaceDir = "";
beforeAll(async () => {
({ getMemorySearchManager, closeAllMemorySearchManagers } =
await import("../../extensions/memory-core/src/memory/index.js"));
({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js"));
({ closeAllMemoryIndexManagers, MemoryIndexManager: RawMemoryIndexManager } =
await import("../../extensions/memory-core/src/memory/manager.js"));
await import("./manager.js"));
});
beforeEach(async () => {

View File

@@ -2,7 +2,6 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js";
import type { OpenClawConfig } from "../config/config.js";
import { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "./embeddings-ollama.js";
import type {
@@ -12,6 +11,7 @@ import type {
OllamaEmbeddingClient,
OpenAiEmbeddingClient,
} from "./embeddings.js";
import type { MemoryIndexManager } from "./index.js";
const { createEmbeddingProviderMock } = vi.hoisted(() => ({
createEmbeddingProviderMock: vi.fn(),
@@ -25,7 +25,7 @@ vi.mock("./sqlite-vec.js", () => ({
loadSqliteVecExtension: async () => ({ ok: false, error: "sqlite-vec disabled in tests" }),
}));
type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js");
type MemoryIndexModule = typeof import("./index.js");
let getMemorySearchManager: MemoryIndexModule["getMemorySearchManager"];
let closeAllMemorySearchManagers: MemoryIndexModule["closeAllMemorySearchManagers"];
@@ -78,8 +78,7 @@ describe("memory manager mistral provider wiring", () => {
beforeEach(async () => {
vi.resetModules();
({ getMemorySearchManager, closeAllMemorySearchManagers } =
await import("../../extensions/memory-core/src/memory/index.js"));
({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js"));
vi.clearAllMocks();
createEmbeddingProviderMock.mockReset();
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-mistral-"));

View File

@@ -2,9 +2,9 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js";
import type { OpenClawConfig } from "../config/config.js";
import { resetEmbeddingMocks } from "./embedding.test-mocks.js";
import type { MemoryIndexManager } from "./index.js";
import { getRequiredMemoryIndexManager } from "./test-manager-helpers.js";
function createMemorySearchCfg(options: {

View File

@@ -3,9 +3,9 @@ import os from "node:os";
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryIndexManager } from "../../extensions/memory-core/src/memory/manager.js";
import type { OpenClawConfig } from "../config/config.js";
import { resetEmbeddingMocks } from "./embedding.test-mocks.js";
import { MemoryIndexManager } from "./manager.js";
import { getRequiredMemoryIndexManager } from "./test-manager-helpers.js";
type ReadonlyRecoveryHarness = {

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runDetachedMemorySync } from "../../extensions/memory-core/src/memory/manager-sync-ops.js";
import { runDetachedMemorySync } from "./manager-sync-ops.js";
describe("memory manager sync failures", () => {
beforeEach(() => {

View File

@@ -1,5 +1,11 @@
import type { DatabaseSync } from "node:sqlite";
import { type FSWatcher } from "chokidar";
import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import type { ResolvedMemorySearchConfig } from "../agents/memory-search.js";
import { resolveMemorySearchConfig } from "../agents/memory-search.js";
import type { OpenClawConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import {
createEmbeddingProvider,
type EmbeddingProvider,
@@ -10,25 +16,20 @@ import {
type OllamaEmbeddingClient,
type OpenAiEmbeddingClient,
type VoyageEmbeddingClient,
extractKeywords,
readMemoryFile,
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveGlobalSingleton,
resolveMemorySearchConfig,
type MemoryEmbeddingProbeResult,
type MemoryProviderStatus,
type MemorySearchManager,
type MemorySearchResult,
type MemorySource,
type MemorySyncProgressUpdate,
type OpenClawConfig,
type ResolvedMemorySearchConfig,
createSubsystemLogger,
} from "../api.js";
} from "./embeddings.js";
import { bm25RankToScore, buildFtsQuery, mergeHybridResults } from "./hybrid.js";
import { MemoryManagerEmbeddingOps } from "./manager-embedding-ops.js";
import { searchKeyword, searchVector } from "./manager-search.js";
import { extractKeywords } from "./query-expansion.js";
import { readMemoryFile } from "./read-file.js";
import type {
MemoryEmbeddingProbeResult,
MemoryProviderStatus,
MemorySearchManager,
MemorySearchResult,
MemorySource,
MemorySyncProgressUpdate,
} from "./types.js";
const SNIPPET_MAX_CHARS = 700;
const VECTOR_TABLE = "chunks_vec";
const FTS_TABLE = "chunks_fts";

View File

@@ -2,8 +2,8 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js";
import type { OpenClawConfig } from "../config/config.js";
import type { MemoryIndexManager } from "./index.js";
vi.mock("./embeddings.js", () => {
return {
@@ -21,7 +21,7 @@ vi.mock("./embeddings.js", () => {
type MemoryInternalModule = typeof import("./internal.js");
type TestManagerModule = typeof import("./test-manager.js");
type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js");
type MemoryIndexModule = typeof import("./index.js");
let buildFileEntry: MemoryInternalModule["buildFileEntry"];
let createMemoryManagerOrThrow: TestManagerModule["createMemoryManagerOrThrow"];
@@ -57,8 +57,7 @@ describe("memory vector dedupe", () => {
vi.resetModules();
({ buildFileEntry } = await import("./internal.js"));
({ createMemoryManagerOrThrow } = await import("./test-manager.js"));
({ closeAllMemorySearchManagers } =
await import("../../extensions/memory-core/src/memory/index.js"));
({ closeAllMemorySearchManagers } = await import("./index.js"));
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-"));
indexPath = path.join(workspaceDir, "index.sqlite");
await seedMemoryWorkspace(workspaceDir);

View File

@@ -2,9 +2,9 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js";
import type { OpenClawConfig } from "../config/config.js";
import type { MemorySearchConfig } from "../config/types.tools.js";
import type { MemoryIndexManager } from "./index.js";
const { watchMock } = vi.hoisted(() => ({
watchMock: vi.fn(() => ({
@@ -34,7 +34,7 @@ vi.mock("./embeddings.js", () => ({
}),
}));
type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js");
type MemoryIndexModule = typeof import("./index.js");
let getMemorySearchManager: MemoryIndexModule["getMemorySearchManager"];
let closeAllMemorySearchManagers: MemoryIndexModule["closeAllMemorySearchManagers"];
@@ -46,8 +46,7 @@ describe("memory watcher config", () => {
beforeEach(async () => {
vi.resetModules();
({ getMemorySearchManager, closeAllMemorySearchManagers } =
await import("../../extensions/memory-core/src/memory/index.js"));
({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js"));
vi.clearAllMocks();
});

View File

@@ -8,7 +8,7 @@ import {
applyMMRToHybridResults,
DEFAULT_MMR_CONFIG,
type MMRItem,
} from "../../extensions/memory-core/src/memory/mmr.js";
} from "./mmr.js";
describe("tokenize", () => {
it("normalizes, filters, and deduplicates token sets", () => {

View File

@@ -88,9 +88,9 @@ vi.mock("node:child_process", async (importOriginal) => {
});
import { spawn as mockedSpawn } from "node:child_process";
import { QmdMemoryManager } from "../../extensions/memory-core/src/memory/qmd-manager.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveMemoryBackendConfig } from "./backend-config.js";
import { QmdMemoryManager } from "./qmd-manager.js";
import { requireNodeSqlite } from "./sqlite.js";
const spawnMock = mockedSpawn as unknown as Mock;

View File

@@ -2,39 +2,38 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import readline from "node:readline";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { writeFileWithinRoot } from "../infra/fs-safe.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import { isFileMissingError, statRegularFile } from "./fs-utils.js";
import { resolveCliSpawnInvocation, runCliCommand } from "./qmd-process.js";
import { deriveQmdScopeChannel, deriveQmdScopeChatType, isQmdScopeAllowed } from "./qmd-scope.js";
import {
buildSessionEntry,
createSubsystemLogger,
deriveQmdScopeChannel,
deriveQmdScopeChatType,
extractKeywords,
isFileMissingError,
isQmdScopeAllowed,
listSessionFilesForAgent,
parseQmdQueryJson,
requireNodeSqlite,
resolveAgentWorkspaceDir,
resolveCliSpawnInvocation,
resolveGlobalSingleton,
resolveStateDir,
runCliCommand,
statRegularFile,
type MemoryEmbeddingProbeResult,
type MemoryProviderStatus,
type MemorySearchManager,
type MemorySearchResult,
type MemorySource,
type MemorySyncProgressUpdate,
type OpenClawConfig,
type QmdQueryResult,
type ResolvedMemoryBackendConfig,
type ResolvedQmdConfig,
type ResolvedQmdMcporterConfig,
buildSessionEntry,
type SessionFileEntry,
writeFileWithinRoot,
} from "../api.js";
} from "./session-files.js";
import { requireNodeSqlite } from "./sqlite.js";
import type {
MemoryEmbeddingProbeResult,
MemoryProviderStatus,
MemorySearchManager,
MemorySearchResult,
MemorySource,
MemorySyncProgressUpdate,
} from "./types.js";
type SqliteDatabase = import("node:sqlite").DatabaseSync;
import type {
ResolvedMemoryBackendConfig,
ResolvedQmdConfig,
ResolvedQmdMcporterConfig,
} from "./backend-config.js";
import { parseQmdQueryJson, type QmdQueryResult } from "./qmd-query-parser.js";
import { extractKeywords } from "./query-expansion.js";
const log = createSubsystemLogger("memory");

View File

@@ -96,7 +96,7 @@ const fallbackSearch = fallbackManager.search;
const mockMemoryIndexGet = vi.hoisted(() => vi.fn(async () => fallbackManager));
const mockCloseAllMemoryIndexManagers = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("../../extensions/memory-core/src/memory/qmd-manager.js", () => ({
vi.mock("./qmd-manager.js", () => ({
QmdMemoryManager: {
create: vi.fn(async () => mockPrimary),
},
@@ -109,11 +109,8 @@ vi.mock("./manager-runtime.js", () => ({
closeAllMemoryIndexManagers: mockCloseAllMemoryIndexManagers,
}));
import { QmdMemoryManager } from "../../extensions/memory-core/src/memory/qmd-manager.js";
import {
closeAllMemorySearchManagers,
getMemorySearchManager,
} from "../../extensions/memory-core/src/memory/search-manager.js";
import { QmdMemoryManager } from "./qmd-manager.js";
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./search-manager.js";
// eslint-disable-next-line @typescript-eslint/unbound-method -- mocked static function
const createQmdManagerMock = vi.mocked(QmdMemoryManager.create);

View File

@@ -1,13 +1,13 @@
import {
createSubsystemLogger,
resolveGlobalSingleton,
resolveMemoryBackendConfig,
type MemoryEmbeddingProbeResult,
type MemorySearchManager,
type MemorySyncProgressUpdate,
type OpenClawConfig,
type ResolvedQmdConfig,
} from "../api.js";
import type { OpenClawConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import type { ResolvedQmdConfig } from "./backend-config.js";
import { resolveMemoryBackendConfig } from "./backend-config.js";
import type {
MemoryEmbeddingProbeResult,
MemorySearchManager,
MemorySyncProgressUpdate,
} from "./types.js";
const MEMORY_SEARCH_MANAGER_CACHE_KEY = Symbol.for("openclaw.memorySearchManagerCache");
type MemorySearchManagerCacheStore = {

View File

@@ -2,12 +2,12 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { mergeHybridResults } from "../../extensions/memory-core/src/memory/hybrid.js";
import { mergeHybridResults } from "./hybrid.js";
import {
applyTemporalDecayToHybridResults,
applyTemporalDecayToScore,
calculateTemporalDecayMultiplier,
} from "../../extensions/memory-core/src/memory/temporal-decay.js";
} from "./temporal-decay.js";
const DAY_MS = 24 * 60 * 60 * 1000;
const NOW_MS = Date.UTC(2026, 1, 10, 0, 0, 0);

View File

@@ -1,5 +1,5 @@
import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js";
import type { OpenClawConfig } from "../config/config.js";
import type { MemoryIndexManager } from "./index.js";
export async function getRequiredMemoryIndexManager(params: {
cfg: OpenClawConfig;
@@ -7,8 +7,7 @@ export async function getRequiredMemoryIndexManager(params: {
purpose?: "default" | "status";
}): Promise<MemoryIndexManager> {
await import("./embedding.test-mocks.js");
const { getMemorySearchManager } =
await import("../../extensions/memory-core/src/memory/index.js");
const { getMemorySearchManager } = await import("./index.js");
const result = await getMemorySearchManager({
cfg: params.cfg,
agentId: params.agentId ?? "main",

View File

@@ -1,8 +1,5 @@
import {
getMemorySearchManager,
type MemoryIndexManager,
} from "../../extensions/memory-core/src/memory/index.js";
import type { OpenClawConfig } from "../config/config.js";
import { getMemorySearchManager, type MemoryIndexManager } from "./index.js";
export async function createMemoryManagerOrThrow(
cfg: OpenClawConfig,

View File

@@ -14,8 +14,6 @@ export {
export {
applyDirectoryQueryAndLimit,
collectNormalizedDirectoryIds,
createInspectedDirectoryEntriesLister,
createResolvedDirectoryEntriesLister,
listDirectoryEntriesFromSources,
listDirectoryGroupEntriesFromMapKeys,
listDirectoryGroupEntriesFromMapKeysAndAllowFrom,

View File

@@ -5,15 +5,11 @@ export type { AnyAgentTool } from "../agents/tools/common.js";
export { resolveCronStyleNow } from "../agents/current-time.js";
export { DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR } from "../agents/pi-settings.js";
export {
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
resolveSessionAgentId,
} from "../agents/agent-scope.js";
export {
resolveMemorySearchConfig,
type ResolvedMemorySearchConfig,
} from "../agents/memory-search.js";
export { resolveMemorySearchConfig } from "../agents/memory-search.js";
export { jsonResult, readNumberParam, readStringParam } from "../agents/tools/common.js";
export { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
export { formatErrorMessage, withManager } from "../cli/cli-utils.js";
@@ -25,112 +21,22 @@ export { parseNonNegativeByteSize } from "../config/byte-size.js";
export { loadConfig } from "../config/config.js";
export { resolveStateDir } from "../config/paths.js";
export { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
export { writeFileWithinRoot } from "../infra/fs-safe.js";
export { createSubsystemLogger } from "../logging/subsystem.js";
export { resolveGlobalSingleton } from "../shared/global-singleton.js";
export { onSessionTranscriptUpdate } from "../sessions/transcript-events.js";
export {
buildFileEntry,
buildMultimodalChunkForIndexing,
chunkMarkdown,
cosineSimilarity,
ensureDir,
hashText,
listMemoryFiles,
normalizeExtraMemoryPaths,
parseEmbedding,
remapChunkLines,
runWithConcurrency,
type MemoryChunk,
type MemoryFileEntry,
} from "../memory/internal.js";
export { readAgentMemoryFile, readMemoryFile } from "../memory/read-file.js";
export { listMemoryFiles, normalizeExtraMemoryPaths } from "../memory/internal.js";
export { readAgentMemoryFile } from "../memory/read-file.js";
export { resolveMemoryBackendConfig } from "../memory/backend-config.js";
export type {
ResolvedMemoryBackendConfig,
ResolvedQmdConfig,
ResolvedQmdMcporterConfig,
} from "../memory/backend-config.js";
export type {
MemoryEmbeddingProbeResult,
MemoryProviderStatus,
MemorySearchManager,
MemorySearchResult,
MemorySource,
MemorySyncProgressUpdate,
} from "../memory/types.js";
export {
createEmbeddingProvider,
type EmbeddingProvider,
type EmbeddingProviderRequest,
type EmbeddingProviderResult,
type GeminiEmbeddingClient,
type MistralEmbeddingClient,
type OllamaEmbeddingClient,
type OpenAiEmbeddingClient,
type VoyageEmbeddingClient,
} from "../memory/embeddings.js";
export {
DEFAULT_GEMINI_EMBEDDING_MODEL,
buildGeminiEmbeddingRequest,
} from "../memory/embeddings-gemini.js";
export { DEFAULT_MISTRAL_EMBEDDING_MODEL } from "../memory/embeddings-mistral.js";
export { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "../memory/embeddings-ollama.js";
export { DEFAULT_OPENAI_EMBEDDING_MODEL } from "../memory/embeddings-openai.js";
export { DEFAULT_VOYAGE_EMBEDDING_MODEL } from "../memory/embeddings-voyage.js";
export { runGeminiEmbeddingBatches, type GeminiBatchRequest } from "../memory/batch-gemini.js";
export {
OPENAI_BATCH_ENDPOINT,
runOpenAiEmbeddingBatches,
type OpenAiBatchRequest,
} from "../memory/batch-openai.js";
export { runVoyageEmbeddingBatches, type VoyageBatchRequest } from "../memory/batch-voyage.js";
export { enforceEmbeddingMaxInputTokens } from "../memory/embedding-chunk-limits.js";
export {
estimateStructuredEmbeddingInputBytes,
estimateUtf8Bytes,
} from "../memory/embedding-input-limits.js";
export { hasNonTextEmbeddingParts, type EmbeddingInput } from "../memory/embedding-inputs.js";
export {
buildCaseInsensitiveExtensionGlob,
classifyMemoryMultimodalPath,
getMemoryMultimodalExtensions,
} from "../memory/multimodal.js";
export { ensureMemoryIndexSchema } from "../memory/memory-schema.js";
export { loadSqliteVecExtension } from "../memory/sqlite-vec.js";
export { requireNodeSqlite } from "../memory/sqlite.js";
export { extractKeywords, isQueryStopWordToken } from "../memory/query-expansion.js";
export {
buildSessionEntry,
listSessionFilesForAgent,
sessionPathForFile,
type SessionFileEntry,
} from "../memory/session-files.js";
export { parseQmdQueryJson, type QmdQueryResult } from "../memory/qmd-query-parser.js";
export {
deriveQmdScopeChannel,
deriveQmdScopeChatType,
isQmdScopeAllowed,
} from "../memory/qmd-scope.js";
export { isFileMissingError, statRegularFile } from "../memory/fs-utils.js";
export { resolveCliSpawnInvocation, runCliCommand } from "../memory/qmd-process.js";
export {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
} from "../config/types.secrets.js";
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
export { getMemorySearchManager } from "../memory/index.js";
export { parseAgentSessionKey } from "../routing/session-key.js";
export { defaultRuntime } from "../runtime.js";
export { colorize, isRich, theme } from "../terminal/theme.js";
export { formatDocsLink } from "../terminal/links.js";
export { detectMime } from "../media/mime.js";
export { setVerbose, isVerbose } from "../globals.js";
export {
shortenHomeInString,
shortenHomePath,
resolveUserPath,
truncateUtf16Safe,
} from "../utils.js";
export { shortenHomeInString, shortenHomePath, resolveUserPath } from "../utils.js";
export { splitShellArgs } from "../utils/shell-argv.js";
export { runTasksWithConcurrency } from "../utils/run-with-concurrency.js";
export type { OpenClawConfig } from "../config/config.js";
@@ -144,6 +50,7 @@ export type {
MemoryQmdSearchMode,
} from "../config/types.memory.js";
export type { SecretInput } from "../config/types.secrets.js";
export type { MemorySearchResult } from "../memory/types.js";
export type {
MemoryFlushPlan,
MemoryFlushPlanResolver,

View File

@@ -14,7 +14,7 @@ export { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
export { loadConfig } from "../config/config.js";
export { resolveStateDir } from "../config/paths.js";
export { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
export { getMemorySearchManager } from "../../extensions/memory-core/src/memory/index.js";
export { getMemorySearchManager } from "../memory/index.js";
export { listMemoryFiles, normalizeExtraMemoryPaths } from "../memory/internal.js";
export { readAgentMemoryFile } from "../memory/read-file.js";
export { resolveMemoryBackendConfig } from "../memory/backend-config.js";

View File

@@ -1,3 +1,10 @@
import {
ADMIN_SCOPE,
APPROVALS_SCOPE,
PAIRING_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
} from "../gateway/method-scopes.js";
import { logVerbose } from "../globals.js";
import {
clearPluginCommands,
@@ -87,6 +94,13 @@ export function validateCommandName(name: string): string | null {
export function validatePluginCommandDefinition(
command: OpenClawPluginCommandDefinition,
): string | null {
const knownOperatorScopes = new Set([
ADMIN_SCOPE,
APPROVALS_SCOPE,
PAIRING_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
]);
if (typeof command.handler !== "function") {
return "Command handler must be a function";
}
@@ -112,6 +126,23 @@ export function validatePluginCommandDefinition(
return `Native command alias "${label}" invalid: ${aliasError}`;
}
}
if (
command.requiredGatewayScopes !== undefined &&
!Array.isArray(command.requiredGatewayScopes)
) {
return "Command requiredGatewayScopes must be an array";
}
if (
command.resolveRequiredGatewayScopes !== undefined &&
typeof command.resolveRequiredGatewayScopes !== "function"
) {
return "Command resolveRequiredGatewayScopes must be a function";
}
for (const scope of command.requiredGatewayScopes ?? []) {
if (!knownOperatorScopes.has(scope)) {
return `Command requiredGatewayScopes contains unknown scope "${scope}"`;
}
}
return null;
}

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import {
__testing,
@@ -52,6 +52,17 @@ describe("registerPluginCommand", () => {
ok: false,
error: "Command description must be a string",
});
const invalidRequiredGatewayScopes = registerPluginCommand("demo-plugin", {
name: "secure",
description: "Secure command",
requiredGatewayScopes: ["operator.nope" as never],
handler: async () => ({ text: "ok" }),
});
expect(invalidRequiredGatewayScopes).toEqual({
ok: false,
error: 'Command requiredGatewayScopes contains unknown scope "operator.nope"',
});
});
it("normalizes command metadata for downstream consumers", () => {
@@ -331,3 +342,171 @@ describe("registerPluginCommand", () => {
);
});
});
describe("executePluginCommand", () => {
it("enforces owner requirements before running the handler", async () => {
const handler = vi.fn(async () => ({ text: "ok" }));
const result = await executePluginCommand({
command: {
name: "owneronly",
description: "Owner-only command",
requireOwner: true,
handler,
pluginId: "demo-plugin",
},
channel: "discord",
senderId: "U123",
isAuthorizedSender: true,
senderIsOwner: false,
commandBody: "/owneronly",
config: {} as never,
});
expect(handler).not.toHaveBeenCalled();
expect(result).toEqual({ text: "⚠️ This command requires owner authorization." });
});
it("blocks internal callers missing required gateway scopes", async () => {
const handler = vi.fn(async () => ({ text: "ok" }));
const result = await executePluginCommand({
command: {
name: "pairing",
description: "Pairing command",
requiredGatewayScopes: ["operator.pairing"],
handler,
pluginId: "demo-plugin",
},
surface: "webchat",
channel: "webchat",
senderId: "writer-1",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.write"],
commandBody: "/pairing",
config: {} as never,
});
expect(handler).not.toHaveBeenCalled();
expect(result).toEqual({
text: "⚠️ This command requires operator.pairing for internal gateway callers.",
});
});
it("allows admin-scoped internal callers to bypass narrower scope requirements", async () => {
const handler = vi.fn(async () => ({ text: "ok" }));
const result = await executePluginCommand({
command: {
name: "pairing",
description: "Pairing command",
requiredGatewayScopes: ["operator.pairing"],
handler,
pluginId: "demo-plugin",
},
surface: "webchat",
channel: "webchat",
senderId: "admin-1",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.admin"],
commandBody: "/pairing",
config: {} as never,
});
expect(handler).toHaveBeenCalledTimes(1);
expect(result).toEqual({ text: "ok" });
});
it("allows external callers to bypass gateway scope requirements", async () => {
const handler = vi.fn(async () => ({ text: "ok" }));
const result = await executePluginCommand({
command: {
name: "pairing",
description: "Pairing command",
requiredGatewayScopes: ["operator.pairing"],
handler,
pluginId: "demo-plugin",
},
surface: "telegram",
channel: "telegram",
senderId: "123",
isAuthorizedSender: true,
commandBody: "/pairing",
config: {} as never,
});
expect(handler).toHaveBeenCalledTimes(1);
expect(result).toEqual({ text: "ok" });
});
it("supports context-sensitive gateway scope requirements", async () => {
const handler = vi.fn(async () => ({ text: "ok" }));
const command: Parameters<typeof executePluginCommand>[0]["command"] = {
name: "pair",
description: "Pair command",
resolveRequiredGatewayScopes: (ctx) => {
const action = ctx.args?.trim().split(/\s+/, 1)[0]?.toLowerCase();
return action === "approve" ? ["operator.pairing"] : undefined;
},
handler,
pluginId: "demo-plugin",
};
const denied = await executePluginCommand({
command,
surface: "webchat",
channel: "webchat",
senderId: "writer-1",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.write"],
args: "approve latest",
commandBody: "/pair approve latest",
config: {} as never,
});
const allowed = await executePluginCommand({
command,
surface: "webchat",
channel: "webchat",
senderId: "writer-1",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.write"],
args: "qr",
commandBody: "/pair qr",
config: {} as never,
});
expect(handler).toHaveBeenCalledTimes(1);
expect(denied).toEqual({
text: "⚠️ This command requires operator.pairing for internal gateway callers.",
});
expect(allowed).toEqual({ text: "ok" });
});
it("returns a safe error reply when dynamic scope resolution throws", async () => {
const handler = vi.fn(async () => ({ text: "ok" }));
const result = await executePluginCommand({
command: {
name: "pair",
description: "Pair command",
resolveRequiredGatewayScopes: () => {
throw new Error("resolver exploded");
},
handler,
pluginId: "demo-plugin",
},
surface: "webchat",
channel: "webchat",
senderId: "writer-1",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.write"],
commandBody: "/pair",
config: {} as never,
});
expect(handler).not.toHaveBeenCalled();
expect(result).toEqual({ text: "⚠️ Command failed. Please try again later." });
});
});

View File

@@ -7,7 +7,9 @@
import { parseExplicitTargetForChannel } from "../channels/plugins/target-parsing.js";
import type { OpenClawConfig } from "../config/config.js";
import { ADMIN_SCOPE, isOperatorScope, type OperatorScope } from "../gateway/method-scopes.js";
import { logVerbose } from "../globals.js";
import { isInternalMessageChannel } from "../utils/message-channel.js";
import {
clearPluginCommands,
clearPluginCommandsForPlugin,
@@ -28,6 +30,7 @@ import {
requestPluginConversationBinding,
} from "./conversation-binding.js";
import type {
PluginCommandAuthorizationContext,
OpenClawPluginCommandDefinition,
PluginCommandContext,
PluginCommandResult,
@@ -36,6 +39,25 @@ import type {
// Maximum allowed length for command arguments (defense in depth)
const MAX_ARGS_LENGTH = 4096;
function formatRequiredGatewayScopes(scopes: readonly string[]): string {
if (scopes.length === 0) {
return "gateway authorization";
}
if (scopes.length === 1) {
return scopes[0];
}
if (scopes.length === 2) {
return `${scopes[0]} and ${scopes[1]}`;
}
return `${scopes.slice(0, -1).join(", ")}, and ${scopes[scopes.length - 1]}`;
}
function buildMissingGatewayScopeReply(scopes: readonly string[]): PluginCommandResult {
return {
text: `⚠️ This command requires ${formatRequiredGatewayScopes(scopes)} for internal gateway callers.`,
};
}
export {
clearPluginCommands,
clearPluginCommandsForPlugin,
@@ -181,9 +203,11 @@ export async function executePluginCommand(params: {
command: RegisteredPluginCommand;
args?: string;
senderId?: string;
surface?: PluginCommandContext["surface"];
channel: string;
channelId?: PluginCommandContext["channelId"];
isAuthorizedSender: boolean;
senderIsOwner?: PluginCommandContext["senderIsOwner"];
gatewayClientScopes?: PluginCommandContext["gatewayClientScopes"];
commandBody: string;
config: OpenClawConfig;
@@ -192,7 +216,17 @@ export async function executePluginCommand(params: {
accountId?: PluginCommandContext["accountId"];
messageThreadId?: PluginCommandContext["messageThreadId"];
}): Promise<PluginCommandResult> {
const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = params;
const {
command,
args,
senderId,
channel,
isAuthorizedSender,
commandBody,
config,
senderIsOwner = false,
} = params;
const surface = params.surface ?? channel;
// Check authorization
const requireAuth = command.requireAuth !== false; // Default to true
@@ -202,9 +236,67 @@ export async function executePluginCommand(params: {
);
return { text: "⚠️ This command requires authorization." };
}
// Sanitize args before passing to handler
if (command.requireOwner && !senderIsOwner) {
logVerbose(
`Plugin command /${command.name} blocked: non-owner sender ${senderId || "<unknown>"}`,
);
return { text: "⚠️ This command requires owner authorization." };
}
const sanitizedArgs = sanitizeArgs(args);
const authContext: PluginCommandAuthorizationContext = {
senderId,
surface,
channel,
channelId: params.channelId,
isAuthorizedSender,
senderIsOwner,
gatewayClientScopes: params.gatewayClientScopes,
args: sanitizedArgs,
commandBody,
config,
from: params.from,
to: params.to,
accountId: params.accountId,
messageThreadId: params.messageThreadId,
};
let dynamicRequiredGatewayScopes: OperatorScope[] = [];
if (command.resolveRequiredGatewayScopes) {
try {
const resolvedScopes = command.resolveRequiredGatewayScopes(authContext) as
| readonly string[]
| undefined;
dynamicRequiredGatewayScopes = (resolvedScopes ?? []).filter(
(scope): scope is OperatorScope => {
if (isOperatorScope(scope)) {
return true;
}
logVerbose(`Plugin command /${command.name} ignored unknown dynamic scope "${scope}"`);
return false;
},
);
} catch (err) {
const error = err as Error;
logVerbose(`Plugin command /${command.name} scope resolver error: ${error.message}`);
return { text: "⚠️ Command failed. Please try again later." };
}
}
const requiredGatewayScopes = Array.from(
new Set([...(command.requiredGatewayScopes ?? []), ...dynamicRequiredGatewayScopes]),
);
if (
requiredGatewayScopes.length > 0 &&
isInternalMessageChannel(surface) &&
!requiredGatewayScopes.every(
(scope) =>
params.gatewayClientScopes?.includes(scope) ||
params.gatewayClientScopes?.includes(ADMIN_SCOPE),
)
) {
logVerbose(
`Plugin command /${command.name} blocked: gateway caller missing scope ${requiredGatewayScopes.join(", ")}`,
);
return buildMissingGatewayScopeReply(requiredGatewayScopes);
}
const bindingConversation = resolveBindingConversationFromCommand({
channel,
from: params.from,
@@ -215,9 +307,11 @@ export async function executePluginCommand(params: {
const ctx: PluginCommandContext = {
senderId,
surface,
channel,
channelId: params.channelId,
isAuthorizedSender,
senderIsOwner,
gatewayClientScopes: params.gatewayClientScopes,
args: sanitizedArgs,
commandBody,

View File

@@ -26,8 +26,3 @@ export async function getActiveMemorySearchManager(params: {
export function resolveActiveMemoryBackendConfig(params: { cfg: OpenClawConfig; agentId: string }) {
return ensureMemoryRuntime(params.cfg)?.resolveMemoryBackendConfig(params) ?? null;
}
export async function closeActiveMemorySearchManagers(cfg?: OpenClawConfig): Promise<void> {
const runtime = ensureMemoryRuntime(cfg);
await runtime?.closeAllMemorySearchManagers?.();
}

View File

@@ -56,7 +56,6 @@ export type MemoryPluginRuntime = {
cfg: OpenClawConfig;
agentId: string;
}): MemoryRuntimeBackendConfig;
closeAllMemorySearchManagers?(): Promise<void>;
};
type MemoryPluginState = {

View File

@@ -984,14 +984,18 @@ export type OpenClawPluginGatewayMethod = {
export type PluginCommandContext = {
/** The sender's identifier (e.g., Telegram user ID) */
senderId?: string;
/** The inbound command surface (e.g., "webchat", "telegram") */
surface: string;
/** The channel/surface (e.g., "telegram", "discord") */
channel: string;
/** Provider channel id (e.g., "telegram") */
channelId?: ChannelId;
/** Whether the sender is on the allowlist */
isAuthorizedSender: boolean;
/** Whether the sender is treated as an owner-level caller */
senderIsOwner: boolean;
/** Gateway client scopes for internal control-plane callers */
gatewayClientScopes?: string[];
gatewayClientScopes?: OperatorScope[];
/** Raw command arguments after the command name */
args?: string;
/** The full normalized command body */
@@ -1068,6 +1072,24 @@ export type PluginConversationBindingResolvedEvent = {
};
};
export type PluginCommandAuthorizationContext = Pick<
PluginCommandContext,
| "senderId"
| "surface"
| "channel"
| "channelId"
| "isAuthorizedSender"
| "senderIsOwner"
| "gatewayClientScopes"
| "args"
| "commandBody"
| "config"
| "from"
| "to"
| "accountId"
| "messageThreadId"
>;
/**
* Result returned by a plugin command handler.
*/
@@ -1098,6 +1120,14 @@ export type OpenClawPluginCommandDefinition = {
acceptsArgs?: boolean;
/** Whether only authorized senders can use this command (default: true) */
requireAuth?: boolean;
/** Whether only owner-level callers can use this command (default: false) */
requireOwner?: boolean;
/** Gateway scopes required for internal control-plane callers */
requiredGatewayScopes?: readonly OperatorScope[];
/** Context-sensitive gateway scopes required for internal control-plane callers */
resolveRequiredGatewayScopes?: (
ctx: PluginCommandAuthorizationContext,
) => readonly OperatorScope[] | undefined;
/** The handler function */
handler: PluginCommandHandler;
};

View File

@@ -54,4 +54,25 @@ export function collectTtsApiKeyAssignments(params: {
}
return;
}
// Legacy compatibility until migrated configs have been rewritten on disk.
const legacyProviders = ["elevenlabs", "openai"] as const;
for (const providerId of legacyProviders) {
const providerConfig = params.tts[providerId];
if (!isRecord(providerConfig)) {
continue;
}
collectSecretInputAssignment({
value: providerConfig.apiKey,
path: `${params.pathPrefix}.${providerId}.apiKey`,
expected: "string",
defaults: params.defaults,
context: params.context,
active: params.active,
inactiveReason: params.inactiveReason,
apply: (value) => {
providerConfig.apiKey = value;
},
});
}
}

View File

@@ -1949,22 +1949,20 @@ describe("secrets runtime snapshot", () => {
loadAuthStore: () => ({ version: 1, profiles: {} }),
});
expect(snapshot.config.channels?.discord?.voice?.tts?.providers?.openai?.apiKey).toEqual({
expect(snapshot.config.channels?.discord?.voice?.tts?.openai?.apiKey).toEqual({
source: "env",
provider: "default",
id: "MISSING_DISCORD_VOICE_TTS_OPENAI",
});
expect(
snapshot.config.channels?.discord?.accounts?.work?.voice?.tts?.providers?.openai?.apiKey,
).toEqual({
expect(snapshot.config.channels?.discord?.accounts?.work?.voice?.tts?.openai?.apiKey).toEqual({
source: "env",
provider: "default",
id: "MISSING_DISCORD_WORK_VOICE_TTS_OPENAI",
});
expect(snapshot.warnings.map((warning) => warning.path)).toEqual(
expect.arrayContaining([
"channels.discord.voice.tts.providers.openai.apiKey",
"channels.discord.accounts.work.voice.tts.providers.openai.apiKey",
"channels.discord.voice.tts.openai.apiKey",
"channels.discord.accounts.work.voice.tts.openai.apiKey",
]),
);
});
@@ -1976,10 +1974,8 @@ describe("secrets runtime snapshot", () => {
discord: {
voice: {
tts: {
providers: {
openai: {
apiKey: { source: "env", provider: "default", id: "DISCORD_BASE_TTS_OPENAI" },
},
openai: {
apiKey: { source: "env", provider: "default", id: "DISCORD_BASE_TTS_OPENAI" },
},
},
},
@@ -1994,13 +1990,11 @@ describe("secrets runtime snapshot", () => {
enabled: true,
voice: {
tts: {
providers: {
openai: {
apiKey: {
source: "env",
provider: "default",
id: "DISCORD_ENABLED_OVERRIDE_TTS_OPENAI",
},
openai: {
apiKey: {
source: "env",
provider: "default",
id: "DISCORD_ENABLED_OVERRIDE_TTS_OPENAI",
},
},
},
@@ -2010,13 +2004,11 @@ describe("secrets runtime snapshot", () => {
enabled: false,
voice: {
tts: {
providers: {
openai: {
apiKey: {
source: "env",
provider: "default",
id: "DISCORD_DISABLED_OVERRIDE_TTS_OPENAI",
},
openai: {
apiKey: {
source: "env",
provider: "default",
id: "DISCORD_DISABLED_OVERRIDE_TTS_OPENAI",
},
},
},
@@ -2042,17 +2034,13 @@ describe("secrets runtime snapshot", () => {
loadAuthStore: () => ({ version: 1, profiles: {} }),
});
expect(snapshot.config.channels?.discord?.voice?.tts?.providers?.openai?.apiKey).toBe(
"base-tts-openai",
);
expect(snapshot.config.channels?.discord?.voice?.tts?.openai?.apiKey).toBe("base-tts-openai");
expect(snapshot.config.channels?.discord?.pluralkit?.token).toBe("base-pk-token");
expect(
snapshot.config.channels?.discord?.accounts?.enabledOverride?.voice?.tts?.providers?.openai
?.apiKey,
snapshot.config.channels?.discord?.accounts?.enabledOverride?.voice?.tts?.openai?.apiKey,
).toBe("enabled-override-tts-openai");
expect(
snapshot.config.channels?.discord?.accounts?.disabledOverride?.voice?.tts?.providers?.openai
?.apiKey,
snapshot.config.channels?.discord?.accounts?.disabledOverride?.voice?.tts?.openai?.apiKey,
).toEqual({
source: "env",
provider: "default",
@@ -2067,7 +2055,7 @@ describe("secrets runtime snapshot", () => {
);
expect(snapshot.warnings.map((warning) => warning.path)).toEqual(
expect.arrayContaining([
"channels.discord.accounts.disabledOverride.voice.tts.providers.openai.apiKey",
"channels.discord.accounts.disabledOverride.voice.tts.openai.apiKey",
"channels.discord.accounts.disabledOverride.pluralkit.token",
]),
);
@@ -2080,13 +2068,11 @@ describe("secrets runtime snapshot", () => {
discord: {
voice: {
tts: {
providers: {
openai: {
apiKey: {
source: "env",
provider: "default",
id: "DISCORD_UNUSED_BASE_TTS_OPENAI",
},
openai: {
apiKey: {
source: "env",
provider: "default",
id: "DISCORD_UNUSED_BASE_TTS_OPENAI",
},
},
},
@@ -2096,13 +2082,11 @@ describe("secrets runtime snapshot", () => {
enabled: true,
voice: {
tts: {
providers: {
openai: {
apiKey: {
source: "env",
provider: "default",
id: "DISCORD_ENABLED_ONLY_TTS_OPENAI",
},
openai: {
apiKey: {
source: "env",
provider: "default",
id: "DISCORD_ENABLED_ONLY_TTS_OPENAI",
},
},
},
@@ -2123,16 +2107,15 @@ describe("secrets runtime snapshot", () => {
});
expect(
snapshot.config.channels?.discord?.accounts?.enabledOverride?.voice?.tts?.providers?.openai
?.apiKey,
snapshot.config.channels?.discord?.accounts?.enabledOverride?.voice?.tts?.openai?.apiKey,
).toBe("enabled-only-tts-openai");
expect(snapshot.config.channels?.discord?.voice?.tts?.providers?.openai?.apiKey).toEqual({
expect(snapshot.config.channels?.discord?.voice?.tts?.openai?.apiKey).toEqual({
source: "env",
provider: "default",
id: "DISCORD_UNUSED_BASE_TTS_OPENAI",
});
expect(snapshot.warnings.map((warning) => warning.path)).toContain(
"channels.discord.voice.tts.providers.openai.apiKey",
"channels.discord.voice.tts.openai.apiKey",
);
});
@@ -2144,10 +2127,8 @@ describe("secrets runtime snapshot", () => {
discord: {
voice: {
tts: {
providers: {
openai: {
apiKey: { source: "env", provider: "default", id: "DISCORD_BASE_TTS_OK" },
},
openai: {
apiKey: { source: "env", provider: "default", id: "DISCORD_BASE_TTS_OK" },
},
},
},
@@ -2156,13 +2137,11 @@ describe("secrets runtime snapshot", () => {
enabled: true,
voice: {
tts: {
providers: {
openai: {
apiKey: {
source: "env",
provider: "default",
id: "DISCORD_ENABLED_OVERRIDE_TTS_MISSING",
},
openai: {
apiKey: {
source: "env",
provider: "default",
id: "DISCORD_ENABLED_OVERRIDE_TTS_MISSING",
},
},
},

View File

@@ -12,7 +12,6 @@ import {
setRuntimeConfigSnapshot,
type OpenClawConfig,
} from "../config/config.js";
import { migrateLegacyConfig } from "../config/legacy-migrate.js";
import { resolveUserPath } from "../utils.js";
import {
collectCommandSecretAssignmentsFromSnapshot,
@@ -140,9 +139,7 @@ export async function prepareSecretsRuntimeSnapshot(params: {
}): Promise<PreparedSecretsRuntimeSnapshot> {
const runtimeEnv = mergeSecretsRuntimeEnv(params.env);
const sourceConfig = structuredClone(params.config);
const resolvedConfig = structuredClone(
migrateLegacyConfig(params.config).config ?? params.config,
);
const resolvedConfig = structuredClone(params.config);
const context = createResolverContext({
sourceConfig,
env: runtimeEnv,

View File

@@ -4,6 +4,7 @@ import { listSpeechProviders } from "./provider-registry.js";
import type {
SpeechModelOverridePolicy,
SpeechProviderConfig,
SpeechProviderOverrides,
TtsDirectiveOverrides,
TtsDirectiveParseResult,
} from "./provider-types.js";
@@ -12,6 +13,7 @@ type ParseTtsDirectiveOptions = {
cfg?: OpenClawConfig;
providers?: readonly SpeechProviderPlugin[];
providerConfigs?: Record<string, SpeechProviderConfig>;
openaiBaseUrl?: string;
};
function buildProviderOrder(left: SpeechProviderPlugin, right: SpeechProviderPlugin): number {
@@ -34,18 +36,49 @@ function resolveDirectiveProviderConfig(
provider: SpeechProviderPlugin,
options?: ParseTtsDirectiveOptions,
): SpeechProviderConfig | undefined {
return options?.providerConfigs?.[provider.id];
const explicit = options?.providerConfigs?.[provider.id];
if (explicit) {
return explicit;
}
if (provider.id === "openai" && options?.openaiBaseUrl) {
return { baseUrl: options.openaiBaseUrl };
}
return undefined;
}
function mergeProviderOverrides(
target: TtsDirectiveOverrides,
providerId: string,
next: SpeechProviderOverrides,
): void {
target.providerOverrides = {
...target.providerOverrides,
[providerId]: {
...target.providerOverrides?.[providerId],
...next,
},
};
}
function resolveLegacyOptions(
optionsOrOpenaiBaseUrl?: ParseTtsDirectiveOptions | string,
): ParseTtsDirectiveOptions | undefined {
if (typeof optionsOrOpenaiBaseUrl === "string") {
return { openaiBaseUrl: optionsOrOpenaiBaseUrl };
}
return optionsOrOpenaiBaseUrl;
}
export function parseTtsDirectives(
text: string,
policy: SpeechModelOverridePolicy,
options?: ParseTtsDirectiveOptions,
optionsOrOpenaiBaseUrl?: ParseTtsDirectiveOptions | string,
): TtsDirectiveParseResult {
if (!policy.enabled) {
return { cleanedText: text, overrides: {}, warnings: [], hasDirective: false };
}
const options = resolveLegacyOptions(optionsOrOpenaiBaseUrl);
const providers = resolveDirectiveProviders(options);
const overrides: TtsDirectiveOverrides = {};
const warnings: string[] = [];
@@ -102,13 +135,7 @@ export function parseTtsDirectives(
}
handled = true;
if (parsed.overrides) {
overrides.providerOverrides = {
...overrides.providerOverrides,
[provider.id]: {
...overrides.providerOverrides?.[provider.id],
...parsed.overrides,
},
};
mergeProviderOverrides(overrides, provider.id, parsed.overrides);
}
if (parsed.warnings?.length) {
warnings.push(...parsed.warnings);

View File

@@ -86,6 +86,7 @@ const {
parseTtsDirectives,
resolveModelOverridePolicy,
summarizeText,
resolveOutputFormat,
getResolvedSpeechProviderConfig,
} = _test;
@@ -233,6 +234,65 @@ describe("tts", () => {
});
});
describe("resolveOutputFormat", () => {
it("selects opus for opus channels (telegram/feishu/whatsapp/matrix) and mp3 for others", () => {
const cases = [
{
channel: "telegram",
expected: {
openai: "opus",
elevenlabs: "opus_48000_64",
extension: ".opus",
voiceCompatible: true,
},
},
{
channel: "feishu",
expected: {
openai: "opus",
elevenlabs: "opus_48000_64",
extension: ".opus",
voiceCompatible: true,
},
},
{
channel: "whatsapp",
expected: {
openai: "opus",
elevenlabs: "opus_48000_64",
extension: ".opus",
voiceCompatible: true,
},
},
{
channel: "matrix",
expected: {
openai: "opus",
elevenlabs: "opus_48000_64",
extension: ".opus",
voiceCompatible: true,
},
},
{
channel: "discord",
expected: {
openai: "mp3",
elevenlabs: "mp3_44100_128",
extension: ".mp3",
voiceCompatible: false,
},
},
] as const;
for (const testCase of cases) {
const output = resolveOutputFormat(testCase.channel);
expect(output.openai, testCase.channel).toBe(testCase.expected.openai);
expect(output.elevenlabs, testCase.channel).toBe(testCase.expected.elevenlabs);
expect(output.extension, testCase.channel).toBe(testCase.expected.extension);
expect(output.voiceCompatible, testCase.channel).toBe(testCase.expected.voiceCompatible);
}
});
});
describe("resolveEdgeOutputFormat", () => {
const baseCfg: OpenClawConfig = {
agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } },
@@ -323,11 +383,9 @@ describe("tts", () => {
it("accepts custom voices and models when openaiBaseUrl is a non-default endpoint", () => {
const policy = resolveModelOverridePolicy({ enabled: true });
const input = "Hello [[tts:voice=kokoro-chinese model=kokoro-v1]] world";
const result = parseTtsDirectives(input, policy, {
providerConfigs: {
openai: { baseUrl: "http://localhost:8880/v1" },
},
});
const customBaseUrl = "http://localhost:8880/v1";
const result = parseTtsDirectives(input, policy, customBaseUrl);
const openaiOverrides = result.overrides.providerOverrides?.openai as
| { voice?: string; model?: string }
| undefined;
@@ -340,11 +398,9 @@ describe("tts", () => {
it("rejects unknown voices and models when openaiBaseUrl is the default OpenAI endpoint", () => {
const policy = resolveModelOverridePolicy({ enabled: true });
const input = "Hello [[tts:voice=kokoro-chinese model=kokoro-v1]] world";
const result = parseTtsDirectives(input, policy, {
providerConfigs: {
openai: { baseUrl: "https://api.openai.com/v1" },
},
});
const defaultBaseUrl = "https://api.openai.com/v1";
const result = parseTtsDirectives(input, policy, defaultBaseUrl);
const openaiOverrides = result.overrides.providerOverrides?.openai as
| { voice?: string }
| undefined;

View File

@@ -48,6 +48,22 @@ const DEFAULT_TTS_MAX_LENGTH = 1500;
const DEFAULT_TTS_SUMMARIZE = true;
const DEFAULT_MAX_TEXT_LENGTH = 4096;
const OPUS_OUTPUT = {
openai: "opus" as const,
// ElevenLabs output formats use codec_sample_rate_bitrate naming.
// Opus @ 48kHz/64kbps is a good voice message tradeoff.
elevenlabs: "opus_48000_64",
extension: ".opus",
voiceCompatible: true,
};
const DEFAULT_OUTPUT = {
openai: "mp3" as const,
elevenlabs: "mp3_44100_128",
extension: ".mp3",
voiceCompatible: false,
};
export type ResolvedTtsConfig = {
auto: TtsAutoMode;
mode: TtsMode;
@@ -402,9 +418,16 @@ export function setLastTtsAttempt(entry: TtsStatusEntry | undefined): void {
lastTtsAttempt = entry;
}
/** Channels that require voice-note-compatible audio */
/** Channels that require opus audio */
const OPUS_CHANNELS = new Set(["telegram", "feishu", "whatsapp", "matrix"]);
function resolveOutputFormat(channelId?: string | null) {
if (channelId && OPUS_CHANNELS.has(channelId)) {
return OPUS_OUTPUT;
}
return DEFAULT_OUTPUT;
}
function resolveChannelId(channel: string | undefined): ChannelId | null {
return channel ? normalizeChannelId(channel) : null;
}
@@ -853,5 +876,6 @@ export const _test = {
parseTtsDirectives,
resolveModelOverridePolicy,
summarizeText,
resolveOutputFormat,
getResolvedSpeechProviderConfig,
};

View File

@@ -67,26 +67,10 @@ const targetedUnitProxyFiles = [
const REPO_ROOT = path.resolve(import.meta.dirname, "../..");
function createPlannerEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
return {
...clearPlannerShardEnv(process.env),
...overrides,
};
}
function createLocalPlannerEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
return createPlannerEnv({
CI: "",
GITHUB_ACTIONS: "",
OPENCLAW_TEST_LOAD_AWARE: "0",
...overrides,
});
}
function runPlannerPlan(args: string[], envOverrides: NodeJS.ProcessEnv = {}): string {
function runPlannerPlan(args: string[], env: NodeJS.ProcessEnv): string {
return execFileSync("node", ["scripts/test-parallel.mjs", ...args], {
cwd: REPO_ROOT,
env: createPlannerEnv(envOverrides),
env,
encoding: "utf8",
});
}
@@ -94,11 +78,15 @@ function runPlannerPlan(args: string[], envOverrides: NodeJS.ProcessEnv = {}): s
function runHighMemoryLocalMultiSurfacePlan(): string {
return runPlannerPlan(
["--plan", "--surface", "unit", "--surface", "extensions", "--surface", "channels"],
createLocalPlannerEnv({
{
...clearPlannerShardEnv(process.env),
CI: "",
GITHUB_ACTIONS: "",
RUNNER_OS: "macOS",
OPENCLAW_TEST_HOST_CPU_COUNT: "12",
OPENCLAW_TEST_HOST_MEMORY_GIB: "128",
}),
OPENCLAW_TEST_LOAD_AWARE: "0",
},
);
}
@@ -203,8 +191,14 @@ describe("scripts/test-parallel memory trace parsing", () => {
describe("scripts/test-parallel lane planning", () => {
it("keeps serial profile on split unit lanes instead of one giant unit worker", () => {
const output = runPlannerPlan(["--plan"], {
OPENCLAW_TEST_PROFILE: "serial",
const repoRoot = path.resolve(import.meta.dirname, "../..");
const output = execFileSync("node", ["scripts/test-parallel.mjs", "--plan"], {
cwd: repoRoot,
env: {
...clearPlannerShardEnv(process.env),
OPENCLAW_TEST_PROFILE: "serial",
},
encoding: "utf8",
});
expect(output).toContain("unit-fast");
@@ -212,10 +206,16 @@ describe("scripts/test-parallel lane planning", () => {
});
it("recycles default local unit-fast runs into bounded batches", () => {
const output = runPlannerPlan(["--plan"], {
CI: "",
OPENCLAW_TEST_UNIT_FAST_LANES: "1",
OPENCLAW_TEST_UNIT_FAST_BATCH_TARGET_MS: "1",
const repoRoot = path.resolve(import.meta.dirname, "../..");
const output = execFileSync("node", ["scripts/test-parallel.mjs", "--plan"], {
cwd: repoRoot,
env: {
...clearPlannerShardEnv(process.env),
CI: "",
OPENCLAW_TEST_UNIT_FAST_LANES: "1",
OPENCLAW_TEST_UNIT_FAST_BATCH_TARGET_MS: "1",
},
encoding: "utf8",
});
expect(output).toContain("unit-fast-batch-");
@@ -223,24 +223,44 @@ describe("scripts/test-parallel lane planning", () => {
});
it("keeps legacy base-pinned targeted reruns on dedicated forks lanes", () => {
const output = runPlannerPlan([
"--plan",
"--files",
"src/auto-reply/reply/followup-runner.test.ts",
]);
const repoRoot = path.resolve(import.meta.dirname, "../..");
const output = execFileSync(
"node",
[
"scripts/test-parallel.mjs",
"--plan",
"--files",
"src/auto-reply/reply/followup-runner.test.ts",
],
{
cwd: repoRoot,
env: clearPlannerShardEnv(process.env),
encoding: "utf8",
},
);
expect(output).toContain("base-pinned-followup-runner");
expect(output).not.toContain("base-followup-runner");
});
it("reports capability-derived output for mid-memory local macOS hosts", () => {
const output = runPlannerPlan(
["--plan", "--surface", "unit", "--surface", "extensions"],
createLocalPlannerEnv({
RUNNER_OS: "macOS",
OPENCLAW_TEST_HOST_CPU_COUNT: "10",
OPENCLAW_TEST_HOST_MEMORY_GIB: "64",
}),
const repoRoot = path.resolve(import.meta.dirname, "../..");
const output = execFileSync(
"node",
["scripts/test-parallel.mjs", "--plan", "--surface", "unit", "--surface", "extensions"],
{
cwd: repoRoot,
env: {
...clearPlannerShardEnv(process.env),
CI: "",
GITHUB_ACTIONS: "",
RUNNER_OS: "macOS",
OPENCLAW_TEST_HOST_CPU_COUNT: "10",
OPENCLAW_TEST_HOST_MEMORY_GIB: "64",
OPENCLAW_TEST_LOAD_AWARE: "0",
},
encoding: "utf8",
},
);
expect(output).toContain("mode=local intent=normal memoryBand=mid");
@@ -249,29 +269,47 @@ describe("scripts/test-parallel lane planning", () => {
});
it("uses higher shared extension worker counts on high-memory local hosts", () => {
const highMemoryOutput = runPlannerPlan(
["--plan", "--surface", "extensions"],
createLocalPlannerEnv({
RUNNER_OS: "macOS",
OPENCLAW_TEST_HOST_CPU_COUNT: "12",
OPENCLAW_TEST_HOST_MEMORY_GIB: "128",
}),
const repoRoot = path.resolve(import.meta.dirname, "../..");
const highMemoryOutput = execFileSync(
"node",
["scripts/test-parallel.mjs", "--plan", "--surface", "extensions"],
{
cwd: repoRoot,
env: {
...clearPlannerShardEnv(process.env),
CI: "",
GITHUB_ACTIONS: "",
RUNNER_OS: "macOS",
OPENCLAW_TEST_HOST_CPU_COUNT: "12",
OPENCLAW_TEST_HOST_MEMORY_GIB: "128",
OPENCLAW_TEST_LOAD_AWARE: "0",
},
encoding: "utf8",
},
);
const midMemoryOutput = runPlannerPlan(
["--plan", "--surface", "extensions"],
createLocalPlannerEnv({
RUNNER_OS: "macOS",
OPENCLAW_TEST_HOST_CPU_COUNT: "10",
OPENCLAW_TEST_HOST_MEMORY_GIB: "64",
}),
const midMemoryOutput = execFileSync(
"node",
["scripts/test-parallel.mjs", "--plan", "--surface", "extensions"],
{
cwd: repoRoot,
env: {
...clearPlannerShardEnv(process.env),
CI: "",
GITHUB_ACTIONS: "",
RUNNER_OS: "macOS",
OPENCLAW_TEST_HOST_CPU_COUNT: "10",
OPENCLAW_TEST_HOST_MEMORY_GIB: "64",
OPENCLAW_TEST_LOAD_AWARE: "0",
},
encoding: "utf8",
},
);
expect(midMemoryOutput).toContain("extensions-batch-1 filters=all maxWorkers=3");
expect(midMemoryOutput).toContain("extensions-batch-2 filters=all maxWorkers=3");
expect(midMemoryOutput).not.toContain("extensions-batch-3");
expect(midMemoryOutput).toContain("extensions-batch-3 filters=all maxWorkers=3");
expect(highMemoryOutput).toContain("extensions-batch-1 filters=all maxWorkers=5");
expect(highMemoryOutput).toContain("extensions-batch-2 filters=all maxWorkers=5");
expect(highMemoryOutput).not.toContain("extensions-batch-3");
expect(highMemoryOutput).toContain("extensions-batch-3 filters=all maxWorkers=5");
expect(highMemoryOutput).not.toContain("extensions-batch-4");
});
it("starts isolated channel lanes before shared extension batches on high-memory local hosts", () => {
@@ -296,18 +334,29 @@ describe("scripts/test-parallel lane planning", () => {
});
it("uses earlier targeted channel batching on high-memory local hosts", () => {
const output = runPlannerPlan(
const repoRoot = path.resolve(import.meta.dirname, "../..");
const output = execFileSync(
"node",
[
"scripts/test-parallel.mjs",
"--plan",
"--surface",
"channels",
...targetedChannelProxyFiles.flatMap((file) => ["--files", file]),
],
createLocalPlannerEnv({
RUNNER_OS: "macOS",
OPENCLAW_TEST_HOST_CPU_COUNT: "12",
OPENCLAW_TEST_HOST_MEMORY_GIB: "128",
}),
{
cwd: repoRoot,
env: {
...clearPlannerShardEnv(process.env),
CI: "",
GITHUB_ACTIONS: "",
RUNNER_OS: "macOS",
OPENCLAW_TEST_HOST_CPU_COUNT: "12",
OPENCLAW_TEST_HOST_MEMORY_GIB: "128",
OPENCLAW_TEST_LOAD_AWARE: "0",
},
encoding: "utf8",
},
);
expect(output).toContain("channels-batch-1 filters=33");
@@ -316,18 +365,29 @@ describe("scripts/test-parallel lane planning", () => {
});
it("uses targeted unit batching on high-memory local hosts", () => {
const output = runPlannerPlan(
const repoRoot = path.resolve(import.meta.dirname, "../..");
const output = execFileSync(
"node",
[
"scripts/test-parallel.mjs",
"--plan",
"--surface",
"unit",
...targetedUnitProxyFiles.flatMap((file) => ["--files", file]),
],
createLocalPlannerEnv({
RUNNER_OS: "macOS",
OPENCLAW_TEST_HOST_CPU_COUNT: "12",
OPENCLAW_TEST_HOST_MEMORY_GIB: "128",
}),
{
cwd: repoRoot,
env: {
...clearPlannerShardEnv(process.env),
CI: "",
GITHUB_ACTIONS: "",
RUNNER_OS: "macOS",
OPENCLAW_TEST_HOST_CPU_COUNT: "12",
OPENCLAW_TEST_HOST_MEMORY_GIB: "128",
OPENCLAW_TEST_LOAD_AWARE: "0",
},
encoding: "utf8",
},
);
expect(output).toContain("unit-batch-1 filters=50");
@@ -336,7 +396,16 @@ describe("scripts/test-parallel lane planning", () => {
});
it("explains targeted file ownership and execution policy", () => {
const output = runPlannerPlan(["--explain", "src/auto-reply/reply/followup-runner.test.ts"]);
const repoRoot = path.resolve(import.meta.dirname, "../..");
const output = execFileSync(
"node",
["scripts/test-parallel.mjs", "--explain", "src/auto-reply/reply/followup-runner.test.ts"],
{
cwd: repoRoot,
env: clearPlannerShardEnv(process.env),
encoding: "utf8",
},
);
expect(output).toContain("surface=base");
expect(output).toContain("reasons=base-surface,base-pinned-manifest");
@@ -344,17 +413,23 @@ describe("scripts/test-parallel lane planning", () => {
});
it("prints the planner-backed CI manifest as JSON", () => {
const output = runPlannerPlan(["--ci-manifest"], {
GITHUB_EVENT_NAME: "pull_request",
OPENCLAW_CI_DOCS_ONLY: "false",
OPENCLAW_CI_DOCS_CHANGED: "false",
OPENCLAW_CI_RUN_NODE: "true",
OPENCLAW_CI_RUN_MACOS: "true",
OPENCLAW_CI_RUN_ANDROID: "false",
OPENCLAW_CI_RUN_WINDOWS: "true",
OPENCLAW_CI_RUN_SKILLS_PYTHON: "false",
OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: "false",
OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: '{"include":[]}',
const repoRoot = path.resolve(import.meta.dirname, "../..");
const output = execFileSync("node", ["scripts/test-parallel.mjs", "--ci-manifest"], {
cwd: repoRoot,
env: {
...clearPlannerShardEnv(process.env),
GITHUB_EVENT_NAME: "pull_request",
OPENCLAW_CI_DOCS_ONLY: "false",
OPENCLAW_CI_DOCS_CHANGED: "false",
OPENCLAW_CI_RUN_NODE: "true",
OPENCLAW_CI_RUN_MACOS: "true",
OPENCLAW_CI_RUN_ANDROID: "false",
OPENCLAW_CI_RUN_WINDOWS: "true",
OPENCLAW_CI_RUN_SKILLS_PYTHON: "false",
OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: "false",
OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: '{"include":[]}',
},
encoding: "utf8",
});
const manifest = JSON.parse(output);
@@ -438,13 +513,28 @@ describe("scripts/test-parallel lane planning", () => {
});
it("passes through vitest --mode values that are not wrapper runtime overrides", () => {
const output = runPlannerPlan(
["--plan", "--mode", "development", "src/infra/outbound/deliver.test.ts"],
createLocalPlannerEnv({
RUNNER_OS: "Linux",
OPENCLAW_TEST_HOST_CPU_COUNT: "16",
OPENCLAW_TEST_HOST_MEMORY_GIB: "128",
}),
const repoRoot = path.resolve(import.meta.dirname, "../..");
const output = execFileSync(
"node",
[
"scripts/test-parallel.mjs",
"--plan",
"--mode",
"development",
"src/infra/outbound/deliver.test.ts",
],
{
cwd: repoRoot,
env: {
...clearPlannerShardEnv(process.env),
CI: "",
GITHUB_ACTIONS: "",
RUNNER_OS: "Linux",
OPENCLAW_TEST_HOST_CPU_COUNT: "16",
OPENCLAW_TEST_HOST_MEMORY_GIB: "128",
},
encoding: "utf8",
},
);
expect(output).toContain("mode=local intent=normal memoryBand=high");
@@ -452,43 +542,90 @@ describe("scripts/test-parallel lane planning", () => {
});
it("rejects removed machine-name profiles", () => {
expect(() => runPlannerPlan(["--plan", "--profile", "macmini"])).toThrowError(
/Unsupported test profile "macmini"/u,
);
const repoRoot = path.resolve(import.meta.dirname, "../..");
expect(() =>
execFileSync("node", ["scripts/test-parallel.mjs", "--plan", "--profile", "macmini"], {
cwd: repoRoot,
env: clearPlannerShardEnv(process.env),
encoding: "utf8",
}),
).toThrowError(/Unsupported test profile "macmini"/u);
});
it("rejects unknown explicit surface names", () => {
expect(() => runPlannerPlan(["--plan", "--surface", "channel"])).toThrowError(
/Unsupported --surface value\(s\): channel/u,
);
const repoRoot = path.resolve(import.meta.dirname, "../..");
expect(() =>
execFileSync("node", ["scripts/test-parallel.mjs", "--plan", "--surface", "channel"], {
cwd: repoRoot,
env: clearPlannerShardEnv(process.env),
encoding: "utf8",
}),
).toThrowError(/Unsupported --surface value\(s\): channel/u);
});
it("rejects wrapper --files values that look like options", () => {
expect(() => runPlannerPlan(["--plan", "--files", "--config"])).toThrowError(
/Invalid --files value/u,
);
const repoRoot = path.resolve(import.meta.dirname, "../..");
expect(() =>
execFileSync("node", ["scripts/test-parallel.mjs", "--plan", "--files", "--config"], {
cwd: repoRoot,
env: clearPlannerShardEnv(process.env),
encoding: "utf8",
}),
).toThrowError(/Invalid --files value/u);
});
it("rejects missing --profile values", () => {
expect(() => runPlannerPlan(["--plan", "--profile"])).toThrowError(/Invalid --profile value/u);
const repoRoot = path.resolve(import.meta.dirname, "../..");
expect(() =>
execFileSync("node", ["scripts/test-parallel.mjs", "--plan", "--profile"], {
cwd: repoRoot,
env: clearPlannerShardEnv(process.env),
encoding: "utf8",
}),
).toThrowError(/Invalid --profile value/u);
});
it("rejects missing --surface values", () => {
expect(() => runPlannerPlan(["--plan", "--surface"])).toThrowError(/Invalid --surface value/u);
const repoRoot = path.resolve(import.meta.dirname, "../..");
expect(() =>
execFileSync("node", ["scripts/test-parallel.mjs", "--plan", "--surface"], {
cwd: repoRoot,
env: clearPlannerShardEnv(process.env),
encoding: "utf8",
}),
).toThrowError(/Invalid --surface value/u);
});
it("rejects missing --explain values", () => {
expect(() => runPlannerPlan(["--explain"])).toThrowError(/Invalid --explain value/u);
const repoRoot = path.resolve(import.meta.dirname, "../..");
expect(() =>
execFileSync("node", ["scripts/test-parallel.mjs", "--explain"], {
cwd: repoRoot,
env: clearPlannerShardEnv(process.env),
encoding: "utf8",
}),
).toThrowError(/Invalid --explain value/u);
});
it("rejects explicit existing files that are not known test files", () => {
const repoRoot = path.resolve(import.meta.dirname, "../..");
const tempFilePath = path.join(os.tmpdir(), `openclaw-non-test-${Date.now()}.ts`);
fs.writeFileSync(tempFilePath, "export const notATest = true;\n", "utf8");
try {
expect(() => runPlannerPlan(["--plan", "--files", tempFilePath])).toThrowError(
/is not a known test file/u,
);
expect(() =>
execFileSync("node", ["scripts/test-parallel.mjs", "--plan", "--files", tempFilePath], {
cwd: repoRoot,
env: clearPlannerShardEnv(process.env),
encoding: "utf8",
}),
).toThrowError(/is not a known test file/u);
} finally {
fs.rmSync(tempFilePath, { force: true });
}

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