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
181 changed files with 2002 additions and 1612 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
@@ -62,8 +61,6 @@ Docs: https://docs.openclaw.ai
- Agents/failover: classify Codex accountId token extraction failures as auth errors so model fallback continues to the next configured candidate. (#55206) Thanks @cosmicnet.
- Talk/macOS: stop direct system-voice failures from replaying system speech, use app-locale fallback for shared watchdog timing, and add regression coverage for the macOS fallback route and language-aware timeout policy. (#53511) thanks @hongsw.
- Discord/gateway cleanup: keep late Carbon reconnect-exhausted errors suppressed through startup/dispose cleanup so Discord monitor shutdown no longer crashes on late gateway close events. (#55373) Thanks @Takhoffman.
- 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.
- CLI/agents: preserve backend session IDs for text-output CLI runs so resume uses the real CLI session handle instead of the OpenClaw session UUID. (#52712) Thanks @kenantan32.
## 2026.3.24

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

@@ -1,11 +0,0 @@
export { isLiveTestEnabled } from "../../src/agents/live-test-helpers.js";
export {
createCliRuntimeCapture,
type CliMockOutputRuntime,
type CliRuntimeCapture,
} from "../../src/cli/test-runtime-capture.js";
export type { OpenClawConfig } from "openclaw/plugin-sdk/browser-support";
export { expectGeneratedTokenPersistedToGatewayAuth } from "../../test/helpers/extensions/auth-token-assertions.ts";
export { withEnv, withEnvAsync } from "../../test/helpers/extensions/env.ts";
export { withFetchPreconnect, type FetchMock } from "../../test/helpers/extensions/fetch-mock.ts";
export { createTempHomeEnv, type TempHomeEnv } from "../../test/helpers/extensions/temp-home.ts";

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({
@@ -316,42 +275,6 @@ describe("runCliAgent with process supervisor", () => {
expect(input.scopeKey).toContain("thread-123");
});
it("preserves the existing CLI session ID for text output mode", async () => {
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "ok",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
}),
);
const result = await runCliAgent({
sessionId: "s1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: EXISTING_CODEX_CONFIG,
prompt: "hi",
provider: "codex-cli",
model: "gpt-5.4",
timeoutMs: 1_000,
runId: "run-text-session-id",
cliSessionBinding: {
sessionId: "thread-123",
authProfileId: "openai-codex:default",
},
authProfileId: "openai-codex:default",
});
expect(result.payloads?.[0]?.text).toBe("ok");
expect(result.meta.agentMeta?.sessionId).toBe("thread-123");
expect(result.meta.agentMeta?.cliSessionBinding?.sessionId).toBe("thread-123");
});
it("keeps resuming the CLI across model changes and passes the new model flag", async () => {
mockSuccessfulCliRun();

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({
@@ -422,7 +427,7 @@ export async function runCliAgent(params: {
const outputMode = useResume ? (backend.resumeOutput ?? backend.output) : backend.output;
if (outputMode === "text") {
return { text: stdout, sessionId: resolvedSessionId };
return { text: stdout, sessionId: undefined };
}
if (outputMode === "jsonl") {
const parsed = parseCliJsonl(stdout, backend);

View File

@@ -27,7 +27,7 @@ const browserClientMocks = vi.hoisted(() => ({
browserStop: vi.fn(async (..._args: unknown[]) => ({})),
browserTabs: vi.fn(async (..._args: unknown[]): Promise<Array<Record<string, unknown>>> => []),
}));
vi.mock("./browser/client.js", () => browserClientMocks);
vi.mock("../../../extensions/browser/src/browser/client.js", () => browserClientMocks);
const browserActionsMocks = vi.hoisted(() => ({
browserAct: vi.fn(async () => ({ ok: true })),
@@ -48,7 +48,7 @@ const browserActionsMocks = vi.hoisted(() => ({
browserPdfSave: vi.fn(async () => ({ ok: true, path: "/tmp/test.pdf" })),
browserScreenshotAction: vi.fn(async () => ({ ok: true, path: "/tmp/test.png" })),
}));
vi.mock("./browser/client-actions.js", () => browserActionsMocks);
vi.mock("../../../extensions/browser/src/browser/client-actions.js", () => browserActionsMocks);
const browserConfigMocks = vi.hoisted(() => ({
resolveBrowserConfig: vi.fn(() => ({
@@ -89,15 +89,13 @@ const browserConfigMocks = vi.hoisted(() => ({
};
}),
}));
vi.mock("./browser/config.js", () => browserConfigMocks);
vi.mock("../../../extensions/browser/src/browser/config.js", () => browserConfigMocks);
const nodesUtilsMocks = vi.hoisted(() => ({
listNodes: vi.fn(async (..._args: unknown[]): Promise<Array<Record<string, unknown>>> => []),
}));
vi.mock("../../../src/agents/tools/nodes-utils.js", async () => {
const actual = await vi.importActual<typeof import("../../../src/agents/tools/nodes-utils.js")>(
"../../../src/agents/tools/nodes-utils.js",
);
vi.mock("./nodes-utils.js", async () => {
const actual = await vi.importActual<typeof import("./nodes-utils.js")>("./nodes-utils.js");
return {
...actual,
listNodes: nodesUtilsMocks.listNodes,
@@ -110,35 +108,39 @@ const gatewayMocks = vi.hoisted(() => ({
payload: { result: { ok: true, running: true } },
})),
}));
vi.mock("../../../src/agents/tools/gateway.js", () => gatewayMocks);
vi.mock("./gateway.js", () => gatewayMocks);
const configMocks = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({ browser: {} })),
}));
vi.mock("../../../src/config/config.js", () => configMocks);
vi.mock("../../config/config.js", () => configMocks);
const sessionTabRegistryMocks = vi.hoisted(() => ({
trackSessionBrowserTab: vi.fn(),
untrackSessionBrowserTab: vi.fn(),
}));
vi.mock("./browser/session-tab-registry.js", () => sessionTabRegistryMocks);
vi.mock(
"../../../extensions/browser/src/browser/session-tab-registry.js",
() => sessionTabRegistryMocks,
);
const toolCommonMocks = vi.hoisted(() => ({
imageResultFromFile: vi.fn(),
}));
vi.mock("../../../src/agents/tools/common.js", async () => {
const actual = await vi.importActual<typeof import("../../../src/agents/tools/common.js")>(
"../../../src/agents/tools/common.js",
);
vi.mock("./common.js", async () => {
const actual = await vi.importActual<typeof import("./common.js")>("./common.js");
return {
...actual,
imageResultFromFile: toolCommonMocks.imageResultFromFile,
};
});
import { __testing as browserToolActionsTesting } from "./browser-tool.actions.js";
import { __testing as browserToolTesting, createBrowserTool } from "./browser-tool.js";
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "./browser/constants.js";
import { __testing as browserToolActionsTesting } from "../../../extensions/browser/src/browser-tool.actions.js";
import {
__testing as browserToolTesting,
createBrowserTool,
} from "../../../extensions/browser/src/browser-tool.js";
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../../extensions/browser/src/browser/constants.js";
function mockSingleBrowserProxyNode() {
nodesUtilsMocks.listNodes.mockResolvedValue([

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,10 +1,13 @@
import { afterEach, describe, expect, it } from "vitest";
import { startBrowserBridgeServer, stopBrowserBridgeServer } from "./bridge-server.js";
import type { ResolvedBrowserConfig } from "./config.js";
import {
startBrowserBridgeServer,
stopBrowserBridgeServer,
} from "../../extensions/browser/src/browser/bridge-server.js";
import type { ResolvedBrowserConfig } from "../../extensions/browser/src/browser/config.js";
import {
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
} from "./constants.js";
} from "../../extensions/browser/src/browser/constants.js";
function buildResolvedConfig(): ResolvedBrowserConfig {
return {

View File

@@ -3,14 +3,17 @@ import {
appendCdpPath,
getHeadersWithAuth,
normalizeCdpHttpBaseForJsonEndpoints,
} from "./cdp.helpers.js";
import { __test } from "./client-fetch.js";
import { resolveBrowserConfig, resolveProfile } from "./config.js";
import { shouldRejectBrowserMutation } from "./csrf.js";
import { toBoolean } from "./routes/utils.js";
import type { BrowserServerState } from "./server-context.js";
import { listKnownProfileNames } from "./server-context.js";
import { resolveTargetIdFromTabs } from "./target-id.js";
} from "../../extensions/browser/src/browser/cdp.helpers.js";
import { __test } from "../../extensions/browser/src/browser/client-fetch.js";
import {
resolveBrowserConfig,
resolveProfile,
} from "../../extensions/browser/src/browser/config.js";
import { shouldRejectBrowserMutation } from "../../extensions/browser/src/browser/csrf.js";
import { toBoolean } from "../../extensions/browser/src/browser/routes/utils.js";
import type { BrowserServerState } from "../../extensions/browser/src/browser/server-context.js";
import { listKnownProfileNames } from "../../extensions/browser/src/browser/server-context.js";
import { resolveTargetIdFromTabs } from "../../extensions/browser/src/browser/target-id.js";
describe("toBoolean", () => {
it("parses yes/no and 1/0", () => {

View File

@@ -6,7 +6,7 @@ import {
hasProxyEnv,
withNoProxyForCdpUrl,
withNoProxyForLocalhost,
} from "./cdp-proxy-bypass.js";
} from "../../extensions/browser/src/browser/cdp-proxy-bypass.js";
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -202,7 +202,8 @@ describe("cdp-proxy-bypass", () => {
describe("withNoProxyForLocalhost concurrency", () => {
it("does not leak NO_PROXY when called concurrently", async () => {
await withIsolatedNoProxyEnv(async () => {
const { withNoProxyForLocalhost } = await import("./cdp-proxy-bypass.js");
const { withNoProxyForLocalhost } =
await import("../../extensions/browser/src/browser/cdp-proxy-bypass.js");
// Simulate concurrent calls
const callA = withNoProxyForLocalhost(async () => {
@@ -229,7 +230,8 @@ describe("withNoProxyForLocalhost concurrency", () => {
describe("withNoProxyForLocalhost reverse exit order", () => {
it("restores NO_PROXY when first caller exits before second", async () => {
await withIsolatedNoProxyEnv(async () => {
const { withNoProxyForLocalhost } = await import("./cdp-proxy-bypass.js");
const { withNoProxyForLocalhost } =
await import("../../extensions/browser/src/browser/cdp-proxy-bypass.js");
// Call A enters first, exits first (short task)
// Call B enters second, exits last (long task)
@@ -259,7 +261,8 @@ describe("withNoProxyForLocalhost preserves user-configured NO_PROXY", () => {
process.env.HTTP_PROXY = "http://proxy:8080";
try {
const { withNoProxyForLocalhost } = await import("./cdp-proxy-bypass.js");
const { withNoProxyForLocalhost } =
await import("../../extensions/browser/src/browser/cdp-proxy-bypass.js");
await withNoProxyForLocalhost(async () => {
// Should not modify since loopback is already covered

View File

@@ -4,7 +4,7 @@ import {
PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS,
PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS,
resolveCdpReachabilityTimeouts,
} from "./cdp-timeouts.js";
} from "../../extensions/browser/src/browser/cdp-timeouts.js";
describe("resolveCdpReachabilityTimeouts", () => {
it("uses loopback defaults when timeout is omitted", () => {

View File

@@ -1,12 +1,17 @@
import { createServer } from "node:http";
import { afterEach, describe, expect, it, vi } from "vitest";
import { type WebSocket, WebSocketServer } from "ws";
import { isWebSocketUrl } from "../../extensions/browser/src/browser/cdp.helpers.js";
import {
createTargetViaCdp,
evaluateJavaScript,
normalizeCdpWsUrl,
snapshotAria,
} from "../../extensions/browser/src/browser/cdp.js";
import { parseHttpUrl } from "../../extensions/browser/src/browser/config.js";
import { InvalidBrowserNavigationUrlError } from "../../extensions/browser/src/browser/navigation-guard.js";
import { SsrFBlockedError } from "../infra/net/ssrf.js";
import { rawDataToString } from "../infra/ws.js";
import { isWebSocketUrl } from "./cdp.helpers.js";
import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
import { parseHttpUrl } from "./config.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
describe("cdp", () => {
let httpServer: ReturnType<typeof createServer> | null = null;

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import {
buildAiSnapshotFromChromeMcpSnapshot,
flattenChromeMcpSnapshotToAriaNodes,
} from "./chrome-mcp.snapshot.js";
} from "../../extensions/browser/src/browser/chrome-mcp.snapshot.js";
const snapshot = {
id: "root",

View File

@@ -6,7 +6,7 @@ import {
openChromeMcpTab,
resetChromeMcpSessionsForTest,
setChromeMcpSessionFactoryForTest,
} from "./chrome-mcp.js";
} from "../../extensions/browser/src/browser/chrome-mcp.js";
type ToolCall = {
name: string;

View File

@@ -25,7 +25,7 @@ import * as fs from "node:fs";
import os from "node:os";
async function loadResolveBrowserExecutableForPlatform() {
const mod = await import("./chrome.executables.js");
const mod = await import("../../extensions/browser/src/browser/chrome.executables.js");
return mod.resolveBrowserExecutableForPlatform;
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildOpenClawChromeLaunchArgs } from "./chrome.js";
import { buildOpenClawChromeLaunchArgs } from "../../extensions/browser/src/browser/chrome.js";
describe("browser chrome launch args", () => {
it("does not force an about:blank tab at startup", () => {

View File

@@ -15,11 +15,11 @@ import {
isChromeReachable,
resolveBrowserExecutableForPlatform,
stopOpenClawChrome,
} from "./chrome.js";
} from "../../extensions/browser/src/browser/chrome.js";
import {
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
} from "./constants.js";
} from "../../extensions/browser/src/browser/constants.js";
type StopChromeTarget = Parameters<typeof stopOpenClawChrome>[0];

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { BrowserDispatchResponse } from "./routes/dispatcher.js";
import type { BrowserDispatchResponse } from "../../extensions/browser/src/browser/routes/dispatcher.js";
function okDispatchResponse(): BrowserDispatchResponse {
return { status: 200, body: { ok: true } };
@@ -22,34 +22,35 @@ const mocks = vi.hoisted(() => ({
dispatch: vi.fn(async (): Promise<BrowserDispatchResponse> => okDispatchResponse()),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
vi.mock("../../extensions/browser/src/config/config.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../extensions/browser/src/config/config.js")>();
return {
...actual,
loadConfig: mocks.loadConfig,
};
});
vi.mock("./control-service.js", () => ({
vi.mock("../../extensions/browser/src/browser/control-service.js", () => ({
createBrowserControlContext: vi.fn(() => ({})),
startBrowserControlServiceFromConfig: mocks.startBrowserControlServiceFromConfig,
}));
vi.mock("./control-auth.js", () => ({
vi.mock("../../extensions/browser/src/browser/control-auth.js", () => ({
resolveBrowserControlAuth: mocks.resolveBrowserControlAuth,
}));
vi.mock("./bridge-auth-registry.js", () => ({
vi.mock("../../extensions/browser/src/browser/bridge-auth-registry.js", () => ({
getBridgeAuthForPort: mocks.getBridgeAuthForPort,
}));
vi.mock("./routes/dispatcher.js", () => ({
vi.mock("../../extensions/browser/src/browser/routes/dispatcher.js", () => ({
createBrowserRouteDispatcher: vi.fn(() => ({
dispatch: mocks.dispatch,
})),
}));
let fetchBrowserJson: typeof import("./client-fetch.js").fetchBrowserJson;
let fetchBrowserJson: typeof import("../../extensions/browser/src/browser/client-fetch.js").fetchBrowserJson;
function stubJsonFetchOk() {
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
@@ -87,7 +88,7 @@ async function expectThrownBrowserFetchError(
describe("fetchBrowserJson loopback auth", () => {
beforeAll(async () => {
vi.resetModules();
({ fetchBrowserJson } = await import("./client-fetch.js"));
({ fetchBrowserJson } = await import("../../extensions/browser/src/browser/client-fetch.js"));
});
beforeEach(() => {

View File

@@ -7,8 +7,13 @@ import {
browserNavigate,
browserPdfSave,
browserScreenshotAction,
} from "./client-actions.js";
import { browserOpenTab, browserSnapshot, browserStatus, browserTabs } from "./client.js";
} from "../../extensions/browser/src/browser/client-actions.js";
import {
browserOpenTab,
browserSnapshot,
browserStatus,
browserTabs,
} from "../../extensions/browser/src/browser/client.js";
describe("browser client", () => {
function stubSnapshotFetch(calls: string[]) {

View File

@@ -1,8 +1,12 @@
import { describe, expect, it } from "vitest";
import { withEnv } from "../../test-support.js";
import {
resolveBrowserConfig,
resolveProfile,
shouldStartLocalBrowserServer,
} from "../../extensions/browser/src/browser/config.js";
import { getBrowserProfileCapabilities } from "../../extensions/browser/src/browser/profile-capabilities.js";
import { withEnv } from "../test-utils/env.js";
import { resolveUserPath } from "../utils.js";
import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
describe("browser config", () => {
it("defaults to enabled with loopback defaults and lobster-orange color", () => {

View File

@@ -1,28 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { expectGeneratedTokenPersistedToGatewayAuth } from "../../test-support.js";
import type { OpenClawConfig } from "../config/config.js";
import { expectGeneratedTokenPersistedToGatewayAuth } from "../test-utils/auth-token-assertions.js";
const mocks = vi.hoisted(() => ({
loadConfig: vi.fn<() => OpenClawConfig>(),
ensureGatewayStartupAuth: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
cfg: {
...cfg,
gateway: {
...cfg.gateway,
auth: {
...cfg.gateway?.auth,
mode: "token" as const,
token: "a".repeat(48),
},
},
},
auth: {
mode: "token" as const,
token: "a".repeat(48),
},
generatedToken: "a".repeat(48),
persistedGeneratedToken: true,
})),
writeConfigFile: vi.fn(async (_cfg: OpenClawConfig) => {}),
}));
vi.mock("../config/config.js", async (importOriginal) => {
@@ -30,14 +12,11 @@ vi.mock("../config/config.js", async (importOriginal) => {
return {
...actual,
loadConfig: mocks.loadConfig,
writeConfigFile: mocks.writeConfigFile,
};
});
vi.mock("../gateway/startup-auth.js", () => ({
ensureGatewayStartupAuth: mocks.ensureGatewayStartupAuth,
}));
let ensureBrowserControlAuth: typeof import("./control-auth.js").ensureBrowserControlAuth;
let ensureBrowserControlAuth: typeof import("../../extensions/browser/src/browser/control-auth.js").ensureBrowserControlAuth;
describe("ensureBrowserControlAuth", () => {
const expectExplicitModeSkipsAutoAuth = async (mode: "password" | "none") => {
@@ -53,28 +32,28 @@ describe("ensureBrowserControlAuth", () => {
const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(result).toEqual({ auth: {} });
expect(mocks.loadConfig).not.toHaveBeenCalled();
expect(mocks.ensureGatewayStartupAuth).not.toHaveBeenCalled();
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
};
const expectGeneratedTokenPersisted = async (result: {
const expectGeneratedTokenPersisted = (result: {
generatedToken?: string;
auth: { token?: string };
}) => {
expect(mocks.ensureGatewayStartupAuth).toHaveBeenCalledTimes(1);
const ensured = await mocks.ensureGatewayStartupAuth.mock.results[0]?.value;
expect(mocks.writeConfigFile).toHaveBeenCalledTimes(1);
expectGeneratedTokenPersistedToGatewayAuth({
generatedToken: result.generatedToken,
authToken: result.auth.token,
persistedConfig: ensured?.cfg,
persistedConfig: mocks.writeConfigFile.mock.calls[0]?.[0],
});
};
beforeEach(async () => {
vi.resetModules();
({ ensureBrowserControlAuth } = await import("./control-auth.js"));
({ ensureBrowserControlAuth } =
await import("../../extensions/browser/src/browser/control-auth.js"));
vi.restoreAllMocks();
mocks.loadConfig.mockClear();
mocks.ensureGatewayStartupAuth.mockClear();
mocks.writeConfigFile.mockClear();
});
it("returns existing auth and skips writes", async () => {
@@ -90,7 +69,7 @@ describe("ensureBrowserControlAuth", () => {
expect(result).toEqual({ auth: { token: "already-set" } });
expect(mocks.loadConfig).not.toHaveBeenCalled();
expect(mocks.ensureGatewayStartupAuth).not.toHaveBeenCalled();
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("auto-generates and persists a token when auth is missing", async () => {
@@ -106,7 +85,7 @@ describe("ensureBrowserControlAuth", () => {
});
const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv });
await expectGeneratedTokenPersisted(result);
expectGeneratedTokenPersisted(result);
});
it("skips auto-generation in test env", async () => {
@@ -123,7 +102,7 @@ describe("ensureBrowserControlAuth", () => {
expect(result).toEqual({ auth: {} });
expect(mocks.loadConfig).not.toHaveBeenCalled();
expect(mocks.ensureGatewayStartupAuth).not.toHaveBeenCalled();
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("respects explicit password mode", async () => {
@@ -154,7 +133,7 @@ describe("ensureBrowserControlAuth", () => {
const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(result).toEqual({ auth: { token: "latest-token" } });
expect(mocks.ensureGatewayStartupAuth).not.toHaveBeenCalled();
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("fails when gateway.auth.token SecretRef is unresolved", async () => {
@@ -175,11 +154,10 @@ describe("ensureBrowserControlAuth", () => {
},
};
mocks.loadConfig.mockReturnValue(cfg);
mocks.ensureGatewayStartupAuth.mockRejectedValueOnce(new Error("MISSING_GW_TOKEN"));
await expect(ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow(
/MISSING_GW_TOKEN/i,
);
expect(mocks.ensureGatewayStartupAuth).toHaveBeenCalledTimes(1);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../test-support.js";
import { ensureBrowserControlAuth } from "./control-auth.js";
import { ensureBrowserControlAuth } from "../../extensions/browser/src/browser/control-auth.js";
import type { OpenClawConfig } from "../config/types.js";
describe("ensureBrowserControlAuth", () => {
async function expectNoAutoGeneratedAuth(cfg: OpenClawConfig): Promise<void> {

View File

@@ -3,29 +3,28 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
ensureBrowserControlAuth: vi.fn(async () => ({ generatedToken: false })),
createBrowserRuntimeState: vi.fn(async () => ({ ok: true })),
loadConfig: vi.fn(() => ({
browser: {
enabled: true,
},
plugins: {
entries: {
browser: {
enabled: false,
},
},
},
})),
}));
vi.mock("openclaw/plugin-sdk/browser-support", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/browser-support")>();
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: mocks.loadConfig,
loadConfig: () => ({
browser: {
enabled: true,
},
plugins: {
entries: {
browser: {
enabled: false,
},
},
},
}),
};
});
vi.mock("./config.js", () => ({
vi.mock("../../extensions/browser/src/browser/config.js", () => ({
resolveBrowserConfig: vi.fn(() => ({
enabled: true,
controlPort: 18791,
@@ -33,24 +32,24 @@ vi.mock("./config.js", () => ({
})),
}));
vi.mock("./control-auth.js", () => ({
vi.mock("../../extensions/browser/src/browser/control-auth.js", () => ({
ensureBrowserControlAuth: mocks.ensureBrowserControlAuth,
}));
vi.mock("./runtime-lifecycle.js", () => ({
vi.mock("../../extensions/browser/src/browser/runtime-lifecycle.js", () => ({
createBrowserRuntimeState: mocks.createBrowserRuntimeState,
stopBrowserRuntime: vi.fn(async () => {}),
}));
let startBrowserControlServiceFromConfig: typeof import("../control-service.js").startBrowserControlServiceFromConfig;
let startBrowserControlServiceFromConfig: typeof import("../../extensions/browser/src/browser/control-service.js").startBrowserControlServiceFromConfig;
describe("startBrowserControlServiceFromConfig", () => {
beforeEach(async () => {
mocks.ensureBrowserControlAuth.mockClear();
mocks.createBrowserRuntimeState.mockClear();
mocks.loadConfig.mockClear();
vi.resetModules();
({ startBrowserControlServiceFromConfig } = await import("../control-service.js"));
({ startBrowserControlServiceFromConfig } =
await import("../../extensions/browser/src/browser/control-service.js"));
});
it("does not start the default service when the browser plugin is disabled", async () => {

View File

@@ -1,12 +1,12 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { SsrFBlockedError, type LookupFn } from "../infra/net/ssrf.js";
import {
assertBrowserNavigationAllowed,
assertBrowserNavigationRedirectChainAllowed,
assertBrowserNavigationResultAllowed,
InvalidBrowserNavigationUrlError,
requiresInspectableBrowserNavigationRedirects,
} from "./navigation-guard.js";
} from "../../extensions/browser/src/browser/navigation-guard.js";
import { SsrFBlockedError, type LookupFn } from "../infra/net/ssrf.js";
function createLookupFn(address: string): LookupFn {
const family = address.includes(":") ? 6 : 4;

View File

@@ -8,7 +8,7 @@ import {
resolvePathWithinRoot,
resolveStrictExistingPathsWithinRoot,
resolveWritablePathWithinRoot,
} from "./paths.js";
} from "../../extensions/browser/src/browser/paths.js";
async function createFixtureRoot(): Promise<{ baseDir: string; uploadsDir: string }> {
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-browser-paths-"));

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { isDefaultBrowserPluginEnabled } from "../../extensions/browser/src/browser/plugin-enabled.js";
import type { OpenClawConfig } from "../config/config.js";
import { isDefaultBrowserPluginEnabled } from "./plugin-enabled.js";
describe("isDefaultBrowserPluginEnabled", () => {
it("defaults to enabled", () => {

View File

@@ -1,13 +1,17 @@
import fs from "node:fs";
import path from "node:path";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { loadConfig, writeConfigFile } from "../config/config.js";
import { resolveOpenClawUserDataDir } from "./chrome.js";
import type { BrowserRouteContext, BrowserServerState } from "./server-context.js";
import { movePathToTrash } from "./trash.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";
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
vi.mock("../../extensions/browser/src/config/config.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../extensions/browser/src/config/config.js")>();
return {
...actual,
loadConfig: vi.fn(),
@@ -15,16 +19,16 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
vi.mock("./trash.js", () => ({
vi.mock("../../extensions/browser/src/browser/trash.js", () => ({
movePathToTrash: vi.fn(async (targetPath: string) => targetPath),
}));
vi.mock("./chrome.js", () => ({
vi.mock("../../extensions/browser/src/browser/chrome.js", () => ({
resolveOpenClawUserDataDir: vi.fn(() => "/tmp/openclaw-test/openclaw/user-data"),
}));
let resolveBrowserConfig: typeof import("./config.js").resolveBrowserConfig;
let createBrowserProfilesService: typeof import("./profiles-service.js").createBrowserProfilesService;
let resolveBrowserConfig: typeof import("../../extensions/browser/src/browser/config.js").resolveBrowserConfig;
let createBrowserProfilesService: typeof import("../../extensions/browser/src/browser/profiles-service.js").createBrowserProfilesService;
function createCtx(resolved: BrowserServerState["resolved"]) {
const state: BrowserServerState = {
@@ -59,8 +63,9 @@ async function createWorkProfileWithConfig(params: {
describe("BrowserProfilesService", () => {
beforeAll(async () => {
vi.resetModules();
({ resolveBrowserConfig } = await import("./config.js"));
({ createBrowserProfilesService } = await import("./profiles-service.js"));
({ resolveBrowserConfig } = await import("../../extensions/browser/src/browser/config.js"));
({ createBrowserProfilesService } =
await import("../../extensions/browser/src/browser/profiles-service.js"));
});
beforeEach(() => {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveBrowserConfig } from "./config.js";
import { resolveBrowserConfig } from "../../extensions/browser/src/browser/config.js";
import {
allocateCdpPort,
allocateColor,
@@ -9,7 +9,7 @@ import {
getUsedPorts,
isValidProfileName,
PROFILE_COLORS,
} from "./profiles.js";
} from "../../extensions/browser/src/browser/profiles.js";
describe("profile name validation", () => {
it.each(["openclaw", "work", "my-profile", "test123", "a", "a-b-c-1-2-3", "1test"])(

View File

@@ -1,9 +1,9 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { MEDIA_MAX_BYTES } from "../../../../src/media/store.js";
import { createTempHomeEnv, type TempHomeEnv } from "../../test-support.js";
import { persistBrowserProxyFiles } from "./proxy-files.js";
import { persistBrowserProxyFiles } from "../../extensions/browser/src/browser/proxy-files.js";
import { MEDIA_MAX_BYTES } from "../media/store.js";
import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js";
describe("persistBrowserProxyFiles", () => {
let tempHome: TempHomeEnv;

View File

@@ -55,16 +55,19 @@ function createBrowser(pages: unknown[]) {
}
let chromiumMock: typeof import("playwright-core").chromium;
let snapshotAiViaPlaywright: typeof import("./pw-tools-core.snapshot.js").snapshotAiViaPlaywright;
let clickViaPlaywright: typeof import("./pw-tools-core.interactions.js").clickViaPlaywright;
let closePlaywrightBrowserConnection: typeof import("./pw-session.js").closePlaywrightBrowserConnection;
let snapshotAiViaPlaywright: typeof import("../../extensions/browser/src/browser/pw-tools-core.snapshot.js").snapshotAiViaPlaywright;
let clickViaPlaywright: typeof import("../../extensions/browser/src/browser/pw-tools-core.interactions.js").clickViaPlaywright;
let closePlaywrightBrowserConnection: typeof import("../../extensions/browser/src/browser/pw-session.js").closePlaywrightBrowserConnection;
beforeAll(async () => {
const pw = await import("playwright-core");
chromiumMock = pw.chromium;
({ snapshotAiViaPlaywright } = await import("./pw-tools-core.snapshot.js"));
({ clickViaPlaywright } = await import("./pw-tools-core.interactions.js"));
({ closePlaywrightBrowserConnection } = await import("./pw-session.js"));
({ snapshotAiViaPlaywright } =
await import("../../extensions/browser/src/browser/pw-tools-core.snapshot.js"));
({ clickViaPlaywright } =
await import("../../extensions/browser/src/browser/pw-tools-core.interactions.js"));
({ closePlaywrightBrowserConnection } =
await import("../../extensions/browser/src/browser/pw-session.js"));
});
afterEach(async () => {

View File

@@ -4,7 +4,7 @@ import {
buildRoleSnapshotFromAriaSnapshot,
getRoleSnapshotStats,
parseRoleRef,
} from "./pw-role-snapshot.js";
} from "../../extensions/browser/src/browser/pw-role-snapshot.js";
describe("pw-role-snapshot", () => {
it("adds refs for interactive elements", () => {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { isLiveTestEnabled } from "../../test-support.js";
import { isLiveTestEnabled } from "../agents/live-test-helpers.js";
const LIVE = isLiveTestEnabled();
const CDP_URL = process.env.OPENCLAW_LIVE_BROWSER_CDP_URL?.trim() || "";
@@ -14,7 +14,7 @@ async function waitFor(
describeLive("browser (live): remote CDP tab persistence", () => {
it("creates, lists, focuses, and closes tabs via Playwright", { timeout: 60_000 }, async () => {
const pw = await import("./pw-ai.js");
const pw = await import("../../extensions/browser/src/browser/pw-ai.js");
await pw.closePlaywrightBrowserConnection().catch(() => {});
const created = await pw.createPageViaPlaywright({ cdpUrl: CDP_URL, url: "about:blank" });

View File

@@ -1,7 +1,10 @@
import { chromium } from "playwright-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as chromeModule from "./chrome.js";
import { closePlaywrightBrowserConnection, listPagesViaPlaywright } from "./pw-session.js";
import * as chromeModule from "../../extensions/browser/src/browser/chrome.js";
import {
closePlaywrightBrowserConnection,
listPagesViaPlaywright,
} from "../../extensions/browser/src/browser/pw-session.js";
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");

View File

@@ -1,9 +1,12 @@
import { chromium } from "playwright-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as chromeModule from "../../extensions/browser/src/browser/chrome.js";
import { InvalidBrowserNavigationUrlError } from "../../extensions/browser/src/browser/navigation-guard.js";
import {
closePlaywrightBrowserConnection,
createPageViaPlaywright,
} from "../../extensions/browser/src/browser/pw-session.js";
import { SsrFBlockedError } from "../infra/net/ssrf.js";
import * as chromeModule from "./chrome.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
import { closePlaywrightBrowserConnection, createPageViaPlaywright } from "./pw-session.js";
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");

View File

@@ -1,7 +1,10 @@
import { chromium } from "playwright-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as chromeModule from "./chrome.js";
import { closePlaywrightBrowserConnection, getPageForTargetId } from "./pw-session.js";
import * as chromeModule from "../../extensions/browser/src/browser/chrome.js";
import {
closePlaywrightBrowserConnection,
getPageForTargetId,
} from "../../extensions/browser/src/browser/pw-session.js";
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { withPageScopedCdpClient } from "./pw-session.page-cdp.js";
import { withPageScopedCdpClient } from "../../extensions/browser/src/browser/pw-session.page-cdp.js";
describe("pw-session page-scoped CDP client", () => {
beforeEach(() => {

View File

@@ -5,7 +5,7 @@ import {
refLocator,
rememberRoleRefsForTarget,
restoreRoleRefsForTarget,
} from "./pw-session.js";
} from "../../extensions/browser/src/browser/pw-session.js";
function fakePage(): {
page: Page;

View File

@@ -3,15 +3,15 @@ import {
installPwToolsCoreTestHooks,
setPwToolsCoreCurrentPage,
setPwToolsCoreCurrentRefLocator,
} from "./pw-tools-core.test-harness.js";
} from "../../extensions/browser/src/browser/pw-tools-core.test-harness.js";
installPwToolsCoreTestHooks();
let mod: typeof import("./pw-tools-core.js");
let mod: typeof import("../../extensions/browser/src/browser/pw-tools-core.js");
describe("pw-tools-core", () => {
beforeAll(async () => {
vi.resetModules();
mod = await import("./pw-tools-core.js");
mod = await import("../../extensions/browser/src/browser/pw-tools-core.js");
});
it("clamps timeoutMs for scrollIntoView", async () => {

View File

@@ -18,7 +18,7 @@ const restoreRoleRefsForTarget = vi.fn(() => {});
const closePageViaPlaywright = vi.fn(async () => {});
const resizeViewportViaPlaywright = vi.fn(async () => {});
vi.mock("./pw-session.js", () => ({
vi.mock("../../extensions/browser/src/browser/pw-session.js", () => ({
ensurePageState,
forceDisconnectPlaywrightForTarget,
getPageForTargetId,
@@ -26,17 +26,18 @@ vi.mock("./pw-session.js", () => ({
restoreRoleRefsForTarget,
}));
vi.mock("./pw-tools-core.snapshot.js", () => ({
vi.mock("../../extensions/browser/src/browser/pw-tools-core.snapshot.js", () => ({
closePageViaPlaywright,
resizeViewportViaPlaywright,
}));
let batchViaPlaywright: typeof import("./pw-tools-core.interactions.js").batchViaPlaywright;
let batchViaPlaywright: typeof import("../../extensions/browser/src/browser/pw-tools-core.interactions.js").batchViaPlaywright;
describe("batchViaPlaywright", () => {
beforeAll(async () => {
vi.resetModules();
({ batchViaPlaywright } = await import("./pw-tools-core.interactions.js"));
({ batchViaPlaywright } =
await import("../../extensions/browser/src/browser/pw-tools-core.interactions.js"));
});
beforeEach(() => {

View File

@@ -19,7 +19,7 @@ const refLocator = vi.fn(() => {
return locator;
});
vi.mock("./pw-session.js", () => {
vi.mock("../../extensions/browser/src/browser/pw-session.js", () => {
return {
ensurePageState,
forceDisconnectPlaywrightForTarget,
@@ -29,7 +29,7 @@ vi.mock("./pw-session.js", () => {
};
});
let evaluateViaPlaywright: typeof import("./pw-tools-core.interactions.js").evaluateViaPlaywright;
let evaluateViaPlaywright: typeof import("../../extensions/browser/src/browser/pw-tools-core.interactions.js").evaluateViaPlaywright;
function createPendingEval() {
let evalCalled!: () => void;
@@ -48,7 +48,8 @@ describe("evaluateViaPlaywright (abort)", () => {
vi.clearAllMocks();
page = null;
locator = null;
({ evaluateViaPlaywright } = await import("./pw-tools-core.interactions.js"));
({ evaluateViaPlaywright } =
await import("../../extensions/browser/src/browser/pw-tools-core.interactions.js"));
});
it.each([

View File

@@ -20,9 +20,11 @@ const refLocator = vi.fn(() => {
const forceDisconnectPlaywrightForTarget = vi.fn(async () => {});
const resolveStrictExistingPathsWithinRoot =
vi.fn<typeof import("./paths.js").resolveStrictExistingPathsWithinRoot>();
vi.fn<
typeof import("../../extensions/browser/src/browser/paths.js").resolveStrictExistingPathsWithinRoot
>();
vi.mock("./pw-session.js", () => {
vi.mock("../../extensions/browser/src/browser/pw-session.js", () => {
return {
ensurePageState,
forceDisconnectPlaywrightForTarget,
@@ -32,14 +34,14 @@ vi.mock("./pw-session.js", () => {
};
});
vi.mock("./paths.js", () => {
vi.mock("../../extensions/browser/src/browser/paths.js", () => {
return {
DEFAULT_UPLOAD_DIR: "/tmp/openclaw/uploads",
resolveStrictExistingPathsWithinRoot,
};
});
let setInputFilesViaPlaywright: typeof import("./pw-tools-core.interactions.js").setInputFilesViaPlaywright;
let setInputFilesViaPlaywright: typeof import("../../extensions/browser/src/browser/pw-tools-core.interactions.js").setInputFilesViaPlaywright;
function seedSingleLocatorPage(): { setInputFiles: ReturnType<typeof vi.fn> } {
const setInputFiles = vi.fn(async () => {});
@@ -56,7 +58,8 @@ function seedSingleLocatorPage(): { setInputFiles: ReturnType<typeof vi.fn> } {
describe("setInputFilesViaPlaywright", () => {
beforeEach(async () => {
vi.resetModules();
({ setInputFilesViaPlaywright } = await import("./pw-tools-core.interactions.js"));
({ setInputFilesViaPlaywright } =
await import("../../extensions/browser/src/browser/pw-tools-core.interactions.js"));
vi.clearAllMocks();
page = null;
locator = null;

View File

@@ -2,19 +2,19 @@ import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { DEFAULT_UPLOAD_DIR } from "./paths.js";
import { DEFAULT_UPLOAD_DIR } from "../../extensions/browser/src/browser/paths.js";
import {
installPwToolsCoreTestHooks,
setPwToolsCoreCurrentPage,
} from "./pw-tools-core.test-harness.js";
} from "../../extensions/browser/src/browser/pw-tools-core.test-harness.js";
installPwToolsCoreTestHooks();
let mod: typeof import("./pw-tools-core.js");
let mod: typeof import("../../extensions/browser/src/browser/pw-tools-core.js");
describe("pw-tools-core", () => {
beforeAll(async () => {
vi.resetModules();
mod = await import("./pw-tools-core.js");
mod = await import("../../extensions/browser/src/browser/pw-tools-core.js");
});
it("last file-chooser arm wins", async () => {

View File

@@ -2,17 +2,17 @@ import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_UPLOAD_DIR } from "./paths.js";
import { DEFAULT_UPLOAD_DIR } from "../../extensions/browser/src/browser/paths.js";
import {
getPwToolsCoreSessionMocks,
installPwToolsCoreTestHooks,
setPwToolsCoreCurrentPage,
setPwToolsCoreCurrentRefLocator,
} from "./pw-tools-core.test-harness.js";
} from "../../extensions/browser/src/browser/pw-tools-core.test-harness.js";
installPwToolsCoreTestHooks();
const sessionMocks = getPwToolsCoreSessionMocks();
let mod: typeof import("./pw-tools-core.js");
let mod: typeof import("../../extensions/browser/src/browser/pw-tools-core.js");
function createFileChooserPageMocks() {
const fileChooser = { setFiles: vi.fn(async () => {}) };
@@ -28,7 +28,7 @@ function createFileChooserPageMocks() {
describe("pw-tools-core", () => {
beforeAll(async () => {
vi.resetModules();
mod = await import("./pw-tools-core.js");
mod = await import("../../extensions/browser/src/browser/pw-tools-core.js");
});
beforeEach(() => {

View File

@@ -1,14 +1,14 @@
import { describe, expect, it, vi } from "vitest";
import { SsrFBlockedError } from "../infra/net/ssrf.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
import { InvalidBrowserNavigationUrlError } from "../../extensions/browser/src/browser/navigation-guard.js";
import {
getPwToolsCoreSessionMocks,
installPwToolsCoreTestHooks,
setPwToolsCoreCurrentPage,
} from "./pw-tools-core.test-harness.js";
} from "../../extensions/browser/src/browser/pw-tools-core.test-harness.js";
import { SsrFBlockedError } from "../infra/net/ssrf.js";
installPwToolsCoreTestHooks();
const mod = await import("./pw-tools-core.snapshot.js");
const mod = await import("../../extensions/browser/src/browser/pw-tools-core.snapshot.js");
describe("pw-tools-core.snapshot navigate guard", () => {
it("blocks unsupported non-network URLs before page lookup", async () => {

View File

@@ -7,7 +7,7 @@ import {
installPwToolsCoreTestHooks,
setPwToolsCoreCurrentPage,
setPwToolsCoreCurrentRefLocator,
} from "./pw-tools-core.test-harness.js";
} from "../../extensions/browser/src/browser/pw-tools-core.test-harness.js";
installPwToolsCoreTestHooks();
const sessionMocks = getPwToolsCoreSessionMocks();
@@ -15,12 +15,12 @@ const tmpDirMocks = vi.hoisted(() => ({
resolvePreferredOpenClawTmpDir: vi.fn(() => "/tmp/openclaw"),
}));
vi.mock("../infra/tmp-openclaw-dir.js", () => tmpDirMocks);
let mod: typeof import("./pw-tools-core.js");
let mod: typeof import("../../extensions/browser/src/browser/pw-tools-core.js");
describe("pw-tools-core", () => {
beforeAll(async () => {
vi.resetModules();
mod = await import("./pw-tools-core.js");
mod = await import("../../extensions/browser/src/browser/pw-tools-core.js");
});
beforeEach(() => {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { isPersistentBrowserProfileMutation } from "./request-policy.js";
import { isPersistentBrowserProfileMutation } from "../../extensions/browser/src/browser/request-policy.js";
describe("isPersistentBrowserProfileMutation", () => {
it.each([

View File

@@ -1,6 +1,9 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
import type { BrowserRequest } from "./types.js";
import {
createBrowserRouteApp,
createBrowserRouteResponse,
} from "../../../extensions/browser/src/browser/routes/test-helpers.js";
import type { BrowserRequest } from "../../../extensions/browser/src/browser/routes/types.js";
const routeState = vi.hoisted(() => ({
profileCtx: {
@@ -33,7 +36,7 @@ const chromeMcpMocks = vi.hoisted(() => ({
})),
}));
vi.mock("../chrome-mcp.js", () => ({
vi.mock("../../../extensions/browser/src/browser/chrome-mcp.js", () => ({
clickChromeMcpElement: vi.fn(async () => {}),
closeChromeMcpTab: vi.fn(async () => {}),
dragChromeMcpElement: vi.fn(async () => {}),
@@ -48,18 +51,18 @@ vi.mock("../chrome-mcp.js", () => ({
takeChromeMcpSnapshot: chromeMcpMocks.takeChromeMcpSnapshot,
}));
vi.mock("../cdp.js", () => ({
vi.mock("../../../extensions/browser/src/browser/cdp.js", () => ({
captureScreenshot: vi.fn(),
snapshotAria: vi.fn(),
}));
vi.mock("../navigation-guard.js", () => ({
vi.mock("../../../extensions/browser/src/browser/navigation-guard.js", () => ({
assertBrowserNavigationAllowed: vi.fn(async () => {}),
assertBrowserNavigationResultAllowed: vi.fn(async () => {}),
withBrowserNavigationPolicy: vi.fn(() => ({})),
}));
vi.mock("../screenshot.js", () => ({
vi.mock("../../../extensions/browser/src/browser/screenshot.js", () => ({
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
normalizeBrowserScreenshot: vi.fn(async (buffer: Buffer) => ({
@@ -73,7 +76,7 @@ vi.mock("../../media/store.js", () => ({
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
}));
vi.mock("./agent.shared.js", () => ({
vi.mock("../../../extensions/browser/src/browser/routes/agent.shared.js", () => ({
getPwAiModule: vi.fn(async () => null),
handleRouteError: vi.fn(),
readBody: vi.fn((req: BrowserRequest) => req.body ?? {}),
@@ -94,13 +97,15 @@ vi.mock("./agent.shared.js", () => ({
}),
}));
let registerBrowserAgentActRoutes: typeof import("./agent.act.js").registerBrowserAgentActRoutes;
let registerBrowserAgentSnapshotRoutes: typeof import("./agent.snapshot.js").registerBrowserAgentSnapshotRoutes;
let registerBrowserAgentActRoutes: typeof import("../../../extensions/browser/src/browser/routes/agent.act.js").registerBrowserAgentActRoutes;
let registerBrowserAgentSnapshotRoutes: typeof import("../../../extensions/browser/src/browser/routes/agent.snapshot.js").registerBrowserAgentSnapshotRoutes;
beforeAll(async () => {
vi.resetModules();
({ registerBrowserAgentActRoutes } = await import("./agent.act.js"));
({ registerBrowserAgentSnapshotRoutes } = await import("./agent.snapshot.js"));
({ registerBrowserAgentActRoutes } =
await import("../../../extensions/browser/src/browser/routes/agent.act.js"));
({ registerBrowserAgentSnapshotRoutes } =
await import("../../../extensions/browser/src/browser/routes/agent.snapshot.js"));
});
function getSnapshotGetHandler() {

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { readBody, resolveTargetIdFromBody, resolveTargetIdFromQuery } from "./agent.shared.js";
import type { BrowserRequest } from "./types.js";
import {
readBody,
resolveTargetIdFromBody,
resolveTargetIdFromQuery,
} from "../../../extensions/browser/src/browser/routes/agent.shared.js";
import type { BrowserRequest } from "../../../extensions/browser/src/browser/routes/types.js";
function requestWithBody(body: unknown): BrowserRequest {
return {

View File

@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import { resolveBrowserConfig, resolveProfile } from "../config.js";
import { resolveSnapshotPlan } from "./agent.snapshot.plan.js";
import {
resolveBrowserConfig,
resolveProfile,
} from "../../../extensions/browser/src/browser/config.js";
import { resolveSnapshotPlan } from "../../../extensions/browser/src/browser/routes/agent.snapshot.plan.js";
describe("resolveSnapshotPlan", () => {
it("defaults existing-session snapshots to ai when format is omitted", () => {

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveTargetIdAfterNavigate } from "./agent.snapshot.js";
import { resolveTargetIdAfterNavigate } from "../../../extensions/browser/src/browser/routes/agent.snapshot.js";
type Tab = { targetId: string; url: string };

View File

@@ -3,7 +3,7 @@ import {
parseRequiredStorageMutationRequest,
parseStorageKind,
parseStorageMutationRequest,
} from "./agent.storage.js";
} from "../../../extensions/browser/src/browser/routes/agent.storage.js";
describe("browser storage route parsing", () => {
describe("parseStorageKind", () => {

View File

@@ -1,12 +1,15 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
import {
createBrowserRouteApp,
createBrowserRouteResponse,
} from "../../../extensions/browser/src/browser/routes/test-helpers.js";
vi.mock("../chrome-mcp.js", () => ({
vi.mock("../../../extensions/browser/src/browser/chrome-mcp.js", () => ({
getChromeMcpPid: vi.fn(() => 4321),
}));
let registerBrowserBasicRoutes: typeof import("./basic.js").registerBrowserBasicRoutes;
let BrowserProfileUnavailableError: typeof import("../errors.js").BrowserProfileUnavailableError;
let registerBrowserBasicRoutes: typeof import("../../../extensions/browser/src/browser/routes/basic.js").registerBrowserBasicRoutes;
let BrowserProfileUnavailableError: typeof import("../../../extensions/browser/src/browser/errors.js").BrowserProfileUnavailableError;
function createExistingSessionProfileState(params?: { isHttpReachable?: () => Promise<boolean> }) {
return {
@@ -54,8 +57,10 @@ async function callBasicRouteWithState(params: {
beforeEach(async () => {
vi.resetModules();
({ BrowserProfileUnavailableError } = await import("../errors.js"));
({ registerBrowserBasicRoutes } = await import("./basic.js"));
({ BrowserProfileUnavailableError } =
await import("../../../extensions/browser/src/browser/errors.js"));
({ registerBrowserBasicRoutes } =
await import("../../../extensions/browser/src/browser/routes/basic.js"));
});
describe("basic browser routes", () => {

View File

@@ -1,12 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { BrowserRouteContext } from "../server-context.js";
import type { BrowserRouteContext } from "../../../extensions/browser/src/browser/server-context.js";
let createBrowserRouteDispatcher: typeof import("./dispatcher.js").createBrowserRouteDispatcher;
let createBrowserRouteDispatcher: typeof import("../../../extensions/browser/src/browser/routes/dispatcher.js").createBrowserRouteDispatcher;
describe("browser route dispatcher (abort)", () => {
beforeEach(async () => {
vi.resetModules();
vi.doMock("./index.js", () => {
vi.doMock("../../../extensions/browser/src/browser/routes/index.js", () => {
return {
registerBrowserRoutes(app: { get: (path: string, handler: unknown) => void }) {
app.get(
@@ -40,7 +40,8 @@ describe("browser route dispatcher (abort)", () => {
},
};
});
({ createBrowserRouteDispatcher } = await import("./dispatcher.js"));
({ createBrowserRouteDispatcher } =
await import("../../../extensions/browser/src/browser/routes/dispatcher.js"));
});
it("propagates AbortSignal and lets handlers observe abort", async () => {

View File

@@ -1,6 +1,6 @@
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { normalizeBrowserScreenshot } from "./screenshot.js";
import { normalizeBrowserScreenshot } from "../../extensions/browser/src/browser/screenshot.js";
describe("browser screenshot normalization", () => {
it("shrinks oversized images to <=2000x2000 and <=5MB", async () => {

View File

@@ -6,15 +6,15 @@ vi.hoisted(() => {
vi.resetModules();
});
import "./server-context.chrome-test-harness.js";
import "../../extensions/browser/src/browser/server-context.chrome-test-harness.js";
import {
PROFILE_ATTACH_RETRY_TIMEOUT_MS,
PROFILE_HTTP_REACHABILITY_TIMEOUT_MS,
} from "./cdp-timeouts.js";
import * as chromeModule from "./chrome.js";
import type { RunningChrome } from "./chrome.js";
import type { BrowserServerState } from "./server-context.js";
import { createBrowserRouteContext } from "./server-context.js";
} from "../../extensions/browser/src/browser/cdp-timeouts.js";
import * as chromeModule from "../../extensions/browser/src/browser/chrome.js";
import type { RunningChrome } from "../../extensions/browser/src/browser/chrome.js";
import type { BrowserServerState } from "../../extensions/browser/src/browser/server-context.js";
import { createBrowserRouteContext } from "../../extensions/browser/src/browser/server-context.js";
function makeBrowserState(): BrowserServerState {
return {

View File

@@ -1,8 +1,8 @@
import fs from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BrowserServerState } from "./server-context.js";
import type { BrowserServerState } from "../../extensions/browser/src/browser/server-context.js";
vi.mock("./chrome-mcp.js", () => ({
vi.mock("../../extensions/browser/src/browser/chrome-mcp.js", () => ({
closeChromeMcpSession: vi.fn(async () => true),
ensureChromeMcpAvailable: vi.fn(async () => {}),
focusChromeMcpTab: vi.fn(async () => {}),
@@ -19,8 +19,8 @@ vi.mock("./chrome-mcp.js", () => ({
getChromeMcpPid: vi.fn(() => 4321),
}));
let createBrowserRouteContext: typeof import("./server-context.js").createBrowserRouteContext;
let chromeMcp: typeof import("./chrome-mcp.js");
let createBrowserRouteContext: typeof import("../../extensions/browser/src/browser/server-context.js").createBrowserRouteContext;
let chromeMcp: typeof import("../../extensions/browser/src/browser/chrome-mcp.js");
function makeState(): BrowserServerState {
return {
@@ -64,8 +64,9 @@ afterEach(() => {
beforeEach(async () => {
vi.resetModules();
({ createBrowserRouteContext } = await import("./server-context.js"));
chromeMcp = await import("./chrome-mcp.js");
({ createBrowserRouteContext } =
await import("../../extensions/browser/src/browser/server-context.js"));
chromeMcp = await import("../../extensions/browser/src/browser/chrome-mcp.js");
});
describe("browser server-context existing-session profile", () => {

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { BrowserServerState } from "./server-context.types.js";
import type { BrowserServerState } from "../../extensions/browser/src/browser/server-context.types.js";
let cfgProfiles: Record<string, { cdpPort?: number; cdpUrl?: string; color?: string }> = {};
@@ -42,17 +42,18 @@ vi.mock("../config/config.js", async (importOriginal) => {
describe("server-context hot-reload profiles", () => {
let loadConfig: typeof import("../config/config.js").loadConfig;
let resolveBrowserConfig: typeof import("./config.js").resolveBrowserConfig;
let resolveProfile: typeof import("./config.js").resolveProfile;
let refreshResolvedBrowserConfigFromDisk: typeof import("./resolved-config-refresh.js").refreshResolvedBrowserConfigFromDisk;
let resolveBrowserProfileWithHotReload: typeof import("./resolved-config-refresh.js").resolveBrowserProfileWithHotReload;
let resolveBrowserConfig: typeof import("../../extensions/browser/src/browser/config.js").resolveBrowserConfig;
let resolveProfile: typeof import("../../extensions/browser/src/browser/config.js").resolveProfile;
let refreshResolvedBrowserConfigFromDisk: typeof import("../../extensions/browser/src/browser/resolved-config-refresh.js").refreshResolvedBrowserConfigFromDisk;
let resolveBrowserProfileWithHotReload: typeof import("../../extensions/browser/src/browser/resolved-config-refresh.js").resolveBrowserProfileWithHotReload;
beforeEach(async () => {
vi.resetModules();
({ loadConfig } = await import("../config/config.js"));
({ resolveBrowserConfig, resolveProfile } = await import("./config.js"));
({ resolveBrowserConfig, resolveProfile } =
await import("../../extensions/browser/src/browser/config.js"));
({ refreshResolvedBrowserConfigFromDisk, resolveBrowserProfileWithHotReload } =
await import("./resolved-config-refresh.js"));
await import("../../extensions/browser/src/browser/resolved-config-refresh.js"));
vi.clearAllMocks();
cfgProfiles = {
openclaw: { cdpPort: 18800, color: "#FF4500" },

View File

@@ -1,8 +1,11 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { withFetchPreconnect } from "../../test-support.js";
import * as cdpModule from "./cdp.js";
import { createBrowserRouteContext } from "./server-context.js";
import { makeState, originalFetch } from "./server-context.remote-tab-ops.harness.js";
import * as cdpModule from "../../extensions/browser/src/browser/cdp.js";
import { createBrowserRouteContext } from "../../extensions/browser/src/browser/server-context.js";
import {
makeState,
originalFetch,
} from "../../extensions/browser/src/browser/server-context.remote-tab-ops.harness.js";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
afterEach(() => {
globalThis.fetch = originalFetch;

View File

@@ -2,26 +2,29 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite
const originalFetch = globalThis.fetch;
let chromeModule: typeof import("./chrome.js");
let InvalidBrowserNavigationUrlError: typeof import("./navigation-guard.js").InvalidBrowserNavigationUrlError;
let pwAiModule: typeof import("./pw-ai-module.js");
let closePlaywrightBrowserConnection: typeof import("./pw-session.js").closePlaywrightBrowserConnection;
let createBrowserRouteContext: typeof import("./server-context.js").createBrowserRouteContext;
let createJsonListFetchMock: typeof import("./server-context.remote-tab-ops.harness.js").createJsonListFetchMock;
let createRemoteRouteHarness: typeof import("./server-context.remote-tab-ops.harness.js").createRemoteRouteHarness;
let createSequentialPageLister: typeof import("./server-context.remote-tab-ops.harness.js").createSequentialPageLister;
let makeState: typeof import("./server-context.remote-tab-ops.harness.js").makeState;
let chromeModule: typeof import("../../extensions/browser/src/browser/chrome.js");
let InvalidBrowserNavigationUrlError: typeof import("../../extensions/browser/src/browser/navigation-guard.js").InvalidBrowserNavigationUrlError;
let pwAiModule: typeof import("../../extensions/browser/src/browser/pw-ai-module.js");
let closePlaywrightBrowserConnection: typeof import("../../extensions/browser/src/browser/pw-session.js").closePlaywrightBrowserConnection;
let createBrowserRouteContext: typeof import("../../extensions/browser/src/browser/server-context.js").createBrowserRouteContext;
let createJsonListFetchMock: typeof import("../../extensions/browser/src/browser/server-context.remote-tab-ops.harness.js").createJsonListFetchMock;
let createRemoteRouteHarness: typeof import("../../extensions/browser/src/browser/server-context.remote-tab-ops.harness.js").createRemoteRouteHarness;
let createSequentialPageLister: typeof import("../../extensions/browser/src/browser/server-context.remote-tab-ops.harness.js").createSequentialPageLister;
let makeState: typeof import("../../extensions/browser/src/browser/server-context.remote-tab-ops.harness.js").makeState;
beforeAll(async () => {
vi.resetModules();
await import("./server-context.chrome-test-harness.js");
chromeModule = await import("./chrome.js");
({ InvalidBrowserNavigationUrlError } = await import("./navigation-guard.js"));
pwAiModule = await import("./pw-ai-module.js");
({ closePlaywrightBrowserConnection } = await import("./pw-session.js"));
({ createBrowserRouteContext } = await import("./server-context.js"));
await import("../../extensions/browser/src/browser/server-context.chrome-test-harness.js");
chromeModule = await import("../../extensions/browser/src/browser/chrome.js");
({ InvalidBrowserNavigationUrlError } =
await import("../../extensions/browser/src/browser/navigation-guard.js"));
pwAiModule = await import("../../extensions/browser/src/browser/pw-ai-module.js");
({ closePlaywrightBrowserConnection } =
await import("../../extensions/browser/src/browser/pw-session.js"));
({ createBrowserRouteContext } =
await import("../../extensions/browser/src/browser/server-context.js"));
({ createJsonListFetchMock, createRemoteRouteHarness, createSequentialPageLister, makeState } =
await import("./server-context.remote-tab-ops.harness.js"));
await import("../../extensions/browser/src/browser/server-context.remote-tab-ops.harness.js"));
});
beforeEach(() => {

View File

@@ -11,10 +11,10 @@ const pwAiMocks = vi.hoisted(() => ({
closePlaywrightBrowserConnection: vi.fn(async () => {}),
}));
vi.mock("./trash.js", () => trashMocks);
vi.mock("./pw-ai.js", () => pwAiMocks);
vi.mock("../../extensions/browser/src/browser/trash.js", () => trashMocks);
vi.mock("../../extensions/browser/src/browser/pw-ai.js", () => pwAiMocks);
let createProfileResetOps: typeof import("./server-context.reset.js").createProfileResetOps;
let createProfileResetOps: typeof import("../../extensions/browser/src/browser/server-context.reset.js").createProfileResetOps;
afterEach(() => {
vi.clearAllMocks();
@@ -22,7 +22,8 @@ afterEach(() => {
beforeEach(async () => {
vi.resetModules();
({ createProfileResetOps } = await import("./server-context.reset.js"));
({ createProfileResetOps } =
await import("../../extensions/browser/src/browser/server-context.reset.js"));
});
function localOpenClawProfile(): Parameters<typeof createProfileResetOps>[0]["profile"] {

View File

@@ -1,22 +1,23 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { withFetchPreconnect } from "../../test-support.js";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
vi.hoisted(() => {
vi.resetModules();
});
import "./server-context.chrome-test-harness.js";
import * as cdpModule from "./cdp.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
import { createBrowserRouteContext } from "./server-context.js";
import "../../extensions/browser/src/browser/server-context.chrome-test-harness.js";
import * as cdpModule from "../../extensions/browser/src/browser/cdp.js";
import { InvalidBrowserNavigationUrlError } from "../../extensions/browser/src/browser/navigation-guard.js";
import { createBrowserRouteContext } from "../../extensions/browser/src/browser/server-context.js";
import {
makeManagedTabsWithNew,
makeState,
originalFetch,
} from "./server-context.remote-tab-ops.harness.js";
} from "../../extensions/browser/src/browser/server-context.remote-tab-ops.harness.js";
afterEach(async () => {
const { closePlaywrightBrowserConnection } = await import("./pw-session.js");
const { closePlaywrightBrowserConnection } =
await import("../../extensions/browser/src/browser/pw-session.js");
await closePlaywrightBrowserConnection().catch(() => {});
globalThis.fetch = originalFetch;
vi.restoreAllMocks();

View File

@@ -9,22 +9,22 @@ const { createBrowserRouteContextMock, listKnownProfileNamesMock } = vi.hoisted(
listKnownProfileNamesMock: vi.fn(),
}));
vi.mock("./chrome.js", () => ({
vi.mock("../../extensions/browser/src/browser/chrome.js", () => ({
stopOpenClawChrome: stopOpenClawChromeMock,
}));
vi.mock("./server-context.js", () => ({
vi.mock("../../extensions/browser/src/browser/server-context.js", () => ({
createBrowserRouteContext: createBrowserRouteContextMock,
listKnownProfileNames: listKnownProfileNamesMock,
}));
let ensureExtensionRelayForProfiles: typeof import("./server-lifecycle.js").ensureExtensionRelayForProfiles;
let stopKnownBrowserProfiles: typeof import("./server-lifecycle.js").stopKnownBrowserProfiles;
let ensureExtensionRelayForProfiles: typeof import("../../extensions/browser/src/browser/server-lifecycle.js").ensureExtensionRelayForProfiles;
let stopKnownBrowserProfiles: typeof import("../../extensions/browser/src/browser/server-lifecycle.js").stopKnownBrowserProfiles;
beforeEach(async () => {
vi.resetModules();
({ ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } =
await import("./server-lifecycle.js"));
await import("../../extensions/browser/src/browser/server-lifecycle.js"));
createBrowserRouteContextMock.mockClear();
listKnownProfileNamesMock.mockClear();
stopOpenClawChromeMock.mockClear();

View File

@@ -2,17 +2,24 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { DEFAULT_DOWNLOAD_DIR, DEFAULT_TRACE_DIR, DEFAULT_UPLOAD_DIR } from "./paths.js";
import {
DEFAULT_DOWNLOAD_DIR,
DEFAULT_TRACE_DIR,
DEFAULT_UPLOAD_DIR,
} from "../../extensions/browser/src/browser/paths.js";
import {
installAgentContractHooks,
postJson,
startServerAndBase,
} from "./server.agent-contract.test-harness.js";
} from "../../extensions/browser/src/browser/server.agent-contract.test-harness.js";
import {
getBrowserControlServerTestState,
getPwMocks,
} from "./server.control-server.test-harness.js";
import { getBrowserTestFetch, type BrowserTestFetch } from "./test-fetch.js";
} from "../../extensions/browser/src/browser/server.control-server.test-harness.js";
import {
getBrowserTestFetch,
type BrowserTestFetch,
} from "../../extensions/browser/src/browser/test-fetch.js";
const state = getBrowserControlServerTestState();
const pwMocks = getPwMocks();

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