diff --git a/.github/labeler.yml b/.github/labeler.yml index f22868736..5c19fa418 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -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: diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index e4a9ac6f2..b610e1718 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -3,7 +3,7 @@ name: Auto response on: issues: types: [labeled] - pull_request: + pull_request_target: types: [labeled] permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ce49a181..422ee8aa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/automation/cron-vs-heartbeat.md b/docs/automation/cron-vs-heartbeat.md index 333a45d0b..325575602 100644 --- a/docs/automation/cron-vs-heartbeat.md +++ b/docs/automation/cron-vs-heartbeat.md @@ -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. diff --git a/docs/help/faq.md b/docs/help/faq.md index f4e177f8d..336b324c9 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -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//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). diff --git a/docs/install/index.md b/docs/install/index.md index dde0e5eeb..7ccab0ca8 100644 --- a/docs/install/index.md +++ b/docs/install/index.md @@ -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) diff --git a/docs/install/migrating.md b/docs/install/migrating.md new file mode 100644 index 000000000..4987b38b9 --- /dev/null +++ b/docs/install/migrating.md @@ -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 ` (often becomes `~/.clawdbot-/`) +- `CLAWDBOT_STATE_DIR=/some/path` + +If you’re 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 aren’t 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, it’s 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, you’ll 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//...` + +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 won’t move the remote gateway’s state. + +If you’re 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 doesn’t 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) diff --git a/docs/tools/lobster.md b/docs/tools/lobster.md index daf04fd39..f4718c4b5 100644 --- a/docs/tools/lobster.md +++ b/docs/tools/lobster.md @@ -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 diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index 98585ca9d..d6e125e33 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -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, }; } diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 9013f1e52..4a0bebed0 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -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"); diff --git a/src/agents/tool-policy.ts b/src/agents/tool-policy.ts index ac2b1a91c..85152069e 100644 --- a/src/agents/tool-policy.ts +++ b/src/agents/tool-policy.ts @@ -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)), diff --git a/src/config/config.tools-alsoAllow.test.ts b/src/config/config.tools-alsoAllow.test.ts new file mode 100644 index 000000000..aea4f02d9 --- /dev/null +++ b/src/config/config.tools-alsoAllow.test.ts @@ -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); + }); +}); diff --git a/src/config/legacy.migrations.part-3.ts b/src/config/legacy.migrations.part-3.ts index 9db9e3ede..21589e4fa 100644 --- a/src/config/legacy.migrations.part-3.ts +++ b/src/config/legacy.migrations.part-3.ts @@ -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", diff --git a/src/config/schema.ts b/src/config/schema.ts index 24d6bccfe..9627d64f3 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -165,7 +165,9 @@ const FIELD_LABELS: Record = { "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", diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index ad7f69d85..d84dd1aa7 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -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; @@ -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; diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index c733dcfa9..99074c55e 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -147,13 +147,22 @@ 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({ @@ -202,10 +211,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 @@ -231,6 +250,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 @@ -271,6 +291,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 @@ -425,6 +454,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, @@ -507,4 +537,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(); diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index 18c23692d..f08035885 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -1,12 +1,19 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { IncomingMessage, ServerResponse } from "node:http"; + 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"; 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 +54,63 @@ 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 res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { + method: "POST", + headers: { "content-type": "application/json" }, + 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: [ diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index 80e2f295e..b747e2561 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -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");