Merge branch 'clawdbot:main' into fix/openrouter-auto-prefix

This commit is contained in:
Trent Pierce 2026-01-26 15:23:03 -06:00 committed by GitHub
commit 204b334894
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 755 additions and 97 deletions

36
.github/labeler.yml vendored
View File

@ -138,6 +138,42 @@
- any-glob-to-any-file:
- "src/cli/**"
"commands":
- changed-files:
- any-glob-to-any-file:
- "src/commands/**"
"scripts":
- changed-files:
- any-glob-to-any-file:
- "scripts/**"
"docker":
- changed-files:
- any-glob-to-any-file:
- "Dockerfile"
- "Dockerfile.*"
- "docker-compose.yml"
- "docker-setup.sh"
- ".dockerignore"
- "scripts/**/*docker*"
- "scripts/**/Dockerfile*"
- "scripts/sandbox-*.sh"
- "src/agents/sandbox*.ts"
- "src/commands/sandbox*.ts"
- "src/cli/sandbox-cli.ts"
- "src/docker-setup.test.ts"
- "src/config/**/*sandbox*"
- "docs/cli/sandbox.md"
- "docs/gateway/sandbox*.md"
- "docs/install/docker.md"
- "docs/multi-agent-sandbox-tools.md"
"agents":
- changed-files:
- any-glob-to-any-file:
- "src/agents/**"
"security":
- changed-files:
- any-glob-to-any-file:

View File

