Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion config/clawbox-gateway.service
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ Type=simple
User=clawbox
WorkingDirectory=/home/clawbox
ExecStartPre=/home/clawbox/clawbox/scripts/gateway-pre-start.sh
ExecStart=/home/clawbox/.npm-global/bin/openclaw gateway --allow-unconfigured --bind lan --token clawbox
# No --token here: the gateway resolves gateway.auth.token from openclaw.json,
# which gateway-pre-start.sh keeps as a strong per-device value (and which
# gateway-proxy.ts injects into the SPA). Passing a literal here would override
# that and reintroduce the shared-token / UI-drift bug (issues #149, #150).
ExecStart=/home/clawbox/.npm-global/bin/openclaw gateway --allow-unconfigured --bind lan
Restart=always
RestartSec=5
Environment=HOME=/home/clawbox
Expand Down
20 changes: 18 additions & 2 deletions install-x64.sh
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,12 @@ step_openclaw_config() {
fi

as_user "$OPENCLAW_BIN" config set gateway.auth.mode token 2>/dev/null || true
as_user "$OPENCLAW_BIN" config set gateway.auth.token clawbox 2>/dev/null || true
# Seed a strong per-device token only when missing/weak (see install.sh).
EXISTING_GW_TOKEN=$(as_user "$OPENCLAW_BIN" config get gateway.auth.token 2>/dev/null | tr -d '"[:space:]')
if [ -z "$EXISTING_GW_TOKEN" ] || [ "$EXISTING_GW_TOKEN" = "clawbox" ] || [ "${#EXISTING_GW_TOKEN}" -lt 32 ]; then
GW_TOKEN=$(openssl rand -hex 32 2>/dev/null || head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')
as_user "$OPENCLAW_BIN" config set gateway.auth.token "$GW_TOKEN" 2>/dev/null || true
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
as_user "$OPENCLAW_BIN" config set gateway.controlUi.allowInsecureAuth true --json 2>/dev/null || true
as_user "$OPENCLAW_BIN" config set gateway.controlUi.dangerouslyDisableDeviceAuth true --json 2>/dev/null || true

Expand Down Expand Up @@ -390,7 +395,18 @@ c.agents.defaults.compaction.reserveTokensFloor=24000;
if(!c.gateway)c.gateway={};
if(!c.gateway.auth)c.gateway.auth={};
c.gateway.auth.mode='token';
c.gateway.auth.token='clawbox';
// Preserve a strong per-device token; only generate when missing/weak. A
// plain literal "clawbox" here would reintroduce the shared-token vuln (#149).
{
// Keep this predicate in lockstep with is_strong_gateway_token() in
// scripts/gateway-pre-start.sh: SecretRef object with a known ref key,
// a non-empty ${ENV} interpolation, or a >=32-char non-legacy string.
const t=c.gateway.auth.token;
const strong =
(t&&typeof t==='object'&&!Array.isArray(t)&&('env' in t||'file' in t||'exec' in t)) ||
(typeof t==='string' && (/^\$\{.+\}$/.test(t) || (t!=='clawbox' && t.length>=32)));
if(!strong) c.gateway.auth.token=require('crypto').randomBytes(32).toString('hex');
}
if(!c.gateway.controlUi)c.gateway.controlUi={};
c.gateway.controlUi.allowInsecureAuth=true;
c.gateway.controlUi.dangerouslyDisableDeviceAuth=true;
Expand Down
13 changes: 12 additions & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -975,7 +975,18 @@ step_openclaw_config() {
fi

as_clawbox "$OPENCLAW_BIN" config set gateway.auth.mode token
as_clawbox "$OPENCLAW_BIN" config set gateway.auth.token clawbox
# Seed a strong per-device gateway token (not the public legacy literal).
# gateway-pre-start.sh preserves it on every start; the gateway resolves it
# from config (no --token flag). Only seed when missing/weak so re-runs of
# the installer don't rotate a token a device is already using.
EXISTING_GW_TOKEN=$(as_clawbox "$OPENCLAW_BIN" config get gateway.auth.token 2>/dev/null | tr -d '"[:space:]')
if [ -z "$EXISTING_GW_TOKEN" ] || [ "$EXISTING_GW_TOKEN" = "clawbox" ] || [ "${#EXISTING_GW_TOKEN}" -lt 32 ]; then
GW_TOKEN=$(openssl rand -hex 32 2>/dev/null || head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')
as_clawbox "$OPENCLAW_BIN" config set gateway.auth.token "$GW_TOKEN"
echo " Gateway auth token generated (per-device)"
else
echo " Gateway auth token preserved (already strong)"
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
echo " Gateway auth mode set to token"

as_clawbox "$OPENCLAW_BIN" config set gateway.controlUi.allowInsecureAuth true --json
Expand Down
45 changes: 43 additions & 2 deletions scripts/gateway-pre-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,29 @@ fi
export CLAWBOX_LAN_IPS

python3 - "$OPENCLAW_CONFIG" <<'PY'
import json, os, sys, tempfile
import json, os, sys, tempfile, secrets

# Gateway auth token gates LAN access to the agent's privileged tools
# (run_command / file_write / system_power). Earlier builds wrote the public
# literal "clawbox" — documented in the open-source history — so any device
# carrying it is an unauthenticated-LAN-access risk. A strong per-device token
# must be PRESERVED once set: the configure route's random hex, a `${ENV}`
# interpolation, or a SecretRef object are all legitimate strong values we
# must not clobber back to the literal.
LEGACY_GATEWAY_TOKEN = "clawbox"
MIN_GATEWAY_TOKEN_LENGTH = 32

def is_strong_gateway_token(v):
# SecretRef object — managed externally. Require a known ref key so an
# empty/malformed {} isn't mistaken for a resolvable secret.
if isinstance(v, dict):
return any(k in v for k in ("env", "file", "exec"))
if isinstance(v, str):
# `${VAR}` interpolation (non-empty body) resolves from env at runtime.
if v.startswith("${") and v.endswith("}") and len(v) > 3:
return True
return v != LEGACY_GATEWAY_TOKEN and len(v) >= MIN_GATEWAY_TOKEN_LENGTH
return False

cfg_path = sys.argv[1]
hostname = os.environ.get("CLAWBOX_HOSTNAME", "clawbox")
Expand Down Expand Up @@ -118,6 +140,19 @@ for k in ("tools", "systemPromptSuffix"):
del agents_defaults[k]
changed = True

# Strip orphaned per-model keys that a newer-than-pinned plugin wrote and a
# version downgrade left behind, which fail strict config validation and
# brick the AI provider page until `openclaw doctor --fix`. `agentRuntime`
# is written by @openclaw/codex >= 2026.5.27 into agents.defaults.models[*];
# when the plugin is realigned to the pinned core (< that version) the key is
# orphaned. Drop it on every gateway start so affected devices self-heal.
agents_models = agents_defaults.get("models")
if isinstance(agents_models, dict):
for _model_key, _model_val in agents_models.items():
if isinstance(_model_val, dict) and "agentRuntime" in _model_val:
del _model_val["agentRuntime"]
changed = True

# Security migration: older ClawBox versions silently wrote
# channels.telegram.dmPolicy="open" + allowFrom=["*"] at bot-token setup,
# which opened the bot — and the agent's shell/file/system_power tools —
Expand Down Expand Up @@ -190,7 +225,13 @@ if gateway.get("bind") not in valid_binds:

set_if(gateway, "mode", "local")
set_if(auth, "mode", "token")
set_if(auth, "token", "clawbox")
# Preserve a strong token; only (re)generate when missing or the weak legacy
# literal. The service no longer passes --token, so the gateway resolves this
# config value at runtime (same value gateway-proxy.ts injects into the SPA) —
# one source of truth, no service↔UI drift (issues #149, #150).
if not is_strong_gateway_token(auth.get("token")):
auth["token"] = secrets.token_hex(32)
changed = True

# Backfill `compat.supportedReasoningEfforts: ["high", "xhigh"]` onto any
# DeepSeek V4 models the configure route wrote before this declaration was
Expand Down
112 changes: 112 additions & 0 deletions src/tests/unit/gateway-pre-start-token.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { execFileSync, spawnSync } from "node:child_process";
import path from "node:path";

// gateway-pre-start.sh decides whether to PRESERVE the on-disk gateway auth
// token or rotate it to a fresh per-device random. That predicate + rotation
// gate LAN access to the agent's privileged tools, so a regression (reverting
// to the unconditional `set_if(auth,"token","clawbox")` clobber, or a botched
// strength check that drops a strong token) is a security risk. We extract the
// real predicate from the shipped script and exercise it — and the actual
// rotation line — via python3 so this tracks the real code. See #149 / #150.

const SCRIPT = path.resolve(process.cwd(), "scripts/gateway-pre-start.sh");

const hasPython3 =
spawnSync("python3", ["--version"], { stdio: "ignore" }).status === 0;

// Pull the token-policy block (constants + predicate) out of the .sh verbatim.
// Anchored between the first constant and the next top-level statement
// (`cfg_path = sys.argv[1]`), so it survives reformatting of the function body.
function extractTokenPolicy(): string {
const src = readFileSync(SCRIPT, "utf-8");
const start = src.indexOf('LEGACY_GATEWAY_TOKEN = "clawbox"');
const end = src.indexOf("\ncfg_path = sys.argv[1]", start);
if (start < 0 || end < 0) {
throw new Error("token-policy block not found in gateway-pre-start.sh");
}
return src.slice(start, end);
}

const POLICY = hasPython3 ? extractTokenPolicy() : "";
const HEX_64 = /^[0-9a-f]{64}$/;

function runPython(body: string, valueJson: string): string {
return execFileSync("python3", ["-c", `${POLICY}\n${body}`, valueJson], {
encoding: "utf-8",
}).trim();
}

/** "strong" (preserve) or "weak" (rotate) for a JSON-encoded token value. */
function classify(value: unknown): string {
return runPython(
`import json, sys\nprint("strong" if is_strong_gateway_token(json.loads(sys.argv[1])) else "weak")`,
JSON.stringify(value),
);
}

/** The token pre-start would end up persisting, given an existing value. */
function rotateOutcome(value: unknown): string {
// Mirrors the rotation at the call site:
// if not is_strong_gateway_token(auth.get("token")): auth["token"] = secrets.token_hex(32)
return runPython(
`import json, sys, secrets\nt = json.loads(sys.argv[1])\nprint(t if is_strong_gateway_token(t) else secrets.token_hex(32))`,
JSON.stringify(value),
);
}

describe.skipIf(!hasPython3)("gateway-pre-start.sh token policy", () => {
describe("is_strong_gateway_token predicate", () => {
it("legacy literal 'clawbox' is weak (must rotate)", () => {
expect(classify("clawbox")).toBe("weak");
});
it("null / missing is weak (must generate)", () => {
expect(classify(null)).toBe("weak");
});
it("64-hex per-device token is strong (must preserve)", () => {
expect(classify("a".repeat(64))).toBe("strong");
});
it("32-char token is strong (length boundary)", () => {
expect(classify("b".repeat(32))).toBe("strong");
});
it("31-char token is weak (just under the boundary)", () => {
expect(classify("c".repeat(31))).toBe("weak");
});
it("non-empty ${ENV} interpolation is strong (runtime-resolved)", () => {
expect(classify("${OPENCLAW_GATEWAY_TOKEN}")).toBe("strong");
});
it("empty ${} interpolation is weak", () => {
expect(classify("${}")).toBe("weak");
});
it("SecretRef object with a known key is strong (externally managed)", () => {
expect(classify({ env: "OPENCLAW_GATEWAY_TOKEN" })).toBe("strong");
});
it("empty/keyless object is weak (not a resolvable secret)", () => {
expect(classify({})).toBe("weak");
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
it("array is weak (matches install-x64.sh Array.isArray guard)", () => {
expect(classify(["a".repeat(64)])).toBe("weak");
});
});

describe("rotation wiring (preserve strong / rotate weak)", () => {
it("rotates the legacy literal to a fresh 64-hex token", () => {
const out = rotateOutcome("clawbox");
expect(out).not.toBe("clawbox");
expect(out).toMatch(HEX_64);
});
it("rotates a missing token to a 64-hex token", () => {
expect(rotateOutcome(null)).toMatch(HEX_64);
});
it("preserves an existing strong 64-hex token unchanged", () => {
const strong = "d".repeat(64);
expect(rotateOutcome(strong)).toBe(strong);
});
it("preserves a ${ENV} interpolation unchanged", () => {
expect(rotateOutcome("${OPENCLAW_GATEWAY_TOKEN}")).toBe(
"${OPENCLAW_GATEWAY_TOKEN}",
);
});
});
});
Loading