Merge branch 'clawdbot:main' into feature/memoryflush-session-reset
This commit is contained in:
commit
50f924da4b
@ -6,8 +6,10 @@ Docs: https://docs.clawd.bot
|
|||||||
Status: unreleased.
|
Status: unreleased.
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
|
||||||
- Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido.
|
- Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido.
|
||||||
- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos.
|
- 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.
|
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
|
||||||
- Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro.
|
- Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro.
|
||||||
- Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng.
|
- Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng.
|
||||||
@ -50,6 +52,7 @@ Status: unreleased.
|
|||||||
- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93.
|
- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93.
|
||||||
- Security: wrap external hook content by default with a per-hook opt-out. (#1827) Thanks @mertcicekci0.
|
- Security: wrap external hook content by default with a per-hook opt-out. (#1827) Thanks @mertcicekci0.
|
||||||
- Gateway: default auth now fail-closed (token/password required; Tailscale Serve identity remains allowed).
|
- Gateway: default auth now fail-closed (token/password required; Tailscale Serve identity remains allowed).
|
||||||
|
- Onboarding: remove unsupported gateway auth "off" choice from onboarding/configure flows and CLI flags.
|
||||||
|
|
||||||
## 2026.1.24-3
|
## 2026.1.24-3
|
||||||
|
|
||||||
|
|||||||
@ -314,7 +314,7 @@ Options:
|
|||||||
- `--opencode-zen-api-key <key>`
|
- `--opencode-zen-api-key <key>`
|
||||||
- `--gateway-port <port>`
|
- `--gateway-port <port>`
|
||||||
- `--gateway-bind <loopback|lan|tailnet|auto|custom>`
|
- `--gateway-bind <loopback|lan|tailnet|auto|custom>`
|
||||||
- `--gateway-auth <off|token|password>`
|
- `--gateway-auth <token|password>`
|
||||||
- `--gateway-token <token>`
|
- `--gateway-token <token>`
|
||||||
- `--gateway-password <password>`
|
- `--gateway-password <password>`
|
||||||
- `--remote-url <url>`
|
- `--remote-url <url>`
|
||||||
|
|||||||
@ -2847,9 +2847,11 @@ Control UI base path:
|
|||||||
- `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served.
|
- `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served.
|
||||||
- Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`.
|
- Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`.
|
||||||
- Default: root (`/`) (unchanged).
|
- Default: root (`/`) (unchanged).
|
||||||
- `gateway.controlUi.allowInsecureAuth` allows token-only auth for the Control UI and skips
|
- `gateway.controlUi.allowInsecureAuth` allows token-only auth for the Control UI when
|
||||||
device identity + pairing (even on HTTPS). Default: `false`. Prefer HTTPS
|
device identity is omitted (typically over HTTP). Default: `false`. Prefer HTTPS
|
||||||
(Tailscale Serve) or `127.0.0.1`.
|
(Tailscale Serve) or `127.0.0.1`.
|
||||||
|
- `gateway.controlUi.dangerouslyDisableDeviceAuth` disables device identity checks for the
|
||||||
|
Control UI (token/password only). Default: `false`. Break-glass only.
|
||||||
|
|
||||||
Related docs:
|
Related docs:
|
||||||
- [Control UI](/web/control-ui)
|
- [Control UI](/web/control-ui)
|
||||||
|
|||||||
@ -198,7 +198,8 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
|
|||||||
- **Local** connects include loopback and the gateway host’s own tailnet address
|
- **Local** connects include loopback and the gateway host’s own tailnet address
|
||||||
(so same‑host tailnet binds can still auto‑approve).
|
(so same‑host tailnet binds can still auto‑approve).
|
||||||
- All WS clients must include `device` identity during `connect` (operator + node).
|
- All WS clients must include `device` identity during `connect` (operator + node).
|
||||||
Control UI can omit it **only** when `gateway.controlUi.allowInsecureAuth` is enabled.
|
Control UI can omit it **only** when `gateway.controlUi.allowInsecureAuth` is enabled
|
||||||
|
(or `gateway.controlUi.dangerouslyDisableDeviceAuth` for break-glass use).
|
||||||
- Non-local connections must sign the server-provided `connect.challenge` nonce.
|
- Non-local connections must sign the server-provided `connect.challenge` nonce.
|
||||||
|
|
||||||
## TLS + pinning
|
## TLS + pinning
|
||||||
|
|||||||
@ -58,9 +58,13 @@ When the audit prints findings, treat this as a priority order:
|
|||||||
|
|
||||||
The Control UI needs a **secure context** (HTTPS or localhost) to generate device
|
The Control UI needs a **secure context** (HTTPS or localhost) to generate device
|
||||||
identity. If you enable `gateway.controlUi.allowInsecureAuth`, the UI falls back
|
identity. If you enable `gateway.controlUi.allowInsecureAuth`, the UI falls back
|
||||||
to **token-only auth** and skips device pairing (even on HTTPS). This is a security
|
to **token-only auth** and skips device pairing when device identity is omitted. This is a security
|
||||||
downgrade—prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
|
downgrade—prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
|
||||||
|
|
||||||
|
For break-glass scenarios only, `gateway.controlUi.dangerouslyDisableDeviceAuth`
|
||||||
|
disables device identity checks entirely. This is a severe security downgrade;
|
||||||
|
keep it off unless you are actively debugging and can revert quickly.
|
||||||
|
|
||||||
`clawdbot security audit` warns when this setting is enabled.
|
`clawdbot security audit` warns when this setting is enabled.
|
||||||
|
|
||||||
## Reverse Proxy Configuration
|
## Reverse Proxy Configuration
|
||||||
|
|||||||
@ -214,7 +214,7 @@ the Gateway likely refused to bind.
|
|||||||
- Fix: run `clawdbot doctor` to update it (or `clawdbot gateway install --force` for a full rewrite).
|
- Fix: run `clawdbot doctor` to update it (or `clawdbot gateway install --force` for a full rewrite).
|
||||||
|
|
||||||
**If `Last gateway error:` mentions “refusing to bind … without auth”**
|
**If `Last gateway error:` mentions “refusing to bind … without auth”**
|
||||||
- You set `gateway.bind` to a non-loopback mode (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) but left auth off.
|
- You set `gateway.bind` to a non-loopback mode (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) but didn’t configure auth.
|
||||||
- Fix: set `gateway.auth.mode` + `gateway.auth.token` (or export `CLAWDBOT_GATEWAY_TOKEN`) and restart the service.
|
- Fix: set `gateway.auth.mode` + `gateway.auth.token` (or export `CLAWDBOT_GATEWAY_TOKEN`) and restart the service.
|
||||||
|
|
||||||
**If `clawdbot gateway status` says `bind=tailnet` but no tailnet interface was found**
|
**If `clawdbot gateway status` says `bind=tailnet` but no tailnet interface was found**
|
||||||
|
|||||||
78
src/agents/pi-tools.safe-bins.test.ts
Normal file
78
src/agents/pi-tools.safe-bins.test.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import type { ExecApprovalsResolved } from "../infra/exec-approvals.js";
|
||||||
|
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||||
|
|
||||||
|
vi.mock("../infra/exec-approvals.js", async (importOriginal) => {
|
||||||
|
const mod = await importOriginal<typeof import("../infra/exec-approvals.js")>();
|
||||||
|
const approvals: ExecApprovalsResolved = {
|
||||||
|
path: "/tmp/exec-approvals.json",
|
||||||
|
socketPath: "/tmp/exec-approvals.sock",
|
||||||
|
token: "token",
|
||||||
|
defaults: {
|
||||||
|
security: "allowlist",
|
||||||
|
ask: "off",
|
||||||
|
askFallback: "deny",
|
||||||
|
autoAllowSkills: false,
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
security: "allowlist",
|
||||||
|
ask: "off",
|
||||||
|
askFallback: "deny",
|
||||||
|
autoAllowSkills: false,
|
||||||
|
},
|
||||||
|
allowlist: [],
|
||||||
|
file: {
|
||||||
|
version: 1,
|
||||||
|
socket: { path: "/tmp/exec-approvals.sock", token: "token" },
|
||||||
|
defaults: {
|
||||||
|
security: "allowlist",
|
||||||
|
ask: "off",
|
||||||
|
askFallback: "deny",
|
||||||
|
autoAllowSkills: false,
|
||||||
|
},
|
||||||
|
agents: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { ...mod, resolveExecApprovals: () => approvals };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createClawdbotCodingTools safeBins", () => {
|
||||||
|
it("threads tools.exec.safeBins into exec allowlist checks", async () => {
|
||||||
|
if (process.platform === "win32") return;
|
||||||
|
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-safe-bins-"));
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
tools: {
|
||||||
|
exec: {
|
||||||
|
host: "gateway",
|
||||||
|
security: "allowlist",
|
||||||
|
ask: "off",
|
||||||
|
safeBins: ["echo"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tools = createClawdbotCodingTools({
|
||||||
|
config: cfg,
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
workspaceDir: tmpDir,
|
||||||
|
agentDir: path.join(tmpDir, "agent"),
|
||||||
|
});
|
||||||
|
const execTool = tools.find((tool) => tool.name === "exec");
|
||||||
|
expect(execTool).toBeDefined();
|
||||||
|
|
||||||
|
const marker = `safe-bins-${Date.now()}`;
|
||||||
|
const result = await execTool!.execute("call1", {
|
||||||
|
command: `echo ${marker}`,
|
||||||
|
workdir: tmpDir,
|
||||||
|
});
|
||||||
|
const text = result.content.find((content) => content.type === "text")?.text ?? "";
|
||||||
|
|
||||||
|
expect(result.details.status).toBe("completed");
|
||||||
|
expect(text).toContain(marker);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -86,6 +86,7 @@ function resolveExecConfig(cfg: ClawdbotConfig | undefined) {
|
|||||||
ask: globalExec?.ask,
|
ask: globalExec?.ask,
|
||||||
node: globalExec?.node,
|
node: globalExec?.node,
|
||||||
pathPrepend: globalExec?.pathPrepend,
|
pathPrepend: globalExec?.pathPrepend,
|
||||||
|
safeBins: globalExec?.safeBins,
|
||||||
backgroundMs: globalExec?.backgroundMs,
|
backgroundMs: globalExec?.backgroundMs,
|
||||||
timeoutSec: globalExec?.timeoutSec,
|
timeoutSec: globalExec?.timeoutSec,
|
||||||
approvalRunningNoticeMs: globalExec?.approvalRunningNoticeMs,
|
approvalRunningNoticeMs: globalExec?.approvalRunningNoticeMs,
|
||||||
@ -235,6 +236,7 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
ask: options?.exec?.ask ?? execConfig.ask,
|
ask: options?.exec?.ask ?? execConfig.ask,
|
||||||
node: options?.exec?.node ?? execConfig.node,
|
node: options?.exec?.node ?? execConfig.node,
|
||||||
pathPrepend: options?.exec?.pathPrepend ?? execConfig.pathPrepend,
|
pathPrepend: options?.exec?.pathPrepend ?? execConfig.pathPrepend,
|
||||||
|
safeBins: options?.exec?.safeBins ?? execConfig.safeBins,
|
||||||
agentId,
|
agentId,
|
||||||
cwd: options?.workspaceDir,
|
cwd: options?.workspaceDir,
|
||||||
allowBackground,
|
allowBackground,
|
||||||
|
|||||||
@ -78,7 +78,7 @@ export function registerOnboardCommand(program: Command) {
|
|||||||
.option("--opencode-zen-api-key <key>", "OpenCode Zen API key")
|
.option("--opencode-zen-api-key <key>", "OpenCode Zen API key")
|
||||||
.option("--gateway-port <port>", "Gateway port")
|
.option("--gateway-port <port>", "Gateway port")
|
||||||
.option("--gateway-bind <mode>", "Gateway bind: loopback|tailnet|lan|auto|custom")
|
.option("--gateway-bind <mode>", "Gateway bind: loopback|tailnet|lan|auto|custom")
|
||||||
.option("--gateway-auth <mode>", "Gateway auth: off|token|password")
|
.option("--gateway-auth <mode>", "Gateway auth: token|password")
|
||||||
.option("--gateway-token <token>", "Gateway token (token auth)")
|
.option("--gateway-token <token>", "Gateway token (token auth)")
|
||||||
.option("--gateway-password <password>", "Gateway password (password auth)")
|
.option("--gateway-password <password>", "Gateway password (password auth)")
|
||||||
.option("--remote-url <url>", "Remote Gateway WebSocket URL")
|
.option("--remote-url <url>", "Remote Gateway WebSocket URL")
|
||||||
|
|||||||
@ -3,26 +3,18 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { buildGatewayAuthConfig } from "./configure.js";
|
import { buildGatewayAuthConfig } from "./configure.js";
|
||||||
|
|
||||||
describe("buildGatewayAuthConfig", () => {
|
describe("buildGatewayAuthConfig", () => {
|
||||||
it("clears token/password when auth is off", () => {
|
it("preserves allowTailscale when switching to token", () => {
|
||||||
const result = buildGatewayAuthConfig({
|
|
||||||
existing: { mode: "token", token: "abc", password: "secret" },
|
|
||||||
mode: "off",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves allowTailscale when auth is off", () => {
|
|
||||||
const result = buildGatewayAuthConfig({
|
const result = buildGatewayAuthConfig({
|
||||||
existing: {
|
existing: {
|
||||||
mode: "token",
|
mode: "password",
|
||||||
token: "abc",
|
password: "secret",
|
||||||
allowTailscale: true,
|
allowTailscale: true,
|
||||||
},
|
},
|
||||||
mode: "off",
|
mode: "token",
|
||||||
|
token: "abc",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({ allowTailscale: true });
|
expect(result).toEqual({ mode: "token", token: "abc", allowTailscale: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops password when switching to token", () => {
|
it("drops password when switching to token", () => {
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import {
|
|||||||
promptModelAllowlist,
|
promptModelAllowlist,
|
||||||
} from "./model-picker.js";
|
} from "./model-picker.js";
|
||||||
|
|
||||||
type GatewayAuthChoice = "off" | "token" | "password";
|
type GatewayAuthChoice = "token" | "password";
|
||||||
|
|
||||||
const ANTHROPIC_OAUTH_MODEL_KEYS = [
|
const ANTHROPIC_OAUTH_MODEL_KEYS = [
|
||||||
"anthropic/claude-opus-4-5",
|
"anthropic/claude-opus-4-5",
|
||||||
@ -30,9 +30,6 @@ export function buildGatewayAuthConfig(params: {
|
|||||||
const base: GatewayAuthConfig = {};
|
const base: GatewayAuthConfig = {};
|
||||||
if (typeof allowTailscale === "boolean") base.allowTailscale = allowTailscale;
|
if (typeof allowTailscale === "boolean") base.allowTailscale = allowTailscale;
|
||||||
|
|
||||||
if (params.mode === "off") {
|
|
||||||
return Object.keys(base).length > 0 ? base : undefined;
|
|
||||||
}
|
|
||||||
if (params.mode === "token") {
|
if (params.mode === "token") {
|
||||||
return { ...base, mode: "token", token: params.token };
|
return { ...base, mode: "token", token: params.token };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { buildGatewayAuthConfig } from "./configure.gateway-auth.js";
|
|||||||
import { confirm, select, text } from "./configure.shared.js";
|
import { confirm, select, text } from "./configure.shared.js";
|
||||||
import { guardCancel, randomToken } from "./onboard-helpers.js";
|
import { guardCancel, randomToken } from "./onboard-helpers.js";
|
||||||
|
|
||||||
type GatewayAuthChoice = "off" | "token" | "password";
|
type GatewayAuthChoice = "token" | "password";
|
||||||
|
|
||||||
export async function promptGatewayConfig(
|
export async function promptGatewayConfig(
|
||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
@ -91,11 +91,6 @@ export async function promptGatewayConfig(
|
|||||||
await select({
|
await select({
|
||||||
message: "Gateway auth",
|
message: "Gateway auth",
|
||||||
options: [
|
options: [
|
||||||
{
|
|
||||||
value: "off",
|
|
||||||
label: "Off (loopback only)",
|
|
||||||
hint: "Not recommended unless you fully trust local processes",
|
|
||||||
},
|
|
||||||
{ value: "token", label: "Token", hint: "Recommended default" },
|
{ value: "token", label: "Token", hint: "Recommended default" },
|
||||||
{ value: "password", label: "Password" },
|
{ value: "password", label: "Password" },
|
||||||
],
|
],
|
||||||
@ -165,11 +160,6 @@ export async function promptGatewayConfig(
|
|||||||
bind = "loopback";
|
bind = "loopback";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authMode === "off" && bind !== "loopback") {
|
|
||||||
note("Non-loopback bind requires auth. Switching to token auth.", "Note");
|
|
||||||
authMode = "token";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tailscaleMode === "funnel" && authMode !== "password") {
|
if (tailscaleMode === "funnel" && authMode !== "password") {
|
||||||
note("Tailscale funnel requires password auth.", "Note");
|
note("Tailscale funnel requires password auth.", "Note");
|
||||||
authMode = "password";
|
authMode = "password";
|
||||||
|
|||||||
@ -210,7 +210,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
|||||||
await fs.rm(stateDir, { recursive: true, force: true });
|
await fs.rm(stateDir, { recursive: true, force: true });
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
it("auto-enables token auth when binding LAN and persists the token", async () => {
|
it("auto-generates token auth when binding LAN and persists the token", async () => {
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
// Windows runner occasionally drops the temp config write in this flow; skip to keep CI green.
|
// Windows runner occasionally drops the temp config write in this flow; skip to keep CI green.
|
||||||
return;
|
return;
|
||||||
@ -242,7 +242,6 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
|||||||
installDaemon: false,
|
installDaemon: false,
|
||||||
gatewayPort: port,
|
gatewayPort: port,
|
||||||
gatewayBind: "lan",
|
gatewayBind: "lan",
|
||||||
gatewayAuth: "off",
|
|
||||||
},
|
},
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -28,16 +28,20 @@ export function applyNonInteractiveGatewayConfig(params: {
|
|||||||
|
|
||||||
const port = hasGatewayPort ? (opts.gatewayPort as number) : params.defaultPort;
|
const port = hasGatewayPort ? (opts.gatewayPort as number) : params.defaultPort;
|
||||||
let bind = opts.gatewayBind ?? "loopback";
|
let bind = opts.gatewayBind ?? "loopback";
|
||||||
let authMode = opts.gatewayAuth ?? "token";
|
const authModeRaw = opts.gatewayAuth ?? "token";
|
||||||
|
if (authModeRaw !== "token" && authModeRaw !== "password") {
|
||||||
|
runtime.error("Invalid --gateway-auth (use token|password).");
|
||||||
|
runtime.exit(1);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let authMode = authModeRaw;
|
||||||
const tailscaleMode = opts.tailscale ?? "off";
|
const tailscaleMode = opts.tailscale ?? "off";
|
||||||
const tailscaleResetOnExit = Boolean(opts.tailscaleResetOnExit);
|
const tailscaleResetOnExit = Boolean(opts.tailscaleResetOnExit);
|
||||||
|
|
||||||
// Tighten config to safe combos:
|
// Tighten config to safe combos:
|
||||||
// - If Tailscale is on, force loopback bind (the tunnel handles external access).
|
// - If Tailscale is on, force loopback bind (the tunnel handles external access).
|
||||||
// - If binding beyond loopback, disallow auth=off.
|
|
||||||
// - If using Tailscale Funnel, require password auth.
|
// - If using Tailscale Funnel, require password auth.
|
||||||
if (tailscaleMode !== "off" && bind !== "loopback") bind = "loopback";
|
if (tailscaleMode !== "off" && bind !== "loopback") bind = "loopback";
|
||||||
if (authMode === "off" && bind !== "loopback") authMode = "token";
|
|
||||||
if (tailscaleMode === "funnel" && authMode !== "password") authMode = "password";
|
if (tailscaleMode === "funnel" && authMode !== "password") authMode = "password";
|
||||||
|
|
||||||
let nextConfig = params.nextConfig;
|
let nextConfig = params.nextConfig;
|
||||||
|
|||||||
@ -32,7 +32,7 @@ export type AuthChoice =
|
|||||||
| "copilot-proxy"
|
| "copilot-proxy"
|
||||||
| "qwen-portal"
|
| "qwen-portal"
|
||||||
| "skip";
|
| "skip";
|
||||||
export type GatewayAuthChoice = "off" | "token" | "password";
|
export type GatewayAuthChoice = "token" | "password";
|
||||||
export type ResetScope = "config" | "config+creds+sessions" | "full";
|
export type ResetScope = "config" | "config+creds+sessions" | "full";
|
||||||
export type GatewayBind = "loopback" | "lan" | "auto" | "custom" | "tailnet";
|
export type GatewayBind = "loopback" | "lan" | "auto" | "custom" | "tailnet";
|
||||||
export type TailscaleMode = "off" | "serve" | "funnel";
|
export type TailscaleMode = "off" | "serve" | "funnel";
|
||||||
|
|||||||
@ -199,6 +199,7 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
|
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
|
||||||
"gateway.controlUi.basePath": "Control UI Base Path",
|
"gateway.controlUi.basePath": "Control UI Base Path",
|
||||||
"gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth",
|
"gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth",
|
||||||
|
"gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth",
|
||||||
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
|
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
|
||||||
"gateway.reload.mode": "Config Reload Mode",
|
"gateway.reload.mode": "Config Reload Mode",
|
||||||
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
|
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
|
||||||
@ -381,6 +382,8 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"Optional URL prefix where the Control UI is served (e.g. /clawdbot).",
|
"Optional URL prefix where the Control UI is served (e.g. /clawdbot).",
|
||||||
"gateway.controlUi.allowInsecureAuth":
|
"gateway.controlUi.allowInsecureAuth":
|
||||||
"Allow Control UI auth over insecure HTTP (token-only; not recommended).",
|
"Allow Control UI auth over insecure HTTP (token-only; not recommended).",
|
||||||
|
"gateway.controlUi.dangerouslyDisableDeviceAuth":
|
||||||
|
"DANGEROUS. Disable Control UI device identity checks (token/password only).",
|
||||||
"gateway.http.endpoints.chatCompletions.enabled":
|
"gateway.http.endpoints.chatCompletions.enabled":
|
||||||
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
|
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
|
||||||
"gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).',
|
"gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).',
|
||||||
|
|||||||
@ -66,6 +66,8 @@ export type GatewayControlUiConfig = {
|
|||||||
basePath?: string;
|
basePath?: string;
|
||||||
/** Allow token-only auth over insecure HTTP (default: false). */
|
/** Allow token-only auth over insecure HTTP (default: false). */
|
||||||
allowInsecureAuth?: boolean;
|
allowInsecureAuth?: boolean;
|
||||||
|
/** DANGEROUS: Disable device identity checks for the Control UI (default: false). */
|
||||||
|
dangerouslyDisableDeviceAuth?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GatewayAuthMode = "token" | "password";
|
export type GatewayAuthMode = "token" | "password";
|
||||||
|
|||||||
@ -319,6 +319,7 @@ export const ClawdbotSchema = z
|
|||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
basePath: z.string().optional(),
|
basePath: z.string().optional(),
|
||||||
allowInsecureAuth: z.boolean().optional(),
|
allowInsecureAuth: z.boolean().optional(),
|
||||||
|
dangerouslyDisableDeviceAuth: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@ -352,6 +352,53 @@ describe("gateway server auth/connect", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("allows control ui with stale device identity when device auth is disabled", async () => {
|
||||||
|
testState.gatewayControlUi = { dangerouslyDisableDeviceAuth: true };
|
||||||
|
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
|
process.env.CLAWDBOT_GATEWAY_TOKEN = "secret";
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startGatewayServer(port);
|
||||||
|
const ws = await openWs(port);
|
||||||
|
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
|
||||||
|
await import("../infra/device-identity.js");
|
||||||
|
const identity = loadOrCreateDeviceIdentity();
|
||||||
|
const signedAtMs = Date.now() - 60 * 60 * 1000;
|
||||||
|
const payload = buildDeviceAuthPayload({
|
||||||
|
deviceId: identity.deviceId,
|
||||||
|
clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||||
|
clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||||
|
role: "operator",
|
||||||
|
scopes: [],
|
||||||
|
signedAtMs,
|
||||||
|
token: "secret",
|
||||||
|
});
|
||||||
|
const device = {
|
||||||
|
id: identity.deviceId,
|
||||||
|
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
||||||
|
signature: signDevicePayload(identity.privateKeyPem, payload),
|
||||||
|
signedAt: signedAtMs,
|
||||||
|
};
|
||||||
|
const res = await connectReq(ws, {
|
||||||
|
token: "secret",
|
||||||
|
device,
|
||||||
|
client: {
|
||||||
|
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||||
|
version: "1.0.0",
|
||||||
|
platform: "web",
|
||||||
|
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect((res.payload as { auth?: unknown } | undefined)?.auth).toBeUndefined();
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
if (prevToken === undefined) {
|
||||||
|
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
|
} else {
|
||||||
|
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects proxied connections without auth when proxy headers are untrusted", async () => {
|
test("rejects proxied connections without auth when proxy headers are untrusted", async () => {
|
||||||
testState.gatewayAuth = { mode: "none" };
|
testState.gatewayAuth = { mode: "none" };
|
||||||
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
|
|||||||
@ -335,7 +335,7 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
connectParams.role = role;
|
connectParams.role = role;
|
||||||
connectParams.scopes = scopes;
|
connectParams.scopes = scopes;
|
||||||
|
|
||||||
const device = connectParams.device;
|
const deviceRaw = connectParams.device;
|
||||||
let devicePublicKey: string | null = null;
|
let devicePublicKey: string | null = null;
|
||||||
const hasTokenAuth = Boolean(connectParams.auth?.token);
|
const hasTokenAuth = Boolean(connectParams.auth?.token);
|
||||||
const hasPasswordAuth = Boolean(connectParams.auth?.password);
|
const hasPasswordAuth = Boolean(connectParams.auth?.password);
|
||||||
@ -343,6 +343,10 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
|
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
|
||||||
const allowInsecureControlUi =
|
const allowInsecureControlUi =
|
||||||
isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true;
|
isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true;
|
||||||
|
const disableControlUiDeviceAuth =
|
||||||
|
isControlUi && configSnapshot.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true;
|
||||||
|
const allowControlUiBypass = allowInsecureControlUi || disableControlUiDeviceAuth;
|
||||||
|
const device = disableControlUiDeviceAuth ? null : deviceRaw;
|
||||||
if (hasUntrustedProxyHeaders && resolvedAuth.mode === "none") {
|
if (hasUntrustedProxyHeaders && resolvedAuth.mode === "none") {
|
||||||
setHandshakeState("failed");
|
setHandshakeState("failed");
|
||||||
setCloseCause("proxy-auth-required", {
|
setCloseCause("proxy-auth-required", {
|
||||||
@ -370,9 +374,9 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
const canSkipDevice = allowInsecureControlUi ? hasSharedAuth : hasTokenAuth;
|
const canSkipDevice = allowControlUiBypass ? hasSharedAuth : hasTokenAuth;
|
||||||
|
|
||||||
if (isControlUi && !allowInsecureControlUi) {
|
if (isControlUi && !allowControlUiBypass) {
|
||||||
const errorMessage = "control ui requires HTTPS or localhost (secure context)";
|
const errorMessage = "control ui requires HTTPS or localhost (secure context)";
|
||||||
setHandshakeState("failed");
|
setHandshakeState("failed");
|
||||||
setCloseCause("control-ui-insecure-auth", {
|
setCloseCause("control-ui-insecure-auth", {
|
||||||
@ -615,7 +619,7 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const skipPairing = allowInsecureControlUi && hasSharedAuth;
|
const skipPairing = allowControlUiBypass && hasSharedAuth;
|
||||||
if (device && devicePublicKey && !skipPairing) {
|
if (device && devicePublicKey && !skipPairing) {
|
||||||
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
|
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
|
||||||
const pairing = await requestDevicePairing({
|
const pairing = await requestDevicePairing({
|
||||||
@ -736,9 +740,7 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
const shouldTrackPresence = !isGatewayCliClient(connectParams.client);
|
const shouldTrackPresence = !isGatewayCliClient(connectParams.client);
|
||||||
const clientId = connectParams.client.id;
|
const clientId = connectParams.client.id;
|
||||||
const instanceId = connectParams.client.instanceId;
|
const instanceId = connectParams.client.instanceId;
|
||||||
const presenceKey = shouldTrackPresence
|
const presenceKey = shouldTrackPresence ? (device?.id ?? instanceId ?? connId) : undefined;
|
||||||
? (connectParams.device?.id ?? instanceId ?? connId)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
logWs("in", "connect", {
|
logWs("in", "connect", {
|
||||||
connId,
|
connId,
|
||||||
@ -766,10 +768,10 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
deviceFamily: connectParams.client.deviceFamily,
|
deviceFamily: connectParams.client.deviceFamily,
|
||||||
modelIdentifier: connectParams.client.modelIdentifier,
|
modelIdentifier: connectParams.client.modelIdentifier,
|
||||||
mode: connectParams.client.mode,
|
mode: connectParams.client.mode,
|
||||||
deviceId: connectParams.device?.id,
|
deviceId: device?.id,
|
||||||
roles: [role],
|
roles: [role],
|
||||||
scopes,
|
scopes,
|
||||||
instanceId: connectParams.device?.id ?? instanceId,
|
instanceId: device?.id ?? instanceId,
|
||||||
reason: "connect",
|
reason: "connect",
|
||||||
});
|
});
|
||||||
incrementPresenceVersion();
|
incrementPresenceVersion();
|
||||||
|
|||||||
@ -293,7 +293,30 @@ describe("security audit", () => {
|
|||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
checkId: "gateway.control_ui.insecure_auth",
|
checkId: "gateway.control_ui.insecure_auth",
|
||||||
severity: "warn",
|
severity: "critical",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns when control UI device auth is disabled", async () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
gateway: {
|
||||||
|
controlUi: { dangerouslyDisableDeviceAuth: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await runSecurityAudit({
|
||||||
|
config: cfg,
|
||||||
|
includeFilesystem: false,
|
||||||
|
includeChannelSecurity: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.findings).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
checkId: "gateway.control_ui.device_auth_disabled",
|
||||||
|
severity: "critical",
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -274,7 +274,7 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding
|
|||||||
if (cfg.gateway?.controlUi?.allowInsecureAuth === true) {
|
if (cfg.gateway?.controlUi?.allowInsecureAuth === true) {
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "gateway.control_ui.insecure_auth",
|
checkId: "gateway.control_ui.insecure_auth",
|
||||||
severity: "warn",
|
severity: "critical",
|
||||||
title: "Control UI allows insecure HTTP auth",
|
title: "Control UI allows insecure HTTP auth",
|
||||||
detail:
|
detail:
|
||||||
"gateway.controlUi.allowInsecureAuth=true allows token-only auth over HTTP and skips device identity.",
|
"gateway.controlUi.allowInsecureAuth=true allows token-only auth over HTTP and skips device identity.",
|
||||||
@ -282,6 +282,17 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cfg.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true) {
|
||||||
|
findings.push({
|
||||||
|
checkId: "gateway.control_ui.device_auth_disabled",
|
||||||
|
severity: "critical",
|
||||||
|
title: "DANGEROUS: Control UI device auth disabled",
|
||||||
|
detail:
|
||||||
|
"gateway.controlUi.dangerouslyDisableDeviceAuth=true disables device identity checks for the Control UI.",
|
||||||
|
remediation: "Disable it unless you are in a short-lived break-glass scenario.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const token =
|
const token =
|
||||||
typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null;
|
typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null;
|
||||||
if (auth.mode === "token" && token && token.length < 24) {
|
if (auth.mode === "token" && token && token.length < 24) {
|
||||||
|
|||||||
@ -93,11 +93,6 @@ export async function configureGatewayForOnboarding(
|
|||||||
: ((await prompter.select({
|
: ((await prompter.select({
|
||||||
message: "Gateway auth",
|
message: "Gateway auth",
|
||||||
options: [
|
options: [
|
||||||
{
|
|
||||||
value: "off",
|
|
||||||
label: "Off (loopback only)",
|
|
||||||
hint: "Not recommended unless you fully trust local processes",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
value: "token",
|
value: "token",
|
||||||
label: "Token",
|
label: "Token",
|
||||||
@ -165,7 +160,6 @@ export async function configureGatewayForOnboarding(
|
|||||||
|
|
||||||
// Safety + constraints:
|
// Safety + constraints:
|
||||||
// - Tailscale wants bind=loopback so we never expose a non-loopback server + tailscale serve/funnel at once.
|
// - Tailscale wants bind=loopback so we never expose a non-loopback server + tailscale serve/funnel at once.
|
||||||
// - Auth off only allowed for bind=loopback.
|
|
||||||
// - Funnel requires password auth.
|
// - Funnel requires password auth.
|
||||||
if (tailscaleMode !== "off" && bind !== "loopback") {
|
if (tailscaleMode !== "off" && bind !== "loopback") {
|
||||||
await prompter.note("Tailscale requires bind=loopback. Adjusting bind to loopback.", "Note");
|
await prompter.note("Tailscale requires bind=loopback. Adjusting bind to loopback.", "Note");
|
||||||
@ -173,11 +167,6 @@ export async function configureGatewayForOnboarding(
|
|||||||
customBindHost = undefined;
|
customBindHost = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authMode === "off" && bind !== "loopback") {
|
|
||||||
await prompter.note("Non-loopback bind requires auth. Switching to token auth.", "Note");
|
|
||||||
authMode = "token";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tailscaleMode === "funnel" && authMode !== "password") {
|
if (tailscaleMode === "funnel" && authMode !== "password") {
|
||||||
await prompter.note("Tailscale funnel requires password auth.", "Note");
|
await prompter.note("Tailscale funnel requires password auth.", "Note");
|
||||||
authMode = "password";
|
authMode = "password";
|
||||||
|
|||||||
@ -244,7 +244,6 @@ export async function runOnboardingWizard(
|
|||||||
return "Auto";
|
return "Auto";
|
||||||
};
|
};
|
||||||
const formatAuth = (value: GatewayAuthChoice) => {
|
const formatAuth = (value: GatewayAuthChoice) => {
|
||||||
if (value === "off") return "Off (loopback only)";
|
|
||||||
if (value === "token") return "Token (default)";
|
if (value === "token") return "Token (default)";
|
||||||
return "Password";
|
return "Password";
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user