Merge branch 'main' into feature/twitch-plugin

This commit is contained in:
jaydenfyi 2026-01-26 00:45:46 +08:00 committed by GitHub
commit 5843283162
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 285 additions and 33 deletions

View File

@ -628,6 +628,8 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: 8.11.1
- name: Install Android SDK packages
run: |

View File

@ -2,10 +2,11 @@
Docs: https://docs.clawd.bot
## 2026.1.25
## 2026.1.24-3
### Changes
- TBD.
### Fixes
- Gateway: harden reverse proxy handling for local-client detection and unauthenticated proxied connects. (#1795) Thanks @orlyjamie.
- Security audit: flag loopback Control UI with auth disabled as critical. (#1795) Thanks @orlyjamie.
## 2026.1.24-2

View File

@ -63,6 +63,23 @@ downgrade—prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
`clawdbot security audit` warns when this setting is enabled.
## Reverse Proxy Configuration
If you run the Gateway behind a reverse proxy (nginx, Caddy, Traefik, etc.), you should configure `gateway.trustedProxies` for proper client IP detection.
When the Gateway detects proxy headers (`X-Forwarded-For` or `X-Real-IP`) from an address that is **not** in `trustedProxies`, it will **not** treat connections as local clients. If gateway auth is disabled, those connections are rejected. This prevents authentication bypass where proxied connections would otherwise appear to come from localhost and receive automatic trust.
```yaml
gateway:
trustedProxies:
- "127.0.0.1" # if your proxy runs on localhost
auth:
mode: password
password: ${CLAWDBOT_GATEWAY_PASSWORD}
```
When `trustedProxies` is configured, the Gateway will use `X-Forwarded-For` headers to determine the real client IP for local client detection. Make sure your proxy overwrites (not appends to) incoming `X-Forwarded-For` headers to prevent spoofing.
## Local session logs live on disk
Clawdbot stores session transcripts on disk under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl`.

View File

@ -105,6 +105,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [How can my agent access my computer if the Gateway is hosted remotely?](#how-can-my-agent-access-my-computer-if-the-gateway-is-hosted-remotely)
- [Tailscale is connected but I get no replies. What now?](#tailscale-is-connected-but-i-get-no-replies-what-now)
- [Can two Clawdbots talk to each other (local + VPS)?](#can-two-clawdbots-talk-to-each-other-local-vps)
- [Do I need separate VPSes for multiple agents](#do-i-need-separate-vpses-for-multiple-agents)
- [Is there a benefit to using a node on my personal laptop instead of SSH from a VPS?](#is-there-a-benefit-to-using-a-node-on-my-personal-laptop-instead-of-ssh-from-a-vps)
- [Do nodes run a gateway service?](#do-nodes-run-a-gateway-service)
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
@ -1463,6 +1464,16 @@ allowlists, or a "do not reply to bot messages" rule).
Docs: [Remote access](/gateway/remote), [Agent CLI](/cli/agent), [Agent send](/tools/agent-send).
### Do I need separate VPSes for multiple agents
No. One Gateway can host multiple agents, each with its own workspace, model defaults,
and routing. That is the normal setup and it is much cheaper and simpler than running
one VPS per agent.
Use separate VPSes only when you need hard isolation (security boundaries) or very
different configs that you do not want to share. Otherwise, keep one Gateway and
use multiple agents or sub-agents.
### Is there a benefit to using a node on my personal laptop instead of SSH from a VPS
Yes - nodes are the firstclass way to reach your laptop from a remote Gateway, and they

View File

@ -30,17 +30,17 @@ Notes:
# From repo root; set release IDs so Sparkle feed is enabled.
# APP_BUILD must be numeric + monotonic for Sparkle compare.
BUNDLE_ID=com.clawdbot.mac \
APP_VERSION=2026.1.24-1 \
APP_VERSION=2026.1.24-3 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-app.sh
# Zip for distribution (includes resource forks for Sparkle delta support)
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.24-1.zip
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.24-3.zip
# Optional: also build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.24-1.dmg
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.24-3.dmg
# Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once:
@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.24-1.dmg
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
BUNDLE_ID=com.clawdbot.mac \
APP_VERSION=2026.1.24-1 \
APP_VERSION=2026.1.24-3 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.24-1.dSYM.zip
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.24-3.dSYM.zip
```
## Appcast entry
Use the release note generator so Sparkle renders formatted HTML notes:
```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.24-1.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.24-3.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
## Publish & verify
- Upload `Clawdbot-2026.1.24-1.zip` (and `Clawdbot-2026.1.24-1.dSYM.zip`) to the GitHub release for tag `v2026.1.24-1`.
- Upload `Clawdbot-2026.1.24-3.zip` (and `Clawdbot-2026.1.24-3.dSYM.zip`) to the GitHub release for tag `v2026.1.24-3`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
- Sanity checks:
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.

View File

@ -1,5 +1,5 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { join } from "node:path";
import { join, parse } from "node:path";
// Mock fs module before importing the module under test
const mockExistsSync = vi.fn();
@ -19,7 +19,9 @@ vi.mock("node:fs", async (importOriginal) => {
});
describe("extractGeminiCliCredentials", () => {
const normalizePath = (value: string) => value.replace(/\\/g, "/");
const normalizePath = (value: string) =>
value.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
const rootDir = parse(process.cwd()).root || "/";
const FAKE_CLIENT_ID = "123456789-abcdef.apps.googleusercontent.com";
const FAKE_CLIENT_SECRET = "GOCSPX-FakeSecretValue123";
const FAKE_OAUTH2_CONTENT = `
@ -49,11 +51,33 @@ describe("extractGeminiCliCredentials", () => {
});
it("extracts credentials from oauth2.js in known path", async () => {
const fakeBinDir = "/fake/bin";
const fakeBinDir = join(rootDir, "fake", "bin");
const fakeGeminiPath = join(fakeBinDir, "gemini");
const fakeResolvedPath = "/fake/lib/node_modules/@google/gemini-cli/dist/index.js";
const fakeOauth2Path =
"/fake/lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js";
const fakeResolvedPath = join(
rootDir,
"fake",
"lib",
"node_modules",
"@google",
"gemini-cli",
"dist",
"index.js",
);
const fakeOauth2Path = join(
rootDir,
"fake",
"lib",
"node_modules",
"@google",
"gemini-cli",
"node_modules",
"@google",
"gemini-cli-core",
"dist",
"src",
"code_assist",
"oauth2.js",
);
process.env.PATH = fakeBinDir;
@ -77,9 +101,18 @@ describe("extractGeminiCliCredentials", () => {
});
it("returns null when oauth2.js cannot be found", async () => {
const fakeBinDir = "/fake/bin";
const fakeBinDir = join(rootDir, "fake", "bin");
const fakeGeminiPath = join(fakeBinDir, "gemini");
const fakeResolvedPath = "/fake/lib/node_modules/@google/gemini-cli/dist/index.js";
const fakeResolvedPath = join(
rootDir,
"fake",
"lib",
"node_modules",
"@google",
"gemini-cli",
"dist",
"index.js",
);
process.env.PATH = fakeBinDir;
@ -95,11 +128,33 @@ describe("extractGeminiCliCredentials", () => {
});
it("returns null when oauth2.js lacks credentials", async () => {
const fakeBinDir = "/fake/bin";
const fakeBinDir = join(rootDir, "fake", "bin");
const fakeGeminiPath = join(fakeBinDir, "gemini");
const fakeResolvedPath = "/fake/lib/node_modules/@google/gemini-cli/dist/index.js";
const fakeOauth2Path =
"/fake/lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js";
const fakeResolvedPath = join(
rootDir,
"fake",
"lib",
"node_modules",
"@google",
"gemini-cli",
"dist",
"index.js",
);
const fakeOauth2Path = join(
rootDir,
"fake",
"lib",
"node_modules",
"@google",
"gemini-cli",
"node_modules",
"@google",
"gemini-cli-core",
"dist",
"src",
"code_assist",
"oauth2.js",
);
process.env.PATH = fakeBinDir;
@ -118,11 +173,33 @@ describe("extractGeminiCliCredentials", () => {
});
it("caches credentials after first extraction", async () => {
const fakeBinDir = "/fake/bin";
const fakeBinDir = join(rootDir, "fake", "bin");
const fakeGeminiPath = join(fakeBinDir, "gemini");
const fakeResolvedPath = "/fake/lib/node_modules/@google/gemini-cli/dist/index.js";
const fakeOauth2Path =
"/fake/lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js";
const fakeResolvedPath = join(
rootDir,
"fake",
"lib",
"node_modules",
"@google",
"gemini-cli",
"dist",
"index.js",
);
const fakeOauth2Path = join(
rootDir,
"fake",
"lib",
"node_modules",
"@google",
"gemini-cli",
"node_modules",
"@google",
"gemini-cli-core",
"dist",
"src",
"code_assist",
"oauth2.js",
);
process.env.PATH = fakeBinDir;

View File

@ -1,6 +1,6 @@
{
"name": "clawdbot",
"version": "2026.1.24-2",
"version": "2026.1.24-3",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",

View File

@ -351,6 +351,27 @@ describe("gateway server auth/connect", () => {
}
});
test("rejects proxied connections without auth when proxy headers are untrusted", async () => {
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
const port = await getFreePort();
const server = await startGatewayServer(port);
const ws = new WebSocket(`ws://127.0.0.1:${port}`, {
headers: { "x-forwarded-for": "203.0.113.10" },
});
await new Promise<void>((resolve) => ws.once("open", resolve));
const res = await connectReq(ws);
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("gateway auth required");
ws.close();
await server.close();
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
});
test("accepts device token auth for paired device", async () => {
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
const { approveDevicePairing, getPairedDevice, listDevicePairing } =

View File

@ -26,7 +26,7 @@ import type { ResolvedGatewayAuth } from "../../auth.js";
import { authorizeGatewayConnect } from "../../auth.js";
import { loadConfig } from "../../../config/config.js";
import { buildDeviceAuthPayload } from "../../device-auth.js";
import { isLocalGatewayAddress, resolveGatewayClientIp } from "../../net.js";
import { isLocalGatewayAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js";
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
import {
type ConnectParams,
@ -177,7 +177,24 @@ export function attachGatewayWsMessageHandler(params: {
const configSnapshot = loadConfig();
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
const clientIp = resolveGatewayClientIp({ remoteAddr, forwardedFor, realIp, trustedProxies });
const isLocalClient = isLocalGatewayAddress(clientIp);
// If proxy headers are present but the remote address isn't trusted, don't treat
// the connection as local. This prevents auth bypass when running behind a reverse
// proxy without proper configuration - the proxy's loopback connection would otherwise
// cause all external requests to be treated as trusted local clients.
const hasProxyHeaders = Boolean(forwardedFor || realIp);
const remoteIsTrustedProxy = isTrustedProxyAddress(remoteAddr, trustedProxies);
const hasUntrustedProxyHeaders = hasProxyHeaders && !remoteIsTrustedProxy;
const isLocalClient = !hasUntrustedProxyHeaders && isLocalGatewayAddress(clientIp);
const reportedClientIp = hasUntrustedProxyHeaders ? undefined : clientIp;
if (hasUntrustedProxyHeaders) {
logWsControl.warn(
"Proxy headers detected from untrusted address. " +
"Connection will not be treated as local. " +
"Configure gateway.trustedProxies to restore local client detection behind your proxy.",
);
}
const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client);
@ -322,6 +339,31 @@ export function attachGatewayWsMessageHandler(params: {
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
const allowInsecureControlUi =
isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true;
if (hasUntrustedProxyHeaders && resolvedAuth.mode === "none") {
setHandshakeState("failed");
setCloseCause("proxy-auth-required", {
client: connectParams.client.id,
clientDisplayName: connectParams.client.displayName,
mode: connectParams.client.mode,
version: connectParams.client.version,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(
ErrorCodes.INVALID_REQUEST,
"gateway auth required behind reverse proxy",
{
details: {
hint: "set gateway.auth or configure gateway.trustedProxies",
},
},
),
});
close(1008, "gateway auth required");
return;
}
if (!device) {
const canSkipDevice = allowInsecureControlUi ? hasSharedAuth : hasTokenAuth;
@ -581,7 +623,7 @@ export function attachGatewayWsMessageHandler(params: {
clientMode: connectParams.client.mode,
role,
scopes,
remoteIp: clientIp,
remoteIp: reportedClientIp,
silent: isLocalClient,
});
const context = buildRequestContext();
@ -665,7 +707,7 @@ export function attachGatewayWsMessageHandler(params: {
clientMode: connectParams.client.mode,
role,
scopes,
remoteIp: clientIp,
remoteIp: reportedClientIp,
});
}
}
@ -714,7 +756,7 @@ export function attachGatewayWsMessageHandler(params: {
if (presenceKey) {
upsertPresence(presenceKey, {
host: connectParams.client.displayName ?? connectParams.client.id ?? os.hostname(),
ip: isLocalClient ? undefined : clientIp,
ip: isLocalClient ? undefined : reportedClientIp,
version: connectParams.client.version,
platform: connectParams.client.platform,
deviceFamily: connectParams.client.deviceFamily,
@ -773,7 +815,9 @@ export function attachGatewayWsMessageHandler(params: {
setHandshakeState("connected");
if (role === "node") {
const context = buildRequestContext();
const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: clientIp });
const nodeSession = context.nodeRegistry.register(nextClient, {
remoteIp: reportedClientIp,
});
const instanceIdRaw = connectParams.client.instanceId;
const instanceId = typeof instanceIdRaw === "string" ? instanceIdRaw.trim() : "";
const nodeIdsForPairing = new Set<string>([nodeSession.nodeId]);

View File

@ -53,6 +53,55 @@ describe("security audit", () => {
).toBe(true);
});
it("warns when loopback control UI lacks trusted proxies", async () => {
const cfg: ClawdbotConfig = {
gateway: {
bind: "loopback",
controlUi: { enabled: true },
},
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: false,
});
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "gateway.trusted_proxies_missing",
severity: "warn",
}),
]),
);
});
it("flags loopback control UI without auth as critical", async () => {
const cfg: ClawdbotConfig = {
gateway: {
bind: "loopback",
controlUi: { enabled: true },
auth: { mode: "none" as any },
},
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: false,
});
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "gateway.loopback_no_auth",
severity: "critical",
}),
]),
);
});
it("flags logging.redactSensitive=off", async () => {
const cfg: ClawdbotConfig = {
logging: { redactSensitive: "off" },

View File

@ -207,6 +207,10 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding
const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback";
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode });
const controlUiEnabled = cfg.gateway?.controlUi?.enabled !== false;
const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies)
? cfg.gateway.trustedProxies
: [];
if (bind !== "loopback" && auth.mode === "none") {
findings.push({
@ -218,6 +222,32 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding
});
}
if (bind === "loopback" && controlUiEnabled && trustedProxies.length === 0) {
findings.push({
checkId: "gateway.trusted_proxies_missing",
severity: "warn",
title: "Reverse proxy headers are not trusted",
detail:
"gateway.bind is loopback and gateway.trustedProxies is empty. " +
"If you expose the Control UI through a reverse proxy, configure trusted proxies " +
"so local-client checks cannot be spoofed.",
remediation:
"Set gateway.trustedProxies to your proxy IPs or keep the Control UI local-only.",
});
}
if (bind === "loopback" && controlUiEnabled && auth.mode === "none") {
findings.push({
checkId: "gateway.loopback_no_auth",
severity: "critical",
title: "Gateway auth disabled on loopback",
detail:
"gateway.bind is loopback and gateway.auth is disabled. " +
"If the Control UI is exposed through a reverse proxy, unauthenticated access is possible.",
remediation: "Set gateway.auth (token recommended) or keep the Control UI local-only.",
});
}
if (tailscaleMode === "funnel") {
findings.push({
checkId: "gateway.tailscale_funnel",