mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:20:45 +00:00
Merge remote-tracking branch 'origin/main' into release/2026.4.25
# Conflicts: # .agents/skills/openclaw-release-maintainer/SKILL.md # CHANGELOG.md # package.json # src/config/schema.base.generated.ts
This commit is contained in:
@@ -80,6 +80,10 @@ Use this skill for release and publish-time workflow. Keep ordinary development
|
||||
parallel, publish npm from the successful npm preflight, then start published
|
||||
npm install/update, Docker, and Parallels verification while mac artifacts
|
||||
continue.
|
||||
- After a beta is published, run the expensive Docker, Parallels, and QA-Lab
|
||||
release rosters in parallel instead of serializing them. Use selective reruns
|
||||
after failures or fixes, but keep proof that Docker, Parallels, and QA-Lab
|
||||
each passed at least once before stable/latest promotion.
|
||||
- Mac packaging may be built from a slight release-branch variation of the
|
||||
tagged commit when the delta is mac packaging, signing, workflow, or
|
||||
validation-only release machinery. If mac packaging needs release-branch-only
|
||||
@@ -542,7 +546,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
the next beta if the fix must change the already-published package. If any
|
||||
lane fails after the beta tag/package is pushed or published, fix,
|
||||
commit/push/pull, increment to the next beta tag, and rerun the affected
|
||||
beta evidence. Ensure the full expensive roster has passed at least once
|
||||
beta evidence. Start Docker, Parallels, and QA-Lab in parallel once the
|
||||
beta is live. Ensure the full expensive roster has passed at least once
|
||||
before stable/latest promotion. The roster includes the manual Actions >
|
||||
`NPM Telegram Beta E2E` workflow against the exact published beta package.
|
||||
If a pre-npm lane fails before any tag/package leaves the machine, fix and
|
||||
|
||||
@@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Providers/plugins: keep onboarding and auth-choice setup lists on cold manifest/install metadata and add Provider Index install metadata for not-yet-installed provider plugins. Thanks @vincentkoc.
|
||||
- Providers/plugins: keep provider setup guidance and configure auth imports on cold manifest metadata, with a regression guard against static provider-runtime imports on setup/configure list paths. Thanks @vincentkoc.
|
||||
- CLI/capabilities: keep capability command registration from importing the models auth runtime until `model auth login` actually runs. Thanks @vincentkoc.
|
||||
- CLI/configure: keep web-search configure prompts on cold plugin registry metadata until the user chooses managed search setup. Thanks @vincentkoc.
|
||||
- Plugins/chat commands: refresh the persisted plugin registry after `/plugins enable` and `/plugins disable`, matching the CLI mutation path. Thanks @vincentkoc.
|
||||
- Plugins/compat: mark `OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY` as a deprecated break-glass switch and point operators at registry repair instead. Thanks @vincentkoc.
|
||||
- Plugins/registry: ignore stale persisted registry reads when plugin policy no longer matches current config, and stamp generated registry files with a do-not-edit warning. Thanks @vincentkoc.
|
||||
@@ -88,7 +89,12 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agents/subagents: deliver completed yielded-subagent results back to no-thread requester routes via direct fallback when the dormant parent announce turn produces no visible reply, and add QA-lab coverage for the regression. Thanks @vincentkoc.
|
||||
- Gateway/Tailscale: let Tailscale-authenticated Control UI operator sessions with browser device identity skip the device-pairing round trip while still rejecting device-less and node-role connections. Refs #71986. Thanks @jokedul.
|
||||
- Doctor: honor `OPENCLAW_SERVICE_REPAIR_POLICY=external` by reporting gateway service health while skipping service install/start/restart/bootstrap, supervisor rewrites, and legacy service cleanup for externally managed environments. Thanks @shakkernerd.
|
||||
- CLI/update: run package post-update doctor with `--fix` so package updates repair config migrations before restart. Thanks @shakkernerd.
|
||||
- CLI/update: retry failed npm global updates with `--omit=optional` and ignore the superseded first failure when the fallback succeeds. Thanks @shakkernerd.
|
||||
- Plugins/uninstall: migrate and reset `plugins.slots.contextEngine` alongside memory slots when plugin ids change or selected plugins are removed. Thanks @shakkernerd.
|
||||
- Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex.
|
||||
- UI/Windows: quote resolved pnpm `.cmd` launcher paths before spawning UI install/build/test commands so Node installs under `C:\Program Files` no longer fail as `C:\Program`. Fixes #45275. Thanks @Kobevictor, @stoppieboy, and @iubns.
|
||||
- Codex/agent: translate `--thinking minimal` to `low` for modern Codex models (gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.2) at request build time so the first turn is accepted instead of paying a wasted call + retry-with-low fallback. Older Codex models still receive `minimal` directly. Fixes #71946. Thanks @hclsys.
|
||||
|
||||
@@ -5,10 +5,12 @@ read_when:
|
||||
- Debugging multi-agent replies in WhatsApp
|
||||
status: experimental
|
||||
title: "Broadcast groups"
|
||||
sidebarTitle: "Broadcast groups"
|
||||
---
|
||||
|
||||
**Status:** Experimental
|
||||
**Version:** Added in 2026.1.9
|
||||
<Note>
|
||||
**Status:** Experimental. Added in 2026.1.9.
|
||||
</Note>
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -18,55 +20,55 @@ Current scope: **WhatsApp only** (web channel).
|
||||
|
||||
Broadcast groups are evaluated after channel allowlists and group activation rules. In WhatsApp groups, this means broadcasts happen when OpenClaw would normally reply (for example: on mention, depending on your group settings).
|
||||
|
||||
## Use Cases
|
||||
## Use cases
|
||||
|
||||
### 1. Specialized Agent Teams
|
||||
<AccordionGroup>
|
||||
<Accordion title="1. Specialized agent teams">
|
||||
Deploy multiple agents with atomic, focused responsibilities:
|
||||
|
||||
Deploy multiple agents with atomic, focused responsibilities:
|
||||
```
|
||||
Group: "Development Team"
|
||||
Agents:
|
||||
- CodeReviewer (reviews code snippets)
|
||||
- DocumentationBot (generates docs)
|
||||
- SecurityAuditor (checks for vulnerabilities)
|
||||
- TestGenerator (suggests test cases)
|
||||
```
|
||||
|
||||
```
|
||||
Group: "Development Team"
|
||||
Agents:
|
||||
- CodeReviewer (reviews code snippets)
|
||||
- DocumentationBot (generates docs)
|
||||
- SecurityAuditor (checks for vulnerabilities)
|
||||
- TestGenerator (suggests test cases)
|
||||
```
|
||||
Each agent processes the same message and provides its specialized perspective.
|
||||
|
||||
Each agent processes the same message and provides its specialized perspective.
|
||||
|
||||
### 2. Multi-Language Support
|
||||
|
||||
```
|
||||
Group: "International Support"
|
||||
Agents:
|
||||
- Agent_EN (responds in English)
|
||||
- Agent_DE (responds in German)
|
||||
- Agent_ES (responds in Spanish)
|
||||
```
|
||||
|
||||
### 3. Quality Assurance Workflows
|
||||
|
||||
```
|
||||
Group: "Customer Support"
|
||||
Agents:
|
||||
- SupportAgent (provides answer)
|
||||
- QAAgent (reviews quality, only responds if issues found)
|
||||
```
|
||||
|
||||
### 4. Task Automation
|
||||
|
||||
```
|
||||
Group: "Project Management"
|
||||
Agents:
|
||||
- TaskTracker (updates task database)
|
||||
- TimeLogger (logs time spent)
|
||||
- ReportGenerator (creates summaries)
|
||||
```
|
||||
</Accordion>
|
||||
<Accordion title="2. Multi-language support">
|
||||
```
|
||||
Group: "International Support"
|
||||
Agents:
|
||||
- Agent_EN (responds in English)
|
||||
- Agent_DE (responds in German)
|
||||
- Agent_ES (responds in Spanish)
|
||||
```
|
||||
</Accordion>
|
||||
<Accordion title="3. Quality assurance workflows">
|
||||
```
|
||||
Group: "Customer Support"
|
||||
Agents:
|
||||
- SupportAgent (provides answer)
|
||||
- QAAgent (reviews quality, only responds if issues found)
|
||||
```
|
||||
</Accordion>
|
||||
<Accordion title="4. Task automation">
|
||||
```
|
||||
Group: "Project Management"
|
||||
Agents:
|
||||
- TaskTracker (updates task database)
|
||||
- TimeLogger (logs time spent)
|
||||
- ReportGenerator (creates summaries)
|
||||
```
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Configuration
|
||||
|
||||
### Basic Setup
|
||||
### Basic setup
|
||||
|
||||
Add a top-level `broadcast` section (next to `bindings`). Keys are WhatsApp peer ids:
|
||||
|
||||
@@ -83,37 +85,40 @@ Add a top-level `broadcast` section (next to `bindings`). Keys are WhatsApp peer
|
||||
|
||||
**Result:** When OpenClaw would reply in this chat, it will run all three agents.
|
||||
|
||||
### Processing Strategy
|
||||
### Processing strategy
|
||||
|
||||
Control how agents process messages:
|
||||
|
||||
#### Parallel (Default)
|
||||
<Tabs>
|
||||
<Tab title="parallel (default)">
|
||||
All agents process simultaneously:
|
||||
|
||||
All agents process simultaneously:
|
||||
```json
|
||||
{
|
||||
"broadcast": {
|
||||
"strategy": "parallel",
|
||||
"120363403215116621@g.us": ["alfred", "baerbel"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"broadcast": {
|
||||
"strategy": "parallel",
|
||||
"120363403215116621@g.us": ["alfred", "baerbel"]
|
||||
}
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="sequential">
|
||||
Agents process in order (one waits for previous to finish):
|
||||
|
||||
#### Sequential
|
||||
```json
|
||||
{
|
||||
"broadcast": {
|
||||
"strategy": "sequential",
|
||||
"120363403215116621@g.us": ["alfred", "baerbel"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Agents process in order (one waits for previous to finish):
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
```json
|
||||
{
|
||||
"broadcast": {
|
||||
"strategy": "sequential",
|
||||
"120363403215116621@g.us": ["alfred", "baerbel"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Complete Example
|
||||
### Complete example
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -148,22 +153,32 @@ Agents process in order (one waits for previous to finish):
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
## How it works
|
||||
|
||||
### Message Flow
|
||||
### Message flow
|
||||
|
||||
1. **Incoming message** arrives in a WhatsApp group
|
||||
2. **Broadcast check**: System checks if peer ID is in `broadcast`
|
||||
3. **If in broadcast list**:
|
||||
- All listed agents process the message
|
||||
- Each agent has its own session key and isolated context
|
||||
- Agents process in parallel (default) or sequentially
|
||||
4. **If not in broadcast list**:
|
||||
- Normal routing applies (first matching binding)
|
||||
<Steps>
|
||||
<Step title="Incoming message arrives">
|
||||
A WhatsApp group or DM message arrives.
|
||||
</Step>
|
||||
<Step title="Broadcast check">
|
||||
System checks if peer ID is in `broadcast`.
|
||||
</Step>
|
||||
<Step title="If in broadcast list">
|
||||
- All listed agents process the message.
|
||||
- Each agent has its own session key and isolated context.
|
||||
- Agents process in parallel (default) or sequentially.
|
||||
</Step>
|
||||
<Step title="If not in broadcast list">
|
||||
Normal routing applies (first matching binding).
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
Note: broadcast groups do not bypass channel allowlists or group activation rules (mentions/commands/etc). They only change _which agents run_ when a message is eligible for processing.
|
||||
<Note>
|
||||
Broadcast groups do not bypass channel allowlists or group activation rules (mentions/commands/etc). They only change _which agents run_ when a message is eligible for processing.
|
||||
</Note>
|
||||
|
||||
### Session Isolation
|
||||
### Session isolation
|
||||
|
||||
Each agent in a broadcast group maintains completely separate:
|
||||
|
||||
@@ -181,92 +196,95 @@ This allows each agent to have:
|
||||
- Different models (e.g., opus vs. sonnet)
|
||||
- Different skills installed
|
||||
|
||||
### Example: Isolated Sessions
|
||||
### Example: isolated sessions
|
||||
|
||||
In group `120363403215116621@g.us` with agents `["alfred", "baerbel"]`:
|
||||
|
||||
**Alfred's context:**
|
||||
<Tabs>
|
||||
<Tab title="Alfred's context">
|
||||
```
|
||||
Session: agent:alfred:whatsapp:group:120363403215116621@g.us
|
||||
History: [user message, alfred's previous responses]
|
||||
Workspace: /Users/user/openclaw-alfred/
|
||||
Tools: read, write, exec
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Bärbel's context">
|
||||
```
|
||||
Session: agent:baerbel:whatsapp:group:120363403215116621@g.us
|
||||
History: [user message, baerbel's previous responses]
|
||||
Workspace: /Users/user/openclaw-baerbel/
|
||||
Tools: read only
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
```
|
||||
Session: agent:alfred:whatsapp:group:120363403215116621@g.us
|
||||
History: [user message, alfred's previous responses]
|
||||
Workspace: /Users/user/openclaw-alfred/
|
||||
Tools: read, write, exec
|
||||
```
|
||||
## Best practices
|
||||
|
||||
**Bärbel's context:**
|
||||
<AccordionGroup>
|
||||
<Accordion title="1. Keep agents focused">
|
||||
Design each agent with a single, clear responsibility:
|
||||
|
||||
```
|
||||
Session: agent:baerbel:whatsapp:group:120363403215116621@g.us
|
||||
History: [user message, baerbel's previous responses]
|
||||
Workspace: /Users/user/openclaw-baerbel/
|
||||
Tools: read only
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Keep Agents Focused
|
||||
|
||||
Design each agent with a single, clear responsibility:
|
||||
|
||||
```json
|
||||
{
|
||||
"broadcast": {
|
||||
"DEV_GROUP": ["formatter", "linter", "tester"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
✅ **Good:** Each agent has one job
|
||||
❌ **Bad:** One generic "dev-helper" agent
|
||||
|
||||
### 2. Use Descriptive Names
|
||||
|
||||
Make it clear what each agent does:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"security-scanner": { "name": "Security Scanner" },
|
||||
"code-formatter": { "name": "Code Formatter" },
|
||||
"test-generator": { "name": "Test Generator" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Configure Different Tool Access
|
||||
|
||||
Give agents only the tools they need:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"reviewer": {
|
||||
"tools": { "allow": ["read", "exec"] } // Read-only
|
||||
},
|
||||
"fixer": {
|
||||
"tools": { "allow": ["read", "write", "edit", "exec"] } // Read-write
|
||||
```json
|
||||
{
|
||||
"broadcast": {
|
||||
"DEV_GROUP": ["formatter", "linter", "tester"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
### 4. Monitor Performance
|
||||
✅ **Good:** Each agent has one job. ❌ **Bad:** One generic "dev-helper" agent.
|
||||
|
||||
With many agents, consider:
|
||||
</Accordion>
|
||||
<Accordion title="2. Use descriptive names">
|
||||
Make it clear what each agent does:
|
||||
|
||||
- Using `"strategy": "parallel"` (default) for speed
|
||||
- Limiting broadcast groups to 5-10 agents
|
||||
- Using faster models for simpler agents
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"security-scanner": { "name": "Security Scanner" },
|
||||
"code-formatter": { "name": "Code Formatter" },
|
||||
"test-generator": { "name": "Test Generator" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Handle Failures Gracefully
|
||||
</Accordion>
|
||||
<Accordion title="3. Configure different tool access">
|
||||
Give agents only the tools they need:
|
||||
|
||||
Agents fail independently. One agent's error doesn't block others:
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"reviewer": {
|
||||
"tools": { "allow": ["read", "exec"] } // Read-only
|
||||
},
|
||||
"fixer": {
|
||||
"tools": { "allow": ["read", "write", "edit", "exec"] } // Read-write
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
Message → [Agent A ✓, Agent B ✗ error, Agent C ✓]
|
||||
Result: Agent A and C respond, Agent B logs error
|
||||
```
|
||||
</Accordion>
|
||||
<Accordion title="4. Monitor performance">
|
||||
With many agents, consider:
|
||||
|
||||
- Using `"strategy": "parallel"` (default) for speed
|
||||
- Limiting broadcast groups to 5-10 agents
|
||||
- Using faster models for simpler agents
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="5. Handle failures gracefully">
|
||||
Agents fail independently. One agent's error doesn't block others:
|
||||
|
||||
```
|
||||
Message → [Agent A ✓, Agent B ✗ error, Agent C ✓]
|
||||
Result: Agent A and C respond, Agent B logs error
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Compatibility
|
||||
|
||||
@@ -297,108 +315,116 @@ Broadcast groups work alongside existing routing:
|
||||
}
|
||||
```
|
||||
|
||||
- `GROUP_A`: Only alfred responds (normal routing)
|
||||
- `GROUP_B`: agent1 AND agent2 respond (broadcast)
|
||||
- `GROUP_A`: Only alfred responds (normal routing).
|
||||
- `GROUP_B`: agent1 AND agent2 respond (broadcast).
|
||||
|
||||
<Note>
|
||||
**Precedence:** `broadcast` takes priority over `bindings`.
|
||||
</Note>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Agents Not Responding
|
||||
<AccordionGroup>
|
||||
<Accordion title="Agents not responding">
|
||||
**Check:**
|
||||
|
||||
**Check:**
|
||||
1. Agent IDs exist in `agents.list`.
|
||||
2. Peer ID format is correct (e.g., `120363403215116621@g.us`).
|
||||
3. Agents are not in deny lists.
|
||||
|
||||
1. Agent IDs exist in `agents.list`
|
||||
2. Peer ID format is correct (e.g., `120363403215116621@g.us`)
|
||||
3. Agents are not in deny lists
|
||||
**Debug:**
|
||||
|
||||
**Debug:**
|
||||
```bash
|
||||
tail -f ~/.openclaw/logs/gateway.log | grep broadcast
|
||||
```
|
||||
|
||||
```bash
|
||||
tail -f ~/.openclaw/logs/gateway.log | grep broadcast
|
||||
```
|
||||
</Accordion>
|
||||
<Accordion title="Only one agent responding">
|
||||
**Cause:** Peer ID might be in `bindings` but not `broadcast`.
|
||||
|
||||
### Only One Agent Responding
|
||||
**Fix:** Add to broadcast config or remove from bindings.
|
||||
|
||||
**Cause:** Peer ID might be in `bindings` but not `broadcast`.
|
||||
</Accordion>
|
||||
<Accordion title="Performance issues">
|
||||
If slow with many agents:
|
||||
|
||||
**Fix:** Add to broadcast config or remove from bindings.
|
||||
- Reduce number of agents per group.
|
||||
- Use lighter models (sonnet instead of opus).
|
||||
- Check sandbox startup time.
|
||||
|
||||
### Performance Issues
|
||||
|
||||
**If slow with many agents:**
|
||||
|
||||
- Reduce number of agents per group
|
||||
- Use lighter models (sonnet instead of opus)
|
||||
- Check sandbox startup time
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Code Review Team
|
||||
|
||||
```json
|
||||
{
|
||||
"broadcast": {
|
||||
"strategy": "parallel",
|
||||
"120363403215116621@g.us": [
|
||||
"code-formatter",
|
||||
"security-scanner",
|
||||
"test-coverage",
|
||||
"docs-checker"
|
||||
]
|
||||
},
|
||||
"agents": {
|
||||
"list": [
|
||||
{
|
||||
"id": "code-formatter",
|
||||
"workspace": "~/agents/formatter",
|
||||
"tools": { "allow": ["read", "write"] }
|
||||
<AccordionGroup>
|
||||
<Accordion title="Example 1: Code review team">
|
||||
```json
|
||||
{
|
||||
"broadcast": {
|
||||
"strategy": "parallel",
|
||||
"120363403215116621@g.us": [
|
||||
"code-formatter",
|
||||
"security-scanner",
|
||||
"test-coverage",
|
||||
"docs-checker"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "security-scanner",
|
||||
"workspace": "~/agents/security",
|
||||
"tools": { "allow": ["read", "exec"] }
|
||||
"agents": {
|
||||
"list": [
|
||||
{
|
||||
"id": "code-formatter",
|
||||
"workspace": "~/agents/formatter",
|
||||
"tools": { "allow": ["read", "write"] }
|
||||
},
|
||||
{
|
||||
"id": "security-scanner",
|
||||
"workspace": "~/agents/security",
|
||||
"tools": { "allow": ["read", "exec"] }
|
||||
},
|
||||
{
|
||||
"id": "test-coverage",
|
||||
"workspace": "~/agents/testing",
|
||||
"tools": { "allow": ["read", "exec"] }
|
||||
},
|
||||
{ "id": "docs-checker", "workspace": "~/agents/docs", "tools": { "allow": ["read"] } }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**User sends:** Code snippet.
|
||||
|
||||
**Responses:**
|
||||
|
||||
- code-formatter: "Fixed indentation and added type hints"
|
||||
- security-scanner: "⚠️ SQL injection vulnerability in line 12"
|
||||
- test-coverage: "Coverage is 45%, missing tests for error cases"
|
||||
- docs-checker: "Missing docstring for function `process_data`"
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Example 2: Multi-language support">
|
||||
```json
|
||||
{
|
||||
"broadcast": {
|
||||
"strategy": "sequential",
|
||||
"+15555550123": ["detect-language", "translator-en", "translator-de"]
|
||||
},
|
||||
{
|
||||
"id": "test-coverage",
|
||||
"workspace": "~/agents/testing",
|
||||
"tools": { "allow": ["read", "exec"] }
|
||||
},
|
||||
{ "id": "docs-checker", "workspace": "~/agents/docs", "tools": { "allow": ["read"] } }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
"agents": {
|
||||
"list": [
|
||||
{ "id": "detect-language", "workspace": "~/agents/lang-detect" },
|
||||
{ "id": "translator-en", "workspace": "~/agents/translate-en" },
|
||||
{ "id": "translator-de", "workspace": "~/agents/translate-de" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
**User sends:** Code snippet
|
||||
**Responses:**
|
||||
## API reference
|
||||
|
||||
- code-formatter: "Fixed indentation and added type hints"
|
||||
- security-scanner: "⚠️ SQL injection vulnerability in line 12"
|
||||
- test-coverage: "Coverage is 45%, missing tests for error cases"
|
||||
- docs-checker: "Missing docstring for function `process_data`"
|
||||
|
||||
### Example 2: Multi-Language Support
|
||||
|
||||
```json
|
||||
{
|
||||
"broadcast": {
|
||||
"strategy": "sequential",
|
||||
"+15555550123": ["detect-language", "translator-en", "translator-de"]
|
||||
},
|
||||
"agents": {
|
||||
"list": [
|
||||
{ "id": "detect-language", "workspace": "~/agents/lang-detect" },
|
||||
{ "id": "translator-en", "workspace": "~/agents/translate-en" },
|
||||
{ "id": "translator-de", "workspace": "~/agents/translate-de" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Config Schema
|
||||
### Config schema
|
||||
|
||||
```typescript
|
||||
interface OpenClawConfig {
|
||||
@@ -411,20 +437,21 @@ interface OpenClawConfig {
|
||||
|
||||
### Fields
|
||||
|
||||
- `strategy` (optional): How to process agents
|
||||
- `"parallel"` (default): All agents process simultaneously
|
||||
- `"sequential"`: Agents process in array order
|
||||
- `[peerId]`: WhatsApp group JID, E.164 number, or other peer ID
|
||||
- Value: Array of agent IDs that should process messages
|
||||
<ParamField path="strategy" type='"parallel" | "sequential"' default='"parallel"'>
|
||||
How to process agents. `parallel` runs all agents simultaneously; `sequential` runs them in array order.
|
||||
</ParamField>
|
||||
<ParamField path="[peerId]" type="string[]">
|
||||
WhatsApp group JID, E.164 number, or other peer ID. Value is the array of agent IDs that should process messages.
|
||||
</ParamField>
|
||||
|
||||
## Limitations
|
||||
|
||||
1. **Max agents:** No hard limit, but 10+ agents may be slow
|
||||
2. **Shared context:** Agents don't see each other's responses (by design)
|
||||
3. **Message ordering:** Parallel responses may arrive in any order
|
||||
4. **Rate limits:** All agents count toward WhatsApp rate limits
|
||||
1. **Max agents:** No hard limit, but 10+ agents may be slow.
|
||||
2. **Shared context:** Agents don't see each other's responses (by design).
|
||||
3. **Message ordering:** Parallel responses may arrive in any order.
|
||||
4. **Rate limits:** All agents count toward WhatsApp rate limits.
|
||||
|
||||
## Future Enhancements
|
||||
## Future enhancements
|
||||
|
||||
Planned features:
|
||||
|
||||
@@ -435,8 +462,8 @@ Planned features:
|
||||
|
||||
## Related
|
||||
|
||||
- [Groups](/channels/groups)
|
||||
- [Channel routing](/channels/channel-routing)
|
||||
- [Pairing](/channels/pairing)
|
||||
- [Groups](/channels/groups)
|
||||
- [Multi-agent sandbox tools](/tools/multi-agent-sandbox-tools)
|
||||
- [Pairing](/channels/pairing)
|
||||
- [Session management](/concepts/session)
|
||||
|
||||
@@ -3,50 +3,69 @@ summary: "Twitch chat bot configuration and setup"
|
||||
read_when:
|
||||
- Setting up Twitch chat integration for OpenClaw
|
||||
title: "Twitch"
|
||||
sidebarTitle: "Twitch"
|
||||
---
|
||||
|
||||
Twitch chat support via IRC connection. OpenClaw connects as a Twitch user (bot account) to receive and send messages in channels.
|
||||
|
||||
## Bundled plugin
|
||||
|
||||
Twitch ships as a bundled plugin in current OpenClaw releases, so normal
|
||||
packaged builds do not need a separate install.
|
||||
<Note>
|
||||
Twitch ships as a bundled plugin in current OpenClaw releases, so normal packaged builds do not need a separate install.
|
||||
</Note>
|
||||
|
||||
If you are on an older build or a custom install that excludes Twitch, install
|
||||
it manually:
|
||||
If you are on an older build or a custom install that excludes Twitch, install it manually:
|
||||
|
||||
Install via CLI (npm registry):
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/twitch
|
||||
```
|
||||
|
||||
Local checkout (when running from a git repo):
|
||||
|
||||
```bash
|
||||
openclaw plugins install ./path/to/local/twitch-plugin
|
||||
```
|
||||
<Tabs>
|
||||
<Tab title="npm registry">
|
||||
```bash
|
||||
openclaw plugins install @openclaw/twitch
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Local checkout">
|
||||
```bash
|
||||
openclaw plugins install ./path/to/local/twitch-plugin
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Details: [Plugins](/tools/plugin)
|
||||
|
||||
## Quick setup (beginner)
|
||||
|
||||
1. Ensure the Twitch plugin is available.
|
||||
- Current packaged OpenClaw releases already bundle it.
|
||||
- Older/custom installs can add it manually with the commands above.
|
||||
2. Create a dedicated Twitch account for the bot (or use an existing account).
|
||||
3. Generate credentials: [Twitch Token Generator](https://twitchtokengenerator.com/)
|
||||
- Select **Bot Token**
|
||||
- Verify scopes `chat:read` and `chat:write` are selected
|
||||
- Copy the **Client ID** and **Access Token**
|
||||
4. Find your Twitch user ID: [https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/](https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/)
|
||||
5. Configure the token:
|
||||
- Env: `OPENCLAW_TWITCH_ACCESS_TOKEN=...` (default account only)
|
||||
- Or config: `channels.twitch.accessToken`
|
||||
- If both are set, config takes precedence (env fallback is default-account only).
|
||||
6. Start the gateway.
|
||||
<Steps>
|
||||
<Step title="Ensure plugin is available">
|
||||
Current packaged OpenClaw releases already bundle it. Older/custom installs can add it manually with the commands above.
|
||||
</Step>
|
||||
<Step title="Create a Twitch bot account">
|
||||
Create a dedicated Twitch account for the bot (or use an existing account).
|
||||
</Step>
|
||||
<Step title="Generate credentials">
|
||||
Use [Twitch Token Generator](https://twitchtokengenerator.com/):
|
||||
|
||||
**⚠️ Important:** Add access control (`allowFrom` or `allowedRoles`) to prevent unauthorized users from triggering the bot. `requireMention` defaults to `true`.
|
||||
- Select **Bot Token**
|
||||
- Verify scopes `chat:read` and `chat:write` are selected
|
||||
- Copy the **Client ID** and **Access Token**
|
||||
|
||||
</Step>
|
||||
<Step title="Find your Twitch user ID">
|
||||
Use [https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/](https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/) to convert a username to a Twitch user ID.
|
||||
</Step>
|
||||
<Step title="Configure the token">
|
||||
- Env: `OPENCLAW_TWITCH_ACCESS_TOKEN=...` (default account only)
|
||||
- Or config: `channels.twitch.accessToken`
|
||||
|
||||
If both are set, config takes precedence (env fallback is default-account only).
|
||||
|
||||
</Step>
|
||||
<Step title="Start the gateway">
|
||||
Start the gateway with the configured channel.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Warning>
|
||||
Add access control (`allowFrom` or `allowedRoles`) to prevent unauthorized users from triggering the bot. `requireMention` defaults to `true`.
|
||||
</Warning>
|
||||
|
||||
Minimal config:
|
||||
|
||||
@@ -82,31 +101,34 @@ Use [Twitch Token Generator](https://twitchtokengenerator.com/):
|
||||
- Verify scopes `chat:read` and `chat:write` are selected
|
||||
- Copy the **Client ID** and **Access Token**
|
||||
|
||||
<Note>
|
||||
No manual app registration needed. Tokens expire after several hours.
|
||||
</Note>
|
||||
|
||||
### Configure the bot
|
||||
|
||||
**Env var (default account only):**
|
||||
|
||||
```bash
|
||||
OPENCLAW_TWITCH_ACCESS_TOKEN=oauth:abc123...
|
||||
```
|
||||
|
||||
**Or config:**
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
twitch: {
|
||||
enabled: true,
|
||||
username: "openclaw",
|
||||
accessToken: "oauth:abc123...",
|
||||
clientId: "xyz789...",
|
||||
channel: "vevisk",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
<Tabs>
|
||||
<Tab title="Env var (default account only)">
|
||||
```bash
|
||||
OPENCLAW_TWITCH_ACCESS_TOKEN=oauth:abc123...
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Config">
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
twitch: {
|
||||
enabled: true,
|
||||
username: "openclaw",
|
||||
accessToken: "oauth:abc123...",
|
||||
clientId: "xyz789...",
|
||||
channel: "vevisk",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
If both env and config are set, config takes precedence.
|
||||
|
||||
@@ -126,9 +148,11 @@ Prefer `allowFrom` for a hard allowlist. Use `allowedRoles` instead if you want
|
||||
|
||||
**Available roles:** `"moderator"`, `"owner"`, `"vip"`, `"subscriber"`, `"all"`.
|
||||
|
||||
<Note>
|
||||
**Why user IDs?** Usernames can change, allowing impersonation. User IDs are permanent.
|
||||
|
||||
Find your Twitch user ID: [https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/](https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/) (Convert your Twitch username to ID)
|
||||
</Note>
|
||||
|
||||
## Token refresh (optional)
|
||||
|
||||
@@ -151,7 +175,7 @@ The bot automatically refreshes tokens before expiration and logs refresh events
|
||||
|
||||
## Multi-account support
|
||||
|
||||
Use `channels.twitch.accounts` with per-account tokens. See [`gateway/configuration`](/gateway/configuration) for the shared pattern.
|
||||
Use `channels.twitch.accounts` with per-account tokens. See [Configuration](/gateway/configuration) for the shared pattern.
|
||||
|
||||
Example (one bot account in two channels):
|
||||
|
||||
@@ -178,78 +202,65 @@ Example (one bot account in two channels):
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Each account needs its own token (one token per channel).
|
||||
<Note>
|
||||
Each account needs its own token (one token per channel).
|
||||
</Note>
|
||||
|
||||
## Access control
|
||||
|
||||
### Role-based restrictions
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
default: {
|
||||
allowedRoles: ["moderator", "vip"],
|
||||
<Tabs>
|
||||
<Tab title="User ID allowlist (most secure)">
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
default: {
|
||||
allowFrom: ["123456789", "987654321"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Allowlist by User ID (most secure)
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
default: {
|
||||
allowFrom: ["123456789", "987654321"],
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Role-based">
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
default: {
|
||||
allowedRoles: ["moderator", "vip"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
}
|
||||
```
|
||||
|
||||
### Role-based access (alternative)
|
||||
`allowFrom` is a hard allowlist. When set, only those user IDs are allowed. If you want role-based access, leave `allowFrom` unset and configure `allowedRoles` instead.
|
||||
|
||||
`allowFrom` is a hard allowlist. When set, only those user IDs are allowed.
|
||||
If you want role-based access, leave `allowFrom` unset and configure `allowedRoles` instead:
|
||||
</Tab>
|
||||
<Tab title="Disable @mention requirement">
|
||||
By default, `requireMention` is `true`. To disable and respond to all messages:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
default: {
|
||||
allowedRoles: ["moderator"],
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
default: {
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
}
|
||||
```
|
||||
|
||||
### Disable @mention requirement
|
||||
|
||||
By default, `requireMention` is `true`. To disable and respond to all messages:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
default: {
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -260,53 +271,77 @@ openclaw doctor
|
||||
openclaw channels status --probe
|
||||
```
|
||||
|
||||
### Bot does not respond to messages
|
||||
<AccordionGroup>
|
||||
<Accordion title="Bot does not respond to messages">
|
||||
- **Check access control:** Ensure your user ID is in `allowFrom`, or temporarily remove `allowFrom` and set `allowedRoles: ["all"]` to test.
|
||||
- **Check the bot is in the channel:** The bot must join the channel specified in `channel`.
|
||||
</Accordion>
|
||||
<Accordion title="Token issues">
|
||||
"Failed to connect" or authentication errors:
|
||||
|
||||
**Check access control:** Ensure your user ID is in `allowFrom`, or temporarily remove
|
||||
`allowFrom` and set `allowedRoles: ["all"]` to test.
|
||||
- Verify `accessToken` is the OAuth access token value (typically starts with `oauth:` prefix)
|
||||
- Check token has `chat:read` and `chat:write` scopes
|
||||
- If using token refresh, verify `clientSecret` and `refreshToken` are set
|
||||
|
||||
**Check the bot is in the channel:** The bot must join the channel specified in `channel`.
|
||||
</Accordion>
|
||||
<Accordion title="Token refresh not working">
|
||||
Check logs for refresh events:
|
||||
|
||||
### Token issues
|
||||
```
|
||||
Using env token source for mybot
|
||||
Access token refreshed for user 123456 (expires in 14400s)
|
||||
```
|
||||
|
||||
**"Failed to connect" or authentication errors:**
|
||||
If you see "token refresh disabled (no refresh token)":
|
||||
|
||||
- Verify `accessToken` is the OAuth access token value (typically starts with `oauth:` prefix)
|
||||
- Check token has `chat:read` and `chat:write` scopes
|
||||
- If using token refresh, verify `clientSecret` and `refreshToken` are set
|
||||
- Ensure `clientSecret` is provided
|
||||
- Ensure `refreshToken` is provided
|
||||
|
||||
### Token refresh not working
|
||||
|
||||
**Check logs for refresh events:**
|
||||
|
||||
```
|
||||
Using env token source for mybot
|
||||
Access token refreshed for user 123456 (expires in 14400s)
|
||||
```
|
||||
|
||||
If you see "token refresh disabled (no refresh token)":
|
||||
|
||||
- Ensure `clientSecret` is provided
|
||||
- Ensure `refreshToken` is provided
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Config
|
||||
|
||||
**Account config:**
|
||||
### Account config
|
||||
|
||||
- `username` - Bot username
|
||||
- `accessToken` - OAuth access token with `chat:read` and `chat:write`
|
||||
- `clientId` - Twitch Client ID (from Token Generator or your app)
|
||||
- `channel` - Channel to join (required)
|
||||
- `enabled` - Enable this account (default: `true`)
|
||||
- `clientSecret` - Optional: For automatic token refresh
|
||||
- `refreshToken` - Optional: For automatic token refresh
|
||||
- `expiresIn` - Token expiry in seconds
|
||||
- `obtainmentTimestamp` - Token obtained timestamp
|
||||
- `allowFrom` - User ID allowlist
|
||||
- `allowedRoles` - Role-based access control (`"moderator" | "owner" | "vip" | "subscriber" | "all"`)
|
||||
- `requireMention` - Require @mention (default: `true`)
|
||||
<ParamField path="username" type="string">
|
||||
Bot username.
|
||||
</ParamField>
|
||||
<ParamField path="accessToken" type="string">
|
||||
OAuth access token with `chat:read` and `chat:write`.
|
||||
</ParamField>
|
||||
<ParamField path="clientId" type="string">
|
||||
Twitch Client ID (from Token Generator or your app).
|
||||
</ParamField>
|
||||
<ParamField path="channel" type="string" required>
|
||||
Channel to join.
|
||||
</ParamField>
|
||||
<ParamField path="enabled" type="boolean" default="true">
|
||||
Enable this account.
|
||||
</ParamField>
|
||||
<ParamField path="clientSecret" type="string">
|
||||
Optional: for automatic token refresh.
|
||||
</ParamField>
|
||||
<ParamField path="refreshToken" type="string">
|
||||
Optional: for automatic token refresh.
|
||||
</ParamField>
|
||||
<ParamField path="expiresIn" type="number">
|
||||
Token expiry in seconds.
|
||||
</ParamField>
|
||||
<ParamField path="obtainmentTimestamp" type="number">
|
||||
Token obtained timestamp.
|
||||
</ParamField>
|
||||
<ParamField path="allowFrom" type="string[]">
|
||||
User ID allowlist.
|
||||
</ParamField>
|
||||
<ParamField path="allowedRoles" type='Array<"moderator" | "owner" | "vip" | "subscriber" | "all">'>
|
||||
Role-based access control.
|
||||
</ParamField>
|
||||
<ParamField path="requireMention" type="boolean" default="true">
|
||||
Require @mention.
|
||||
</ParamField>
|
||||
|
||||
**Provider options:**
|
||||
### Provider options
|
||||
|
||||
- `channels.twitch.enabled` - Enable/disable channel startup
|
||||
- `channels.twitch.username` - Bot username (simplified single-account config)
|
||||
@@ -368,25 +403,25 @@ Example:
|
||||
}
|
||||
```
|
||||
|
||||
## Safety & ops
|
||||
## Safety and ops
|
||||
|
||||
- **Treat tokens like passwords** - Never commit tokens to git
|
||||
- **Use automatic token refresh** for long-running bots
|
||||
- **Use user ID allowlists** instead of usernames for access control
|
||||
- **Monitor logs** for token refresh events and connection status
|
||||
- **Scope tokens minimally** - Only request `chat:read` and `chat:write`
|
||||
- **If stuck**: Restart the gateway after confirming no other process owns the session
|
||||
- **Treat tokens like passwords** — Never commit tokens to git.
|
||||
- **Use automatic token refresh** for long-running bots.
|
||||
- **Use user ID allowlists** instead of usernames for access control.
|
||||
- **Monitor logs** for token refresh events and connection status.
|
||||
- **Scope tokens minimally** — Only request `chat:read` and `chat:write`.
|
||||
- **If stuck**: Restart the gateway after confirming no other process owns the session.
|
||||
|
||||
## Limits
|
||||
|
||||
- **500 characters** per message (auto-chunked at word boundaries)
|
||||
- Markdown is stripped before chunking
|
||||
- No rate limiting (uses Twitch's built-in rate limits)
|
||||
- **500 characters** per message (auto-chunked at word boundaries).
|
||||
- Markdown is stripped before chunking.
|
||||
- No rate limiting (uses Twitch's built-in rate limits).
|
||||
|
||||
## Related
|
||||
|
||||
- [Channels Overview](/channels) — all supported channels
|
||||
- [Pairing](/channels/pairing) — DM authentication and pairing flow
|
||||
- [Groups](/channels/groups) — group chat behavior and mention gating
|
||||
- [Channel Routing](/channels/channel-routing) — session routing for messages
|
||||
- [Channels Overview](/channels) — all supported channels
|
||||
- [Groups](/channels/groups) — group chat behavior and mention gating
|
||||
- [Pairing](/channels/pairing) — DM authentication and pairing flow
|
||||
- [Security](/gateway/security) — access model and hardening
|
||||
|
||||
@@ -5,19 +5,22 @@ read_when:
|
||||
- Debugging Gateway auth, bind modes, and connectivity
|
||||
- Discovering gateways via Bonjour (local + wide-area DNS-SD)
|
||||
title: "Gateway"
|
||||
sidebarTitle: "Gateway"
|
||||
---
|
||||
|
||||
# Gateway CLI
|
||||
The Gateway is OpenClaw's WebSocket server (channels, nodes, sessions, hooks). Subcommands in this page live under `openclaw gateway …`.
|
||||
|
||||
The Gateway is OpenClaw’s WebSocket server (channels, nodes, sessions, hooks).
|
||||
|
||||
Subcommands in this page live under `openclaw gateway …`.
|
||||
|
||||
Related docs:
|
||||
|
||||
- [/gateway/bonjour](/gateway/bonjour)
|
||||
- [/gateway/discovery](/gateway/discovery)
|
||||
- [/gateway/configuration](/gateway/configuration)
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Bonjour discovery" href="/gateway/bonjour">
|
||||
Local mDNS + wide-area DNS-SD setup.
|
||||
</Card>
|
||||
<Card title="Discovery overview" href="/gateway/discovery">
|
||||
How OpenClaw advertises and finds gateways.
|
||||
</Card>
|
||||
<Card title="Configuration" href="/gateway/configuration">
|
||||
Top-level gateway config keys.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Run the Gateway
|
||||
|
||||
@@ -33,37 +36,79 @@ Foreground alias:
|
||||
openclaw gateway run
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.openclaw/openclaw.json`. Use `--allow-unconfigured` for ad-hoc/dev runs.
|
||||
- `openclaw onboard --mode local` and `openclaw setup` are expected to write `gateway.mode=local`. If the file exists but `gateway.mode` is missing, treat that as a broken or clobbered config and repair it instead of assuming local mode implicitly.
|
||||
- If the file exists and `gateway.mode` is missing, the Gateway treats that as suspicious config damage and refuses to “guess local” for you.
|
||||
- Binding beyond loopback without auth is blocked (safety guardrail).
|
||||
- `SIGUSR1` triggers an in-process restart when authorized (`commands.restart` is enabled by default; set `commands.restart: false` to block manual restart, while gateway tool/config apply/update remain allowed).
|
||||
- `SIGINT`/`SIGTERM` handlers stop the gateway process, but they don’t restore any custom terminal state. If you wrap the CLI with a TUI or raw-mode input, restore the terminal before exit.
|
||||
<AccordionGroup>
|
||||
<Accordion title="Startup behavior">
|
||||
- By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.openclaw/openclaw.json`. Use `--allow-unconfigured` for ad-hoc/dev runs.
|
||||
- `openclaw onboard --mode local` and `openclaw setup` are expected to write `gateway.mode=local`. If the file exists but `gateway.mode` is missing, treat that as a broken or clobbered config and repair it instead of assuming local mode implicitly.
|
||||
- If the file exists and `gateway.mode` is missing, the Gateway treats that as suspicious config damage and refuses to "guess local" for you.
|
||||
- Binding beyond loopback without auth is blocked (safety guardrail).
|
||||
- `SIGUSR1` triggers an in-process restart when authorized (`commands.restart` is enabled by default; set `commands.restart: false` to block manual restart, while gateway tool/config apply/update remain allowed).
|
||||
- `SIGINT`/`SIGTERM` handlers stop the gateway process, but they don't restore any custom terminal state. If you wrap the CLI with a TUI or raw-mode input, restore the terminal before exit.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### Options
|
||||
|
||||
- `--port <port>`: WebSocket port (default comes from config/env; usually `18789`).
|
||||
- `--bind <loopback|lan|tailnet|auto|custom>`: listener bind mode.
|
||||
- `--auth <token|password>`: auth mode override.
|
||||
- `--token <token>`: token override (also sets `OPENCLAW_GATEWAY_TOKEN` for the process).
|
||||
- `--password <password>`: password override. Warning: inline passwords can be exposed in local process listings.
|
||||
- `--password-file <path>`: read the gateway password from a file.
|
||||
- `--tailscale <off|serve|funnel>`: expose the Gateway via Tailscale.
|
||||
- `--tailscale-reset-on-exit`: reset Tailscale serve/funnel config on shutdown.
|
||||
- `--allow-unconfigured`: allow gateway start without `gateway.mode=local` in config. This bypasses the startup guard for ad-hoc/dev bootstrap only; it does not write or repair the config file.
|
||||
- `--dev`: create a dev config + workspace if missing (skips BOOTSTRAP.md).
|
||||
- `--reset`: reset dev config + credentials + sessions + workspace (requires `--dev`).
|
||||
- `--force`: kill any existing listener on the selected port before starting.
|
||||
- `--verbose`: verbose logs.
|
||||
- `--cli-backend-logs`: only show CLI backend logs in the console (and enable stdout/stderr).
|
||||
- `--ws-log <auto|full|compact>`: websocket log style (default `auto`).
|
||||
- `--compact`: alias for `--ws-log compact`.
|
||||
- `--raw-stream`: log raw model stream events to jsonl.
|
||||
- `--raw-stream-path <path>`: raw stream jsonl path.
|
||||
<ParamField path="--port <port>" type="number">
|
||||
WebSocket port (default comes from config/env; usually `18789`).
|
||||
</ParamField>
|
||||
<ParamField path="--bind <loopback|lan|tailnet|auto|custom>" type="string">
|
||||
Listener bind mode.
|
||||
</ParamField>
|
||||
<ParamField path="--auth <token|password>" type="string">
|
||||
Auth mode override.
|
||||
</ParamField>
|
||||
<ParamField path="--token <token>" type="string">
|
||||
Token override (also sets `OPENCLAW_GATEWAY_TOKEN` for the process).
|
||||
</ParamField>
|
||||
<ParamField path="--password <password>" type="string">
|
||||
Password override.
|
||||
</ParamField>
|
||||
<ParamField path="--password-file <path>" type="string">
|
||||
Read the gateway password from a file.
|
||||
</ParamField>
|
||||
<ParamField path="--tailscale <off|serve|funnel>" type="string">
|
||||
Expose the Gateway via Tailscale.
|
||||
</ParamField>
|
||||
<ParamField path="--tailscale-reset-on-exit" type="boolean">
|
||||
Reset Tailscale serve/funnel config on shutdown.
|
||||
</ParamField>
|
||||
<ParamField path="--allow-unconfigured" type="boolean">
|
||||
Allow gateway start without `gateway.mode=local` in config. Bypasses the startup guard for ad-hoc/dev bootstrap only; does not write or repair the config file.
|
||||
</ParamField>
|
||||
<ParamField path="--dev" type="boolean">
|
||||
Create a dev config + workspace if missing (skips BOOTSTRAP.md).
|
||||
</ParamField>
|
||||
<ParamField path="--reset" type="boolean">
|
||||
Reset dev config + credentials + sessions + workspace (requires `--dev`).
|
||||
</ParamField>
|
||||
<ParamField path="--force" type="boolean">
|
||||
Kill any existing listener on the selected port before starting.
|
||||
</ParamField>
|
||||
<ParamField path="--verbose" type="boolean">
|
||||
Verbose logs.
|
||||
</ParamField>
|
||||
<ParamField path="--cli-backend-logs" type="boolean">
|
||||
Only show CLI backend logs in the console (and enable stdout/stderr).
|
||||
</ParamField>
|
||||
<ParamField path="--ws-log <auto|full|compact>" type="string" default="auto">
|
||||
Websocket log style.
|
||||
</ParamField>
|
||||
<ParamField path="--compact" type="boolean">
|
||||
Alias for `--ws-log compact`.
|
||||
</ParamField>
|
||||
<ParamField path="--raw-stream" type="boolean">
|
||||
Log raw model stream events to jsonl.
|
||||
</ParamField>
|
||||
<ParamField path="--raw-stream-path <path>" type="string">
|
||||
Raw stream jsonl path.
|
||||
</ParamField>
|
||||
|
||||
Startup profiling:
|
||||
<Warning>
|
||||
Inline `--password` can be exposed in local process listings. Prefer `--password-file`, env, or a SecretRef-backed `gateway.auth.password`.
|
||||
</Warning>
|
||||
|
||||
### Startup profiling
|
||||
|
||||
- Set `OPENCLAW_GATEWAY_STARTUP_TRACE=1` to log phase timings during Gateway startup.
|
||||
- Run `pnpm test:startup:gateway -- --runs 5 --warmup 1` to benchmark Gateway startup. The benchmark records first process output, `/healthz`, `/readyz`, and startup trace timings.
|
||||
@@ -72,22 +117,24 @@ Startup profiling:
|
||||
|
||||
All query commands use WebSocket RPC.
|
||||
|
||||
Output modes:
|
||||
<Tabs>
|
||||
<Tab title="Output modes">
|
||||
- Default: human-readable (colored in TTY).
|
||||
- `--json`: machine-readable JSON (no styling/spinner).
|
||||
- `--no-color` (or `NO_COLOR=1`): disable ANSI while keeping human layout.
|
||||
</Tab>
|
||||
<Tab title="Shared options">
|
||||
- `--url <url>`: Gateway WebSocket URL.
|
||||
- `--token <token>`: Gateway token.
|
||||
- `--password <password>`: Gateway password.
|
||||
- `--timeout <ms>`: timeout/budget (varies per command).
|
||||
- `--expect-final`: wait for a "final" response (agent calls).
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
- Default: human-readable (colored in TTY).
|
||||
- `--json`: machine-readable JSON (no styling/spinner).
|
||||
- `--no-color` (or `NO_COLOR=1`): disable ANSI while keeping human layout.
|
||||
|
||||
Shared options (where supported):
|
||||
|
||||
- `--url <url>`: Gateway WebSocket URL.
|
||||
- `--token <token>`: Gateway token.
|
||||
- `--password <password>`: Gateway password.
|
||||
- `--timeout <ms>`: timeout/budget (varies per command).
|
||||
- `--expect-final`: wait for a “final” response (agent calls).
|
||||
|
||||
Note: when you set `--url`, the CLI does not fall back to config or environment credentials.
|
||||
Pass `--token` or `--password` explicitly. Missing explicit credentials is an error.
|
||||
<Note>
|
||||
When you set `--url`, the CLI does not fall back to config or environment credentials. Pass `--token` or `--password` explicitly. Missing explicit credentials is an error.
|
||||
</Note>
|
||||
|
||||
### `gateway health`
|
||||
|
||||
@@ -107,9 +154,9 @@ openclaw gateway usage-cost --days 7
|
||||
openclaw gateway usage-cost --json
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--days <days>`: number of days to include (default `30`).
|
||||
<ParamField path="--days <days>" type="number" default="30">
|
||||
Number of days to include.
|
||||
</ParamField>
|
||||
|
||||
### `gateway stability`
|
||||
|
||||
@@ -123,24 +170,35 @@ openclaw gateway stability --bundle latest --export
|
||||
openclaw gateway stability --json
|
||||
```
|
||||
|
||||
Options:
|
||||
<ParamField path="--limit <limit>" type="number" default="25">
|
||||
Maximum number of recent events to include (max `1000`).
|
||||
</ParamField>
|
||||
<ParamField path="--type <type>" type="string">
|
||||
Filter by diagnostic event type, such as `payload.large` or `diagnostic.memory.pressure`.
|
||||
</ParamField>
|
||||
<ParamField path="--since-seq <seq>" type="number">
|
||||
Include only events after a diagnostic sequence number.
|
||||
</ParamField>
|
||||
<ParamField path="--bundle [path]" type="string">
|
||||
Read a persisted stability bundle instead of calling the running Gateway. Use `--bundle latest` (or just `--bundle`) for the newest bundle under the state directory, or pass a bundle JSON path directly.
|
||||
</ParamField>
|
||||
<ParamField path="--export" type="boolean">
|
||||
Write a shareable support diagnostics zip instead of printing stability details.
|
||||
</ParamField>
|
||||
<ParamField path="--output <path>" type="string">
|
||||
Output path for `--export`.
|
||||
</ParamField>
|
||||
|
||||
- `--limit <limit>`: maximum number of recent events to include (default `25`, max `1000`).
|
||||
- `--type <type>`: filter by diagnostic event type, such as `payload.large` or `diagnostic.memory.pressure`.
|
||||
- `--since-seq <seq>`: include only events after a diagnostic sequence number.
|
||||
- `--bundle [path]`: read a persisted stability bundle instead of calling the running Gateway. Use `--bundle latest` (or just `--bundle`) for the newest bundle under the state directory, or pass a bundle JSON path directly.
|
||||
- `--export`: write a shareable support diagnostics zip instead of printing stability details.
|
||||
- `--output <path>`: output path for `--export`.
|
||||
|
||||
Notes:
|
||||
|
||||
- Records keep operational metadata: event names, counts, byte sizes, memory readings, queue/session state, channel/plugin names, and redacted session summaries. They do not keep chat text, webhook bodies, tool outputs, raw request or response bodies, tokens, cookies, secret values, hostnames, or raw session ids. Set `diagnostics.enabled: false` to disable the recorder entirely.
|
||||
- On fatal Gateway exits, shutdown timeouts, and restart startup failures, OpenClaw writes the same diagnostic snapshot to `~/.openclaw/logs/stability/openclaw-stability-*.json` when the recorder has events. Inspect the newest bundle with `openclaw gateway stability --bundle latest`; `--limit`, `--type`, and `--since-seq` also apply to bundle output.
|
||||
<AccordionGroup>
|
||||
<Accordion title="Privacy and bundle behavior">
|
||||
- Records keep operational metadata: event names, counts, byte sizes, memory readings, queue/session state, channel/plugin names, and redacted session summaries. They do not keep chat text, webhook bodies, tool outputs, raw request or response bodies, tokens, cookies, secret values, hostnames, or raw session ids. Set `diagnostics.enabled: false` to disable the recorder entirely.
|
||||
- On fatal Gateway exits, shutdown timeouts, and restart startup failures, OpenClaw writes the same diagnostic snapshot to `~/.openclaw/logs/stability/openclaw-stability-*.json` when the recorder has events. Inspect the newest bundle with `openclaw gateway stability --bundle latest`; `--limit`, `--type`, and `--since-seq` also apply to bundle output.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### `gateway diagnostics export`
|
||||
|
||||
Write a local diagnostics zip that is designed to attach to bug reports.
|
||||
For the privacy model and bundle contents, see [Diagnostics Export](/gateway/diagnostics).
|
||||
Write a local diagnostics zip that is designed to attach to bug reports. For the privacy model and bundle contents, see [Diagnostics Export](/gateway/diagnostics).
|
||||
|
||||
```bash
|
||||
openclaw gateway diagnostics export
|
||||
@@ -148,17 +206,33 @@ openclaw gateway diagnostics export --output openclaw-diagnostics.zip
|
||||
openclaw gateway diagnostics export --json
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--output <path>`: output zip path. Defaults to a support export under the state directory.
|
||||
- `--log-lines <count>`: maximum sanitized log lines to include (default `5000`).
|
||||
- `--log-bytes <bytes>`: maximum log bytes to inspect (default `1000000`).
|
||||
- `--url <url>`: Gateway WebSocket URL for the health snapshot.
|
||||
- `--token <token>`: Gateway token for the health snapshot.
|
||||
- `--password <password>`: Gateway password for the health snapshot.
|
||||
- `--timeout <ms>`: status/health snapshot timeout (default `3000`).
|
||||
- `--no-stability-bundle`: skip persisted stability bundle lookup.
|
||||
- `--json`: print the written path, size, and manifest as JSON.
|
||||
<ParamField path="--output <path>" type="string">
|
||||
Output zip path. Defaults to a support export under the state directory.
|
||||
</ParamField>
|
||||
<ParamField path="--log-lines <count>" type="number" default="5000">
|
||||
Maximum sanitized log lines to include.
|
||||
</ParamField>
|
||||
<ParamField path="--log-bytes <bytes>" type="number" default="1000000">
|
||||
Maximum log bytes to inspect.
|
||||
</ParamField>
|
||||
<ParamField path="--url <url>" type="string">
|
||||
Gateway WebSocket URL for the health snapshot.
|
||||
</ParamField>
|
||||
<ParamField path="--token <token>" type="string">
|
||||
Gateway token for the health snapshot.
|
||||
</ParamField>
|
||||
<ParamField path="--password <password>" type="string">
|
||||
Gateway password for the health snapshot.
|
||||
</ParamField>
|
||||
<ParamField path="--timeout <ms>" type="number" default="3000">
|
||||
Status/health snapshot timeout.
|
||||
</ParamField>
|
||||
<ParamField path="--no-stability-bundle" type="boolean">
|
||||
Skip persisted stability bundle lookup.
|
||||
</ParamField>
|
||||
<ParamField path="--json" type="boolean">
|
||||
Print the written path, size, and manifest as JSON.
|
||||
</ParamField>
|
||||
|
||||
The export contains a manifest, a Markdown summary, config shape, sanitized config details, sanitized log summaries, sanitized Gateway status/health snapshots, and the newest stability bundle when one exists.
|
||||
|
||||
@@ -174,93 +248,113 @@ openclaw gateway status --json
|
||||
openclaw gateway status --require-rpc
|
||||
```
|
||||
|
||||
Options:
|
||||
<ParamField path="--url <url>" type="string">
|
||||
Add an explicit probe target. Configured remote + localhost are still probed.
|
||||
</ParamField>
|
||||
<ParamField path="--token <token>" type="string">
|
||||
Token auth for the probe.
|
||||
</ParamField>
|
||||
<ParamField path="--password <password>" type="string">
|
||||
Password auth for the probe.
|
||||
</ParamField>
|
||||
<ParamField path="--timeout <ms>" type="number" default="10000">
|
||||
Probe timeout.
|
||||
</ParamField>
|
||||
<ParamField path="--no-probe" type="boolean">
|
||||
Skip the connectivity probe (service-only view).
|
||||
</ParamField>
|
||||
<ParamField path="--deep" type="boolean">
|
||||
Scan system-level services too.
|
||||
</ParamField>
|
||||
<ParamField path="--require-rpc" type="boolean">
|
||||
Upgrade the default connectivity probe to a read probe and exit non-zero when that read probe fails. Cannot be combined with `--no-probe`.
|
||||
</ParamField>
|
||||
|
||||
- `--url <url>`: add an explicit probe target. Configured remote + localhost are still probed.
|
||||
- `--token <token>`: token auth for the probe.
|
||||
- `--password <password>`: password auth for the probe.
|
||||
- `--timeout <ms>`: probe timeout (default `10000`).
|
||||
- `--no-probe`: skip the connectivity probe (service-only view).
|
||||
- `--deep`: scan system-level services too.
|
||||
- `--require-rpc`: upgrade the default connectivity probe to a read probe and exit non-zero when that read probe fails. Cannot be combined with `--no-probe`.
|
||||
|
||||
Notes:
|
||||
|
||||
- `gateway status` stays available for diagnostics even when the local CLI config is missing or invalid.
|
||||
- Default `gateway status` proves service state, WebSocket connect, and the auth capability visible at handshake time. It does not prove read/write/admin operations.
|
||||
- Diagnostic probes are non-mutating for first-time device auth: they reuse an
|
||||
existing cached device token when one exists, but they do not create a new CLI
|
||||
device identity or read-only device pairing record just to check status.
|
||||
- `gateway status` resolves configured auth SecretRefs for probe auth when possible.
|
||||
- If a required auth SecretRef is unresolved in this command path, `gateway status --json` reports `rpc.authWarning` when probe connectivity/auth fails; pass `--token`/`--password` explicitly or resolve the secret source first.
|
||||
- If the probe succeeds, unresolved auth-ref warnings are suppressed to avoid false positives.
|
||||
- Use `--require-rpc` in scripts and automation when a listening service is not enough and you need read-scope RPC calls to be healthy too.
|
||||
- `--deep` adds a best-effort scan for extra launchd/systemd/schtasks installs. When multiple gateway-like services are detected, human output prints cleanup hints and warns that most setups should run one gateway per machine.
|
||||
- Human output includes the resolved file log path plus the CLI-vs-service config paths/validity snapshot to help diagnose profile or state-dir drift.
|
||||
- On Linux systemd installs, service auth drift checks read both `Environment=` and `EnvironmentFile=` values from the unit (including `%h`, quoted paths, multiple files, and optional `-` files).
|
||||
- Drift checks resolve `gateway.auth.token` SecretRefs using merged runtime env (service command env first, then process env fallback).
|
||||
- If token auth is not effectively active (explicit `gateway.auth.mode` of `password`/`none`/`trusted-proxy`, or mode unset where password can win and no token candidate can win), token-drift checks skip config token resolution.
|
||||
<AccordionGroup>
|
||||
<Accordion title="Status semantics">
|
||||
- `gateway status` stays available for diagnostics even when the local CLI config is missing or invalid.
|
||||
- Default `gateway status` proves service state, WebSocket connect, and the auth capability visible at handshake time. It does not prove read/write/admin operations.
|
||||
- Diagnostic probes are non-mutating for first-time device auth: they reuse an existing cached device token when one exists, but they do not create a new CLI device identity or read-only device pairing record just to check status.
|
||||
- `gateway status` resolves configured auth SecretRefs for probe auth when possible.
|
||||
- If a required auth SecretRef is unresolved in this command path, `gateway status --json` reports `rpc.authWarning` when probe connectivity/auth fails; pass `--token`/`--password` explicitly or resolve the secret source first.
|
||||
- If the probe succeeds, unresolved auth-ref warnings are suppressed to avoid false positives.
|
||||
- Use `--require-rpc` in scripts and automation when a listening service is not enough and you need read-scope RPC calls to be healthy too.
|
||||
- `--deep` adds a best-effort scan for extra launchd/systemd/schtasks installs. When multiple gateway-like services are detected, human output prints cleanup hints and warns that most setups should run one gateway per machine.
|
||||
- Human output includes the resolved file log path plus the CLI-vs-service config paths/validity snapshot to help diagnose profile or state-dir drift.
|
||||
</Accordion>
|
||||
<Accordion title="Linux systemd auth-drift checks">
|
||||
- On Linux systemd installs, service auth drift checks read both `Environment=` and `EnvironmentFile=` values from the unit (including `%h`, quoted paths, multiple files, and optional `-` files).
|
||||
- Drift checks resolve `gateway.auth.token` SecretRefs using merged runtime env (service command env first, then process env fallback).
|
||||
- If token auth is not effectively active (explicit `gateway.auth.mode` of `password`/`none`/`trusted-proxy`, or mode unset where password can win and no token candidate can win), token-drift checks skip config token resolution.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### `gateway probe`
|
||||
|
||||
`gateway probe` is the “debug everything” command. It always probes:
|
||||
`gateway probe` is the "debug everything" command. It always probes:
|
||||
|
||||
- your configured remote gateway (if set), and
|
||||
- localhost (loopback) **even if remote is configured**.
|
||||
|
||||
If you pass `--url`, that explicit target is added ahead of both. Human output labels the
|
||||
targets as:
|
||||
If you pass `--url`, that explicit target is added ahead of both. Human output labels the targets as:
|
||||
|
||||
- `URL (explicit)`
|
||||
- `Remote (configured)` or `Remote (configured, inactive)`
|
||||
- `Local loopback`
|
||||
|
||||
<Note>
|
||||
If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use isolated profiles/ports (e.g., a rescue bot), but most installs still run a single gateway.
|
||||
</Note>
|
||||
|
||||
```bash
|
||||
openclaw gateway probe
|
||||
openclaw gateway probe --json
|
||||
```
|
||||
|
||||
Interpretation:
|
||||
<AccordionGroup>
|
||||
<Accordion title="Interpretation">
|
||||
- `Reachable: yes` means at least one target accepted a WebSocket connect.
|
||||
- `Capability: read-only|write-capable|admin-capable|pairing-pending|connect-only` reports what the probe could prove about auth. It is separate from reachability.
|
||||
- `Read probe: ok` means read-scope detail RPC calls (`health`/`status`/`system-presence`/`config.get`) also succeeded.
|
||||
- `Read probe: limited - missing scope: operator.read` means connect succeeded but read-scope RPC is limited. This is reported as **degraded** reachability, not full failure.
|
||||
- Like `gateway status`, probe reuses existing cached device auth but does not create first-time device identity or pairing state.
|
||||
- Exit code is non-zero only when no probed target is reachable.
|
||||
</Accordion>
|
||||
<Accordion title="JSON output">
|
||||
Top level:
|
||||
|
||||
- `Reachable: yes` means at least one target accepted a WebSocket connect.
|
||||
- `Capability: read-only|write-capable|admin-capable|pairing-pending|connect-only` reports what the probe could prove about auth. It is separate from reachability.
|
||||
- `Read probe: ok` means read-scope detail RPC calls (`health`/`status`/`system-presence`/`config.get`) also succeeded.
|
||||
- `Read probe: limited - missing scope: operator.read` means connect succeeded but read-scope RPC is limited. This is reported as **degraded** reachability, not full failure.
|
||||
- Like `gateway status`, probe reuses existing cached device auth but does not
|
||||
create first-time device identity or pairing state.
|
||||
- Exit code is non-zero only when no probed target is reachable.
|
||||
- `ok`: at least one target is reachable.
|
||||
- `degraded`: at least one target had scope-limited detail RPC.
|
||||
- `capability`: best capability seen across reachable targets (`read_only`, `write_capable`, `admin_capable`, `pairing_pending`, `connected_no_operator_scope`, or `unknown`).
|
||||
- `primaryTargetId`: best target to treat as the active winner in this order: explicit URL, SSH tunnel, configured remote, then local loopback.
|
||||
- `warnings[]`: best-effort warning records with `code`, `message`, and optional `targetIds`.
|
||||
- `network`: local loopback/tailnet URL hints derived from current config and host networking.
|
||||
- `discovery.timeoutMs` and `discovery.count`: the actual discovery budget/result count used for this probe pass.
|
||||
|
||||
JSON notes (`--json`):
|
||||
Per target (`targets[].connect`):
|
||||
|
||||
- Top level:
|
||||
- `ok`: at least one target is reachable.
|
||||
- `degraded`: at least one target had scope-limited detail RPC.
|
||||
- `capability`: best capability seen across reachable targets (`read_only`, `write_capable`, `admin_capable`, `pairing_pending`, `connected_no_operator_scope`, or `unknown`).
|
||||
- `primaryTargetId`: best target to treat as the active winner in this order: explicit URL, SSH tunnel, configured remote, then local loopback.
|
||||
- `warnings[]`: best-effort warning records with `code`, `message`, and optional `targetIds`.
|
||||
- `network`: local loopback/tailnet URL hints derived from current config and host networking.
|
||||
- `discovery.timeoutMs` and `discovery.count`: the actual discovery budget/result count used for this probe pass.
|
||||
- Per target (`targets[].connect`):
|
||||
- `ok`: reachability after connect + degraded classification.
|
||||
- `rpcOk`: full detail RPC success.
|
||||
- `scopeLimited`: detail RPC failed due to missing operator scope.
|
||||
- Per target (`targets[].auth`):
|
||||
- `role`: auth role reported in `hello-ok` when available.
|
||||
- `scopes`: granted scopes reported in `hello-ok` when available.
|
||||
- `capability`: the surfaced auth capability classification for that target.
|
||||
- `ok`: reachability after connect + degraded classification.
|
||||
- `rpcOk`: full detail RPC success.
|
||||
- `scopeLimited`: detail RPC failed due to missing operator scope.
|
||||
|
||||
Common warning codes:
|
||||
Per target (`targets[].auth`):
|
||||
|
||||
- `ssh_tunnel_failed`: SSH tunnel setup failed; the command fell back to direct probes.
|
||||
- `multiple_gateways`: more than one target was reachable; this is unusual unless you intentionally run isolated profiles, such as a rescue bot.
|
||||
- `auth_secretref_unresolved`: a configured auth SecretRef could not be resolved for a failed target.
|
||||
- `probe_scope_limited`: WebSocket connect succeeded, but the read probe was limited by missing `operator.read`.
|
||||
- `role`: auth role reported in `hello-ok` when available.
|
||||
- `scopes`: granted scopes reported in `hello-ok` when available.
|
||||
- `capability`: the surfaced auth capability classification for that target.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Common warning codes">
|
||||
- `ssh_tunnel_failed`: SSH tunnel setup failed; the command fell back to direct probes.
|
||||
- `multiple_gateways`: more than one target was reachable; this is unusual unless you intentionally run isolated profiles, such as a rescue bot.
|
||||
- `auth_secretref_unresolved`: a configured auth SecretRef could not be resolved for a failed target.
|
||||
- `probe_scope_limited`: WebSocket connect succeeded, but the read probe was limited by missing `operator.read`.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
#### Remote over SSH (Mac app parity)
|
||||
|
||||
The macOS app “Remote over SSH” mode uses a local port-forward so the remote gateway (which may be bound to loopback only) becomes reachable at `ws://127.0.0.1:<port>`.
|
||||
The macOS app "Remote over SSH" mode uses a local port-forward so the remote gateway (which may be bound to loopback only) becomes reachable at `ws://127.0.0.1:<port>`.
|
||||
|
||||
CLI equivalent:
|
||||
|
||||
@@ -268,13 +362,15 @@ CLI equivalent:
|
||||
openclaw gateway probe --ssh user@gateway-host
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--ssh <target>`: `user@host` or `user@host:port` (port defaults to `22`).
|
||||
- `--ssh-identity <path>`: identity file.
|
||||
- `--ssh-auto`: pick the first discovered gateway host as SSH target from the resolved
|
||||
discovery endpoint (`local.` plus the configured wide-area domain, if any). TXT-only
|
||||
hints are ignored.
|
||||
<ParamField path="--ssh <target>" type="string">
|
||||
`user@host` or `user@host:port` (port defaults to `22`).
|
||||
</ParamField>
|
||||
<ParamField path="--ssh-identity <path>" type="string">
|
||||
Identity file.
|
||||
</ParamField>
|
||||
<ParamField path="--ssh-auto" type="boolean">
|
||||
Pick the first discovered gateway host as SSH target from the resolved discovery endpoint (`local.` plus the configured wide-area domain, if any). TXT-only hints are ignored.
|
||||
</ParamField>
|
||||
|
||||
Config (optional, used as defaults):
|
||||
|
||||
@@ -290,20 +386,31 @@ openclaw gateway call status
|
||||
openclaw gateway call logs.tail --params '{"sinceMs": 60000}'
|
||||
```
|
||||
|
||||
Options:
|
||||
<ParamField path="--params <json>" type="string" default="{}">
|
||||
JSON object string for params.
|
||||
</ParamField>
|
||||
<ParamField path="--url <url>" type="string">
|
||||
Gateway WebSocket URL.
|
||||
</ParamField>
|
||||
<ParamField path="--token <token>" type="string">
|
||||
Gateway token.
|
||||
</ParamField>
|
||||
<ParamField path="--password <password>" type="string">
|
||||
Gateway password.
|
||||
</ParamField>
|
||||
<ParamField path="--timeout <ms>" type="number">
|
||||
Timeout budget.
|
||||
</ParamField>
|
||||
<ParamField path="--expect-final" type="boolean">
|
||||
Mainly for agent-style RPCs that stream intermediate events before a final payload.
|
||||
</ParamField>
|
||||
<ParamField path="--json" type="boolean">
|
||||
Machine-readable JSON output.
|
||||
</ParamField>
|
||||
|
||||
- `--params <json>`: JSON object string for params (default `{}`)
|
||||
- `--url <url>`
|
||||
- `--token <token>`
|
||||
- `--password <password>`
|
||||
- `--timeout <ms>`
|
||||
- `--expect-final`
|
||||
- `--json`
|
||||
|
||||
Notes:
|
||||
|
||||
- `--params` must be valid JSON.
|
||||
- `--expect-final` is mainly for agent-style RPCs that stream intermediate events before a final payload.
|
||||
<Note>
|
||||
`--params` must be valid JSON.
|
||||
</Note>
|
||||
|
||||
## Manage the Gateway service
|
||||
|
||||
@@ -315,29 +422,30 @@ openclaw gateway restart
|
||||
openclaw gateway uninstall
|
||||
```
|
||||
|
||||
Command options:
|
||||
|
||||
- `gateway status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json`
|
||||
- `gateway install`: `--port`, `--runtime <node|bun>`, `--token`, `--force`, `--json`
|
||||
- `gateway uninstall|start|stop|restart`: `--json`
|
||||
|
||||
Notes:
|
||||
|
||||
- `gateway install` supports `--port`, `--runtime`, `--token`, `--force`, `--json`.
|
||||
- Use `gateway restart` to restart a managed service. Do not chain `gateway stop` and `gateway start` as a restart substitute; on macOS, `gateway stop` intentionally disables the LaunchAgent before stopping it.
|
||||
- When token auth requires a token and `gateway.auth.token` is SecretRef-managed, `gateway install` validates that the SecretRef is resolvable but does not persist the resolved token into service environment metadata.
|
||||
- If token auth requires a token and the configured token SecretRef is unresolved, install fails closed instead of persisting fallback plaintext.
|
||||
- For password auth on `gateway run`, prefer `OPENCLAW_GATEWAY_PASSWORD`, `--password-file`, or a SecretRef-backed `gateway.auth.password` over inline `--password`.
|
||||
- In inferred auth mode, shell-only `OPENCLAW_GATEWAY_PASSWORD` does not relax install token requirements; use durable config (`gateway.auth.password` or config `env`) when installing a managed service.
|
||||
- If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, install is blocked until mode is set explicitly.
|
||||
- Lifecycle commands accept `--json` for scripting.
|
||||
<AccordionGroup>
|
||||
<Accordion title="Command options">
|
||||
- `gateway status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json`
|
||||
- `gateway install`: `--port`, `--runtime <node|bun>`, `--token`, `--force`, `--json`
|
||||
- `gateway uninstall|start|stop|restart`: `--json`
|
||||
</Accordion>
|
||||
<Accordion title="Service install and lifecycle notes">
|
||||
- `gateway install` supports `--port`, `--runtime`, `--token`, `--force`, `--json`.
|
||||
- Use `gateway restart` to restart a managed service. Do not chain `gateway stop` and `gateway start` as a restart substitute; on macOS, `gateway stop` intentionally disables the LaunchAgent before stopping it.
|
||||
- When token auth requires a token and `gateway.auth.token` is SecretRef-managed, `gateway install` validates that the SecretRef is resolvable but does not persist the resolved token into service environment metadata.
|
||||
- If token auth requires a token and the configured token SecretRef is unresolved, install fails closed instead of persisting fallback plaintext.
|
||||
- For password auth on `gateway run`, prefer `OPENCLAW_GATEWAY_PASSWORD`, `--password-file`, or a SecretRef-backed `gateway.auth.password` over inline `--password`.
|
||||
- In inferred auth mode, shell-only `OPENCLAW_GATEWAY_PASSWORD` does not relax install token requirements; use durable config (`gateway.auth.password` or config `env`) when installing a managed service.
|
||||
- If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, install is blocked until mode is set explicitly.
|
||||
- Lifecycle commands accept `--json` for scripting.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Discover gateways (Bonjour)
|
||||
|
||||
`gateway discover` scans for Gateway beacons (`_openclaw-gw._tcp`).
|
||||
|
||||
- Multicast DNS-SD: `local.`
|
||||
- Unicast DNS-SD (Wide-Area Bonjour): choose a domain (example: `openclaw.internal.`) and set up split DNS + a DNS server; see [/gateway/bonjour](/gateway/bonjour)
|
||||
- Unicast DNS-SD (Wide-Area Bonjour): choose a domain (example: `openclaw.internal.`) and set up split DNS + a DNS server; see [Bonjour](/gateway/bonjour).
|
||||
|
||||
Only gateways with Bonjour discovery enabled (default) advertise the beacon.
|
||||
|
||||
@@ -357,10 +465,12 @@ Wide-Area discovery records include (TXT):
|
||||
openclaw gateway discover
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--timeout <ms>`: per-command timeout (browse/resolve); default `2000`.
|
||||
- `--json`: machine-readable output (also disables styling/spinner).
|
||||
<ParamField path="--timeout <ms>" type="number" default="2000">
|
||||
Per-command timeout (browse/resolve).
|
||||
</ParamField>
|
||||
<ParamField path="--json" type="boolean">
|
||||
Machine-readable output (also disables styling/spinner).
|
||||
</ParamField>
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -369,14 +479,11 @@ openclaw gateway discover --timeout 4000
|
||||
openclaw gateway discover --json | jq '.beacons[].wsUrl'
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
<Note>
|
||||
- The CLI scans `local.` plus the configured wide-area domain when one is enabled.
|
||||
- `wsUrl` in JSON output is derived from the resolved service endpoint, not from TXT-only
|
||||
hints such as `lanHost` or `tailnetDns`.
|
||||
- On `local.` mDNS, `sshPort` and `cliPath` are only broadcast when
|
||||
`discovery.mdns.mode` is `full`. Wide-area DNS-SD still writes `cliPath`; `sshPort`
|
||||
stays optional there too.
|
||||
- `wsUrl` in JSON output is derived from the resolved service endpoint, not from TXT-only hints such as `lanHost` or `tailnetDns`.
|
||||
- On `local.` mDNS, `sshPort` and `cliPath` are only broadcast when `discovery.mdns.mode` is `full`. Wide-area DNS-SD still writes `cliPath`; `sshPort` stays optional there too.
|
||||
</Note>
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -4,18 +4,25 @@ read_when:
|
||||
- You want to install or manage Gateway plugins or compatible bundles
|
||||
- You want to debug plugin load failures
|
||||
title: "Plugins"
|
||||
sidebarTitle: "Plugins"
|
||||
---
|
||||
|
||||
# `openclaw plugins`
|
||||
|
||||
Manage Gateway plugins, hook packs, and compatible bundles.
|
||||
|
||||
Related:
|
||||
|
||||
- Plugin system: [Plugins](/tools/plugin)
|
||||
- Bundle compatibility: [Plugin bundles](/plugins/bundles)
|
||||
- Plugin manifest + schema: [Plugin manifest](/plugins/manifest)
|
||||
- Security hardening: [Security](/gateway/security)
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Plugin system" href="/tools/plugin">
|
||||
End-user guide for installing, enabling, and troubleshooting plugins.
|
||||
</Card>
|
||||
<Card title="Plugin bundles" href="/plugins/bundles">
|
||||
Bundle compatibility model.
|
||||
</Card>
|
||||
<Card title="Plugin manifest" href="/plugins/manifest">
|
||||
Manifest fields and config schema.
|
||||
</Card>
|
||||
<Card title="Security" href="/gateway/security">
|
||||
Security hardening for plugin installs.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -41,17 +48,13 @@ openclaw plugins marketplace list <marketplace>
|
||||
openclaw plugins marketplace list <marketplace> --json
|
||||
```
|
||||
|
||||
Bundled plugins ship with OpenClaw. Some are enabled by default (for example
|
||||
bundled model providers, bundled speech providers, and the bundled browser
|
||||
plugin); others require `plugins enable`.
|
||||
<Note>
|
||||
Bundled plugins ship with OpenClaw. Some are enabled by default (for example bundled model providers, bundled speech providers, and the bundled browser plugin); others require `plugins enable`.
|
||||
|
||||
Native OpenClaw plugins must ship `openclaw.plugin.json` with an inline JSON
|
||||
Schema (`configSchema`, even if empty). Compatible bundles use their own bundle
|
||||
manifests instead.
|
||||
Native OpenClaw plugins must ship `openclaw.plugin.json` with an inline JSON Schema (`configSchema`, even if empty). Compatible bundles use their own bundle manifests instead.
|
||||
|
||||
`plugins list` shows `Format: openclaw` or `Format: bundle`. Verbose list/info
|
||||
output also shows the bundle subtype (`codex`, `claude`, or `cursor`) plus detected bundle
|
||||
capabilities.
|
||||
`plugins list` shows `Format: openclaw` or `Format: bundle`. Verbose list/info output also shows the bundle subtype (`codex`, `claude`, or `cursor`) plus detected bundle capabilities.
|
||||
</Note>
|
||||
|
||||
### Install
|
||||
|
||||
@@ -67,66 +70,49 @@ openclaw plugins install <plugin> --marketplace <name> # marketplace (explicit)
|
||||
openclaw plugins install <plugin> --marketplace https://github.com/<owner>/<repo>
|
||||
```
|
||||
|
||||
Bare package names are checked against ClawHub first, then npm. Security note:
|
||||
treat plugin installs like running code. Prefer pinned versions.
|
||||
<Warning>
|
||||
Bare package names are checked against ClawHub first, then npm. Treat plugin installs like running code. Prefer pinned versions.
|
||||
</Warning>
|
||||
|
||||
If your `plugins` section is backed by a single-file `$include`, `plugins install/update/enable/disable/uninstall` write through to that included file and leave `openclaw.json` untouched. Root includes, include arrays, and includes with sibling overrides fail closed instead of flattening. See [Config includes](/gateway/configuration) for the supported shapes.
|
||||
<AccordionGroup>
|
||||
<Accordion title="Config includes and invalid-config recovery">
|
||||
If your `plugins` section is backed by a single-file `$include`, `plugins install/update/enable/disable/uninstall` write through to that included file and leave `openclaw.json` untouched. Root includes, include arrays, and includes with sibling overrides fail closed instead of flattening. See [Config includes](/gateway/configuration) for the supported shapes.
|
||||
|
||||
If config is invalid, `plugins install` normally fails closed and tells you to
|
||||
run `openclaw doctor --fix` first. The only documented exception is a narrow
|
||||
bundled-plugin recovery path for plugins that explicitly opt into
|
||||
`openclaw.install.allowInvalidConfigRecovery`.
|
||||
If config is invalid, `plugins install` normally fails closed and tells you to run `openclaw doctor --fix` first. The only documented exception is a narrow bundled-plugin recovery path for plugins that explicitly opt into `openclaw.install.allowInvalidConfigRecovery`.
|
||||
|
||||
`--force` reuses the existing install target and overwrites an already-installed
|
||||
plugin or hook pack in place. Use it when you are intentionally reinstalling
|
||||
the same id from a new local path, archive, ClawHub package, or npm artifact.
|
||||
For routine upgrades of an already tracked npm plugin, prefer
|
||||
`openclaw plugins update <id-or-npm-spec>`.
|
||||
</Accordion>
|
||||
<Accordion title="--force and reinstall vs update">
|
||||
`--force` reuses the existing install target and overwrites an already-installed plugin or hook pack in place. Use it when you are intentionally reinstalling the same id from a new local path, archive, ClawHub package, or npm artifact. For routine upgrades of an already tracked npm plugin, prefer `openclaw plugins update <id-or-npm-spec>`.
|
||||
|
||||
If you run `plugins install` for a plugin id that is already installed, OpenClaw
|
||||
stops and points you at `plugins update <id-or-npm-spec>` for a normal upgrade,
|
||||
or at `plugins install <package> --force` when you genuinely want to overwrite
|
||||
the current install from a different source.
|
||||
If you run `plugins install` for a plugin id that is already installed, OpenClaw stops and points you at `plugins update <id-or-npm-spec>` for a normal upgrade, or at `plugins install <package> --force` when you genuinely want to overwrite the current install from a different source.
|
||||
|
||||
`--pin` applies to npm installs only. It is not supported with `--marketplace`,
|
||||
because marketplace installs persist marketplace source metadata instead of an
|
||||
npm spec.
|
||||
</Accordion>
|
||||
<Accordion title="--pin scope">
|
||||
`--pin` applies to npm installs only. It is not supported with `--marketplace`, because marketplace installs persist marketplace source metadata instead of an npm spec.
|
||||
</Accordion>
|
||||
<Accordion title="--dangerously-force-unsafe-install">
|
||||
`--dangerously-force-unsafe-install` is a break-glass option for false positives in the built-in dangerous-code scanner. It allows the install to continue even when the built-in scanner reports `critical` findings, but it does **not** bypass plugin `before_install` hook policy blocks and does **not** bypass scan failures.
|
||||
|
||||
`--dangerously-force-unsafe-install` is a break-glass option for false positives
|
||||
in the built-in dangerous-code scanner. It allows the install to continue even
|
||||
when the built-in scanner reports `critical` findings, but it does **not**
|
||||
bypass plugin `before_install` hook policy blocks and does **not** bypass scan
|
||||
failures.
|
||||
This CLI flag applies to plugin install/update flows. Gateway-backed skill dependency installs use the matching `dangerouslyForceUnsafeInstall` request override, while `openclaw skills install` remains a separate ClawHub skill download/install flow.
|
||||
|
||||
This CLI flag applies to plugin install/update flows. Gateway-backed skill
|
||||
dependency installs use the matching `dangerouslyForceUnsafeInstall` request
|
||||
override, while `openclaw skills install` remains a separate ClawHub skill
|
||||
download/install flow.
|
||||
</Accordion>
|
||||
<Accordion title="Hook packs and npm specs">
|
||||
`plugins install` is also the install surface for hook packs that expose `openclaw.hooks` in `package.json`. Use `openclaw hooks` for filtered hook visibility and per-hook enablement, not package installation.
|
||||
|
||||
`plugins install` is also the install surface for hook packs that expose
|
||||
`openclaw.hooks` in `package.json`. Use `openclaw hooks` for filtered hook
|
||||
visibility and per-hook enablement, not package installation.
|
||||
Npm specs are **registry-only** (package name + optional **exact version** or **dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency installs run project-local with `--ignore-scripts` for safety, even when your shell has global npm install settings.
|
||||
|
||||
Npm specs are **registry-only** (package name + optional **exact version** or
|
||||
**dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency
|
||||
installs run project-local with `--ignore-scripts` for safety, even when your
|
||||
shell has global npm install settings.
|
||||
Bare specs and `@latest` stay on the stable track. If npm resolves either of those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a prerelease tag such as `@beta`/`@rc` or an exact prerelease version such as `@1.2.3-beta.4`.
|
||||
|
||||
Bare specs and `@latest` stay on the stable track. If npm resolves either of
|
||||
those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a
|
||||
prerelease tag such as `@beta`/`@rc` or an exact prerelease version such as
|
||||
`@1.2.3-beta.4`.
|
||||
If a bare install spec matches a bundled plugin id (for example `diffs`), OpenClaw installs the bundled plugin directly. To install an npm package with the same name, use an explicit scoped spec (for example `@scope/diffs`).
|
||||
|
||||
If a bare install spec matches a bundled plugin id (for example `diffs`), OpenClaw
|
||||
installs the bundled plugin directly. To install an npm package with the same
|
||||
name, use an explicit scoped spec (for example `@scope/diffs`).
|
||||
</Accordion>
|
||||
<Accordion title="Archives">
|
||||
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`. Native OpenClaw plugin archives must contain a valid `openclaw.plugin.json` at the extracted plugin root; archives that only contain `package.json` are rejected before OpenClaw writes install records.
|
||||
|
||||
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
|
||||
Native OpenClaw plugin archives must contain a valid `openclaw.plugin.json` at
|
||||
the extracted plugin root; archives that only contain `package.json` are
|
||||
rejected before OpenClaw writes install records.
|
||||
Claude marketplace installs are also supported.
|
||||
|
||||
Claude marketplace installs are also supported.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
ClawHub installs use an explicit `clawhub:<package>` locator:
|
||||
|
||||
@@ -135,20 +121,17 @@ openclaw plugins install clawhub:openclaw-codex-app-server
|
||||
openclaw plugins install clawhub:openclaw-codex-app-server@1.2.3
|
||||
```
|
||||
|
||||
OpenClaw now also prefers ClawHub for bare npm-safe plugin specs. It only falls
|
||||
back to npm if ClawHub does not have that package or version:
|
||||
OpenClaw now also prefers ClawHub for bare npm-safe plugin specs. It only falls back to npm if ClawHub does not have that package or version:
|
||||
|
||||
```bash
|
||||
openclaw plugins install openclaw-codex-app-server
|
||||
```
|
||||
|
||||
OpenClaw downloads the package archive from ClawHub, checks the advertised
|
||||
plugin API / minimum gateway compatibility, then installs it through the normal
|
||||
archive path. Recorded installs keep their ClawHub source metadata for later
|
||||
updates.
|
||||
OpenClaw downloads the package archive from ClawHub, checks the advertised plugin API / minimum gateway compatibility, then installs it through the normal archive path. Recorded installs keep their ClawHub source metadata for later updates.
|
||||
|
||||
Use `plugin@marketplace` shorthand when the marketplace name exists in Claude's
|
||||
local registry cache at `~/.claude/plugins/known_marketplaces.json`:
|
||||
#### Marketplace shorthand
|
||||
|
||||
Use `plugin@marketplace` shorthand when the marketplace name exists in Claude's local registry cache at `~/.claude/plugins/known_marketplaces.json`:
|
||||
|
||||
```bash
|
||||
openclaw plugins marketplace list <marketplace-name>
|
||||
@@ -164,33 +147,29 @@ openclaw plugins install <plugin-name> --marketplace https://github.com/<owner>/
|
||||
openclaw plugins install <plugin-name> --marketplace ./my-marketplace
|
||||
```
|
||||
|
||||
Marketplace sources can be:
|
||||
|
||||
- a Claude known-marketplace name from `~/.claude/plugins/known_marketplaces.json`
|
||||
- a local marketplace root or `marketplace.json` path
|
||||
- a GitHub repo shorthand such as `owner/repo`
|
||||
- a GitHub repo URL such as `https://github.com/owner/repo`
|
||||
- a git URL
|
||||
|
||||
For remote marketplaces loaded from GitHub or git, plugin entries must stay
|
||||
inside the cloned marketplace repo. OpenClaw accepts relative path sources from
|
||||
that repo and rejects HTTP(S), absolute-path, git, GitHub, and other non-path
|
||||
plugin sources from remote manifests.
|
||||
<Tabs>
|
||||
<Tab title="Marketplace sources">
|
||||
- a Claude known-marketplace name from `~/.claude/plugins/known_marketplaces.json`
|
||||
- a local marketplace root or `marketplace.json` path
|
||||
- a GitHub repo shorthand such as `owner/repo`
|
||||
- a GitHub repo URL such as `https://github.com/owner/repo`
|
||||
- a git URL
|
||||
</Tab>
|
||||
<Tab title="Remote marketplace rules">
|
||||
For remote marketplaces loaded from GitHub or git, plugin entries must stay inside the cloned marketplace repo. OpenClaw accepts relative path sources from that repo and rejects HTTP(S), absolute-path, git, GitHub, and other non-path plugin sources from remote manifests.
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
For local paths and archives, OpenClaw auto-detects:
|
||||
|
||||
- native OpenClaw plugins (`openclaw.plugin.json`)
|
||||
- Codex-compatible bundles (`.codex-plugin/plugin.json`)
|
||||
- Claude-compatible bundles (`.claude-plugin/plugin.json` or the default Claude
|
||||
component layout)
|
||||
- Claude-compatible bundles (`.claude-plugin/plugin.json` or the default Claude component layout)
|
||||
- Cursor-compatible bundles (`.cursor-plugin/plugin.json`)
|
||||
|
||||
Compatible bundles install into the normal plugin root and participate in
|
||||
the same list/info/enable/disable flow. Today, bundle skills, Claude
|
||||
command-skills, Claude `settings.json` defaults, Claude `.lsp.json` /
|
||||
manifest-declared `lspServers` defaults, Cursor command-skills, and compatible
|
||||
Codex hook directories are supported; other detected bundle capabilities are
|
||||
shown in diagnostics/info but are not yet wired into runtime execution.
|
||||
<Note>
|
||||
Compatible bundles install into the normal plugin root and participate in the same list/info/enable/disable flow. Today, bundle skills, Claude command-skills, Claude `settings.json` defaults, Claude `.lsp.json` / manifest-declared `lspServers` defaults, Cursor command-skills, and compatible Codex hook directories are supported; other detected bundle capabilities are shown in diagnostics/info but are not yet wired into runtime execution.
|
||||
</Note>
|
||||
|
||||
### List
|
||||
|
||||
@@ -201,30 +180,25 @@ openclaw plugins list --verbose
|
||||
openclaw plugins list --json
|
||||
```
|
||||
|
||||
Use `--enabled` to show only enabled plugins. Use `--verbose` to switch from the
|
||||
table view to per-plugin detail lines with source/origin/version/activation
|
||||
metadata. Use `--json` for machine-readable inventory plus registry
|
||||
diagnostics.
|
||||
<ParamField path="--enabled" type="boolean">
|
||||
Show only enabled plugins.
|
||||
</ParamField>
|
||||
<ParamField path="--verbose" type="boolean">
|
||||
Switch from the table view to per-plugin detail lines with source/origin/version/activation metadata.
|
||||
</ParamField>
|
||||
<ParamField path="--json" type="boolean">
|
||||
Machine-readable inventory plus registry diagnostics.
|
||||
</ParamField>
|
||||
|
||||
`plugins list` reads the persisted local plugin registry first, with a
|
||||
manifest-only derived fallback when the registry is missing or invalid. It is
|
||||
useful for checking whether a plugin is installed, enabled, and visible to cold
|
||||
startup planning, but it is not a live runtime probe of an already-running
|
||||
Gateway process. After changing plugin code, enablement, hook policy, or
|
||||
`plugins.load.paths`, restart the Gateway that serves the channel before
|
||||
expecting new `register(api)` code or hooks to run. For remote/container
|
||||
deployments, verify you are restarting the actual `openclaw gateway run` child,
|
||||
not only a wrapper process.
|
||||
<Note>
|
||||
`plugins list` reads the persisted local plugin registry first, with a manifest-only derived fallback when the registry is missing or invalid. It is useful for checking whether a plugin is installed, enabled, and visible to cold startup planning, but it is not a live runtime probe of an already-running Gateway process. After changing plugin code, enablement, hook policy, or `plugins.load.paths`, restart the Gateway that serves the channel before expecting new `register(api)` code or hooks to run. For remote/container deployments, verify you are restarting the actual `openclaw gateway run` child, not only a wrapper process.
|
||||
</Note>
|
||||
|
||||
For runtime hook debugging:
|
||||
|
||||
- `openclaw plugins inspect <id> --json` shows registered hooks and diagnostics
|
||||
from a module-loaded inspection pass.
|
||||
- `openclaw gateway status --deep --require-rpc` confirms the reachable Gateway,
|
||||
service/process hints, config path, and RPC health.
|
||||
- Non-bundled conversation hooks (`llm_input`, `llm_output`,
|
||||
`before_agent_finalize`, `agent_end`) require
|
||||
`plugins.entries.<id>.hooks.allowConversationAccess=true`.
|
||||
- `openclaw plugins inspect <id> --json` shows registered hooks and diagnostics from a module-loaded inspection pass.
|
||||
- `openclaw gateway status --deep --require-rpc` confirms the reachable Gateway, service/process hints, config path, and RPC health.
|
||||
- Non-bundled conversation hooks (`llm_input`, `llm_output`, `before_agent_finalize`, `agent_end`) require `plugins.entries.<id>.hooks.allowConversationAccess=true`.
|
||||
|
||||
Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
|
||||
|
||||
@@ -232,24 +206,17 @@ Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
|
||||
openclaw plugins install -l ./my-plugin
|
||||
```
|
||||
|
||||
`--force` is not supported with `--link` because linked installs reuse the
|
||||
source path instead of copying over a managed install target.
|
||||
<Note>
|
||||
`--force` is not supported with `--link` because linked installs reuse the source path instead of copying over a managed install target.
|
||||
|
||||
Use `--pin` on npm installs to save the resolved exact spec (`name@version`) in
|
||||
the managed plugin index while keeping the default behavior unpinned.
|
||||
Use `--pin` on npm installs to save the resolved exact spec (`name@version`) in the managed plugin index while keeping the default behavior unpinned.
|
||||
</Note>
|
||||
|
||||
### Plugin Index
|
||||
### Plugin index
|
||||
|
||||
Plugin install metadata is machine-managed state, not user config. Installs
|
||||
and updates write it to `plugins/installs.json` under the active OpenClaw state
|
||||
directory. Its top-level `installRecords` map is the durable source of install
|
||||
metadata, including records for broken or missing plugin manifests. The
|
||||
`plugins` array is the manifest-derived cold registry cache. The file includes a
|
||||
do-not-edit warning and is used by `openclaw plugins update`, uninstall,
|
||||
diagnostics, and the cold plugin registry.
|
||||
When OpenClaw sees shipped legacy `plugins.installs` records in config, it moves
|
||||
them into the plugin index and removes the config key; if either write fails,
|
||||
the config records are kept so the install metadata is not lost.
|
||||
Plugin install metadata is machine-managed state, not user config. Installs and updates write it to `plugins/installs.json` under the active OpenClaw state directory. Its top-level `installRecords` map is the durable source of install metadata, including records for broken or missing plugin manifests. The `plugins` array is the manifest-derived cold registry cache. The file includes a do-not-edit warning and is used by `openclaw plugins update`, uninstall, diagnostics, and the cold plugin registry.
|
||||
|
||||
When OpenClaw sees shipped legacy `plugins.installs` records in config, it moves them into the plugin index and removes the config key; if either write fails, the config records are kept so the install metadata is not lost.
|
||||
|
||||
### Uninstall
|
||||
|
||||
@@ -259,13 +226,11 @@ openclaw plugins uninstall <id> --dry-run
|
||||
openclaw plugins uninstall <id> --keep-files
|
||||
```
|
||||
|
||||
`uninstall` removes plugin records from `plugins.entries`, the persisted plugin
|
||||
index, the plugin allowlist, and linked `plugins.load.paths` entries when
|
||||
applicable. Unless `--keep-files` is set, uninstall also removes the tracked
|
||||
managed install directory when it is inside OpenClaw's plugin extensions root.
|
||||
For active memory plugins, the memory slot resets to `memory-core`.
|
||||
`uninstall` removes plugin records from `plugins.entries`, the persisted plugin index, the plugin allowlist, and linked `plugins.load.paths` entries when applicable. Unless `--keep-files` is set, uninstall also removes the tracked managed install directory when it is inside OpenClaw's plugin extensions root. For active memory plugins, the memory slot resets to `memory-core`.
|
||||
|
||||
<Note>
|
||||
`--keep-config` is supported as a deprecated alias for `--keep-files`.
|
||||
</Note>
|
||||
|
||||
### Update
|
||||
|
||||
@@ -277,38 +242,27 @@ openclaw plugins update @openclaw/voice-call@beta
|
||||
openclaw plugins update openclaw-codex-app-server --dangerously-force-unsafe-install
|
||||
```
|
||||
|
||||
Updates apply to tracked plugin installs in the managed plugin index and
|
||||
tracked hook-pack installs in `hooks.internal.installs`.
|
||||
Updates apply to tracked plugin installs in the managed plugin index and tracked hook-pack installs in `hooks.internal.installs`.
|
||||
|
||||
When you pass a plugin id, OpenClaw reuses the recorded install spec for that
|
||||
plugin. That means previously stored dist-tags such as `@beta` and exact pinned
|
||||
versions continue to be used on later `update <id>` runs.
|
||||
<AccordionGroup>
|
||||
<Accordion title="Resolving plugin id vs npm spec">
|
||||
When you pass a plugin id, OpenClaw reuses the recorded install spec for that plugin. That means previously stored dist-tags such as `@beta` and exact pinned versions continue to be used on later `update <id>` runs.
|
||||
|
||||
For npm installs, you can also pass an explicit npm package spec with a dist-tag
|
||||
or exact version. OpenClaw resolves that package name back to the tracked plugin
|
||||
record, updates that installed plugin, and records the new npm spec for future
|
||||
id-based updates.
|
||||
For npm installs, you can also pass an explicit npm package spec with a dist-tag or exact version. OpenClaw resolves that package name back to the tracked plugin record, updates that installed plugin, and records the new npm spec for future id-based updates.
|
||||
|
||||
Passing the npm package name without a version or tag also resolves back to the
|
||||
tracked plugin record. Use this when a plugin was pinned to an exact version and
|
||||
you want to move it back to the registry's default release line.
|
||||
Passing the npm package name without a version or tag also resolves back to the tracked plugin record. Use this when a plugin was pinned to an exact version and you want to move it back to the registry's default release line.
|
||||
|
||||
Before a live npm update, OpenClaw checks the installed package version against
|
||||
the npm registry metadata. If the installed version and recorded artifact
|
||||
identity already match the resolved target, the update is skipped without
|
||||
downloading, reinstalling, or rewriting `openclaw.json`.
|
||||
</Accordion>
|
||||
<Accordion title="Version checks and integrity drift">
|
||||
Before a live npm update, OpenClaw checks the installed package version against the npm registry metadata. If the installed version and recorded artifact identity already match the resolved target, the update is skipped without downloading, reinstalling, or rewriting `openclaw.json`.
|
||||
|
||||
When a stored integrity hash exists and the fetched artifact hash changes,
|
||||
OpenClaw treats that as npm artifact drift. The interactive
|
||||
`openclaw plugins update` command prints the expected and actual hashes and asks
|
||||
for confirmation before proceeding. Non-interactive update helpers fail closed
|
||||
unless the caller supplies an explicit continuation policy.
|
||||
When a stored integrity hash exists and the fetched artifact hash changes, OpenClaw treats that as npm artifact drift. The interactive `openclaw plugins update` command prints the expected and actual hashes and asks for confirmation before proceeding. Non-interactive update helpers fail closed unless the caller supplies an explicit continuation policy.
|
||||
|
||||
`--dangerously-force-unsafe-install` is also available on `plugins update` as a
|
||||
break-glass override for built-in dangerous-code scan false positives during
|
||||
plugin updates. It still does not bypass plugin `before_install` policy blocks
|
||||
or scan-failure blocking, and it only applies to plugin updates, not hook-pack
|
||||
updates.
|
||||
</Accordion>
|
||||
<Accordion title="--dangerously-force-unsafe-install on update">
|
||||
`--dangerously-force-unsafe-install` is also available on `plugins update` as a break-glass override for built-in dangerous-code scan false positives during plugin updates. It still does not bypass plugin `before_install` policy blocks or scan-failure blocking, and it only applies to plugin updates, not hook-pack updates.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### Inspect
|
||||
|
||||
@@ -317,10 +271,7 @@ openclaw plugins inspect <id>
|
||||
openclaw plugins inspect <id> --json
|
||||
```
|
||||
|
||||
Deep introspection for a single plugin. Shows identity, load status, source,
|
||||
registered capabilities, hooks, tools, commands, services, gateway methods,
|
||||
HTTP routes, policy flags, diagnostics, install metadata, bundle capabilities,
|
||||
and any detected MCP or LSP server support.
|
||||
Deep introspection for a single plugin. Shows identity, load status, source, registered capabilities, hooks, tools, commands, services, gateway methods, HTTP routes, policy flags, diagnostics, install metadata, bundle capabilities, and any detected MCP or LSP server support.
|
||||
|
||||
Each plugin is classified by what it actually registers at runtime:
|
||||
|
||||
@@ -331,13 +282,9 @@ Each plugin is classified by what it actually registers at runtime:
|
||||
|
||||
See [Plugin shapes](/plugins/architecture#plugin-shapes) for more on the capability model.
|
||||
|
||||
The `--json` flag outputs a machine-readable report suitable for scripting and
|
||||
auditing.
|
||||
|
||||
`inspect --all` renders a fleet-wide table with shape, capability kinds,
|
||||
compatibility notices, bundle capabilities, and hook summary columns.
|
||||
|
||||
`info` is an alias for `inspect`.
|
||||
<Note>
|
||||
The `--json` flag outputs a machine-readable report suitable for scripting and auditing. `inspect --all` renders a fleet-wide table with shape, capability kinds, compatibility notices, bundle capabilities, and hook summary columns. `info` is an alias for `inspect`.
|
||||
</Note>
|
||||
|
||||
### Doctor
|
||||
|
||||
@@ -345,13 +292,9 @@ compatibility notices, bundle capabilities, and hook summary columns.
|
||||
openclaw plugins doctor
|
||||
```
|
||||
|
||||
`doctor` reports plugin load errors, manifest/discovery diagnostics, and
|
||||
compatibility notices. When everything is clean it prints `No plugin issues
|
||||
detected.`
|
||||
`doctor` reports plugin load errors, manifest/discovery diagnostics, and compatibility notices. When everything is clean it prints `No plugin issues detected.`
|
||||
|
||||
For module-shape failures such as missing `register`/`activate` exports, rerun
|
||||
with `OPENCLAW_PLUGIN_LOAD_DEBUG=1` to include a compact export-shape summary in
|
||||
the diagnostic output.
|
||||
For module-shape failures such as missing `register`/`activate` exports, rerun with `OPENCLAW_PLUGIN_LOAD_DEBUG=1` to include a compact export-shape summary in the diagnostic output.
|
||||
|
||||
### Registry
|
||||
|
||||
@@ -361,20 +304,13 @@ openclaw plugins registry --refresh
|
||||
openclaw plugins registry --json
|
||||
```
|
||||
|
||||
The local plugin registry is OpenClaw's persisted cold read model for installed
|
||||
plugin identity, enablement, source metadata, and contribution ownership.
|
||||
Normal startup, provider owner lookup, channel setup classification, and plugin
|
||||
inventory can read it without importing plugin runtime modules.
|
||||
The local plugin registry is OpenClaw's persisted cold read model for installed plugin identity, enablement, source metadata, and contribution ownership. Normal startup, provider owner lookup, channel setup classification, and plugin inventory can read it without importing plugin runtime modules.
|
||||
|
||||
Use `plugins registry` to inspect whether the persisted registry is present,
|
||||
current, or stale. Use `--refresh` to rebuild it from the persisted plugin
|
||||
index, config policy, and manifest/package metadata. This is a repair path, not
|
||||
a runtime activation path.
|
||||
Use `plugins registry` to inspect whether the persisted registry is present, current, or stale. Use `--refresh` to rebuild it from the persisted plugin index, config policy, and manifest/package metadata. This is a repair path, not a runtime activation path.
|
||||
|
||||
`OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY=1` is a deprecated break-glass
|
||||
compatibility switch for registry read failures. Prefer `plugins registry
|
||||
--refresh` or `openclaw doctor --fix`; the env fallback is only for emergency
|
||||
startup recovery while the migration rolls out.
|
||||
<Warning>
|
||||
`OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY=1` is a deprecated break-glass compatibility switch for registry read failures. Prefer `plugins registry --refresh` or `openclaw doctor --fix`; the env fallback is only for emergency startup recovery while the migration rolls out.
|
||||
</Warning>
|
||||
|
||||
### Marketplace
|
||||
|
||||
@@ -383,13 +319,10 @@ openclaw plugins marketplace list <source>
|
||||
openclaw plugins marketplace list <source> --json
|
||||
```
|
||||
|
||||
Marketplace list accepts a local marketplace path, a `marketplace.json` path, a
|
||||
GitHub shorthand like `owner/repo`, a GitHub repo URL, or a git URL. `--json`
|
||||
prints the resolved source label plus the parsed marketplace manifest and
|
||||
plugin entries.
|
||||
Marketplace list accepts a local marketplace path, a `marketplace.json` path, a GitHub shorthand like `owner/repo`, a GitHub repo URL, or a git URL. `--json` prints the resolved source label plus the parsed marketplace manifest and plugin entries.
|
||||
|
||||
## Related
|
||||
|
||||
- [CLI reference](/cli)
|
||||
- [Building plugins](/plugins/building-plugins)
|
||||
- [CLI reference](/cli)
|
||||
- [Community plugins](/plugins/community)
|
||||
|
||||
@@ -37,6 +37,11 @@ daemon (`tailscale whois`) and matching it to the header before accepting it.
|
||||
OpenClaw only treats a request as Serve when it arrives from loopback with
|
||||
Tailscale’s `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`
|
||||
headers.
|
||||
For Control UI operator sessions that include browser device identity, this
|
||||
verified Serve path also skips the device-pairing round trip. It does not bypass
|
||||
browser device identity: device-less clients are still rejected, and node-role
|
||||
or non-Control UI WebSocket connections still follow the normal pairing and
|
||||
auth checks.
|
||||
HTTP API endpoints (for example `/v1/*`, `/tools/invoke`, and `/api/channels/*`)
|
||||
do **not** use Tailscale identity-header auth. They still follow the gateway's
|
||||
normal HTTP auth mode: shared-secret auth by default, or an intentionally
|
||||
|
||||
@@ -766,30 +766,32 @@ and troubleshooting see the main [FAQ](/help/faq).
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Can I switch between npm and git installs later?">
|
||||
Yes. Install the other flavor, then run Doctor so the gateway service points at the new entrypoint.
|
||||
This **does not delete your data** - it only changes the OpenClaw code install. Your state
|
||||
(`~/.openclaw`) and workspace (`~/.openclaw/workspace`) stay untouched.
|
||||
Yes. Use `openclaw update --channel ...` when OpenClaw is already installed.
|
||||
This **does not delete your data** - it only changes the OpenClaw code install.
|
||||
Your state (`~/.openclaw`) and workspace (`~/.openclaw/workspace`) stay untouched.
|
||||
|
||||
From npm to git:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/openclaw/openclaw.git
|
||||
cd openclaw
|
||||
pnpm install
|
||||
pnpm build
|
||||
openclaw doctor
|
||||
openclaw gateway restart
|
||||
openclaw update --channel dev
|
||||
```
|
||||
|
||||
From git to npm:
|
||||
|
||||
```bash
|
||||
npm install -g openclaw@latest
|
||||
openclaw doctor
|
||||
openclaw gateway restart
|
||||
openclaw update --channel stable
|
||||
```
|
||||
|
||||
Doctor detects a gateway service entrypoint mismatch and offers to rewrite the service config to match the current install (use `--repair` in automation).
|
||||
Add `--dry-run` to preview the planned mode switch first. The updater runs
|
||||
Doctor follow-ups, refreshes plugin sources for the target channel, and
|
||||
restarts the gateway unless you pass `--no-restart`.
|
||||
|
||||
The installer can force either mode too:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://openclaw.ai/install.sh | bash -s -- --install-method git
|
||||
curl -fsSL https://openclaw.ai/install.sh | bash -s -- --install-method npm
|
||||
```
|
||||
|
||||
Backup tips: see [Backup strategy](#where-things-live-on-disk).
|
||||
|
||||
|
||||
@@ -61,6 +61,10 @@ curl -fsSL https://openclaw.ai/install-cli.sh | bash
|
||||
It supports npm installs by default, plus git-checkout installs under the same
|
||||
prefix flow. Full reference: [Installer internals](/install/installer#install-clish).
|
||||
|
||||
Already installed? Switch between package and git installs with
|
||||
`openclaw update --channel dev` and `openclaw update --channel stable`. See
|
||||
[Updating](/install/updating#switch-between-npm-and-git-installs).
|
||||
|
||||
### npm, pnpm, or bun
|
||||
|
||||
If you already manage Node yourself:
|
||||
|
||||
@@ -20,6 +20,7 @@ To switch channels or target a specific version:
|
||||
|
||||
```bash
|
||||
openclaw update --channel beta
|
||||
openclaw update --channel dev
|
||||
openclaw update --tag main
|
||||
openclaw update --dry-run # preview without applying
|
||||
```
|
||||
@@ -30,13 +31,41 @@ if you want the raw npm beta dist-tag for a one-off package update.
|
||||
|
||||
See [Development channels](/install/development-channels) for channel semantics.
|
||||
|
||||
## Switch between npm and git installs
|
||||
|
||||
Use channels when you want to change the install type. The updater keeps your
|
||||
state, config, credentials, and workspace in `~/.openclaw`; it only changes
|
||||
which OpenClaw code install the CLI and gateway use.
|
||||
|
||||
```bash
|
||||
# npm package install -> editable git checkout
|
||||
openclaw update --channel dev
|
||||
|
||||
# git checkout -> npm package install
|
||||
openclaw update --channel stable
|
||||
```
|
||||
|
||||
Run with `--dry-run` first to preview the exact install-mode switch:
|
||||
|
||||
```bash
|
||||
openclaw update --channel dev --dry-run
|
||||
openclaw update --channel stable --dry-run
|
||||
```
|
||||
|
||||
The `dev` channel ensures a git checkout, builds it, and installs the global CLI
|
||||
from that checkout. The `stable` and `beta` channels use package installs. If the
|
||||
gateway is already installed, `openclaw update` refreshes the service metadata
|
||||
and restarts it unless you pass `--no-restart`.
|
||||
|
||||
## Alternative: re-run the installer
|
||||
|
||||
```bash
|
||||
curl -fsSL https://openclaw.ai/install.sh | bash
|
||||
```
|
||||
|
||||
Add `--no-onboard` to skip onboarding. For source installs, pass `--install-method git --no-onboard`.
|
||||
Add `--no-onboard` to skip onboarding. To force a specific install type through
|
||||
the installer, pass `--install-method git --no-onboard` or
|
||||
`--install-method npm --no-onboard`.
|
||||
|
||||
## Alternative: manual npm, pnpm, or bun
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ The dashboard settings panel keeps a token for the current browser tab session a
|
||||
|
||||
## Device pairing (first connection)
|
||||
|
||||
When you connect to the Control UI from a new browser or device, the Gateway requires a **one-time pairing approval** — even if you're on the same Tailnet with `gateway.auth.allowTailscale: true`. This is a security measure to prevent unauthorized access.
|
||||
When you connect to the Control UI from a new browser or device, the Gateway usually requires a **one-time pairing approval**. This is a security measure to prevent unauthorized access.
|
||||
|
||||
**What you'll see:** "disconnected (1008): pairing required"
|
||||
|
||||
@@ -58,7 +58,8 @@ Once approved, the device is remembered and won't require re-approval unless you
|
||||
|
||||
<Note>
|
||||
- Direct local loopback browser connections (`127.0.0.1` / `localhost`) are auto-approved.
|
||||
- Tailnet and LAN browser connects still require explicit approval, even when they originate from the same machine.
|
||||
- Tailscale Serve can skip the pairing round trip for Control UI operator sessions when `gateway.auth.allowTailscale: true`, Tailscale identity verifies, and the browser presents its device identity.
|
||||
- Direct Tailnet binds, LAN browser connects, and browser profiles without device identity still require explicit approval.
|
||||
- Each browser profile generates a unique device ID, so switching browsers or clearing browser data will require re-pairing.
|
||||
</Note>
|
||||
|
||||
@@ -237,7 +238,7 @@ Absolute external `http(s)` embed URLs stay blocked by default. If you intention
|
||||
|
||||
- `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`)
|
||||
|
||||
By default, Control UI/WebSocket Serve requests can authenticate via Tailscale identity headers (`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. OpenClaw verifies the identity by resolving the `x-forwarded-for` address with `tailscale whois` and matching it to the header, and only accepts these when the request hits loopback with Tailscale's `x-forwarded-*` headers. Set `gateway.auth.allowTailscale: false` if you want to require explicit shared-secret credentials even for Serve traffic. Then use `gateway.auth.mode: "token"` or `"password"`.
|
||||
By default, Control UI/WebSocket Serve requests can authenticate via Tailscale identity headers (`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. OpenClaw verifies the identity by resolving the `x-forwarded-for` address with `tailscale whois` and matching it to the header, and only accepts these when the request hits loopback with Tailscale's `x-forwarded-*` headers. For Control UI operator sessions with browser device identity, this verified Serve path also skips the device-pairing round trip; device-less browsers and node-role connections still follow the normal device checks. Set `gateway.auth.allowTailscale: false` if you want to require explicit shared-secret credentials even for Serve traffic. Then use `gateway.auth.mode: "token"` or `"password"`.
|
||||
|
||||
For that async Serve identity path, failed auth attempts for the same client IP and auth scope are serialized before rate-limit writes. Concurrent bad retries from the same browser can therefore show `retry later` on the second request instead of two plain mismatches racing in parallel.
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
|
||||
leaked ASCII/full-width model control tokens are stripped from visible text,
|
||||
and assistant entries whose whole visible text is only the exact silent
|
||||
token `NO_REPLY` / `no_reply` are omitted.
|
||||
- Reasoning-flagged reply payloads (`isReasoning: true`) are excluded from WebChat assistant content, transcript replay text, and audio content blocks, so thinking-only payloads do not surface as visible assistant messages or playable audio.
|
||||
- `chat.inject` appends an assistant note directly to the transcript and broadcasts it to the UI (no agent run).
|
||||
- Aborted runs can keep partial assistant output visible in the UI.
|
||||
- Gateway persists aborted partial assistant text into transcript history when buffered output exists, and marks those entries with abort metadata.
|
||||
|
||||
@@ -188,6 +188,7 @@ function formatThreadBindingDurationForConfigLabel(durationMs: number): string {
|
||||
async function appendPluginCommandSpecs(params: {
|
||||
commandSpecs: NativeCommandSpec[];
|
||||
runtime: RuntimeEnv;
|
||||
cfg: OpenClawConfig;
|
||||
}): Promise<NativeCommandSpec[]> {
|
||||
const merged = [...params.commandSpecs];
|
||||
const existingNames = new Set(
|
||||
@@ -195,7 +196,7 @@ async function appendPluginCommandSpecs(params: {
|
||||
);
|
||||
const getPluginCommandSpecs =
|
||||
getPluginCommandSpecsForTesting ?? (await loadPluginRuntime()).getPluginCommandSpecs;
|
||||
for (const pluginCommand of getPluginCommandSpecs("discord")) {
|
||||
for (const pluginCommand of getPluginCommandSpecs("discord", { config: params.cfg })) {
|
||||
const normalizedName = normalizeLowercaseStringOrEmpty(pluginCommand.name);
|
||||
if (!normalizedName) {
|
||||
continue;
|
||||
@@ -747,7 +748,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
})
|
||||
: [];
|
||||
if (nativeEnabled) {
|
||||
commandSpecs = await appendPluginCommandSpecs({ commandSpecs, runtime });
|
||||
commandSpecs = await appendPluginCommandSpecs({ commandSpecs, runtime, cfg });
|
||||
}
|
||||
const initialCommandCount = commandSpecs.length;
|
||||
if (nativeEnabled && nativeSkillsEnabled && commandSpecs.length > maxDiscordCommands) {
|
||||
@@ -756,7 +757,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
cfg,
|
||||
{ skillCommands: [], provider: "discord" },
|
||||
);
|
||||
commandSpecs = await appendPluginCommandSpecs({ commandSpecs, runtime });
|
||||
commandSpecs = await appendPluginCommandSpecs({ commandSpecs, runtime, cfg });
|
||||
runtime.log?.(
|
||||
warn(
|
||||
`discord: ${initialCommandCount} commands exceeds limit; removing per-skill commands and keeping /skill.`,
|
||||
|
||||
@@ -68,6 +68,7 @@ function makeUserInput(text: string) {
|
||||
}
|
||||
|
||||
const SESSIONS_SPAWN_TOOL = { type: "function", name: "sessions_spawn" } as const;
|
||||
const SESSIONS_YIELD_TOOL = { type: "function", name: "sessions_yield" } as const;
|
||||
const THREAD_SUBAGENT_CHILD_ERROR_TOKEN = "QA_SUBAGENT_CHILD_ERROR";
|
||||
const THREAD_SUBAGENT_TOOL_ERROR =
|
||||
"thread=true requested but thread delivery is unavailable in this test harness.";
|
||||
@@ -707,6 +708,75 @@ describe("qa mock openai server", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("drives yielded-parent subagent fallback QA through sessions_spawn and sessions_yield", async () => {
|
||||
const server = await startMockServer();
|
||||
const prompt =
|
||||
"Subagent direct fallback QA check: spawn one worker and yield until QA-SUBAGENT-DIRECT-FALLBACK-OK is delivered.";
|
||||
|
||||
await expectResponsesText(server, {
|
||||
stream: true,
|
||||
tools: [SESSIONS_SPAWN_TOOL, SESSIONS_YIELD_TOOL],
|
||||
input: [makeUserInput(prompt)],
|
||||
});
|
||||
|
||||
await expect(
|
||||
(await fetch(`${server.baseUrl}/debug/last-request`)).json(),
|
||||
).resolves.toMatchObject({
|
||||
plannedToolName: "sessions_spawn",
|
||||
plannedToolArgs: {
|
||||
label: "qa-direct-fallback-worker",
|
||||
thread: false,
|
||||
mode: "run",
|
||||
},
|
||||
});
|
||||
|
||||
const body = await expectResponsesText(server, {
|
||||
stream: true,
|
||||
tools: [SESSIONS_SPAWN_TOOL, SESSIONS_YIELD_TOOL],
|
||||
input: [
|
||||
makeUserInput(prompt),
|
||||
{
|
||||
type: "function_call_output",
|
||||
call_id: "call_mock_sessions_spawn_1",
|
||||
output: JSON.stringify({
|
||||
status: "accepted",
|
||||
childSessionKey: "agent:qa:subagent:child",
|
||||
runId: "run-child-1",
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(body).toContain('"name":"sessions_yield"');
|
||||
expect(body).toContain("QA-SUBAGENT-DIRECT-FALLBACK-OK");
|
||||
await expect(
|
||||
(await fetch(`${server.baseUrl}/debug/last-request`)).json(),
|
||||
).resolves.toMatchObject({
|
||||
plannedToolName: "sessions_yield",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns no visible announce output for the direct fallback QA marker", async () => {
|
||||
const server = await startMockServer();
|
||||
|
||||
const body = await expectResponsesJson<{
|
||||
output?: Array<{ content?: Array<{ text?: string }> }>;
|
||||
}>(server, {
|
||||
stream: false,
|
||||
input: [
|
||||
makeUserInput(
|
||||
[
|
||||
"[Internal task completion event]",
|
||||
"Task: qa-direct-fallback-worker",
|
||||
"Result: QA-SUBAGENT-DIRECT-FALLBACK-OK",
|
||||
].join("\n"),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
expect(body.output?.[0]?.content?.[0]?.text).toBe("");
|
||||
});
|
||||
|
||||
it("surfaces sessions_spawn tool errors instead of echoing child-task tokens", async () => {
|
||||
const server = await startMockServer();
|
||||
|
||||
|
||||
@@ -147,6 +147,9 @@ const QA_EMPTY_RESPONSE_RECOVERY_PROMPT_RE = /empty response continuation qa che
|
||||
const QA_EMPTY_RESPONSE_EXHAUSTION_PROMPT_RE = /empty response exhaustion qa check/i;
|
||||
const QA_QUIET_STREAMING_PROMPT_RE = /quiet streaming qa check/i;
|
||||
const QA_BLOCK_STREAMING_PROMPT_RE = /block streaming qa check/i;
|
||||
const QA_SUBAGENT_DIRECT_FALLBACK_PROMPT_RE = /subagent direct fallback qa check/i;
|
||||
const QA_SUBAGENT_DIRECT_FALLBACK_WORKER_RE = /subagent direct fallback worker/i;
|
||||
const QA_SUBAGENT_DIRECT_FALLBACK_MARKER = "QA-SUBAGENT-DIRECT-FALLBACK-OK";
|
||||
const QA_REASONING_ONLY_RETRY_NEEDLE =
|
||||
"recorded reasoning but did not produce a user-visible answer";
|
||||
const QA_EMPTY_RESPONSE_RETRY_NEEDLE =
|
||||
@@ -784,6 +787,9 @@ function buildAssistantText(
|
||||
if (/fanout worker beta/i.test(prompt)) {
|
||||
return "BETA-OK";
|
||||
}
|
||||
if (QA_SUBAGENT_DIRECT_FALLBACK_WORKER_RE.test(prompt)) {
|
||||
return QA_SUBAGENT_DIRECT_FALLBACK_MARKER;
|
||||
}
|
||||
if (/report the visible code/i.test(prompt) && /FORKED-CONTEXT-ALPHA/i.test(allInputText)) {
|
||||
return "FORKED-CONTEXT-ALPHA";
|
||||
}
|
||||
@@ -1153,6 +1159,29 @@ async function buildResponsesPayload(
|
||||
const hasReasoningOnlyRetryInstruction = allInputText.includes(QA_REASONING_ONLY_RETRY_NEEDLE);
|
||||
const hasEmptyResponseRetryInstruction = allInputText.includes(QA_EMPTY_RESPONSE_RETRY_NEEDLE);
|
||||
const canCallSessionsSpawn = hasDeclaredTool(body, "sessions_spawn");
|
||||
const canCallSessionsYield = hasDeclaredTool(body, "sessions_yield");
|
||||
if (
|
||||
allInputText.includes(QA_SUBAGENT_DIRECT_FALLBACK_MARKER) &&
|
||||
/Internal task completion event/i.test(allInputText)
|
||||
) {
|
||||
return buildAssistantEvents("");
|
||||
}
|
||||
if (QA_SUBAGENT_DIRECT_FALLBACK_PROMPT_RE.test(allInputText)) {
|
||||
if (!toolOutput && canCallSessionsSpawn) {
|
||||
return buildToolCallEventsWithArgs("sessions_spawn", {
|
||||
task: `Subagent direct fallback worker: finish with exactly ${QA_SUBAGENT_DIRECT_FALLBACK_MARKER}.`,
|
||||
label: "qa-direct-fallback-worker",
|
||||
thread: false,
|
||||
mode: "run",
|
||||
runTimeoutSeconds: 30,
|
||||
});
|
||||
}
|
||||
if (toolOutput && canCallSessionsYield && !/\byielded\b/i.test(toolOutput)) {
|
||||
return buildToolCallEventsWithArgs("sessions_yield", {
|
||||
message: `Waiting for ${QA_SUBAGENT_DIRECT_FALLBACK_MARKER}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (/remember this fact/i.test(prompt)) {
|
||||
return buildAssistantEvents(buildAssistantText(input, body, scenarioState));
|
||||
}
|
||||
|
||||
99
qa/scenarios/agents/subagent-completion-direct-fallback.md
Normal file
99
qa/scenarios/agents/subagent-completion-direct-fallback.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Subagent completion direct fallback
|
||||
|
||||
```yaml qa-scenario
|
||||
id: subagent-completion-direct-fallback
|
||||
title: Subagent completion direct fallback
|
||||
surface: subagents
|
||||
coverage:
|
||||
primary:
|
||||
- agents.subagents
|
||||
secondary:
|
||||
- runtime.delivery
|
||||
- channels.qa-channel
|
||||
objective: Verify a yielded parent still receives a successful subagent result through direct fallback delivery when the dormant announce turn produces no visible reply.
|
||||
successCriteria:
|
||||
- Parent launches a native subagent.
|
||||
- Parent yields instead of waiting in-turn.
|
||||
- Subagent completion result is delivered to the original QA DM without a thread id.
|
||||
- Durable task delivery is marked delivered, not failed.
|
||||
docsRefs:
|
||||
- docs/tools/subagents.md
|
||||
- docs/help/testing.md
|
||||
- docs/channels/qa-channel.md
|
||||
codeRefs:
|
||||
- src/agents/subagent-announce-delivery.ts
|
||||
- src/agents/subagent-registry-lifecycle.ts
|
||||
- src/agents/tools/sessions-yield-tool.ts
|
||||
- extensions/qa-lab/src/providers/mock-openai/server.ts
|
||||
execution:
|
||||
kind: flow
|
||||
summary: Reproduce yielded-parent subagent completion delivery and require frozen-result fallback to the QA DM.
|
||||
config:
|
||||
prompt: "Subagent direct fallback QA check: spawn one native subagent worker. The worker must finish with exactly QA-SUBAGENT-DIRECT-FALLBACK-OK. After spawning it, call sessions_yield and wait for the completion event. Do not use ACP."
|
||||
expectedMarker: QA-SUBAGENT-DIRECT-FALLBACK-OK
|
||||
expectedLabel: qa-direct-fallback-worker
|
||||
```
|
||||
|
||||
```yaml qa-flow
|
||||
steps:
|
||||
- name: yielded parent receives child completion through direct fallback
|
||||
actions:
|
||||
- call: waitForGatewayHealthy
|
||||
args:
|
||||
- ref: env
|
||||
- 120000
|
||||
- call: waitForQaChannelReady
|
||||
args:
|
||||
- ref: env
|
||||
- 120000
|
||||
- call: reset
|
||||
- set: sessionKey
|
||||
value:
|
||||
expr: "`agent:qa:subagent-direct-fallback:${randomUUID().slice(0, 8)}`"
|
||||
- call: runAgentPrompt
|
||||
args:
|
||||
- ref: env
|
||||
- sessionKey:
|
||||
ref: sessionKey
|
||||
message:
|
||||
expr: config.prompt
|
||||
timeoutMs:
|
||||
expr: liveTurnTimeoutMs(env, 90000)
|
||||
- call: waitForCondition
|
||||
saveAs: outbound
|
||||
args:
|
||||
- lambda:
|
||||
expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound' && String(message.text ?? '').includes(config.expectedMarker)).at(-1)"
|
||||
- expr: liveTurnTimeoutMs(env, 60000)
|
||||
- expr: "env.providerMode === 'mock-openai' ? 100 : 250"
|
||||
- assert:
|
||||
expr: "String(outbound.text ?? '').trim().includes(config.expectedMarker)"
|
||||
message:
|
||||
expr: "`fallback completion marker missing from outbound QA DM: ${recentOutboundSummary(state)}`"
|
||||
- if:
|
||||
expr: "Boolean(env.mock)"
|
||||
then:
|
||||
- set: fallbackDebugRequests
|
||||
value:
|
||||
expr: "[...(await fetchJson(`${env.mock.baseUrl}/debug/requests`))]"
|
||||
- assert:
|
||||
expr: "fallbackDebugRequests.some((request) => !request.toolOutput && /subagent direct fallback qa check/i.test(String(request.allInputText ?? '')) && request.plannedToolName === 'sessions_spawn' && request.plannedToolArgs?.label === config.expectedLabel)"
|
||||
message:
|
||||
expr: "`expected sessions_spawn for yielded fallback scenario, saw ${JSON.stringify(fallbackDebugRequests.map((request) => ({ plannedToolName: request.plannedToolName ?? null, plannedToolArgs: request.plannedToolArgs ?? null })))}`"
|
||||
- assert:
|
||||
expr: "fallbackDebugRequests.some((request) => /subagent direct fallback qa check/i.test(String(request.allInputText ?? '')) && request.plannedToolName === 'sessions_yield')"
|
||||
message:
|
||||
expr: "`expected sessions_yield for yielded fallback scenario, saw ${JSON.stringify(fallbackDebugRequests.map((request) => request.plannedToolName ?? null))}`"
|
||||
- call: waitForCondition
|
||||
saveAs: deliveredTask
|
||||
args:
|
||||
- lambda:
|
||||
expr: "(async () => { const payload = await runQaCli(env, ['tasks', 'list', '--json', '--runtime', 'subagent'], { timeoutMs: liveTurnTimeoutMs(env, 60000), json: true }); return (payload.tasks ?? []).find((task) => task.label === config.expectedLabel && task.deliveryStatus === 'delivered' && task.status === 'succeeded') ?? null; })()"
|
||||
- expr: liveTurnTimeoutMs(env, 30000)
|
||||
- 250
|
||||
- assert:
|
||||
expr: "deliveredTask.deliveryStatus === 'delivered'"
|
||||
message:
|
||||
expr: "`expected delivered task status for ${config.expectedLabel}, got ${JSON.stringify(deliveredTask)}`"
|
||||
detailsExpr: "outbound.text"
|
||||
```
|
||||
@@ -44,6 +44,7 @@ cat > \"\$HOME/.openclaw/openclaw.json\" <<'JSON'
|
||||
\"id\": \"GATEWAY_AUTH_TOKEN_REF\"
|
||||
}
|
||||
},
|
||||
\"channelHealthCheckMinutes\": 1,
|
||||
\"controlUi\": {
|
||||
\"enabled\": false
|
||||
},
|
||||
@@ -51,17 +52,6 @@ cat > \"\$HOME/.openclaw/openclaw.json\" <<'JSON'
|
||||
\"mode\": \"hybrid\",
|
||||
\"debounceMs\": 0
|
||||
}
|
||||
},
|
||||
\"plugins\": {
|
||||
\"installs\": {
|
||||
\"lossless-claw\": {
|
||||
\"source\": \"npm\",
|
||||
\"spec\": \"@martian-engineering/lossless-claw\",
|
||||
\"installPath\": \"/tmp/lossless-claw\",
|
||||
\"installedAt\": \"2026-04-22T00:00:00.000Z\",
|
||||
\"resolvedAt\": \"2026-04-22T00:00:00.000Z\"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
@@ -110,7 +100,7 @@ entry=dist/index.mjs
|
||||
node \"\$entry\" gateway status --url ws://127.0.0.1:$PORT --token '$TOKEN' --require-rpc --timeout 30000 >/tmp/config-reload-status-before.log
|
||||
"
|
||||
|
||||
echo "Mutating plugin install timestamp metadata..."
|
||||
echo "Mutating hot-reload gateway metadata..."
|
||||
docker exec "$CONTAINER_NAME" bash -lc "node --input-type=module - <<'NODE'
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
@@ -118,8 +108,7 @@ import path from 'node:path';
|
||||
|
||||
const configPath = path.join(os.homedir(), '.openclaw', 'openclaw.json');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
config.plugins.installs['lossless-claw'].installedAt = '2026-04-22T00:01:00.000Z';
|
||||
config.plugins.installs['lossless-claw'].resolvedAt = '2026-04-22T00:01:00.000Z';
|
||||
config.gateway.channelHealthCheckMinutes = 2;
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
||||
NODE"
|
||||
|
||||
|
||||
@@ -36,20 +36,26 @@ mkdir -p \"\$HOME/.openclaw/plugins\"
|
||||
cat > \"\$HOME/.openclaw/plugins/installs.json\" <<'JSON'
|
||||
{
|
||||
\"version\": 1,
|
||||
\"warning\": \"DO NOT EDIT. This file is generated by OpenClaw plugin install/update/uninstall commands. Use `openclaw plugins install/update/uninstall` instead.\",
|
||||
\"updatedAtMs\": 1777118400000,
|
||||
\"records\": {
|
||||
\"lossless-claw\": {
|
||||
\"source\": \"npm\",
|
||||
\"spec\": \"@example/lossless-claw@0.9.0\",
|
||||
\"installPath\": \"~/.openclaw/extensions/lossless-claw\",
|
||||
\"resolvedName\": \"@example/lossless-claw\",
|
||||
\"resolvedVersion\": \"0.9.0\",
|
||||
\"resolvedSpec\": \"@example/lossless-claw@0.9.0\",
|
||||
\"integrity\": \"sha512-same\",
|
||||
\"shasum\": \"same\"
|
||||
}
|
||||
}
|
||||
\"warning\": \"DO NOT EDIT. This file is generated by OpenClaw plugin registry commands.\",
|
||||
\"hostContractVersion\": \"docker-e2e\",
|
||||
\"compatRegistryVersion\": \"docker-e2e\",
|
||||
\"migrationVersion\": 1,
|
||||
\"policyHash\": \"docker-e2e\",
|
||||
\"generatedAtMs\": 1777118400000,
|
||||
\"installRecords\": {
|
||||
\"lossless-claw\": {
|
||||
\"source\": \"npm\",
|
||||
\"spec\": \"@example/lossless-claw@0.9.0\",
|
||||
\"installPath\": \"~/.openclaw/extensions/lossless-claw\",
|
||||
\"resolvedName\": \"@example/lossless-claw\",
|
||||
\"resolvedVersion\": \"0.9.0\",
|
||||
\"resolvedSpec\": \"@example/lossless-claw@0.9.0\",
|
||||
\"integrity\": \"sha512-same\",
|
||||
\"shasum\": \"same\"
|
||||
}
|
||||
},
|
||||
\"plugins\": [],
|
||||
\"diagnostics\": []
|
||||
}
|
||||
JSON
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# Or: & ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -NoOnboard
|
||||
|
||||
param(
|
||||
[ValidateSet("npm", "git")]
|
||||
[string]$InstallMethod = "npm",
|
||||
[string]$Tag = "latest",
|
||||
[string]$GitDir = "$env:USERPROFILE\openclaw",
|
||||
@@ -336,11 +337,13 @@ function Install-OpenClawGit {
|
||||
if (!(Test-Path $wrapperDir)) {
|
||||
New-Item -ItemType Directory -Path $wrapperDir -Force | Out-Null
|
||||
}
|
||||
|
||||
|
||||
$entryPath = Join-Path $RepoDir "dist\entry.js"
|
||||
@"
|
||||
@echo off
|
||||
node "%~dp0..\openclaw\dist\entry.js" %*
|
||||
node "$entryPath" %*
|
||||
"@ | Out-File -FilePath "$wrapperDir\openclaw.cmd" -Encoding ASCII -Force
|
||||
Add-ToPath -Path $wrapperDir
|
||||
|
||||
Write-Host "OpenClaw installed" -Level success
|
||||
return $true
|
||||
@@ -432,7 +435,12 @@ function Main {
|
||||
if ($DryRun) {
|
||||
Write-Host "[DRY RUN] Would install OpenClaw from git to $GitDir" -Level info
|
||||
} else {
|
||||
Install-OpenClawGit -RepoDir $GitDir -Update:(-not $NoGitUpdate)
|
||||
try {
|
||||
npm uninstall -g openclaw 2>$null | Out-Null
|
||||
} catch { }
|
||||
if (!(Install-OpenClawGit -RepoDir $GitDir -Update:(-not $NoGitUpdate))) {
|
||||
return (Fail-Install)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
# npm method
|
||||
@@ -443,6 +451,11 @@ function Main {
|
||||
if ($DryRun) {
|
||||
Write-Host "[DRY RUN] Would install OpenClaw via npm ($((Resolve-PackageInstallSpec -Target $Tag)))" -Level info
|
||||
} else {
|
||||
$gitWrapper = "$env:USERPROFILE\.local\bin\openclaw.cmd"
|
||||
if (Test-Path $gitWrapper) {
|
||||
Remove-Item -Force $gitWrapper
|
||||
Write-Host "Removed git wrapper (switching to npm)" -Level info
|
||||
}
|
||||
if (!(Install-OpenClawNpm -Target $Tag)) {
|
||||
return (Fail-Install)
|
||||
}
|
||||
|
||||
@@ -2667,7 +2667,7 @@ main() {
|
||||
ui_section "Source install details"
|
||||
ui_kv "Checkout" "$final_git_dir"
|
||||
ui_kv "Wrapper" "$HOME/.local/bin/openclaw"
|
||||
ui_kv "Update command" "openclaw update --restart"
|
||||
ui_kv "Update command" "openclaw update"
|
||||
ui_kv "Switch to npm" "curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --install-method npm"
|
||||
elif [[ "$is_upgrade" == "true" ]]; then
|
||||
ui_info "Upgrade complete"
|
||||
|
||||
@@ -115,6 +115,48 @@ async function deliverDiscordDirectMessageCompletion(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function deliverTelegramDirectMessageCompletion(params: {
|
||||
callGateway: typeof runtimeCallGateway;
|
||||
sendMessage?: typeof runtimeSendMessage;
|
||||
internalEvents?: AgentInternalEvent[];
|
||||
isActive?: boolean;
|
||||
queueEmbeddedPiMessage?: (sessionId: string, message: string) => boolean;
|
||||
}) {
|
||||
const origin = {
|
||||
channel: "telegram",
|
||||
to: "123456789",
|
||||
accountId: "bot-1",
|
||||
};
|
||||
__testing.setDepsForTest({
|
||||
callGateway: params.callGateway,
|
||||
getRequesterSessionActivity: () => ({
|
||||
sessionId: "requester-session-telegram",
|
||||
isActive: params.isActive === true,
|
||||
}),
|
||||
loadConfig: () => ({}) as never,
|
||||
...(params.queueEmbeddedPiMessage
|
||||
? { queueEmbeddedPiMessage: params.queueEmbeddedPiMessage }
|
||||
: {}),
|
||||
...(params.sendMessage ? { sendMessage: params.sendMessage } : {}),
|
||||
});
|
||||
|
||||
return deliverSubagentAnnouncement({
|
||||
requesterSessionKey: "agent:main:telegram:123456789",
|
||||
targetRequesterSessionKey: "agent:main:telegram:123456789",
|
||||
triggerMessage: "child done",
|
||||
steerMessage: "child done",
|
||||
requesterOrigin: origin,
|
||||
requesterSessionOrigin: origin,
|
||||
completionDirectOrigin: origin,
|
||||
directOrigin: origin,
|
||||
requesterIsSubagent: false,
|
||||
expectsCompletionMessage: true,
|
||||
bestEffortDeliver: true,
|
||||
directIdempotencyKey: "announce-telegram-dm-fallback",
|
||||
internalEvents: params.internalEvents,
|
||||
});
|
||||
}
|
||||
|
||||
async function deliverSlackChannelAnnouncement(params: {
|
||||
callGateway: typeof runtimeCallGateway;
|
||||
isActive: boolean;
|
||||
@@ -510,6 +552,92 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses direct fallback for Telegram DMs when announce-agent delivery fails", async () => {
|
||||
const callGateway = vi.fn(async () => {
|
||||
throw new Error("UNAVAILABLE: requester wake failed");
|
||||
}) as unknown as typeof runtimeCallGateway;
|
||||
const sendMessage = createSendMessageMock();
|
||||
const result = await deliverTelegramDirectMessageCompletion({
|
||||
callGateway,
|
||||
sendMessage,
|
||||
internalEvents: [
|
||||
{
|
||||
type: "task_completion",
|
||||
source: "subagent",
|
||||
childSessionKey: "agent:worker:subagent:child",
|
||||
childSessionId: "child-session-id",
|
||||
announceType: "subagent task",
|
||||
taskLabel: "telegram completion smoke",
|
||||
status: "ok",
|
||||
statusLabel: "completed successfully",
|
||||
result: "child completion output",
|
||||
replyInstruction: "Summarize the result.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
delivered: true,
|
||||
path: "direct-fallback",
|
||||
}),
|
||||
);
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
accountId: "bot-1",
|
||||
to: "123456789",
|
||||
threadId: undefined,
|
||||
content: "child completion output",
|
||||
requesterSessionKey: "agent:main:telegram:123456789",
|
||||
bestEffort: true,
|
||||
idempotencyKey: "announce-telegram-dm-fallback",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses direct fallback when an active Telegram requester cannot be woken", async () => {
|
||||
const callGateway = createGatewayMock();
|
||||
const sendMessage = createSendMessageMock();
|
||||
const queueEmbeddedPiMessage = vi.fn(() => false);
|
||||
const result = await deliverTelegramDirectMessageCompletion({
|
||||
callGateway,
|
||||
sendMessage,
|
||||
isActive: true,
|
||||
queueEmbeddedPiMessage,
|
||||
internalEvents: [
|
||||
{
|
||||
type: "task_completion",
|
||||
source: "subagent",
|
||||
childSessionKey: "agent:worker:subagent:child",
|
||||
childSessionId: "child-session-id",
|
||||
announceType: "subagent task",
|
||||
taskLabel: "telegram wake smoke",
|
||||
status: "ok",
|
||||
statusLabel: "completed successfully",
|
||||
result: "child completion output",
|
||||
replyInstruction: "Summarize the result.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
delivered: true,
|
||||
path: "direct-fallback",
|
||||
}),
|
||||
);
|
||||
expect(queueEmbeddedPiMessage).toHaveBeenCalledWith("requester-session-telegram", "child done");
|
||||
expect(callGateway).not.toHaveBeenCalled();
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
to: "123456789",
|
||||
content: "child completion output",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a direct thread fallback when announce-agent returns no visible output", async () => {
|
||||
const callGateway = createGatewayMock({
|
||||
result: {
|
||||
|
||||
@@ -681,6 +681,10 @@ async function sendSubagentAnnounceDirectly(params: {
|
||||
isGatewayMessageChannel(normalizedSessionOnlyOriginChannel)
|
||||
? normalizedSessionOnlyOriginChannel
|
||||
: undefined;
|
||||
const completionFallbackText =
|
||||
params.expectsCompletionMessage && deliveryTarget.deliver
|
||||
? extractThreadCompletionFallbackText(params.internalEvents)
|
||||
: "";
|
||||
const requesterActivity = resolveRequesterSessionActivity(canonicalRequesterSessionKey);
|
||||
if (params.expectsCompletionMessage && requesterActivity.sessionId) {
|
||||
const woke = requesterActivity.sessionId
|
||||
@@ -696,6 +700,32 @@ async function sendSubagentAnnounceDirectly(params: {
|
||||
};
|
||||
}
|
||||
if (requesterActivity.isActive) {
|
||||
try {
|
||||
const didFallback = await sendCompletionFallback({
|
||||
cfg,
|
||||
channel: deliveryTarget.channel,
|
||||
to: deliveryTarget.to,
|
||||
accountId: deliveryTarget.accountId,
|
||||
threadId: deliveryTarget.threadId,
|
||||
content: completionFallbackText,
|
||||
requesterSessionKey: canonicalRequesterSessionKey,
|
||||
bestEffortDeliver: params.bestEffortDeliver,
|
||||
idempotencyKey: params.directIdempotencyKey,
|
||||
signal: params.signal,
|
||||
});
|
||||
if (didFallback) {
|
||||
return {
|
||||
delivered: true,
|
||||
path: resolveCompletionFallbackPath(deliveryTarget.threadId),
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
delivered: false,
|
||||
path: "direct",
|
||||
error: `active requester session could not be woken; fallback send failed: ${summarizeDeliveryError(err)}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
delivered: false,
|
||||
path: "direct",
|
||||
@@ -709,10 +739,6 @@ async function sendSubagentAnnounceDirectly(params: {
|
||||
path: "none",
|
||||
};
|
||||
}
|
||||
const completionFallbackText =
|
||||
params.expectsCompletionMessage && deliveryTarget.deliver
|
||||
? extractThreadCompletionFallbackText(params.internalEvents)
|
||||
: "";
|
||||
let directAnnounceResponse: unknown;
|
||||
try {
|
||||
directAnnounceResponse = await runAnnounceDeliveryWithRetry({
|
||||
@@ -758,22 +784,30 @@ async function sendSubagentAnnounceDirectly(params: {
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
const didFallback = await sendCompletionFallback({
|
||||
cfg,
|
||||
channel: deliveryTarget.channel,
|
||||
to: deliveryTarget.to,
|
||||
accountId: deliveryTarget.accountId,
|
||||
threadId: deliveryTarget.threadId,
|
||||
content: deliveryTarget.threadId ? completionFallbackText : "",
|
||||
requesterSessionKey: canonicalRequesterSessionKey,
|
||||
bestEffortDeliver: params.bestEffortDeliver,
|
||||
idempotencyKey: params.directIdempotencyKey,
|
||||
signal: params.signal,
|
||||
});
|
||||
let didFallback = false;
|
||||
try {
|
||||
didFallback = await sendCompletionFallback({
|
||||
cfg,
|
||||
channel: deliveryTarget.channel,
|
||||
to: deliveryTarget.to,
|
||||
accountId: deliveryTarget.accountId,
|
||||
threadId: deliveryTarget.threadId,
|
||||
content: completionFallbackText,
|
||||
requesterSessionKey: canonicalRequesterSessionKey,
|
||||
bestEffortDeliver: params.bestEffortDeliver,
|
||||
idempotencyKey: params.directIdempotencyKey,
|
||||
signal: params.signal,
|
||||
});
|
||||
} catch (fallbackErr) {
|
||||
throw new Error(
|
||||
`${summarizeDeliveryError(err)}; fallback send failed: ${summarizeDeliveryError(fallbackErr)}`,
|
||||
{ cause: fallbackErr },
|
||||
);
|
||||
}
|
||||
if (didFallback) {
|
||||
return {
|
||||
delivered: true,
|
||||
path: "direct-thread-fallback",
|
||||
path: resolveCompletionFallbackPath(deliveryTarget.threadId),
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
resolveSubagentAnnounceTimeoutMs,
|
||||
resolveSubagentCompletionOrigin,
|
||||
} from "./subagent-announce-delivery.js";
|
||||
import type { SubagentAnnounceDeliveryResult } from "./subagent-announce-dispatch.js";
|
||||
import { resolveAnnounceOrigin } from "./subagent-announce-origin.js";
|
||||
import {
|
||||
applySubagentWaitOutcome,
|
||||
@@ -244,6 +245,7 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
wakeOnDescendantSettle?: boolean;
|
||||
signal?: AbortSignal;
|
||||
bestEffortDeliver?: boolean;
|
||||
onDeliveryResult?: (delivery: SubagentAnnounceDeliveryResult) => void;
|
||||
}): Promise<boolean> {
|
||||
let didAnnounce = false;
|
||||
const expectsCompletionMessage = params.expectsCompletionMessage === true;
|
||||
@@ -562,6 +564,7 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
directIdempotencyKey,
|
||||
signal: params.signal,
|
||||
});
|
||||
params.onDeliveryResult?.(delivery);
|
||||
didAnnounce = delivery.delivered;
|
||||
if (!delivery.delivered && delivery.path === "direct" && delivery.error) {
|
||||
defaultRuntime.error?.(
|
||||
|
||||
@@ -569,6 +569,82 @@ describe("subagent registry lifecycle hardening", () => {
|
||||
expect(persist).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists the concrete announce delivery error when cleanup gives up", async () => {
|
||||
const persist = vi.fn();
|
||||
const entry = createRunEntry({
|
||||
endedAt: 4_000,
|
||||
expectsCompletionMessage: true,
|
||||
retainAttachmentsOnKeep: true,
|
||||
});
|
||||
const runSubagentAnnounceFlow = vi.fn(
|
||||
async (announceParams: {
|
||||
onDeliveryResult?: (delivery: {
|
||||
delivered: false;
|
||||
path: "direct";
|
||||
error: string;
|
||||
phases: Array<{
|
||||
phase: "direct-primary" | "queue-fallback";
|
||||
delivered: boolean;
|
||||
path: "direct" | "none";
|
||||
error?: string;
|
||||
}>;
|
||||
}) => void;
|
||||
}) => {
|
||||
announceParams.onDeliveryResult?.({
|
||||
delivered: false,
|
||||
path: "direct",
|
||||
error: "UNAVAILABLE: requester wake failed",
|
||||
phases: [
|
||||
{
|
||||
phase: "direct-primary",
|
||||
delivered: false,
|
||||
path: "direct",
|
||||
error: "UNAVAILABLE: requester wake failed",
|
||||
},
|
||||
{
|
||||
phase: "queue-fallback",
|
||||
delivered: false,
|
||||
path: "none",
|
||||
},
|
||||
],
|
||||
});
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
const controller = createLifecycleController({
|
||||
entry,
|
||||
persist,
|
||||
runSubagentAnnounceFlow,
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.completeSubagentRun({
|
||||
runId: entry.runId,
|
||||
endedAt: 4_000,
|
||||
outcome: { status: "ok" },
|
||||
reason: SUBAGENT_ENDED_REASON_COMPLETE,
|
||||
triggerCleanup: true,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(taskExecutorMocks.setDetachedTaskDeliveryStatusByRunId).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: entry.runId,
|
||||
runtime: "subagent",
|
||||
sessionKey: entry.childSessionKey,
|
||||
deliveryStatus: "failed",
|
||||
error:
|
||||
"UNAVAILABLE: requester wake failed; direct-primary: UNAVAILABLE: requester wake failed",
|
||||
}),
|
||||
);
|
||||
expect(entry.lastAnnounceDeliveryError).toBe(
|
||||
"UNAVAILABLE: requester wake failed; direct-primary: UNAVAILABLE: requester wake failed",
|
||||
);
|
||||
expect(entry.cleanupCompletedAt).toBeTypeOf("number");
|
||||
expect(persist).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips browser cleanup when steer restart suppresses cleanup flow", async () => {
|
||||
const entry = createRunEntry({
|
||||
expectsCompletionMessage: false,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "../tasks/detached-task-runtime.js";
|
||||
import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js";
|
||||
import { retireSessionMcpRuntimeForSessionKey } from "./pi-bundle-mcp-tools.js";
|
||||
import type { SubagentAnnounceDeliveryResult } from "./subagent-announce-dispatch.js";
|
||||
import { type SubagentRunOutcome, withSubagentOutcomeTiming } from "./subagent-announce-output.js";
|
||||
import {
|
||||
SUBAGENT_ENDED_REASON_COMPLETE,
|
||||
@@ -126,10 +127,25 @@ export function createSubagentRegistryLifecycleController(params: {
|
||||
return name ? { name, message } : { message };
|
||||
};
|
||||
|
||||
const formatAnnounceDeliveryError = (delivery: SubagentAnnounceDeliveryResult): string => {
|
||||
const errors = [
|
||||
delivery.error,
|
||||
...(delivery.phases ?? []).map((phase) =>
|
||||
phase.error ? `${phase.phase}: ${phase.error}` : undefined,
|
||||
),
|
||||
]
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
return errors.length > 0
|
||||
? [...new Set(errors)].join("; ")
|
||||
: `delivery path ${delivery.path} did not complete`;
|
||||
};
|
||||
|
||||
const safeSetSubagentTaskDeliveryStatus = (args: {
|
||||
runId: string;
|
||||
childSessionKey: string;
|
||||
deliveryStatus: "delivered" | "failed";
|
||||
deliveryError?: string;
|
||||
}) => {
|
||||
try {
|
||||
setDetachedTaskDeliveryStatusByRunId({
|
||||
@@ -137,6 +153,7 @@ export function createSubagentRegistryLifecycleController(params: {
|
||||
runtime: "subagent",
|
||||
sessionKey: args.childSessionKey,
|
||||
deliveryStatus: args.deliveryStatus,
|
||||
error: args.deliveryStatus === "failed" ? args.deliveryError : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
params.warn("failed to update subagent background task delivery state", {
|
||||
@@ -301,6 +318,7 @@ export function createSubagentRegistryLifecycleController(params: {
|
||||
runId: giveUpParams.runId,
|
||||
childSessionKey: giveUpParams.entry.childSessionKey,
|
||||
deliveryStatus: "failed",
|
||||
deliveryError: giveUpParams.entry.lastAnnounceDeliveryError,
|
||||
});
|
||||
giveUpParams.entry.wakeOnDescendantSettle = undefined;
|
||||
giveUpParams.entry.fallbackFrozenResultText = undefined;
|
||||
@@ -464,6 +482,7 @@ export function createSubagentRegistryLifecycleController(params: {
|
||||
childSessionKey: entry.childSessionKey,
|
||||
deliveryStatus: "delivered",
|
||||
});
|
||||
entry.lastAnnounceDeliveryError = undefined;
|
||||
entry.wakeOnDescendantSettle = undefined;
|
||||
entry.fallbackFrozenResultText = undefined;
|
||||
entry.fallbackFrozenResultCapturedAt = undefined;
|
||||
@@ -518,6 +537,7 @@ export function createSubagentRegistryLifecycleController(params: {
|
||||
runId,
|
||||
childSessionKey: entry.childSessionKey,
|
||||
deliveryStatus: "failed",
|
||||
deliveryError: entry.lastAnnounceDeliveryError,
|
||||
});
|
||||
entry.wakeOnDescendantSettle = undefined;
|
||||
entry.fallbackFrozenResultText = undefined;
|
||||
@@ -571,7 +591,11 @@ export function createSubagentRegistryLifecycleController(params: {
|
||||
return false;
|
||||
}
|
||||
const requesterOrigin = normalizeDeliveryContext(entry.requesterOrigin);
|
||||
let latestDeliveryError = entry.lastAnnounceDeliveryError;
|
||||
const finalizeAnnounceCleanup = (didAnnounce: boolean) => {
|
||||
if (!didAnnounce && latestDeliveryError) {
|
||||
entry.lastAnnounceDeliveryError = latestDeliveryError;
|
||||
}
|
||||
void finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce).catch((err) => {
|
||||
defaultRuntime.log(`[warn] subagent cleanup finalize failed (${runId}): ${String(err)}`);
|
||||
const current = params.runs.get(runId);
|
||||
@@ -603,6 +627,21 @@ export function createSubagentRegistryLifecycleController(params: {
|
||||
spawnMode: entry.spawnMode,
|
||||
expectsCompletionMessage: entry.expectsCompletionMessage,
|
||||
wakeOnDescendantSettle: entry.wakeOnDescendantSettle === true,
|
||||
onDeliveryResult: (delivery) => {
|
||||
if (delivery.delivered) {
|
||||
if (entry.lastAnnounceDeliveryError !== undefined) {
|
||||
entry.lastAnnounceDeliveryError = undefined;
|
||||
params.persist();
|
||||
}
|
||||
latestDeliveryError = undefined;
|
||||
return;
|
||||
}
|
||||
latestDeliveryError = formatAnnounceDeliveryError(delivery);
|
||||
if (entry.lastAnnounceDeliveryError !== latestDeliveryError) {
|
||||
entry.lastAnnounceDeliveryError = latestDeliveryError;
|
||||
params.persist();
|
||||
}
|
||||
},
|
||||
})
|
||||
.then((didAnnounce) => {
|
||||
finalizeAnnounceCleanup(didAnnounce);
|
||||
|
||||
@@ -30,6 +30,7 @@ export type SubagentRunRecord = {
|
||||
expectsCompletionMessage?: boolean;
|
||||
announceRetryCount?: number;
|
||||
lastAnnounceRetryAt?: number;
|
||||
lastAnnounceDeliveryError?: string;
|
||||
endedReason?: SubagentLifecycleEndedReason;
|
||||
wakeOnDescendantSettle?: boolean;
|
||||
frozenResultText?: string | null;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { isBlockedObjectKey } from "../../infra/prototype-keys.js";
|
||||
import type { PluginManifestRecord } from "../../plugins/manifest-registry.js";
|
||||
import { loadPluginManifestRegistryForPluginRegistry } from "../../plugins/plugin-registry.js";
|
||||
import {
|
||||
isPluginEnabled,
|
||||
loadPluginManifestRegistryForPluginRegistry,
|
||||
} from "../../plugins/plugin-registry.js";
|
||||
import { normalizeOptionalString } from "../../shared/string-coerce.js";
|
||||
import type { ChannelPlugin } from "./types.plugin.js";
|
||||
|
||||
@@ -36,12 +40,17 @@ export function normalizeChannelCommandDefaults(
|
||||
: undefined;
|
||||
const nativeSkillsAutoEnabled =
|
||||
typeof value.nativeSkillsAutoEnabled === "boolean" ? value.nativeSkillsAutoEnabled : undefined;
|
||||
return nativeCommandsAutoEnabled !== undefined || nativeSkillsAutoEnabled !== undefined
|
||||
? {
|
||||
...(nativeCommandsAutoEnabled !== undefined ? { nativeCommandsAutoEnabled } : {}),
|
||||
...(nativeSkillsAutoEnabled !== undefined ? { nativeSkillsAutoEnabled } : {}),
|
||||
}
|
||||
: undefined;
|
||||
if (nativeCommandsAutoEnabled === undefined && nativeSkillsAutoEnabled === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const defaults: ChannelCommandDefaults = {};
|
||||
if (nativeCommandsAutoEnabled !== undefined) {
|
||||
defaults.nativeCommandsAutoEnabled = nativeCommandsAutoEnabled;
|
||||
}
|
||||
if (nativeSkillsAutoEnabled !== undefined) {
|
||||
defaults.nativeSkillsAutoEnabled = nativeSkillsAutoEnabled;
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
export function resolveReadOnlyChannelCommandDefaults(
|
||||
@@ -50,13 +59,15 @@ export function resolveReadOnlyChannelCommandDefaults(
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
workspaceDir?: string;
|
||||
} = {},
|
||||
config: OpenClawConfig;
|
||||
},
|
||||
): ChannelCommandDefaults | undefined {
|
||||
const normalizedChannelId = normalizeOptionalString(channelId) ?? "";
|
||||
if (!normalizedChannelId || !isSafeManifestChannelId(normalizedChannelId)) {
|
||||
return undefined;
|
||||
}
|
||||
const registry = loadPluginManifestRegistryForPluginRegistry({
|
||||
config: options.config,
|
||||
stateDir: options.stateDir,
|
||||
workspaceDir: options.workspaceDir,
|
||||
env: options.env ?? process.env,
|
||||
@@ -66,6 +77,23 @@ export function resolveReadOnlyChannelCommandDefaults(
|
||||
if (!record.channels.includes(normalizedChannelId)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
record.id !== normalizedChannelId &&
|
||||
record.channelCatalogMeta?.id !== normalizedChannelId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!isPluginEnabled({
|
||||
pluginId: record.id,
|
||||
config: options.config,
|
||||
stateDir: options.stateDir,
|
||||
workspaceDir: options.workspaceDir,
|
||||
env: options.env ?? process.env,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const channelConfigValue = record.channelConfigs
|
||||
? readOwnRecordValue(record.channelConfigs as Record<string, unknown>, normalizedChannelId)
|
||||
: undefined;
|
||||
|
||||
@@ -594,6 +594,7 @@ export function resetPluginsCliTestState() {
|
||||
allowlist: false,
|
||||
loadPath: false,
|
||||
memorySlot: false,
|
||||
contextEngineSlot: false,
|
||||
directory: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -634,6 +634,11 @@ export function registerPluginsCli(program: Command) {
|
||||
if (cfg.plugins?.slots?.memory === pluginId) {
|
||||
preview.push(`memory slot (will reset to "${defaultSlotIdForKey("memory")}")`);
|
||||
}
|
||||
if (cfg.plugins?.slots?.contextEngine === pluginId) {
|
||||
preview.push(
|
||||
`context engine slot (will reset to "${defaultSlotIdForKey("contextEngine")}")`,
|
||||
);
|
||||
}
|
||||
const channelIds = plugin?.status === "loaded" ? plugin.channelIds : undefined;
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
if (hasInstall && channels) {
|
||||
@@ -723,6 +728,9 @@ export function registerPluginsCli(program: Command) {
|
||||
if (result.actions.memorySlot) {
|
||||
removed.push("memory slot");
|
||||
}
|
||||
if (result.actions.contextEngineSlot) {
|
||||
removed.push("context engine slot");
|
||||
}
|
||||
if (result.actions.channelConfig) {
|
||||
removed.push("channel config");
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ describe("plugins cli uninstall", () => {
|
||||
installPath: ALPHA_INSTALL_PATH,
|
||||
},
|
||||
},
|
||||
slots: {
|
||||
contextEngine: "alpha",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
buildPluginDiagnosticsReport.mockReturnValue({
|
||||
@@ -53,6 +56,7 @@ describe("plugins cli uninstall", () => {
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(refreshPluginRegistry).not.toHaveBeenCalled();
|
||||
expect(runtimeLogs.some((line) => line.includes("Dry run, no changes made."))).toBe(true);
|
||||
expect(runtimeLogs.some((line) => line.includes("context engine slot"))).toBe(true);
|
||||
});
|
||||
|
||||
it("uninstalls with --force and --keep-files without prompting", async () => {
|
||||
@@ -93,6 +97,7 @@ describe("plugins cli uninstall", () => {
|
||||
allowlist: false,
|
||||
loadPath: false,
|
||||
memorySlot: false,
|
||||
contextEngineSlot: false,
|
||||
directory: false,
|
||||
},
|
||||
});
|
||||
@@ -162,6 +167,7 @@ describe("plugins cli uninstall", () => {
|
||||
allowlist: false,
|
||||
loadPath: false,
|
||||
memorySlot: false,
|
||||
contextEngineSlot: false,
|
||||
directory: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1202,7 +1202,7 @@ describe("update-cli", () => {
|
||||
await updateCommand({ yes: true });
|
||||
|
||||
expect(runCommandWithTimeout).toHaveBeenCalledWith(
|
||||
[expect.stringMatching(/node/), entryPath, "doctor", "--non-interactive"],
|
||||
[expect.stringMatching(/node/), entryPath, "doctor", "--non-interactive", "--fix"],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
OPENCLAW_UPDATE_IN_PROGRESS: "1",
|
||||
@@ -1271,7 +1271,7 @@ describe("update-cli", () => {
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(runCommandWithTimeout).toHaveBeenCalledWith(
|
||||
[expect.stringMatching(/node/), entryPath, "doctor", "--non-interactive"],
|
||||
[expect.stringMatching(/node/), entryPath, "doctor", "--non-interactive", "--fix"],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(updateNpmInstalledPlugins).toHaveBeenCalled();
|
||||
@@ -1283,6 +1283,76 @@ describe("update-cli", () => {
|
||||
).not.toContain("already-current");
|
||||
});
|
||||
|
||||
it("retries package updates without optional deps when npm global update fails", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-optional-"));
|
||||
const nodeModules = path.join(tempDir, "node_modules");
|
||||
const pkgRoot = path.join(nodeModules, "openclaw");
|
||||
mockPackageInstallStatus(pkgRoot);
|
||||
await fs.mkdir(pkgRoot, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(pkgRoot, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", version: "1.0.0" }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
|
||||
if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
|
||||
return {
|
||||
stdout: `${nodeModules}\n`,
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit",
|
||||
};
|
||||
}
|
||||
if (
|
||||
Array.isArray(argv) &&
|
||||
argv[0] === "npm" &&
|
||||
argv.includes("-g") &&
|
||||
!argv.includes("--omit=optional")
|
||||
) {
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: "node-gyp failed",
|
||||
code: 1,
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit",
|
||||
};
|
||||
}
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit",
|
||||
};
|
||||
});
|
||||
|
||||
await updateCommand({ yes: true, restart: false });
|
||||
|
||||
expect(runCommandWithTimeout).toHaveBeenCalledWith(
|
||||
["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(runCommandWithTimeout).toHaveBeenCalledWith(
|
||||
[
|
||||
"npm",
|
||||
"i",
|
||||
"-g",
|
||||
"openclaw@latest",
|
||||
"--omit=optional",
|
||||
"--no-fund",
|
||||
"--no-audit",
|
||||
"--loglevel=error",
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("uses the owning npm binary for package updates when PATH npm points elsewhere", async () => {
|
||||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
|
||||
const brewPrefix = createCaseDir("brew-prefix");
|
||||
|
||||
@@ -75,7 +75,7 @@ ${theme.heading("Switch channels:")}
|
||||
|
||||
${theme.heading("Non-interactive:")}
|
||||
- Use --yes to accept downgrade prompts
|
||||
- Combine with --channel/--tag/--restart/--json/--timeout as needed
|
||||
- Combine with --channel/--tag/--no-restart/--json/--timeout as needed
|
||||
- Use --dry-run to preview actions without writing config/installing/restarting
|
||||
|
||||
${theme.heading("Examples:")}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
canResolveRegistryVersionForPackageTarget,
|
||||
createGlobalInstallEnv,
|
||||
cleanupGlobalRenameDirs,
|
||||
globalInstallFallbackArgs,
|
||||
globalInstallArgs,
|
||||
resolveExpectedInstalledVersionFromSpec,
|
||||
resolveGlobalInstallTarget,
|
||||
@@ -407,6 +408,21 @@ async function runPackageInstallUpdate(params: {
|
||||
});
|
||||
|
||||
const steps = [updateStep];
|
||||
let finalInstallStep = updateStep;
|
||||
if (updateStep.exitCode !== 0) {
|
||||
const fallbackArgv = globalInstallFallbackArgs(installTarget, installSpec);
|
||||
if (fallbackArgv) {
|
||||
const fallbackStep = await runUpdateStep({
|
||||
name: "global update (omit optional)",
|
||||
argv: fallbackArgv,
|
||||
env: installEnv,
|
||||
timeoutMs: params.timeoutMs,
|
||||
progress: params.progress,
|
||||
});
|
||||
steps.push(fallbackStep);
|
||||
finalInstallStep = fallbackStep;
|
||||
}
|
||||
}
|
||||
let afterVersion = beforeVersion;
|
||||
|
||||
const verifiedPackageRoot =
|
||||
@@ -439,7 +455,7 @@ async function runPackageInstallUpdate(params: {
|
||||
if (entryPath) {
|
||||
const doctorStep = await runUpdateStep({
|
||||
name: `${CLI_NAME} doctor`,
|
||||
argv: [resolveNodeRunner(), entryPath, "doctor", "--non-interactive"],
|
||||
argv: [resolveNodeRunner(), entryPath, "doctor", "--non-interactive", "--fix"],
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_UPDATE_IN_PROGRESS: "1",
|
||||
@@ -451,7 +467,10 @@ async function runPackageInstallUpdate(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const failedStep = steps.find((step) => step.exitCode !== 0);
|
||||
const failedStep =
|
||||
finalInstallStep.exitCode !== 0
|
||||
? finalInstallStep
|
||||
: (steps.find((step) => step !== updateStep && step.exitCode !== 0) ?? null);
|
||||
return {
|
||||
status: failedStep ? "error" : "ok",
|
||||
mode: manager,
|
||||
|
||||
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => {
|
||||
clackText: vi.fn(),
|
||||
clackConfirm: vi.fn(),
|
||||
resolveSearchProviderOptions: vi.fn(),
|
||||
resolvePluginContributionOwners: vi.fn(),
|
||||
setupSearch: vi.fn(),
|
||||
readConfigFileSnapshot: vi.fn(),
|
||||
writeConfigFile,
|
||||
@@ -113,6 +114,10 @@ vi.mock("./onboard-search.js", () => ({
|
||||
setupSearch: mocks.setupSearch,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/plugin-registry.js", () => ({
|
||||
resolvePluginContributionOwners: mocks.resolvePluginContributionOwners,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/codex-native-web-search.js", () => ({
|
||||
isCodexNativeWebSearchRelevant: mocks.isCodexNativeWebSearchRelevant,
|
||||
}));
|
||||
@@ -210,6 +215,7 @@ describe("runConfigureWizard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true });
|
||||
mocks.resolvePluginContributionOwners.mockReturnValue(["firecrawl"]);
|
||||
mocks.resolveSearchProviderOptions.mockReturnValue([
|
||||
{
|
||||
id: "firecrawl",
|
||||
@@ -360,6 +366,25 @@ describe("runConfigureWizard", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not load managed search provider options when web search is disabled", async () => {
|
||||
setupBaseWizardState();
|
||||
queueWizardPrompts({
|
||||
select: ["local"],
|
||||
confirm: [false, true],
|
||||
});
|
||||
|
||||
await runWebConfigureWizard();
|
||||
|
||||
expect(mocks.resolvePluginContributionOwners).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contribution: "contracts",
|
||||
matches: "webSearchProviders",
|
||||
}),
|
||||
);
|
||||
expect(mocks.resolveSearchProviderOptions).not.toHaveBeenCalled();
|
||||
expect(mocks.setupSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defers channel status checks until a channel is selected", async () => {
|
||||
setupBaseWizardState();
|
||||
queueWizardPrompts({
|
||||
|
||||
@@ -9,6 +9,7 @@ import { logConfigUpdated } from "../config/logging.js";
|
||||
import { ConfigMutationConflictError } from "../config/mutate.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
||||
import { resolvePluginContributionOwners } from "../plugins/plugin-registry.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
@@ -199,9 +200,13 @@ async function promptWebToolsConfig(
|
||||
type WebSearchConfig = NonNullable<NonNullable<OpenClawConfig["tools"]>["web"]>["search"];
|
||||
const existingSearch = nextConfig.tools?.web?.search;
|
||||
const existingFetch = nextConfig.tools?.web?.fetch;
|
||||
const { resolveSearchProviderOptions, setupSearch } = await import("./onboard-search.js");
|
||||
const { isCodexNativeWebSearchRelevant } = await import("../agents/codex-native-web-search.js");
|
||||
const searchProviderOptions = resolveSearchProviderOptions(nextConfig);
|
||||
const hasManagedSearchProviders =
|
||||
resolvePluginContributionOwners({
|
||||
config: nextConfig,
|
||||
contribution: "contracts",
|
||||
matches: "webSearchProviders",
|
||||
}).length > 0;
|
||||
|
||||
note(
|
||||
[
|
||||
@@ -215,7 +220,7 @@ async function promptWebToolsConfig(
|
||||
const enableSearch = guardCancel(
|
||||
await confirm({
|
||||
message: "Enable web_search?",
|
||||
initialValue: existingSearch?.enabled ?? searchProviderOptions.length > 0,
|
||||
initialValue: existingSearch?.enabled ?? hasManagedSearchProviders,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
@@ -297,8 +302,10 @@ async function promptWebToolsConfig(
|
||||
}
|
||||
}
|
||||
|
||||
if (searchProviderOptions.length === 0) {
|
||||
if (configureManagedProvider) {
|
||||
if (configureManagedProvider) {
|
||||
const { resolveSearchProviderOptions, setupSearch } = await import("./onboard-search.js");
|
||||
const searchProviderOptions = resolveSearchProviderOptions(nextConfig);
|
||||
if (searchProviderOptions.length === 0) {
|
||||
note(
|
||||
[
|
||||
"No web search providers are currently available under this plugin policy.",
|
||||
@@ -307,23 +314,23 @@ async function promptWebToolsConfig(
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
);
|
||||
}
|
||||
if (nextSearch.openaiCodex?.enabled !== true) {
|
||||
if (nextSearch.openaiCodex?.enabled !== true) {
|
||||
nextSearch = {
|
||||
...existingSearch,
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
workingConfig = await setupSearch(workingConfig, runtime, prompter);
|
||||
nextSearch = {
|
||||
...existingSearch,
|
||||
enabled: false,
|
||||
...workingConfig.tools?.web?.search,
|
||||
enabled: workingConfig.tools?.web?.search?.provider ? true : existingSearch?.enabled,
|
||||
openaiCodex: {
|
||||
...existingSearch?.openaiCodex,
|
||||
...(nextSearch.openaiCodex as Record<string, unknown> | undefined),
|
||||
},
|
||||
};
|
||||
}
|
||||
} else if (configureManagedProvider) {
|
||||
workingConfig = await setupSearch(workingConfig, runtime, prompter);
|
||||
nextSearch = {
|
||||
...workingConfig.tools?.web?.search,
|
||||
enabled: workingConfig.tools?.web?.search?.provider ? true : existingSearch?.enabled,
|
||||
openaiCodex: {
|
||||
...existingSearch?.openaiCodex,
|
||||
...(nextSearch.openaiCodex as Record<string, unknown> | undefined),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
15
src/commands/search-setup-cold-imports.test.ts
Normal file
15
src/commands/search-setup-cold-imports.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const repoRoot = fileURLToPath(new URL("../..", import.meta.url));
|
||||
|
||||
describe("search setup cold imports", () => {
|
||||
it("keeps configure wizard command registration off search provider runtime", () => {
|
||||
const source = fs.readFileSync(path.join(repoRoot, "src/commands/configure.wizard.ts"), "utf8");
|
||||
|
||||
expect(source).not.toMatch(/\bfrom\s+["'][^"']*onboard-search\.js["']/);
|
||||
expect(source).not.toMatch(/\bfrom\s+["'][^"']*web-search-providers\.runtime\.js["']/);
|
||||
});
|
||||
});
|
||||
@@ -101,7 +101,7 @@ describe("resolveNativeSkillsEnabled", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses package channel metadata for bundled auto defaults before runtime loads", () => {
|
||||
it("uses only enabled package channel metadata for bundled auto defaults before runtime loads", () => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
const env = {
|
||||
...process.env,
|
||||
@@ -117,6 +117,22 @@ describe("resolveNativeSkillsEnabled", () => {
|
||||
globalSetting: "auto",
|
||||
env,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveNativeSkillsEnabled({
|
||||
providerId: "discord",
|
||||
globalSetting: "auto",
|
||||
env,
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveNativeCommandsEnabled({
|
||||
@@ -125,6 +141,22 @@ describe("resolveNativeSkillsEnabled", () => {
|
||||
env,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveNativeCommandsEnabled({
|
||||
providerId: "discord",
|
||||
globalSetting: "auto",
|
||||
env,
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
discord: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("honors explicit provider settings", () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { resolveReadOnlyChannelCommandDefaults } from "../channels/plugins/read-
|
||||
import type { ChannelId } from "../channels/plugins/types.public.js";
|
||||
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
|
||||
import type { NativeCommandsSetting } from "./types.js";
|
||||
import type { OpenClawConfig } from "./types.openclaw.js";
|
||||
export { isCommandFlagEnabled, isRestartEnabled, type CommandFlagKey } from "./commands.flags.js";
|
||||
|
||||
function resolveAutoDefault(
|
||||
@@ -12,6 +13,7 @@ function resolveAutoDefault(
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
workspaceDir?: string;
|
||||
config?: OpenClawConfig;
|
||||
autoDefault?: boolean;
|
||||
},
|
||||
): boolean {
|
||||
@@ -23,7 +25,13 @@ function resolveAutoDefault(
|
||||
return options.autoDefault;
|
||||
}
|
||||
const commandDefaults =
|
||||
getLoadedChannelPlugin(id)?.commands ?? resolveReadOnlyChannelCommandDefaults(id, options);
|
||||
getLoadedChannelPlugin(id)?.commands ??
|
||||
(options?.config
|
||||
? resolveReadOnlyChannelCommandDefaults(id, {
|
||||
...options,
|
||||
config: options.config,
|
||||
})
|
||||
: undefined);
|
||||
if (kind === "native") {
|
||||
return commandDefaults?.nativeCommandsAutoEnabled === true;
|
||||
}
|
||||
@@ -37,6 +45,7 @@ export function resolveNativeSkillsEnabled(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
workspaceDir?: string;
|
||||
config?: OpenClawConfig;
|
||||
autoDefault?: boolean;
|
||||
}): boolean {
|
||||
return resolveNativeCommandSetting({ ...params, kind: "nativeSkills" });
|
||||
@@ -49,6 +58,7 @@ export function resolveNativeCommandsEnabled(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
workspaceDir?: string;
|
||||
config?: OpenClawConfig;
|
||||
autoDefault?: boolean;
|
||||
}): boolean {
|
||||
return resolveNativeCommandSetting({ ...params, kind: "native" });
|
||||
@@ -62,6 +72,7 @@ function resolveNativeCommandSetting(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
workspaceDir?: string;
|
||||
config?: OpenClawConfig;
|
||||
autoDefault?: boolean;
|
||||
}): boolean {
|
||||
const { providerId, providerSetting, globalSetting, kind = "native", ...options } = params;
|
||||
|
||||
@@ -172,9 +172,10 @@ function mapCommand(
|
||||
function buildPluginCommandEntries(params: {
|
||||
provider?: string;
|
||||
nameSurface: CommandNameSurface;
|
||||
cfg: OpenClawConfig;
|
||||
}): CommandEntry[] {
|
||||
const pluginTextSpecs = listPluginCommands();
|
||||
const pluginNativeSpecs = getPluginCommandSpecs(params.provider);
|
||||
const pluginNativeSpecs = getPluginCommandSpecs(params.provider, { config: params.cfg });
|
||||
const entries: CommandEntry[] = [];
|
||||
|
||||
for (const [index, textSpec] of pluginTextSpecs.entries()) {
|
||||
@@ -233,7 +234,7 @@ export function buildCommandsListResult(params: {
|
||||
);
|
||||
}
|
||||
|
||||
commands.push(...buildPluginCommandEntries({ provider, nameSurface }));
|
||||
commands.push(...buildPluginCommandEntries({ provider, nameSurface, cfg: params.cfg }));
|
||||
|
||||
return { commands: commands.slice(0, COMMAND_LIST_MAX_ITEMS) };
|
||||
}
|
||||
|
||||
@@ -145,9 +145,18 @@ export function registerAuthModesSuite(): void {
|
||||
describe("tailscale auth", () => {
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
||||
let port: number;
|
||||
const tailscaleOrigin = "https://gateway.tailnet.ts.net";
|
||||
|
||||
beforeAll(async () => {
|
||||
testState.gatewayAuth = { mode: "token", token: "secret", allowTailscale: true };
|
||||
testState.gatewayControlUi = { allowedOrigins: [tailscaleOrigin] };
|
||||
const { writeConfigFile } = await import("../config/config.js");
|
||||
await writeConfigFile({
|
||||
gateway: {
|
||||
auth: testState.gatewayAuth,
|
||||
controlUi: testState.gatewayControlUi,
|
||||
},
|
||||
});
|
||||
port = await getFreePort();
|
||||
server = await startGatewayServer(port);
|
||||
});
|
||||
@@ -158,6 +167,7 @@ export function registerAuthModesSuite(): void {
|
||||
|
||||
beforeEach(() => {
|
||||
testState.gatewayAuth = { mode: "token", token: "secret", allowTailscale: true };
|
||||
testState.gatewayControlUi = { allowedOrigins: [tailscaleOrigin] };
|
||||
testTailscaleWhois.value = { login: "peter", name: "Peter" };
|
||||
});
|
||||
|
||||
@@ -173,6 +183,20 @@ export function registerAuthModesSuite(): void {
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("skips pairing for tailscale-authenticated control ui with device identity", async () => {
|
||||
const ws = await openTailscaleWs(port, { origin: tailscaleOrigin });
|
||||
const res = await connectReq(ws, {
|
||||
skipDefaultAuth: true,
|
||||
client: {
|
||||
...CONTROL_UI_CLIENT,
|
||||
},
|
||||
});
|
||||
expect(res.ok, JSON.stringify(res)).toBe(true);
|
||||
const status = await rpcReq(ws, "status");
|
||||
expect(status.ok).toBe(true);
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("connects with shared token but clears scopes when tailscale auth skips device", async () => {
|
||||
const ws = await openTailscaleWs(port);
|
||||
const res = await connectReq(ws, { token: "secret", device: null });
|
||||
|
||||
@@ -74,7 +74,7 @@ const readConnectChallengeNonce = async (ws: WebSocket) => {
|
||||
return String(nonce);
|
||||
};
|
||||
|
||||
const openTailscaleWs = async (port: number) => {
|
||||
const openTailscaleWs = async (port: number, headers?: Record<string, string>) => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`, {
|
||||
headers: {
|
||||
"x-forwarded-for": "100.64.0.1",
|
||||
@@ -82,6 +82,7 @@ const openTailscaleWs = async (port: number) => {
|
||||
"x-forwarded-host": "gateway.tailnet.ts.net",
|
||||
"tailscale-user-login": "peter",
|
||||
"tailscale-user-name": "Peter",
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
trackConnectChallengeNonce(ws);
|
||||
|
||||
@@ -251,6 +251,47 @@ describe("ws connect policy", () => {
|
||||
expect(shouldSkipControlUiPairing(controlUi, "operator", false)).toBe(false);
|
||||
});
|
||||
|
||||
test("tailscale auth skips pairing only for operator control-ui with device identity", () => {
|
||||
const device = {
|
||||
id: "dev-1",
|
||||
publicKey: "pk",
|
||||
signature: "sig",
|
||||
signedAt: Date.now(),
|
||||
nonce: "nonce-1",
|
||||
};
|
||||
const controlUiWithDevice = resolveControlUiAuthPolicy({
|
||||
isControlUi: true,
|
||||
controlUiConfig: undefined,
|
||||
deviceRaw: device,
|
||||
});
|
||||
const controlUiWithoutDevice = resolveControlUiAuthPolicy({
|
||||
isControlUi: true,
|
||||
controlUiConfig: undefined,
|
||||
deviceRaw: null,
|
||||
});
|
||||
const nonControlUiWithDevice = resolveControlUiAuthPolicy({
|
||||
isControlUi: false,
|
||||
controlUiConfig: undefined,
|
||||
deviceRaw: device,
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldSkipControlUiPairing(controlUiWithDevice, "operator", false, "token", "tailscale"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldSkipControlUiPairing(controlUiWithoutDevice, "operator", false, "token", "tailscale"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSkipControlUiPairing(controlUiWithDevice, "node", false, "token", "tailscale"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSkipControlUiPairing(nonControlUiWithDevice, "operator", false, "token", "tailscale"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSkipControlUiPairing(controlUiWithDevice, "operator", false, "token", "token"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("trusted-proxy control-ui bypass only applies to operator + trusted-proxy auth", () => {
|
||||
const cases: Array<{
|
||||
role: "operator" | "node";
|
||||
|
||||
@@ -39,10 +39,14 @@ export function shouldSkipControlUiPairing(
|
||||
role: GatewayRole,
|
||||
trustedProxyAuthOk = false,
|
||||
authMode?: string,
|
||||
authMethod?: string,
|
||||
): boolean {
|
||||
if (trustedProxyAuthOk) {
|
||||
return true;
|
||||
}
|
||||
if (policy.isControlUi && role === "operator" && authMethod === "tailscale" && policy.device) {
|
||||
return true;
|
||||
}
|
||||
// When auth is completely disabled (mode=none), there is no shared secret
|
||||
// or token to gate pairing. Requiring pairing in this configuration adds
|
||||
// friction without security value since any client can already connect
|
||||
|
||||
@@ -844,6 +844,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
role,
|
||||
trustedProxyAuthOk,
|
||||
resolvedAuth.mode,
|
||||
authMethod,
|
||||
);
|
||||
if (device && devicePublicKey) {
|
||||
const formatAuditList = (items: string[] | undefined): string => {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { getLoadedChannelPlugin } from "../channels/plugins/index.js";
|
||||
import { resolveReadOnlyChannelCommandDefaults } from "../channels/plugins/read-only-command-defaults.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
|
||||
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
|
||||
import type { OpenClawPluginCommandDefinition } from "./types.js";
|
||||
@@ -81,6 +84,37 @@ function resolvePluginNativeName(
|
||||
return command.name;
|
||||
}
|
||||
|
||||
export function getPluginCommandSpecs(
|
||||
provider?: string,
|
||||
options: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
workspaceDir?: string;
|
||||
config?: OpenClawConfig;
|
||||
} = {},
|
||||
): Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
acceptsArgs: boolean;
|
||||
}> {
|
||||
const providerName = normalizeOptionalLowercaseString(provider);
|
||||
const commandDefaults =
|
||||
providerName && options.config
|
||||
? resolveReadOnlyChannelCommandDefaults(providerName, {
|
||||
...options,
|
||||
config: options.config,
|
||||
})
|
||||
: undefined;
|
||||
if (
|
||||
providerName &&
|
||||
(getLoadedChannelPlugin(providerName)?.commands ?? commandDefaults)
|
||||
?.nativeCommandsAutoEnabled !== true
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return listProviderPluginCommandSpecs(provider);
|
||||
}
|
||||
|
||||
/** Resolve plugin command specs for a provider's native naming surface without support gating. */
|
||||
export function listProviderPluginCommandSpecs(provider?: string): Array<{
|
||||
name: string;
|
||||
|
||||
@@ -1,20 +1,34 @@
|
||||
import { getLoadedChannelPlugin } from "../channels/plugins/index.js";
|
||||
import { resolveReadOnlyChannelCommandDefaults } from "../channels/plugins/read-only.js";
|
||||
import { resolveReadOnlyChannelCommandDefaults } from "../channels/plugins/read-only-command-defaults.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
|
||||
import { listProviderPluginCommandSpecs } from "./command-registry-state.js";
|
||||
|
||||
export function getPluginCommandSpecs(provider?: string): Array<{
|
||||
export function getPluginCommandSpecs(
|
||||
provider?: string,
|
||||
options: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
workspaceDir?: string;
|
||||
config?: OpenClawConfig;
|
||||
} = {},
|
||||
): Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
acceptsArgs: boolean;
|
||||
}> {
|
||||
const providerName = normalizeOptionalLowercaseString(provider);
|
||||
const commandDefaults =
|
||||
providerName && options.config
|
||||
? resolveReadOnlyChannelCommandDefaults(providerName, {
|
||||
...options,
|
||||
config: options.config,
|
||||
})
|
||||
: undefined;
|
||||
if (
|
||||
providerName &&
|
||||
(
|
||||
getLoadedChannelPlugin(providerName)?.commands ??
|
||||
resolveReadOnlyChannelCommandDefaults(providerName)
|
||||
)?.nativeCommandsAutoEnabled !== true
|
||||
(getLoadedChannelPlugin(providerName)?.commands ?? commandDefaults)
|
||||
?.nativeCommandsAutoEnabled !== true
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import {
|
||||
@@ -331,6 +332,45 @@ describe("registerPluginCommand", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires config before using read-only manifest command defaults", () => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
registerVoiceCommandForTest({
|
||||
nativeNames: {
|
||||
discord: "discordvoice",
|
||||
},
|
||||
description: "Demo command",
|
||||
});
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: path.resolve("extensions"),
|
||||
OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY: "1",
|
||||
OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1",
|
||||
OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1",
|
||||
};
|
||||
|
||||
expect(getPluginCommandSpecs("discord", { env })).toEqual([]);
|
||||
expect(
|
||||
getPluginCommandSpecs("discord", {
|
||||
env,
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
name: "discordvoice",
|
||||
description: "Demo command",
|
||||
acceptsArgs: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts native progress metadata on plugin commands", () => {
|
||||
const result = registerVoiceCommandForTest({
|
||||
nativeProgressMessages: { telegram: "Running voice command..." },
|
||||
|
||||
@@ -102,6 +102,43 @@ describe("installed plugin index persistence", () => {
|
||||
await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toMatchObject(index);
|
||||
});
|
||||
|
||||
it("does not preserve prototype poison keys from persisted index JSON", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const filePath = resolveInstalledPluginIndexStorePath({ stateDir });
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const index = createIndex({
|
||||
installRecords: {
|
||||
demo: {
|
||||
source: "npm",
|
||||
spec: "demo@1.0.0",
|
||||
},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(index, "__proto__", {
|
||||
enumerable: true,
|
||||
value: { polluted: true },
|
||||
});
|
||||
Object.defineProperty(index.installRecords, "__proto__", {
|
||||
enumerable: true,
|
||||
value: { polluted: true },
|
||||
});
|
||||
fs.writeFileSync(filePath, JSON.stringify(index), "utf8");
|
||||
|
||||
const persisted = await readPersistedInstalledPluginIndex({ stateDir });
|
||||
|
||||
expect(persisted).toMatchObject({
|
||||
plugins: [expect.objectContaining({ pluginId: "demo" })],
|
||||
installRecords: {
|
||||
demo: expect.objectContaining({ source: "npm" }),
|
||||
},
|
||||
});
|
||||
expect(Object.prototype.hasOwnProperty.call(persisted as object, "__proto__")).toBe(false);
|
||||
expect(Object.prototype.hasOwnProperty.call(persisted?.installRecords ?? {}, "__proto__")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns null for missing or invalid persisted indexes", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toBeNull();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { saveJsonFile } from "../infra/json-file.js";
|
||||
import { readJsonFile, readJsonFileSync, writeJsonAtomic } from "../infra/json-files.js";
|
||||
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
|
||||
import { safeParseWithSchema } from "../utils/zod-parse.js";
|
||||
import {
|
||||
resolveInstalledPluginIndexStorePath,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
loadInstalledPluginIndex,
|
||||
refreshInstalledPluginIndex,
|
||||
type InstalledPluginIndex,
|
||||
type InstalledPluginInstallRecordInfo,
|
||||
type InstalledPluginIndexRefreshReason,
|
||||
type LoadInstalledPluginIndexParams,
|
||||
type RefreshInstalledPluginIndexParams,
|
||||
@@ -36,71 +38,79 @@ export type InstalledPluginIndexStoreInspection = {
|
||||
|
||||
const StringArraySchema = z.array(z.string());
|
||||
|
||||
const InstalledPluginIndexStartupSchema = z
|
||||
.object({
|
||||
sidecar: z.boolean(),
|
||||
memory: z.boolean(),
|
||||
deferConfiguredChannelFullLoadUntilAfterListen: z.boolean(),
|
||||
agentHarnesses: StringArraySchema,
|
||||
})
|
||||
.passthrough();
|
||||
const InstalledPluginIndexStartupSchema = z.object({
|
||||
sidecar: z.boolean(),
|
||||
memory: z.boolean(),
|
||||
deferConfiguredChannelFullLoadUntilAfterListen: z.boolean(),
|
||||
agentHarnesses: StringArraySchema,
|
||||
});
|
||||
|
||||
const InstalledPluginIndexRecordSchema = z
|
||||
.object({
|
||||
pluginId: z.string(),
|
||||
packageName: z.string().optional(),
|
||||
packageVersion: z.string().optional(),
|
||||
installRecord: z.record(z.string(), z.unknown()).optional(),
|
||||
installRecordHash: z.string().optional(),
|
||||
packageInstall: z.unknown().optional(),
|
||||
packageChannel: z.unknown().optional(),
|
||||
manifestPath: z.string(),
|
||||
manifestHash: z.string(),
|
||||
format: z.string().optional(),
|
||||
bundleFormat: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
setupSource: z.string().optional(),
|
||||
packageJson: z
|
||||
.object({
|
||||
path: z.string(),
|
||||
hash: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
rootDir: z.string(),
|
||||
origin: z.string(),
|
||||
enabled: z.boolean(),
|
||||
enabledByDefault: z.boolean().optional(),
|
||||
startup: InstalledPluginIndexStartupSchema,
|
||||
compat: z.array(z.string()),
|
||||
})
|
||||
.passthrough();
|
||||
const InstalledPluginIndexRecordSchema = z.object({
|
||||
pluginId: z.string(),
|
||||
packageName: z.string().optional(),
|
||||
packageVersion: z.string().optional(),
|
||||
installRecord: z.record(z.string(), z.unknown()).optional(),
|
||||
installRecordHash: z.string().optional(),
|
||||
packageInstall: z.unknown().optional(),
|
||||
packageChannel: z.unknown().optional(),
|
||||
manifestPath: z.string(),
|
||||
manifestHash: z.string(),
|
||||
format: z.string().optional(),
|
||||
bundleFormat: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
setupSource: z.string().optional(),
|
||||
packageJson: z
|
||||
.object({
|
||||
path: z.string(),
|
||||
hash: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
rootDir: z.string(),
|
||||
origin: z.string(),
|
||||
enabled: z.boolean(),
|
||||
enabledByDefault: z.boolean().optional(),
|
||||
startup: InstalledPluginIndexStartupSchema,
|
||||
compat: z.array(z.string()),
|
||||
});
|
||||
|
||||
const InstalledPluginInstallRecordSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
const PluginDiagnosticSchema = z
|
||||
.object({
|
||||
level: z.union([z.literal("warn"), z.literal("error")]),
|
||||
message: z.string(),
|
||||
pluginId: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const PluginDiagnosticSchema = z.object({
|
||||
level: z.union([z.literal("warn"), z.literal("error")]),
|
||||
message: z.string(),
|
||||
pluginId: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
});
|
||||
|
||||
const InstalledPluginIndexSchema = z
|
||||
.object({
|
||||
version: z.literal(INSTALLED_PLUGIN_INDEX_VERSION),
|
||||
warning: z.string().optional(),
|
||||
hostContractVersion: z.string(),
|
||||
compatRegistryVersion: z.string(),
|
||||
migrationVersion: z.literal(INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION),
|
||||
policyHash: z.string(),
|
||||
generatedAtMs: z.number(),
|
||||
refreshReason: z.string().optional(),
|
||||
installRecords: z.record(z.string(), InstalledPluginInstallRecordSchema).optional(),
|
||||
plugins: z.array(InstalledPluginIndexRecordSchema),
|
||||
diagnostics: z.array(PluginDiagnosticSchema),
|
||||
})
|
||||
.passthrough();
|
||||
const InstalledPluginIndexSchema = z.object({
|
||||
version: z.literal(INSTALLED_PLUGIN_INDEX_VERSION),
|
||||
warning: z.string().optional(),
|
||||
hostContractVersion: z.string(),
|
||||
compatRegistryVersion: z.string(),
|
||||
migrationVersion: z.literal(INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION),
|
||||
policyHash: z.string(),
|
||||
generatedAtMs: z.number(),
|
||||
refreshReason: z.string().optional(),
|
||||
installRecords: z.record(z.string(), InstalledPluginInstallRecordSchema).optional(),
|
||||
plugins: z.array(InstalledPluginIndexRecordSchema),
|
||||
diagnostics: z.array(PluginDiagnosticSchema),
|
||||
});
|
||||
|
||||
function copySafeInstallRecords(
|
||||
records: Readonly<Record<string, InstalledPluginInstallRecordInfo>> | undefined,
|
||||
): Record<string, InstalledPluginInstallRecordInfo> | undefined {
|
||||
if (!records) {
|
||||
return undefined;
|
||||
}
|
||||
const safeRecords: Record<string, InstalledPluginInstallRecordInfo> = {};
|
||||
for (const [pluginId, record] of Object.entries(records)) {
|
||||
if (isBlockedObjectKey(pluginId)) {
|
||||
continue;
|
||||
}
|
||||
safeRecords[pluginId] = record;
|
||||
}
|
||||
return safeRecords;
|
||||
}
|
||||
|
||||
function parseInstalledPluginIndex(value: unknown): InstalledPluginIndex | null {
|
||||
const parsed = safeParseWithSchema(InstalledPluginIndexSchema, value) as
|
||||
@@ -111,11 +121,24 @@ function parseInstalledPluginIndex(value: unknown): InstalledPluginIndex | null
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...parsed,
|
||||
installRecords:
|
||||
parsed.installRecords ??
|
||||
const installRecords =
|
||||
copySafeInstallRecords(parsed.installRecords) ??
|
||||
copySafeInstallRecords(
|
||||
extractPluginInstallRecordsFromInstalledPluginIndex(parsed as InstalledPluginIndex),
|
||||
) ??
|
||||
{};
|
||||
return {
|
||||
version: parsed.version,
|
||||
...(parsed.warning ? { warning: parsed.warning } : {}),
|
||||
hostContractVersion: parsed.hostContractVersion,
|
||||
compatRegistryVersion: parsed.compatRegistryVersion,
|
||||
migrationVersion: parsed.migrationVersion,
|
||||
policyHash: parsed.policyHash,
|
||||
generatedAtMs: parsed.generatedAtMs,
|
||||
...(parsed.refreshReason ? { refreshReason: parsed.refreshReason } : {}),
|
||||
installRecords,
|
||||
plugins: parsed.plugins,
|
||||
diagnostics: parsed.diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -365,6 +365,22 @@ describe("removePluginFromConfig", () => {
|
||||
expect(actions.memorySlot).toBe(expectedChanged);
|
||||
});
|
||||
|
||||
it("clears context engine slot when uninstalling active context engine plugin", () => {
|
||||
const config = createPluginConfig({
|
||||
entries: {
|
||||
"context-plugin": { enabled: true },
|
||||
},
|
||||
slots: {
|
||||
contextEngine: "context-plugin",
|
||||
},
|
||||
});
|
||||
|
||||
const { config: result, actions } = removePluginFromConfig(config, "context-plugin");
|
||||
|
||||
expect(result.plugins?.slots?.contextEngine).toBe("legacy");
|
||||
expect(actions.contextEngineSlot).toBe(true);
|
||||
});
|
||||
|
||||
it("removes plugins object when uninstall leaves only empty slots", () => {
|
||||
const config = createSinglePluginWithEmptySlotsConfig();
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export type UninstallActions = {
|
||||
allowlist: boolean;
|
||||
loadPath: boolean;
|
||||
memorySlot: boolean;
|
||||
contextEngineSlot: boolean;
|
||||
channelConfig: boolean;
|
||||
directory: boolean;
|
||||
};
|
||||
@@ -155,6 +156,7 @@ export function removePluginFromConfig(
|
||||
allowlist: false,
|
||||
loadPath: false,
|
||||
memorySlot: false,
|
||||
contextEngineSlot: false,
|
||||
channelConfig: false,
|
||||
};
|
||||
|
||||
@@ -204,7 +206,7 @@ export function removePluginFromConfig(
|
||||
}
|
||||
}
|
||||
|
||||
// Reset memory slot if this plugin was selected
|
||||
// Reset slots if this plugin was selected.
|
||||
let slots = pluginsConfig.slots;
|
||||
if (slots?.memory === pluginId) {
|
||||
slots = {
|
||||
@@ -213,6 +215,13 @@ export function removePluginFromConfig(
|
||||
};
|
||||
actions.memorySlot = true;
|
||||
}
|
||||
if (slots?.contextEngine === pluginId) {
|
||||
slots = {
|
||||
...slots,
|
||||
contextEngine: defaultSlotIdForKey("contextEngine"),
|
||||
};
|
||||
actions.contextEngineSlot = true;
|
||||
}
|
||||
if (slots && Object.keys(slots).length === 0) {
|
||||
slots = undefined;
|
||||
}
|
||||
|
||||
@@ -876,6 +876,41 @@ describe("updateNpmInstalledPlugins", () => {
|
||||
expect(result.config.plugins?.installs?.["voice-call"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("migrates context engine slot when a plugin id changes during update", async () => {
|
||||
installPluginFromNpmSpecMock.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "@openclaw/context-engine",
|
||||
targetDir: "/tmp/openclaw-context-engine",
|
||||
version: "0.0.2",
|
||||
extensions: ["index.ts"],
|
||||
});
|
||||
|
||||
const result = await updateNpmInstalledPlugins({
|
||||
config: {
|
||||
plugins: {
|
||||
slots: { contextEngine: "context-engine" },
|
||||
installs: {
|
||||
"context-engine": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/context-engine",
|
||||
installPath: "/tmp/context-engine",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
pluginIds: ["context-engine"],
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.slots?.contextEngine).toBe("@openclaw/context-engine");
|
||||
expect(result.config.plugins?.installs?.["@openclaw/context-engine"]).toMatchObject({
|
||||
source: "npm",
|
||||
spec: "@openclaw/context-engine",
|
||||
installPath: "/tmp/openclaw-context-engine",
|
||||
version: "0.0.2",
|
||||
});
|
||||
expect(result.config.plugins?.installs?.["context-engine"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("checks marketplace installs during dry-run updates", async () => {
|
||||
installPluginFromMarketplaceMock.mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@@ -417,13 +417,13 @@ function migratePluginConfigId(cfg: OpenClawConfig, fromId: string, toId: string
|
||||
delete nextEntries[fromId];
|
||||
}
|
||||
|
||||
const nextSlots =
|
||||
slots?.memory === fromId
|
||||
? {
|
||||
...slots,
|
||||
memory: toId,
|
||||
}
|
||||
: slots;
|
||||
const nextSlots = slots
|
||||
? {
|
||||
...slots,
|
||||
...(slots.memory === fromId ? { memory: toId } : {}),
|
||||
...(slots.contextEngine === fromId ? { contextEngine: toId } : {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
|
||||
@@ -96,6 +96,7 @@ export type DetachedTaskDeliveryStatusParams = {
|
||||
runtime?: TaskRuntime;
|
||||
sessionKey?: string;
|
||||
deliveryStatus: TaskDeliveryStatus;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type DetachedTaskCancelParams = {
|
||||
|
||||
@@ -211,6 +211,7 @@ export function setDetachedTaskDeliveryStatusByRunId(params: {
|
||||
runtime?: TaskRuntime;
|
||||
sessionKey?: string;
|
||||
deliveryStatus: TaskDeliveryStatus;
|
||||
error?: string;
|
||||
}) {
|
||||
return setTaskRunDeliveryStatusByRunId(params);
|
||||
}
|
||||
|
||||
@@ -1672,15 +1672,20 @@ function updateTaskDeliveryByRunId(params: {
|
||||
runtime?: TaskRuntime;
|
||||
sessionKey?: string;
|
||||
deliveryStatus: TaskDeliveryStatus;
|
||||
error?: string;
|
||||
}) {
|
||||
ensureTaskRegistryReady();
|
||||
const patch: Partial<TaskRecord> = {
|
||||
deliveryStatus: params.deliveryStatus,
|
||||
};
|
||||
if (params.error !== undefined) {
|
||||
patch.error = params.error;
|
||||
}
|
||||
return updateTasksByRunId({
|
||||
runId: params.runId,
|
||||
runtime: params.runtime,
|
||||
sessionKey: params.sessionKey,
|
||||
patch: {
|
||||
deliveryStatus: params.deliveryStatus,
|
||||
},
|
||||
patch,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1772,6 +1777,7 @@ export function setTaskRunDeliveryStatusByRunId(params: {
|
||||
runtime?: TaskRuntime;
|
||||
sessionKey?: string;
|
||||
deliveryStatus: TaskDeliveryStatus;
|
||||
error?: string;
|
||||
}) {
|
||||
return updateTaskDeliveryByRunId(params);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user