@ -3,7 +3,7 @@ name: Auto response
on:
issues:
types: [labeled]
pull_request:
pull_request_target:
types: [labeled]
permissions:
@ -14,9 +14,15 @@ jobs:
auto-response:
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Handle labeled items
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const rules = [
{

View File

@ -8,6 +8,7 @@ Status: unreleased.
### Changes
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
- Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido.
- Docs: add migration guide for moving to a new machine. (#2381)
- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos.
- Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248)
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
@ -40,6 +41,7 @@ Status: unreleased.
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein.
- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.
- Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma.
- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.

View File

@ -282,22 +282,6 @@ enum CommandResolver {
guard !settings.target.isEmpty else { return nil }
guard let parsed = self.parseSSHTarget(settings.target) else { return nil }
var args: [String] = [
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
]
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
if !identity.isEmpty {
// Only use IdentitiesOnly when an explicit identity file is provided.
// This allows 1Password SSH agent and other SSH agents to provide keys.
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
args.append(contentsOf: ["-i", identity])
}
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
args.append(userHost)
// Run the real clawdbot CLI on the remote host.
let exportedPath = [
"/opt/homebrew/bin",
@ -324,7 +308,7 @@ enum CommandResolver {
} else {
"""
PRJ=\(self.shellQuote(userPRJ))
cd \(self.shellQuote(userPRJ)) || { echo "Project root not found: \(userPRJ)"; exit 127; }
cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; }
"""
}
@ -378,7 +362,16 @@ enum CommandResolver {
echo "clawdbot CLI missing on remote host"; exit 127;
fi
"""
args.append(contentsOf: ["/bin/sh", "-c", scriptBody])
let options: [String] = [
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
]
let args = self.sshArguments(
target: parsed,
identity: settings.identity,
options: options,
remoteCommand: ["/bin/sh", "-c", scriptBody])
return ["/usr/bin/ssh"] + args
}
@ -427,8 +420,11 @@ enum CommandResolver {
}
static func parseSSHTarget(_ target: String) -> SSHParsedTarget? {
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmed = self.normalizeSSHTargetInput(target)
guard !trimmed.isEmpty else { return nil }
if trimmed.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) != nil {
return nil
}
let userHostPort: String
let user: String?
if let atRange = trimmed.range(of: "@") {
@ -444,13 +440,31 @@ enum CommandResolver {
if let colon = userHostPort.lastIndex(of: ":"), colon != userHostPort.startIndex {
host = String(userHostPort[..<colon])
let portStr = String(userHostPort[userHostPort.index(after: colon)...])
port = Int(portStr) ?? 22
guard let parsedPort = Int(portStr), parsedPort > 0, parsedPort <= 65535 else {
return nil
}
port = parsedPort
} else {
host = userHostPort
port = 22
}
return SSHParsedTarget(user: user, host: host, port: port)
return self.makeSSHTarget(user: user, host: host, port: port)
}
static func sshTargetValidationMessage(_ target: String) -> String? {
let trimmed = self.normalizeSSHTargetInput(target)
guard !trimmed.isEmpty else { return nil }
if trimmed.hasPrefix("-") {
return "SSH target cannot start with '-'"
}
if trimmed.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) != nil {
return "SSH target cannot contain spaces"
}
if self.parseSSHTarget(trimmed) == nil {
return "SSH target must look like user@host[:port]"
}
return nil
}
private static func shellQuote(_ text: String) -> String {
@ -468,6 +482,64 @@ enum CommandResolver {
return URL(fileURLWithPath: expanded)
}
private static func normalizeSSHTargetInput(_ target: String) -> String {
var trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("ssh ") {
trimmed = trimmed.replacingOccurrences(of: "ssh ", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
return trimmed
}
private static func isValidSSHComponent(_ value: String, allowLeadingDash: Bool = false) -> Bool {
if value.isEmpty { return false }
if !allowLeadingDash, value.hasPrefix("-") { return false }
let invalid = CharacterSet.whitespacesAndNewlines.union(.controlCharacters)
return value.rangeOfCharacter(from: invalid) == nil
}
static func makeSSHTarget(user: String?, host: String, port: Int) -> SSHParsedTarget? {
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard self.isValidSSHComponent(trimmedHost) else { return nil }
let trimmedUser = user?.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedUser: String?
if let trimmedUser {
guard self.isValidSSHComponent(trimmedUser) else { return nil }
normalizedUser = trimmedUser.isEmpty ? nil : trimmedUser
} else {
normalizedUser = nil
}
guard port > 0, port <= 65535 else { return nil }
return SSHParsedTarget(user: normalizedUser, host: trimmedHost, port: port)
}
private static func sshTargetString(_ target: SSHParsedTarget) -> String {
target.user.map { "\($0)@\(target.host)" } ?? target.host
}
static func sshArguments(
target: SSHParsedTarget,
identity: String,
options: [String],
remoteCommand: [String] = []) -> [String]
{
var args = options
if target.port > 0 {
args.append(contentsOf: ["-p", String(target.port)])
}
let trimmedIdentity = identity.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedIdentity.isEmpty {
// Only use IdentitiesOnly when an explicit identity file is provided.
// This allows 1Password SSH agent and other SSH agents to provide keys.
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
args.append(contentsOf: ["-i", trimmedIdentity])
}
args.append("--")
args.append(self.sshTargetString(target))
args.append(contentsOf: remoteCommand)
return args
}
#if SWIFT_PACKAGE
static func _testNodeManagerBinPaths(home: URL) -> [String] {
self.nodeManagerBinPaths(home: home)

View File

@ -243,25 +243,36 @@ struct GeneralSettings: View {
}
private var remoteSshRow: some View {
HStack(alignment: .center, spacing: 10) {
Text("SSH target")
.font(.callout.weight(.semibold))
.frame(width: self.remoteLabelWidth, alignment: .leading)
TextField("user@host[:22]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
Button {
Task { await self.testRemote() }
} label: {
if self.remoteStatus == .checking {
ProgressView().controlSize(.small)
} else {
Text("Test remote")
let trimmedTarget = self.state.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines)
let validationMessage = CommandResolver.sshTargetValidationMessage(trimmedTarget)
let canTest = !trimmedTarget.isEmpty && validationMessage == nil
return VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .center, spacing: 10) {
Text("SSH target")
.font(.callout.weight(.semibold))
.frame(width: self.remoteLabelWidth, alignment: .leading)
TextField("user@host[:22]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
Button {
Task { await self.testRemote() }
} label: {
if self.remoteStatus == .checking {
ProgressView().controlSize(.small)
} else {
Text("Test remote")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || !canTest)
}
if let validationMessage {
Text(validationMessage)
.font(.caption)
.foregroundStyle(.red)
.padding(.leading, self.remoteLabelWidth + 10)
}
.buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
@ -540,8 +551,15 @@ extension GeneralSettings {
}
// Step 1: basic SSH reachability check
guard let sshCommand = Self.sshCheckCommand(
target: settings.target,
identity: settings.identity)
else {
self.remoteStatus = .failed("SSH target is invalid")
return
}
let sshResult = await ShellExecutor.run(
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
command: sshCommand,
cwd: nil,
env: nil,
timeout: 8)
@ -587,20 +605,20 @@ extension GeneralSettings {
return !host.isEmpty
}
private static func sshCheckCommand(target: String, identity: String) -> [String] {
var args: [String] = [
"/usr/bin/ssh",
private static func sshCheckCommand(target: String, identity: String) -> [String]? {
guard let parsed = CommandResolver.parseSSHTarget(target) else { return nil }
let options = [
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
]
if !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
args.append(contentsOf: ["-i", identity])
}
args.append(target)
args.append("echo ok")
return args
let args = CommandResolver.sshArguments(
target: parsed,
identity: identity,
options: options,
remoteCommand: ["echo", "ok"])
return ["/usr/bin/ssh"] + args
}
private func formatSSHFailure(_ response: Response, target: String) -> String {

View File

@ -559,22 +559,21 @@ final class NodePairingApprovalPrompter {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
var args = [
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=5",
"-o",
"NumberOfPasswordPrompts=0",
"-o",
"PreferredAuthentications=publickey",
"-o",
"StrictHostKeyChecking=accept-new",
let options = [
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=5",
"-o", "NumberOfPasswordPrompts=0",
"-o", "PreferredAuthentications=publickey",
"-o", "StrictHostKeyChecking=accept-new",
]
if port > 0, port != 22 {
args.append(contentsOf: ["-p", String(port)])
guard let target = CommandResolver.makeSSHTarget(user: user, host: host, port: port) else {
return false
}
args.append(contentsOf: ["-l", user, host, "/usr/bin/true"])
let args = CommandResolver.sshArguments(
target: target,
identity: "",
options: options,
remoteCommand: ["/usr/bin/true"])
process.arguments = args
let pipe = Pipe()
process.standardOutput = pipe

View File

@ -206,6 +206,16 @@ extension OnboardingView {
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
if let message = CommandResolver.sshTargetValidationMessage(self.state.remoteTarget) {
GridRow {
Text("")
.frame(width: labelWidth, alignment: .leading)
Text(message)
.font(.caption)
.foregroundStyle(.red)
.frame(width: fieldWidth, alignment: .leading)
}
}
GridRow {
Text("Identity file")
.font(.callout.weight(.semibold))

View File

@ -70,7 +70,7 @@ final class RemotePortTunnel {
"ssh tunnel using default remote port " +
"host=\(sshHost, privacy: .public) port=\(remotePort, privacy: .public)")
}
var args: [String] = [
let options: [String] = [
"-o", "BatchMode=yes",
"-o", "ExitOnForwardFailure=yes",
"-o", "StrictHostKeyChecking=accept-new",
@ -81,16 +81,11 @@ final class RemotePortTunnel {
"-N",
"-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)",
]
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
if !identity.isEmpty {
// Only use IdentitiesOnly when an explicit identity file is provided.
// This allows 1Password SSH agent and other SSH agents to provide keys.
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
args.append(contentsOf: ["-i", identity])
}
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
args.append(userHost)
let args = CommandResolver.sshArguments(
target: parsed,
identity: identity,
options: options)
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")

View File

@ -123,11 +123,16 @@ import Testing
configRoot: [:])
#expect(cmd.first == "/usr/bin/ssh")
#expect(cmd.contains("clawd@example.com"))
if let marker = cmd.firstIndex(of: "--") {
#expect(cmd[marker + 1] == "clawd@example.com")
} else {
#expect(Bool(false))
}
#expect(cmd.contains("-i"))
#expect(cmd.contains("/tmp/id_ed25519"))
if let script = cmd.last {
#expect(script.contains("cd '/srv/clawdbot'"))
#expect(script.contains("PRJ='/srv/clawdbot'"))
#expect(script.contains("cd \"$PRJ\""))
#expect(script.contains("clawdbot"))
#expect(script.contains("status"))
#expect(script.contains("--json"))
@ -135,6 +140,12 @@ import Testing
}
}
@Test func rejectsUnsafeSSHTargets() async throws {
#expect(CommandResolver.parseSSHTarget("-oProxyCommand=calc") == nil)
#expect(CommandResolver.parseSSHTarget("host:-oProxyCommand=calc") == nil)
#expect(CommandResolver.parseSSHTarget("user@host:2222")?.port == 2222)
}
@Test func configRootLocalOverridesRemoteDefaults() async throws {
let defaults = self.makeDefaults()
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)

View File

@ -11,7 +11,12 @@ struct MasterDiscoveryMenuSmokeTests {
discovery.statusText = "Searching…"
discovery.gateways = []
let view = GatewayDiscoveryInlineList(discovery: discovery, currentTarget: nil, onSelect: { _ in })
let view = GatewayDiscoveryInlineList(
discovery: discovery,
currentTarget: nil,
currentUrl: nil,
transport: .ssh,
onSelect: { _ in })
_ = view.body
}
@ -32,7 +37,12 @@ struct MasterDiscoveryMenuSmokeTests {
]
let currentTarget = "\(NSUserName())@office.tailnet-123.ts.net:2222"
let view = GatewayDiscoveryInlineList(discovery: discovery, currentTarget: currentTarget, onSelect: { _ in })
let view = GatewayDiscoveryInlineList(
discovery: discovery,
currentTarget: currentTarget,
currentUrl: nil,
transport: .ssh,
onSelect: { _ in })
_ = view.body
}

View File

@ -201,7 +201,7 @@ For ad-hoc workflows, call Lobster directly.
- Lobster runs as a **local subprocess** (`lobster` CLI) in tool mode and returns a **JSON envelope**.
- If the tool returns `needs_approval`, you resume with a `resumeToken` and `approve` flag.
- The tool is an **optional plugin**; you must allowlist `lobster` in `tools.allow`.
- The tool is an **optional plugin**; enable it additively via `tools.alsoAllow: ["lobster"]` (recommended).
- If you pass `lobsterPath`, it must be an **absolute path**.
See [Lobster](/tools/lobster) for full usage and examples.

View File

@ -401,7 +401,7 @@ remote mode, remember the gateway host owns the session store and workspace.
up **memory + bootstrap files**, but **not** session history or auth. Those live
under `~/.clawdbot/` (for example `~/.clawdbot/agents/<agentId>/sessions/`).
Related: [Where things live on disk](/help/faq#where-does-clawdbot-store-its-data),
Related: [Migrating](/install/migrating), [Where things live on disk](/help/faq#where-does-clawdbot-store-its-data),
[Agent workspace](/concepts/agent-workspace), [Doctor](/gateway/doctor),
[Remote mode](/gateway/remote).

View File

@ -177,4 +177,5 @@ Then open a new terminal (or `rehash` in zsh / `hash -r` in bash).
## Update / uninstall
- Updates: [Updating](/install/updating)
- Migrate to a new machine: [Migrating](/install/migrating)
- Uninstall: [Uninstall](/install/uninstall)

190
docs/install/migrating.md Normal file
View File

@ -0,0 +1,190 @@
---
summary: "Move (migrate) a Clawdbot install from one machine to another"
read_when:
- You are moving Clawdbot to a new laptop/server
- You want to preserve sessions, auth, and channel logins (WhatsApp, etc.)
---
# Migrating Clawdbot to a new machine
This guide migrates a Clawdbot Gateway from one machine to another **without redoing onboarding**.
The migration is simple conceptually:
- Copy the **state directory** (`$CLAWDBOT_STATE_DIR`, default: `~/.clawdbot/`) — this includes config, auth, sessions, and channel state.
- Copy your **workspace** (`~/clawd/` by default) — this includes your agent files (memory, prompts, etc.).
But there are common footguns around **profiles**, **permissions**, and **partial copies**.
## Before you start (what you are migrating)
### 1) Identify your state directory
Most installs use the default:
- **State dir:** `~/.clawdbot/`
But it may be different if you use:
- `--profile <name>` (often becomes `~/.clawdbot-<profile>/`)
- `CLAWDBOT_STATE_DIR=/some/path`
If youre not sure, run on the **old** machine:
```bash
clawdbot status
```
Look for mentions of `CLAWDBOT_STATE_DIR` / profile in the output. If you run multiple gateways, repeat for each profile.
### 2) Identify your workspace
Common defaults:
- `~/clawd/` (recommended workspace)
- a custom folder you created
Your workspace is where files like `MEMORY.md`, `USER.md`, and `memory/*.md` live.
### 3) Understand what you will preserve
If you copy **both** the state dir and workspace, you keep:
- Gateway configuration (`clawdbot.json`)
- Auth profiles / API keys / OAuth tokens
- Session history + agent state
- Channel state (e.g. WhatsApp login/session)
- Your workspace files (memory, skills notes, etc.)
If you copy **only** the workspace (e.g., via Git), you do **not** preserve:
- sessions
- credentials
- channel logins
Those live under `$CLAWDBOT_STATE_DIR`.
## Migration steps (recommended)
### Step 0 — Make a backup (old machine)
On the **old** machine, stop the gateway first so files arent changing mid-copy:
```bash
clawdbot gateway stop
```
(Optional but recommended) archive the state dir and workspace:
```bash
# Adjust paths if you use a profile or custom locations
cd ~
tar -czf clawdbot-state.tgz .clawdbot
tar -czf clawd-workspace.tgz clawd
```
If you have multiple profiles/state dirs (e.g. `~/.clawdbot-main`, `~/.clawdbot-work`), archive each.
### Step 1 — Install Clawdbot on the new machine
On the **new** machine, install the CLI (and Node if needed):
- See: [Install](/install)
At this stage, its OK if onboarding creates a fresh `~/.clawdbot/` — you will overwrite it in the next step.
### Step 2 — Copy the state dir + workspace to the new machine
Copy **both**:
- `$CLAWDBOT_STATE_DIR` (default `~/.clawdbot/`)
- your workspace (default `~/clawd/`)
Common approaches:
- `scp` the tarballs and extract
- `rsync -a` over SSH
- external drive
After copying, ensure:
- Hidden directories were included (e.g. `.clawdbot/`)
- File ownership is correct for the user running the gateway
### Step 3 — Run Doctor (migrations + service repair)
On the **new** machine:
```bash
clawdbot doctor
```
Doctor is the “safe boring” command. It repairs services, applies config migrations, and warns about mismatches.
Then:
```bash
clawdbot gateway restart
clawdbot status
```
## Common footguns (and how to avoid them)
### Footgun: profile / state-dir mismatch
If you ran the old gateway with a profile (or `CLAWDBOT_STATE_DIR`), and the new gateway uses a different one, youll see symptoms like:
- config changes not taking effect
- channels missing / logged out
- empty session history
Fix: run the gateway/service using the **same** profile/state dir you migrated, then rerun:
```bash
clawdbot doctor
```
### Footgun: copying only `clawdbot.json`
`clawdbot.json` is not enough. Many providers store state under:
- `$CLAWDBOT_STATE_DIR/credentials/`
- `$CLAWDBOT_STATE_DIR/agents/<agentId>/...`
Always migrate the entire `$CLAWDBOT_STATE_DIR` folder.
### Footgun: permissions / ownership
If you copied as root or changed users, the gateway may fail to read credentials/sessions.
Fix: ensure the state dir + workspace are owned by the user running the gateway.
### Footgun: migrating between remote/local modes
- If your UI (WebUI/TUI) points at a **remote** gateway, the remote host owns the session store + workspace.
- Migrating your laptop wont move the remote gateways state.
If youre in remote mode, migrate the **gateway host**.
### Footgun: secrets in backups
`$CLAWDBOT_STATE_DIR` contains secrets (API keys, OAuth tokens, WhatsApp creds). Treat backups like production secrets:
- store encrypted
- avoid sharing over insecure channels
- rotate keys if you suspect exposure
## Verification checklist
On the new machine, confirm:
- `clawdbot status` shows the gateway running
- Your channels are still connected (e.g. WhatsApp doesnt require re-pair)
- The dashboard opens and shows existing sessions
- Your workspace files (memory, configs) are present
## Related
- [Doctor](/gateway/doctor)
- [Gateway troubleshooting](/gateway/troubleshooting)
- [Where does Clawdbot store its data?](/help/faq#where-does-clawdbot-store-its-data)

View File

@ -158,7 +158,19 @@ If you want to use a custom binary location, pass an **absolute** `lobsterPath`
## Enable the tool
Lobster is an **optional** plugin tool (not enabled by default). Allow it per agent:
Lobster is an **optional** plugin tool (not enabled by default).
Recommended (additive, safe):
```json
{
"tools": {
"alsoAllow": ["lobster"]
}
}
```
Or per-agent:
```json
{
@ -167,7 +179,7 @@ Lobster is an **optional** plugin tool (not enabled by default). Allow it per ag
{
"id": "main",
"tools": {
"allow": ["lobster"]
"alsoAllow": ["lobster"]
}
}
]
@ -175,7 +187,7 @@ Lobster is an **optional** plugin tool (not enabled by default). Allow it per ag
}
```
You can also allow it globally with `tools.allow` if every agent should see it.
Avoid using `tools.allow: ["lobster"]` unless you intend to run in restrictive allowlist mode.
Note: allowlists are opt-in for optional plugins. If your allowlist only names
plugin tools (like `lobster`), Clawdbot keeps core tools enabled. To restrict core

View File

@ -96,13 +96,28 @@ export function filterToolsByPolicy(tools: AnyAgentTool[], policy?: SandboxToolP
type ToolPolicyConfig = {
allow?: string[];
alsoAllow?: string[];
deny?: string[];
profile?: string;
};
function unionAllow(base?: string[], extra?: string[]) {
if (!Array.isArray(extra) || extra.length === 0) return base;
// If the user is using alsoAllow without an allowlist, treat it as additive on top of
// an implicit allow-all policy.
if (!Array.isArray(base) || base.length === 0) {
return Array.from(new Set(["*", ...extra]));
}
return Array.from(new Set([...base, ...extra]));
}
function pickToolPolicy(config?: ToolPolicyConfig): SandboxToolPolicy | undefined {
if (!config) return undefined;
const allow = Array.isArray(config.allow) ? config.allow : undefined;
const allow = Array.isArray(config.allow)
? unionAllow(config.allow, config.alsoAllow)
: Array.isArray(config.alsoAllow) && config.alsoAllow.length > 0
? unionAllow(undefined, config.alsoAllow)
: undefined;
const deny = Array.isArray(config.deny) ? config.deny : undefined;
if (!allow && !deny) return undefined;
return { allow, deny };
@ -195,6 +210,17 @@ export function resolveEffectiveToolPolicy(params: {
agentProviderPolicy: pickToolPolicy(agentProviderPolicy),
profile,
providerProfile: agentProviderPolicy?.profile ?? providerPolicy?.profile,
// alsoAllow is applied at the profile stage (to avoid being filtered out early).
profileAlsoAllow: Array.isArray(agentTools?.alsoAllow)
? agentTools?.alsoAllow
: Array.isArray(globalTools?.alsoAllow)
? globalTools?.alsoAllow
: undefined,
providerProfileAlsoAllow: Array.isArray(agentProviderPolicy?.alsoAllow)
? agentProviderPolicy?.alsoAllow
: Array.isArray(providerPolicy?.alsoAllow)
? providerPolicy?.alsoAllow
: undefined,
};
}

View File

@ -157,6 +157,8 @@ export function createClawdbotCodingTools(options?: {
agentProviderPolicy,
profile,
providerProfile,
profileAlsoAllow,
providerProfileAlsoAllow,
} = resolveEffectiveToolPolicy({
config: options?.config,
sessionKey: options?.sessionKey,
@ -175,14 +177,25 @@ export function createClawdbotCodingTools(options?: {
});
const profilePolicy = resolveToolProfilePolicy(profile);
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
const mergeAlsoAllow = (policy: typeof profilePolicy, alsoAllow?: string[]) => {
if (!policy?.allow || !Array.isArray(alsoAllow) || alsoAllow.length === 0) return policy;
return { ...policy, allow: Array.from(new Set([...policy.allow, ...alsoAllow])) };
};
const profilePolicyWithAlsoAllow = mergeAlsoAllow(profilePolicy, profileAlsoAllow);
const providerProfilePolicyWithAlsoAllow = mergeAlsoAllow(
providerProfilePolicy,
providerProfileAlsoAllow,
);
const scopeKey = options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined);
const subagentPolicy =
isSubagentSessionKey(options?.sessionKey) && options?.sessionKey
? resolveSubagentToolPolicy(options.config)
: undefined;
const allowBackground = isToolAllowedByPolicies("process", [
profilePolicy,
providerProfilePolicy,
profilePolicyWithAlsoAllow,
providerProfilePolicyWithAlsoAllow,
globalPolicy,
globalProviderPolicy,
agentPolicy,
@ -333,18 +346,18 @@ export function createClawdbotCodingTools(options?: {
if (resolved.unknownAllowlist.length > 0) {
const entries = resolved.unknownAllowlist.join(", ");
const suffix = resolved.strippedAllowlist
? "Ignoring allowlist so core tools remain available."
? "Ignoring allowlist so core tools remain available. Use tools.alsoAllow for additive plugin tool enablement."
: "These entries won't match any tool unless the plugin is enabled.";
logWarn(`tools: ${label} allowlist contains unknown entries (${entries}). ${suffix}`);
}
return expandPolicyWithPluginGroups(resolved.policy, pluginGroups);
};
const profilePolicyExpanded = resolvePolicy(
profilePolicy,
profilePolicyWithAlsoAllow,
profile ? `tools.profile (${profile})` : "tools.profile",
);
const providerProfileExpanded = resolvePolicy(
providerProfilePolicy,
providerProfilePolicyWithAlsoAllow,
providerProfile ? `tools.byProvider.profile (${providerProfile})` : "tools.byProvider.profile",
);
const globalPolicyExpanded = resolvePolicy(globalPolicy, "tools.allow");

View File

@ -209,6 +209,12 @@ export function stripPluginOnlyAllowlist(
if (!isCoreEntry && !isPluginEntry) unknownAllowlist.push(entry);
}
const strippedAllowlist = !hasCoreEntry;
// When an allowlist contains only plugin tools, we strip it to avoid accidentally
// disabling core tools. Users who want additive behavior should prefer `tools.alsoAllow`.
if (strippedAllowlist) {
// Note: logging happens in the caller (pi-tools/tools-invoke) after this function returns.
// We keep this note here for future maintainers.
}
return {
policy: strippedAllowlist ? { ...policy, allow: undefined } : policy,
unknownAllowlist: Array.from(new Set(unknownAllowlist)),

View File

@ -59,6 +59,7 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole
replyTo: Type.Optional(Type.String()),
threadId: Type.Optional(Type.String()),
asVoice: Type.Optional(Type.Boolean()),
silent: Type.Optional(Type.Boolean()),
bestEffort: Type.Optional(Type.Boolean()),
gifPlayback: Type.Optional(Type.Boolean()),
buttons: Type.Optional(

View File

@ -176,6 +176,7 @@ export async function handleTelegramAction(
replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined,
asVoice: typeof params.asVoice === "boolean" ? params.asVoice : undefined,
silent: typeof params.silent === "boolean" ? params.silent : undefined,
});
return jsonResult({
ok: true,

View File

@ -36,4 +36,30 @@ describe("telegramMessageActions", () => {
cfg,
);
});
it("passes silent flag for silent sends", async () => {
handleTelegramAction.mockClear();
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
await telegramMessageActions.handleAction({
action: "send",
params: {
to: "456",
message: "Silent notification test",
silent: true,
},
cfg,
accountId: undefined,
});
expect(handleTelegramAction).toHaveBeenCalledWith(
expect.objectContaining({
action: "sendMessage",
to: "456",
content: "Silent notification test",
silent: true,
}),
cfg,
);
});
});

View File

@ -20,6 +20,7 @@ function readTelegramSendParams(params: Record<string, unknown>) {
const threadId = readStringParam(params, "threadId");
const buttons = params.buttons;
const asVoice = typeof params.asVoice === "boolean" ? params.asVoice : undefined;
const silent = typeof params.silent === "boolean" ? params.silent : undefined;
return {
to,
content,
@ -28,6 +29,7 @@ function readTelegramSendParams(params: Record<string, unknown>) {
messageThreadId: threadId ?? undefined,
buttons,
asVoice,
silent,
};
}

View File

@ -22,7 +22,8 @@ export function registerMessageSendCommand(message: Command, helpers: MessageCli
.option("--card <json>", "Adaptive Card JSON object (when supported by the channel)")
.option("--reply-to <id>", "Reply-to message id")
.option("--thread-id <id>", "Thread id (Telegram forum thread)")
.option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false),
.option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false)
.option("--silent", "Send message silently without notification (Telegram only)", false),
)
.action(async (opts) => {
await helpers.runMessageAction("send", opts);

View File

@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./validation.js";
// NOTE: These tests ensure allow + alsoAllow cannot be set in the same scope.
describe("config: tools.alsoAllow", () => {
it("rejects tools.allow + tools.alsoAllow together", () => {
const res = validateConfigObject({
tools: {
allow: ["group:fs"],
alsoAllow: ["lobster"],
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues.some((i) => i.path === "tools")).toBe(true);
}
});
it("rejects agents.list[].tools.allow + alsoAllow together", () => {
const res = validateConfigObject({
agents: {
list: [
{
id: "main",
tools: {
allow: ["group:fs"],
alsoAllow: ["lobster"],
},
},
],
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues.some((i) => i.path.includes("agents.list"))).toBe(true);
}
});
it("allows profile + alsoAllow", () => {
const res = validateConfigObject({
tools: {
profile: "coding",
alsoAllow: ["lobster"],
},
});
expect(res.ok).toBe(true);
});
});

View File

@ -9,6 +9,10 @@ import {
resolveDefaultAgentIdFromRaw,
} from "./legacy.shared.js";
// NOTE: tools.alsoAllow was introduced after legacy migrations; no legacy migration needed.
// tools.alsoAllow legacy migration intentionally omitted (field not shipped in prod).
export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
{
id: "auth.anthropic-claude-cli-mode-oauth",
@ -24,6 +28,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
changes.push('Updated auth.profiles["anthropic:claude-cli"].mode → "oauth".');
},
},
// tools.alsoAllow migration removed (field not shipped in prod; enforce via schema instead).
{
id: "tools.bash->tools.exec",
describe: "Move tools.bash to tools.exec",

View File

@ -165,7 +165,9 @@ const FIELD_LABELS: Record<string, string> = {
"tools.links.models": "Link Understanding Models",
"tools.links.scope": "Link Understanding Scope",
"tools.profile": "Tool Profile",
"tools.alsoAllow": "Tool Allowlist Additions",
"agents.list[].tools.profile": "Agent Tool Profile",
"agents.list[].tools.alsoAllow": "Agent Tool Allowlist Additions",
"tools.byProvider": "Tool Policy by Provider",
"agents.list[].tools.byProvider": "Agent Tool Policy by Provider",
"tools.exec.applyPatch.enabled": "Enable apply_patch",

View File

@ -140,12 +140,21 @@ export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
export type ToolPolicyConfig = {
allow?: string[];
/**
* Additional allowlist entries merged into the effective allowlist.
*
* Intended for additive configuration (e.g., "also allow lobster") without forcing
* users to replace/duplicate an existing allowlist or profile.
*/
alsoAllow?: string[];
deny?: string[];
profile?: ToolProfileId;
};
export type GroupToolPolicyConfig = {
allow?: string[];
/** Additional allowlist entries merged into allow. */
alsoAllow?: string[];
deny?: string[];
};
@ -188,6 +197,8 @@ export type AgentToolsConfig = {
/** Base tool profile applied before allow/deny lists. */
profile?: ToolProfileId;
allow?: string[];
/** Additional allowlist entries merged into allow and/or profile allowlist. */
alsoAllow?: string[];
deny?: string[];
/** Optional tool policy overrides keyed by provider id or "provider/model". */
byProvider?: Record<string, ToolPolicyConfig>;
@ -312,6 +323,8 @@ export type ToolsConfig = {
/** Base tool profile applied before allow/deny lists. */
profile?: ToolProfileId;
allow?: string[];
/** Additional allowlist entries merged into allow and/or profile allowlist. */
alsoAllow?: string[];
deny?: string[];
/** Optional tool policy overrides keyed by provider id or "provider/model". */
byProvider?: Record<string, ToolPolicyConfig>;

View File

@ -148,13 +148,23 @@ export const SandboxPruneSchema = z
.strict()
.optional();
export const ToolPolicySchema = z
const ToolPolicyBaseSchema = z
.object({
allow: z.array(z.string()).optional(),
alsoAllow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
})
.strict()
.optional();
.strict();
export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) => {
if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"tools policy cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)",
});
}
}).optional();
export const ToolsWebSearchSchema = z
.object({
@ -203,10 +213,20 @@ export const ToolProfileSchema = z
export const ToolPolicyWithProfileSchema = z
.object({
allow: z.array(z.string()).optional(),
alsoAllow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
profile: ToolProfileSchema,
})
.strict();
.strict()
.superRefine((value, ctx) => {
if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"tools.byProvider policy cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)",
});
}
});
// Provider docking: allowlists keyed by provider id (no schema updates when adding providers).
export const ElevatedAllowFromSchema = z
@ -232,6 +252,7 @@ export const AgentToolsSchema = z
.object({
profile: ToolProfileSchema,
allow: z.array(z.string()).optional(),
alsoAllow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
byProvider: z.record(z.string(), ToolPolicyWithProfileSchema).optional(),
elevated: z
@ -272,6 +293,15 @@ export const AgentToolsSchema = z
.optional(),
})
.strict()
.superRefine((value, ctx) => {
if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"agent tools cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)",
});
}
})
.optional();
export const MemorySearchSchema = z
@ -426,6 +456,7 @@ export const ToolsSchema = z
.object({
profile: ToolProfileSchema,
allow: z.array(z.string()).optional(),
alsoAllow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
byProvider: z.record(z.string(), ToolPolicyWithProfileSchema).optional(),
web: ToolsWebSchema,
@ -508,4 +539,13 @@ export const ToolsSchema = z
.optional(),
})
.strict()
.superRefine((value, ctx) => {
if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"tools cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)",
});
}
})
.optional();

View File

@ -1,12 +1,22 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { IncomingMessage, ServerResponse } from "node:http";
import { promises as fs } from "node:fs";
import path from "node:path";
import { installGatewayTestHooks, getFreePort, startGatewayServer } from "./test-helpers.server.js";
import { resetTestPluginRegistry, setTestPluginRegistry, testState } from "./test-helpers.mocks.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { CONFIG_PATH_CLAWDBOT } from "../config/config.js";
installGatewayTestHooks({ scope: "suite" });
beforeEach(() => {
// Ensure these tests are not affected by host env vars.
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
});
const resolveGatewayToken = (): string => {
const token = (testState.gatewayAuth as { token?: string } | undefined)?.token;
if (!token) throw new Error("test gateway token missing");
@ -47,6 +57,64 @@ describe("POST /tools/invoke", () => {
await server.close();
});
it("supports tools.alsoAllow as additive allowlist (profile stage)", async () => {
// No explicit tool allowlist; rely on profile + alsoAllow.
testState.agentsConfig = {
list: [{ id: "main" }],
} as any;
// minimal profile does NOT include sessions_list, but alsoAllow should.
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({
tools: { profile: "minimal", alsoAllow: ["sessions_list"] },
} as any);
const port = await getFreePort();
const server = await startGatewayServer(port, { bind: "loopback" });
const token = resolveGatewayToken();
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ok).toBe(true);
await server.close();
});
it("supports tools.alsoAllow without allow/profile (implicit allow-all)", async () => {
testState.agentsConfig = {
list: [{ id: "main" }],
} as any;
await fs.mkdir(path.dirname(CONFIG_PATH_CLAWDBOT), { recursive: true });
await fs.writeFile(
CONFIG_PATH_CLAWDBOT,
JSON.stringify({ tools: { alsoAllow: ["sessions_list"] } }, null, 2),
"utf-8",
);
const port = await getFreePort();
const server = await startGatewayServer(port, { bind: "loopback" });
const token = resolveGatewayToken();
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ok).toBe(true);
await server.close();
});
it("accepts password auth when bearer token matches", async () => {
testState.agentsConfig = {
list: [

View File

@ -130,9 +130,22 @@ export async function handleToolsInvokeHttpRequest(
agentProviderPolicy,
profile,
providerProfile,
profileAlsoAllow,
providerProfileAlsoAllow,
} = resolveEffectiveToolPolicy({ config: cfg, sessionKey });
const profilePolicy = resolveToolProfilePolicy(profile);
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
const mergeAlsoAllow = (policy: typeof profilePolicy, alsoAllow?: string[]) => {
if (!policy?.allow || !Array.isArray(alsoAllow) || alsoAllow.length === 0) return policy;
return { ...policy, allow: Array.from(new Set([...policy.allow, ...alsoAllow])) };
};
const profilePolicyWithAlsoAllow = mergeAlsoAllow(profilePolicy, profileAlsoAllow);
const providerProfilePolicyWithAlsoAllow = mergeAlsoAllow(
providerProfilePolicy,
providerProfileAlsoAllow,
);
const groupPolicy = resolveGroupToolPolicy({
config: cfg,
sessionKey,
@ -176,18 +189,18 @@ export async function handleToolsInvokeHttpRequest(
if (resolved.unknownAllowlist.length > 0) {
const entries = resolved.unknownAllowlist.join(", ");
const suffix = resolved.strippedAllowlist
? "Ignoring allowlist so core tools remain available."
? "Ignoring allowlist so core tools remain available. Use tools.alsoAllow for additive plugin tool enablement."
: "These entries won't match any tool unless the plugin is enabled.";
logWarn(`tools: ${label} allowlist contains unknown entries (${entries}). ${suffix}`);
}
return expandPolicyWithPluginGroups(resolved.policy, pluginGroups);
};
const profilePolicyExpanded = resolvePolicy(
profilePolicy,
profilePolicyWithAlsoAllow,
profile ? `tools.profile (${profile})` : "tools.profile",
);
const providerProfileExpanded = resolvePolicy(
providerProfilePolicy,
providerProfilePolicyWithAlsoAllow,
providerProfile ? `tools.byProvider.profile (${providerProfile})` : "tools.byProvider.profile",
);
const globalPolicyExpanded = resolvePolicy(globalPolicy, "tools.allow");

View File

@ -476,6 +476,28 @@ describe("sendMessageTelegram", () => {
});
});
it("sets disable_notification when silent is true", async () => {
const chatId = "123";
const sendMessage = vi.fn().mockResolvedValue({
message_id: 1,
chat: { id: chatId },
});
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
await sendMessageTelegram(chatId, "hi", {
token: "tok",
api,
silent: true,
});
expect(sendMessage).toHaveBeenCalledWith(chatId, "hi", {
parse_mode: "HTML",
disable_notification: true,
});
});
it("parses message_thread_id from recipient string (telegram:group:...:topic:...)", async () => {
const chatId = "-1001234567890";
const sendMessage = vi.fn().mockResolvedValue({

View File

@ -40,6 +40,8 @@ type TelegramSendOpts = {
plainText?: string;
/** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */
asVoice?: boolean;
/** Send message silently (no notification). Defaults to false. */
silent?: boolean;
/** Message ID to reply to (for threading) */
replyToMessageId?: number;
/** Forum topic thread ID (for forum supergroups) */
@ -245,6 +247,7 @@ export async function sendMessageTelegram(
const sendParams = {
parse_mode: "HTML" as const,
...baseParams,
...(opts.silent === true ? { disable_notification: true } : {}),
};
const res = await requestWithDiag(
() => api.sendMessage(chatId, htmlText, sendParams),
@ -298,6 +301,7 @@ export async function sendMessageTelegram(
caption: htmlCaption,
...(htmlCaption ? { parse_mode: "HTML" as const } : {}),
...baseMediaParams,
...(opts.silent === true ? { disable_notification: true } : {}),
};
let result:
| Awaited<ReturnType<typeof api.sendPhoto>>