重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,26 @@
# Scripts Guide
This directory owns local tooling, script wrappers, and generated-artifact helper rules.
## Wrapper Rules
- Prefer existing wrappers over raw tool entrypoints when the repo already has a curated seam.
- For tests, prefer `scripts/run-vitest.mjs` or the root `pnpm test ...` entrypoints over raw `vitest run` calls.
- For lint/typecheck flows, prefer `scripts/run-oxlint.mjs` and `scripts/run-tsgo.mjs` when adding or editing package scripts or CI steps that should honor repo-local runtime behavior.
## Local Heavy-Check Lock
- Respect the local heavy-check lock behavior in `scripts/lib/local-heavy-check-runtime.mjs`.
- Do not bypass that lock for real heavy commands just to make a local loop look faster.
- Metadata-only or explicitly narrow commands may skip the lock when the existing helper logic says that is safe.
- If you change the lock heuristics, add or update the narrow tests under `test/scripts/`.
## Generated Outputs
- If a script writes generated artifacts, keep the source-of-truth generator, the package script, and the matching verification/check command aligned.
- Prefer additive generator/check pairs like `*:gen` and `*:check` over one-off undocumented scripts.
## Scope
- Keep script-runner behavior, wrapper expectations, and generated-artifact guidance here.
- Leave repo-global verification policy in the root `AGENTS.md`.

View file

@ -0,0 +1 @@
AGENTS.md

View file

@ -0,0 +1,9 @@
#!/usr/bin/env node
import { main } from "./ts-topology.ts";
const forwardedArgs = process.argv.slice(2);
const normalizedArgs = forwardedArgs[0] === "--" ? forwardedArgs.slice(1) : forwardedArgs;
const exitCode = await main(["--scope=plugin-sdk", ...normalizedArgs]);
if (exitCode !== 0) {
process.exit(exitCode);
}

View file

@ -0,0 +1,662 @@
import { spawn } from "node:child_process";
// Live prompt probe for Anthropic setup-token and Claude CLI prompt-path debugging.
// Usage:
// OPENCLAW_PROMPT_TRANSPORT=direct|gateway
// OPENCLAW_PROMPT_MODE=extra|override
// OPENCLAW_PROMPT_TEXT='...'
// OPENCLAW_PROMPT_CAPTURE=1
// pnpm probe:anthropic:prompt
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { resolveOpenClawAgentDir } from "../src/agents/agent-paths.js";
import { ensureAuthProfileStore, type AuthProfileCredential } from "../src/agents/auth-profiles.js";
import { normalizeProviderId } from "../src/agents/model-selection.js";
import { validateAnthropicSetupToken } from "../src/commands/auth-token.js";
import { callGateway } from "../src/gateway/call.js";
import { extractPayloadText } from "../src/gateway/test-helpers.agent-results.js";
import { getFreePortBlockWithPermissionFallback } from "../src/test-utils/ports.js";
const TRANSPORT = process.env.OPENCLAW_PROMPT_TRANSPORT?.trim() === "direct" ? "direct" : "gateway";
const GATEWAY_PROMPT_MODE =
process.env.OPENCLAW_PROMPT_MODE?.trim() === "override" ? "override" : "extra";
const PROMPT_TEXT = process.env.OPENCLAW_PROMPT_TEXT?.trim() ?? "";
const PROMPT_LIST_JSON = process.env.OPENCLAW_PROMPT_LIST_JSON?.trim() ?? "";
const USER_PROMPT = process.env.OPENCLAW_USER_PROMPT?.trim() || "is clawd here?";
const ENABLE_CAPTURE = process.env.OPENCLAW_PROMPT_CAPTURE === "1";
const INCLUDE_RAW = process.env.OPENCLAW_PROMPT_INCLUDE_RAW === "1";
const CLAUDE_BIN = process.env.CLAUDE_BIN?.trim() || "claude";
const NODE_BIN = process.env.OPENCLAW_NODE_BIN?.trim() || process.execPath;
const TIMEOUT_MS = Number(process.env.OPENCLAW_PROMPT_TIMEOUT_MS ?? "45000");
const GATEWAY_TIMEOUT_MS = Number(process.env.OPENCLAW_PROMPT_GATEWAY_TIMEOUT_MS ?? "120000");
const SETUP_TOKEN_RAW = process.env.OPENCLAW_LIVE_SETUP_TOKEN?.trim() ?? "";
const SETUP_TOKEN_VALUE = process.env.OPENCLAW_LIVE_SETUP_TOKEN_VALUE?.trim() ?? "";
const SETUP_TOKEN_PROFILE = process.env.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE?.trim() ?? "";
const DIRECT_CLAUDE_ARGS = ["-p", "--append-system-prompt"];
if (!PROMPT_TEXT && !PROMPT_LIST_JSON) {
throw new Error("missing OPENCLAW_PROMPT_TEXT or OPENCLAW_PROMPT_LIST_JSON");
}
type CaptureSummary = {
url?: string;
authScheme?: string;
xApp?: string;
anthropicBeta?: string;
systemBlockCount: number;
systemBlocks: Array<{ index: number; bytes: number; preview: string }>;
containsPromptExact: boolean;
bodyContainsPromptExact: boolean;
userBytes?: number;
userPreview?: string;
rawBody?: string;
};
type PromptResult = {
prompt: string;
ok: boolean;
transport: "direct" | "gateway";
promptMode?: "extra" | "override";
exitCode?: number | null;
signal?: NodeJS.Signals | null;
status?: string;
text?: string;
stdout?: string;
stderr?: string;
error?: string;
matchedExtraUsage400: boolean;
capture?: CaptureSummary;
tmpDir: string;
};
type ProxyCapture = {
url?: string;
authHeader?: string;
xApp?: string;
anthropicBeta?: string;
systemTexts: string[];
userText?: string;
rawBody?: string;
};
type TokenSource = {
profileId: string;
token: string;
};
function toHeaderValue(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value.join(", ") : value;
}
function summarizeText(text: string, max = 120): string {
const normalized = text.replace(/\s+/g, " ").trim();
if (normalized.length <= max) {
return normalized;
}
return `${normalized.slice(0, max - 1)}…`;
}
function summarizeCapture(
capture: ProxyCapture | undefined,
prompt: string,
): CaptureSummary | undefined {
if (!capture) {
return undefined;
}
return {
url: capture.url,
authScheme: capture.authHeader?.split(/\s+/, 1)[0],
xApp: capture.xApp,
anthropicBeta: capture.anthropicBeta,
systemBlockCount: capture.systemTexts.length,
systemBlocks: capture.systemTexts.map((entry, index) => ({
index,
bytes: Buffer.byteLength(entry, "utf8"),
preview: summarizeText(entry),
})),
containsPromptExact: capture.systemTexts.includes(prompt),
bodyContainsPromptExact: capture.rawBody?.includes(prompt) ?? false,
userBytes: capture.userText ? Buffer.byteLength(capture.userText, "utf8") : undefined,
userPreview: capture.userText ? summarizeText(capture.userText) : undefined,
rawBody: INCLUDE_RAW ? capture.rawBody : undefined,
};
}
function matchesExtraUsage400(...parts: Array<string | undefined>): boolean {
return parts
.filter((value): value is string => typeof value === "string" && value.length > 0)
.join(" ")
.toLowerCase()
.includes("third-party apps now draw from your extra usage");
}
function isSetupToken(value: string): boolean {
return value.startsWith("sk-ant-oat01-");
}
function listSetupTokenProfiles(store: {
profiles: Record<string, AuthProfileCredential>;
}): Array<{ id: string; token: string }> {
return Object.entries(store.profiles)
.filter(([, cred]) => {
if (cred.type !== "token") {
return false;
}
if (normalizeProviderId(cred.provider) !== "anthropic") {
return false;
}
return isSetupToken(cred.token ?? "");
})
.map(([id, cred]) => ({ id, token: cred.token ?? "" }));
}
function pickSetupTokenProfile(candidates: Array<{ id: string; token: string }>): {
id: string;
token: string;
} | null {
const preferred = ["anthropic:setup-token-test", "anthropic:setup-token", "anthropic:default"];
for (const id of preferred) {
const match = candidates.find((entry) => entry.id === id);
if (match) {
return match;
}
}
return candidates[0] ?? null;
}
function validateSetupToken(value: string): string {
const error = validateAnthropicSetupToken(value);
if (error) {
throw new Error(`invalid setup-token: ${error}`);
}
return value;
}
function resolveSetupTokenSource(): TokenSource {
const explicitToken =
(SETUP_TOKEN_RAW && isSetupToken(SETUP_TOKEN_RAW) ? SETUP_TOKEN_RAW : "") || SETUP_TOKEN_VALUE;
if (explicitToken) {
return {
profileId: "anthropic:default",
token: validateSetupToken(explicitToken),
};
}
const agentDir = resolveOpenClawAgentDir();
const store = ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
});
const candidates = listSetupTokenProfiles(store);
if (SETUP_TOKEN_PROFILE) {
const match = candidates.find((entry) => entry.id === SETUP_TOKEN_PROFILE);
if (!match) {
throw new Error(`setup-token profile not found: ${SETUP_TOKEN_PROFILE}`);
}
return { profileId: match.id, token: validateSetupToken(match.token) };
}
const match = pickSetupTokenProfile(candidates);
if (!match) {
throw new Error(
"no Anthropics setup-token profile found; set OPENCLAW_LIVE_SETUP_TOKEN_VALUE or OPENCLAW_LIVE_SETUP_TOKEN_PROFILE",
);
}
return { profileId: match.id, token: validateSetupToken(match.token) };
}
async function sleep(ms: number): Promise<void> {
return await new Promise((resolve) => setTimeout(resolve, ms));
}
async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
fallback: () => T,
): Promise<T> {
return await Promise.race([promise, sleep(timeoutMs).then(() => fallback())]);
}
async function readRequestBody(req: http.IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
function extractProxyCapture(rawBody: string, req: http.IncomingMessage): ProxyCapture {
let parsed: {
system?: Array<{ text?: string }>;
messages?: Array<{ role?: string; content?: unknown }>;
} | null = null;
try {
parsed = JSON.parse(rawBody) as typeof parsed;
} catch {
parsed = null;
}
const systemTexts = Array.isArray(parsed?.system)
? parsed.system
.map((entry) => (typeof entry?.text === "string" ? entry.text : ""))
.filter(Boolean)
: [];
const userText = Array.isArray(parsed?.messages)
? parsed.messages
.filter((entry) => entry?.role === "user")
.flatMap((entry) => {
const content = entry?.content;
if (typeof content === "string") {
return [content];
}
if (!Array.isArray(content)) {
return [];
}
return content
.map((item) =>
item && typeof item === "object" && "text" in item && typeof item.text === "string"
? item.text
: "",
)
.filter(Boolean);
})
.join("\n")
: undefined;
return {
url: req.url ?? undefined,
authHeader: toHeaderValue(req.headers.authorization),
xApp: toHeaderValue(req.headers["x-app"]),
anthropicBeta: toHeaderValue(req.headers["anthropic-beta"]),
systemTexts,
userText,
rawBody,
};
}
async function startAnthropicProxy(params: { port: number; upstreamBaseUrl: string }) {
let lastCapture: ProxyCapture | undefined;
const sockets = new Set<import("node:net").Socket>();
const server = http.createServer(async (req, res) => {
try {
const method = req.method ?? "GET";
const requestBody = await readRequestBody(req);
const rawBody = requestBody.toString("utf8");
lastCapture = extractProxyCapture(rawBody, req);
const upstreamUrl = new URL(req.url ?? "/", params.upstreamBaseUrl).toString();
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (value === undefined) {
continue;
}
const lower = key.toLowerCase();
if (lower === "host" || lower === "content-length") {
continue;
}
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
}
const upstreamRes = await fetch(upstreamUrl, {
method,
headers,
body:
method === "GET" || method === "HEAD" || requestBody.byteLength === 0
? undefined
: requestBody,
duplex: "half",
});
const responseHeaders: Record<string, string> = {};
for (const [key, value] of upstreamRes.headers.entries()) {
const lower = key.toLowerCase();
if (
lower === "content-length" ||
lower === "content-encoding" ||
lower === "transfer-encoding" ||
lower === "connection" ||
lower === "keep-alive"
) {
continue;
}
responseHeaders[key] = value;
}
res.writeHead(upstreamRes.status, responseHeaders);
if (upstreamRes.body) {
for await (const chunk of upstreamRes.body) {
res.write(Buffer.from(chunk));
}
}
res.end();
} catch (error) {
res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
res.end(`proxy error: ${String(error)}`);
}
});
server.on("connection", (socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(params.port, "127.0.0.1", () => resolve());
});
return {
getLastCapture() {
return lastCapture;
},
async stop() {
for (const socket of sockets) {
socket.destroy();
}
await withTimeout(
new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
1_000,
() => undefined,
);
},
};
}
async function getFreePort(): Promise<number> {
return await getFreePortBlockWithPermissionFallback({
offsets: [0, 1, 2, 4],
fallbackBase: 44_000,
});
}
async function runDirectPrompt(prompt: string): Promise<PromptResult> {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-direct-prompt-probe-"));
const proxyPort = ENABLE_CAPTURE ? await getFreePort() : undefined;
const proxy =
ENABLE_CAPTURE && proxyPort
? await startAnthropicProxy({ port: proxyPort, upstreamBaseUrl: "https://api.anthropic.com" })
: undefined;
const stdout: string[] = [];
const stderr: string[] = [];
const child = spawn(CLAUDE_BIN, [...DIRECT_CLAUDE_ARGS, prompt, USER_PROMPT], {
cwd: process.cwd(),
env: {
...process.env,
...(proxyPort ? { ANTHROPIC_BASE_URL: `http://127.0.0.1:${proxyPort}` } : {}),
ANTHROPIC_API_KEY: "",
ANTHROPIC_API_KEY_OLD: "",
},
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", (chunk) => stdout.push(String(chunk)));
child.stderr.on("data", (chunk) => stderr.push(String(chunk)));
const exit = await withTimeout(
new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
child.once("exit", (code, signal) => resolve({ code, signal }));
}),
TIMEOUT_MS,
() => {
child.kill("SIGKILL");
return { code: null, signal: "SIGKILL" as NodeJS.Signals };
},
);
await proxy?.stop().catch(() => {});
const joinedStdout = stdout.join("");
const joinedStderr = stderr.join("");
return {
prompt,
ok: exit.code === 0 && !matchesExtraUsage400(joinedStdout, joinedStderr),
transport: "direct",
exitCode: exit.code,
signal: exit.signal,
stdout: joinedStdout.trim() || undefined,
stderr: joinedStderr.trim() || undefined,
matchedExtraUsage400: matchesExtraUsage400(joinedStdout, joinedStderr),
capture: summarizeCapture(proxy?.getLastCapture(), prompt),
tmpDir,
};
}
async function startGatewayProcess(params: {
port: number;
gatewayToken: string;
configPath: string;
stateDir: string;
agentDir: string;
bundledPluginsDir: string;
logPath: string;
}) {
const logFile = await fs.open(params.logPath, "a");
const child = spawn(
NODE_BIN,
["openclaw.mjs", "gateway", "--port", String(params.port), "--bind", "loopback", "--force"],
{
cwd: process.cwd(),
env: {
...process.env,
OPENCLAW_CONFIG_PATH: params.configPath,
OPENCLAW_STATE_DIR: params.stateDir,
OPENCLAW_AGENT_DIR: params.agentDir,
OPENCLAW_GATEWAY_TOKEN: params.gatewayToken,
OPENCLAW_SKIP_CHANNELS: "1",
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
OPENCLAW_SKIP_CANVAS_HOST: "1",
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1",
OPENCLAW_DISABLE_BONJOUR: "1",
OPENCLAW_SKIP_CRON: "1",
OPENCLAW_TEST_MINIMAL_GATEWAY: "1",
OPENCLAW_BUNDLED_PLUGINS_DIR: params.bundledPluginsDir,
ANTHROPIC_API_KEY: "",
ANTHROPIC_API_KEY_OLD: "",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
child.stdout.on("data", (chunk) => void logFile.appendFile(chunk));
child.stderr.on("data", (chunk) => void logFile.appendFile(chunk));
return {
async stop() {
if (!child.killed) {
child.kill("SIGINT");
}
const exited = await withTimeout(
new Promise<boolean>((resolve) => child.once("exit", () => resolve(true))),
1_500,
() => false,
);
if (!exited && !child.killed) {
child.kill("SIGKILL");
}
await logFile.close();
},
};
}
async function waitForGatewayReady(url: string, token: string): Promise<void> {
const deadline = Date.now() + 45_000;
let lastError = "gateway start timeout";
while (Date.now() < deadline) {
try {
await callGateway({ url, token, method: "health", timeoutMs: 5_000 });
return;
} catch (error) {
lastError = String(error);
await sleep(500);
}
}
throw new Error(lastError);
}
async function readLogTail(logPath: string): Promise<string> {
const raw = await fs.readFile(logPath, "utf8").catch(() => "");
return raw.split(/\r?\n/).slice(-40).join("\n").trim();
}
async function runGatewayPrompt(prompt: string): Promise<PromptResult> {
const tokenSource = resolveSetupTokenSource();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-prompt-probe-"));
const stateDir = path.join(tmpDir, "state");
const agentDir = path.join(stateDir, "agents", "main", "agent");
const bundledPluginsDir = path.join(tmpDir, "bundled-plugins-empty");
const configPath = path.join(tmpDir, "openclaw.json");
const logPath = path.join(tmpDir, "gateway.log");
const gatewayToken = `gw-${randomUUID()}`;
const port = await getFreePort();
const proxyPort = ENABLE_CAPTURE ? await getFreePort() : undefined;
const proxy =
ENABLE_CAPTURE && proxyPort
? await startAnthropicProxy({ port: proxyPort, upstreamBaseUrl: "https://api.anthropic.com" })
: undefined;
await fs.mkdir(agentDir, { recursive: true });
await fs.mkdir(bundledPluginsDir, { recursive: true });
await fs.writeFile(
configPath,
`${JSON.stringify(
{
gateway: {
mode: "local",
controlUi: { enabled: false },
tailscale: { mode: "off" },
},
discovery: {
mdns: { mode: "off" },
wideArea: { enabled: false },
},
...(proxyPort
? {
models: {
providers: {
anthropic: {
baseUrl: `http://127.0.0.1:${proxyPort}`,
api: "anthropic-messages",
models: [],
},
},
},
}
: {}),
auth: {
profiles: { [tokenSource.profileId]: { provider: "anthropic", mode: "token" } },
order: { anthropic: [tokenSource.profileId] },
},
agents: {
defaults: {
model: "anthropic/claude-sonnet-4-6",
heartbeat: {
includeSystemPromptSection: false,
},
...(GATEWAY_PROMPT_MODE === "override" ? { systemPromptOverride: prompt } : {}),
},
},
},
null,
2,
)}\n`,
);
await fs.writeFile(
path.join(agentDir, "auth-profiles.json"),
`${JSON.stringify(
{
version: 1,
profiles: {
[tokenSource.profileId]: {
type: "token",
provider: "anthropic",
token: tokenSource.token,
},
},
},
null,
2,
)}\n`,
);
const gateway = await startGatewayProcess({
port,
gatewayToken,
configPath,
stateDir,
agentDir,
bundledPluginsDir,
logPath,
});
try {
const url = `ws://127.0.0.1:${port}`;
await waitForGatewayReady(url, gatewayToken);
const agentRes = await callGateway({
url,
token: gatewayToken,
method: "agent",
params: {
sessionKey: `agent:main:prompt-probe-${randomUUID()}`,
idempotencyKey: `idem-${randomUUID()}`,
message: "Reply with exactly: PROMPT PROBE OK.",
...(GATEWAY_PROMPT_MODE === "extra" ? { extraSystemPrompt: prompt } : {}),
deliver: false,
},
timeoutMs: 15_000,
clientName: "cli",
mode: "cli",
});
if (typeof agentRes.runId !== "string" || agentRes.runId.trim().length === 0) {
return {
prompt,
ok: false,
transport: "gateway",
promptMode: GATEWAY_PROMPT_MODE,
error: `missing runId: ${JSON.stringify(agentRes)}`,
matchedExtraUsage400: false,
capture: summarizeCapture(proxy?.getLastCapture(), prompt),
tmpDir,
};
}
const waitRes = await callGateway({
url,
token: gatewayToken,
method: "agent.wait",
params: { runId: agentRes.runId, timeoutMs: GATEWAY_TIMEOUT_MS },
timeoutMs: GATEWAY_TIMEOUT_MS + 10_000,
clientName: "cli",
mode: "cli",
});
const text = extractPayloadText(waitRes);
const logTail = await readLogTail(logPath);
const matched400 = matchesExtraUsage400(waitRes.error, logTail, JSON.stringify(waitRes));
return {
prompt,
ok: waitRes.status === "ok" && !matched400,
transport: "gateway",
promptMode: GATEWAY_PROMPT_MODE,
status: waitRes.status,
text: text || undefined,
error: waitRes.status === "ok" ? undefined : waitRes.error || logTail || "agent.wait failed",
matchedExtraUsage400: matched400,
capture: summarizeCapture(proxy?.getLastCapture(), prompt),
tmpDir,
};
} finally {
await gateway.stop().catch(() => {});
await proxy?.stop().catch(() => {});
}
}
async function main() {
const prompts = PROMPT_LIST_JSON ? (JSON.parse(PROMPT_LIST_JSON) as string[]) : [PROMPT_TEXT];
const results: PromptResult[] = [];
for (const prompt of prompts) {
results.push(
TRANSPORT === "direct" ? await runDirectPrompt(prompt) : await runGatewayPrompt(prompt),
);
}
console.log(
JSON.stringify(
{
transport: TRANSPORT,
...(TRANSPORT === "gateway" ? { promptMode: GATEWAY_PROMPT_MODE } : {}),
capture: ENABLE_CAPTURE,
results,
},
null,
2,
),
);
}
await main();

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,89 @@
#!/bin/bash
# Auth Expiry Monitor
# Run via cron or systemd timer to get proactive notifications
# before Claude Code auth expires.
#
# Suggested cron: */30 * * * * /home/admin/openclaw/scripts/auth-monitor.sh
#
# Environment variables:
# NOTIFY_PHONE - Phone number to send OpenClaw notification (e.g., +1234567890)
# NOTIFY_NTFY - ntfy.sh topic for push notifications (e.g., openclaw-alerts)
# WARN_HOURS - Hours before expiry to warn (default: 2)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CLAUDE_CREDS="$HOME/.claude/.credentials.json"
STATE_FILE="$HOME/.openclaw/auth-monitor-state"
# Configuration
WARN_HOURS="${WARN_HOURS:-2}"
NOTIFY_PHONE="${NOTIFY_PHONE:-}"
NOTIFY_NTFY="${NOTIFY_NTFY:-}"
# State tracking to avoid spam
mkdir -p "$(dirname "$STATE_FILE")"
LAST_NOTIFIED=$(cat "$STATE_FILE" 2>/dev/null || echo "0")
NOW=$(date +%s)
# Only notify once per hour max
MIN_INTERVAL=3600
send_notification() {
local message="$1"
local priority="${2:-default}"
echo "$(date '+%Y-%m-%d %H:%M:%S') - $message"
# Check if we notified recently
if [ $((NOW - LAST_NOTIFIED)) -lt $MIN_INTERVAL ]; then
echo "Skipping notification (sent recently)"
return
fi
# Send via OpenClaw if phone configured and auth still valid
if [ -n "$NOTIFY_PHONE" ]; then
# Check if we can still use openclaw
if "$SCRIPT_DIR/claude-auth-status.sh" simple 2>/dev/null | grep -q "OK\|EXPIRING"; then
echo "Sending via OpenClaw to $NOTIFY_PHONE..."
openclaw send --to "$NOTIFY_PHONE" --message "$message" 2>/dev/null || true
fi
fi
# Send via ntfy.sh if configured
if [ -n "$NOTIFY_NTFY" ]; then
echo "Sending via ntfy.sh to $NOTIFY_NTFY..."
curl -s -o /dev/null \
-H "Title: OpenClaw Auth Alert" \
-H "Priority: $priority" \
-H "Tags: warning,key" \
-d "$message" \
"https://ntfy.sh/$NOTIFY_NTFY" || true
fi
# Update state
echo "$NOW" > "$STATE_FILE"
}
# Check auth status
if [ ! -f "$CLAUDE_CREDS" ]; then
send_notification "Claude Code credentials missing! Run: claude setup-token" "high"
exit 1
fi
EXPIRES_AT=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS")
NOW_MS=$((NOW * 1000))
DIFF_MS=$((EXPIRES_AT - NOW_MS))
HOURS_LEFT=$((DIFF_MS / 3600000))
MINS_LEFT=$(((DIFF_MS % 3600000) / 60000))
if [ "$DIFF_MS" -lt 0 ]; then
send_notification "Claude Code auth EXPIRED! OpenClaw is down. Run: ssh l36 '~/openclaw/scripts/mobile-reauth.sh'" "urgent"
exit 1
elif [ "$HOURS_LEFT" -lt "$WARN_HOURS" ]; then
send_notification "Claude Code auth expires in ${HOURS_LEFT}h ${MINS_LEFT}m. Consider re-auth soon." "high"
exit 0
else
echo "$(date '+%Y-%m-%d %H:%M:%S') - Auth OK: ${HOURS_LEFT}h ${MINS_LEFT}m remaining"
exit 0
fi

View file

@ -0,0 +1,520 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
type CommandCase = {
id: string;
name: string;
args: string[];
presets: readonly string[];
};
type Sample = {
ms: number;
maxRssMb: number | null;
exitCode: number | null;
signal: string | null;
};
type SummaryStats = {
avg: number;
p50: number;
p95: number;
min: number;
max: number;
};
type CaseSummary = {
sampleCount: number;
durationMs: SummaryStats;
maxRssMb: SummaryStats | null;
exitSummary: string;
};
type SuiteResult = {
entry: string;
cases: Array<{
id: string;
name: string;
args: string[];
samples: Sample[];
summary: CaseSummary;
}>;
};
type CliOptions = {
cases: CommandCase[];
entryPrimary: string;
entrySecondary?: string;
runs: number;
warmup: number;
timeoutMs: number;
json: boolean;
output?: string;
cpuProfDir?: string;
heapProfDir?: string;
};
const DEFAULT_RUNS = 5;
const DEFAULT_WARMUP = 1;
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_ENTRY = "openclaw.mjs";
const MAX_RSS_MARKER = "__OPENCLAW_MAX_RSS_KB__=";
const COMMAND_CASES: readonly CommandCase[] = [
{ id: "version", name: "--version", args: ["--version"], presets: ["startup"] },
{ id: "help", name: "--help", args: ["--help"], presets: ["startup"] },
{ id: "health", name: "health", args: ["health"], presets: ["startup", "real"] },
{ id: "healthJson", name: "health --json", args: ["health", "--json"], presets: ["startup"] },
{
id: "statusJson",
name: "status --json",
args: ["status", "--json"],
presets: ["startup", "real"],
},
{ id: "status", name: "status", args: ["status"], presets: ["startup", "real"] },
{ id: "sessions", name: "sessions", args: ["sessions"], presets: ["real"] },
{
id: "sessionsJson",
name: "sessions --json",
args: ["sessions", "--json"],
presets: ["real"],
},
{
id: "agentsListJson",
name: "agents list --json",
args: ["agents", "list", "--json"],
presets: ["real"],
},
{
id: "gatewayStatus",
name: "gateway status",
args: ["gateway", "status"],
presets: ["real"],
},
{
id: "gatewayStatusJson",
name: "gateway status --json",
args: ["gateway", "status", "--json"],
presets: ["real"],
},
{
id: "gatewayHealthJson",
name: "gateway health --json",
args: ["gateway", "health", "--json"],
presets: ["real"],
},
{
id: "configGetGatewayPort",
name: "config get gateway.port",
args: ["config", "get", "gateway.port"],
presets: ["real"],
},
] as const;
function parseFlagValue(flag: string): string | undefined {
const idx = process.argv.indexOf(flag);
if (idx === -1) {
return undefined;
}
return process.argv[idx + 1];
}
function hasFlag(flag: string): boolean {
return process.argv.includes(flag);
}
function parseRepeatableFlag(flag: string): string[] {
const values: string[] = [];
for (let i = 0; i < process.argv.length; i += 1) {
if (process.argv[i] === flag && process.argv[i + 1]) {
values.push(process.argv[i + 1]);
}
}
return values;
}
function parsePositiveInt(raw: string | undefined, fallback: number): number {
if (!raw) {
return fallback;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
return fallback;
}
return parsed;
}
function parsePresets(raw: string | undefined): string[] {
if (!raw) {
return ["startup"];
}
const values = raw
.split(",")
.map((value) => value.trim())
.filter(Boolean);
if (values.includes("all")) {
return ["startup", "real"];
}
return values.length > 0 ? values : ["startup"];
}
function resolveCases(options: { presets: string[]; caseIds: string[] }): CommandCase[] {
const byId = new Map(COMMAND_CASES.map((commandCase) => [commandCase.id, commandCase]));
if (options.caseIds.length > 0) {
return options.caseIds.map((id) => {
const commandCase = byId.get(id);
if (!commandCase) {
throw new Error(`Unknown --case "${id}"`);
}
return commandCase;
});
}
return COMMAND_CASES.filter((commandCase) =>
commandCase.presets.some((preset) => options.presets.includes(preset)),
);
}
function median(values: number[]): number {
if (values.length === 0) {
return 0;
}
const sorted = [...values].toSorted((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 0) {
return (sorted[mid - 1] + sorted[mid]) / 2;
}
return sorted[mid];
}
function percentile(values: number[], p: number): number {
if (values.length === 0) {
return 0;
}
const sorted = [...values].toSorted((a, b) => a - b);
const index = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length));
return sorted[index] ?? 0;
}
function summarizeNumbers(values: number[]): SummaryStats {
const total = values.reduce((sum, value) => sum + value, 0);
const avg = values.length > 0 ? total / values.length : 0;
const min = values.length > 0 ? Math.min(...values) : 0;
const max = values.length > 0 ? Math.max(...values) : 0;
return {
avg,
p50: median(values),
p95: percentile(values, 95),
min,
max,
};
}
function summarizeSamples(samples: Sample[]): CaseSummary {
const durations = summarizeNumbers(samples.map((sample) => sample.ms));
const rssValues = samples
.map((sample) => sample.maxRssMb)
.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
return {
sampleCount: samples.length,
durationMs: durations,
maxRssMb: rssValues.length > 0 ? summarizeNumbers(rssValues) : null,
exitSummary: collectExitSummary(samples),
};
}
function formatMs(value: number): string {
return `${value.toFixed(1)}ms`;
}
function formatMb(value: number): string {
return `${value.toFixed(1)}MB`;
}
function collectExitSummary(samples: Sample[]): string {
const buckets = new Map<string, number>();
for (const sample of samples) {
const key =
sample.signal != null
? `signal:${sample.signal}`
: `code:${sample.exitCode == null ? "null" : String(sample.exitCode)}`;
buckets.set(key, (buckets.get(key) ?? 0) + 1);
}
return [...buckets.entries()].map(([key, count]) => `${key}x${count}`).join(", ");
}
function buildRssHook(tmpDir: string): string {
const rssHookPath = path.join(tmpDir, "measure-rss.mjs");
writeFileSync(
rssHookPath,
[
"process.on('exit', () => {",
" const usage = typeof process.resourceUsage === 'function' ? process.resourceUsage() : null;",
` if (usage && typeof usage.maxRSS === 'number') console.error('${MAX_RSS_MARKER}' + String(usage.maxRSS));`,
"});",
"",
].join("\n"),
"utf8",
);
return rssHookPath;
}
function parseMaxRssMb(stderr: string): number | null {
const matches = [...stderr.matchAll(new RegExp(`^${MAX_RSS_MARKER}(\\d+)\\s*$`, "gm"))];
const lastMatch = matches.at(-1);
if (!lastMatch?.[1]) {
return null;
}
return Number(lastMatch[1]) / 1024;
}
function buildCpuOrHeapFlags(options: { cpuProfDir?: string; heapProfDir?: string }): string[] {
const flags: string[] = [];
if (options.cpuProfDir) {
flags.push("--cpu-prof", "--cpu-prof-dir", options.cpuProfDir);
}
if (options.heapProfDir) {
flags.push("--heap-prof", "--heap-prof-dir", options.heapProfDir);
}
return flags;
}
function runCase(params: {
entry: string;
commandCase: CommandCase;
runs: number;
warmup: number;
timeoutMs: number;
cpuProfDir?: string;
heapProfDir?: string;
rssHookPath: string;
}): Sample[] {
const samples: Sample[] = [];
const totalRuns = params.warmup + params.runs;
for (let i = 0; i < totalRuns; i += 1) {
const nodeArgs = [
"--import",
params.rssHookPath,
...buildCpuOrHeapFlags({
cpuProfDir: params.cpuProfDir,
heapProfDir: params.heapProfDir,
}),
params.entry,
...params.commandCase.args,
];
const started = process.hrtime.bigint();
const proc = spawnSync(process.execPath, nodeArgs, {
cwd: process.cwd(),
env: {
...process.env,
OPENCLAW_HIDE_BANNER: "1",
},
stdio: ["ignore", "ignore", "pipe"],
encoding: "utf8",
timeout: params.timeoutMs,
maxBuffer: 32 * 1024 * 1024,
});
const ms = Number(process.hrtime.bigint() - started) / 1e6;
if (i < params.warmup) {
continue;
}
samples.push({
ms,
maxRssMb: parseMaxRssMb(proc.stderr ?? ""),
exitCode: proc.status,
signal: proc.signal,
});
}
return samples;
}
function printSuite(result: SuiteResult): void {
console.log(`Entry: ${result.entry}`);
for (const commandCase of result.cases) {
const { durationMs, maxRssMb, exitSummary } = commandCase.summary;
const rssSummary =
maxRssMb == null
? "rss=n/a"
: `rss(avg=${formatMb(maxRssMb.avg)} p50=${formatMb(maxRssMb.p50)} p95=${formatMb(maxRssMb.p95)})`;
console.log(
`${commandCase.name.padEnd(24)} avg=${formatMs(durationMs.avg)} p50=${formatMs(
durationMs.p50,
)} p95=${formatMs(durationMs.p95)} min=${formatMs(durationMs.min)} max=${formatMs(
durationMs.max,
)} ${rssSummary} exits=[${exitSummary}]`,
);
}
console.log("");
}
function printDelta(primary: SuiteResult, secondary: SuiteResult): void {
const primaryById = new Map(primary.cases.map((commandCase) => [commandCase.id, commandCase]));
console.log("Delta (secondary - primary, avg)");
for (const commandCase of secondary.cases) {
const baseline = primaryById.get(commandCase.id);
if (!baseline) {
continue;
}
const durationDelta = commandCase.summary.durationMs.avg - baseline.summary.durationMs.avg;
const durationPct =
baseline.summary.durationMs.avg > 0
? (durationDelta / baseline.summary.durationMs.avg) * 100
: 0;
const durationSign = durationDelta > 0 ? "+" : "";
let line = `${commandCase.name.padEnd(24)} ${durationSign}${formatMs(durationDelta)} (${durationSign}${durationPct.toFixed(1)}%)`;
if (baseline.summary.maxRssMb && commandCase.summary.maxRssMb) {
const rssDelta = commandCase.summary.maxRssMb.avg - baseline.summary.maxRssMb.avg;
const rssPct =
baseline.summary.maxRssMb.avg > 0 ? (rssDelta / baseline.summary.maxRssMb.avg) * 100 : 0;
const rssSign = rssDelta > 0 ? "+" : "";
line += ` rss ${rssSign}${formatMb(rssDelta)} (${rssSign}${rssPct.toFixed(1)}%)`;
}
console.log(line);
}
}
function buildSuiteResult(params: {
entry: string;
options: CliOptions;
rssHookPath: string;
}): SuiteResult {
const cases = params.options.cases.map((commandCase) => {
const samples = runCase({
entry: params.entry,
commandCase,
runs: params.options.runs,
warmup: params.options.warmup,
timeoutMs: params.options.timeoutMs,
cpuProfDir: params.options.cpuProfDir,
heapProfDir: params.options.heapProfDir,
rssHookPath: params.rssHookPath,
});
return {
id: commandCase.id,
name: commandCase.name,
args: commandCase.args,
samples,
summary: summarizeSamples(samples),
};
});
return {
entry: params.entry,
cases,
};
}
function parseOptions(): CliOptions {
const presets = parsePresets(parseFlagValue("--preset"));
const cases = resolveCases({
presets,
caseIds: parseRepeatableFlag("--case"),
});
return {
cases,
entryPrimary: parseFlagValue("--entry-primary") ?? parseFlagValue("--entry") ?? DEFAULT_ENTRY,
entrySecondary: parseFlagValue("--entry-secondary"),
runs: parsePositiveInt(parseFlagValue("--runs"), DEFAULT_RUNS),
warmup: parsePositiveInt(parseFlagValue("--warmup"), DEFAULT_WARMUP),
timeoutMs: parsePositiveInt(parseFlagValue("--timeout-ms"), DEFAULT_TIMEOUT_MS),
json: hasFlag("--json"),
output: parseFlagValue("--output"),
cpuProfDir: parseFlagValue("--cpu-prof-dir"),
heapProfDir: parseFlagValue("--heap-prof-dir"),
};
}
function printUsage(): void {
console.log(`OpenClaw CLI benchmark
Usage:
pnpm tsx scripts/bench-cli-startup.ts [options]
Options:
--preset <startup|real|all> Command preset to run (default: startup)
--case <id> Specific case id to run; repeatable
--entry <path> Primary entry file (default: openclaw.mjs)
--entry-secondary <path> Secondary entry file for avg delta comparison
--runs <n> Measured runs per case (default: ${DEFAULT_RUNS})
--warmup <n> Warmup runs per case (default: ${DEFAULT_WARMUP})
--timeout-ms <ms> Per-run timeout (default: ${DEFAULT_TIMEOUT_MS})
--output <path> Write machine-readable JSON to a file
--cpu-prof-dir <dir> Write V8 CPU profiles for each run
--heap-prof-dir <dir> Write V8 heap profiles for each run
--json Emit machine-readable JSON
--help Show this text
Case ids:
${COMMAND_CASES.map((commandCase) => `${commandCase.id} (${commandCase.name})`).join("\n ")}
`);
}
async function main(): Promise<void> {
if (hasFlag("--help")) {
printUsage();
return;
}
const options = parseOptions();
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-bench-"));
const rssHookPath = buildRssHook(tmpDir);
try {
const primary = buildSuiteResult({
entry: options.entryPrimary,
options,
rssHookPath,
});
const secondary = options.entrySecondary
? buildSuiteResult({
entry: options.entrySecondary,
options,
rssHookPath,
})
: undefined;
const report = {
node: process.version,
runs: options.runs,
warmup: options.warmup,
timeoutMs: options.timeoutMs,
cpuProfDir: options.cpuProfDir ?? null,
heapProfDir: options.heapProfDir ?? null,
primary,
secondary: secondary ?? null,
};
if (options.output) {
mkdirSync(path.dirname(options.output), { recursive: true });
writeFileSync(options.output, `${JSON.stringify(report, null, 2)}\n`, "utf8");
}
if (options.json) {
console.log(JSON.stringify(report, null, 2));
return;
}
console.log(`Node: ${process.version}`);
console.log(`Runs per case: ${options.runs}`);
console.log(`Warmup runs per case: ${options.warmup}`);
console.log(`Timeout: ${options.timeoutMs}ms`);
if (options.cpuProfDir) {
console.log(`CPU profiles: ${options.cpuProfDir}`);
}
if (options.heapProfDir) {
console.log(`Heap profiles: ${options.heapProfDir}`);
}
console.log("");
console.log("Primary entry");
printSuite(primary);
if (secondary) {
console.log("Secondary entry");
printSuite(secondary);
printDelta(primary, secondary);
}
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
}
await main();

View file

@ -0,0 +1,145 @@
import { completeSimple, getModel, type Api, type Model } from "@mariozechner/pi-ai";
type Usage = {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
totalTokens?: number;
};
type RunResult = {
durationMs: number;
usage?: Usage;
};
const DEFAULT_PROMPT = "Reply with a single word: ok. No punctuation or extra text.";
const DEFAULT_RUNS = 10;
function parseArg(flag: string): string | undefined {
const idx = process.argv.indexOf(flag);
if (idx === -1) {
return undefined;
}
return process.argv[idx + 1];
}
function parseRuns(raw: string | undefined): number {
if (!raw) {
return DEFAULT_RUNS;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_RUNS;
}
return Math.floor(parsed);
}
function median(values: number[]): number {
if (values.length === 0) {
return 0;
}
const sorted = [...values].toSorted((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 0) {
return Math.round((sorted[mid - 1] + sorted[mid]) / 2);
}
return sorted[mid];
}
async function runModel(opts: {
label: string;
model: Model<Api>;
apiKey: string;
runs: number;
prompt: string;
}): Promise<RunResult[]> {
const results: RunResult[] = [];
for (let i = 0; i < opts.runs; i += 1) {
const started = Date.now();
const res = await completeSimple(
opts.model,
{
messages: [
{
role: "user",
content: opts.prompt,
timestamp: Date.now(),
},
],
},
{ apiKey: opts.apiKey, maxTokens: 64 },
);
const durationMs = Date.now() - started;
results.push({ durationMs, usage: res.usage });
console.log(`${opts.label} run ${i + 1}/${opts.runs}: ${durationMs}ms`);
}
return results;
}
async function main(): Promise<void> {
const runs = parseRuns(parseArg("--runs"));
const prompt = parseArg("--prompt") ?? DEFAULT_PROMPT;
const anthropicKey = process.env.ANTHROPIC_API_KEY?.trim();
const minimaxKey = process.env.MINIMAX_API_KEY?.trim();
if (!anthropicKey) {
throw new Error("Missing ANTHROPIC_API_KEY in environment.");
}
if (!minimaxKey) {
throw new Error("Missing MINIMAX_API_KEY in environment.");
}
const minimaxBaseUrl = process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/v1";
const minimaxModelId = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.1";
const minimaxModel: Model<"openai-completions"> = {
id: minimaxModelId,
name: `MiniMax ${minimaxModelId}`,
api: "openai-completions",
provider: "minimax",
baseUrl: minimaxBaseUrl,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
};
const opusModel = getModel("anthropic", "claude-opus-4-6");
console.log(`Prompt: ${prompt}`);
console.log(`Runs: ${runs}`);
console.log("");
const minimaxResults = await runModel({
label: "minimax",
model: minimaxModel,
apiKey: minimaxKey,
runs,
prompt,
});
const opusResults = await runModel({
label: "opus",
model: opusModel,
apiKey: anthropicKey,
runs,
prompt,
});
const summarize = (label: string, results: RunResult[]) => {
const durations = results.map((r) => r.durationMs);
const med = median(durations);
const min = Math.min(...durations);
const max = Math.max(...durations);
return { label, med, min, max };
};
const summary = [summarize("minimax", minimaxResults), summarize("opus", opusResults)];
console.log("");
console.log("Summary (ms):");
for (const row of summary) {
console.log(`${row.label.padEnd(7)} median=${row.med} min=${row.min} max=${row.max}`);
}
}
await main();

View file

@ -0,0 +1,187 @@
import { spawnSync } from "node:child_process";
import path from "node:path";
import { floatFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mjs";
import { formatMs } from "./lib/vitest-report-cli-utils.mjs";
function parseArgs(argv) {
const args = parseFlagArgs(
argv,
{
cwd: process.cwd(),
ref: "origin/main",
rss: process.platform === "darwin",
mode: "ref",
},
[
stringFlag("--cwd", "cwd"),
stringFlag("--ref", "ref"),
floatFlag("--max-workers", "maxWorkers", { min: 1 }),
],
{
allowUnknownOptions: true,
onUnhandledArg(arg, target) {
if (arg === "--no-rss") {
target.rss = false;
return "handled";
}
if (arg === "--worktree") {
target.mode = "worktree";
return "handled";
}
return undefined;
},
},
);
return {
cwd: path.resolve(args.cwd),
mode: args.mode,
ref: args.ref,
rss: args.rss,
...(typeof args.maxWorkers === "number" ? { maxWorkers: Math.trunc(args.maxWorkers) } : {}),
};
}
function quoteArg(arg) {
return /[^A-Za-z0-9_./:-]/.test(arg) ? JSON.stringify(arg) : arg;
}
function runGitList(args, cwd) {
const result = spawnSync("git", args, {
cwd,
encoding: "utf8",
});
if (result.status !== 0) {
throw new Error(result.stderr || result.stdout || `git ${args.join(" ")} failed`);
}
return result.stdout
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
}
function listChangedPaths(opts) {
if (opts.mode === "worktree") {
return [
...new Set([
...runGitList(["diff", "--name-only", "--relative", "HEAD", "--"], opts.cwd),
...runGitList(["ls-files", "--others", "--exclude-standard"], opts.cwd),
]),
].toSorted((left, right) => left.localeCompare(right));
}
return runGitList(["diff", "--name-only", `${opts.ref}...HEAD`], opts.cwd);
}
function parseMaxRssKb(output) {
const match = output.match(/(\d+)\s+maximum resident set size/u);
return match ? Number.parseInt(match[1], 10) : null;
}
function formatRss(valueKb) {
if (valueKb === null) {
return "n/a";
}
return `${(valueKb / 1024).toFixed(1)}MB`;
}
function runBenchCommand(params) {
const env = { ...process.env };
if (typeof params.maxWorkers === "number") {
env.OPENCLAW_VITEST_MAX_WORKERS = String(params.maxWorkers);
}
const startedAt = process.hrtime.bigint();
const commandArgs = params.rss ? ["-l", ...params.command] : params.command;
const result = spawnSync(
params.rss ? "/usr/bin/time" : commandArgs[0],
params.rss ? commandArgs : commandArgs.slice(1),
{
cwd: params.cwd,
env,
encoding: "utf8",
maxBuffer: 1024 * 1024 * 32,
},
);
const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
return {
elapsedMs,
maxRssKb: params.rss ? parseMaxRssKb(output) : null,
status: result.status ?? 1,
output,
};
}
function printRunSummary(label, result) {
console.log(
`${label.padEnd(8, " ")} wall=${formatMs(result.elapsedMs).padStart(9, " ")} rss=${formatRss(
result.maxRssKb,
).padStart(9, " ")}`,
);
}
const opts = parseArgs(process.argv.slice(2));
const changedPaths = listChangedPaths(opts);
if (changedPaths.length === 0) {
console.log(
opts.mode === "worktree"
? "[bench-test-changed] no changed paths in worktree"
: `[bench-test-changed] no changed paths for ${opts.ref}...HEAD`,
);
process.exit(0);
}
console.log(
opts.mode === "worktree"
? "[bench-test-changed] mode=worktree"
: `[bench-test-changed] ref=${opts.ref}`,
);
console.log("[bench-test-changed] changed paths:");
for (const changedPath of changedPaths) {
console.log(`- ${changedPath}`);
}
const routedCommand =
opts.mode === "worktree"
? [process.execPath, "scripts/test-projects.mjs", ...changedPaths]
: [process.execPath, "scripts/test-projects.mjs", "--changed", opts.ref];
const rootCommand = [
process.execPath,
"scripts/run-vitest.mjs",
"run",
"--config",
"vitest.config.ts",
...changedPaths,
];
console.log(`[bench-test-changed] routed: ${routedCommand.map(quoteArg).join(" ")}`);
const routed = runBenchCommand({
command: routedCommand,
cwd: opts.cwd,
rss: opts.rss,
...(typeof opts.maxWorkers === "number" ? { maxWorkers: opts.maxWorkers } : {}),
});
if (routed.status !== 0) {
process.stderr.write(routed.output);
process.exit(routed.status);
}
console.log(`[bench-test-changed] root: ${rootCommand.map(quoteArg).join(" ")}`);
const root = runBenchCommand({
command: rootCommand,
cwd: opts.cwd,
rss: opts.rss,
...(typeof opts.maxWorkers === "number" ? { maxWorkers: opts.maxWorkers } : {}),
});
if (root.status !== 0) {
process.stderr.write(root.output);
process.exit(root.status);
}
printRunSummary("routed", routed);
printRunSummary("root", root);
console.log(
`[bench-test-changed] delta wall=${formatMs(root.elapsedMs - routed.elapsedMs)} rss=${
routed.maxRssKb !== null && root.maxRssKb !== null
? formatRss(root.maxRssKb - routed.maxRssKb)
: "n/a"
}`,
);

View file

@ -0,0 +1,388 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { resolvePnpmRunner } from "./pnpm-runner.mjs";
const nodeBin = process.execPath;
const WINDOWS_BUILD_MAX_OLD_SPACE_MB = 4096;
const BUILD_CACHE_VERSION = 2;
export const BUILD_ALL_STEPS = [
{ label: "canvas:a2ui:bundle", kind: "pnpm", pnpmArgs: ["canvas:a2ui:bundle"] },
{ label: "tsdown", kind: "node", args: ["scripts/tsdown-build.mjs"] },
{ label: "runtime-postbuild", kind: "node", args: ["scripts/runtime-postbuild.mjs"] },
{
label: "write-npm-update-compat-sidecars",
kind: "node",
args: ["--import", "tsx", "scripts/write-npm-update-compat-sidecars.ts"],
cache: {
inputs: [
"scripts/write-npm-update-compat-sidecars.ts",
"src/infra/npm-update-compat-sidecars.ts",
],
outputs: [
"dist/extensions/qa-channel/runtime-api.js",
"dist/extensions/qa-lab/runtime-api.js",
],
},
},
{ label: "build-stamp", kind: "node", args: ["scripts/build-stamp.mjs"] },
{
label: "build:plugin-sdk:dts",
kind: "pnpm",
pnpmArgs: ["build:plugin-sdk:dts"],
windowsNodeOptions: `--max-old-space-size=${WINDOWS_BUILD_MAX_OLD_SPACE_MB}`,
cache: {
inputs: [
"tsconfig.json",
"tsconfig.plugin-sdk.dts.json",
"src/plugin-sdk",
"src/types",
"src/video-generation/dashscope-compatible.ts",
"src/video-generation/types.ts",
],
outputs: ["dist/plugin-sdk/.tsbuildinfo", "dist/plugin-sdk/src"],
},
},
{
label: "write-plugin-sdk-entry-dts",
kind: "node",
args: ["--import", "tsx", "scripts/write-plugin-sdk-entry-dts.ts"],
cache: {
inputs: [
"scripts/write-plugin-sdk-entry-dts.ts",
"scripts/lib/plugin-sdk-entrypoints.json",
"dist/plugin-sdk/src/plugin-sdk",
],
outputs: ["dist/plugin-sdk", "packages/plugin-sdk/dist/src/plugin-sdk"],
},
},
{
label: "check-plugin-sdk-exports",
kind: "node",
args: ["scripts/check-plugin-sdk-exports.mjs"],
},
{
label: "canvas-a2ui-copy",
kind: "node",
args: ["--import", "tsx", "scripts/canvas-a2ui-copy.ts"],
cache: {
inputs: ["scripts/canvas-a2ui-copy.ts", "src/canvas-host/a2ui"],
outputs: ["dist/canvas-host/a2ui/index.html", "dist/canvas-host/a2ui/a2ui.bundle.js"],
},
},
{
label: "copy-hook-metadata",
kind: "node",
args: ["--import", "tsx", "scripts/copy-hook-metadata.ts"],
cache: {
inputs: ["scripts/copy-hook-metadata.ts", "scripts/lib/copy-assets.ts", "src/hooks/bundled"],
outputs: ["dist/bundled"],
},
},
{
label: "copy-export-html-templates",
kind: "node",
args: ["--import", "tsx", "scripts/copy-export-html-templates.ts"],
cache: {
inputs: [
"scripts/copy-export-html-templates.ts",
"scripts/lib/copy-assets.ts",
"src/auto-reply/reply/export-html",
],
outputs: ["dist/export-html"],
},
},
{
label: "write-build-info",
kind: "node",
args: ["--import", "tsx", "scripts/write-build-info.ts"],
},
{
label: "write-cli-startup-metadata",
kind: "node",
args: ["--experimental-strip-types", "scripts/write-cli-startup-metadata.ts"],
},
{
label: "write-cli-compat",
kind: "node",
args: ["--import", "tsx", "scripts/write-cli-compat.ts"],
},
];
export const BUILD_ALL_PROFILES = {
full: BUILD_ALL_STEPS.map((step) => step.label),
ciArtifacts: [
"canvas:a2ui:bundle",
"tsdown",
"runtime-postbuild",
"write-npm-update-compat-sidecars",
"build-stamp",
"canvas-a2ui-copy",
"copy-hook-metadata",
"copy-export-html-templates",
"write-build-info",
"write-cli-startup-metadata",
"write-cli-compat",
],
};
export function resolveBuildAllSteps(profile = "full") {
const labels = BUILD_ALL_PROFILES[profile];
if (!labels) {
throw new Error(`Unknown build profile: ${profile}`);
}
const selected = labels.map((label) => BUILD_ALL_STEPS.find((step) => step.label === label));
if (selected.some((step) => !step)) {
const missing = labels.filter((label) => !BUILD_ALL_STEPS.some((step) => step.label === label));
throw new Error(`Build profile ${profile} references unknown steps: ${missing.join(", ")}`);
}
return selected;
}
function resolveStepEnv(step, env, platform) {
if (platform !== "win32" || !step.windowsNodeOptions) {
return env;
}
const currentNodeOptions = env.NODE_OPTIONS?.trim() ?? "";
if (currentNodeOptions.includes(step.windowsNodeOptions)) {
return env;
}
return {
...env,
NODE_OPTIONS: currentNodeOptions
? `${currentNodeOptions} ${step.windowsNodeOptions}`
: step.windowsNodeOptions,
};
}
export function resolveBuildAllStep(step, params = {}) {
const platform = params.platform ?? process.platform;
const env = resolveStepEnv(step, params.env ?? process.env, platform);
if (step.kind === "pnpm") {
const runner = resolvePnpmRunner({
pnpmArgs: step.pnpmArgs,
nodeExecPath: params.nodeExecPath ?? nodeBin,
npmExecPath: params.npmExecPath ?? env.npm_execpath,
comSpec: params.comSpec ?? env.ComSpec,
platform,
});
return {
command: runner.command,
args: runner.args,
options: {
stdio: "inherit",
env,
shell: runner.shell,
windowsVerbatimArguments: runner.windowsVerbatimArguments,
},
};
}
return {
command: params.nodeExecPath ?? nodeBin,
args: step.args,
options: {
stdio: "inherit",
env,
},
};
}
function listFilesRecursively(rootPath, fsImpl) {
let stat;
try {
stat = fsImpl.statSync(rootPath);
} catch {
return [];
}
if (stat.isFile()) {
return [rootPath];
}
if (!stat.isDirectory()) {
return [];
}
const out = [];
const entries = fsImpl.readdirSync(rootPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === ".DS_Store") {
continue;
}
const entryPath = path.join(rootPath, entry.name);
if (entry.isDirectory()) {
out.push(...listFilesRecursively(entryPath, fsImpl));
} else if (entry.isFile()) {
out.push(entryPath);
}
}
return out;
}
function listCacheFiles(rootDir, entries, fsImpl) {
return entries
.flatMap((entry) => listFilesRecursively(path.resolve(rootDir, entry), fsImpl))
.toSorted();
}
function resolveCachePaths(rootDir, step) {
const safeLabel = step.label.replace(/[^a-zA-Z0-9._-]+/g, "_");
const cacheDir = path.resolve(rootDir, ".artifacts/build-all-cache", safeLabel);
return {
cacheDir,
outputRoot: path.join(cacheDir, "outputs"),
stampPath: path.join(cacheDir, "stamp.json"),
};
}
function hashInputFiles(rootDir, files, fsImpl) {
const hash = createHash("sha256");
hash.update(`v${BUILD_CACHE_VERSION}\0`);
for (const file of files) {
hash.update(path.relative(rootDir, file));
hash.update("\0");
hash.update(fsImpl.readFileSync(file));
hash.update("\0");
}
return hash.digest("hex");
}
function readCacheStamp(stampPath, fsImpl) {
try {
return JSON.parse(fsImpl.readFileSync(stampPath, "utf8"));
} catch {
return undefined;
}
}
function hasAllFiles(rootDir, relativeFiles, fsImpl) {
return relativeFiles.every((relativeFile) => {
try {
return fsImpl.statSync(path.resolve(rootDir, relativeFile)).isFile();
} catch {
return false;
}
});
}
function copyFileSync(fsImpl, sourcePath, targetPath) {
fsImpl.mkdirSync(path.dirname(targetPath), { recursive: true });
fsImpl.copyFileSync(sourcePath, targetPath);
}
export function resolveBuildAllStepCacheState(step, params = {}) {
if (!step.cache) {
return { cacheable: false, fresh: false, reason: "no-cache" };
}
const rootDir = params.rootDir ?? process.cwd();
const fsImpl = params.fs ?? fs;
const inputFiles = listCacheFiles(rootDir, step.cache.inputs, fsImpl);
if (inputFiles.length === 0) {
return { cacheable: true, fresh: false, reason: "missing-inputs" };
}
const signature = hashInputFiles(rootDir, inputFiles, fsImpl);
const { outputRoot, stampPath } = resolveCachePaths(rootDir, step);
const stamp = readCacheStamp(stampPath, fsImpl);
const outputFiles = listCacheFiles(rootDir, step.cache.outputs, fsImpl);
const relativeOutputFiles = outputFiles.map((file) => path.relative(rootDir, file));
const stampedOutputs = Array.isArray(stamp?.outputs) ? stamp.outputs : [];
const stampMatches = stamp?.version === BUILD_CACHE_VERSION && stamp.signature === signature;
const actualOutputsPresent =
stampedOutputs.length > 0 && hasAllFiles(rootDir, stampedOutputs, fsImpl);
const cachedOutputsPresent =
stampedOutputs.length > 0 && hasAllFiles(outputRoot, stampedOutputs, fsImpl);
const restorable = stampMatches && !actualOutputsPresent && cachedOutputsPresent;
const fresh = stampMatches && (actualOutputsPresent || cachedOutputsPresent);
return {
cacheable: true,
fresh,
restorable,
reason: fresh ? (restorable ? "fresh-cache" : "fresh") : "stale",
signature,
outputRoot,
stampPath,
inputFiles: inputFiles.length,
outputFiles: outputFiles.length,
relativeOutputFiles,
stampedOutputs,
};
}
export function writeBuildAllStepCacheStamp(step, cacheState, params = {}) {
if (
!cacheState.cacheable ||
!cacheState.signature ||
!cacheState.stampPath ||
!cacheState.outputRoot ||
!cacheState.relativeOutputFiles?.length
) {
return;
}
const fsImpl = params.fs ?? fs;
const rootDir = params.rootDir ?? process.cwd();
for (const relativeFile of cacheState.relativeOutputFiles) {
copyFileSync(
fsImpl,
path.resolve(rootDir, relativeFile),
path.resolve(cacheState.outputRoot, relativeFile),
);
}
fsImpl.mkdirSync(path.dirname(cacheState.stampPath), { recursive: true });
fsImpl.writeFileSync(
cacheState.stampPath,
`${JSON.stringify({
version: BUILD_CACHE_VERSION,
label: step.label,
signature: cacheState.signature,
outputs: cacheState.relativeOutputFiles,
})}\n`,
);
}
export function restoreBuildAllStepCacheOutputs(cacheState, params = {}) {
if (!cacheState.restorable || !cacheState.outputRoot || !cacheState.stampedOutputs?.length) {
return false;
}
const fsImpl = params.fs ?? fs;
const rootDir = params.rootDir ?? process.cwd();
for (const relativeFile of cacheState.stampedOutputs) {
copyFileSync(
fsImpl,
path.resolve(cacheState.outputRoot, relativeFile),
path.resolve(rootDir, relativeFile),
);
}
return true;
}
function isMainModule() {
const argv1 = process.argv[1];
if (!argv1) {
return false;
}
return import.meta.url === pathToFileURL(argv1).href;
}
if (isMainModule()) {
const profile = process.argv[2] ?? "full";
for (const step of resolveBuildAllSteps(profile)) {
const cacheState = resolveBuildAllStepCacheState(step);
if (process.env.OPENCLAW_BUILD_CACHE !== "0" && cacheState.fresh) {
restoreBuildAllStepCacheOutputs(cacheState);
console.error(`[build-all] ${step.label} (cached)`);
continue;
}
console.error(`[build-all] ${step.label}`);
const invocation = resolveBuildAllStep(step);
const result = spawnSync(invocation.command, invocation.args, invocation.options);
if (typeof result.status === "number") {
if (result.status !== 0) {
process.exit(result.status);
}
writeBuildAllStepCacheStamp(step, resolveBuildAllStepCacheState(step));
continue;
}
process.exit(1);
}
}

View file

@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/../apps/macos"
BUILD_PATH=".build-local"
PRODUCT="OpenClaw"
BIN="$BUILD_PATH/debug/$PRODUCT"
printf "\n▶️ Building $PRODUCT (debug, build path: $BUILD_PATH)\n"
swift build -c debug --product "$PRODUCT" --build-path "$BUILD_PATH"
printf "\n⏹ Stopping existing $PRODUCT...\n"
killall -q "$PRODUCT" 2>/dev/null || true
printf "\n🚀 Launching $BIN ...\n"
nohup "$BIN" >/tmp/openclaw.log 2>&1 &
PID=$!
printf "Started $PRODUCT (PID $PID). Logs: /tmp/openclaw.log\n"

View file

@ -0,0 +1,14 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const binDir = path.join(root, "bin");
const binPath = path.join(binDir, "docs-list");
fs.mkdirSync(binDir, { recursive: true });
const wrapper = `#!/usr/bin/env node\nimport { spawnSync } from "node:child_process";\nimport path from "node:path";\nimport { fileURLToPath } from "node:url";\n\nconst here = path.dirname(fileURLToPath(import.meta.url));\nconst script = path.join(here, "..", "scripts", "docs-list.js");\n\nconst result = spawnSync(process.execPath, [script], { stdio: "inherit" });\nprocess.exit(result.status ?? 1);\n`;
fs.writeFileSync(binPath, wrapper, { mode: 0o755 });

View file

@ -0,0 +1,22 @@
export function resolveGitHead(params?: {
cwd?: string;
spawnSync?: (
cmd: string,
args: string[],
options: unknown,
) => { status: number | null; stdout?: string | null };
}): string | null;
export function writeBuildStamp(params?: {
cwd?: string;
fs?: {
mkdirSync(path: string, options?: { recursive?: boolean }): void;
writeFileSync(path: string, data: string, encoding?: string): void;
};
now?: () => number;
spawnSync?: (
cmd: string,
args: string[],
options: unknown,
) => { status: number | null; stdout?: string | null };
}): string;

View file

@ -0,0 +1,50 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
export function resolveGitHead(params = {}) {
const cwd = params.cwd ?? process.cwd();
const spawnSyncImpl = params.spawnSync ?? spawnSync;
try {
const result = spawnSyncImpl("git", ["rev-parse", "HEAD"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
if (result.status !== 0) {
return null;
}
const head = (result.stdout ?? "").trim();
return head || null;
} catch {
return null;
}
}
export function writeBuildStamp(params = {}) {
const cwd = params.cwd ?? process.cwd();
const fsImpl = params.fs ?? fs;
const now = params.now ?? Date.now;
const distRoot = path.join(cwd, "dist");
const buildStampPath = path.join(distRoot, ".buildstamp");
const head = resolveGitHead({
cwd,
spawnSync: params.spawnSync,
});
fsImpl.mkdirSync(distRoot, { recursive: true });
fsImpl.writeFileSync(buildStampPath, `${JSON.stringify({ builtAt: now(), head })}\n`, "utf8");
return buildStampPath;
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
writeBuildStamp();
} catch (error) {
console.error(error);
process.exit(1);
}
}

View file

@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -euo pipefail
# Render the macOS .icon bundle to a padded .icns like Trimmy's pipeline.
# Defaults target the OpenClaw assets so you can just run the script from repo root.
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
ICON_FILE=${1:-"$ROOT_DIR/apps/macos/Icon.icon"}
BASENAME=${2:-OpenClaw}
OUT_ROOT=${3:-"$ROOT_DIR/apps/macos/build/icon"}
XCODE_APP=${XCODE_APP:-/Applications/Xcode.app}
# Where the final .icns should live; override DEST_ICNS to change.
DEST_ICNS=${DEST_ICNS:-"$ROOT_DIR/apps/macos/Sources/OpenClaw/Resources/OpenClaw.icns"}
ICTOOL="$XCODE_APP/Contents/Applications/Icon Composer.app/Contents/Executables/ictool"
if [[ ! -x "$ICTOOL" ]]; then
ICTOOL="$XCODE_APP/Contents/Applications/Icon Composer.app/Contents/Executables/icontool"
fi
if [[ ! -x "$ICTOOL" ]]; then
echo "ictool/icontool not found. Set XCODE_APP if Xcode is elsewhere." >&2
exit 1
fi
ICONSET_DIR="$OUT_ROOT/${BASENAME}.iconset"
TMP_DIR="$OUT_ROOT/tmp"
mkdir -p "$ICONSET_DIR" "$TMP_DIR"
MASTER_ART="$TMP_DIR/icon_art_824.png"
MASTER_1024="$TMP_DIR/icon_1024.png"
# Render inner art (no margin) with macOS Default appearance
"$ICTOOL" "$ICON_FILE" \
--export-preview macOS Default 824 824 1 -45 "$MASTER_ART"
# Pad to 1024x1024 with transparent border
sips --padToHeightWidth 1024 1024 "$MASTER_ART" --out "$MASTER_1024" >/dev/null
# Generate required sizes
sizes=(16 32 64 128 256 512 1024)
for sz in "${sizes[@]}"; do
out="$ICONSET_DIR/icon_${sz}x${sz}.png"
sips -z "$sz" "$sz" "$MASTER_1024" --out "$out" >/dev/null
if [[ "$sz" -ne 1024 ]]; then
dbl=$((sz*2))
out2="$ICONSET_DIR/icon_${sz}x${sz}@2x.png"
sips -z "$dbl" "$dbl" "$MASTER_1024" --out "$out2" >/dev/null
fi
done
# 512x512@2x already covered by 1024; ensure it exists
cp "$MASTER_1024" "$ICONSET_DIR/icon_512x512@2x.png"
iconutil -c icns "$ICONSET_DIR" -o "$OUT_ROOT/${BASENAME}.icns"
mkdir -p "$(dirname "$DEST_ICNS")"
cp "$OUT_ROOT/${BASENAME}.icns" "$DEST_ICNS"
echo "Icon.icns generated at $DEST_ICNS"

View file

@ -0,0 +1,246 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { resolvePnpmRunner } from "./pnpm-runner.mjs";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const hashFile = path.join(rootDir, "src", "canvas-host", "a2ui", ".bundle.hash");
const outputFile = path.join(rootDir, "src", "canvas-host", "a2ui", "a2ui.bundle.js");
const a2uiRendererDir = path.join(rootDir, "vendor", "a2ui", "renderers", "lit");
const a2uiAppDir = path.join(rootDir, "apps", "shared", "OpenClawKit", "Tools", "CanvasA2UI");
const uiPackageFile = path.join(rootDir, "ui", "package.json");
const bundleDependencyIds = ["lit", "@lit/context", "@lit-labs/signals", "signal-utils"];
const repoInputPaths = [uiPackageFile, a2uiRendererDir, a2uiAppDir];
const ignoredBundleHashInputPrefixes = ["vendor/a2ui/renderers/lit/dist"];
const relativeRepoInputPaths = repoInputPaths.map((inputPath) =>
normalizePath(path.relative(rootDir, inputPath)),
);
function fail(message) {
console.error(message);
console.error("A2UI bundling failed. Re-run with: pnpm canvas:a2ui:bundle");
console.error("If this persists, verify pnpm deps and try again.");
process.exit(1);
}
async function pathExists(targetPath) {
try {
await fs.stat(targetPath);
return true;
} catch {
return false;
}
}
function normalizePath(filePath) {
return filePath.split(path.sep).join("/");
}
export function isBundleHashInputPath(filePath, repoRoot = rootDir) {
const relativePath = normalizePath(path.relative(repoRoot, filePath));
return !ignoredBundleHashInputPrefixes.some(
(ignoredPath) => relativePath === ignoredPath || relativePath.startsWith(`${ignoredPath}/`),
);
}
export function getLocalRolldownCliCandidates(repoRoot = rootDir) {
return [
path.join(repoRoot, "node_modules", "rolldown", "bin", "cli.mjs"),
path.join(repoRoot, "node_modules", ".pnpm", "node_modules", "rolldown", "bin", "cli.mjs"),
path.join(
repoRoot,
"node_modules",
".pnpm",
"rolldown@1.0.0-rc.12",
"node_modules",
"rolldown",
"bin",
"cli.mjs",
),
];
}
export function getBundleHashRepoInputPaths(repoRoot = rootDir) {
return [
path.join(repoRoot, "ui", "package.json"),
path.join(repoRoot, "vendor", "a2ui", "renderers", "lit"),
path.join(repoRoot, "apps", "shared", "OpenClawKit", "Tools", "CanvasA2UI"),
];
}
export function getResolvedBundleDependencyPackageJsonPaths(repoRoot = rootDir) {
const uiNodeModules = path.join(repoRoot, "ui", "node_modules");
const repoNodeModules = path.join(repoRoot, "node_modules");
const paths = [];
for (const dependencyId of bundleDependencyIds) {
const candidates = [
path.join(uiNodeModules, dependencyId, "package.json"),
path.join(repoNodeModules, dependencyId, "package.json"),
];
const match = candidates.find((candidate) => existsSync(candidate));
if (match) {
paths.push(match);
}
}
return [...new Set(paths)];
}
export function getBundleHashInputPaths(repoRoot = rootDir) {
return [
...getBundleHashRepoInputPaths(repoRoot),
...getResolvedBundleDependencyPackageJsonPaths(repoRoot),
];
}
export function compareNormalizedPaths(left, right) {
const normalizedLeft = normalizePath(left);
const normalizedRight = normalizePath(right);
if (normalizedLeft < normalizedRight) {
return -1;
}
if (normalizedLeft > normalizedRight) {
return 1;
}
return 0;
}
async function walkFiles(entryPath, files) {
if (!isBundleHashInputPath(entryPath)) {
return;
}
const stat = await fs.stat(entryPath);
if (!stat.isDirectory()) {
files.push(entryPath);
return;
}
const entries = await fs.readdir(entryPath);
for (const entry of entries) {
await walkFiles(path.join(entryPath, entry), files);
}
}
function listTrackedInputFiles() {
const result = spawnSync("git", ["ls-files", "--", ...relativeRepoInputPaths], {
cwd: rootDir,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
return null;
}
const trackedFiles = result.stdout
.split("\n")
.filter(Boolean)
.map((filePath) => path.join(rootDir, filePath))
.filter((filePath) => isBundleHashInputPath(filePath));
return [...trackedFiles, ...getResolvedBundleDependencyPackageJsonPaths(rootDir)];
}
async function computeHash() {
let files = listTrackedInputFiles();
if (!files) {
files = [];
for (const inputPath of getBundleHashRepoInputPaths(rootDir)) {
await walkFiles(inputPath, files);
}
files.push(...getResolvedBundleDependencyPackageJsonPaths(rootDir));
}
files = [...new Set(files)].toSorted(compareNormalizedPaths);
const hash = createHash("sha256");
for (const filePath of files) {
hash.update(normalizePath(path.relative(rootDir, filePath)));
hash.update("\0");
hash.update(await fs.readFile(filePath));
hash.update("\0");
}
return hash.digest("hex");
}
function runStep(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: rootDir,
stdio: "inherit",
env: process.env,
...options,
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
function runPnpm(pnpmArgs) {
const runner = resolvePnpmRunner({
pnpmArgs,
nodeExecPath: process.execPath,
npmExecPath: process.env.npm_execpath,
comSpec: process.env.ComSpec,
platform: process.platform,
});
runStep(runner.command, runner.args, {
shell: runner.shell,
windowsVerbatimArguments: runner.windowsVerbatimArguments,
});
}
async function main() {
const hasRendererDir = await pathExists(a2uiRendererDir);
const hasAppDir = await pathExists(a2uiAppDir);
const hasOutputFile = await pathExists(outputFile);
if (!hasRendererDir || !hasAppDir) {
if (hasOutputFile) {
console.log("A2UI sources missing; keeping prebuilt bundle.");
return;
}
if (process.env.OPENCLAW_SPARSE_PROFILE || process.env.OPENCLAW_A2UI_SKIP_MISSING === "1") {
console.error(
"A2UI sources missing; skipping bundle because OPENCLAW_A2UI_SKIP_MISSING=1 or OPENCLAW_SPARSE_PROFILE is set.",
);
return;
}
fail(`A2UI sources missing and no prebuilt bundle found at: ${outputFile}`);
}
const currentHash = await computeHash();
if (await pathExists(hashFile)) {
const previousHash = (await fs.readFile(hashFile, "utf8")).trim();
if (previousHash === currentHash && hasOutputFile) {
console.log("A2UI bundle up to date; skipping.");
return;
}
}
runPnpm(["-s", "exec", "tsc", "-p", path.join(a2uiRendererDir, "tsconfig.json")]);
const localRolldownCliCandidates = getLocalRolldownCliCandidates(rootDir);
const localRolldownCli = (
await Promise.all(
localRolldownCliCandidates.map(async (candidate) =>
(await pathExists(candidate)) ? candidate : null,
),
)
).find(Boolean);
if (localRolldownCli) {
runStep(process.execPath, [
localRolldownCli,
"-c",
path.join(a2uiAppDir, "rolldown.config.mjs"),
]);
} else {
runPnpm(["-s", "exec", "rolldown", "-c", path.join(a2uiAppDir, "rolldown.config.mjs")]);
}
await fs.writeFile(hashFile, `${currentHash}\n`, "utf8");
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main().catch((error) => {
fail(error instanceof Error ? error.message : String(error));
});
}

View file

@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec node "$ROOT_DIR/scripts/bundle-a2ui.mjs" "$@"

View file

@ -0,0 +1,46 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
export function getA2uiPaths(env = process.env) {
const srcDir = env.OPENCLAW_A2UI_SRC_DIR ?? path.join(repoRoot, "src", "canvas-host", "a2ui");
const outDir = env.OPENCLAW_A2UI_OUT_DIR ?? path.join(repoRoot, "dist", "canvas-host", "a2ui");
return { srcDir, outDir };
}
export function shouldSkipMissingA2uiAssets(env = process.env): boolean {
return env.OPENCLAW_A2UI_SKIP_MISSING === "1" || Boolean(env.OPENCLAW_SPARSE_PROFILE);
}
export async function copyA2uiAssets({ srcDir, outDir }: { srcDir: string; outDir: string }) {
const skipMissing = shouldSkipMissingA2uiAssets(process.env);
try {
await fs.stat(path.join(srcDir, "index.html"));
await fs.stat(path.join(srcDir, "a2ui.bundle.js"));
} catch (err) {
const message = 'Missing A2UI bundle assets. Run "pnpm canvas:a2ui:bundle" and retry.';
if (skipMissing) {
console.warn(
`${message} Skipping copy because OPENCLAW_A2UI_SKIP_MISSING=1 or OPENCLAW_SPARSE_PROFILE is set.`,
);
return;
}
throw new Error(message, { cause: err });
}
await fs.mkdir(path.dirname(outDir), { recursive: true });
await fs.cp(srcDir, outDir, { recursive: true });
}
async function main() {
const { srcDir, outDir } = getA2uiPaths();
await copyA2uiAssets({ srcDir, outDir });
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main().catch((err) => {
console.error(String(err));
process.exit(1);
});
}

View file

@ -0,0 +1,65 @@
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { appendUnreleasedChangelogEntry } from "../src/infra/changelog-unreleased.js";
type SectionArg = "breaking" | "changes" | "fixes";
function parseArgs(argv: string[]): {
changelogPath: string;
section: "Breaking" | "Changes" | "Fixes";
entry: string;
} {
let changelogPath = resolve("CHANGELOG.md");
let section: SectionArg | undefined;
const entryParts: string[] = [];
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--file") {
const next = argv[index + 1];
if (!next) {
throw new Error("Missing value for --file.");
}
changelogPath = resolve(next);
index += 1;
continue;
}
if (arg === "--section") {
const next = argv[index + 1] as SectionArg | undefined;
if (!next || !["breaking", "changes", "fixes"].includes(next)) {
throw new Error("Missing or invalid value for --section.");
}
section = next;
index += 1;
continue;
}
entryParts.push(arg);
}
if (!section) {
throw new Error("Missing required --section <breaking|changes|fixes>.");
}
const entry = entryParts.join(" ").trim();
if (!entry) {
throw new Error("Missing changelog entry text.");
}
return {
changelogPath,
section: section === "breaking" ? "Breaking" : section === "changes" ? "Changes" : "Fixes",
entry,
};
}
if (import.meta.main) {
const { changelogPath, section, entry } = parseArgs(process.argv.slice(2));
const content = readFileSync(changelogPath, "utf8");
const next = appendUnreleasedChangelogEntry(content, {
section,
entry,
});
if (next !== content) {
writeFileSync(changelogPath, next);
}
console.log(`Updated ${changelogPath} (${section}).`);
}

View file

@ -0,0 +1,91 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION=${1:-}
CHANGELOG_FILE=${2:-}
if [[ -z "$VERSION" ]]; then
echo "Usage: $0 <version> [changelog_file]" >&2
exit 1
fi
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
if [[ -z "$CHANGELOG_FILE" ]]; then
if [[ -f "$SCRIPT_DIR/../CHANGELOG.md" ]]; then
CHANGELOG_FILE="$SCRIPT_DIR/../CHANGELOG.md"
elif [[ -f "CHANGELOG.md" ]]; then
CHANGELOG_FILE="CHANGELOG.md"
elif [[ -f "../CHANGELOG.md" ]]; then
CHANGELOG_FILE="../CHANGELOG.md"
else
echo "Error: Could not find CHANGELOG.md" >&2
exit 1
fi
fi
if [[ ! -f "$CHANGELOG_FILE" ]]; then
echo "Error: Changelog file '$CHANGELOG_FILE' not found" >&2
exit 1
fi
extract_version_section() {
local version=$1
local file=$2
awk -v version="$version" '
BEGIN { found=0 }
/^## / {
if ($0 ~ "^##[[:space:]]+" version "([[:space:]].*|$)") { found=1; next }
if (found) { exit }
}
found { print }
' "$file"
}
markdown_to_html() {
local text=$1
text=$(echo "$text" | sed 's/^##### \(.*\)$/<h5>\1<\/h5>/')
text=$(echo "$text" | sed 's/^#### \(.*\)$/<h4>\1<\/h4>/')
text=$(echo "$text" | sed 's/^### \(.*\)$/<h3>\1<\/h3>/')
text=$(echo "$text" | sed 's/^## \(.*\)$/<h2>\1<\/h2>/')
text=$(echo "$text" | sed 's/^- \*\*\([^*]*\)\*\*\(.*\)$/<li><strong>\1<\/strong>\2<\/li>/')
text=$(echo "$text" | sed 's/^- \([^*].*\)$/<li>\1<\/li>/')
text=$(echo "$text" | sed 's/\*\*\([^*]*\)\*\*/<strong>\1<\/strong>/g')
text=$(echo "$text" | sed 's/`\([^`]*\)`/<code>\1<\/code>/g')
text=$(echo "$text" | sed 's/\[\([^]]*\)\](\([^)]*\))/<a href="\2">\1<\/a>/g')
echo "$text"
}
version_content=$(extract_version_section "$VERSION" "$CHANGELOG_FILE")
if [[ -z "$version_content" ]]; then
echo "<h2>OpenClaw $VERSION</h2>"
echo "<p>Latest OpenClaw update.</p>"
echo "<p><a href=\"https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md\">View full changelog</a></p>"
exit 0
fi
echo "<h2>OpenClaw $VERSION</h2>"
in_list=false
while IFS= read -r line; do
if [[ "$line" =~ ^- ]]; then
if [[ "$in_list" == false ]]; then
echo "<ul>"
in_list=true
fi
markdown_to_html "$line"
else
if [[ "$in_list" == true ]]; then
echo "</ul>"
in_list=false
fi
if [[ -n "$line" ]]; then
markdown_to_html "$line"
fi
fi
done <<< "$version_content"
if [[ "$in_list" == true ]]; then
echo "</ul>"
fi
echo "<p><a href=\"https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md\">View full changelog</a></p>"

View file

@ -0,0 +1,259 @@
#!/usr/bin/env node
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import { BUNDLED_PLUGIN_PATH_PREFIX } from "./lib/bundled-plugin-paths.mjs";
import {
collectTypeScriptInventory,
normalizeRepoPath,
resolveRepoSpecifier,
visitModuleSpecifiers,
writeLine,
} from "./lib/guard-inventory-utils.mjs";
import {
collectTypeScriptFilesFromRoots,
resolveSourceRoots,
runAsScript,
toLine,
} from "./lib/ts-guard-utils.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const scanRoots = resolveSourceRoots(repoRoot, ["src/plugin-sdk", "src/plugins/runtime"]);
function compareEntries(left, right) {
return (
left.category.localeCompare(right.category) ||
left.file.localeCompare(right.file) ||
left.line - right.line ||
left.kind.localeCompare(right.kind) ||
left.specifier.localeCompare(right.specifier) ||
left.reason.localeCompare(right.reason)
);
}
function pushEntry(entries, entry) {
entries.push(entry);
}
function scanPluginSdkExtensionFacadeSmells(sourceFile, filePath) {
const relativeFile = normalizeRepoPath(repoRoot, filePath);
if (!relativeFile.startsWith("src/plugin-sdk/")) {
return [];
}
const entries = [];
visitModuleSpecifiers(ts, sourceFile, ({ kind, specifier, specifierNode }) => {
if (kind !== "export") {
return;
}
const resolvedPath = resolveRepoSpecifier(repoRoot, specifier, filePath);
if (!resolvedPath?.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) {
return;
}
pushEntry(entries, {
category: "plugin-sdk-extension-facade",
file: relativeFile,
line: toLine(sourceFile, specifierNode),
kind,
specifier,
resolvedPath,
reason: "plugin-sdk public surface re-exports extension-owned implementation",
});
});
return entries;
}
function scanRuntimeTypeImplementationSmells(sourceFile, filePath) {
const relativeFile = normalizeRepoPath(repoRoot, filePath);
if (!/^src\/plugins\/runtime\/types(?:-[^/]+)?\.ts$/.test(relativeFile)) {
return [];
}
const entries = [];
function visit(node) {
if (
ts.isImportTypeNode(node) &&
ts.isLiteralTypeNode(node.argument) &&
ts.isStringLiteral(node.argument.literal)
) {
const specifier = node.argument.literal.text;
const resolvedPath = resolveRepoSpecifier(repoRoot, specifier, filePath);
if (
resolvedPath &&
(/^src\/plugins\/runtime\/runtime-[^/]+\.ts$/.test(resolvedPath) ||
/^extensions\/[^/]+\/runtime-api\.[^/]+$/.test(resolvedPath))
) {
pushEntry(entries, {
category: "runtime-type-implementation-edge",
file: relativeFile,
line: toLine(sourceFile, node.argument.literal),
kind: "import-type",
specifier,
resolvedPath,
reason: "runtime type file references implementation shim directly",
});
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return entries;
}
function scanRuntimeServiceLocatorSmells(sourceFile, filePath) {
const relativeFile = normalizeRepoPath(repoRoot, filePath);
if (
!relativeFile.startsWith("src/plugin-sdk/") &&
!relativeFile.startsWith("src/plugins/runtime/")
) {
return [];
}
const entries = [];
const exportedNames = new Set();
const runtimeStoreCalls = [];
const mutableStateNodes = [];
for (const statement of sourceFile.statements) {
if (ts.isFunctionDeclaration(statement) && statement.name) {
const isExported = statement.modifiers?.some(
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
);
if (isExported) {
exportedNames.add(statement.name.text);
}
} else if (ts.isVariableStatement(statement)) {
const isExported = statement.modifiers?.some(
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
);
for (const declaration of statement.declarationList.declarations) {
if (ts.isIdentifier(declaration.name) && isExported) {
exportedNames.add(declaration.name.text);
}
if (
!isExported &&
(statement.declarationList.flags & ts.NodeFlags.Let) !== 0 &&
ts.isIdentifier(declaration.name)
) {
mutableStateNodes.push(declaration.name);
}
}
}
}
function visit(node) {
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === "createPluginRuntimeStore"
) {
runtimeStoreCalls.push(node.expression);
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
const getterNames = [...exportedNames].filter((name) => /^get[A-Z]/.test(name));
const setterNames = [...exportedNames].filter((name) => /^set[A-Z]/.test(name));
if (runtimeStoreCalls.length > 0 && getterNames.length > 0 && setterNames.length > 0) {
for (const callNode of runtimeStoreCalls) {
pushEntry(entries, {
category: "runtime-service-locator",
file: relativeFile,
line: toLine(sourceFile, callNode),
kind: "runtime-store",
specifier: "createPluginRuntimeStore",
resolvedPath: relativeFile,
reason: `exports paired runtime accessors (${getterNames.join(", ")} / ${setterNames.join(", ")}) over module-global store state`,
});
}
}
if (mutableStateNodes.length > 0 && getterNames.length > 0 && setterNames.length > 0) {
for (const identifier of mutableStateNodes) {
pushEntry(entries, {
category: "runtime-service-locator",
file: relativeFile,
line: toLine(sourceFile, identifier),
kind: "mutable-state",
specifier: identifier.text,
resolvedPath: relativeFile,
reason: `module-global mutable state backs exported runtime accessors (${getterNames.join(", ")} / ${setterNames.join(", ")})`,
});
}
}
return entries;
}
export async function collectArchitectureSmells() {
const files = (await collectTypeScriptFilesFromRoots(scanRoots)).toSorted((left, right) =>
normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)),
);
return await collectTypeScriptInventory({
ts,
files,
compareEntries,
collectEntries(sourceFile, filePath) {
return [
...scanPluginSdkExtensionFacadeSmells(sourceFile, filePath),
...scanRuntimeTypeImplementationSmells(sourceFile, filePath),
...scanRuntimeServiceLocatorSmells(sourceFile, filePath),
];
},
});
}
function formatInventoryHuman(inventory) {
if (inventory.length === 0) {
return "No architecture smells found for the configured checks.";
}
const lines = ["Architecture smell inventory:"];
let activeCategory = "";
let activeFile = "";
for (const entry of inventory) {
if (entry.category !== activeCategory) {
activeCategory = entry.category;
activeFile = "";
lines.push(entry.category);
}
if (entry.file !== activeFile) {
activeFile = entry.file;
lines.push(` ${activeFile}`);
}
lines.push(` - line ${entry.line} [${entry.kind}] ${entry.reason}`);
lines.push(` specifier: ${entry.specifier}`);
lines.push(` resolved: ${entry.resolvedPath}`);
}
return lines.join("\n");
}
export async function runArchitectureSmellsCheck(argv = process.argv.slice(2), io) {
const streams = io ?? { stdout: process.stdout, stderr: process.stderr };
const json = argv.includes("--json");
const inventory = await collectArchitectureSmells();
if (json) {
writeLine(streams.stdout, JSON.stringify(inventory, null, 2));
return 0;
}
writeLine(streams.stdout, formatInventoryHuman(inventory));
writeLine(streams.stdout, `${inventory.length} smell${inventory.length === 1 ? "" : "s"} found.`);
return 0;
}
export async function main(argv = process.argv.slice(2), io) {
return await runArchitectureSmellsCheck(argv, io);
}
runAsScript(import.meta.url, main);

View file

@ -0,0 +1,353 @@
#!/usr/bin/env node
import { promises as fs } from "node:fs";
import path from "node:path";
import ts from "typescript";
import {
collectTypeScriptFiles,
getPropertyNameText,
resolveRepoRoot,
runAsScript,
toLine,
} from "./lib/ts-guard-utils.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
const acpCoreProtectedSources = [
path.join(repoRoot, "src", "acp"),
path.join(repoRoot, "src", "agents", "acp-spawn.ts"),
path.join(repoRoot, "src", "auto-reply", "reply", "commands-acp"),
path.join(repoRoot, "src", "infra", "outbound", "conversation-id.ts"),
];
const channelCoreProtectedSources = [
path.join(repoRoot, "src", "channels", "thread-bindings-policy.ts"),
path.join(repoRoot, "src", "channels", "thread-bindings-messages.ts"),
path.join(repoRoot, "src", "sessions", "send-policy.ts"),
path.join(repoRoot, "src", "sessions", "session-chat-type-shared.ts"),
path.join(repoRoot, "src", "utils", "delivery-context.ts"),
];
const acpUserFacingTextSources = [
path.join(repoRoot, "src", "auto-reply", "reply", "commands-acp"),
];
const systemMarkLiteralGuardSources = [
path.join(repoRoot, "src", "auto-reply", "reply", "commands-acp"),
path.join(repoRoot, "src", "auto-reply", "reply", "dispatch-acp.ts"),
path.join(repoRoot, "src", "auto-reply", "reply", "directive-handling.shared.ts"),
path.join(repoRoot, "src", "channels", "thread-bindings-messages.ts"),
];
const channelIds = [
"bluebubbles",
"discord",
"googlechat",
"imessage",
"irc",
"line",
"mattermost",
"matrix",
"msteams",
"nextcloud-talk",
"nostr",
"qqbot",
"signal",
"slack",
"synology-chat",
"telegram",
"tlon",
"twitch",
"web",
"whatsapp",
"zalo",
"zalouser",
];
const channelIdSet = new Set(channelIds);
const channelSegmentRe = new RegExp(`(^|[._/-])(?:${channelIds.join("|")})([._/-]|$)`);
const comparisonOperators = new Set([
ts.SyntaxKind.EqualsEqualsEqualsToken,
ts.SyntaxKind.ExclamationEqualsEqualsToken,
ts.SyntaxKind.EqualsEqualsToken,
ts.SyntaxKind.ExclamationEqualsToken,
]);
const allowedViolations = new Set([]);
function isChannelsPropertyAccess(node) {
if (ts.isPropertyAccessExpression(node)) {
return node.name.text === "channels";
}
if (ts.isElementAccessExpression(node) && ts.isStringLiteral(node.argumentExpression)) {
return node.argumentExpression.text === "channels";
}
return false;
}
function readStringLiteral(node) {
if (ts.isStringLiteral(node)) {
return node.text;
}
if (ts.isNoSubstitutionTemplateLiteral(node)) {
return node.text;
}
return null;
}
function isChannelLiteralNode(node) {
const text = readStringLiteral(node);
return text ? channelIdSet.has(text) : false;
}
function matchesChannelModuleSpecifier(specifier) {
return channelSegmentRe.test(specifier.replaceAll("\\", "/"));
}
const userFacingChannelNameRe =
/\b(?:discord|telegram|slack|signal|imessage|whatsapp|google\s*chat|irc|line|zalo|matrix|msteams|bluebubbles)\b/i;
const systemMarkLiteral = "⚙️";
function isModuleSpecifierStringNode(node) {
const parent = node.parent;
if (ts.isImportDeclaration(parent) || ts.isExportDeclaration(parent)) {
return true;
}
return (
ts.isCallExpression(parent) &&
parent.expression.kind === ts.SyntaxKind.ImportKeyword &&
parent.arguments[0] === node
);
}
export function findChannelAgnosticBoundaryViolations(
content,
fileName = "source.ts",
options = {},
) {
const checkModuleSpecifiers = options.checkModuleSpecifiers ?? true;
const checkConfigPaths = options.checkConfigPaths ?? true;
const checkChannelComparisons = options.checkChannelComparisons ?? true;
const checkChannelAssignments = options.checkChannelAssignments ?? true;
const moduleSpecifierMatcher = options.moduleSpecifierMatcher ?? matchesChannelModuleSpecifier;
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const violations = [];
const visit = (node) => {
if (
checkModuleSpecifiers &&
ts.isImportDeclaration(node) &&
ts.isStringLiteral(node.moduleSpecifier)
) {
const specifier = node.moduleSpecifier.text;
if (moduleSpecifierMatcher(specifier)) {
violations.push({
line: toLine(sourceFile, node.moduleSpecifier),
reason: `imports channel module "${specifier}"`,
});
}
}
if (
checkModuleSpecifiers &&
ts.isExportDeclaration(node) &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier)
) {
const specifier = node.moduleSpecifier.text;
if (moduleSpecifierMatcher(specifier)) {
violations.push({
line: toLine(sourceFile, node.moduleSpecifier),
reason: `re-exports channel module "${specifier}"`,
});
}
}
if (
checkModuleSpecifiers &&
ts.isCallExpression(node) &&
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
node.arguments.length > 0 &&
ts.isStringLiteral(node.arguments[0])
) {
const specifier = node.arguments[0].text;
if (moduleSpecifierMatcher(specifier)) {
violations.push({
line: toLine(sourceFile, node.arguments[0]),
reason: `dynamically imports channel module "${specifier}"`,
});
}
}
if (
checkConfigPaths &&
ts.isPropertyAccessExpression(node) &&
channelIdSet.has(node.name.text)
) {
if (isChannelsPropertyAccess(node.expression)) {
violations.push({
line: toLine(sourceFile, node.name),
reason: `references config path "channels.${node.name.text}"`,
});
}
}
if (
checkConfigPaths &&
ts.isElementAccessExpression(node) &&
ts.isStringLiteral(node.argumentExpression) &&
channelIdSet.has(node.argumentExpression.text)
) {
if (isChannelsPropertyAccess(node.expression)) {
violations.push({
line: toLine(sourceFile, node.argumentExpression),
reason: `references config path "channels[${JSON.stringify(node.argumentExpression.text)}]"`,
});
}
}
if (
checkChannelComparisons &&
ts.isBinaryExpression(node) &&
comparisonOperators.has(node.operatorToken.kind)
) {
if (isChannelLiteralNode(node.left) || isChannelLiteralNode(node.right)) {
const leftText = node.left.getText(sourceFile);
const rightText = node.right.getText(sourceFile);
violations.push({
line: toLine(sourceFile, node.operatorToken),
reason: `compares with channel id literal (${leftText} ${node.operatorToken.getText(sourceFile)} ${rightText})`,
});
}
}
if (checkChannelAssignments && ts.isPropertyAssignment(node)) {
const propName = getPropertyNameText(node.name);
if (propName === "channel" && isChannelLiteralNode(node.initializer)) {
violations.push({
line: toLine(sourceFile, node.initializer),
reason: `assigns channel id literal to "channel" (${node.initializer.getText(sourceFile)})`,
});
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return violations;
}
export function findChannelCoreReverseDependencyViolations(content, fileName = "source.ts") {
return findChannelAgnosticBoundaryViolations(content, fileName, {
checkModuleSpecifiers: true,
checkConfigPaths: false,
checkChannelComparisons: false,
checkChannelAssignments: false,
moduleSpecifierMatcher: matchesChannelModuleSpecifier,
});
}
export function findAcpUserFacingChannelNameViolations(content, fileName = "source.ts") {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const violations = [];
const visit = (node) => {
const text = readStringLiteral(node);
if (text && userFacingChannelNameRe.test(text) && !isModuleSpecifierStringNode(node)) {
violations.push({
line: toLine(sourceFile, node),
reason: `user-facing text references channel name (${JSON.stringify(text)})`,
});
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return violations;
}
export function findSystemMarkLiteralViolations(content, fileName = "source.ts") {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const violations = [];
const visit = (node) => {
const text = readStringLiteral(node);
if (text && text.includes(systemMarkLiteral) && !isModuleSpecifierStringNode(node)) {
violations.push({
line: toLine(sourceFile, node),
reason: `hardcoded system mark literal (${JSON.stringify(text)})`,
});
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return violations;
}
const boundaryRuleSets = [
{
id: "acp-core",
sources: acpCoreProtectedSources,
scan: (content, fileName) => findChannelAgnosticBoundaryViolations(content, fileName),
},
{
id: "channel-core-reverse-deps",
sources: channelCoreProtectedSources,
scan: (content, fileName) => findChannelCoreReverseDependencyViolations(content, fileName),
},
{
id: "acp-user-facing-text",
sources: acpUserFacingTextSources,
scan: (content, fileName) => findAcpUserFacingChannelNameViolations(content, fileName),
},
{
id: "system-mark-literal-usage",
sources: systemMarkLiteralGuardSources,
scan: (content, fileName) => findSystemMarkLiteralViolations(content, fileName),
},
];
export async function main() {
const violations = [];
for (const ruleSet of boundaryRuleSets) {
const files = (
await Promise.all(
ruleSet.sources.map(
async (sourcePath) =>
await collectTypeScriptFiles(sourcePath, {
ignoreMissing: true,
}),
),
)
).flat();
for (const filePath of files) {
const relativeFile = path.relative(repoRoot, filePath);
if (
allowedViolations.has(`${ruleSet.id}:${relativeFile}`) ||
allowedViolations.has(relativeFile)
) {
continue;
}
const content = await fs.readFile(filePath, "utf8");
for (const violation of ruleSet.scan(content, relativeFile)) {
violations.push(`${ruleSet.id} ${relativeFile}:${violation.line}: ${violation.reason}`);
}
}
}
if (violations.length === 0) {
return;
}
console.error("Found channel-specific references in channel-agnostic sources:");
for (const violation of violations) {
console.error(`- ${violation}`);
}
console.error(
"Move channel-specific logic to channel adapters or add a justified allowlist entry.",
);
process.exit(1);
}
runAsScript(import.meta.url, main);

View file

@ -0,0 +1,184 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
const isLinux = process.platform === "linux";
const isMac = process.platform === "darwin";
if (!isLinux && !isMac) {
console.log(`[startup-memory] Skipping on unsupported platform: ${process.platform}`);
process.exit(0);
}
const repoRoot = process.cwd();
const tmpHome = mkdtempSync(path.join(os.tmpdir(), "openclaw-startup-memory-"));
const tmpDir = process.env.TMPDIR || process.env.TEMP || process.env.TMP || os.tmpdir();
const rssHookPath = path.join(tmpHome, "measure-rss.mjs");
const MAX_RSS_MARKER = "__OPENCLAW_MAX_RSS_KB__=";
writeFileSync(
rssHookPath,
[
"process.on('exit', () => {",
" const usage = typeof process.resourceUsage === 'function' ? process.resourceUsage() : null;",
` if (usage && typeof usage.maxRSS === 'number') console.error('${MAX_RSS_MARKER}' + String(usage.maxRSS));`,
"});",
"",
].join("\n"),
"utf8",
);
const DEFAULT_LIMITS_MB = {
help: 100,
statusJson: 400,
gatewayStatus: 500,
};
const cases = [
{
id: "help",
label: "--help",
args: ["openclaw.mjs", "--help"],
limitMb: Number(process.env.OPENCLAW_STARTUP_MEMORY_HELP_MB ?? DEFAULT_LIMITS_MB.help),
},
{
id: "statusJson",
label: "status --json",
args: ["openclaw.mjs", "status", "--json"],
limitMb: Number(
process.env.OPENCLAW_STARTUP_MEMORY_STATUS_JSON_MB ?? DEFAULT_LIMITS_MB.statusJson,
),
},
{
id: "gatewayStatus",
label: "gateway status",
args: ["openclaw.mjs", "gateway", "status"],
limitMb: Number(
process.env.OPENCLAW_STARTUP_MEMORY_GATEWAY_STATUS_MB ?? DEFAULT_LIMITS_MB.gatewayStatus,
),
},
];
function formatFixGuidance(testCase, details) {
const command = `node ${testCase.args.join(" ")}`;
const guidance = [
"[startup-memory] Fix guidance",
`Case: ${testCase.label}`,
`Command: ${command}`,
"Next steps:",
`1. Run \`${command}\` locally on the built tree.`,
"2. If this is an RSS overage, compare the startup import graph against the last passing commit and look for newly eager imports, bootstrap side effects, or plugin loading on the command path.",
"3. If this is a non-zero exit, inspect the first transitive import/config error in stderr and fix that root cause before re-checking memory.",
"LLM prompt:",
`"OpenClaw startup-memory CI failed for '${testCase.label}'. Analyze this failure, identify the first runtime/import side effect that makes startup heavier or broken, and propose the smallest safe patch. Failure output:\n${details}"`,
];
return `${guidance.join("\n")}\n`;
}
function formatFailure(testCase, message, details = "") {
const trimmedDetails = details.trim();
const sections = [message];
if (trimmedDetails) {
sections.push(trimmedDetails);
}
sections.push(formatFixGuidance(testCase, trimmedDetails || message));
return sections.join("\n\n");
}
function parseMaxRssMb(stderr) {
const matches = [...stderr.matchAll(new RegExp(`^${MAX_RSS_MARKER}(\\d+)\\s*$`, "gm"))];
const lastMatch = matches.at(-1);
if (!lastMatch) {
return null;
}
return Number(lastMatch[1]) / 1024;
}
function buildBenchEnv() {
const env = {
HOME: tmpHome,
USERPROFILE: tmpHome,
XDG_CONFIG_HOME: path.join(tmpHome, ".config"),
XDG_DATA_HOME: path.join(tmpHome, ".local", "share"),
XDG_CACHE_HOME: path.join(tmpHome, ".cache"),
PATH: process.env.PATH ?? "",
TMPDIR: tmpDir,
TEMP: tmpDir,
TMP: tmpDir,
LANG: process.env.LANG ?? "C.UTF-8",
TERM: process.env.TERM ?? "dumb",
};
if (process.env.LC_ALL) {
env.LC_ALL = process.env.LC_ALL;
}
if (process.env.CI) {
env.CI = process.env.CI;
}
if (process.env.NODE_DISABLE_COMPILE_CACHE) {
env.NODE_DISABLE_COMPILE_CACHE = process.env.NODE_DISABLE_COMPILE_CACHE;
} else {
// Keep the regression check focused on app/runtime startup, not Node's
// one-shot compile cache overhead, which varies across runner builds.
env.NODE_DISABLE_COMPILE_CACHE = "1";
}
// Keep the benchmark on a single process so RSS reflects the actual command
// path rather than the warning-suppression respawn wrapper.
env.OPENCLAW_NO_RESPAWN = "1";
return env;
}
function runCase(testCase) {
const env = buildBenchEnv();
const result = spawnSync(process.execPath, ["--import", rssHookPath, ...testCase.args], {
cwd: repoRoot,
env,
encoding: "utf8",
maxBuffer: 20 * 1024 * 1024,
});
const stderr = result.stderr ?? "";
const maxRssMb = parseMaxRssMb(stderr);
const matrixBootstrapWarning = /matrix: crypto runtime bootstrap failed/i.test(stderr);
if (result.status !== 0) {
throw new Error(
formatFailure(
testCase,
`${testCase.label} exited with ${String(result.status)}`,
stderr.trim() || result.stdout || "",
),
);
}
if (maxRssMb == null) {
throw new Error(formatFailure(testCase, `${testCase.label} did not report max RSS`, stderr));
}
if (matrixBootstrapWarning) {
throw new Error(
formatFailure(testCase, `${testCase.label} triggered Matrix crypto bootstrap during startup`),
);
}
if (maxRssMb > testCase.limitMb) {
throw new Error(
formatFailure(
testCase,
`${testCase.label} used ${maxRssMb.toFixed(1)} MB RSS (limit ${testCase.limitMb} MB)`,
),
);
}
console.log(
`[startup-memory] ${testCase.label}: ${maxRssMb.toFixed(1)} MB RSS (limit ${testCase.limitMb} MB)`,
);
}
try {
for (const testCase of cases) {
runCase(testCase);
}
} finally {
rmSync(tmpHome, { recursive: true, force: true });
}

View file

@ -0,0 +1,78 @@
import fs from "node:fs/promises";
import path from "node:path";
const codexRepo = process.env.OPENCLAW_CODEX_REPO
? path.resolve(process.env.OPENCLAW_CODEX_REPO)
: path.resolve(process.cwd(), "../codex");
const schemaRoot = path.join(codexRepo, "codex-rs/app-server-protocol/schema/typescript");
const checks: Array<{ file: string; snippets: string[] }> = [
{
file: "ServerRequest.ts",
snippets: [
'"item/commandExecution/requestApproval"',
'"item/fileChange/requestApproval"',
'"item/permissions/requestApproval"',
'"item/tool/call"',
],
},
{
file: "v2/ThreadItem.ts",
snippets: [
'"type": "contextCompaction"',
'"type": "dynamicToolCall"',
'"type": "commandExecution"',
'"type": "mcpToolCall"',
],
},
{
file: "v2/DynamicToolSpec.ts",
snippets: ["name: string", "description: string", "inputSchema: JsonValue"],
},
{
file: "v2/CommandExecutionApprovalDecision.ts",
snippets: ['"accept"', '"acceptForSession"', '"decline"', '"cancel"'],
},
{
file: "ReviewDecision.ts",
snippets: ['"approved"', '"approved_for_session"', '"denied"', '"abort"'],
},
{
file: "v2/PlanDeltaNotification.ts",
snippets: ["itemId: string", "delta: string"],
},
{
file: "v2/TurnPlanUpdatedNotification.ts",
snippets: ["explanation: string | null", "plan: Array<TurnPlanStep>"],
},
];
const failures: string[] = [];
for (const check of checks) {
const filePath = path.join(schemaRoot, check.file);
let text: string;
try {
text = await fs.readFile(filePath, "utf8");
} catch (error) {
failures.push(`${check.file}: missing (${String(error)})`);
continue;
}
for (const snippet of check.snippets) {
if (!text.includes(snippet)) {
failures.push(`${check.file}: missing ${snippet}`);
}
}
}
if (failures.length > 0) {
console.error("Codex app-server generated protocol drift:");
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log(
`Codex app-server generated protocol matches OpenClaw bridge assumptions: ${schemaRoot}`,
);

View file

@ -0,0 +1,81 @@
#!/usr/bin/env python3
from __future__ import annotations
import pathlib
import re
import sys
INPUT_INTERPOLATION_RE = re.compile(r"\$\{\{\s*inputs\.")
RUN_LINE_RE = re.compile(r"^(\s*)run:\s*(.*)$")
USING_COMPOSITE_RE = re.compile(r"^\s*using:\s*composite\s*$", re.MULTILINE)
def indentation(line: str) -> int:
return len(line) - len(line.lstrip(" "))
def scan_file(path: pathlib.Path) -> list[tuple[int, str]]:
text = path.read_text(encoding="utf-8")
if not USING_COMPOSITE_RE.search(text):
return []
lines = text.splitlines()
violations: list[tuple[int, str]] = []
line_count = len(lines)
index = 0
while index < line_count:
line = lines[index]
match = RUN_LINE_RE.match(line)
if not match:
index += 1
continue
run_indent = len(match.group(1))
run_value = match.group(2).strip()
line_no = index + 1
if run_value and run_value[0] not in ("|", ">"):
if INPUT_INTERPOLATION_RE.search(run_value):
violations.append((line_no, line.strip()))
index += 1
continue
index += 1
while index < line_count:
script_line = lines[index]
if script_line.strip() == "":
index += 1
continue
if indentation(script_line) <= run_indent:
break
if INPUT_INTERPOLATION_RE.search(script_line):
violations.append((index + 1, script_line.strip()))
index += 1
return violations
def main() -> int:
root = pathlib.Path(".github/actions")
files = sorted(root.rglob("action.y*ml"))
all_violations: list[tuple[pathlib.Path, int, str]] = []
for file_path in files:
for line_no, line in scan_file(file_path):
all_violations.append((file_path, line_no, line))
if all_violations:
print("Disallowed direct inputs interpolation in composite run blocks:")
for file_path, line_no, line in all_violations:
print(f"- {file_path}:{line_no}: {line}")
print("Use env: and reference shell variables instead.")
return 1
print("No direct inputs interpolation found in composite run blocks.")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,237 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const ROOT = process.cwd();
const GLOSSARY_PATH = path.join(ROOT, "docs", ".i18n", "glossary.zh-CN.json");
const DOC_FILE_RE = /^docs\/(?!zh-CN\/).+\.(md|mdx)$/i;
const LIST_ITEM_LINK_RE = /^\s*(?:[-*]|\d+\.)\s+\[([^\]]+)\]\((\/[^)]+)\)/;
const MAX_TITLE_WORDS = 8;
const MAX_LABEL_WORDS = 6;
const MAX_TERM_LENGTH = 80;
/**
* @typedef {{
* file: string;
* line: number;
* kind: "title" | "link label";
* term: string;
* }} TermMatch
*/
function parseArgs(argv) {
/** @type {{ base: string; head: string }} */
const args = { base: "", head: "" };
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--base") {
args.base = argv[i + 1] ?? "";
i += 1;
continue;
}
if (argv[i] === "--head") {
args.head = argv[i + 1] ?? "";
i += 1;
}
}
return args;
}
function runGit(args) {
return execFileSync("git", args, {
cwd: ROOT,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
}).trim();
}
function resolveBase(explicitBase) {
if (explicitBase) {
return explicitBase;
}
const envBase = process.env.DOCS_I18N_GLOSSARY_BASE?.trim();
if (envBase) {
return envBase;
}
for (const candidate of ["origin/main", "fork/main", "main"]) {
try {
return runGit(["merge-base", candidate, "HEAD"]);
} catch {
// Try the next candidate.
}
}
return "";
}
function listChangedDocs(base, head) {
const args = ["diff", "--name-only", "--diff-filter=ACMR", base];
if (head) {
args.push(head);
}
args.push("--", "docs");
return runGit(args)
.split("\n")
.map((line) => line.trim())
.filter((line) => DOC_FILE_RE.test(line));
}
function loadGlossarySources() {
const data = fs.readFileSync(GLOSSARY_PATH, "utf8");
const entries = JSON.parse(data);
return new Set(entries.map((entry) => String(entry.source || "").trim()).filter(Boolean));
}
function containsLatin(text) {
return /[A-Za-z]/.test(text);
}
function wordCount(text) {
return text.trim().split(/\s+/).filter(Boolean).length;
}
function unquoteScalar(raw) {
const value = raw.trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1).trim();
}
return value;
}
function isGlossaryCandidate(term, maxWords) {
if (!term) {
return false;
}
if (!containsLatin(term)) {
return false;
}
if (term.includes("`")) {
return false;
}
if (term.length > MAX_TERM_LENGTH) {
return false;
}
return wordCount(term) <= maxWords;
}
function readGitFile(base, relPath) {
try {
return runGit(["show", `${base}:${relPath}`]);
} catch {
return "";
}
}
/**
* @param {string} file
* @param {string} text
* @returns {Map<string, TermMatch>}
*/
function extractTerms(file, text) {
/** @type {Map<string, TermMatch>} */
const terms = new Map();
const lines = text.split("\n");
if (lines[0]?.trim() === "---") {
for (let index = 1; index < lines.length; index += 1) {
const line = lines[index];
if (line.trim() === "---") {
break;
}
const match = line.match(/^title:\s*(.+)\s*$/);
if (!match) {
continue;
}
const title = unquoteScalar(match[1]);
if (isGlossaryCandidate(title, MAX_TITLE_WORDS)) {
terms.set(title, { file, line: index + 1, kind: "title", term: title });
}
break;
}
}
for (let index = 0; index < lines.length; index += 1) {
const match = lines[index].match(LIST_ITEM_LINK_RE);
if (!match) {
continue;
}
const label = match[1].trim();
if (!isGlossaryCandidate(label, MAX_LABEL_WORDS)) {
continue;
}
if (!terms.has(label)) {
terms.set(label, { file, line: index + 1, kind: "link label", term: label });
}
}
return terms;
}
function main() {
const args = parseArgs(process.argv.slice(2));
const base = resolveBase(args.base);
if (!base) {
console.warn(
"docs:check-i18n-glossary: no merge base found; skipping glossary coverage check.",
);
process.exit(0);
}
const changedDocs = listChangedDocs(base, args.head);
if (changedDocs.length === 0) {
process.exit(0);
}
const glossary = loadGlossarySources();
/** @type {TermMatch[]} */
const missing = [];
for (const relPath of changedDocs) {
const absPath = path.join(ROOT, relPath);
if (!fs.existsSync(absPath)) {
continue;
}
const currentTerms = extractTerms(relPath, fs.readFileSync(absPath, "utf8"));
const baseTerms = extractTerms(relPath, readGitFile(base, relPath));
for (const [term, match] of currentTerms) {
if (baseTerms.has(term)) {
continue;
}
if (glossary.has(term)) {
continue;
}
missing.push(match);
}
}
if (missing.length === 0) {
process.exit(0);
}
console.error("docs:check-i18n-glossary: missing zh-CN glossary entries for changed doc labels:");
for (const match of missing) {
console.error(`- ${match.file}:${match.line} ${match.kind} "${match.term}"`);
}
console.error("");
console.error(
"Add exact source terms to docs/.i18n/glossary.zh-CN.json before rerunning docs-i18n.",
);
console.error(`Checked changed English docs relative to ${base}.`);
process.exit(1);
}
main();

View file

@ -0,0 +1,198 @@
#!/usr/bin/env node
import { promises as fs } from "node:fs";
import path from "node:path";
import ts from "typescript";
import {
collectTypeScriptFilesFromRoots,
resolveRepoRoot,
runAsScript,
toLine,
} from "./lib/ts-guard-utils.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
const defaultRoots = [path.join(repoRoot, "src"), path.join(repoRoot, "extensions")];
function readStringLiteral(node) {
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
return node.text;
}
return null;
}
function isTypeOnlyImportDeclaration(node) {
const clause = node.importClause;
if (!clause) {
return false;
}
if (clause.isTypeOnly) {
return true;
}
if (clause.name) {
return false;
}
const bindings = clause.namedBindings;
return (
Boolean(bindings) &&
ts.isNamedImports(bindings) &&
bindings.elements.length > 0 &&
bindings.elements.every((element) => element.isTypeOnly)
);
}
function readDeclarationName(node) {
if (
(ts.isFunctionDeclaration(node) ||
ts.isMethodDeclaration(node) ||
ts.isVariableDeclaration(node)) &&
node.name &&
ts.isIdentifier(node.name)
) {
return node.name.text;
}
if (ts.isPropertyAssignment(node)) {
if (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) {
return node.name.text;
}
}
return null;
}
function isIgnoredTestHelperContent(content) {
return /\bfrom\s+["']vitest["']/.test(content) || /\bfrom\s+["']@vitest\//.test(content);
}
function isIgnoredTestHelperPath(filePath) {
const normalized = filePath.split(path.sep).join("/");
const base = path.basename(filePath);
return (
normalized.includes("/test/") ||
/(?:^|[./-])test(?:[./-]|$)/.test(base) ||
base.includes("test-support") ||
base.includes("test-harness") ||
base.includes("test-helper") ||
base.includes("test-mocks")
);
}
export function findDynamicImportAdvisories(content, fileName = "source.ts") {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const staticRuntimeImports = new Map();
const dynamicImports = new Map();
const directExecuteImports = [];
const declarationStack = [];
const addLine = (map, specifier, line) => {
const lines = map.get(specifier) ?? [];
lines.push(line);
map.set(specifier, lines);
};
const visit = (node) => {
const declarationName = readDeclarationName(node);
if (declarationName) {
declarationStack.push(declarationName);
}
if (
ts.isImportDeclaration(node) &&
ts.isStringLiteral(node.moduleSpecifier) &&
!isTypeOnlyImportDeclaration(node)
) {
addLine(staticRuntimeImports, node.moduleSpecifier.text, toLine(sourceFile, node));
}
if (
ts.isCallExpression(node) &&
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
node.arguments.length > 0
) {
const specifier = readStringLiteral(node.arguments[0]);
if (specifier) {
const line = toLine(sourceFile, node);
addLine(dynamicImports, specifier, line);
if (declarationStack.includes("execute")) {
directExecuteImports.push({
line,
reason: `direct dynamic import of "${specifier}" inside execute path; move it behind a cached loader`,
});
}
}
}
ts.forEachChild(node, visit);
if (declarationName) {
declarationStack.pop();
}
};
visit(sourceFile);
const advisories = [...directExecuteImports];
for (const [specifier, dynamicLines] of dynamicImports) {
const staticLines = staticRuntimeImports.get(specifier);
if (staticLines?.length) {
advisories.push({
line: dynamicLines[0],
reason: `runtime static + dynamic import of "${specifier}" (static line ${staticLines[0]})`,
});
}
if (dynamicLines.length > 1) {
advisories.push({
line: dynamicLines[0],
reason: `repeated direct dynamic import of "${specifier}" (${dynamicLines.length} callsites: ${dynamicLines.join(", ")})`,
});
}
}
return advisories;
}
export async function collectDynamicImportAdvisories(options = {}) {
const roots = options.roots ?? defaultRoots;
const files = await collectTypeScriptFilesFromRoots(roots, {
extraTestSuffixes: [".suite.ts"],
});
const advisories = [];
for (const filePath of files) {
if (isIgnoredTestHelperPath(filePath)) {
continue;
}
const content = await fs.readFile(filePath, "utf8");
if (isIgnoredTestHelperContent(content)) {
continue;
}
for (const advisory of findDynamicImportAdvisories(content, filePath)) {
advisories.push({
path: path.relative(repoRoot, filePath),
...advisory,
});
}
}
return advisories;
}
export async function main(argv = process.argv.slice(2)) {
const fail = argv.includes("--fail");
const json = argv.includes("--json");
const advisories = await collectDynamicImportAdvisories();
if (json) {
console.log(JSON.stringify({ advisories }, null, 2));
} else if (advisories.length === 0) {
console.log("No dynamic import advisories found.");
} else {
console.log(`Dynamic import advisories (${advisories.length}):`);
for (const advisory of advisories) {
console.log(`- ${advisory.path}:${advisory.line} ${advisory.reason}`);
}
console.log("Advisory only. Use --fail when ratcheting this into a hard check.");
}
if (fail && advisories.length > 0) {
process.exit(1);
}
}
runAsScript(import.meta.url, main);

View file

@ -0,0 +1,818 @@
#!/usr/bin/env node
import { spawn, spawnSync } from "node:child_process";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path, { dirname, join, resolve } from "node:path";
const require = createRequire(import.meta.url);
const repoRoot = resolve(import.meta.dirname, "..");
const tscBin = require.resolve("typescript/bin/tsc");
const prepareBoundaryArtifactsBin = resolve(
repoRoot,
"scripts/prepare-extension-package-boundary-artifacts.mjs",
);
const extensionPackageBoundaryBaseConfig = "../tsconfig.package-boundary.base.json";
const FAILURE_OUTPUT_TAIL_LINES = 40;
const SLOW_COMPILE_SUMMARY_LIMIT = 10;
const COMPILE_INPUT_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".json"]);
const ROOTDIR_BOUNDARY_CANARY_IMPORT_PATH =
"../../src/plugins/contracts/rootdir-boundary-canary.ts";
const ROOTDIR_BOUNDARY_CANARY_OUTPUT_HINT = "src/plugins/contracts/rootdir-boundary-canary.ts";
function parseMode(argv) {
const modeArg = argv.find((arg) => arg.startsWith("--mode="));
const mode = modeArg?.slice("--mode=".length) ?? "all";
if (!new Set(["all", "compile", "canary"]).has(mode)) {
throw new Error(`Unknown mode: ${mode}`);
}
return mode;
}
function resolveCompileConcurrency() {
const raw = process.env.OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY;
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
if (Number.isInteger(parsed) && parsed > 0) {
return parsed;
}
return Math.max(1, Math.min(6, Math.floor(os.availableParallelism() / 2)));
}
function readJsonFile(filePath) {
return JSON.parse(readFileSync(filePath, "utf8"));
}
function summarizeOutputSection(name, output) {
const trimmed = output.trim();
if (!trimmed) {
return "";
}
const lines = trimmed.split("\n");
if (lines.length <= FAILURE_OUTPUT_TAIL_LINES) {
return `${name}:\n${trimmed}`;
}
const omittedLineCount = lines.length - FAILURE_OUTPUT_TAIL_LINES;
const tail = lines.slice(-FAILURE_OUTPUT_TAIL_LINES).join("\n");
return `${name}:\n[... ${omittedLineCount} earlier lines omitted ...]\n${tail}`;
}
function formatFailureFooter(params = {}) {
const footerLines = [];
if (params.kind) {
footerLines.push(`kind: ${params.kind}`);
}
if (Number.isFinite(params.elapsedMs)) {
footerLines.push(`elapsed: ${params.elapsedMs}ms`);
}
if (params.note) {
footerLines.push(params.note);
}
return footerLines.join("\n");
}
export function formatBoundaryCheckSuccessSummary(params = {}) {
const lines = ["extension package boundary check passed"];
if (params.mode) {
lines.push(`mode: ${params.mode}`);
}
if (Number.isInteger(params.compileCount)) {
lines.push(`compiled plugins: ${params.compileCount}`);
}
if (Number.isInteger(params.skippedCompileCount) && params.skippedCompileCount > 0) {
lines.push(`skipped plugins: ${params.skippedCompileCount}`);
}
if (Number.isInteger(params.canaryCount)) {
lines.push(`canary plugins: ${params.canaryCount}`);
}
if (Number.isFinite(params.prepElapsedMs) && params.prepElapsedMs > 0) {
lines.push(`prep elapsed: ${params.prepElapsedMs}ms`);
}
if (Number.isFinite(params.compileElapsedMs) && params.compileElapsedMs > 0) {
lines.push(`compile elapsed: ${params.compileElapsedMs}ms`);
}
if (Number.isFinite(params.canaryElapsedMs) && params.canaryElapsedMs > 0) {
lines.push(`canary elapsed: ${params.canaryElapsedMs}ms`);
}
if (Number.isFinite(params.elapsedMs)) {
lines.push(`elapsed: ${params.elapsedMs}ms`);
}
return `${lines.join("\n")}\n`;
}
export function formatSkippedCompileProgress(params = {}) {
const skippedCount = params.skippedCount ?? 0;
const totalCount = params.totalCount ?? 0;
if (!Number.isInteger(skippedCount) || skippedCount <= 0) {
return "";
}
const staleCount = Math.max(0, totalCount - skippedCount);
if (staleCount > 0) {
return `skipped ${skippedCount} fresh plugin compiles before running ${staleCount} stale plugin checks\n`;
}
return `skipped ${skippedCount} fresh plugin compiles\n`;
}
export function formatSlowCompileSummary(params = {}) {
const compileTimings = Array.isArray(params.compileTimings) ? params.compileTimings : [];
if (compileTimings.length === 0) {
return "";
}
const limit =
Number.isInteger(params.limit) && params.limit > 0 ? params.limit : SLOW_COMPILE_SUMMARY_LIMIT;
const lines = ["slowest plugin compiles:"];
for (const timing of [...compileTimings]
.toSorted((left, right) => right.elapsedMs - left.elapsedMs)
.slice(0, limit)) {
lines.push(`- ${timing.extensionId}: ${timing.elapsedMs}ms`);
}
return `${lines.join("\n")}\n`;
}
export function formatStepFailure(label, params = {}) {
const stdoutSection = summarizeOutputSection("stdout", params.stdout ?? "");
const stderrSection = summarizeOutputSection("stderr", params.stderr ?? "");
const footer = formatFailureFooter(params);
return [label, stdoutSection, stderrSection, footer].filter(Boolean).join("\n\n");
}
function attachStepFailureMetadata(error, label, params = {}) {
error.stepLabel = label;
error.kind = params.kind ?? "unknown";
error.elapsedMs = params.elapsedMs ?? null;
error.fullOutput = [label, params.stdout ?? "", params.stderr ?? "", formatFailureFooter(params)]
.filter(Boolean)
.join("\n")
.trim();
return error;
}
function collectBundledExtensionIds() {
return readdirSync(join(repoRoot, "extensions"), { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.toSorted();
}
function resolveExtensionTsconfigPath(extensionId) {
return join(repoRoot, "extensions", extensionId, "tsconfig.json");
}
function readExtensionTsconfig(extensionId) {
return readJsonFile(resolveExtensionTsconfigPath(extensionId));
}
function collectOptInExtensionIds() {
return collectBundledExtensionIds().filter((extensionId) => {
const tsconfigPath = resolveExtensionTsconfigPath(extensionId);
if (!existsSync(tsconfigPath)) {
return false;
}
return readExtensionTsconfig(extensionId).extends === extensionPackageBoundaryBaseConfig;
});
}
function collectCanaryExtensionIds(extensionIds) {
return [
...new Map(
extensionIds.map((extensionId) => [
JSON.stringify(readExtensionTsconfig(extensionId)),
extensionId,
]),
).values(),
];
}
function isRelevantCompileInput(filePath) {
const basename = path.basename(filePath);
if (
basename === "__rootdir_boundary_canary__.ts" ||
basename === "tsconfig.rootdir-canary.json"
) {
return false;
}
if (basename.endsWith(".tsbuildinfo")) {
return false;
}
return COMPILE_INPUT_EXTENSIONS.has(path.extname(filePath));
}
function collectNewestMtime(entryPath, params = {}) {
const includeFile = params.includeFile ?? (() => true);
const skipDistDirectories = params.skipDistDirectories ?? true;
let newestMtimeMs = 0;
function visit(currentPath) {
if (!existsSync(currentPath)) {
return;
}
const stats = statSync(currentPath);
if (stats.isDirectory()) {
const basename = path.basename(currentPath);
if ((skipDistDirectories && basename === "dist") || basename === "node_modules") {
return;
}
for (const child of readdirSync(currentPath)) {
visit(path.join(currentPath, child));
}
return;
}
if (!includeFile(currentPath)) {
return;
}
newestMtimeMs = Math.max(newestMtimeMs, stats.mtimeMs);
}
visit(entryPath);
return newestMtimeMs;
}
function collectOldestMtime(paths) {
let oldestMtimeMs = Number.POSITIVE_INFINITY;
for (const entryPath of paths) {
if (!existsSync(entryPath)) {
return null;
}
oldestMtimeMs = Math.min(oldestMtimeMs, statSync(entryPath).mtimeMs);
}
return Number.isFinite(oldestMtimeMs) ? oldestMtimeMs : null;
}
export function isBoundaryCompileFresh(extensionId, params = {}) {
const rootDir = params.rootDir ?? repoRoot;
const extensionRoot = resolve(rootDir, "extensions", extensionId);
const extensionNewestInputMtimeMs =
params.extensionNewestInputMtimeMs ??
collectNewestMtime(extensionRoot, { includeFile: isRelevantCompileInput });
const sharedNewestInputMtimeMs =
params.sharedNewestInputMtimeMs ??
Math.max(
collectNewestMtime(resolve(rootDir, "dist/plugin-sdk"), {
skipDistDirectories: false,
}),
collectNewestMtime(resolve(rootDir, "packages/plugin-sdk/dist"), {
skipDistDirectories: false,
}),
);
const newestInputMtimeMs = Math.max(extensionNewestInputMtimeMs, sharedNewestInputMtimeMs);
const oldestOutputMtimeMs = collectOldestMtime([
resolveBoundaryTsStampPath(extensionId, rootDir),
]);
return oldestOutputMtimeMs !== null && oldestOutputMtimeMs >= newestInputMtimeMs;
}
function writeStampFile(filePath) {
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${new Date().toISOString()}\n`, "utf8");
}
function runNodeStep(label, args, timeoutMs) {
const startedAt = Date.now();
const result = spawnSync(process.execPath, args, {
cwd: repoRoot,
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
timeout: timeoutMs,
});
if (result.status === 0 && !result.error) {
return result;
}
const timeoutSuffix =
result.error?.name === "Error" && result.error.message.includes("ETIMEDOUT")
? `${label} timed out after ${timeoutMs}ms`
: "";
const errorSuffix = result.error ? result.error.message : "";
const note = [timeoutSuffix, errorSuffix].filter(Boolean).join("\n");
const elapsedMs = Date.now() - startedAt;
const kind = timeoutSuffix ? "timeout" : result.error ? "spawn-error" : "nonzero-exit";
const failure = attachStepFailureMetadata(
new Error(
formatStepFailure(label, {
stdout: result.stdout,
stderr: result.stderr,
kind,
elapsedMs,
note,
}),
),
label,
{
stdout: result.stdout,
stderr: result.stderr,
kind,
elapsedMs,
note,
},
);
failure.status = result.status ?? 1;
throw failure;
}
function abortSiblingSteps(abortController) {
if (abortController && !abortController.signal.aborted) {
abortController.abort();
}
}
export function runNodeStepAsync(label, args, timeoutMs, params = {}) {
const abortController = params.abortController;
const onFailure = params.onFailure;
const startedAt = Date.now();
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn(process.execPath, args, {
cwd: repoRoot,
env: process.env,
signal: abortController?.signal,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let settled = false;
const timer = setTimeout(() => {
if (settled) {
return;
}
child.kill("SIGTERM");
settled = true;
const error = attachStepFailureMetadata(
new Error(
formatStepFailure(label, {
stdout,
stderr,
kind: "timeout",
elapsedMs: Date.now() - startedAt,
note: `${label} timed out after ${timeoutMs}ms`,
}),
),
label,
{
stdout,
stderr,
kind: "timeout",
elapsedMs: Date.now() - startedAt,
note: `${label} timed out after ${timeoutMs}ms`,
},
);
onFailure?.(error);
abortSiblingSteps(abortController);
rejectPromise(error);
}, timeoutMs);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", (error) => {
if (settled) {
return;
}
clearTimeout(timer);
settled = true;
if (error.name === "AbortError" && abortController?.signal.aborted) {
rejectPromise(
attachStepFailureMetadata(new Error(`${label} canceled after sibling failure`), label, {
kind: "canceled",
elapsedMs: Date.now() - startedAt,
note: "canceled after sibling failure",
}),
);
return;
}
const failure = attachStepFailureMetadata(
new Error(
formatStepFailure(label, {
stdout,
stderr,
kind: "spawn-error",
elapsedMs: Date.now() - startedAt,
note: error.message,
}),
),
label,
{
stdout,
stderr,
kind: "spawn-error",
elapsedMs: Date.now() - startedAt,
note: error.message,
},
);
onFailure?.(failure);
abortSiblingSteps(abortController);
rejectPromise(failure);
});
child.on("close", (code) => {
if (settled) {
return;
}
clearTimeout(timer);
settled = true;
if (code === 0) {
resolvePromise({ stdout, stderr, elapsedMs: Date.now() - startedAt });
return;
}
const error = attachStepFailureMetadata(
new Error(
formatStepFailure(label, {
stdout,
stderr,
kind: "nonzero-exit",
elapsedMs: Date.now() - startedAt,
}),
),
label,
{
stdout,
stderr,
kind: "nonzero-exit",
elapsedMs: Date.now() - startedAt,
},
);
onFailure?.(error);
abortSiblingSteps(abortController);
rejectPromise(error);
});
});
}
export async function runNodeStepsWithConcurrency(steps, concurrency) {
const abortController = new AbortController();
let firstFailure = null;
let nextIndex = 0;
const workers = Array.from({ length: Math.min(concurrency, steps.length) }, async () => {
while (true) {
if (abortController.signal.aborted) {
return;
}
const index = nextIndex;
nextIndex += 1;
if (index >= steps.length) {
return;
}
const step = steps[index];
step.onStart?.();
const result = await runNodeStepAsync(step.label, step.args, step.timeoutMs, {
abortController,
onFailure(error) {
firstFailure ??= error;
},
});
step.onSuccess?.(result);
}
});
await Promise.allSettled(workers);
if (firstFailure) {
throw firstFailure;
}
}
export function resolveCanaryArtifactPaths(extensionId, rootDir = repoRoot) {
const extensionRoot = resolve(rootDir, "extensions", extensionId);
return {
extensionRoot,
canaryPath: resolve(extensionRoot, "__rootdir_boundary_canary__.ts"),
tsconfigPath: resolve(extensionRoot, "tsconfig.rootdir-canary.json"),
};
}
export function cleanupCanaryArtifacts(extensionId, rootDir = repoRoot) {
const { canaryPath, tsconfigPath } = resolveCanaryArtifactPaths(extensionId, rootDir);
rmSync(canaryPath, { force: true });
rmSync(tsconfigPath, { force: true });
}
export function cleanupCanaryArtifactsForExtensions(extensionIds, rootDir = repoRoot) {
for (const extensionId of extensionIds) {
cleanupCanaryArtifacts(extensionId, rootDir);
}
}
export function installCanaryArtifactCleanup(extensionIds, params = {}) {
const rootDir = params.rootDir ?? repoRoot;
const processObject = params.processObject ?? process;
const exitHandler = () => {
cleanupCanaryArtifactsForExtensions(extensionIds, rootDir);
};
processObject.on("exit", exitHandler);
return () => {
processObject.off("exit", exitHandler);
};
}
function resolveBoundaryTsBuildInfoPath(extensionId) {
return resolve(repoRoot, "extensions", extensionId, "dist", ".boundary-tsc.tsbuildinfo");
}
function resolveBoundaryTsStampPath(extensionId, rootDir = repoRoot) {
return resolve(rootDir, "extensions", extensionId, "dist", ".boundary-tsc.stamp");
}
export function resolveBoundaryCheckLockPath(rootDir = repoRoot) {
return resolve(rootDir, "dist", ".extension-package-boundary.lock");
}
function resolveBoundaryCheckLockOwnerPath(lockPath) {
return join(lockPath, "owner.json");
}
function isProcessAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (error) {
return Boolean(error && typeof error === "object" && "code" in error && error.code === "EPERM");
}
}
function removeStaleBoundaryCheckLock(lockPath) {
const ownerPath = resolveBoundaryCheckLockOwnerPath(lockPath);
let owner;
try {
owner = JSON.parse(readFileSync(ownerPath, "utf8"));
} catch {
rmSync(lockPath, { force: true, recursive: true });
return true;
}
if (owner && typeof owner === "object" && isProcessAlive(owner.pid)) {
return false;
}
rmSync(lockPath, { force: true, recursive: true });
return true;
}
export function acquireBoundaryCheckLock(params = {}) {
const rootDir = params.rootDir ?? repoRoot;
const processObject = params.processObject ?? process;
const lockPath = resolveBoundaryCheckLockPath(rootDir);
mkdirSync(dirname(lockPath), { recursive: true });
try {
mkdirSync(lockPath);
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "EEXIST") {
if (removeStaleBoundaryCheckLock(lockPath)) {
mkdirSync(lockPath);
} else {
throw attachStepFailureMetadata(
new Error(
[
"extension package boundary check",
"kind: lock-contention",
`lock: ${lockPath}`,
"another extension package boundary check is already running in this checkout",
].join("\n\n"),
{ cause: error },
),
"extension package boundary check",
{
kind: "lock-contention",
note: `lock: ${lockPath}\nanother extension package boundary check is already running in this checkout`,
},
);
}
} else {
throw error;
}
}
writeFileSync(
resolveBoundaryCheckLockOwnerPath(lockPath),
`${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }, null, 2)}\n`,
"utf8",
);
const release = () => {
rmSync(lockPath, { force: true, recursive: true });
};
processObject.on("exit", release);
return () => {
processObject.off("exit", release);
release();
};
}
async function runCompileCheck(extensionIds) {
const prepStartedAt = Date.now();
process.stdout.write(
`preparing plugin-sdk boundary artifacts for ${extensionIds.length} plugins\n`,
);
runNodeStep("plugin-sdk boundary prep", [prepareBoundaryArtifactsBin], 420_000);
const prepElapsedMs = Date.now() - prepStartedAt;
const concurrency = resolveCompileConcurrency();
const verboseFreshLogs = process.env.OPENCLAW_EXTENSION_BOUNDARY_VERBOSE_FRESH === "1";
const sharedNewestInputMtimeMs = Math.max(
collectNewestMtime(resolve(repoRoot, "dist/plugin-sdk"), {
skipDistDirectories: false,
}),
collectNewestMtime(resolve(repoRoot, "packages/plugin-sdk/dist"), {
skipDistDirectories: false,
}),
);
process.stdout.write(`compile concurrency ${concurrency}\n`);
const compileStartedAt = Date.now();
let skippedCompileCount = 0;
const compileTimings = [];
const steps = extensionIds
.map((extensionId, index) => {
const tsBuildInfoPath = resolveBoundaryTsBuildInfoPath(extensionId);
const extensionNewestInputMtimeMs = collectNewestMtime(
resolve(repoRoot, "extensions", extensionId),
{
includeFile: isRelevantCompileInput,
},
);
mkdirSync(dirname(tsBuildInfoPath), { recursive: true });
if (
isBoundaryCompileFresh(extensionId, {
extensionNewestInputMtimeMs,
sharedNewestInputMtimeMs,
})
) {
skippedCompileCount += 1;
if (verboseFreshLogs) {
process.stdout.write(
`[${index + 1}/${extensionIds.length}] ${extensionId} (fresh; skipping)\n`,
);
}
return null;
}
return {
label: extensionId,
onStart() {
process.stdout.write(`[${index + 1}/${extensionIds.length}] ${extensionId}\n`);
},
onSuccess(result) {
writeStampFile(resolveBoundaryTsStampPath(extensionId));
compileTimings.push({
extensionId,
elapsedMs: result.elapsedMs,
});
},
args: [
tscBin,
"-p",
resolve(repoRoot, "extensions", extensionId, "tsconfig.json"),
"--noEmit",
"--incremental",
"--tsBuildInfoFile",
tsBuildInfoPath,
],
timeoutMs: 120_000,
};
})
.filter(Boolean);
if (!verboseFreshLogs && skippedCompileCount > 0) {
process.stdout.write(
formatSkippedCompileProgress({
skippedCount: skippedCompileCount,
totalCount: extensionIds.length,
}),
);
}
if (steps.length > 0) {
await runNodeStepsWithConcurrency(steps, concurrency);
}
return {
prepElapsedMs,
compileCount: steps.length,
skippedCompileCount,
compileElapsedMs: Date.now() - compileStartedAt,
compileTimings,
};
}
async function runCanaryCheck(extensionIds) {
const startedAt = Date.now();
await Promise.all(
extensionIds.map(async (extensionId, index) => {
const { canaryPath, tsconfigPath } = resolveCanaryArtifactPaths(extensionId);
cleanupCanaryArtifacts(extensionId);
process.stdout.write(`[${index + 1}/${extensionIds.length}] ${extensionId} canary\n`);
try {
writeFileSync(
canaryPath,
[
`import { ROOTDIR_BOUNDARY_CANARY } from "${ROOTDIR_BOUNDARY_CANARY_IMPORT_PATH}";`,
"void ROOTDIR_BOUNDARY_CANARY;",
"export {};",
"",
].join("\n"),
"utf8",
);
writeFileSync(
tsconfigPath,
`${JSON.stringify(
{
extends: "./tsconfig.json",
include: ["./__rootdir_boundary_canary__.ts"],
exclude: [],
},
null,
2,
)}\n`,
"utf8",
);
const result = await runNodeStepAsync(
`${extensionId} canary`,
[tscBin, "-p", tsconfigPath, "--noEmit"],
120_000,
);
throw new Error(
`${extensionId} canary unexpectedly passed\n${result.stdout}${result.stderr}`,
);
} catch (error) {
const output =
error instanceof Error && typeof error.fullOutput === "string"
? error.fullOutput
: String(error);
if (!output.includes("TS6059") || !output.includes(ROOTDIR_BOUNDARY_CANARY_OUTPUT_HINT)) {
throw error;
}
} finally {
cleanupCanaryArtifacts(extensionId);
}
}),
);
return {
canaryElapsedMs: Date.now() - startedAt,
};
}
export async function main(argv = process.argv.slice(2)) {
const startedAt = Date.now();
const mode = parseMode(argv);
const optInExtensionIds = collectOptInExtensionIds();
const canaryExtensionIds = collectCanaryExtensionIds(optInExtensionIds);
const cleanupExtensionIds = optInExtensionIds;
const shouldRunCanary = mode === "all" || mode === "canary";
const releaseBoundaryLock = acquireBoundaryCheckLock();
const teardownCanaryCleanup = installCanaryArtifactCleanup(cleanupExtensionIds);
let prepElapsedMs;
let compileCount = 0;
let skippedCompileCount = 0;
let compileElapsedMs;
let compileTimings = [];
let canaryElapsedMs;
try {
cleanupCanaryArtifactsForExtensions(cleanupExtensionIds);
if (mode === "all" || mode === "compile") {
({ prepElapsedMs, compileCount, skippedCompileCount, compileElapsedMs, compileTimings } =
await runCompileCheck(optInExtensionIds));
}
if (shouldRunCanary) {
({ canaryElapsedMs } = await runCanaryCheck(canaryExtensionIds));
}
process.stdout.write(
formatBoundaryCheckSuccessSummary({
mode,
compileCount,
skippedCompileCount,
canaryCount: shouldRunCanary ? canaryExtensionIds.length : 0,
prepElapsedMs,
compileElapsedMs,
canaryElapsedMs,
elapsedMs: Date.now() - startedAt,
}),
);
process.stdout.write(
formatSlowCompileSummary({
compileTimings,
}),
);
} finally {
releaseBoundaryLock?.();
teardownCanaryCleanup?.();
cleanupCanaryArtifactsForExtensions(cleanupExtensionIds);
}
}
if (import.meta.main) {
await main();
}

View file

@ -0,0 +1,363 @@
#!/usr/bin/env node
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import {
BUNDLED_PLUGIN_PATH_PREFIX,
BUNDLED_PLUGIN_ROOT_DIR,
} from "./lib/bundled-plugin-paths.mjs";
import { classifyBundledExtensionSourcePath } from "./lib/extension-source-classifier.mjs";
import {
diffInventoryEntries,
normalizeRepoPath,
resolveRepoSpecifier,
visitModuleSpecifiers,
writeLine,
} from "./lib/guard-inventory-utils.mjs";
import { toLine } from "./lib/ts-guard-utils.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const extensionsRoot = path.join(repoRoot, BUNDLED_PLUGIN_ROOT_DIR);
const MODES = new Set([
"src-outside-plugin-sdk",
"plugin-sdk-internal",
"relative-outside-package",
]);
const baselinePathByMode = {
"src-outside-plugin-sdk": path.join(
repoRoot,
"test",
"fixtures",
"extension-src-outside-plugin-sdk-inventory.json",
),
"plugin-sdk-internal": path.join(
repoRoot,
"test",
"fixtures",
"extension-plugin-sdk-internal-inventory.json",
),
"relative-outside-package": path.join(
repoRoot,
"test",
"fixtures",
"extension-relative-outside-package-inventory.json",
),
};
let allInventoryByModePromise;
let parsedExtensionSourceFilesPromise;
const ruleTextByMode = {
"src-outside-plugin-sdk":
"Rule: production bundled plugins must not import src/** outside src/plugin-sdk/**",
"plugin-sdk-internal":
"Rule: production bundled plugins must not import src/plugin-sdk-internal/**",
"relative-outside-package":
"Rule: production bundled plugins must not use relative imports that escape their own package root",
};
function isCodeFile(fileName) {
return /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(fileName);
}
function isBoundaryCanaryFile(fileName) {
return fileName.includes("__rootdir_boundary_canary__");
}
async function collectExtensionSourceFiles(rootDir) {
const out = [];
async function walk(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === "dist" || entry.name === "node_modules") {
continue;
}
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
continue;
}
if (!entry.isFile() || !isCodeFile(entry.name) || isBoundaryCanaryFile(entry.name)) {
continue;
}
const relativePath = normalizeRepoPath(repoRoot, fullPath);
if (classifyBundledExtensionSourcePath(relativePath).isTestLike) {
continue;
}
out.push(fullPath);
}
}
await walk(rootDir);
return out.toSorted((left, right) =>
normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)),
);
}
async function collectParsedExtensionSourceFiles() {
if (!parsedExtensionSourceFilesPromise) {
parsedExtensionSourceFilesPromise = (async () => {
const files = await collectExtensionSourceFiles(extensionsRoot);
return await Promise.all(
files.map(async (filePath) => {
const source = await fs.readFile(filePath, "utf8");
const scriptKind =
filePath.endsWith(".tsx") || filePath.endsWith(".jsx")
? ts.ScriptKind.TSX
: ts.ScriptKind.TS;
return {
filePath,
sourceFile: ts.createSourceFile(
filePath,
source,
ts.ScriptTarget.Latest,
true,
scriptKind,
),
};
}),
);
})();
}
return await parsedExtensionSourceFilesPromise;
}
function resolveExtensionRoot(filePath) {
const relativePath = normalizeRepoPath(repoRoot, filePath);
const segments = relativePath.split("/");
if (segments[0] !== BUNDLED_PLUGIN_ROOT_DIR || !segments[1]) {
return null;
}
return `${segments[0]}/${segments[1]}`;
}
function classifyReason(mode, kind, resolvedPath, specifier) {
const verb =
kind === "export"
? "re-exports"
: kind === "dynamic-import"
? "dynamically imports"
: "imports";
if (mode === "relative-outside-package") {
if (resolvedPath?.startsWith("src/plugin-sdk/")) {
return `${verb} plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>`;
}
if (resolvedPath?.startsWith("src/")) {
return `${verb} core src path via relative path outside the extension package`;
}
if (resolvedPath?.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) {
return `${verb} another bundled plugin via relative path outside the extension package`;
}
return `${verb} relative path ${specifier} outside the extension package`;
}
if (mode === "plugin-sdk-internal") {
return `${verb} src/plugin-sdk-internal from an extension`;
}
if (resolvedPath.startsWith("src/plugin-sdk/")) {
return `${verb} allowed plugin-sdk path`;
}
return `${verb} core src path outside plugin-sdk from an extension`;
}
function compareEntries(left, right) {
return (
left.file.localeCompare(right.file) ||
left.line - right.line ||
left.kind.localeCompare(right.kind) ||
left.specifier.localeCompare(right.specifier) ||
left.resolvedPath.localeCompare(right.resolvedPath) ||
left.reason.localeCompare(right.reason)
);
}
function shouldReport(mode, resolvedPath) {
if (mode === "relative-outside-package") {
return false;
}
if (!resolvedPath?.startsWith("src/")) {
return false;
}
if (mode === "plugin-sdk-internal") {
return resolvedPath.startsWith("src/plugin-sdk-internal/");
}
return !resolvedPath.startsWith("src/plugin-sdk/");
}
function collectEntriesByModeFromSourceFile(sourceFile, filePath) {
const entriesByMode = {
"src-outside-plugin-sdk": [],
"plugin-sdk-internal": [],
"relative-outside-package": [],
};
const extensionRoot = resolveExtensionRoot(filePath);
const relativeFile = normalizeRepoPath(repoRoot, filePath);
function push(kind, specifierNode, specifier) {
const resolvedPath = resolveRepoSpecifier(repoRoot, specifier, filePath);
const baseEntry = {
file: relativeFile,
line: toLine(sourceFile, specifierNode),
kind,
specifier,
resolvedPath,
};
if (specifier.startsWith(".") && resolvedPath && extensionRoot) {
if (!(resolvedPath === extensionRoot || resolvedPath.startsWith(`${extensionRoot}/`))) {
entriesByMode["relative-outside-package"].push({
...baseEntry,
reason: classifyReason("relative-outside-package", kind, resolvedPath, specifier),
});
}
}
for (const mode of ["src-outside-plugin-sdk", "plugin-sdk-internal"]) {
if (!shouldReport(mode, resolvedPath)) {
continue;
}
entriesByMode[mode].push({
...baseEntry,
reason: classifyReason(mode, kind, resolvedPath, specifier),
});
}
}
visitModuleSpecifiers(ts, sourceFile, ({ kind, specifier, specifierNode }) => {
push(kind, specifierNode, specifier);
});
return entriesByMode;
}
export async function collectExtensionPluginSdkBoundaryInventory(mode) {
if (!MODES.has(mode)) {
throw new Error(`Unknown mode: ${mode}`);
}
if (!allInventoryByModePromise) {
allInventoryByModePromise = (async () => {
const files = await collectParsedExtensionSourceFiles();
const inventoryByMode = {
"src-outside-plugin-sdk": [],
"plugin-sdk-internal": [],
"relative-outside-package": [],
};
for (const { filePath, sourceFile } of files) {
const entriesByMode = collectEntriesByModeFromSourceFile(sourceFile, filePath);
for (const inventoryMode of MODES) {
inventoryByMode[inventoryMode].push(...entriesByMode[inventoryMode]);
}
}
for (const inventoryMode of MODES) {
inventoryByMode[inventoryMode] = inventoryByMode[inventoryMode].toSorted(compareEntries);
}
return inventoryByMode;
})();
}
const inventoryByMode = await allInventoryByModePromise;
return inventoryByMode[mode];
}
export async function readExpectedInventory(mode) {
try {
return JSON.parse(await fs.readFile(baselinePathByMode[mode], "utf8"));
} catch (error) {
if (
(mode === "plugin-sdk-internal" ||
mode === "src-outside-plugin-sdk" ||
mode === "relative-outside-package") &&
error &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT"
) {
return [];
}
throw error;
}
}
export function diffInventory(expected, actual) {
return diffInventoryEntries(expected, actual, compareEntries);
}
function formatInventoryHuman(mode, inventory) {
const lines = [ruleTextByMode[mode]];
if (inventory.length === 0) {
lines.push("No extension plugin-sdk boundary violations found.");
return lines.join("\n");
}
lines.push("Extension boundary inventory:");
let activeFile = "";
for (const entry of inventory) {
if (entry.file !== activeFile) {
activeFile = entry.file;
lines.push(activeFile);
}
lines.push(` - line ${entry.line} [${entry.kind}] ${entry.reason}`);
lines.push(` specifier: ${entry.specifier}`);
lines.push(` resolved: ${entry.resolvedPath}`);
}
return lines.join("\n");
}
export async function runExtensionPluginSdkBoundaryCheck(argv = process.argv.slice(2), io) {
const streams = io ?? { stdout: process.stdout, stderr: process.stderr };
const json = argv.includes("--json");
const modeArg = argv.find((arg) => arg.startsWith("--mode="));
const mode = modeArg?.slice("--mode=".length) ?? "src-outside-plugin-sdk";
if (!MODES.has(mode)) {
throw new Error(`Unknown mode: ${mode}`);
}
const actual = await collectExtensionPluginSdkBoundaryInventory(mode);
if (json) {
writeLine(streams.stdout, JSON.stringify(actual, null, 2));
return 0;
}
writeLine(streams.stdout, formatInventoryHuman(mode, actual));
if (mode === "relative-outside-package") {
if (actual.length === 0) {
return 0;
}
writeLine(
streams.stderr,
`Relative outside-package violations found (${actual.length}); this mode no longer uses a baseline.`,
);
return 1;
}
const expected = await readExpectedInventory(mode);
const diff = diffInventory(expected, actual);
if (diff.missing.length === 0 && diff.unexpected.length === 0) {
writeLine(streams.stdout, `Baseline matches (${actual.length} entries).`);
return 0;
}
if (diff.missing.length > 0) {
writeLine(streams.stderr, `Missing baseline entries (${diff.missing.length}):`);
for (const entry of diff.missing) {
writeLine(streams.stderr, ` - ${entry.file}:${entry.line} ${entry.reason}`);
}
}
if (diff.unexpected.length > 0) {
writeLine(streams.stderr, `Unexpected inventory entries (${diff.unexpected.length}):`);
for (const entry of diff.unexpected) {
writeLine(streams.stderr, ` - ${entry.file}:${entry.line} ${entry.reason}`);
}
}
return 1;
}
export async function main(argv = process.argv.slice(2), io) {
const exitCode = await runExtensionPluginSdkBoundaryCheck(argv, io);
if (!io) {
process.exitCode = exitCode;
}
return exitCode;
}
if (path.resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) {
await main();
}

View file

@ -0,0 +1,63 @@
import fs from "node:fs";
import path from "node:path";
const DEFAULT_SKIPPED_DIR_NAMES = new Set(["node_modules", "dist", "coverage", ".generated"]);
export function isCodeFile(filePath: string): boolean {
if (filePath.endsWith(".d.ts")) {
return false;
}
return /\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/u.test(filePath);
}
export function collectFilesSync(
rootDir: string,
options: {
includeFile: (filePath: string) => boolean;
skipDirNames?: ReadonlySet<string>;
},
): string[] {
const skipDirNames = options.skipDirNames ?? DEFAULT_SKIPPED_DIR_NAMES;
const files: string[] = [];
const stack = [rootDir];
while (stack.length > 0) {
const current = stack.pop();
if (!current) {
continue;
}
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
if (skipDirNames.has(entry.name)) {
continue;
}
stack.push(fullPath);
continue;
}
if (entry.isFile() && options.includeFile(fullPath)) {
files.push(fullPath);
}
}
}
return files;
}
export function toPosixPath(filePath: string): string {
if (path.sep === "/") {
return filePath;
}
return filePath.replaceAll("\\", "/");
}
export function relativeToCwd(filePath: string): string {
const relativePath = path.relative(process.cwd(), filePath) || filePath;
return toPosixPath(relativePath);
}

View file

@ -0,0 +1,700 @@
#!/usr/bin/env node
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { writeBuildStamp } from "./build-stamp.mjs";
import { resolveBuildRequirement } from "./run-node.mjs";
const DEFAULTS = {
outputDir: path.join(process.cwd(), ".local", "gateway-watch-regression"),
windowMs: 10_000,
readyTimeoutMs: 20_000,
readySettleMs: 500,
sigkillGraceMs: 10_000,
cpuWarnMs: 1_000,
cpuFailMs: 8_000,
distRuntimeFileGrowthMax: 200,
distRuntimeByteGrowthMax: 2 * 1024 * 1024,
keepLogs: true,
skipBuild: false,
};
const WATCH_GATEWAY_SKIP_ENV = {
OPENCLAW_DISABLE_BONJOUR: "1",
OPENCLAW_SKIP_ACPX_RUNTIME: "1",
OPENCLAW_SKIP_ACPX_RUNTIME_PROBE: "1",
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1",
OPENCLAW_SKIP_CANVAS_HOST: "1",
OPENCLAW_SKIP_CHANNELS: "1",
OPENCLAW_SKIP_CRON: "1",
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
};
function parseArgs(argv) {
const options = { ...DEFAULTS };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
const next = argv[i + 1];
const readValue = () => {
if (!next) {
throw new Error(`Missing value for ${arg}`);
}
i += 1;
return next;
};
switch (arg) {
case "--output-dir":
options.outputDir = path.resolve(readValue());
break;
case "--window-ms":
options.windowMs = Number(readValue());
break;
case "--ready-timeout-ms":
options.readyTimeoutMs = Number(readValue());
break;
case "--ready-settle-ms":
options.readySettleMs = Number(readValue());
break;
case "--sigkill-grace-ms":
options.sigkillGraceMs = Number(readValue());
break;
case "--cpu-warn-ms":
options.cpuWarnMs = Number(readValue());
break;
case "--cpu-fail-ms":
options.cpuFailMs = Number(readValue());
break;
case "--dist-runtime-file-growth-max":
options.distRuntimeFileGrowthMax = Number(readValue());
break;
case "--dist-runtime-byte-growth-max":
options.distRuntimeByteGrowthMax = Number(readValue());
break;
case "--skip-build":
options.skipBuild = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function removePathIfExists(targetPath) {
fs.rmSync(targetPath, { recursive: true, force: true });
}
function normalizePath(filePath) {
return filePath.replaceAll("\\", "/");
}
function listTreeEntries(rootName) {
const rootPath = path.join(process.cwd(), rootName);
if (!fs.existsSync(rootPath)) {
return [`${rootName} (missing)`];
}
const entries = [rootName];
const queue = [rootPath];
while (queue.length > 0) {
const current = queue.pop();
if (!current) {
continue;
}
const dirents = fs.readdirSync(current, { withFileTypes: true });
for (const dirent of dirents) {
const fullPath = path.join(current, dirent.name);
const relativePath = normalizePath(path.relative(process.cwd(), fullPath));
entries.push(relativePath);
if (dirent.isDirectory()) {
queue.push(fullPath);
}
}
}
return entries.toSorted((a, b) => a.localeCompare(b));
}
function humanBytes(bytes) {
if (bytes < 1024) {
return `${bytes}B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)}K`;
}
if (bytes < 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
}
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;
}
function snapshotTree(rootName) {
const rootPath = path.join(process.cwd(), rootName);
const stats = {
exists: fs.existsSync(rootPath),
files: 0,
directories: 0,
symlinks: 0,
entries: 0,
apparentBytes: 0,
};
if (!stats.exists) {
return stats;
}
const queue = [rootPath];
while (queue.length > 0) {
const current = queue.pop();
if (!current) {
continue;
}
const currentStats = fs.lstatSync(current);
stats.entries += 1;
if (currentStats.isDirectory()) {
stats.directories += 1;
for (const dirent of fs.readdirSync(current, { withFileTypes: true })) {
queue.push(path.join(current, dirent.name));
}
continue;
}
if (currentStats.isSymbolicLink()) {
stats.symlinks += 1;
continue;
}
if (currentStats.isFile()) {
stats.files += 1;
stats.apparentBytes += currentStats.size;
}
}
return stats;
}
function writeSnapshot(snapshotDir) {
ensureDir(snapshotDir);
const pathEntries = [...listTreeEntries("dist"), ...listTreeEntries("dist-runtime")];
fs.writeFileSync(path.join(snapshotDir, "paths.txt"), `${pathEntries.join("\n")}\n`, "utf8");
const dist = snapshotTree("dist");
const distRuntime = snapshotTree("dist-runtime");
const snapshot = {
generatedAt: new Date().toISOString(),
dist,
distRuntime,
};
fs.writeFileSync(
path.join(snapshotDir, "snapshot.json"),
`${JSON.stringify(snapshot, null, 2)}\n`,
);
fs.writeFileSync(
path.join(snapshotDir, "stats.txt"),
[
`generated_at: ${snapshot.generatedAt}`,
"",
"[dist]",
`files: ${dist.files}`,
`directories: ${dist.directories}`,
`symlinks: ${dist.symlinks}`,
`entries: ${dist.entries}`,
`apparent_bytes: ${dist.apparentBytes}`,
`apparent_human: ${humanBytes(dist.apparentBytes)}`,
"",
"[dist-runtime]",
`files: ${distRuntime.files}`,
`directories: ${distRuntime.directories}`,
`symlinks: ${distRuntime.symlinks}`,
`entries: ${distRuntime.entries}`,
`apparent_bytes: ${distRuntime.apparentBytes}`,
`apparent_human: ${humanBytes(distRuntime.apparentBytes)}`,
"",
].join("\n"),
"utf8",
);
return snapshot;
}
function runCheckedCommand(command, args) {
const result = spawnSync(command, args, {
cwd: process.cwd(),
stdio: "inherit",
env: process.env,
});
if (typeof result.status === "number" && result.status === 0) {
return;
}
throw new Error(`${command} ${args.join(" ")} failed with status ${result.status ?? "unknown"}`);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parsePsCpuTimeMs(timeText) {
const [maybeDays, clockText] = timeText.includes("-") ? timeText.split("-", 2) : ["0", timeText];
const days = Number(maybeDays);
const parts = clockText.split(":");
if (!Number.isFinite(days) || parts.length < 2 || parts.length > 3) {
return null;
}
const seconds = Number(parts.at(-1));
const minutes = Number(parts.at(-2));
const hours = parts.length === 3 ? Number(parts[0]) : 0;
if (![seconds, minutes, hours].every(Number.isFinite)) {
return null;
}
return Math.round(((days * 24 + hours) * 60 * 60 + minutes * 60 + seconds) * 1000);
}
function readProcessTreeCpuMs(rootPid) {
if (!Number.isInteger(rootPid) || rootPid <= 0) {
return null;
}
const result = spawnSync("ps", ["-eo", "pid=,ppid=,time="], {
cwd: process.cwd(),
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
if (result.status !== 0) {
return null;
}
const rows = [];
for (const line of result.stdout.split("\n")) {
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)$/);
if (!match) {
continue;
}
const pid = Number(match[1]);
const ppid = Number(match[2]);
const cpuMs = parsePsCpuTimeMs(match[3]);
if (!Number.isInteger(pid) || !Number.isInteger(ppid) || cpuMs == null) {
continue;
}
rows.push({ pid, ppid, cpuMs });
}
const childrenByParent = new Map();
const cpuByPid = new Map();
for (const row of rows) {
cpuByPid.set(row.pid, row.cpuMs);
const children = childrenByParent.get(row.ppid) ?? [];
children.push(row.pid);
childrenByParent.set(row.ppid, children);
}
if (!cpuByPid.has(rootPid)) {
return null;
}
let totalCpuMs = 0;
const seen = new Set();
const stack = [rootPid];
while (stack.length > 0) {
const pid = stack.pop();
if (!pid || seen.has(pid)) {
continue;
}
seen.add(pid);
totalCpuMs += cpuByPid.get(pid) ?? 0;
for (const childPid of childrenByParent.get(pid) ?? []) {
stack.push(childPid);
}
}
return totalCpuMs;
}
async function waitForGatewayReady(readText, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (/\[gateway\] ready \(/.test(readText())) {
return true;
}
await sleep(100);
}
return false;
}
async function allocateLoopbackPort() {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate watch regression port")));
return;
}
const { port } = address;
server.close((closeErr) => {
if (closeErr) {
reject(closeErr);
return;
}
resolve(port);
});
});
});
}
function buildTimedWatchCommand(pidFilePath, timeFilePath, isolatedHomeDir, port) {
const shellSource = [
'echo "$$" > "$OPENCLAW_WATCH_PID_FILE"',
'mkdir -p "$OPENCLAW_HOME/.openclaw"',
`printf '%s\n' '{"gateway":{"controlUi":{"enabled":false}}}' > "$OPENCLAW_HOME/.openclaw/openclaw.json"`,
`exec node scripts/watch-node.mjs gateway --force --allow-unconfigured --port ${String(port)} --token watch-regression-token`,
].join("\n");
const env = {
OPENCLAW_WATCH_PID_FILE: pidFilePath,
HOME: isolatedHomeDir,
OPENCLAW_HOME: isolatedHomeDir,
...WATCH_GATEWAY_SKIP_ENV,
};
if (process.platform === "darwin") {
return {
command: "/usr/bin/time",
args: ["-lp", "-o", timeFilePath, "/bin/sh", "-lc", shellSource],
env,
};
}
return {
command: "/usr/bin/time",
args: [
"-f",
"__TIMING__ user=%U sys=%S elapsed=%e",
"-o",
timeFilePath,
"/bin/sh",
"-lc",
shellSource,
],
env,
};
}
function parseTimingFile(timeFilePath) {
const text = fs.readFileSync(timeFilePath, "utf8");
if (process.platform === "darwin") {
const user = Number(text.match(/^user\s+([0-9.]+)/m)?.[1] ?? "NaN");
const sys = Number(text.match(/^sys\s+([0-9.]+)/m)?.[1] ?? "NaN");
const elapsed = Number(text.match(/^real\s+([0-9.]+)/m)?.[1] ?? "NaN");
return {
userSeconds: user,
sysSeconds: sys,
elapsedSeconds: elapsed,
};
}
const match = text.match(/__TIMING__ user=([0-9.]+) sys=([0-9.]+) elapsed=([0-9.]+)/);
return {
userSeconds: Number(match?.[1] ?? "NaN"),
sysSeconds: Number(match?.[2] ?? "NaN"),
elapsedSeconds: Number(match?.[3] ?? "NaN"),
};
}
async function runTimedWatch(options, outputDir) {
const pidFilePath = path.join(outputDir, "watch.pid");
const timeFilePath = path.join(outputDir, "watch.time.log");
const isolatedHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-gateway-watch-"));
fs.writeFileSync(path.join(outputDir, "watch.home.txt"), `${isolatedHomeDir}\n`, "utf8");
const stdoutPath = path.join(outputDir, "watch.stdout.log");
const stderrPath = path.join(outputDir, "watch.stderr.log");
for (const stalePath of [pidFilePath, timeFilePath, stdoutPath, stderrPath]) {
removePathIfExists(stalePath);
}
const port = await allocateLoopbackPort();
fs.writeFileSync(path.join(outputDir, "watch.port.txt"), `${String(port)}\n`, "utf8");
const { command, args, env } = buildTimedWatchCommand(
pidFilePath,
timeFilePath,
isolatedHomeDir,
port,
);
const child = spawn(command, args, {
cwd: process.cwd(),
env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr?.on("data", (chunk) => {
stderr += String(chunk);
});
const exitPromise = new Promise((resolve) => {
child.on("exit", (code, signal) => resolve({ code, signal }));
});
let watchPid = null;
for (let attempt = 0; attempt < 50; attempt += 1) {
if (fs.existsSync(pidFilePath)) {
watchPid = Number(fs.readFileSync(pidFilePath, "utf8").trim());
break;
}
await sleep(100);
}
const readyBeforeWindow = await waitForGatewayReady(
() => `${stdout}\n${stderr}`,
options.readyTimeoutMs,
);
if (readyBeforeWindow && options.readySettleMs > 0) {
await sleep(options.readySettleMs);
}
const idleCpuStartMs = watchPid ? readProcessTreeCpuMs(watchPid) : null;
await sleep(options.windowMs);
const idleCpuEndMs = watchPid ? readProcessTreeCpuMs(watchPid) : null;
if (watchPid) {
try {
process.kill(watchPid, "SIGTERM");
} catch {
// ignore
}
}
const gracefulExit = await Promise.race([
exitPromise,
sleep(options.sigkillGraceMs).then(() => null),
]);
if (gracefulExit === null) {
if (watchPid) {
try {
process.kill(watchPid, "SIGKILL");
} catch {
// ignore
}
}
}
const exit = (await exitPromise) ?? { code: null, signal: null };
fs.writeFileSync(stdoutPath, stdout, "utf8");
fs.writeFileSync(stderrPath, stderr, "utf8");
const timing = fs.existsSync(timeFilePath)
? parseTimingFile(timeFilePath)
: { userSeconds: Number.NaN, sysSeconds: Number.NaN, elapsedSeconds: Number.NaN };
return {
exit,
timing,
readyBeforeWindow,
idleCpuMs:
idleCpuStartMs == null || idleCpuEndMs == null
? null
: Math.max(0, idleCpuEndMs - idleCpuStartMs),
stdoutPath,
stderrPath,
timeFilePath,
};
}
function parsePathFile(filePath) {
return fs
.readFileSync(filePath, "utf8")
.split("\n")
.map((line) => line.trimEnd())
.filter(Boolean);
}
function writeDiffArtifacts(outputDir, preDir, postDir) {
const diffDir = path.join(outputDir, "diff");
ensureDir(diffDir);
const prePaths = parsePathFile(path.join(preDir, "paths.txt"));
const postPaths = parsePathFile(path.join(postDir, "paths.txt"));
const preSet = new Set(prePaths);
const postSet = new Set(postPaths);
const added = postPaths.filter((entry) => !preSet.has(entry));
const removed = prePaths.filter((entry) => !postSet.has(entry));
fs.writeFileSync(path.join(diffDir, "added-paths.txt"), `${added.join("\n")}\n`, "utf8");
fs.writeFileSync(path.join(diffDir, "removed-paths.txt"), `${removed.join("\n")}\n`, "utf8");
return { added, removed };
}
function fail(message) {
console.error(`FAIL: ${message}`);
}
function warn(message) {
console.error(`WARN: ${message}`);
}
function detectWatchBuildReason(stdout, stderr) {
const combined = `${stdout}\n${stderr}`;
const match = combined.match(/Building TypeScript \(dist is stale: ([a-z_]+)/);
return match?.[1] ?? null;
}
function buildRunNodeDeps(env) {
const cwd = process.cwd();
return {
cwd,
env,
fs,
spawnSync,
distRoot: path.join(cwd, "dist"),
distEntry: path.join(cwd, "dist", "/entry.js"),
buildStampPath: path.join(cwd, "dist", ".buildstamp"),
sourceRoots: ["src", "extensions"].map((sourceRoot) => ({
name: sourceRoot,
path: path.join(cwd, sourceRoot),
})),
configFiles: ["tsconfig.json", "package.json", "tsdown.config.ts"].map((filePath) =>
path.join(cwd, filePath),
),
};
}
async function main() {
const options = parseArgs(process.argv.slice(2));
ensureDir(options.outputDir);
if (!options.skipBuild) {
runCheckedCommand("pnpm", ["build"]);
// The watch harness must start from a completed-build baseline. Refresh
// the build stamp after the full build pipeline finishes so run-node does
// not spuriously rebuild inside the bounded watch window.
writeBuildStamp({ cwd: process.cwd() });
}
const preflightBuildRequirement = resolveBuildRequirement(buildRunNodeDeps(process.env));
if (
preflightBuildRequirement.shouldBuild &&
preflightBuildRequirement.reason === "dirty_watched_tree"
) {
const summary = {
windowMs: options.windowMs,
invalidated: true,
invalidationReason: preflightBuildRequirement.reason,
invalidationMessage:
"gateway-watch-regression cannot run on a dirty watched tree because run-node will intentionally rebuild during the watch window.",
};
fs.writeFileSync(
path.join(options.outputDir, "summary.json"),
`${JSON.stringify(summary, null, 2)}\n`,
);
console.log(JSON.stringify(summary, null, 2));
fail(
"gateway-watch-regression invalid local run: dirty watched source tree would force a rebuild inside the watch window",
);
process.exit(1);
}
const preDir = path.join(options.outputDir, "pre");
const pre = writeSnapshot(preDir);
const watchDir = path.join(options.outputDir, "watch");
ensureDir(watchDir);
const watchResult = await runTimedWatch(options, watchDir);
const postDir = path.join(options.outputDir, "post");
const post = writeSnapshot(postDir);
const diff = writeDiffArtifacts(options.outputDir, preDir, postDir);
const distRuntimeFileGrowth = post.distRuntime.files - pre.distRuntime.files;
const distRuntimeByteGrowth = post.distRuntime.apparentBytes - pre.distRuntime.apparentBytes;
const distRuntimeAddedPaths = diff.added.filter((entry) =>
entry.startsWith("dist-runtime/"),
).length;
const totalCpuMs = Math.round(
(watchResult.timing.userSeconds + watchResult.timing.sysSeconds) * 1000,
);
const cpuMs = watchResult.idleCpuMs ?? totalCpuMs;
const watchTriggeredBuild =
fs
.readFileSync(watchResult.stderrPath, "utf8")
.includes("Building TypeScript (dist is stale") ||
fs.readFileSync(watchResult.stdoutPath, "utf8").includes("Building TypeScript (dist is stale");
const watchBuildReason = detectWatchBuildReason(
fs.readFileSync(watchResult.stdoutPath, "utf8"),
fs.readFileSync(watchResult.stderrPath, "utf8"),
);
const summary = {
windowMs: options.windowMs,
watchTriggeredBuild,
watchBuildReason,
cpuMs,
totalCpuMs,
readyBeforeWindow: watchResult.readyBeforeWindow,
cpuWarnMs: options.cpuWarnMs,
cpuFailMs: options.cpuFailMs,
distRuntimeFileGrowth,
distRuntimeFileGrowthMax: options.distRuntimeFileGrowthMax,
distRuntimeByteGrowth,
distRuntimeByteGrowthMax: options.distRuntimeByteGrowthMax,
distRuntimeAddedPaths,
addedPaths: diff.added.length,
removedPaths: diff.removed.length,
watchExit: watchResult.exit,
timing: watchResult.timing,
};
fs.writeFileSync(
path.join(options.outputDir, "summary.json"),
`${JSON.stringify(summary, null, 2)}\n`,
);
console.log(JSON.stringify(summary, null, 2));
const failures = [];
const warnings = [];
if (watchTriggeredBuild && watchBuildReason === "dirty_watched_tree") {
failures.push(
"gateway:watch invalid local run: dirty watched source tree forced a rebuild during the watch window",
);
}
if (distRuntimeFileGrowth > options.distRuntimeFileGrowthMax) {
failures.push(
`dist-runtime file growth ${distRuntimeFileGrowth} exceeded max ${options.distRuntimeFileGrowthMax}`,
);
}
if (distRuntimeByteGrowth > options.distRuntimeByteGrowthMax) {
failures.push(
`dist-runtime apparent byte growth ${distRuntimeByteGrowth} exceeded max ${options.distRuntimeByteGrowthMax}`,
);
}
if (!Number.isFinite(cpuMs)) {
failures.push("failed to parse CPU timing from the bounded gateway:watch run");
} else if (cpuMs > options.cpuFailMs) {
failures.push(
`LOUD ALARM: gateway:watch used ${cpuMs}ms CPU in ${options.windowMs}ms window, above loud-alarm threshold ${options.cpuFailMs}ms`,
);
} else if (cpuMs > options.cpuWarnMs) {
warnings.push(
`gateway:watch used ${cpuMs}ms CPU in ${options.windowMs}ms window, above target ${options.cpuWarnMs}ms`,
);
}
for (const message of warnings) {
warn(message);
}
if (failures.length > 0) {
for (const message of failures) {
fail(message);
}
if (!failures.every((message) => message.includes("dirty watched source tree"))) {
fail(
"Possible duplicate dist-runtime graph regression: this can reintroduce split runtime personalities where plugins and core observe different global state, including Telegram missing /voice, /phone, or /pair.",
);
}
process.exit(1);
}
process.exit(0);
}
await main();

View file

@ -0,0 +1,278 @@
#!/usr/bin/env node
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const scanRoots = ["src", "extensions", "scripts"] as const;
const sourceExtensions = [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs"] as const;
const testSourcePattern = /(?:\.test|\.e2e\.test)\.[cm]?[tj]sx?$/;
const generatedSourcePattern = /\.(?:generated|bundle)\.[tj]s$/;
const declarationSourcePattern = /\.d\.[cm]?ts$/;
const ignoredPathPartPattern =
/(^|\/)(node_modules|dist|build|coverage|\.artifacts|\.git|assets)(\/|$)/;
function normalizeRepoPath(filePath: string): string {
return path.relative(repoRoot, filePath).split(path.sep).join("/");
}
function cycleSignature(files: readonly string[]): string {
return files.toSorted((left, right) => left.localeCompare(right)).join("\n");
}
function shouldSkipRepoPath(repoPath: string): boolean {
return (
ignoredPathPartPattern.test(repoPath) ||
testSourcePattern.test(repoPath) ||
generatedSourcePattern.test(repoPath) ||
declarationSourcePattern.test(repoPath)
);
}
function collectSourceFiles(root: string): string[] {
const repoPath = normalizeRepoPath(root);
if (shouldSkipRepoPath(repoPath)) {
return [];
}
const stats = statSync(root);
if (stats.isFile()) {
return sourceExtensions.some((extension) => repoPath.endsWith(extension)) ? [repoPath] : [];
}
if (!stats.isDirectory()) {
return [];
}
return readdirSync(root, { withFileTypes: true })
.flatMap((entry) => collectSourceFiles(path.join(root, entry.name)))
.toSorted((left, right) => left.localeCompare(right));
}
function createSourceResolver(files: readonly string[]) {
const fileSet = new Set(files);
const pathMap = new Map<string, string>();
for (const file of files) {
const parsed = path.posix.parse(file);
const extensionless = path.posix.join(parsed.dir, parsed.name);
pathMap.set(extensionless, file);
if (file.endsWith(".ts")) {
pathMap.set(`${extensionless}.js`, file);
} else if (file.endsWith(".tsx")) {
pathMap.set(`${extensionless}.jsx`, file);
} else if (file.endsWith(".mts")) {
pathMap.set(`${extensionless}.mjs`, file);
} else if (file.endsWith(".cts")) {
pathMap.set(`${extensionless}.cjs`, file);
}
}
return (importer: string, specifier: string): string | null => {
if (!specifier.startsWith(".")) {
return null;
}
const base = path.posix.normalize(path.posix.join(path.posix.dirname(importer), specifier));
const candidates = [
base,
...sourceExtensions.map((extension) => `${base}${extension}`),
`${base}/index.ts`,
`${base}/index.tsx`,
`${base}/index.js`,
`${base}/index.mjs`,
];
for (const candidate of candidates) {
if (fileSet.has(candidate)) {
return candidate;
}
const mapped = pathMap.get(candidate);
if (mapped) {
return mapped;
}
}
return null;
};
}
function importDeclarationHasRuntimeEdge(node: ts.ImportDeclaration): boolean {
if (!node.importClause) {
return true;
}
if (node.importClause.isTypeOnly) {
return false;
}
const bindings = node.importClause.namedBindings;
if (node.importClause.name || !bindings || ts.isNamespaceImport(bindings)) {
return true;
}
return bindings.elements.some((element) => !element.isTypeOnly);
}
function exportDeclarationHasRuntimeEdge(node: ts.ExportDeclaration): boolean {
if (!node.moduleSpecifier || node.isTypeOnly) {
return false;
}
const clause = node.exportClause;
if (!clause || ts.isNamespaceExport(clause)) {
return true;
}
return clause.elements.some((element) => !element.isTypeOnly);
}
function collectRuntimeStaticImports(
file: string,
resolveSource: ReturnType<typeof createSourceResolver>,
) {
const sourceFile = ts.createSourceFile(
file,
readFileSync(path.join(repoRoot, file), "utf8"),
ts.ScriptTarget.Latest,
true,
);
const imports: string[] = [];
const visit = (node: ts.Node) => {
let specifier: string | undefined;
let include = false;
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
specifier = node.moduleSpecifier.text;
include = importDeclarationHasRuntimeEdge(node);
} else if (
ts.isExportDeclaration(node) &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier)
) {
specifier = node.moduleSpecifier.text;
include = exportDeclarationHasRuntimeEdge(node);
}
if (include && specifier) {
const resolved = resolveSource(file, specifier);
if (resolved) {
imports.push(resolved);
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return imports.toSorted((left, right) => left.localeCompare(right));
}
function collectStronglyConnectedComponents(
graph: ReadonlyMap<string, readonly string[]>,
): string[][] {
let nextIndex = 0;
const stack: string[] = [];
const onStack = new Set<string>();
const indexByNode = new Map<string, number>();
const lowLinkByNode = new Map<string, number>();
const components: string[][] = [];
const visit = (node: string) => {
indexByNode.set(node, nextIndex);
lowLinkByNode.set(node, nextIndex);
nextIndex += 1;
stack.push(node);
onStack.add(node);
for (const next of graph.get(node) ?? []) {
if (!indexByNode.has(next)) {
visit(next);
lowLinkByNode.set(node, Math.min(lowLinkByNode.get(node)!, lowLinkByNode.get(next)!));
} else if (onStack.has(next)) {
lowLinkByNode.set(node, Math.min(lowLinkByNode.get(node)!, indexByNode.get(next)!));
}
}
if (lowLinkByNode.get(node) !== indexByNode.get(node)) {
return;
}
const component: string[] = [];
let current: string | undefined;
do {
current = stack.pop();
if (!current) {
throw new Error("Import cycle stack underflow");
}
onStack.delete(current);
component.push(current);
} while (current !== node);
if (component.length > 1 || (graph.get(node) ?? []).includes(node)) {
components.push(component.toSorted((left, right) => left.localeCompare(right)));
}
};
for (const node of graph.keys()) {
if (!indexByNode.has(node)) {
visit(node);
}
}
return components.toSorted(
(left, right) =>
right.length - left.length || cycleSignature(left).localeCompare(cycleSignature(right)),
);
}
function findCycleWitness(
component: readonly string[],
graph: ReadonlyMap<string, readonly string[]>,
): string[] {
const componentSet = new Set(component);
const start = component[0];
if (!start) {
return [];
}
const activePath: string[] = [];
const visited = new Set<string>();
const visit = (node: string): string[] | null => {
activePath.push(node);
visited.add(node);
for (const next of graph.get(node) ?? []) {
if (!componentSet.has(next)) {
continue;
}
const existingIndex = activePath.indexOf(next);
if (existingIndex >= 0) {
return [...activePath.slice(existingIndex), next];
}
if (!visited.has(next)) {
const result = visit(next);
if (result) {
return result;
}
}
}
activePath.pop();
return null;
};
return visit(start) ?? component;
}
function formatCycle(
component: readonly string[],
graph: ReadonlyMap<string, readonly string[]>,
): string {
const witness = findCycleWitness(component, graph);
return witness.map((file, index) => `${index === 0 ? " " : " -> "}${file}`).join("\n");
}
function main(): number {
const files = scanRoots.flatMap((root) => collectSourceFiles(path.join(repoRoot, root)));
const resolveSource = createSourceResolver(files);
const graph = new Map(
files.map((file): [string, string[]] => [
file,
collectRuntimeStaticImports(file, resolveSource),
]),
);
const components = collectStronglyConnectedComponents(graph);
console.log(`Import cycle check: ${components.length} runtime value cycle(s).`);
if (components.length === 0) {
return 0;
}
console.error("\nRuntime value import cycles:");
for (const component of components) {
console.error(`\n# component size ${component.length}`);
console.error(formatCycle(component, graph));
}
console.error("\nBreak the cycle or convert type-only edges to `import type`.");
return 1;
}
process.exitCode = main();

View file

@ -0,0 +1,42 @@
#!/usr/bin/env node
import path from "node:path";
import ts from "typescript";
import { bundledPluginFile } from "./lib/bundled-plugin-paths.mjs";
import { runCallsiteGuard } from "./lib/callsite-guard.mjs";
import {
collectCallExpressionLines,
runAsScript,
unwrapExpression,
} from "./lib/ts-guard-utils.mjs";
const sourceRoots = ["src/gateway", bundledPluginFile("discord", "src/voice")];
const enforcedFiles = new Set([
bundledPluginFile("discord", "src/voice/manager.ts"),
"src/gateway/openai-http.ts",
"src/gateway/openresponses-http.ts",
"src/gateway/server-methods/agent.ts",
"src/gateway/server-node-events.ts",
]);
export function findLegacyAgentCommandCallLines(content, fileName = "source.ts") {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
return collectCallExpressionLines(ts, sourceFile, (node) => {
const callee = unwrapExpression(node.expression);
return ts.isIdentifier(callee) && callee.text === "agentCommand" ? callee : null;
});
}
export async function main() {
await runCallsiteGuard({
importMetaUrl: import.meta.url,
sourceRoots,
findCallLines: findLegacyAgentCommandCallLines,
skipRelativePath: (relPath) => !enforcedFiles.has(relPath.replaceAll(path.sep, "/")),
header: "Found ingress callsites using local agentCommand() (must be explicit owner-aware):",
footer:
"Use agentCommandFromIngress(...) and pass senderIsOwner explicitly at ingress boundaries.",
});
}
runAsScript(import.meta.url, main);

View file

@ -0,0 +1,18 @@
import { runLiveCacheRegression } from "../src/agents/live-cache-regression-runner.js";
import { LIVE_CACHE_TEST_ENABLED, logLiveCache } from "../src/agents/live-cache-test-support.js";
if (!LIVE_CACHE_TEST_ENABLED) {
logLiveCache("skipped; set OPENCLAW_LIVE_TEST=1 and OPENCLAW_LIVE_CACHE_TEST=1");
process.exit(0);
}
const result = await runLiveCacheRegression();
if (result.regressions.length > 0) {
process.stderr.write("\n[live-cache] regressions detected:\n");
for (const regression of result.regressions) {
process.stderr.write(`- ${regression}\n`);
}
process.exitCode = 1;
} else {
process.stderr.write("\n[live-cache] all regression floors satisfied\n");
}

View file

@ -0,0 +1,37 @@
#!/usr/bin/env node
import path from "node:path";
import { fileURLToPath } from "node:url";
import madge from "madge";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const scanRoots = ["src", "extensions", "ui"] as const;
function normalizeRepoPath(filePath: string): string {
return filePath.split(path.sep).join("/");
}
async function main(): Promise<number> {
const result = await madge([...scanRoots], {
baseDir: repoRoot,
fileExtensions: ["ts"],
tsConfig: path.join(repoRoot, "tsconfig.json"),
});
const cycles = result.circular().map((cycle) => cycle.map((file) => normalizeRepoPath(file)));
console.log(`Madge import cycle check: ${cycles.length} cycle(s).`);
if (cycles.length === 0) {
return 0;
}
console.error("\nMadge circular dependencies:");
for (const [index, cycle] of cycles.entries()) {
console.error(`\n# cycle ${index + 1}`);
console.error(` ${cycle.join("\n -> ")}`);
}
console.error(
"\nBreak the cycle or extract a leaf contract instead of routing through a barrel.",
);
return 1;
}
process.exitCode = await main();

View file

@ -0,0 +1,85 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
function isBinaryBuffer(buffer) {
return buffer.includes(0);
}
export function findConflictMarkerLines(content) {
const lines = content.split(/\r?\n/u);
const matches = [];
for (const [index, line] of lines.entries()) {
if (
line.startsWith("<<<<<<< ") ||
line.startsWith("||||||| ") ||
line === "=======" ||
line.startsWith(">>>>>>> ")
) {
matches.push(index + 1);
}
}
return matches;
}
export function listTrackedFiles(cwd = process.cwd()) {
const output = execFileSync("git", ["ls-files", "-z"], {
cwd,
encoding: "utf8",
});
return output
.split("\0")
.filter(Boolean)
.map((relativePath) => path.join(cwd, relativePath));
}
export function findConflictMarkersInFiles(filePaths, readFile = fs.readFileSync) {
const violations = [];
for (const filePath of filePaths) {
let content;
try {
content = readFile(filePath);
} catch {
continue;
}
if (!Buffer.isBuffer(content)) {
content = Buffer.from(String(content));
}
if (isBinaryBuffer(content)) {
continue;
}
const lines = findConflictMarkerLines(content.toString("utf8"));
if (lines.length > 0) {
violations.push({
filePath,
lines,
});
}
}
return violations;
}
export async function main() {
const cwd = process.cwd();
const violations = findConflictMarkersInFiles(listTrackedFiles(cwd));
if (violations.length === 0) {
return;
}
console.error("Found unresolved merge conflict markers:");
for (const violation of violations) {
const relativePath = path.relative(cwd, violation.filePath) || violation.filePath;
console.error(`- ${relativePath}:${violation.lines.join(",")}`);
}
process.exitCode = 1;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}

View file

@ -0,0 +1,43 @@
import fs from "node:fs";
import path from "node:path";
import { collectFilesSync, isCodeFile, relativeToCwd } from "./check-file-utils.js";
import { classifyBundledExtensionSourcePath } from "./lib/extension-source-classifier.mjs";
const FORBIDDEN_REPO_SRC_IMPORT = /["'](?:\.\.\/)+(?:src\/)[^"']+["']/;
function collectExtensionSourceFiles(rootDir: string): string[] {
return collectFilesSync(rootDir, {
includeFile: (filePath) =>
isCodeFile(filePath) && classifyBundledExtensionSourcePath(filePath).isProductionSource,
});
}
function main() {
const extensionsDir = path.join(process.cwd(), "extensions");
const files = collectExtensionSourceFiles(extensionsDir);
const offenders: string[] = [];
for (const file of files) {
const content = fs.readFileSync(file, "utf8");
if (FORBIDDEN_REPO_SRC_IMPORT.test(content)) {
offenders.push(file);
}
}
if (offenders.length > 0) {
console.error("Production extension files must not import the repo src/ tree directly.");
for (const offender of offenders.toSorted()) {
console.error(`- ${relativeToCwd(offender)}`);
}
console.error(
"Publish a focused openclaw/plugin-sdk/<subpath> surface or use the extension's own public barrel instead.",
);
process.exit(1);
}
console.log(
`OK: production extension files avoid direct repo src/ imports (${files.length} checked).`,
);
}
main();

View file

@ -0,0 +1,73 @@
import fs from "node:fs";
import path from "node:path";
import { collectFilesSync, relativeToCwd } from "./check-file-utils.js";
const FORBIDDEN_PATTERNS: Array<{ pattern: RegExp; hint: string }> = [
{
pattern: /["']openclaw\/plugin-sdk["']/,
hint: "Use openclaw/plugin-sdk/<subpath> instead of the monolithic root entry.",
},
{
pattern: /["']openclaw\/plugin-sdk\/test-utils["']/,
hint: "Use openclaw/plugin-sdk/testing for the public extension test surface.",
},
{
pattern: /["']openclaw\/plugin-sdk\/compat["']/,
hint: "Use a focused public plugin-sdk subpath instead of compat.",
},
{
pattern: /["'](?:\.\.\/)+(?:test-utils\/)[^"']+["']/,
hint: "Use test/helpers/plugins/* for repo-only bundled extension test helpers.",
},
{
pattern: /["'](?:\.\.\/)+(?:src\/test-utils\/)[^"']+["']/,
hint: "Use test/helpers/plugins/* for repo-only helpers, or openclaw/plugin-sdk/testing for public surfaces.",
},
{
pattern: /["'](?:\.\.\/)+(?:src\/plugins\/types\.js)["']/,
hint: "Use public plugin-sdk/core types or test/helpers/plugins/* instead.",
},
];
function isExtensionTestFile(filePath: string): boolean {
return /\.test\.[cm]?[jt]sx?$/u.test(filePath) || /\.e2e\.test\.[cm]?[jt]sx?$/u.test(filePath);
}
function collectExtensionTestFiles(rootDir: string): string[] {
return collectFilesSync(rootDir, {
includeFile: (filePath) => isExtensionTestFile(filePath),
});
}
function main() {
const extensionsDir = path.join(process.cwd(), "extensions");
const files = collectExtensionTestFiles(extensionsDir);
const offenders: Array<{ file: string; hint: string }> = [];
for (const file of files) {
const content = fs.readFileSync(file, "utf8");
for (const rule of FORBIDDEN_PATTERNS) {
if (!rule.pattern.test(content)) {
continue;
}
offenders.push({ file, hint: rule.hint });
break;
}
}
if (offenders.length > 0) {
console.error(
"Extension test files must stay on extension test bridges or public plugin-sdk surfaces.",
);
for (const offender of offenders.toSorted((a, b) => a.file.localeCompare(b.file))) {
console.error(`- ${relativeToCwd(offender.file)}: ${offender.hint}`);
}
process.exit(1);
}
console.log(
`OK: extension test files avoid direct core test/internal imports (${files.length} checked).`,
);
}
main();

View file

@ -0,0 +1,117 @@
import fs from "node:fs";
import path from "node:path";
import { discoverOpenClawPlugins } from "../src/plugins/discovery.js";
import { collectFilesSync, isCodeFile, relativeToCwd } from "./check-file-utils.js";
// Match exact monolithic-root specifier in any code path:
// imports/exports, require/dynamic import, and test mocks (vi.mock/jest.mock).
const ROOT_IMPORT_PATTERN = /["']openclaw\/plugin-sdk["']/;
const LEGACY_COMPAT_IMPORT_PATTERN = /["']openclaw\/plugin-sdk\/compat["']/;
function hasMonolithicRootImport(content: string): boolean {
return ROOT_IMPORT_PATTERN.test(content);
}
function hasLegacyCompatImport(content: string): boolean {
return LEGACY_COMPAT_IMPORT_PATTERN.test(content);
}
function collectPluginSourceFiles(rootDir: string): string[] {
const srcDir = path.join(rootDir, "src");
if (!fs.existsSync(srcDir)) {
return [];
}
return collectFilesSync(srcDir, {
includeFile: (filePath) => isCodeFile(filePath),
skipDirNames: new Set(["node_modules", "dist", ".git", "coverage"]),
});
}
function collectSharedExtensionSourceFiles(): string[] {
return collectPluginSourceFiles(path.join(process.cwd(), "extensions", "shared"));
}
function collectBundledExtensionSourceFiles(): string[] {
const extensionsDir = path.join(process.cwd(), "extensions");
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(extensionsDir, { withFileTypes: true });
} catch {
return [];
}
const files: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === "shared") {
continue;
}
for (const srcFile of collectPluginSourceFiles(path.join(extensionsDir, entry.name))) {
files.push(srcFile);
}
}
return files;
}
function main() {
const discovery = discoverOpenClawPlugins({});
const bundledCandidates = discovery.candidates.filter((c) => c.origin === "bundled");
const filesToCheck = new Set<string>();
for (const candidate of bundledCandidates) {
filesToCheck.add(candidate.source);
for (const srcFile of collectPluginSourceFiles(candidate.rootDir)) {
filesToCheck.add(srcFile);
}
}
for (const sharedFile of collectSharedExtensionSourceFiles()) {
filesToCheck.add(sharedFile);
}
for (const extensionFile of collectBundledExtensionSourceFiles()) {
filesToCheck.add(extensionFile);
}
const monolithicOffenders: string[] = [];
const legacyCompatOffenders: string[] = [];
for (const entryFile of filesToCheck) {
let content = "";
try {
content = fs.readFileSync(entryFile, "utf8");
} catch {
continue;
}
if (hasMonolithicRootImport(content)) {
monolithicOffenders.push(entryFile);
}
if (hasLegacyCompatImport(content)) {
legacyCompatOffenders.push(entryFile);
}
}
if (monolithicOffenders.length > 0 || legacyCompatOffenders.length > 0) {
if (monolithicOffenders.length > 0) {
console.error("Bundled plugin source files must not import monolithic openclaw/plugin-sdk.");
for (const file of monolithicOffenders.toSorted()) {
console.error(`- ${relativeToCwd(file)}`);
}
}
if (legacyCompatOffenders.length > 0) {
console.error(
"Bundled plugin source files must not import legacy openclaw/plugin-sdk/compat.",
);
for (const file of legacyCompatOffenders.toSorted()) {
console.error(`- ${relativeToCwd(file)}`);
}
}
if (monolithicOffenders.length > 0 || legacyCompatOffenders.length > 0) {
console.error(
"Use openclaw/plugin-sdk/<domain> or openclaw/plugin-sdk/<channel> subpaths for bundled plugins; root and compat are legacy surfaces only.",
);
}
process.exit(1);
}
console.log(
`OK: bundled plugin source files use scoped plugin-sdk subpaths (${filesToCheck.size} checked).`,
);
}
main();

View file

@ -0,0 +1,180 @@
#!/usr/bin/env node
import ts from "typescript";
import { createPairingGuardContext } from "./lib/pairing-guard-context.mjs";
import {
collectFileViolations,
getPropertyNameText,
runAsScript,
toLine,
} from "./lib/ts-guard-utils.mjs";
const { repoRoot, sourceRoots, resolveFromRepo } = createPairingGuardContext(import.meta.url);
const allowedFiles = new Set([
resolveFromRepo("src/security/dm-policy-shared.ts"),
resolveFromRepo("src/channels/allow-from.ts"),
// Config migration/audit logic may intentionally reference store + group fields.
resolveFromRepo("src/security/fix.ts"),
resolveFromRepo("src/security/audit-channel.ts"),
]);
const storeIdentifierRe = /^(?:storeAllowFrom|storedAllowFrom|storeAllowList)$/i;
const groupNameRe =
/(?:groupAllowFrom|effectiveGroupAllowFrom|groupAllowed|groupAllow|groupAuth|groupSender)/i;
const storeSourceCallNames = new Set([
"readChannelAllowFromStore",
"readChannelAllowFromStoreSync",
"readStoreAllowFromForDmPolicy",
]);
const allowedResolverCallNames = new Set([
"resolveEffectiveAllowFromLists",
"resolveDmGroupAccessWithLists",
"resolveMattermostEffectiveAllowFromLists",
"resolveIrcEffectiveAllowlists",
]);
function getDeclarationNameText(name) {
if (ts.isIdentifier(name)) {
return name.text;
}
if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) {
return name.getText();
}
return null;
}
function containsPairingStoreSource(node) {
let found = false;
const visit = (current) => {
if (found) {
return;
}
if (ts.isIdentifier(current) && storeIdentifierRe.test(current.text)) {
found = true;
return;
}
if (ts.isCallExpression(current)) {
const callName = getCallName(current);
if (callName && storeSourceCallNames.has(callName)) {
found = true;
return;
}
}
ts.forEachChild(current, visit);
};
visit(node);
return found;
}
function getCallName(node) {
if (!ts.isCallExpression(node)) {
return null;
}
if (ts.isIdentifier(node.expression)) {
return node.expression.text;
}
if (ts.isPropertyAccessExpression(node.expression)) {
return node.expression.name.text;
}
return null;
}
function isSuspiciousNormalizeWithStoreCall(node) {
if (!ts.isCallExpression(node)) {
return false;
}
if (!ts.isIdentifier(node.expression) || node.expression.text !== "normalizeAllowFromWithStore") {
return false;
}
const firstArg = node.arguments[0];
if (!firstArg || !ts.isObjectLiteralExpression(firstArg)) {
return false;
}
let hasStoreProp = false;
let hasGroupAllowProp = false;
for (const property of firstArg.properties) {
if (!ts.isPropertyAssignment(property)) {
continue;
}
const name = getPropertyNameText(property.name);
if (!name) {
continue;
}
if (name === "storeAllowFrom" && containsPairingStoreSource(property.initializer)) {
hasStoreProp = true;
}
if (name === "allowFrom" && groupNameRe.test(property.initializer.getText())) {
hasGroupAllowProp = true;
}
}
return hasStoreProp && hasGroupAllowProp;
}
function findViolations(content, filePath) {
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
const violations = [];
const visit = (node) => {
if (ts.isVariableDeclaration(node) && node.initializer) {
const name = getDeclarationNameText(node.name);
if (name && groupNameRe.test(name) && containsPairingStoreSource(node.initializer)) {
const callName = getCallName(node.initializer);
if (callName && allowedResolverCallNames.has(callName)) {
ts.forEachChild(node, visit);
return;
}
violations.push({
line: toLine(sourceFile, node),
reason: `group-scoped variable "${name}" references pairing-store identifiers`,
});
}
}
if (ts.isPropertyAssignment(node)) {
const propName = getPropertyNameText(node.name);
if (propName && groupNameRe.test(propName) && containsPairingStoreSource(node.initializer)) {
violations.push({
line: toLine(sourceFile, node),
reason: `group-scoped property "${propName}" references pairing-store identifiers`,
});
}
}
if (isSuspiciousNormalizeWithStoreCall(node)) {
violations.push({
line: toLine(sourceFile, node),
reason: "group allowlist uses normalizeAllowFromWithStore(...) with pairing-store entries",
});
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return violations;
}
async function main() {
const violations = await collectFileViolations({
sourceRoots,
repoRoot,
findViolations,
skipFile: (filePath) => allowedFiles.has(filePath),
});
if (violations.length === 0) {
return;
}
console.error("Found pairing-store identifiers referenced in group auth composition:");
for (const violation of violations) {
console.error(`- ${violation.path}:${violation.line} (${violation.reason})`);
}
console.error(
"Group auth must be composed via shared resolvers (resolveDmGroupAccessWithLists / resolveEffectiveAllowFromLists).",
);
process.exit(1);
}
runAsScript(import.meta.url, main);

View file

@ -0,0 +1,86 @@
#!/usr/bin/env node
import ts from "typescript";
import { bundledPluginFile } from "./lib/bundled-plugin-paths.mjs";
import { runCallsiteGuard } from "./lib/callsite-guard.mjs";
import {
collectCallExpressionLines,
runAsScript,
unwrapExpression,
} from "./lib/ts-guard-utils.mjs";
export const messagingTmpdirGuardSourceRoots = [
"src/channels",
"src/infra/outbound",
"src/line",
"src/media",
"src/media-understanding",
"extensions",
];
const allowedRelativePaths = new Set([bundledPluginFile("feishu", "src/dedup.ts")]);
function collectOsTmpdirImports(sourceFile) {
const osModuleSpecifiers = new Set(["node:os", "os"]);
const osNamespaceOrDefault = new Set();
const namedTmpdir = new Set();
for (const statement of sourceFile.statements) {
if (!ts.isImportDeclaration(statement)) {
continue;
}
if (!statement.importClause || !ts.isStringLiteral(statement.moduleSpecifier)) {
continue;
}
if (!osModuleSpecifiers.has(statement.moduleSpecifier.text)) {
continue;
}
const clause = statement.importClause;
if (clause.name) {
osNamespaceOrDefault.add(clause.name.text);
}
if (!clause.namedBindings) {
continue;
}
if (ts.isNamespaceImport(clause.namedBindings)) {
osNamespaceOrDefault.add(clause.namedBindings.name.text);
continue;
}
for (const element of clause.namedBindings.elements) {
if ((element.propertyName?.text ?? element.name.text) === "tmpdir") {
namedTmpdir.add(element.name.text);
}
}
}
return { osNamespaceOrDefault, namedTmpdir };
}
export function findMessagingTmpdirCallLines(content, fileName = "source.ts") {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const { osNamespaceOrDefault, namedTmpdir } = collectOsTmpdirImports(sourceFile);
return collectCallExpressionLines(ts, sourceFile, (node) => {
const callee = unwrapExpression(node.expression);
if (
ts.isPropertyAccessExpression(callee) &&
callee.name.text === "tmpdir" &&
ts.isIdentifier(callee.expression) &&
osNamespaceOrDefault.has(callee.expression.text)
) {
return callee;
}
return ts.isIdentifier(callee) && namedTmpdir.has(callee.text) ? callee : null;
});
}
export async function main() {
await runCallsiteGuard({
importMetaUrl: import.meta.url,
sourceRoots: messagingTmpdirGuardSourceRoots,
findCallLines: findMessagingTmpdirCallLines,
skipRelativePath: (relativePath) => allowedRelativePaths.has(relativePath),
header: "Found os.tmpdir()/tmpdir() usage in messaging/channel runtime sources:",
footer:
"Use resolvePreferredOpenClawTmpDir() or plugin-sdk temp helpers instead of host tmp defaults.",
sortViolations: false,
});
}
runAsScript(import.meta.url, main);

View file

@ -0,0 +1,108 @@
#!/usr/bin/env node
import ts from "typescript";
import { bundledPluginCallsite } from "./lib/bundled-plugin-paths.mjs";
import { runCallsiteGuard } from "./lib/callsite-guard.mjs";
import {
collectCallExpressionLines,
runAsScript,
unwrapExpression,
} from "./lib/ts-guard-utils.mjs";
const sourceRoots = ["src/channels", "src/routing", "src/line", "extensions"];
// Temporary allowlist for legacy callsites. New raw fetch callsites in channel/plugin runtime
// code should be rejected and migrated to fetchWithSsrFGuard/shared channel helpers.
const allowedRawFetchCallsites = new Set([
bundledPluginCallsite("bluebubbles", "src/test-harness.ts", 132),
bundledPluginCallsite("bluebubbles", "src/types.ts", 189),
bundledPluginCallsite("browser", "src/browser/cdp.helpers.ts", 268),
bundledPluginCallsite("browser", "src/browser/client-fetch.ts", 192),
bundledPluginCallsite("browser", "src/browser/test-fetch.ts", 24),
bundledPluginCallsite("browser", "src/browser/test-fetch.ts", 27),
bundledPluginCallsite("chutes", "models.ts", 535),
bundledPluginCallsite("chutes", "models.ts", 542),
bundledPluginCallsite("discord", "src/monitor/gateway-plugin.ts", 387),
bundledPluginCallsite("discord", "src/monitor/gateway-plugin.ts", 453),
bundledPluginCallsite("discord", "src/voice-message.ts", 298),
bundledPluginCallsite("discord", "src/voice-message.ts", 333),
bundledPluginCallsite("elevenlabs", "speech-provider.ts", 295),
bundledPluginCallsite("elevenlabs", "tts.ts", 116),
bundledPluginCallsite("feishu", "src/monitor.webhook.test-helpers.ts", 25),
bundledPluginCallsite("github-copilot", "login.ts", 48),
bundledPluginCallsite("github-copilot", "login.ts", 80),
bundledPluginCallsite("googlechat", "src/auth.ts", 83),
bundledPluginCallsite("huggingface", "models.ts", 142),
bundledPluginCallsite("kilocode", "provider-models.ts", 130),
bundledPluginCallsite("matrix", "src/matrix/sdk/transport.ts", 112),
bundledPluginCallsite("microsoft-foundry", "onboard.ts", 479),
bundledPluginCallsite("microsoft", "speech-provider.ts", 140),
bundledPluginCallsite("minimax", "oauth.ts", 66),
bundledPluginCallsite("minimax", "oauth.ts", 107),
bundledPluginCallsite("minimax", "tts.ts", 52),
bundledPluginCallsite("msteams", "src/graph.ts", 47),
bundledPluginCallsite("msteams", "src/sdk.ts", 400),
bundledPluginCallsite("msteams", "src/sdk.ts", 441),
bundledPluginCallsite("ollama", "src/stream.ts", 649),
bundledPluginCallsite("openai", "tts.ts", 149),
bundledPluginCallsite("qa-channel", "src/bus-client.ts", 41),
bundledPluginCallsite("qa-channel", "src/bus-client.ts", 221),
bundledPluginCallsite("qa-lab", "src/docker-up.runtime.ts", 274),
bundledPluginCallsite("qa-lab", "src/gateway-child.ts", 489),
bundledPluginCallsite("qa-lab", "src/suite.ts", 330),
bundledPluginCallsite("qa-lab", "src/suite.ts", 341),
bundledPluginCallsite("qa-lab", "web/src/app.ts", 21),
bundledPluginCallsite("qa-lab", "web/src/app.ts", 29),
bundledPluginCallsite("qa-lab", "web/src/app.ts", 37),
bundledPluginCallsite("qqbot", "src/api.ts", 102),
bundledPluginCallsite("qqbot", "src/api.ts", 237),
bundledPluginCallsite("qqbot", "src/stt.ts", 81),
bundledPluginCallsite("qqbot", "src/tools/channel.ts", 180),
bundledPluginCallsite("qqbot", "src/utils/audio-convert.ts", 377),
bundledPluginCallsite("signal", "src/install-signal-cli.ts", 224),
bundledPluginCallsite("slack", "src/monitor/media.ts", 99),
bundledPluginCallsite("slack", "src/monitor/media.ts", 118),
bundledPluginCallsite("slack", "src/monitor/media.ts", 123),
bundledPluginCallsite("tlon", "src/tlon-api.ts", 185),
bundledPluginCallsite("tlon", "src/tlon-api.ts", 235),
bundledPluginCallsite("tlon", "src/tlon-api.ts", 289),
bundledPluginCallsite("venice", "models.ts", 552),
bundledPluginCallsite("vercel-ai-gateway", "models.ts", 181),
bundledPluginCallsite("voice-call", "src/providers/twilio/api.ts", 23),
]);
function isRawFetchCall(expression) {
const callee = unwrapExpression(expression);
if (ts.isIdentifier(callee)) {
return callee.text === "fetch";
}
if (ts.isPropertyAccessExpression(callee)) {
return (
ts.isIdentifier(callee.expression) &&
callee.expression.text === "globalThis" &&
callee.name.text === "fetch"
);
}
return false;
}
export function findRawFetchCallLines(content, fileName = "source.ts") {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
return collectCallExpressionLines(ts, sourceFile, (node) =>
isRawFetchCall(node.expression) ? node.expression : null,
);
}
export async function main() {
await runCallsiteGuard({
importMetaUrl: import.meta.url,
sourceRoots,
extraTestSuffixes: [".browser.test.ts", ".node.test.ts"],
findCallLines: findRawFetchCallLines,
allowCallsite: (callsite) => allowedRawFetchCallsites.has(callsite),
header: "Found raw fetch() usage in channel/plugin runtime sources outside allowlist:",
footer: "Use fetchWithSsrFGuard() or existing channel/plugin SDK wrappers for network calls.",
});
}
runAsScript(import.meta.url, main);

View file

@ -0,0 +1,86 @@
#!/usr/bin/env node
import { promises as fs } from "node:fs";
import path from "node:path";
import ts from "typescript";
import {
collectTypeScriptFiles,
resolveRepoRoot,
runAsScript,
toLine,
unwrapExpression,
} from "./lib/ts-guard-utils.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
const uiSourceDir = path.join(repoRoot, "ui", "src", "ui");
const allowedCallsites = new Set([path.join(uiSourceDir, "open-external-url.ts")]);
function asPropertyAccess(expression) {
if (ts.isPropertyAccessExpression(expression)) {
return expression;
}
if (typeof ts.isPropertyAccessChain === "function" && ts.isPropertyAccessChain(expression)) {
return expression;
}
return null;
}
function isRawWindowOpenCall(expression) {
const propertyAccess = asPropertyAccess(unwrapExpression(expression));
if (!propertyAccess || propertyAccess.name.text !== "open") {
return false;
}
const receiver = unwrapExpression(propertyAccess.expression);
return (
ts.isIdentifier(receiver) && (receiver.text === "window" || receiver.text === "globalThis")
);
}
export function findRawWindowOpenLines(content, fileName = "source.ts") {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const lines = [];
const visit = (node) => {
if (ts.isCallExpression(node) && isRawWindowOpenCall(node.expression)) {
lines.push(toLine(sourceFile, node.expression));
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return lines;
}
export async function main() {
const files = await collectTypeScriptFiles(uiSourceDir, {
extraTestSuffixes: [".browser.test.ts", ".node.test.ts"],
ignoreMissing: true,
});
const violations = [];
for (const filePath of files) {
if (allowedCallsites.has(filePath)) {
continue;
}
const content = await fs.readFile(filePath, "utf8");
for (const line of findRawWindowOpenLines(content, filePath)) {
const relPath = path.relative(repoRoot, filePath);
violations.push(`${relPath}:${line}`);
}
}
if (violations.length === 0) {
return;
}
console.error("Found raw window.open usage outside safe helper:");
for (const violation of violations) {
console.error(`- ${violation}`);
}
console.error("Use openExternalUrlSafe(...) from ui/src/ui/open-external-url.ts instead.");
process.exit(1);
}
runAsScript(import.meta.url, main);

View file

@ -0,0 +1,36 @@
#!/usr/bin/env node
import ts from "typescript";
import { runCallsiteGuard } from "./lib/callsite-guard.mjs";
import {
collectCallExpressionLines,
runAsScript,
unwrapExpression,
} from "./lib/ts-guard-utils.mjs";
const sourceRoots = ["src", "extensions"];
function isDeprecatedRegisterHttpHandlerCall(expression) {
const callee = unwrapExpression(expression);
return ts.isPropertyAccessExpression(callee) && callee.name.text === "registerHttpHandler";
}
export function findDeprecatedRegisterHttpHandlerLines(content, fileName = "source.ts") {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
return collectCallExpressionLines(ts, sourceFile, (node) =>
isDeprecatedRegisterHttpHandlerCall(node.expression) ? node.expression : null,
);
}
export async function main() {
await runCallsiteGuard({
importMetaUrl: import.meta.url,
sourceRoots,
findCallLines: findDeprecatedRegisterHttpHandlerLines,
header: "Found deprecated plugin API call registerHttpHandler(...):",
footer:
"Use registerHttpRoute({ path, auth, match, handler }) and registerPluginHttpRoute for dynamic webhook paths.",
});
}
runAsScript(import.meta.url, main);

View file

@ -0,0 +1,100 @@
#!/usr/bin/env node
import ts from "typescript";
import { createPairingGuardContext } from "./lib/pairing-guard-context.mjs";
import {
collectFileViolations,
getPropertyNameText,
runAsScript,
toLine,
} from "./lib/ts-guard-utils.mjs";
const { repoRoot, sourceRoots } = createPairingGuardContext(import.meta.url);
function isUndefinedLikeExpression(node) {
if (ts.isIdentifier(node) && node.text === "undefined") {
return true;
}
return node.kind === ts.SyntaxKind.NullKeyword;
}
function hasRequiredAccountIdProperty(node) {
if (!ts.isObjectLiteralExpression(node)) {
return false;
}
for (const property of node.properties) {
if (ts.isShorthandPropertyAssignment(property) && property.name.text === "accountId") {
return true;
}
if (!ts.isPropertyAssignment(property)) {
continue;
}
if (getPropertyNameText(property.name) !== "accountId") {
continue;
}
if (isUndefinedLikeExpression(property.initializer)) {
return false;
}
return true;
}
return false;
}
function findViolations(content, filePath) {
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
const violations = [];
const visit = (node) => {
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
const callName = node.expression.text;
if (callName === "readChannelAllowFromStore") {
if (node.arguments.length < 3 || isUndefinedLikeExpression(node.arguments[2])) {
violations.push({
line: toLine(sourceFile, node),
reason: "readChannelAllowFromStore call must pass explicit accountId as 3rd arg",
});
}
} else if (
callName === "readLegacyChannelAllowFromStore" ||
callName === "readLegacyChannelAllowFromStoreSync"
) {
violations.push({
line: toLine(sourceFile, node),
reason: `${callName} is legacy-only; use account-scoped readChannelAllowFromStore* APIs`,
});
} else if (callName === "upsertChannelPairingRequest") {
const firstArg = node.arguments[0];
if (!firstArg || !hasRequiredAccountIdProperty(firstArg)) {
violations.push({
line: toLine(sourceFile, node),
reason: "upsertChannelPairingRequest call must include accountId in params",
});
}
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return violations;
}
async function main() {
const violations = await collectFileViolations({
sourceRoots,
repoRoot,
findViolations,
});
if (violations.length === 0) {
return;
}
console.error("Found unscoped pairing-store calls:");
for (const violation of violations) {
console.error(`- ${violation.path}:${violation.line} (${violation.reason})`);
}
process.exit(1);
}
runAsScript(import.meta.url, main);

View file

@ -0,0 +1,222 @@
#!/usr/bin/env node
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import { BUNDLED_PLUGIN_PATH_PREFIX } from "./lib/bundled-plugin-paths.mjs";
import {
collectTypeScriptInventory,
createCachedAsync,
diffInventoryEntries,
formatGroupedInventoryHuman,
normalizeRepoPath,
runBaselineInventoryCheck,
resolveRepoSpecifier,
visitModuleSpecifiers,
} from "./lib/guard-inventory-utils.mjs";
import {
collectTypeScriptFilesFromRoots,
resolveSourceRoots,
runAsScript,
toLine,
} from "./lib/ts-guard-utils.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const scanRoots = resolveSourceRoots(repoRoot, ["src/plugins"]);
const baselinePath = path.join(
repoRoot,
"test",
"fixtures",
"plugin-extension-import-boundary-inventory.json",
);
const bundledWebSearchProviders = new Set([
"brave",
"firecrawl",
"gemini",
"grok",
"kimi",
"perplexity",
]);
const bundledWebSearchPluginIds = new Set([
"brave",
"firecrawl",
"google",
"moonshot",
"perplexity",
"xai",
]);
function compareEntries(left, right) {
return (
left.file.localeCompare(right.file) ||
left.line - right.line ||
left.kind.localeCompare(right.kind) ||
left.specifier.localeCompare(right.specifier) ||
left.reason.localeCompare(right.reason)
);
}
function classifyResolvedExtensionReason(kind, resolvedPath) {
const verb =
kind === "export"
? "re-exports"
: kind === "dynamic-import"
? "dynamically imports"
: "imports";
if (/^extensions\/[^/]+\/src\//.test(resolvedPath)) {
return `${verb} extension implementation from src/plugins`;
}
if (/^extensions\/[^/]+\/index\.[^/]+$/.test(resolvedPath)) {
return `${verb} extension entrypoint from src/plugins`;
}
return `${verb} extension-owned file from src/plugins`;
}
function pushEntry(entries, entry) {
entries.push(entry);
}
function scanImportBoundaryViolations(sourceFile, filePath) {
const entries = [];
const relativeFile = normalizeRepoPath(repoRoot, filePath);
visitModuleSpecifiers(ts, sourceFile, ({ kind, specifier, specifierNode }) => {
const resolvedPath = resolveRepoSpecifier(repoRoot, specifier, filePath);
if (!resolvedPath?.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) {
return;
}
pushEntry(entries, {
file: relativeFile,
line: toLine(sourceFile, specifierNode),
kind,
specifier,
resolvedPath,
reason: classifyResolvedExtensionReason(kind, resolvedPath),
});
});
return entries;
}
function scanWebSearchRegistrySmells(sourceFile, filePath) {
const relativeFile = normalizeRepoPath(repoRoot, filePath);
if (relativeFile !== "src/plugins/web-search-providers.ts") {
return [];
}
const entries = [];
const lines = sourceFile.text.split(/\r?\n/);
for (const [index, line] of lines.entries()) {
const lineNumber = index + 1;
if (line.includes("web-search-plugin-factory.js")) {
pushEntry(entries, {
file: relativeFile,
line: lineNumber,
kind: "registry-smell",
specifier: "../agents/tools/web-search-plugin-factory.js",
resolvedPath: "src/agents/tools/web-search-plugin-factory.js",
reason: "imports core-owned web search provider factory into plugin registry",
});
}
const pluginMatch = line.match(/pluginId:\s*"([^"]+)"/);
if (pluginMatch && bundledWebSearchPluginIds.has(pluginMatch[1])) {
pushEntry(entries, {
file: relativeFile,
line: lineNumber,
kind: "registry-smell",
specifier: pluginMatch[1],
resolvedPath: relativeFile,
reason: "hardcodes bundled web search plugin ownership in core registry",
});
}
const providerMatch = line.match(/id:\s*"(brave|firecrawl|gemini|grok|kimi|perplexity)"/);
if (providerMatch && bundledWebSearchProviders.has(providerMatch[1])) {
pushEntry(entries, {
file: relativeFile,
line: lineNumber,
kind: "registry-smell",
specifier: providerMatch[1],
resolvedPath: relativeFile,
reason: "hardcodes bundled web search provider metadata in core registry",
});
}
}
return entries;
}
function shouldSkipFile(filePath) {
const relativeFile = normalizeRepoPath(repoRoot, filePath);
return (
relativeFile === "src/plugins/bundled-web-search-registry.ts" ||
relativeFile.startsWith("src/plugins/contracts/") ||
/^src\/plugins\/runtime\/runtime-[^/]+-contract\.[cm]?[jt]s$/u.test(relativeFile)
);
}
export const collectPluginExtensionImportBoundaryInventory = createCachedAsync(async () => {
const files = (await collectTypeScriptFilesFromRoots(scanRoots))
.filter((filePath) => !shouldSkipFile(filePath))
.toSorted((left, right) =>
normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)),
);
return await collectTypeScriptInventory({
ts,
files,
compareEntries,
collectEntries(sourceFile, filePath) {
return [
...scanImportBoundaryViolations(sourceFile, filePath),
...scanWebSearchRegistrySmells(sourceFile, filePath),
];
},
});
});
export const readExpectedInventory = createCachedAsync(async () =>
JSON.parse(await fs.readFile(baselinePath, "utf8")),
);
export function diffInventory(expected, actual) {
return diffInventoryEntries(expected, actual, compareEntries);
}
const formatInventoryHuman = (inventory) =>
formatGroupedInventoryHuman(
{
rule: "Rule: src/plugins/** must not import bundled plugin files",
cleanMessage: "No plugin import boundary violations found.",
inventoryTitle: "Plugin extension import boundary inventory:",
},
inventory,
);
function formatEntry(entry) {
return `${entry.file}:${entry.line} [${entry.kind}] ${entry.reason} (${entry.specifier} -> ${entry.resolvedPath})`;
}
export async function runPluginExtensionImportBoundaryCheck(argv = process.argv.slice(2), io) {
return await runBaselineInventoryCheck({
argv,
io,
collectActual: collectPluginExtensionImportBoundaryInventory,
readExpected: readExpectedInventory,
diffInventory,
formatInventoryHuman,
formatEntry,
});
}
export async function main(argv = process.argv.slice(2), io) {
const exitCode = await runPluginExtensionImportBoundaryCheck(argv, io);
if (!io && exitCode !== 0) {
process.exit(exitCode);
}
return exitCode;
}
runAsScript(import.meta.url, main);

View file

@ -0,0 +1,127 @@
#!/usr/bin/env node
/**
* Verifies that the root plugin-sdk runtime surface is present in the compiled
* dist output.
*
* Run after `pnpm build` to catch missing root exports or leaked repo-only type
* aliases before release.
*/
import { readFileSync, existsSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { pluginSdkSubpaths } from "./lib/plugin-sdk-entries.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const distFile = resolve(__dirname, "..", "dist", "plugin-sdk", "index.js");
if (!existsSync(distFile)) {
console.error("ERROR: dist/plugin-sdk/index.js not found. Run `pnpm build` first.");
process.exit(1);
}
const content = readFileSync(distFile, "utf-8");
// Extract the final export statement from the compiled output.
// tsdown/rolldown emits a single `export { ... }` at the end of the file.
const exportMatch = content.match(/export\s*\{([^}]+)\}\s*;?\s*$/);
if (!exportMatch) {
console.error("ERROR: Could not find export statement in dist/plugin-sdk/index.js");
process.exit(1);
}
const exportedNames = exportMatch[1]
.split(",")
.map((s) => {
// Handle `foo as bar` aliases — the exported name is the `bar` part
const parts = s.trim().split(/\s+as\s+/);
return (parts[parts.length - 1] || "").trim();
})
.filter(Boolean);
const exportSet = new Set(exportedNames);
const requiredRuntimeShimEntries = ["compat.js", "root-alias.cjs"];
const requiredSubpathExports = {
"secret-input-runtime": [
"coerceSecretRef",
"hasConfiguredSecretInput",
"isSecretRef",
"normalizeResolvedSecretInputString",
"normalizeSecretInputString",
"resolveSecretInputString",
],
};
// The root plugin-sdk entry intentionally stays tiny. Keep this list aligned
// with src/plugin-sdk/index.ts runtime exports.
const requiredExports = [
"emptyPluginConfigSchema",
"onDiagnosticEvent",
"registerContextEngine",
"delegateCompactionToRuntime",
];
let missing = 0;
for (const name of requiredExports) {
if (!exportSet.has(name)) {
console.error(`MISSING EXPORT: ${name}`);
missing += 1;
}
}
for (const entry of pluginSdkSubpaths) {
const jsPath = resolve(__dirname, "..", "dist", "plugin-sdk", `${entry}.js`);
const dtsPath = resolve(__dirname, "..", "dist", "plugin-sdk", `${entry}.d.ts`);
if (!existsSync(jsPath)) {
console.error(`MISSING SUBPATH JS: dist/plugin-sdk/${entry}.js`);
missing += 1;
}
if (!existsSync(dtsPath)) {
console.error(`MISSING SUBPATH DTS: dist/plugin-sdk/${entry}.d.ts`);
missing += 1;
}
}
for (const entry of requiredRuntimeShimEntries) {
const shimPath = resolve(__dirname, "..", "dist", "plugin-sdk", entry);
if (!existsSync(shimPath)) {
console.error(`MISSING RUNTIME SHIM: dist/plugin-sdk/${entry}`);
missing += 1;
}
}
for (const [entry, names] of Object.entries(requiredSubpathExports)) {
const jsPath = resolve(__dirname, "..", "dist", "plugin-sdk", `${entry}.js`);
if (!existsSync(jsPath)) {
continue;
}
let runtime;
try {
runtime = await import(pathToFileURL(jsPath).href);
} catch (err) {
console.error(`BROKEN SUBPATH JS: dist/plugin-sdk/${entry}.js`);
console.error(err instanceof Error ? err.message : String(err));
missing += 1;
continue;
}
for (const name of names) {
if (typeof runtime[name] !== "function") {
console.error(`MISSING SUBPATH EXPORT: dist/plugin-sdk/${entry}.js#${name}`);
missing += 1;
}
}
}
if (missing > 0) {
console.error(
`\nERROR: ${missing} required plugin-sdk artifact(s) missing (named exports or subpath files).`,
);
console.error("This will break published plugin-sdk artifacts.");
console.error(
"Check src/plugin-sdk/index.ts, generated d.ts rewrites, subpath entries, and rebuild.",
);
process.exit(1);
}
console.log(`OK: All ${requiredExports.length} required plugin-sdk exports verified.`);

View file

@ -0,0 +1,126 @@
#!/usr/bin/env node
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import { normalizeRepoPath, visitModuleSpecifiers } from "./lib/guard-inventory-utils.mjs";
import {
collectTypeScriptFilesFromRoots,
resolveSourceRoots,
toLine,
} from "./lib/ts-guard-utils.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const scanRoots = resolveSourceRoots(repoRoot, ["src", "extensions", "scripts", "test"]);
function readPackageExports() {
const packageJson = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8"));
return new Set(
Object.keys(packageJson.exports ?? {})
.filter((key) => key.startsWith("./plugin-sdk/"))
.map((key) => key.slice("./plugin-sdk/".length)),
);
}
function readEntrypoints() {
const entrypoints = JSON.parse(
readFileSync(path.join(repoRoot, "scripts/lib/plugin-sdk-entrypoints.json"), "utf8"),
);
return new Set(entrypoints.filter((entry) => entry !== "index"));
}
function parsePluginSdkSubpath(specifier) {
if (!specifier.startsWith("openclaw/plugin-sdk/")) {
return null;
}
const subpath = specifier.slice("openclaw/plugin-sdk/".length);
return subpath || null;
}
function compareEntries(left, right) {
return (
left.file.localeCompare(right.file) ||
left.line - right.line ||
left.kind.localeCompare(right.kind) ||
left.specifier.localeCompare(right.specifier) ||
left.subpath.localeCompare(right.subpath)
);
}
async function collectViolations() {
const entrypoints = readEntrypoints();
const exports = readPackageExports();
const files = (await collectTypeScriptFilesFromRoots(scanRoots, { includeTests: true })).toSorted(
(left, right) =>
normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)),
);
const violations = [];
for (const filePath of files) {
const sourceText = readFileSync(filePath, "utf8");
const sourceFile = ts.createSourceFile(
filePath,
sourceText,
ts.ScriptTarget.Latest,
true,
filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
function push(kind, specifierNode, specifier) {
const subpath = parsePluginSdkSubpath(specifier);
if (!subpath) {
return;
}
const missingFrom = [];
if (!entrypoints.has(subpath)) {
missingFrom.push("scripts/lib/plugin-sdk-entrypoints.json");
}
if (!exports.has(subpath)) {
missingFrom.push("package.json exports");
}
if (missingFrom.length === 0) {
return;
}
violations.push({
file: normalizeRepoPath(repoRoot, filePath),
line: toLine(sourceFile, specifierNode),
kind,
specifier,
subpath,
missingFrom,
});
}
visitModuleSpecifiers(ts, sourceFile, ({ kind, specifier, specifierNode }) => {
push(kind, specifierNode, specifier);
});
}
return violations.toSorted(compareEntries);
}
async function main() {
const violations = await collectViolations();
if (violations.length === 0) {
console.log("OK: all referenced openclaw/plugin-sdk/<subpath> imports are exported.");
return;
}
console.error(
"Rule: every referenced openclaw/plugin-sdk/<subpath> must exist in the public package exports.",
);
for (const violation of violations) {
console.error(
`- ${violation.file}:${violation.line} [${violation.kind}] ${violation.specifier} missing from ${violation.missingFrom.join(" and ")}`,
);
}
process.exit(1);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View file

@ -0,0 +1,21 @@
#!/usr/bin/env node
import { createExtensionImportBoundaryChecker } from "./lib/extension-import-boundary-checker.mjs";
import { runAsScript } from "./lib/ts-guard-utils.mjs";
const checker = createExtensionImportBoundaryChecker({
roots: ["src/plugin-sdk", "packages"],
boundaryLabel: "sdk/package",
rule: "Rule: src/plugin-sdk/** and packages/** must not import bundled plugin files",
cleanMessage: "No sdk/package import boundary violations found.",
inventoryTitle: "SDK/package extension import boundary inventory:",
skipSourcesWithoutBundledPluginPrefix: true,
shouldSkipFile(relativeFile) {
return relativeFile.startsWith("packages/plugin-sdk/dist/");
},
});
export const collectSdkPackageExtensionImportBoundaryInventory = checker.collectInventory;
export const main = checker.main;
runAsScript(import.meta.url, main);

View file

@ -0,0 +1,26 @@
#!/usr/bin/env node
import { createExtensionImportBoundaryChecker } from "./lib/extension-import-boundary-checker.mjs";
import { runAsScript } from "./lib/ts-guard-utils.mjs";
const checker = createExtensionImportBoundaryChecker({
roots: ["src"],
boundaryLabel: "src",
rule: "Rule: production src/** must not import bundled plugin files",
cleanMessage: "No src import boundary violations found.",
inventoryTitle: "Src extension import boundary inventory:",
skipSourcesWithoutBundledPluginPrefix: true,
shouldSkipFile(relativeFile) {
return (
relativeFile.endsWith(".test.ts") ||
relativeFile.endsWith(".test.tsx") ||
relativeFile.endsWith(".e2e.test.ts") ||
relativeFile.endsWith(".e2e.test.tsx")
);
},
});
export const collectSrcExtensionImportBoundaryInventory = checker.collectInventory;
export const main = checker.main;
runAsScript(import.meta.url, main);

View file

@ -0,0 +1,17 @@
#!/usr/bin/env node
import { createExtensionImportBoundaryChecker } from "./lib/extension-import-boundary-checker.mjs";
import { runAsScript } from "./lib/ts-guard-utils.mjs";
const checker = createExtensionImportBoundaryChecker({
roots: ["test/helpers"],
boundaryLabel: "test helper",
rule: "Rule: test/helpers/** must not import bundled plugin files directly",
cleanMessage: "No test-helper import boundary violations found.",
inventoryTitle: "Test-helper extension import boundary inventory:",
});
export const collectTestHelperExtensionImportBoundaryInventory = checker.collectInventory;
export const main = checker.main;
runAsScript(import.meta.url, main);

View file

@ -0,0 +1,3 @@
import { main } from "./check.mjs";
await main([...process.argv.slice(2), "--timed"]);

View file

@ -0,0 +1,83 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
function writeStdoutLine(message: string): void {
process.stdout.write(`${message}\n`);
}
type ParsedArgs = {
maxLines: number;
};
function parseArgs(argv: string[]): ParsedArgs {
let maxLines = 500;
for (let index = 0; index < argv.length; index++) {
const arg = argv[index];
if (arg === "--max") {
const next = argv[index + 1];
if (!next || Number.isNaN(Number(next))) {
throw new Error("Missing/invalid --max value");
}
maxLines = Number(next);
index++;
continue;
}
}
return { maxLines };
}
function gitLsFilesAll(): string[] {
// Include untracked files too so local refactors don’t “pass” by accident.
const stdout = execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], {
encoding: "utf8",
});
return stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
async function countLines(filePath: string): Promise<number> {
const content = await readFile(filePath, "utf8");
// Count physical lines. Keeps the rule simple + predictable.
return content.split("\n").length;
}
async function main() {
// Makes `... | head` safe.
process.stdout.on("error", (error: NodeJS.ErrnoException) => {
if (error.code === "EPIPE") {
process.exit(0);
}
throw error;
});
const { maxLines } = parseArgs(process.argv.slice(2));
const files = gitLsFilesAll()
.filter((filePath) => existsSync(filePath))
.filter((filePath) => filePath.endsWith(".ts") || filePath.endsWith(".tsx"));
const results = await Promise.all(
files.map(async (filePath) => ({ filePath, lines: await countLines(filePath) })),
);
const offenders = results
.filter((result) => result.lines > maxLines)
.toSorted((a, b) => b.lines - a.lines);
if (!offenders.length) {
return;
}
// Minimal, grep-friendly output.
for (const offender of offenders) {
writeStdoutLine(`${offender.lines}\t${offender.filePath}`);
}
process.exitCode = 1;
}
await main();

View file

@ -0,0 +1,59 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import path from "node:path";
const repoRoot = path.resolve(import.meta.dirname, "..");
const tsgoPath = path.join(repoRoot, "node_modules", ".bin", "tsgo");
const coreGraphs = [
{ name: "core", config: "tsconfig.core.json" },
{ name: "core-test", config: "tsconfig.core.test.json" },
{ name: "core-test-agents", config: "tsconfig.core.test.agents.json" },
{ name: "core-test-non-agents", config: "tsconfig.core.test.non-agents.json" },
];
function normalizeFilePath(filePath) {
const normalized = filePath.trim().replaceAll("\\", "/");
const normalizedRoot = repoRoot.replaceAll("\\", "/");
if (normalized.startsWith(`${normalizedRoot}/`)) {
return normalized.slice(normalizedRoot.length + 1);
}
return normalized;
}
function listGraphFiles(graph) {
const result = spawnSync(tsgoPath, ["-p", graph.config, "--pretty", "false", "--listFilesOnly"], {
cwd: repoRoot,
encoding: "utf8",
maxBuffer: 256 * 1024 * 1024,
shell: process.platform === "win32",
});
if (result.error) {
throw result.error;
}
if ((result.status ?? 1) !== 0) {
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
throw new Error(`${graph.name} file listing failed with exit code ${result.status}\n${output}`);
}
return (result.stdout ?? "").split(/\r?\n/u).map(normalizeFilePath).filter(Boolean);
}
const violations = [];
for (const graph of coreGraphs) {
const extensionFiles = listGraphFiles(graph).filter((file) => file.startsWith("extensions/"));
for (const file of extensionFiles) {
violations.push(`${graph.name}: ${file}`);
}
}
if (violations.length > 0) {
console.error("Core tsgo graphs must not include bundled extension files:");
for (const violation of violations) {
console.error(`- ${violation}`);
}
console.error(
"Move extension-owned behavior behind plugin SDK contracts, public artifacts, or extension-local tests.",
);
process.exit(1);
}

View file

@ -0,0 +1,104 @@
#!/usr/bin/env node
import path from "node:path";
import { fileURLToPath } from "node:url";
import { collectSourceFileContents } from "./lib/source-file-scan-cache.mjs";
import { runAsScript } from "./lib/ts-guard-utils.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const scanExtensions = new Set([".ts", ".js", ".mjs", ".cjs"]);
const ignoredDirNames = new Set([
".artifacts",
".git",
".turbo",
"build",
"coverage",
"dist",
"extensions",
"node_modules",
]);
const allowedFiles = new Set([
"src/agents/tools/web-fetch.test-harness.ts",
"src/config/legacy-web-fetch.ts",
"src/config/zod-schema.agent-runtime.ts",
"src/secrets/target-registry-data.ts",
]);
const suspiciousPatterns = [
/fetchFirecrawlContent/,
/firecrawl-fetch-provider\.js/,
/createFirecrawlWebFetchProvider/,
/providerId:\s*"firecrawl"/,
/provider:\s*"firecrawl"/,
/id:\s*"firecrawl"/,
];
export async function collectWebFetchProviderBoundaryViolations() {
const violations = [];
const files = await collectSourceFileContents({
repoRoot,
scanRoots: ["src"],
scanExtensions,
ignoredDirNames,
});
for (const { relativeFile, content } of files) {
if (
allowedFiles.has(relativeFile) ||
relativeFile.includes(".test.") ||
relativeFile.includes("test-support")
) {
continue;
}
const lines = content.split(/\r?\n/);
for (const [index, line] of lines.entries()) {
if (!line.includes("firecrawl") && !line.includes("Firecrawl")) {
continue;
}
if (!suspiciousPatterns.some((pattern) => pattern.test(line))) {
continue;
}
violations.push({
file: relativeFile,
line: index + 1,
reason: "core web-fetch runtime/tooling contains Firecrawl-specific fetch logic",
});
}
}
return violations.toSorted(
(left, right) => left.file.localeCompare(right.file) || left.line - right.line,
);
}
export async function main(argv = process.argv.slice(2), io) {
const json = argv.includes("--json");
const violations = await collectWebFetchProviderBoundaryViolations();
const writeStdout = (chunk) => {
if (io?.stdout?.write) {
io.stdout.write(chunk);
return;
}
process.stdout.write(chunk);
};
const writeStderr = (chunk) => {
if (io?.stderr?.write) {
io.stderr.write(chunk);
return;
}
process.stderr.write(chunk);
};
if (json) {
writeStdout(`${JSON.stringify(violations, null, 2)}\n`);
} else if (violations.length > 0) {
for (const violation of violations) {
writeStderr(`${violation.file}:${violation.line} ${violation.reason}\n`);
}
}
return violations.length === 0 ? 0 : 1;
}
runAsScript(import.meta.url, async (argv, io) => {
const exitCode = await main(argv, io);
if (!io && exitCode !== 0) {
process.exit(exitCode);
}
return exitCode;
});

View file

@ -0,0 +1,242 @@
#!/usr/bin/env node
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { diffInventoryEntries, runBaselineInventoryCheck } from "./lib/guard-inventory-utils.mjs";
import { collectSourceFileContents } from "./lib/source-file-scan-cache.mjs";
import { runAsScript } from "./lib/ts-guard-utils.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const baselinePath = path.join(
repoRoot,
"test",
"fixtures",
"web-search-provider-boundary-inventory.json",
);
const scanRoots = ["src"];
const scanExtensions = new Set([".ts", ".js", ".mjs", ".cjs"]);
const ignoredDirNames = new Set([
".artifacts",
".git",
".turbo",
"build",
"coverage",
"dist",
"extensions",
"node_modules",
]);
const bundledProviderPluginToSearchProvider = new Map([
["brave", "brave"],
["firecrawl", "firecrawl"],
["google", "gemini"],
["moonshot", "kimi"],
["perplexity", "perplexity"],
["xai", "grok"],
]);
const providerIds = new Set([
"brave",
"firecrawl",
"gemini",
"grok",
"kimi",
"perplexity",
"shared",
]);
const allowedGenericFiles = new Set([
"src/agents/tools/web-search.ts",
"src/commands/onboard-search.ts",
"src/plugins/bundled-web-search-registry.ts",
"src/secrets/runtime-web-tools.ts",
"src/web-search/runtime.ts",
]);
const ignoredFiles = new Set([
"src/config/config.web-search-provider.test.ts",
"src/plugins/contracts/loader.contract.test.ts",
"src/plugins/contracts/registry.contract.test.ts",
"src/plugins/web-search-providers.test.ts",
"src/secrets/runtime-web-tools.test.ts",
]);
let webSearchProviderInventoryPromise;
function compareInventoryEntries(left, right) {
return (
left.provider.localeCompare(right.provider) ||
left.file.localeCompare(right.file) ||
left.line - right.line ||
left.reason.localeCompare(right.reason)
);
}
function pushEntry(inventory, entry) {
if (!providerIds.has(entry.provider)) {
throw new Error(`Unknown provider id in boundary inventory: ${entry.provider}`);
}
inventory.push(entry);
}
function scanWebSearchProviderRegistry(lines, relativeFile, inventory) {
for (const [index, line] of lines.entries()) {
const lineNumber = index + 1;
if (line.includes("firecrawl-search-provider.js")) {
pushEntry(inventory, {
provider: "shared",
file: relativeFile,
line: lineNumber,
reason: "imports extension web search provider implementation into core registry",
});
}
if (line.includes("web-search-plugin-factory.js")) {
pushEntry(inventory, {
provider: "shared",
file: relativeFile,
line: lineNumber,
reason: "imports shared web search provider registration helper into core registry",
});
}
const pluginMatch = line.match(/pluginId:\s*"([^"]+)"/);
const providerFromPlugin = pluginMatch
? bundledProviderPluginToSearchProvider.get(pluginMatch[1])
: undefined;
if (providerFromPlugin) {
pushEntry(inventory, {
provider: providerFromPlugin,
file: relativeFile,
line: lineNumber,
reason: "hardcodes bundled web search plugin ownership in core registry",
});
}
const providerMatch = line.match(/id:\s*"(brave|firecrawl|gemini|grok|kimi|perplexity)"/);
if (providerMatch) {
pushEntry(inventory, {
provider: providerMatch[1],
file: relativeFile,
line: lineNumber,
reason: "hardcodes bundled web search provider id in core registry",
});
}
}
}
function scanGenericCoreImports(lines, relativeFile, inventory) {
if (allowedGenericFiles.has(relativeFile)) {
return;
}
for (const [index, line] of lines.entries()) {
const lineNumber = index + 1;
if (line.includes("web-search-providers.js")) {
pushEntry(inventory, {
provider: "shared",
file: relativeFile,
line: lineNumber,
reason: "imports bundled web search registry outside allowed generic plumbing",
});
}
if (line.includes("web-search-plugin-factory.js")) {
pushEntry(inventory, {
provider: "shared",
file: relativeFile,
line: lineNumber,
reason: "imports web search provider registration helper outside extensions",
});
}
}
}
export async function collectWebSearchProviderBoundaryInventory() {
if (!webSearchProviderInventoryPromise) {
webSearchProviderInventoryPromise = (async () => {
const inventory = [];
const files = await collectSourceFileContents({
repoRoot,
scanRoots,
scanExtensions,
ignoredDirNames,
});
for (const { relativeFile, content } of files) {
if (ignoredFiles.has(relativeFile) || relativeFile.includes(".test.")) {
continue;
}
const lines = content.split(/\r?\n/);
if (relativeFile === "src/plugins/web-search-providers.ts") {
scanWebSearchProviderRegistry(lines, relativeFile, inventory);
continue;
}
scanGenericCoreImports(lines, relativeFile, inventory);
}
return inventory.toSorted(compareInventoryEntries);
})();
}
return await webSearchProviderInventoryPromise;
}
export async function readExpectedInventory() {
try {
return JSON.parse(await fs.readFile(baselinePath, "utf8"));
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return [];
}
throw error;
}
}
export function diffInventory(expected, actual) {
return diffInventoryEntries(expected, actual, compareInventoryEntries);
}
function formatInventoryHuman(inventory) {
if (inventory.length === 0) {
return "No web search provider boundary inventory entries found.";
}
const lines = ["Web search provider boundary inventory:"];
let activeProvider = "";
for (const entry of inventory) {
if (entry.provider !== activeProvider) {
activeProvider = entry.provider;
lines.push(`${activeProvider}:`);
}
lines.push(` - ${entry.file}:${entry.line} ${entry.reason}`);
}
return lines.join("\n");
}
function formatEntry(entry) {
return `${entry.provider} ${entry.file}:${entry.line} ${entry.reason}`;
}
export async function runWebSearchProviderBoundaryCheck(argv = process.argv.slice(2), io) {
return await runBaselineInventoryCheck({
argv,
io,
collectActual: collectWebSearchProviderBoundaryInventory,
readExpected: readExpectedInventory,
diffInventory,
formatInventoryHuman,
formatEntry,
});
}
export async function main(argv = process.argv.slice(2), io) {
const exitCode = await runWebSearchProviderBoundaryCheck(argv, io);
if (!io && exitCode !== 0) {
process.exit(exitCode);
}
return exitCode;
}
runAsScript(import.meta.url, main);

View file

@ -0,0 +1,62 @@
#!/usr/bin/env node
import path from "node:path";
import ts from "typescript";
import { bundledPluginCallsite, bundledPluginFile } from "./lib/bundled-plugin-paths.mjs";
import { runCallsiteGuard } from "./lib/callsite-guard.mjs";
import { runAsScript, toLine, unwrapExpression } from "./lib/ts-guard-utils.mjs";
const sourceRoots = ["extensions"];
const enforcedFiles = new Set([
bundledPluginFile("bluebubbles", "src/monitor.ts"),
bundledPluginFile("feishu", "src/monitor.transport.ts"),
bundledPluginFile("googlechat", "src/monitor.ts"),
bundledPluginFile("zalo", "src/monitor.webhook.ts"),
]);
const blockedCallees = new Set(["readJsonBodyWithLimit", "readRequestBodyWithLimit"]);
const allowedCallsites = new Set([
// Feishu signs the exact wire body, so this handler must read raw bytes before parsing JSON.
bundledPluginCallsite("feishu", "src/monitor.transport.ts", 199),
]);
function getCalleeName(expression) {
const callee = unwrapExpression(expression);
if (ts.isIdentifier(callee)) {
return callee.text;
}
if (ts.isPropertyAccessExpression(callee)) {
return callee.name.text;
}
return null;
}
export function findBlockedWebhookBodyReadLines(content, fileName = "source.ts") {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const lines = [];
const visit = (node) => {
if (ts.isCallExpression(node)) {
const calleeName = getCalleeName(node.expression);
if (calleeName && blockedCallees.has(calleeName)) {
lines.push(toLine(sourceFile, node.expression));
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return lines;
}
export async function main() {
await runCallsiteGuard({
importMetaUrl: import.meta.url,
sourceRoots,
findCallLines: findBlockedWebhookBodyReadLines,
skipRelativePath: (relPath) => !enforcedFiles.has(relPath.replaceAll(path.sep, "/")),
allowCallsite: (callsite) => allowedCallsites.has(callsite),
header: "Found forbidden low-level body reads in auth-sensitive webhook handlers:",
footer:
"Use plugin-sdk webhook guards (`readJsonWebhookBodyOrReject` / `readWebhookBodyOrReject`) with explicit pre-auth/post-auth profiles.",
});
}
runAsScript(import.meta.url, main);

131
openclaw/scripts/check.mjs Normal file
View file

@ -0,0 +1,131 @@
import { spawn } from "node:child_process";
import { performance } from "node:perf_hooks";
export async function main(argv = process.argv.slice(2)) {
const timed = argv.includes("--timed");
const includeArchitecture = argv.includes("--include-architecture");
const includeTestTypes = argv.includes("--include-test-types");
const tailChecks = [
{ name: "webhook body guard", args: ["lint:webhook:no-low-level-body-read"] },
{ name: "pairing store guard", args: ["lint:auth:no-pairing-store-group"] },
{ name: "pairing account guard", args: ["lint:auth:pairing-account-scope"] },
includeArchitecture
? { name: "architecture import cycles", args: ["check:architecture"] }
: { name: "runtime import cycles", args: ["check:import-cycles"] },
];
const stages = [
{
name: "preflight guards",
parallel: true,
commands: [
{ name: "conflict markers", args: ["check:no-conflict-markers"] },
{ name: "tool display", args: ["tool-display:check"] },
{ name: "host env policy", args: ["check:host-env-policy:swift"] },
],
},
{
name: "typecheck",
parallel: false,
commands: [
{
name: includeTestTypes ? "typecheck all" : "typecheck prod",
args: [includeTestTypes ? "tsgo:all" : "tsgo:prod"],
},
],
},
{
name: "lint",
parallel: false,
commands: [{ name: "lint", args: ["lint"] }],
},
{
name: "policy guards",
parallel: true,
commands: tailChecks,
},
];
const timings = [];
let exitCode = 0;
for (const stage of stages) {
console.error(`\n[check] ${stage.name}`);
const results = stage.parallel
? await Promise.all(stage.commands.map((command) => runCommand(command)))
: await runSerial(stage.commands);
timings.push(...results);
const failed = results.find((result) => result.status !== 0);
if (failed) {
exitCode = failed.status;
break;
}
}
if (timed || exitCode !== 0) {
printSummary(timings);
}
process.exitCode = exitCode;
}
async function runSerial(commands) {
const results = [];
for (const command of commands) {
const result = await runCommand(command);
results.push(result);
if (result.status !== 0) {
break;
}
}
return results;
}
async function runCommand(command) {
const startedAt = performance.now();
const child = spawn("pnpm", command.args, {
stdio: "inherit",
shell: process.platform === "win32",
});
return await new Promise((resolve) => {
child.once("error", (error) => {
console.error(error);
resolve({
name: command.name,
durationMs: performance.now() - startedAt,
status: 1,
});
});
child.once("close", (status) => {
resolve({
name: command.name,
durationMs: performance.now() - startedAt,
status: status ?? 1,
});
});
});
}
function printSummary(timings) {
console.error("\n[check] summary");
for (const timing of timings) {
const status = timing.status === 0 ? "ok" : `failed:${timing.status}`;
console.error(
`${formatMs(timing.durationMs).padStart(8)} ${status.padEnd(9)} ${timing.name}`,
);
}
}
function formatMs(durationMs) {
if (durationMs < 1000) {
return `${Math.round(durationMs)}ms`;
}
return `${(durationMs / 1000).toFixed(2)}s`;
}
if (import.meta.main) {
await main();
}

View file

@ -0,0 +1,13 @@
export type ChangedScope = {
runNode: boolean;
runMacos: boolean;
runAndroid: boolean;
runWindows: boolean;
runSkillsPython: boolean;
runChangedSmoke: boolean;
runControlUiI18n: boolean;
};
export function detectChangedScope(changedPaths: string[]): ChangedScope;
export function listChangedPaths(base: string, head?: string): string[];
export function writeGitHubOutput(scope: ChangedScope, outputPath?: string): void;

View file

@ -0,0 +1,208 @@
import { execFileSync } from "node:child_process";
import { appendFileSync } from "node:fs";
/** @typedef {{ runNode: boolean; runMacos: boolean; runAndroid: boolean; runWindows: boolean; runSkillsPython: boolean; runChangedSmoke: boolean; runControlUiI18n: boolean }} ChangedScope */
const DOCS_PATH_RE = /^(docs\/|.*\.mdx?$)/;
const SKILLS_PYTHON_SCOPE_RE = /^(skills\/|pyproject\.toml$)/;
const CI_WORKFLOW_SCOPE_RE = /^\.github\/workflows\/ci\.yml$/;
const INSTALL_SMOKE_WORKFLOW_SCOPE_RE = /^\.github\/workflows\/install-smoke\.yml$/;
const MACOS_PROTOCOL_GEN_RE =
/^(apps\/macos\/Sources\/OpenClawProtocol\/|apps\/shared\/OpenClawKit\/Sources\/OpenClawProtocol\/)/;
const MACOS_NATIVE_RE = /^(apps\/macos\/|apps\/ios\/|apps\/shared\/|Swabble\/)/;
const ANDROID_NATIVE_RE = /^(apps\/android\/|apps\/shared\/)/;
const NODE_SCOPE_RE =
/^(src\/|test\/|extensions\/|packages\/|scripts\/|ui\/|\.github\/|openclaw\.mjs$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsconfig.*\.json$|vitest.*\.ts$|tsdown\.config\.ts$|\.oxlintrc\.json$|\.oxfmtrc\.jsonc$)/;
const WINDOWS_SCOPE_RE =
/^(src\/|test\/|extensions\/|packages\/|scripts\/|ui\/|openclaw\.mjs$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsconfig.*\.json$|vitest.*\.ts$|tsdown\.config\.ts$|\.github\/workflows\/ci\.yml$|\.github\/actions\/setup-node-env\/action\.yml$|\.github\/actions\/setup-pnpm-store-cache\/action\.yml$)/;
const CONTROL_UI_I18N_SCOPE_RE =
/^(ui\/src\/i18n\/|scripts\/control-ui-i18n\.ts$|\.github\/workflows\/control-ui-locale-refresh\.yml$)/;
const NATIVE_ONLY_RE =
/^(apps\/android\/|apps\/ios\/|apps\/macos\/|apps\/shared\/|Swabble\/|appcast\.xml$)/;
const CHANGED_SMOKE_SCOPE_RE =
/^(Dockerfile$|\.npmrc$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|scripts\/install\.sh$|scripts\/test-install-sh-docker\.sh$|scripts\/docker\/|extensions\/[^/]+\/package\.json$|\.github\/workflows\/install-smoke\.yml$|\.github\/actions\/setup-node-env\/action\.yml$)/;
/**
* @param {string[]} changedPaths
* @returns {ChangedScope}
*/
export function detectChangedScope(changedPaths) {
if (!Array.isArray(changedPaths) || changedPaths.length === 0) {
return {
runNode: true,
runMacos: true,
runAndroid: true,
runWindows: true,
runSkillsPython: true,
runChangedSmoke: true,
runControlUiI18n: true,
};
}
let runNode = false;
let runMacos = false;
let runAndroid = false;
let runWindows = false;
let runSkillsPython = false;
let runChangedSmoke = false;
let runControlUiI18n = false;
let hasNonDocs = false;
let hasNonNativeNonDocs = false;
for (const rawPath of changedPaths) {
const path = rawPath.trim();
if (!path) {
continue;
}
if (DOCS_PATH_RE.test(path)) {
continue;
}
hasNonDocs = true;
if (SKILLS_PYTHON_SCOPE_RE.test(path)) {
runSkillsPython = true;
}
if (CI_WORKFLOW_SCOPE_RE.test(path)) {
runMacos = true;
runAndroid = true;
runSkillsPython = true;
}
if (INSTALL_SMOKE_WORKFLOW_SCOPE_RE.test(path)) {
runChangedSmoke = true;
}
if (!MACOS_PROTOCOL_GEN_RE.test(path) && MACOS_NATIVE_RE.test(path)) {
runMacos = true;
}
if (ANDROID_NATIVE_RE.test(path)) {
runAndroid = true;
}
if (NODE_SCOPE_RE.test(path)) {
runNode = true;
}
if (WINDOWS_SCOPE_RE.test(path)) {
runWindows = true;
}
if (CHANGED_SMOKE_SCOPE_RE.test(path)) {
runChangedSmoke = true;
}
if (CONTROL_UI_I18N_SCOPE_RE.test(path)) {
runControlUiI18n = true;
}
if (!NATIVE_ONLY_RE.test(path)) {
hasNonNativeNonDocs = true;
}
}
if (!runNode && hasNonDocs && hasNonNativeNonDocs) {
runNode = true;
}
return {
runNode,
runMacos,
runAndroid,
runWindows,
runSkillsPython,
runChangedSmoke,
runControlUiI18n,
};
}
/**
* @param {string} base
* @param {string} [head]
* @returns {string[]}
*/
export function listChangedPaths(base, head = "HEAD") {
if (!base) {
return [];
}
const output = execFileSync("git", ["diff", "--name-only", base, head], {
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
});
return output
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
}
/**
* @param {ChangedScope} scope
* @param {string} [outputPath]
*/
export function writeGitHubOutput(scope, outputPath = process.env.GITHUB_OUTPUT) {
if (!outputPath) {
throw new Error("GITHUB_OUTPUT is required");
}
appendFileSync(outputPath, `run_node=${scope.runNode}\n`, "utf8");
appendFileSync(outputPath, `run_macos=${scope.runMacos}\n`, "utf8");
appendFileSync(outputPath, `run_android=${scope.runAndroid}\n`, "utf8");
appendFileSync(outputPath, `run_windows=${scope.runWindows}\n`, "utf8");
appendFileSync(outputPath, `run_skills_python=${scope.runSkillsPython}\n`, "utf8");
appendFileSync(outputPath, `run_changed_smoke=${scope.runChangedSmoke}\n`, "utf8");
appendFileSync(outputPath, `run_control_ui_i18n=${scope.runControlUiI18n}\n`, "utf8");
}
function isDirectRun() {
const direct = process.argv[1];
return Boolean(direct && import.meta.url.endsWith(direct));
}
/** @param {string[]} argv */
function parseArgs(argv) {
const args = { base: "", head: "HEAD" };
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--base") {
args.base = argv[i + 1] ?? "";
i += 1;
continue;
}
if (argv[i] === "--head") {
args.head = argv[i + 1] ?? "HEAD";
i += 1;
}
}
return args;
}
if (isDirectRun()) {
const args = parseArgs(process.argv.slice(2));
try {
const changedPaths = listChangedPaths(args.base, args.head);
if (changedPaths.length === 0) {
writeGitHubOutput({
runNode: true,
runMacos: true,
runAndroid: true,
runWindows: true,
runSkillsPython: true,
runChangedSmoke: true,
runControlUiI18n: true,
});
process.exit(0);
}
writeGitHubOutput(detectChangedScope(changedPaths));
} catch {
writeGitHubOutput({
runNode: true,
runMacos: true,
runAndroid: true,
runWindows: true,
runSkillsPython: true,
runChangedSmoke: true,
runControlUiI18n: true,
});
}
}

View file

@ -0,0 +1,84 @@
#!/usr/bin/env bash
set -euo pipefail
profile_path="${1:-${RUNNER_TEMP:-/tmp}/openclaw-live.profile}"
mkdir -p "$(dirname "$profile_path")"
: >"$profile_path"
chmod 600 "$profile_path"
append_profile_env() {
local key="$1"
local value="${!key:-}"
if [[ -z "$value" || "$value" == "undefined" || "$value" == "null" ]]; then
return
fi
printf 'export %s=%q\n' "$key" "$value" >>"$profile_path"
}
write_secret_file() {
local destination="$1"
local source_env="$2"
local value="${!source_env:-}"
if [[ -z "$value" ]]; then
return
fi
mkdir -p "$(dirname "$destination")"
printf '%s' "$value" >"$destination"
chmod 600 "$destination"
}
for env_key in \
OPENAI_API_KEY \
OPENAI_BASE_URL \
ANTHROPIC_API_KEY \
ANTHROPIC_API_KEY_OLD \
ANTHROPIC_API_TOKEN \
BYTEPLUS_API_KEY \
CEREBRAS_API_KEY \
DASHSCOPE_API_KEY \
GROQ_API_KEY \
KIMI_API_KEY \
MODELSTUDIO_API_KEY \
MOONSHOT_API_KEY \
MISTRAL_API_KEY \
MINIMAX_API_KEY \
OPENCODE_API_KEY \
OPENCODE_ZEN_API_KEY \
OPENCLAW_LIVE_BROWSER_CDP_URL \
OPENCLAW_LIVE_SETUP_TOKEN \
OPENCLAW_LIVE_SETUP_TOKEN_MODEL \
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE \
OPENCLAW_LIVE_SETUP_TOKEN_VALUE \
GEMINI_API_KEY \
GOOGLE_API_KEY \
OPENROUTER_API_KEY \
QWEN_API_KEY \
FAL_KEY \
RUNWAY_API_KEY \
DEEPGRAM_API_KEY \
TOGETHER_API_KEY \
VYDRA_API_KEY \
XAI_API_KEY \
ZAI_API_KEY \
Z_AI_API_KEY \
BYTEPLUS_ACCESS_KEY_ID \
BYTEPLUS_SECRET_ACCESS_KEY \
CLAUDE_CODE_OAUTH_TOKEN
do
append_profile_env "$env_key"
done
write_secret_file "$HOME/.codex/auth.json" OPENCLAW_CODEX_AUTH_JSON
write_secret_file "$HOME/.codex/config.toml" OPENCLAW_CODEX_CONFIG_TOML
write_secret_file "$HOME/.claude.json" OPENCLAW_CLAUDE_JSON
write_secret_file "$HOME/.claude/.credentials.json" OPENCLAW_CLAUDE_CREDENTIALS_JSON
write_secret_file "$HOME/.claude/settings.json" OPENCLAW_CLAUDE_SETTINGS_JSON
write_secret_file "$HOME/.claude/settings.local.json" OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON
write_secret_file "$HOME/.gemini/settings.json" OPENCLAW_GEMINI_SETTINGS_JSON
if [[ -n "${GITHUB_ENV:-}" ]]; then
{
echo "OPENCLAW_PROFILE_FILE=$profile_path"
} >>"$GITHUB_ENV"
fi

View file

@ -0,0 +1,280 @@
#!/bin/bash
# Claude Code Authentication Status Checker
# Checks both Claude Code and OpenClaw auth status
set -euo pipefail
CLAUDE_CREDS="$HOME/.claude/.credentials.json"
OPENCLAW_AUTH="$HOME/.openclaw/agents/main/agent/auth-profiles.json"
# Colors for terminal output
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
# Output mode: "full" (default), "json", or "simple"
OUTPUT_MODE="${1:-full}"
fetch_models_status_json() {
openclaw models status --json 2>/dev/null || true
}
STATUS_JSON="$(fetch_models_status_json)"
USE_JSON=0
if [ -n "$STATUS_JSON" ]; then
USE_JSON=1
fi
calc_status_from_expires() {
local expires_at="$1"
if ! [[ "$expires_at" =~ ^-?[0-9]+$ ]]; then
expires_at=0
fi
local now_ms=$(( $(date +%s) * 1000 ))
local diff_ms=$((expires_at - now_ms))
local hours=$((diff_ms / 3600000))
local mins=$(((diff_ms % 3600000) / 60000))
if [ "$expires_at" -le 0 ]; then
echo "MISSING"
return 1
elif [ "$diff_ms" -lt 0 ]; then
echo "EXPIRED"
return 1
elif [ "$diff_ms" -lt 3600000 ]; then
echo "EXPIRING:${mins}m"
return 2
else
echo "OK:${hours}h${mins}m"
return 0
fi
}
json_expires_for_claude_cli() {
echo "$STATUS_JSON" | jq -r '
[.auth.oauth.profiles[]
| select(.provider == "anthropic" and (.type == "oauth" or .type == "token"))
| .expiresAt // 0]
| max // 0
' 2>/dev/null || echo "0"
}
json_expires_for_anthropic_any() {
echo "$STATUS_JSON" | jq -r '
[.auth.oauth.profiles[]
| select(.provider == "anthropic" and .type == "oauth")
| .expiresAt // 0]
| max // 0
' 2>/dev/null || echo "0"
}
json_best_anthropic_profile() {
echo "$STATUS_JSON" | jq -r '
[.auth.oauth.profiles[]
| select(.provider == "anthropic" and .type == "oauth")
| {id: .profileId, exp: (.expiresAt // 0)}]
| sort_by(.exp) | reverse | .[0].id // "none"
' 2>/dev/null || echo "none"
}
json_anthropic_api_key_count() {
echo "$STATUS_JSON" | jq -r '
[.auth.providers[] | select(.provider == "anthropic") | .profiles.apiKey]
| max // 0
' 2>/dev/null || echo "0"
}
check_claude_code_auth() {
if [ "$USE_JSON" -eq 1 ]; then
local expires_at
expires_at=$(json_expires_for_claude_cli)
calc_status_from_expires "$expires_at"
return $?
fi
if [ ! -f "$CLAUDE_CREDS" ]; then
echo "MISSING"
return 1
fi
local expires_at
expires_at=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS" 2>/dev/null || echo "0")
calc_status_from_expires "$expires_at"
}
check_openclaw_auth() {
if [ "$USE_JSON" -eq 1 ]; then
local api_keys
api_keys=$(json_anthropic_api_key_count)
if ! [[ "$api_keys" =~ ^[0-9]+$ ]]; then
api_keys=0
fi
local expires_at
expires_at=$(json_expires_for_anthropic_any)
if [ "$expires_at" -le 0 ] && [ "$api_keys" -gt 0 ]; then
echo "OK:static"
return 0
fi
calc_status_from_expires "$expires_at"
return $?
fi
if [ ! -f "$OPENCLAW_AUTH" ]; then
echo "MISSING"
return 1
fi
local expires
expires=$(jq -r '
[.profiles | to_entries[] | select(.value.provider == "anthropic") | .value.expires]
| max // 0
' "$OPENCLAW_AUTH" 2>/dev/null || echo "0")
calc_status_from_expires "$expires"
}
# JSON output mode
if [ "$OUTPUT_MODE" = "json" ]; then
claude_status=$(check_claude_code_auth 2>/dev/null || true)
openclaw_status=$(check_openclaw_auth 2>/dev/null || true)
claude_expires=0
openclaw_expires=0
if [ "$USE_JSON" -eq 1 ]; then
claude_expires=$(json_expires_for_claude_cli)
openclaw_expires=$(json_expires_for_anthropic_any)
else
claude_expires=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS" 2>/dev/null || echo "0")
openclaw_expires=$(jq -r '.profiles["anthropic:default"].expires // 0' "$OPENCLAW_AUTH" 2>/dev/null || echo "0")
fi
jq -n \
--arg cs "$claude_status" \
--arg ce "$claude_expires" \
--arg bs "$openclaw_status" \
--arg be "$openclaw_expires" \
'{
claude_code: {status: $cs, expires_at_ms: ($ce | tonumber)},
openclaw: {status: $bs, expires_at_ms: ($be | tonumber)},
needs_reauth: (($cs | startswith("EXPIRED") or startswith("EXPIRING") or startswith("MISSING")) or ($bs | startswith("EXPIRED") or startswith("EXPIRING") or startswith("MISSING")))
}'
exit 0
fi
# Simple output mode (for scripts/widgets)
if [ "$OUTPUT_MODE" = "simple" ]; then
claude_status=$(check_claude_code_auth 2>/dev/null || true)
openclaw_status=$(check_openclaw_auth 2>/dev/null || true)
if [[ "$claude_status" == EXPIRED* ]] || [[ "$claude_status" == MISSING* ]]; then
echo "CLAUDE_EXPIRED"
exit 1
elif [[ "$openclaw_status" == EXPIRED* ]] || [[ "$openclaw_status" == MISSING* ]]; then
echo "OPENCLAW_EXPIRED"
exit 1
elif [[ "$claude_status" == EXPIRING* ]]; then
echo "CLAUDE_EXPIRING"
exit 2
elif [[ "$openclaw_status" == EXPIRING* ]]; then
echo "OPENCLAW_EXPIRING"
exit 2
else
echo "OK"
exit 0
fi
fi
# Full output mode (default)
echo "=== Claude Code Auth Status ==="
echo ""
# Claude Code credentials
echo "Claude Code (~/.claude/.credentials.json):"
if [ "$USE_JSON" -eq 1 ]; then
expires_at=$(json_expires_for_claude_cli)
else
expires_at=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS" 2>/dev/null || echo "0")
fi
if [ -f "$CLAUDE_CREDS" ]; then
sub_type=$(jq -r '.claudeAiOauth.subscriptionType // "unknown"' "$CLAUDE_CREDS" 2>/dev/null || echo "unknown")
rate_tier=$(jq -r '.claudeAiOauth.rateLimitTier // "unknown"' "$CLAUDE_CREDS" 2>/dev/null || echo "unknown")
echo " Subscription: $sub_type"
echo " Rate tier: $rate_tier"
fi
if [ "$expires_at" -le 0 ]; then
echo -e " Status: ${RED}NOT FOUND${NC}"
echo " Action needed: Run 'claude setup-token'"
else
now_ms=$(( $(date +%s) * 1000 ))
diff_ms=$((expires_at - now_ms))
hours=$((diff_ms / 3600000))
mins=$(((diff_ms % 3600000) / 60000))
if [ "$diff_ms" -lt 0 ]; then
echo -e " Status: ${RED}EXPIRED${NC}"
echo " Action needed: Run 'claude setup-token' or re-authenticate"
elif [ "$diff_ms" -lt 3600000 ]; then
echo -e " Status: ${YELLOW}EXPIRING SOON (${mins}m remaining)${NC}"
echo " Consider running: claude setup-token"
else
echo -e " Status: ${GREEN}OK${NC}"
echo " Expires: $(date -d @$((expires_at/1000))) (${hours}h ${mins}m)"
fi
fi
echo ""
echo "OpenClaw Auth (~/.openclaw/agents/main/agent/auth-profiles.json):"
if [ "$USE_JSON" -eq 1 ]; then
best_profile=$(json_best_anthropic_profile)
expires=$(json_expires_for_anthropic_any)
api_keys=$(json_anthropic_api_key_count)
else
best_profile=$(jq -r '
.profiles | to_entries
| map(select(.value.provider == "anthropic"))
| sort_by(.value.expires) | reverse
| .[0].key // "none"
' "$OPENCLAW_AUTH" 2>/dev/null || echo "none")
expires=$(jq -r '
[.profiles | to_entries[] | select(.value.provider == "anthropic") | .value.expires]
| max // 0
' "$OPENCLAW_AUTH" 2>/dev/null || echo "0")
api_keys=0
fi
echo " Profile: $best_profile"
if [ "$expires" -le 0 ] && [ "$api_keys" -gt 0 ]; then
echo -e " Status: ${GREEN}OK${NC} (API key)"
elif [ "$expires" -le 0 ]; then
echo -e " Status: ${RED}NOT FOUND${NC}"
echo " Note: Run 'openclaw doctor --yes' to sync from Claude Code"
else
now_ms=$(( $(date +%s) * 1000 ))
diff_ms=$((expires - now_ms))
hours=$((diff_ms / 3600000))
mins=$(((diff_ms % 3600000) / 60000))
if [ "$diff_ms" -lt 0 ]; then
echo -e " Status: ${RED}EXPIRED${NC}"
echo " Note: Run 'openclaw doctor --yes' to sync from Claude Code"
elif [ "$diff_ms" -lt 3600000 ]; then
echo -e " Status: ${YELLOW}EXPIRING SOON (${mins}m remaining)${NC}"
else
echo -e " Status: ${GREEN}OK${NC}"
echo " Expires: $(date -d @$((expires/1000))) (${hours}h ${mins}m)"
fi
fi
echo ""
echo "=== Service Status ==="
if systemctl --user is-active openclaw >/dev/null 2>&1; then
echo -e "OpenClaw service: ${GREEN}running${NC}"
else
echo -e "OpenClaw service: ${RED}NOT running${NC}"
fi

View file

@ -0,0 +1,355 @@
# ClawDock <!-- omit in toc -->
Stop typing `docker-compose` commands. Just type `clawdock-start`.
Inspired by Simon Willison's [Running OpenClaw in Docker](https://til.simonwillison.net/llms/openclaw-docker).
- [Quickstart](#quickstart)
- [Available Commands](#available-commands)
- [Basic Operations](#basic-operations)
- [Container Access](#container-access)
- [Web UI \& Devices](#web-ui--devices)
- [Setup \& Configuration](#setup--configuration)
- [Maintenance](#maintenance)
- [Utilities](#utilities)
- [Configuration \& Secrets](#configuration--secrets)
- [Docker Files](#docker-files)
- [Config Files](#config-files)
- [Initial Setup](#initial-setup)
- [How It Works in Docker](#how-it-works-in-docker)
- [Env Precedence](#env-precedence)
- [Common Workflows](#common-workflows)
- [Check Status and Logs](#check-status-and-logs)
- [Set Up WhatsApp Bot](#set-up-whatsapp-bot)
- [Troubleshooting Device Pairing](#troubleshooting-device-pairing)
- [Fix Token Mismatch Issues](#fix-token-mismatch-issues)
- [Permission Denied](#permission-denied)
- [Requirements](#requirements)
- [Development](#development)
## Quickstart
**Install:**
```bash
mkdir -p ~/.clawdock && curl -sL https://raw.githubusercontent.com/openclaw/openclaw/main/scripts/clawdock/clawdock-helpers.sh -o ~/.clawdock/clawdock-helpers.sh
```
```bash
echo 'source ~/.clawdock/clawdock-helpers.sh' >> ~/.zshrc && source ~/.zshrc
```
Canonical docs page: https://docs.openclaw.ai/install/clawdock
If you previously installed ClawDock from `scripts/shell-helpers/clawdock-helpers.sh`, rerun the install command above. The old raw GitHub path has been removed.
**See what you get:**
```bash
clawdock-help
```
On first command, ClawDock auto-detects your OpenClaw directory:
- Checks common paths (`~/openclaw`, `~/workspace/openclaw`, etc.)
- If found, asks you to confirm
- Saves to `~/.clawdock/config`
**First time setup:**
```bash
clawdock-start
```
```bash
clawdock-fix-token
```
```bash
clawdock-dashboard
```
If you see "pairing required":
```bash
clawdock-devices
```
And approve the request for the specific device:
```bash
clawdock-approve <request-id>
```
## Available Commands
### Basic Operations
| Command | Description |
| ------------------ | ------------------------------- |
| `clawdock-start` | Start the gateway |
| `clawdock-stop` | Stop the gateway |
| `clawdock-restart` | Restart the gateway |
| `clawdock-status` | Check container status |
| `clawdock-logs` | View live logs (follows output) |
### Container Access
| Command | Description |
| ------------------------- | ---------------------------------------------- |
| `clawdock-shell` | Interactive shell inside the gateway container |
| `clawdock-cli <command>` | Run OpenClaw CLI commands |
| `clawdock-exec <command>` | Execute arbitrary commands in the container |
### Web UI & Devices
| Command | Description |
| ----------------------- | ------------------------------------------ |
| `clawdock-dashboard` | Open web UI in browser with authentication |
| `clawdock-devices` | List device pairing requests |
| `clawdock-approve <id>` | Approve a device pairing request |
### Setup & Configuration
| Command | Description |
| -------------------- | ------------------------------------------------- |
| `clawdock-fix-token` | Configure gateway authentication token (run once) |
### Maintenance
| Command | Description |
| ------------------ | ----------------------------------------------------- |
| `clawdock-update` | Pull latest, rebuild image, and restart (one command) |
| `clawdock-rebuild` | Rebuild the Docker image only |
| `clawdock-clean` | Remove all containers and volumes (destructive!) |
### Utilities
| Command | Description |
| ---------------------- | ----------------------------------------- |
| `clawdock-health` | Run gateway health check |
| `clawdock-token` | Display the gateway authentication token |
| `clawdock-cd` | Jump to the OpenClaw project directory |
| `clawdock-config` | Open the OpenClaw config directory |
| `clawdock-show-config` | Print config files with redacted values |
| `clawdock-workspace` | Open the workspace directory |
| `clawdock-help` | Show all available commands with examples |
## Configuration & Secrets
The Docker setup uses three config files on the host. The container never stores secrets — everything is bind-mounted from local files.
### Docker Files
| File | Purpose |
| -------------------------- | -------------------------------------------------------------------------- |
| `Dockerfile` | Builds the `openclaw:local` image (Node 22, pnpm, non-root `node` user) |
| `docker-compose.yml` | Defines `openclaw-gateway` and `openclaw-cli` services, bind-mounts, ports |
| `docker-setup.sh` | First-time setup — builds image, creates `.env` from `.env.example` |
| `.env.example` | Template for `<project>/.env` with all supported vars and docs |
| `docker-compose.extra.yml` | Optional overrides — auto-loaded by ClawDock helpers if present |
### Config Files
| File | Purpose | Examples |
| --------------------------- | ------------------------------------------------ | ------------------------------------------------------------------- |
| `<project>/.env` | **Docker infra** — image, ports, gateway token | `OPENCLAW_GATEWAY_TOKEN`, `OPENCLAW_IMAGE`, `OPENCLAW_GATEWAY_PORT` |
| `~/.openclaw/.env` | **Secrets** — API keys and bot tokens | `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `TELEGRAM_BOT_TOKEN` |
| `~/.openclaw/openclaw.json` | **Behavior config** — models, channels, policies | Model selection, WhatsApp allowlists, agent settings |
**Do NOT** put API keys or bot tokens in `openclaw.json`. Use `~/.openclaw/.env` for all secrets.
### Initial Setup
`./docker-setup.sh` (in the project root) handles first-time Docker configuration:
- Builds the `openclaw:local` image from `Dockerfile`
- Creates `<project>/.env` from `.env.example` with a generated gateway token
- Sets up `~/.openclaw` directories if they don't exist
```bash
./docker-setup.sh
```
After setup, add your API keys:
```bash
vim ~/.openclaw/.env
```
See `.env.example` for all supported keys.
The `Dockerfile` supports two optional build args:
- `OPENCLAW_DOCKER_APT_PACKAGES` — extra apt packages to install (e.g. `ffmpeg`)
- `OPENCLAW_INSTALL_BROWSER=1` — pre-install Chromium for browser automation (adds ~300MB, but skips the 60-90s Playwright install on each container start)
### How It Works in Docker
`docker-compose.yml` bind-mounts both config and workspace from the host:
```yaml
volumes:
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
- ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
```
This means:
- `~/.openclaw/.env` is available inside the container at `/home/node/.openclaw/.env` — OpenClaw loads it automatically as the global env fallback
- `~/.openclaw/openclaw.json` is available at `/home/node/.openclaw/openclaw.json` — the gateway watches it and hot-reloads most changes
- No need to add API keys to `docker-compose.yml` or configure anything inside the container
- Keys survive `clawdock-update`, `clawdock-rebuild`, and `clawdock-clean` because they live on the host
The project `.env` feeds Docker Compose directly (gateway token, image name, ports). The `~/.openclaw/.env` feeds the OpenClaw process inside the container.
### Example `~/.openclaw/.env`
```bash
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
TELEGRAM_BOT_TOKEN=123456:ABCDEF...
```
### Example `<project>/.env`
```bash
OPENCLAW_CONFIG_DIR=/Users/you/.openclaw
OPENCLAW_WORKSPACE_DIR=/Users/you/.openclaw/workspace
OPENCLAW_GATEWAY_PORT=18789
OPENCLAW_BRIDGE_PORT=18790
OPENCLAW_GATEWAY_BIND=lan
OPENCLAW_GATEWAY_TOKEN=<generated-by-docker-setup>
OPENCLAW_IMAGE=openclaw:local
```
### Env Precedence
OpenClaw loads env vars in this order (highest wins, never overrides existing):
1. **Process environment** — `docker-compose.yml` `environment:` block (gateway token, session keys)
2. **`.env` in CWD** — project root `.env` (Docker infra vars)
3. **`~/.openclaw/.env`** — global secrets (API keys, bot tokens)
4. **`openclaw.json` `env` block** — inline vars, applied only if still missing
5. **Shell env import** — optional login-shell scrape (`OPENCLAW_LOAD_SHELL_ENV=1`)
## Common Workflows
### Update OpenClaw
> **Important:** `openclaw update` does not work inside Docker.
> The container runs as a non-root user with a source-built image, so `npm i -g` fails with EACCES.
> Use `clawdock-update` instead — it pulls, rebuilds, and restarts from the host.
```bash
clawdock-update
```
This runs `git pull` → `docker compose build` → `docker compose down/up` in one step.
If you only want to rebuild without pulling:
```bash
clawdock-rebuild && clawdock-stop && clawdock-start
```
### Check Status and Logs
**Restart the gateway:**
```bash
clawdock-restart
```
**Check container status:**
```bash
clawdock-status
```
**View live logs:**
```bash
clawdock-logs
```
### Set Up WhatsApp Bot
**Shell into the container:**
```bash
clawdock-shell
```
**Inside the container, login to WhatsApp:**
```bash
openclaw channels login --channel whatsapp --verbose
```
Scan the QR code with WhatsApp on your phone.
**Verify connection:**
```bash
openclaw status
```
### Troubleshooting Device Pairing
**Check for pending pairing requests:**
```bash
clawdock-devices
```
**Copy the Request ID from the "Pending" table, then approve:**
```bash
clawdock-approve <request-id>
```
Then refresh your browser.
### Fix Token Mismatch Issues
If you see "gateway token mismatch" errors:
```bash
clawdock-fix-token
```
This will:
1. Read the token from your `.env` file
2. Configure it in the OpenClaw config
3. Restart the gateway
4. Verify the configuration
### Permission Denied
**Ensure Docker is running and you have permission:**
```bash
docker ps
```
## Requirements
- Docker and Docker Compose installed
- Bash or Zsh shell
- OpenClaw project (run `scripts/docker/setup.sh`)
## Development
**Test with fresh config (mimics first-time install):**
```bash
unset CLAWDOCK_DIR && rm -f ~/.clawdock/config && source scripts/clawdock/clawdock-helpers.sh
```
Then run any command to trigger auto-detect:
```bash
clawdock-start
```

View file

@ -0,0 +1,512 @@
#!/usr/bin/env bash
# ClawDock - Docker helpers for OpenClaw
# Inspired by Simon Willison's "Running OpenClaw in Docker"
# https://til.simonwillison.net/llms/openclaw-docker
#
# Installation:
# mkdir -p ~/.clawdock && curl -sL https://raw.githubusercontent.com/openclaw/openclaw/main/scripts/clawdock/clawdock-helpers.sh -o ~/.clawdock/clawdock-helpers.sh
# echo 'source ~/.clawdock/clawdock-helpers.sh' >> ~/.zshrc
#
# Usage:
# clawdock-help # Show all available commands
# =============================================================================
# Colors
# =============================================================================
_CLR_RESET='\033[0m'
_CLR_BOLD='\033[1m'
_CLR_DIM='\033[2m'
_CLR_GREEN='\033[0;32m'
_CLR_YELLOW='\033[1;33m'
_CLR_BLUE='\033[0;34m'
_CLR_MAGENTA='\033[0;35m'
_CLR_CYAN='\033[0;36m'
_CLR_RED='\033[0;31m'
# Styled command output (green + bold)
_clr_cmd() {
echo -e "${_CLR_GREEN}${_CLR_BOLD}$1${_CLR_RESET}"
}
# Inline command for use in sentences
_cmd() {
echo "${_CLR_GREEN}${_CLR_BOLD}$1${_CLR_RESET}"
}
# =============================================================================
# Config
# =============================================================================
CLAWDOCK_CONFIG="${HOME}/.clawdock/config"
# Common paths to check for OpenClaw
CLAWDOCK_COMMON_PATHS=(
"${HOME}/openclaw"
"${HOME}/workspace/openclaw"
"${HOME}/projects/openclaw"
"${HOME}/dev/openclaw"
"${HOME}/code/openclaw"
"${HOME}/src/openclaw"
)
_clawdock_filter_warnings() {
grep -v "^WARN\|^time="
}
_clawdock_trim_quotes() {
local value="$1"
value="${value#\"}"
value="${value%\"}"
printf "%s" "$value"
}
_clawdock_mask_value() {
local value="$1"
local length=${#value}
if (( length == 0 )); then
printf "%s" "<empty>"
return 0
fi
if (( length == 1 )); then
printf "%s" "<redacted:1 char>"
return 0
fi
printf "%s" "<redacted:${length} chars>"
}
_clawdock_read_config_dir() {
if [[ ! -f "$CLAWDOCK_CONFIG" ]]; then
return 1
fi
local raw
raw=$(sed -n 's/^CLAWDOCK_DIR=//p' "$CLAWDOCK_CONFIG" | head -n 1)
if [[ -z "$raw" ]]; then
return 1
fi
_clawdock_trim_quotes "$raw"
}
# Ensure CLAWDOCK_DIR is set and valid
_clawdock_ensure_dir() {
# Already set and valid?
if [[ -n "$CLAWDOCK_DIR" && -f "${CLAWDOCK_DIR}/docker-compose.yml" ]]; then
return 0
fi
# Try loading from config
local config_dir
config_dir=$(_clawdock_read_config_dir)
if [[ -n "$config_dir" && -f "${config_dir}/docker-compose.yml" ]]; then
CLAWDOCK_DIR="$config_dir"
return 0
fi
# Auto-detect from common paths
local found_path=""
for path in "${CLAWDOCK_COMMON_PATHS[@]}"; do
if [[ -f "${path}/docker-compose.yml" ]]; then
found_path="$path"
break
fi
done
if [[ -n "$found_path" ]]; then
echo ""
echo "🦞 Found OpenClaw at: $found_path"
echo -n " Use this location? [Y/n] "
read -r response
if [[ "$response" =~ ^[Nn] ]]; then
echo ""
echo "Set CLAWDOCK_DIR manually:"
echo " export CLAWDOCK_DIR=/path/to/openclaw"
return 1
fi
CLAWDOCK_DIR="$found_path"
else
echo ""
echo "❌ OpenClaw not found in common locations."
echo ""
echo "Clone it first:"
echo ""
echo " git clone https://github.com/openclaw/openclaw.git ~/openclaw"
echo " cd ~/openclaw && ./scripts/docker/setup.sh"
echo ""
echo "Or set CLAWDOCK_DIR if it's elsewhere:"
echo ""
echo " export CLAWDOCK_DIR=/path/to/openclaw"
echo ""
return 1
fi
# Save to config
if [[ ! -d "${HOME}/.clawdock" ]]; then
/bin/mkdir -p "${HOME}/.clawdock"
fi
echo "CLAWDOCK_DIR=\"$CLAWDOCK_DIR\"" > "$CLAWDOCK_CONFIG"
echo "✅ Saved to $CLAWDOCK_CONFIG"
echo ""
return 0
}
# Wrapper to run docker compose commands
_clawdock_compose() {
_clawdock_ensure_dir || return 1
local compose_args=(-f "${CLAWDOCK_DIR}/docker-compose.yml")
if [[ -f "${CLAWDOCK_DIR}/docker-compose.extra.yml" ]]; then
compose_args+=(-f "${CLAWDOCK_DIR}/docker-compose.extra.yml")
fi
command docker compose "${compose_args[@]}" "$@"
}
_clawdock_read_env_token() {
_clawdock_ensure_dir || return 1
if [[ ! -f "${CLAWDOCK_DIR}/.env" ]]; then
return 1
fi
local raw
raw=$(sed -n 's/^OPENCLAW_GATEWAY_TOKEN=//p' "${CLAWDOCK_DIR}/.env" | head -n 1)
if [[ -z "$raw" ]]; then
return 1
fi
_clawdock_trim_quotes "$raw"
}
# Basic Operations
clawdock-start() {
_clawdock_compose up -d openclaw-gateway
}
clawdock-stop() {
_clawdock_compose down
}
clawdock-restart() {
_clawdock_compose restart openclaw-gateway
}
clawdock-logs() {
_clawdock_compose logs -f openclaw-gateway
}
clawdock-status() {
_clawdock_compose ps
}
# Navigation
clawdock-cd() {
_clawdock_ensure_dir || return 1
cd "${CLAWDOCK_DIR}"
}
clawdock-config() {
cd ~/.openclaw
}
clawdock-show-config() {
_clawdock_ensure_dir >/dev/null 2>&1 || true
local config_dir="${HOME}/.openclaw"
echo -e "${_CLR_BOLD}Config directory:${_CLR_RESET} ${_CLR_CYAN}${config_dir}${_CLR_RESET}"
echo ""
# Show openclaw.json
if [[ -f "${config_dir}/openclaw.json" ]]; then
echo -e "${_CLR_BOLD}${config_dir}/openclaw.json${_CLR_RESET}"
echo -e "${_CLR_DIM}$(cat "${config_dir}/openclaw.json")${_CLR_RESET}"
else
echo -e "${_CLR_YELLOW}No openclaw.json found${_CLR_RESET}"
fi
echo ""
# Show .env (mask secret values)
if [[ -f "${config_dir}/.env" ]]; then
echo -e "${_CLR_BOLD}${config_dir}/.env${_CLR_RESET}"
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" =~ ^[[:space:]]*# ]] || [[ -z "$line" ]]; then
echo -e "${_CLR_DIM}${line}${_CLR_RESET}"
elif [[ "$line" == *=* ]]; then
local key="${line%%=*}"
local val="${line#*=}"
echo -e "${_CLR_CYAN}${key}${_CLR_RESET}=${_CLR_DIM}$(_clawdock_mask_value "$val")${_CLR_RESET}"
else
echo -e "${_CLR_DIM}${line}${_CLR_RESET}"
fi
done < "${config_dir}/.env"
else
echo -e "${_CLR_YELLOW}No .env found${_CLR_RESET}"
fi
echo ""
# Show project .env if available
if [[ -n "$CLAWDOCK_DIR" && -f "${CLAWDOCK_DIR}/.env" ]]; then
echo -e "${_CLR_BOLD}${CLAWDOCK_DIR}/.env${_CLR_RESET}"
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" =~ ^[[:space:]]*# ]] || [[ -z "$line" ]]; then
echo -e "${_CLR_DIM}${line}${_CLR_RESET}"
elif [[ "$line" == *=* ]]; then
local key="${line%%=*}"
local val="${line#*=}"
echo -e "${_CLR_CYAN}${key}${_CLR_RESET}=${_CLR_DIM}$(_clawdock_mask_value "$val")${_CLR_RESET}"
else
echo -e "${_CLR_DIM}${line}${_CLR_RESET}"
fi
done < "${CLAWDOCK_DIR}/.env"
fi
echo ""
}
clawdock-workspace() {
cd ~/.openclaw/workspace
}
# Container Access
clawdock-shell() {
_clawdock_compose exec openclaw-gateway \
bash -c 'echo "alias openclaw=\"./openclaw.mjs\"" > /tmp/.bashrc_openclaw && bash --rcfile /tmp/.bashrc_openclaw'
}
clawdock-exec() {
_clawdock_compose exec openclaw-gateway "$@"
}
clawdock-cli() {
_clawdock_compose run --rm openclaw-cli "$@"
}
# Maintenance
clawdock-update() {
_clawdock_ensure_dir || return 1
echo "🔄 Updating OpenClaw..."
echo ""
echo "📥 Pulling latest source..."
git -C "${CLAWDOCK_DIR}" pull || { echo "❌ git pull failed"; return 1; }
echo ""
echo "🔨 Rebuilding Docker image (this may take a few minutes)..."
_clawdock_compose build openclaw-gateway || { echo "❌ Build failed"; return 1; }
echo ""
echo "♻️ Recreating container with new image..."
_clawdock_compose down 2>&1 | _clawdock_filter_warnings
_clawdock_compose up -d openclaw-gateway 2>&1 | _clawdock_filter_warnings
echo ""
echo "⏳ Waiting for gateway to start..."
sleep 5
echo "✅ Update complete!"
echo -e " Verify: $(_cmd clawdock-cli status)"
}
clawdock-rebuild() {
_clawdock_compose build openclaw-gateway
}
clawdock-clean() {
_clawdock_compose down -v --remove-orphans
}
# Health check
clawdock-health() {
_clawdock_ensure_dir || return 1
local token
token=$(_clawdock_read_env_token)
if [[ -z "$token" ]]; then
echo "❌ Error: Could not find gateway token"
echo " Check: ${CLAWDOCK_DIR}/.env"
return 1
fi
_clawdock_compose exec -e "OPENCLAW_GATEWAY_TOKEN=$token" openclaw-gateway \
node dist/index.js health
}
# Show gateway token
clawdock-token() {
_clawdock_read_env_token
}
# Fix token configuration (run this once after setup)
clawdock-fix-token() {
_clawdock_ensure_dir || return 1
echo "🔧 Configuring gateway token..."
local token
token=$(clawdock-token)
if [[ -z "$token" ]]; then
echo "❌ Error: Could not find gateway token"
echo " Check: ${CLAWDOCK_DIR}/.env"
return 1
fi
echo "📝 Setting token: ${token:0:20}..."
_clawdock_compose exec -e "TOKEN=$token" openclaw-gateway \
bash -c './openclaw.mjs config set gateway.remote.token "$TOKEN" && ./openclaw.mjs config set gateway.auth.token "$TOKEN"' 2>&1 | _clawdock_filter_warnings
echo "🔍 Verifying token was saved..."
local saved_token
saved_token=$(_clawdock_compose exec openclaw-gateway \
bash -c "./openclaw.mjs config get gateway.remote.token 2>/dev/null" 2>&1 | _clawdock_filter_warnings | tr -d '\r\n' | head -c 64)
if [[ "$saved_token" == "$token" ]]; then
echo "✅ Token saved correctly!"
else
echo "⚠️ Token mismatch detected"
echo " Expected: ${token:0:20}..."
echo " Got: ${saved_token:0:20}..."
fi
echo "🔄 Restarting gateway..."
_clawdock_compose restart openclaw-gateway 2>&1 | _clawdock_filter_warnings
echo "⏳ Waiting for gateway to start..."
sleep 5
echo "✅ Configuration complete!"
echo -e " Try: $(_cmd clawdock-devices)"
}
# Open dashboard in browser
clawdock-dashboard() {
_clawdock_ensure_dir || return 1
echo "🦞 Getting dashboard URL..."
local output exit_status url
output=$(_clawdock_compose run --rm openclaw-cli dashboard --no-open 2>&1)
exit_status=$?
url=$(printf "%s\n" "$output" | _clawdock_filter_warnings | grep -o 'http[s]\?://[^[:space:]]*' | head -n 1)
if [[ $exit_status -ne 0 ]]; then
echo "❌ Failed to get dashboard URL"
echo -e " Try restarting: $(_cmd clawdock-restart)"
return 1
fi
if [[ -n "$url" ]]; then
echo -e "✅ Opening: ${_CLR_CYAN}${url}${_CLR_RESET}"
open "$url" 2>/dev/null || xdg-open "$url" 2>/dev/null || echo -e " Please open manually: ${_CLR_CYAN}${url}${_CLR_RESET}"
echo ""
echo -e "${_CLR_CYAN}💡 If you see ${_CLR_RED}'pairing required'${_CLR_CYAN} error:${_CLR_RESET}"
echo -e " 1. Run: $(_cmd clawdock-devices)"
echo " 2. Copy the Request ID from the Pending table"
echo -e " 3. Run: $(_cmd 'clawdock-approve <request-id>')"
else
echo "❌ Failed to get dashboard URL"
echo -e " Try restarting: $(_cmd clawdock-restart)"
fi
}
# List device pairings
clawdock-devices() {
_clawdock_ensure_dir || return 1
echo "🔍 Checking device pairings..."
local output exit_status
output=$(_clawdock_compose exec openclaw-gateway node dist/index.js devices list 2>&1)
exit_status=$?
printf "%s\n" "$output" | _clawdock_filter_warnings
if [ $exit_status -ne 0 ]; then
echo ""
echo -e "${_CLR_CYAN}💡 If you see token errors above:${_CLR_RESET}"
echo -e " 1. Verify token is set: $(_cmd clawdock-token)"
echo -e " 2. Try fixing the token automatically: $(_cmd clawdock-fix-token)"
echo " 3. If you still see errors, try manual config inside container:"
echo -e " $(_cmd clawdock-shell)"
echo -e " $(_cmd 'openclaw config get gateway.remote.token')"
return 1
fi
echo ""
echo -e "${_CLR_CYAN}💡 To approve a pairing request:${_CLR_RESET}"
echo -e " $(_cmd 'clawdock-approve <request-id>')"
}
# Approve device pairing request
clawdock-approve() {
_clawdock_ensure_dir || return 1
if [[ -z "$1" ]]; then
echo -e "❌ Usage: $(_cmd 'clawdock-approve <request-id>')"
echo ""
echo -e "${_CLR_CYAN}💡 How to approve a device:${_CLR_RESET}"
echo -e " 1. Run: $(_cmd clawdock-devices)"
echo " 2. Find the Request ID in the Pending table (long UUID)"
echo -e " 3. Run: $(_cmd 'clawdock-approve <that-request-id>')"
echo ""
echo "Example:"
echo -e " $(_cmd 'clawdock-approve 6f9db1bd-a1cc-4d3f-b643-2c195262464e')"
return 1
fi
echo "✅ Approving device: $1"
_clawdock_compose exec openclaw-gateway \
node dist/index.js devices approve "$1" 2>&1 | _clawdock_filter_warnings
echo ""
echo "✅ Device approved! Refresh your browser."
}
# Show all available clawdock helper commands
clawdock-help() {
echo -e "\n${_CLR_BOLD}${_CLR_CYAN}🦞 ClawDock - Docker Helpers for OpenClaw${_CLR_RESET}\n"
echo -e "${_CLR_BOLD}${_CLR_MAGENTA}⚡ Basic Operations${_CLR_RESET}"
echo -e " $(_cmd clawdock-start) ${_CLR_DIM}Start the gateway${_CLR_RESET}"
echo -e " $(_cmd clawdock-stop) ${_CLR_DIM}Stop the gateway${_CLR_RESET}"
echo -e " $(_cmd clawdock-restart) ${_CLR_DIM}Restart the gateway${_CLR_RESET}"
echo -e " $(_cmd clawdock-status) ${_CLR_DIM}Check container status${_CLR_RESET}"
echo -e " $(_cmd clawdock-logs) ${_CLR_DIM}View live logs (follows)${_CLR_RESET}"
echo ""
echo -e "${_CLR_BOLD}${_CLR_MAGENTA}🐚 Container Access${_CLR_RESET}"
echo -e " $(_cmd clawdock-shell) ${_CLR_DIM}Shell into container (openclaw alias ready)${_CLR_RESET}"
echo -e " $(_cmd clawdock-cli) ${_CLR_DIM}Run CLI commands (e.g., clawdock-cli status)${_CLR_RESET}"
echo -e " $(_cmd clawdock-exec) ${_CLR_CYAN}<cmd>${_CLR_RESET} ${_CLR_DIM}Execute command in gateway container${_CLR_RESET}"
echo ""
echo -e "${_CLR_BOLD}${_CLR_MAGENTA}🌐 Web UI & Devices${_CLR_RESET}"
echo -e " $(_cmd clawdock-dashboard) ${_CLR_DIM}Open web UI in browser ${_CLR_CYAN}(auto-guides you)${_CLR_RESET}"
echo -e " $(_cmd clawdock-devices) ${_CLR_DIM}List device pairings ${_CLR_CYAN}(auto-guides you)${_CLR_RESET}"
echo -e " $(_cmd clawdock-approve) ${_CLR_CYAN}<id>${_CLR_RESET} ${_CLR_DIM}Approve device pairing ${_CLR_CYAN}(with examples)${_CLR_RESET}"
echo ""
echo -e "${_CLR_BOLD}${_CLR_MAGENTA}⚙️ Setup & Configuration${_CLR_RESET}"
echo -e " $(_cmd clawdock-fix-token) ${_CLR_DIM}Configure gateway token ${_CLR_CYAN}(run once)${_CLR_RESET}"
echo ""
echo -e "${_CLR_BOLD}${_CLR_MAGENTA}🔧 Maintenance${_CLR_RESET}"
echo -e " $(_cmd clawdock-update) ${_CLR_DIM}Pull, rebuild, and restart ${_CLR_CYAN}(one-command update)${_CLR_RESET}"
echo -e " $(_cmd clawdock-rebuild) ${_CLR_DIM}Rebuild Docker image only${_CLR_RESET}"
echo -e " $(_cmd clawdock-clean) ${_CLR_RED}⚠️ Remove containers & volumes (nuclear)${_CLR_RESET}"
echo ""
echo -e "${_CLR_BOLD}${_CLR_MAGENTA}🛠️ Utilities${_CLR_RESET}"
echo -e " $(_cmd clawdock-health) ${_CLR_DIM}Run health check${_CLR_RESET}"
echo -e " $(_cmd clawdock-token) ${_CLR_DIM}Show gateway auth token${_CLR_RESET}"
echo -e " $(_cmd clawdock-cd) ${_CLR_DIM}Jump to openclaw project directory${_CLR_RESET}"
echo -e " $(_cmd clawdock-config) ${_CLR_DIM}Open config directory (~/.openclaw)${_CLR_RESET}"
echo -e " $(_cmd clawdock-show-config) ${_CLR_DIM}Print config files with redacted values${_CLR_RESET}"
echo -e " $(_cmd clawdock-workspace) ${_CLR_DIM}Open workspace directory${_CLR_RESET}"
echo ""
echo -e "${_CLR_BOLD}${_CLR_CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${_CLR_RESET}"
echo -e "${_CLR_BOLD}${_CLR_GREEN}🚀 First Time Setup${_CLR_RESET}"
echo -e "${_CLR_CYAN} 1.${_CLR_RESET} $(_cmd clawdock-start) ${_CLR_DIM}# Start the gateway${_CLR_RESET}"
echo -e "${_CLR_CYAN} 2.${_CLR_RESET} $(_cmd clawdock-fix-token) ${_CLR_DIM}# Configure token${_CLR_RESET}"
echo -e "${_CLR_CYAN} 3.${_CLR_RESET} $(_cmd clawdock-dashboard) ${_CLR_DIM}# Open web UI${_CLR_RESET}"
echo -e "${_CLR_CYAN} 4.${_CLR_RESET} $(_cmd clawdock-devices) ${_CLR_DIM}# If pairing needed${_CLR_RESET}"
echo -e "${_CLR_CYAN} 5.${_CLR_RESET} $(_cmd clawdock-approve) ${_CLR_CYAN}<id>${_CLR_RESET} ${_CLR_DIM}# Approve pairing${_CLR_RESET}"
echo ""
echo -e "${_CLR_BOLD}${_CLR_GREEN}💬 WhatsApp Setup${_CLR_RESET}"
echo -e " $(_cmd clawdock-shell)"
echo -e " ${_CLR_BLUE}>${_CLR_RESET} $(_cmd 'openclaw channels login --channel whatsapp')"
echo -e " ${_CLR_BLUE}>${_CLR_RESET} $(_cmd 'openclaw status')"
echo ""
echo -e "${_CLR_BOLD}${_CLR_CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${_CLR_RESET}"
echo ""
echo -e "${_CLR_CYAN}💡 All commands guide you through next steps!${_CLR_RESET}"
echo -e "${_CLR_BLUE}📚 Docs: ${_CLR_RESET}${_CLR_CYAN}https://docs.openclaw.ai${_CLR_RESET}"
echo ""
}

321
openclaw/scripts/clawlog.sh Normal file
View file

@ -0,0 +1,321 @@
#!/bin/bash
# VibeTunnel Logging Utility
# Simplifies access to VibeTunnel logs using macOS unified logging system
set -euo pipefail
# Configuration
SUBSYSTEM="ai.openclaw"
DEFAULT_LEVEL="info"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to handle sudo password errors
handle_sudo_error() {
echo -e "\n${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${YELLOW}⚠️ Password Required for Log Access${NC}"
echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
echo -e "clawlog needs to use sudo to show complete log data (Apple hides sensitive info by default)."
echo -e "\nTo avoid password prompts, configure passwordless sudo for the log command:"
echo -e "See: ${BLUE}apple/docs/logging-private-fix.md${NC}\n"
echo -e "Quick fix:"
echo -e " 1. Run: ${GREEN}sudo visudo${NC}"
echo -e " 2. Add: ${GREEN}$(whoami) ALL=(ALL) NOPASSWD: /usr/bin/log${NC}"
echo -e " 3. Save and exit (:wq)\n"
echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
exit 1
}
# Default values
STREAM_MODE=false
TIME_RANGE="5m" # Default to last 5 minutes
CATEGORY=""
LOG_LEVEL="$DEFAULT_LEVEL"
SEARCH_TEXT=""
OUTPUT_FILE=""
ERRORS_ONLY=false
SERVER_ONLY=false
TAIL_LINES=50 # Default number of lines to show
SHOW_TAIL=true
SHOW_HELP=false
STYLE_JSON=false
# Function to show usage
show_usage() {
cat << EOF
clawlog - OpenClaw Logging Utility
USAGE:
clawlog [OPTIONS]
DESCRIPTION:
View OpenClaw logs with full details (bypasses Apple's privacy redaction).
Requires sudo access configured for /usr/bin/log command.
LOG FLOW ARCHITECTURE:
OpenClaw logs flow through the macOS unified log (subsystem: ai.openclaw).
LOG CATEGORIES (examples):
• voicewake - Voice wake detection/test harness
• gateway - Gateway process manager
• xpc - XPC service calls
• notifications - Notification helper
• screenshot - Screenshotter
• shell - ShellExecutor
QUICK START:
clawlog -n 100 Show last 100 lines from all components
clawlog -f Follow logs in real-time
clawlog -e Show only errors
clawlog -c ServerManager Show logs from ServerManager only
OPTIONS:
-h, --help Show this help message
-f, --follow Stream logs continuously (like tail -f)
-n, --lines NUM Number of lines to show (default: 50)
-l, --last TIME Time range to search (default: 5m)
Examples: 5m, 1h, 2d, 1w
-c, --category CAT Filter by category (e.g., ServerManager, SessionService)
-e, --errors Show only error messages
-d, --debug Show debug level logs (more verbose)
-s, --search TEXT Search for specific text in log messages
-o, --output FILE Export logs to file
--server Show only server output logs
--all Show all logs without tail limit
--list-categories List all available log categories
--json Output in JSON format
EXAMPLES:
clawlog Show last 50 lines from past 5 minutes (default)
clawlog -f Stream logs continuously
clawlog -n 100 Show last 100 lines
clawlog -e Show only recent errors
clawlog -l 30m -n 200 Show last 200 lines from past 30 minutes
clawlog -c ServerManager Show recent ServerManager logs
clawlog -s "fail" Search for "fail" in recent logs
clawlog --server -e Show recent server errors
clawlog -f -d Stream debug logs continuously
CATEGORIES:
Common categories include:
- ServerManager - Server lifecycle and configuration
- SessionService - Terminal session management
- TerminalManager - Terminal spawning and control
- GitRepository - Git integration features
- ScreencapService - Screen capture functionality
- WebRTCManager - WebRTC connections
- UnixSocket - Unix socket communication
- WindowTracker - Window tracking and focus
- NgrokService - Ngrok tunnel management
- ServerOutput - Node.js server output
TIME FORMATS:
- 5m = 5 minutes - 1h = 1 hour
- 2d = 2 days - 1w = 1 week
EOF
}
# Function to list categories
list_categories() {
echo -e "${BLUE}Fetching VibeTunnel log categories from the last hour...${NC}\n"
# Get unique categories from recent logs
log show --predicate "subsystem == \"$SUBSYSTEM\"" --last 1h 2>/dev/null | \
grep -E "category: \"[^\"]+\"" | \
sed -E 's/.*category: "([^"]+)".*/\1/' | \
sort | uniq | \
while read -r cat; do
echo " • $cat"
done
echo -e "\n${YELLOW}Note: Only categories with recent activity are shown${NC}"
}
# Escape user input embedded in macOS log predicate string literals.
escape_predicate_literal() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
printf '%s' "$value"
}
# Show help if no arguments provided
if [[ $# -eq 0 ]]; then
show_usage
exit 0
fi
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help)
show_usage
exit 0
;;
-f|--follow)
STREAM_MODE=true
SHOW_TAIL=false
shift
;;
-n|--lines)
TAIL_LINES="$2"
shift 2
;;
-l|--last)
TIME_RANGE="$2"
shift 2
;;
-c|--category)
CATEGORY="$2"
shift 2
;;
-e|--errors)
ERRORS_ONLY=true
shift
;;
-d|--debug)
LOG_LEVEL="debug"
shift
;;
-s|--search)
SEARCH_TEXT="$2"
shift 2
;;
-o|--output)
OUTPUT_FILE="$2"
shift 2
;;
--server)
SERVER_ONLY=true
CATEGORY="ServerOutput"
shift
;;
--list-categories)
list_categories
exit 0
;;
--json)
STYLE_JSON=true
shift
;;
--all)
SHOW_TAIL=false
shift
;;
*)
echo -e "${RED}Unknown option: $1${NC}"
echo "Use -h or --help for usage information"
exit 1
;;
esac
done
# Build the predicate
PREDICATE="subsystem == \"$SUBSYSTEM\""
# Add category filter if specified
if [[ -n "$CATEGORY" ]]; then
ESCAPED_CATEGORY=$(escape_predicate_literal "$CATEGORY")
PREDICATE="$PREDICATE AND category == \"$ESCAPED_CATEGORY\""
fi
# Add error filter if specified
if [[ "$ERRORS_ONLY" == true ]]; then
PREDICATE="$PREDICATE AND (eventType == \"error\" OR messageType == \"error\" OR eventMessage CONTAINS \"ERROR\" OR eventMessage CONTAINS \"[31m\")"
fi
# Add search filter if specified
if [[ -n "$SEARCH_TEXT" ]]; then
ESCAPED_SEARCH_TEXT=$(escape_predicate_literal "$SEARCH_TEXT")
PREDICATE="$PREDICATE AND eventMessage CONTAINS[c] \"$ESCAPED_SEARCH_TEXT\""
fi
# Build the command as argv array to avoid shell eval injection
LOG_CMD=(sudo log)
if [[ "$STREAM_MODE" == true ]]; then
# Streaming mode
LOG_CMD+=(stream --predicate "$PREDICATE" --level "$LOG_LEVEL" --info)
echo -e "${GREEN}Streaming VibeTunnel logs continuously...${NC}"
echo -e "${YELLOW}Press Ctrl+C to stop${NC}\n"
else
# Show mode
LOG_CMD+=(show --predicate "$PREDICATE")
# Add log level for show command
if [[ "$LOG_LEVEL" == "debug" ]]; then
LOG_CMD+=(--debug)
else
LOG_CMD+=(--info)
fi
# Add time range
LOG_CMD+=(--last "$TIME_RANGE")
if [[ "$SHOW_TAIL" == true ]]; then
echo -e "${GREEN}Showing last $TAIL_LINES log lines from the past $TIME_RANGE${NC}"
else
echo -e "${GREEN}Showing all logs from the past $TIME_RANGE${NC}"
fi
# Show applied filters
if [[ "$ERRORS_ONLY" == true ]]; then
echo -e "${RED}Filter: Errors only${NC}"
fi
if [[ -n "$CATEGORY" ]]; then
echo -e "${BLUE}Category: $CATEGORY${NC}"
fi
if [[ -n "$SEARCH_TEXT" ]]; then
echo -e "${YELLOW}Search: \"$SEARCH_TEXT\"${NC}"
fi
echo "" # Empty line for readability
fi
# Add style arguments if specified
if [[ "$STYLE_JSON" == true ]]; then
LOG_CMD+=(--style json)
fi
# Execute the command
if [[ -n "$OUTPUT_FILE" ]]; then
# First check if sudo works without password for the log command
if sudo -n /usr/bin/log show --last 1s 2>&1 | grep -q "password"; then
handle_sudo_error
fi
echo -e "${BLUE}Exporting logs to: $OUTPUT_FILE${NC}\n"
if [[ "$SHOW_TAIL" == true ]] && [[ "$STREAM_MODE" == false ]]; then
"${LOG_CMD[@]}" 2>&1 | tail -n "$TAIL_LINES" > "$OUTPUT_FILE"
else
"${LOG_CMD[@]}" > "$OUTPUT_FILE" 2>&1
fi
# Check if file was created and has content
if [[ -s "$OUTPUT_FILE" ]]; then
LINE_COUNT=$(wc -l < "$OUTPUT_FILE" | tr -d ' ')
echo -e "${GREEN}✓ Exported $LINE_COUNT lines to $OUTPUT_FILE${NC}"
else
echo -e "${YELLOW}⚠ No logs found matching the criteria${NC}"
fi
else
# Run interactively
# First check if sudo works without password for the log command
if sudo -n /usr/bin/log show --last 1s 2>&1 | grep -q "password"; then
handle_sudo_error
fi
if [[ "$SHOW_TAIL" == true ]] && [[ "$STREAM_MODE" == false ]]; then
# Apply tail for non-streaming mode
"${LOG_CMD[@]}" 2>&1 | tail -n "$TAIL_LINES"
echo -e "\n${YELLOW}Showing last $TAIL_LINES lines. Use --all or -n to see more.${NC}"
else
"${LOG_CMD[@]}"
fi
fi

View file

@ -0,0 +1,39 @@
{
"ensureLogins": [
"odrobnik",
"alphonse-arianee",
"aaronn",
"ronak-guliani",
"cpojer",
"carlulsoe",
"jdrhyne",
"latitudeki5223",
"longmaba",
"manmal",
"thesash",
"rhjoh",
"ysqander",
"atalovesyou",
"0xJonHoldsCrypto",
"hougangdev",
"jiulingyun"
],
"seedCommit": "d6863f87",
"displayName": {
"jdrhyne": "Jonathan D. Rhyne (DJ-D)"
},
"nameToLogin": {
"peter steinberger": "steipete",
"eng. juan combetto": "omniwired",
"mariano belinky": "mbelinky",
"vasanth rao naik sabavat": "vsabavat",
"tu nombre real": "nachx639",
"django navarro": "djangonavarro220"
},
"emailToLogin": {
"steipete@gmail.com": "steipete",
"sbarrios93@gmail.com": "sebslight",
"rltorres26+github@gmail.com": "RandyVentures",
"hixvac@gmail.com": "VACInc"
}
}

View file

@ -0,0 +1,289 @@
#!/usr/bin/env bash
set -euo pipefail
APP_BUNDLE="${1:-dist/OpenClaw.app}"
IDENTITY="${SIGN_IDENTITY:-}"
TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}"
DISABLE_LIBRARY_VALIDATION="${DISABLE_LIBRARY_VALIDATION:-0}"
SKIP_TEAM_ID_CHECK="${SKIP_TEAM_ID_CHECK:-0}"
ENT_TMP_BASE=$(mktemp -t openclaw-entitlements-base.XXXXXX)
ENT_TMP_APP_BASE=$(mktemp -t openclaw-entitlements-app-base.XXXXXX)
ENT_TMP_RUNTIME=$(mktemp -t openclaw-entitlements-runtime.XXXXXX)
if [[ "${APP_BUNDLE}" == "--help" || "${APP_BUNDLE}" == "-h" ]]; then
cat <<'HELP'
Usage: scripts/codesign-mac-app.sh [app-bundle]
Env:
SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"
ALLOW_ADHOC_SIGNING=1
CODESIGN_TIMESTAMP=auto|on|off
DISABLE_LIBRARY_VALIDATION=1 # dev-only Sparkle Team ID workaround
SKIP_TEAM_ID_CHECK=1 # bypass Team ID audit
HELP
exit 0
fi
if [ ! -d "$APP_BUNDLE" ]; then
echo "App bundle not found: $APP_BUNDLE" >&2
exit 1
fi
select_identity() {
local preferred available first
# Prefer a Developer ID Application cert.
preferred="$(security find-identity -p codesigning -v 2>/dev/null \
| awk -F'\"' '/Developer ID Application/ { print $2; exit }')"
if [ -n "$preferred" ]; then
echo "$preferred"
return
fi
# Next, try Apple Distribution.
preferred="$(security find-identity -p codesigning -v 2>/dev/null \
| awk -F'\"' '/Apple Distribution/ { print $2; exit }')"
if [ -n "$preferred" ]; then
echo "$preferred"
return
fi
# Then, try Apple Development.
preferred="$(security find-identity -p codesigning -v 2>/dev/null \
| awk -F'\"' '/Apple Development/ { print $2; exit }')"
if [ -n "$preferred" ]; then
echo "$preferred"
return
fi
# Fallback to the first valid signing identity.
available="$(security find-identity -p codesigning -v 2>/dev/null \
| sed -n 's/.*\"\\(.*\\)\"/\\1/p')"
if [ -n "$available" ]; then
first="$(printf '%s\n' "$available" | head -n1)"
echo "$first"
return
fi
return 1
}
if [ -z "$IDENTITY" ]; then
if ! IDENTITY="$(select_identity)"; then
if [[ "${ALLOW_ADHOC_SIGNING:-}" == "1" ]]; then
echo "WARN: No signing identity found. Falling back to ad-hoc signing (-)." >&2
echo " !!! WARNING: Ad-hoc signed apps do NOT persist TCC permissions (Accessibility, etc) !!!" >&2
echo " !!! You will need to re-grant permissions every time you restart the app. !!!" >&2
IDENTITY="-"
else
echo "ERROR: No signing identity found. Set SIGN_IDENTITY to a valid codesigning certificate." >&2
echo " Alternatively, set ALLOW_ADHOC_SIGNING=1 to fallback to ad-hoc signing (limitations apply)." >&2
exit 1
fi
fi
fi
echo "Using signing identity: $IDENTITY"
if [[ "$IDENTITY" == "-" ]]; then
cat <<'WARN' >&2
================================================================================
!!! AD-HOC SIGNING IN USE - PERMISSIONS WILL NOT STICK (macOS RESTRICTION) !!!
macOS ties permissions to the code signature, bundle ID, and app path.
Ad-hoc signing generates a new signature every build, so macOS treats the app
as a different binary and will forget permissions (prompts may vanish).
For correct permission behavior you MUST sign with a real Apple Development or
Developer ID certificate.
If prompts disappear: remove the app entry in System Settings -> Privacy & Security,
relaunch the app, and re-grant. Some permissions only reappear after a full
macOS restart.
================================================================================
WARN
fi
timestamp_arg="--timestamp=none"
case "$TIMESTAMP_MODE" in
1|on|yes|true)
timestamp_arg="--timestamp"
;;
0|off|no|false)
timestamp_arg="--timestamp=none"
;;
auto)
if [[ "$IDENTITY" == *"Developer ID Application"* ]]; then
timestamp_arg="--timestamp"
fi
;;
*)
echo "ERROR: Unknown CODESIGN_TIMESTAMP value: $TIMESTAMP_MODE (use auto|on|off)" >&2
exit 1
;;
esac
if [[ "$IDENTITY" == "-" ]]; then
timestamp_arg="--timestamp=none"
fi
options_args=()
if [[ "$IDENTITY" != "-" ]]; then
options_args=("--options" "runtime")
fi
timestamp_args=("$timestamp_arg")
cat > "$ENT_TMP_BASE" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>
PLIST
cat > "$ENT_TMP_APP_BASE" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
</dict>
</plist>
PLIST
cat > "$ENT_TMP_RUNTIME" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
</dict>
</plist>
PLIST
if [[ "$DISABLE_LIBRARY_VALIDATION" == "1" ]]; then
/usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP_BASE" >/dev/null 2>&1 || \
/usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP_BASE"
echo "Note: disable-library-validation entitlement enabled (DISABLE_LIBRARY_VALIDATION=1)."
fi
APP_ENTITLEMENTS="$ENT_TMP_APP_BASE"
# clear extended attributes to avoid stale signatures
xattr -cr "$APP_BUNDLE" 2>/dev/null || true
sign_item() {
local target="$1"
local entitlements="$2"
codesign --force ${options_args+"${options_args[@]}"} "${timestamp_args[@]}" --entitlements "$entitlements" --sign "$IDENTITY" "$target"
}
sign_plain_item() {
local target="$1"
codesign --force ${options_args+"${options_args[@]}"} "${timestamp_args[@]}" --sign "$IDENTITY" "$target"
}
team_id_for() {
codesign -dv --verbose=4 "$1" 2>&1 | awk -F= '/^TeamIdentifier=/{print $2; exit}'
}
verify_team_ids() {
if [[ "$SKIP_TEAM_ID_CHECK" == "1" ]]; then
echo "Note: skipping Team ID audit (SKIP_TEAM_ID_CHECK=1)."
return 0
fi
local expected
expected="$(team_id_for "$APP_BUNDLE" || true)"
if [[ -z "$expected" ]]; then
echo "WARN: TeamIdentifier missing on app bundle; skipping Team ID audit."
return 0
fi
local mismatches=()
while IFS= read -r -d '' f; do
if /usr/bin/file "$f" | /usr/bin/grep -q "Mach-O"; then
local team
team="$(team_id_for "$f" || true)"
if [[ -z "$team" ]]; then
team="not set"
fi
if [[ "$expected" == "not set" ]]; then
if [[ "$team" != "not set" ]]; then
mismatches+=("$f (TeamIdentifier=$team)")
fi
elif [[ "$team" != "$expected" ]]; then
mismatches+=("$f (TeamIdentifier=$team)")
fi
fi
done < <(find "$APP_BUNDLE" -type f -print0)
if [[ "${#mismatches[@]}" -gt 0 ]]; then
echo "ERROR: Team ID mismatch detected (expected: $expected)"
for entry in "${mismatches[@]}"; do
echo " - $entry"
done
echo "Hint: re-sign embedded frameworks or set DISABLE_LIBRARY_VALIDATION=1 for dev builds."
exit 1
fi
}
# Sign main binary
if [ -f "$APP_BUNDLE/Contents/MacOS/OpenClaw" ]; then
echo "Signing main binary"; sign_item "$APP_BUNDLE/Contents/MacOS/OpenClaw" "$APP_ENTITLEMENTS"
fi
# Sign Sparkle deeply if present
SPARKLE="$APP_BUNDLE/Contents/Frameworks/Sparkle.framework"
if [ -d "$SPARKLE" ]; then
echo "Signing Sparkle framework and helpers"
find "$SPARKLE" -type f -print0 | while IFS= read -r -d '' f; do
if /usr/bin/file "$f" | /usr/bin/grep -q "Mach-O"; then
sign_plain_item "$f"
fi
done
sign_plain_item "$SPARKLE/Versions/B/Sparkle"
sign_plain_item "$SPARKLE/Versions/B/Autoupdate"
sign_plain_item "$SPARKLE/Versions/B/Updater.app/Contents/MacOS/Updater"
sign_plain_item "$SPARKLE/Versions/B/Updater.app"
sign_plain_item "$SPARKLE/Versions/B/XPCServices/Downloader.xpc/Contents/MacOS/Downloader"
sign_plain_item "$SPARKLE/Versions/B/XPCServices/Downloader.xpc"
sign_plain_item "$SPARKLE/Versions/B/XPCServices/Installer.xpc/Contents/MacOS/Installer"
sign_plain_item "$SPARKLE/Versions/B/XPCServices/Installer.xpc"
sign_plain_item "$SPARKLE/Versions/B"
sign_plain_item "$SPARKLE"
fi
# Sign any other embedded frameworks/dylibs
if [ -d "$APP_BUNDLE/Contents/Frameworks" ]; then
find "$APP_BUNDLE/Contents/Frameworks" \( -name "*.framework" -o -name "*.dylib" \) ! -path "*Sparkle.framework*" -print0 | while IFS= read -r -d '' f; do
echo "Signing framework: $f"; sign_plain_item "$f"
done
fi
# Finally sign the bundle
sign_item "$APP_BUNDLE" "$APP_ENTITLEMENTS"
verify_team_ids
rm -f "$ENT_TMP_BASE" "$ENT_TMP_APP_BASE" "$ENT_TMP_RUNTIME"
echo "Codesign complete for $APP_BUNDLE"

View file

@ -0,0 +1,3 @@
messagesNcontentXtooluseinput->messages.content.tool_use.input
groupsthreads->groups/threads
startstoprestart->start/stop/restart

View file

@ -0,0 +1,9 @@
iTerm
FO
Nam
Lins
Vai
OptionA
CAF
overlayed
re-use

232
openclaw/scripts/committer Normal file
View file

@ -0,0 +1,232 @@
#!/usr/bin/env bash
set -euo pipefail
# Disable glob expansion to handle brackets in file paths
set -f
usage() {
local exit_code=${1:-2}
if [ "$exit_code" -eq 0 ]; then
printf 'Usage: %s [--force] [--fast] "commit message" "file" ["file" ...]\n' "$(basename "$0")"
else
printf 'Usage: %s [--force] [--fast] "commit message" "file" ["file" ...]\n' "$(basename "$0")" >&2
fi
exit "$exit_code"
}
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
usage 0
fi
if [ "$#" -lt 2 ]; then
usage
fi
force_delete_lock=false
fast_commit=false
while [[ "${1:-}" == --* ]]; do
case "${1:-}" in
--force)
force_delete_lock=true
shift
;;
--fast)
fast_commit=true
shift
;;
--help|-h)
usage 0
;;
*)
usage
;;
esac
done
if [ "$#" -lt 2 ]; then
usage
fi
commit_message=$1
shift
if [[ "$commit_message" != *[![:space:]]* ]]; then
printf 'Error: commit message must not be empty\n' >&2
exit 1
fi
if [ -e "$commit_message" ]; then
printf 'Error: first argument looks like a file path ("%s"); provide the commit message first\n' "$commit_message" >&2
exit 1
fi
if [ "$#" -eq 0 ]; then
usage
fi
path_exists_or_tracked() {
local candidate=$1
[ -e "$candidate" ] || git ls-files --error-unmatch -- "$candidate" >/dev/null 2>&1
}
append_normalized_file_arg() {
local raw=$1
if path_exists_or_tracked "$raw"; then
files+=("$raw")
return
fi
if [[ "$raw" == *$'\n'* || "$raw" == *$'\r'* ]]; then
local normalized=${raw//$'\r'/}
while IFS= read -r line; do
if [[ "$line" == *[![:space:]]* ]]; then
files+=("$line")
fi
done <<< "$normalized"
return
fi
if [[ "$raw" == *[[:space:]]* ]]; then
local split_paths=()
# Intentional IFS split for callers that pass a single shell-expanded path blob.
# shellcheck disable=SC2206
split_paths=($raw)
if [ "${#split_paths[@]}" -gt 1 ]; then
files+=("${split_paths[@]}")
return
fi
fi
files+=("$raw")
}
files=()
for raw_arg in "$@"; do
append_normalized_file_arg "$raw_arg"
done
# Disallow "." because it stages the entire repository and defeats the helper's safety guardrails.
for file in "${files[@]}"; do
if [ "$file" = "." ]; then
printf 'Error: "." is not allowed; list specific paths instead\n' >&2
exit 1
fi
done
# Prevent staging node_modules even if a path is forced.
for file in "${files[@]}"; do
case "$file" in
*node_modules* | */node_modules | */node_modules/* | node_modules)
printf 'Error: node_modules paths are not allowed: %s\n' "$file" >&2
exit 1
;;
esac
done
last_commit_error=''
run_git_command() {
local stderr_log
stderr_log=$(mktemp)
if "$@" 2>"$stderr_log"; then
if [ -s "$stderr_log" ]; then
cat "$stderr_log" >&2
fi
rm -f "$stderr_log"
last_commit_error=''
return 0
fi
if [ -s "$stderr_log" ]; then
cat "$stderr_log" >&2
fi
last_commit_error=$(cat "$stderr_log")
rm -f "$stderr_log"
return 1
}
is_git_lock_error() {
printf '%s\n' "$last_commit_error" | grep -Eq \
"Another git process seems to be running|Unable to create '.*\\.git/[^']+\\.lock'"
}
extract_git_lock_path() {
printf '%s\n' "$last_commit_error" |
sed -n "s/.*'\(.*\.git\/[^']*\.lock\)'.*/\1/p" |
head -n 1
}
run_git_with_lock_retry() {
local label=$1
shift
local deadline=$((SECONDS + 5))
local announced_retry=false
while true; do
if run_git_command "$@"; then
return 0
fi
if ! is_git_lock_error; then
return 1
fi
if [ "$SECONDS" -ge "$deadline" ]; then
break
fi
if [ "$announced_retry" = false ]; then
printf 'Git lock during %s; retrying for up to 5 seconds...\n' "$label" >&2
announced_retry=true
fi
sleep 0.5
done
if [ "$force_delete_lock" = true ]; then
local lock_path
lock_path=$(extract_git_lock_path)
if [ -n "$lock_path" ] && [ -e "$lock_path" ]; then
rm -f "$lock_path"
printf 'Removed stale git lock: %s\n' "$lock_path" >&2
run_git_command "$@"
return $?
fi
fi
return 1
}
for file in "${files[@]}"; do
if ! path_exists_or_tracked "$file"; then
printf 'Error: file not found: %s\n' "$file" >&2
exit 1
fi
done
run_git_with_lock_retry "unstaging files" git restore --staged :/
run_git_with_lock_retry "staging files" git add --force -- "${files[@]}"
if git diff --staged --quiet; then
printf 'Warning: no staged changes detected for: %s\n' "${files[*]}" >&2
exit 1
fi
committed=false
if [ "$fast_commit" = true ]; then
declare -a commit_env=(FAST_COMMIT=1)
if run_git_with_lock_retry "commit" env "${commit_env[@]}" git commit -m "$commit_message"; then
committed=true
fi
else
if run_git_with_lock_retry "commit" git commit -m "$commit_message"; then
committed=true
fi
fi
if [ "$committed" = false ]; then
exit 1
fi
printf 'Committed "%s" with %d files\n' "$commit_message" "${#files[@]}"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,7 @@
export function rewritePackageExtensions(entries: unknown): string[] | undefined;
export function copyBundledPluginMetadata(params?: {
repoRoot?: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
}): void;

View file

@ -0,0 +1,323 @@
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { NON_PACKAGED_BUNDLED_PLUGIN_DIRS } from "./lib/bundled-plugin-build-entries.mjs";
import { shouldBuildBundledCluster } from "./lib/optional-bundled-clusters.mjs";
import {
removeFileIfExists,
removePathIfExists,
writeTextFileIfChanged,
} from "./runtime-postbuild-shared.mjs";
const GENERATED_BUNDLED_SKILLS_DIR = "bundled-skills";
const TRANSIENT_COPY_ERROR_CODES = new Set(["EEXIST", "ENOENT", "ENOTEMPTY", "EBUSY"]);
const COPY_RETRY_DELAYS_MS = [10, 25, 50];
function shouldCopyBundledPluginMetadata(id, env) {
if (!NON_PACKAGED_BUNDLED_PLUGIN_DIRS.has(id)) {
return true;
}
return env.OPENCLAW_BUILD_PRIVATE_QA === "1";
}
export function rewritePackageExtensions(entries) {
if (!Array.isArray(entries)) {
return undefined;
}
return entries
.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
.map((entry) => {
const normalized = entry.replace(/^\.\//, "");
const rewritten = normalized.replace(/\.[^.]+$/u, ".js");
return `./${rewritten}`;
});
}
function collectTopLevelPublicSurfaceEntries(pluginDir) {
if (!fs.existsSync(pluginDir)) {
return [];
}
return fs
.readdirSync(pluginDir, { withFileTypes: true })
.flatMap((dirent) => {
if (!dirent.isFile()) {
return [];
}
if (!/\.(?:[cm]?[jt]s)$/u.test(dirent.name) || dirent.name.endsWith(".d.ts")) {
return [];
}
const normalizedName = dirent.name.toLowerCase();
if (
/^config-api\.(?:[cm]?[jt]s)$/u.test(normalizedName) ||
normalizedName.includes(".test.") ||
normalizedName.includes(".spec.") ||
normalizedName.includes(".fixture.") ||
normalizedName.includes(".snap")
) {
return [];
}
return [dirent.name];
})
.toSorted((left, right) => left.localeCompare(right));
}
function isManifestlessBundledRuntimeSupportPackage(params) {
const packageName = typeof params.packageJson?.name === "string" ? params.packageJson.name : "";
if (packageName !== `@openclaw/${params.dirName}`) {
return false;
}
return params.topLevelPublicSurfaceEntries.length > 0;
}
function rewritePackageEntry(entry) {
if (typeof entry !== "string" || entry.trim().length === 0) {
return undefined;
}
const normalized = entry.replace(/^\.\//, "");
const rewritten = normalized.replace(/\.[^.]+$/u, ".js");
return `./${rewritten}`;
}
function ensurePathInsideRoot(rootDir, rawPath) {
const resolved = path.resolve(rootDir, rawPath);
const relative = path.relative(rootDir, resolved);
if (
relative === "" ||
relative === "." ||
(!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative))
) {
return resolved;
}
throw new Error(`path escapes plugin root: ${rawPath}`);
}
function normalizeManifestRelativePath(rawPath) {
return rawPath.replaceAll("\\", "/").replace(/^\.\//u, "");
}
function resolveDeclaredSkillSourcePath(params) {
const normalized = normalizeManifestRelativePath(params.rawPath);
const pluginLocalPath = ensurePathInsideRoot(params.pluginDir, normalized);
if (fs.existsSync(pluginLocalPath)) {
return pluginLocalPath;
}
if (!/^node_modules(?:\/|$)/u.test(normalized)) {
return pluginLocalPath;
}
return ensurePathInsideRoot(params.repoRoot, normalized);
}
function resolveBundledSkillTarget(rawPath) {
const normalized = normalizeManifestRelativePath(rawPath);
if (/^node_modules(?:\/|$)/u.test(normalized)) {
// Bundled dist/plugin roots must not publish nested node_modules trees. Relocate
// dependency-backed skill assets into a dist-owned directory and rewrite the manifest.
const trimmed = normalized.replace(/^node_modules\/?/u, "");
if (!trimmed) {
throw new Error(`node_modules skill path must point to a package: ${rawPath}`);
}
const bundledRelativePath = `${GENERATED_BUNDLED_SKILLS_DIR}/${trimmed}`;
return {
manifestPath: `./${bundledRelativePath}`,
outputPath: bundledRelativePath,
};
}
return {
manifestPath: rawPath,
outputPath: normalized,
};
}
function isTransientCopyError(error) {
return (
!!error &&
typeof error === "object" &&
typeof error.code === "string" &&
TRANSIENT_COPY_ERROR_CODES.has(error.code)
);
}
function sleepSync(ms) {
if (!Number.isFinite(ms) || ms <= 0) {
return;
}
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function copySkillPathWithRetry(params) {
const maxAttempts = COPY_RETRY_DELAYS_MS.length + 1;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
removePathIfExists(params.targetPath);
fs.mkdirSync(path.dirname(params.targetPath), { recursive: true });
fs.cpSync(params.sourcePath, params.targetPath, params.copyOptions);
return;
} catch (error) {
if (!isTransientCopyError(error) || attempt === maxAttempts - 1) {
throw error;
}
sleepSync(COPY_RETRY_DELAYS_MS[attempt] ?? 0);
}
}
}
function copyDeclaredPluginSkillPaths(params) {
const skills = Array.isArray(params.manifest.skills) ? params.manifest.skills : [];
const copiedSkills = [];
for (const raw of skills) {
if (typeof raw !== "string" || raw.trim().length === 0) {
continue;
}
const sourcePath = resolveDeclaredSkillSourcePath({
rawPath: raw,
pluginDir: params.pluginDir,
repoRoot: params.repoRoot,
});
const target = resolveBundledSkillTarget(raw);
if (!fs.existsSync(sourcePath)) {
// Some Docker/lightweight builds intentionally omit optional plugin-local
// dependencies. Only advertise skill paths that were actually bundled.
console.warn(
`[bundled-plugin-metadata] skipping missing skill path ${sourcePath} (plugin ${params.manifest.id ?? path.basename(params.pluginDir)})`,
);
continue;
}
const targetPath = ensurePathInsideRoot(params.distPluginDir, target.outputPath);
const shouldExcludeNestedNodeModules = /^node_modules(?:\/|$)/u.test(
normalizeManifestRelativePath(raw),
);
copySkillPathWithRetry({
sourcePath,
targetPath,
copyOptions: {
dereference: true,
force: true,
recursive: true,
filter: (candidatePath) => {
if (!shouldExcludeNestedNodeModules || candidatePath === sourcePath) {
return true;
}
const relativeCandidate = path.relative(sourcePath, candidatePath).replaceAll("\\", "/");
return !relativeCandidate.split("/").includes("node_modules");
},
},
});
copiedSkills.push(target.manifestPath);
}
return copiedSkills;
}
/**
* @param {{
* cwd?: string;
* repoRoot?: string;
* env?: NodeJS.ProcessEnv;
* }} [params]
*/
export function copyBundledPluginMetadata(params = {}) {
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
const env = params.env ?? process.env;
const extensionsRoot = path.join(repoRoot, "extensions");
const distExtensionsRoot = path.join(repoRoot, "dist", "extensions");
if (!fs.existsSync(extensionsRoot)) {
return;
}
const sourcePluginDirs = new Set();
for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) {
if (!dirent.isDirectory()) {
continue;
}
const pluginDir = path.join(extensionsRoot, dirent.name);
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
const distPluginDir = path.join(distExtensionsRoot, dirent.name);
const packageJsonPath = path.join(pluginDir, "package.json");
const packageJson = fs.existsSync(packageJsonPath)
? JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
: undefined;
const topLevelPublicSurfaceEntries = collectTopLevelPublicSurfaceEntries(pluginDir);
if (!shouldCopyBundledPluginMetadata(dirent.name, env)) {
removePathIfExists(distPluginDir);
continue;
}
if (!shouldBuildBundledCluster(dirent.name, env, { packageJson })) {
removePathIfExists(distPluginDir);
continue;
}
const isManifestlessSupportPackage =
!fs.existsSync(manifestPath) &&
isManifestlessBundledRuntimeSupportPackage({
dirName: dirent.name,
packageJson,
topLevelPublicSurfaceEntries,
});
sourcePluginDirs.add(dirent.name);
const distManifestPath = path.join(distPluginDir, "openclaw.plugin.json");
const distPackageJsonPath = path.join(distPluginDir, "package.json");
if (!fs.existsSync(manifestPath) && !isManifestlessSupportPackage) {
removePathIfExists(distPluginDir);
continue;
}
if (fs.existsSync(manifestPath)) {
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
// Generated skill assets live under a dedicated dist-owned directory. Also
// remove the older bad node_modules tree so release packs cannot pick it up.
removePathIfExists(path.join(distPluginDir, GENERATED_BUNDLED_SKILLS_DIR));
removePathIfExists(path.join(distPluginDir, "node_modules"));
const copiedSkills = copyDeclaredPluginSkillPaths({
manifest,
pluginDir,
distPluginDir,
repoRoot,
});
const bundledManifest = Array.isArray(manifest.skills)
? { ...manifest, skills: copiedSkills }
: manifest;
writeTextFileIfChanged(distManifestPath, `${JSON.stringify(bundledManifest, null, 2)}\n`);
} else {
removeFileIfExists(distManifestPath);
}
if (!fs.existsSync(packageJsonPath)) {
removeFileIfExists(distPackageJsonPath);
continue;
}
if (packageJson.openclaw && "extensions" in packageJson.openclaw) {
packageJson.openclaw = {
...packageJson.openclaw,
extensions: rewritePackageExtensions(packageJson.openclaw.extensions),
...(typeof packageJson.openclaw.setupEntry === "string"
? { setupEntry: rewritePackageEntry(packageJson.openclaw.setupEntry) }
: {}),
};
}
writeTextFileIfChanged(distPackageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
}
if (!fs.existsSync(distExtensionsRoot)) {
return;
}
for (const dirent of fs.readdirSync(distExtensionsRoot, { withFileTypes: true })) {
if (!dirent.isDirectory() || sourcePluginDirs.has(dirent.name)) {
continue;
}
const distPluginDir = path.join(distExtensionsRoot, dirent.name);
removePathIfExists(distPluginDir);
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
copyBundledPluginMetadata();
}

View file

@ -0,0 +1,54 @@
#!/usr/bin/env tsx
/**
* Copy export-html templates from src to dist
*/
import fs from "node:fs";
import path from "node:path";
import { ensureDirectory, logVerboseCopy, resolveBuildCopyContext } from "./lib/copy-assets.ts";
const context = resolveBuildCopyContext(import.meta.url);
const srcDir = path.join(context.projectRoot, "src", "auto-reply", "reply", "export-html");
const distDir = path.join(context.projectRoot, "dist", "export-html");
function copyExportHtmlTemplates() {
if (!fs.existsSync(srcDir)) {
console.warn(`${context.prefix} Source directory not found:`, srcDir);
return;
}
ensureDirectory(distDir);
const templateFiles = ["template.html", "template.css", "template.js"];
let copiedCount = 0;
for (const file of templateFiles) {
const srcFile = path.join(srcDir, file);
const distFile = path.join(distDir, file);
if (fs.existsSync(srcFile)) {
fs.copyFileSync(srcFile, distFile);
copiedCount += 1;
logVerboseCopy(context, `Copied ${file}`);
}
}
const srcVendor = path.join(srcDir, "vendor");
const distVendor = path.join(distDir, "vendor");
if (fs.existsSync(srcVendor)) {
ensureDirectory(distVendor);
const vendorFiles = fs.readdirSync(srcVendor);
for (const file of vendorFiles) {
const srcFile = path.join(srcVendor, file);
const distFile = path.join(distVendor, file);
if (fs.statSync(srcFile).isFile()) {
fs.copyFileSync(srcFile, distFile);
copiedCount += 1;
logVerboseCopy(context, `Copied vendor/${file}`);
}
}
}
console.log(`${context.prefix} Copied ${copiedCount} export-html assets.`);
}
copyExportHtmlTemplates();

View file

@ -0,0 +1,52 @@
#!/usr/bin/env tsx
/**
* Copy HOOK.md files from src/hooks/bundled to dist/bundled
*/
import fs from "node:fs";
import path from "node:path";
import { ensureDirectory, logVerboseCopy, resolveBuildCopyContext } from "./lib/copy-assets.ts";
const context = resolveBuildCopyContext(import.meta.url);
const srcBundled = path.join(context.projectRoot, "src", "hooks", "bundled");
const distBundled = path.join(context.projectRoot, "dist", "bundled");
function copyHookMetadata() {
if (!fs.existsSync(srcBundled)) {
console.warn(`${context.prefix} Source directory not found:`, srcBundled);
return;
}
ensureDirectory(distBundled);
const entries = fs.readdirSync(srcBundled, { withFileTypes: true });
let copiedCount = 0;
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const hookName = entry.name;
const srcHookDir = path.join(srcBundled, hookName);
const distHookDir = path.join(distBundled, hookName);
const srcHookMd = path.join(srcHookDir, "HOOK.md");
const distHookMd = path.join(distHookDir, "HOOK.md");
if (!fs.existsSync(srcHookMd)) {
console.warn(`${context.prefix} No HOOK.md found for ${hookName}`);
continue;
}
ensureDirectory(distHookDir);
fs.copyFileSync(srcHookMd, distHookMd);
copiedCount += 1;
logVerboseCopy(context, `Copied ${hookName}/HOOK.md`);
}
console.log(`${context.prefix} Copied ${copiedCount} hook metadata files.`);
}
copyHookMetadata();

View file

@ -0,0 +1,16 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs";
export function copyPluginSdkRootAlias(params = {}) {
const cwd = params.cwd ?? process.cwd();
const source = resolve(cwd, "src/plugin-sdk/root-alias.cjs");
const target = resolve(cwd, "dist/plugin-sdk/root-alias.cjs");
writeTextFileIfChanged(target, readFileSync(source, "utf8"));
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
copyPluginSdkRootAlias();
}

View file

@ -0,0 +1,176 @@
#!/usr/bin/env bash
set -euo pipefail
# Create a styled DMG containing the app bundle + /Applications symlink.
#
# Usage:
# scripts/create-dmg.sh <app_path> [output_dmg]
#
# Env:
# DMG_VOLUME_NAME default: CFBundleName (or "OpenClaw")
# DMG_BACKGROUND_PATH default: assets/dmg-background.png
# DMG_BACKGROUND_SMALL default: assets/dmg-background-small.png (recommended)
# DMG_WINDOW_BOUNDS default: "400 100 900 420" (500x320)
# DMG_ICON_SIZE default: 128
# DMG_APP_POS default: "125 160"
# DMG_APPS_POS default: "375 160"
# SKIP_DMG_STYLE=1 skip Finder styling
# DMG_EXTRA_SECTORS extra sectors to keep when shrinking RW image (default: 2048)
APP_PATH="${1:-}"
OUT_PATH="${2:-}"
if [[ -z "$APP_PATH" ]]; then
echo "Usage: $0 <app_path> [output_dmg]" >&2
exit 1
fi
if [[ ! -d "$APP_PATH" ]]; then
echo "Error: App not found: $APP_PATH" >&2
exit 1
fi
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
BUILD_DIR="$ROOT_DIR/dist"
mkdir -p "$BUILD_DIR"
APP_NAME=$(/usr/libexec/PlistBuddy -c "Print CFBundleName" "$APP_PATH/Contents/Info.plist" 2>/dev/null || echo "OpenClaw")
VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "$APP_PATH/Contents/Info.plist" 2>/dev/null || echo "0.0.0")
DMG_NAME="${APP_NAME}-${VERSION}.dmg"
DMG_VOLUME_NAME="${DMG_VOLUME_NAME:-$APP_NAME}"
DMG_BACKGROUND_SMALL="${DMG_BACKGROUND_SMALL:-$ROOT_DIR/assets/dmg-background-small.png}"
DMG_BACKGROUND_PATH="${DMG_BACKGROUND_PATH:-$ROOT_DIR/assets/dmg-background.png}"
DMG_WINDOW_BOUNDS="${DMG_WINDOW_BOUNDS:-400 100 900 420}"
DMG_ICON_SIZE="${DMG_ICON_SIZE:-128}"
DMG_APP_POS="${DMG_APP_POS:-125 160}"
DMG_APPS_POS="${DMG_APPS_POS:-375 160}"
DMG_EXTRA_SECTORS="${DMG_EXTRA_SECTORS:-2048}"
to_applescript_list4() {
local raw="$1"
echo "$raw" | awk '{ printf "%s, %s, %s, %s", $1, $2, $3, $4 }'
}
to_applescript_pair() {
local raw="$1"
echo "$raw" | awk '{ printf "%s, %s", $1, $2 }'
}
if [[ -z "$OUT_PATH" ]]; then
OUT_PATH="$BUILD_DIR/$DMG_NAME"
fi
echo "Creating DMG: $OUT_PATH"
# Cleanup stuck volumes.
for vol in "/Volumes/$DMG_VOLUME_NAME"* "/Volumes/$APP_NAME"*; do
if [[ -d "$vol" ]]; then
hdiutil detach "$vol" -force 2>/dev/null || true
sleep 1
fi
done
DMG_TEMP="$(mktemp -d /tmp/openclaw-dmg.XXXXXX)"
trap 'hdiutil detach "/Volumes/'"$DMG_VOLUME_NAME"'" -force 2>/dev/null || true; rm -rf "$DMG_TEMP" 2>/dev/null || true' EXIT
cp -R "$APP_PATH" "$DMG_TEMP/"
ln -s /Applications "$DMG_TEMP/Applications"
APP_SIZE_MB=$(du -sm "$APP_PATH" | awk '{print $1}')
DMG_SIZE_MB=$((APP_SIZE_MB + 80))
DMG_RW_PATH="${OUT_PATH%.dmg}-rw.dmg"
rm -f "$DMG_RW_PATH" "$OUT_PATH"
hdiutil create \
-volname "$DMG_VOLUME_NAME" \
-srcfolder "$DMG_TEMP" \
-ov \
-format UDRW \
-size "${DMG_SIZE_MB}m" \
"$DMG_RW_PATH"
MOUNT_POINT="/Volumes/$DMG_VOLUME_NAME"
if [[ -d "$MOUNT_POINT" ]]; then
hdiutil detach "$MOUNT_POINT" -force 2>/dev/null || true
sleep 2
fi
hdiutil attach "$DMG_RW_PATH" -mountpoint "$MOUNT_POINT" -nobrowse
if [[ "${SKIP_DMG_STYLE:-0}" != "1" ]]; then
mkdir -p "$MOUNT_POINT/.background"
if [[ -f "$DMG_BACKGROUND_SMALL" ]]; then
cp "$DMG_BACKGROUND_SMALL" "$MOUNT_POINT/.background/background.png"
elif [[ -f "$DMG_BACKGROUND_PATH" ]]; then
cp "$DMG_BACKGROUND_PATH" "$MOUNT_POINT/.background/background.png"
else
echo "WARN: DMG background missing: $DMG_BACKGROUND_SMALL / $DMG_BACKGROUND_PATH" >&2
fi
# Volume icon: reuse the app icon if available.
ICON_SRC="$ROOT_DIR/apps/macos/Sources/OpenClaw/Resources/OpenClaw.icns"
if [[ -f "$ICON_SRC" ]]; then
cp "$ICON_SRC" "$MOUNT_POINT/.VolumeIcon.icns"
if command -v SetFile >/dev/null 2>&1; then
SetFile -a C "$MOUNT_POINT" 2>/dev/null || true
fi
fi
osascript <<EOF
tell application "Finder"
tell disk "$DMG_VOLUME_NAME"
open
set current view of container window to icon view
set toolbar visible of container window to false
set statusbar visible of container window to false
set the bounds of container window to {$(to_applescript_list4 "$DMG_WINDOW_BOUNDS")}
set viewOptions to the icon view options of container window
set arrangement of viewOptions to not arranged
set icon size of viewOptions to ${DMG_ICON_SIZE}
if exists file ".background:background.png" then
set background picture of viewOptions to file ".background:background.png"
end if
set text size of viewOptions to 12
set label position of viewOptions to bottom
set shows item info of viewOptions to false
set shows icon preview of viewOptions to true
set position of item "${APP_NAME}.app" of container window to {$(to_applescript_pair "$DMG_APP_POS")}
set position of item "Applications" of container window to {$(to_applescript_pair "$DMG_APPS_POS")}
update without registering applications
delay 2
close
open
delay 1
end tell
end tell
EOF
sleep 2
osascript -e 'tell application "Finder" to close every window' || true
fi
for i in {1..5}; do
if hdiutil detach "$MOUNT_POINT" -quiet 2>/dev/null; then
break
fi
if [[ "$i" == "3" ]]; then
hdiutil detach "$MOUNT_POINT" -force 2>/dev/null || true
fi
sleep 2
done
hdiutil resize -limits "$DMG_RW_PATH" >/tmp/openclaw-dmg-limits.txt 2>/dev/null || true
MIN_SECTORS="$(tail -n 1 /tmp/openclaw-dmg-limits.txt 2>/dev/null | awk '{print $1}')"
rm -f /tmp/openclaw-dmg-limits.txt
if [[ "$MIN_SECTORS" =~ ^[0-9]+$ ]] && [[ "$DMG_EXTRA_SECTORS" =~ ^[0-9]+$ ]]; then
TARGET_SECTORS=$((MIN_SECTORS + DMG_EXTRA_SECTORS))
echo "Shrinking RW image: min sectors=$MIN_SECTORS (+$DMG_EXTRA_SECTORS) -> $TARGET_SECTORS"
hdiutil resize -sectors "$TARGET_SECTORS" "$DMG_RW_PATH" >/dev/null 2>&1 || true
fi
hdiutil convert "$DMG_RW_PATH" -format ULMO -o "$OUT_PATH" -ov
rm -f "$DMG_RW_PATH"
hdiutil verify "$OUT_PATH" >/dev/null
echo "✅ DMG ready: $OUT_PATH"

View file

@ -0,0 +1,274 @@
import fs from "node:fs/promises";
import path from "node:path";
type Usage = {
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
cache_read_tokens?: number;
cache_write_tokens?: number;
};
type CronRunLogEntry = {
ts: number;
jobId: string;
action: "finished";
status?: "ok" | "error" | "skipped";
model?: string;
provider?: string;
usage?: Usage;
};
function parseArgs(argv: string[]) {
const args: Record<string, string | boolean> = {};
for (let i = 2; i < argv.length; i++) {
const a = argv[i] ?? "";
if (!a.startsWith("--")) {
continue;
}
const key = a.slice(2);
const next = argv[i + 1];
if (next && !next.startsWith("--")) {
args[key] = next;
i++;
} else {
args[key] = true;
}
}
return args;
}
function usageAndExit(code: number): never {
console.error(
[
"cron_usage_report.ts",
"",
"Required (choose one):",
" --store <path-to-cron-store-json> (derive runs dir as dirname(store)/runs)",
" --runsDir <path-to-runs-dir>",
"",
"Time window:",
" --hours <n> (default 24)",
" --from <iso> (overrides --hours)",
" --to <iso> (default now)",
"",
"Filters:",
" --jobId <id>",
" --model <name>",
"",
"Output:",
" --json (emit JSON)",
].join("\n"),
);
process.exit(code);
}
async function listJsonlFiles(dir: string): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
return entries
.filter((e) => e.isFile() && e.name.endsWith(".jsonl"))
.map((e) => path.join(dir, e.name));
}
function safeParseLine(line: string): CronRunLogEntry | null {
try {
const obj = JSON.parse(line) as Partial<CronRunLogEntry> | null;
if (!obj || typeof obj !== "object") {
return null;
}
if (obj.action !== "finished") {
return null;
}
if (typeof obj.ts !== "number" || !Number.isFinite(obj.ts)) {
return null;
}
if (typeof obj.jobId !== "string" || !obj.jobId.trim()) {
return null;
}
return obj as CronRunLogEntry;
} catch {
return null;
}
}
function fmtInt(n: number) {
return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(n);
}
export async function main() {
const args = parseArgs(process.argv);
const store = typeof args.store === "string" ? args.store : undefined;
const runsDirArg = typeof args.runsDir === "string" ? args.runsDir : undefined;
const runsDir =
runsDirArg ?? (store ? path.join(path.dirname(path.resolve(store)), "runs") : null);
if (!runsDir) {
usageAndExit(2);
}
const hours = typeof args.hours === "string" ? Number(args.hours) : 24;
const toMs = typeof args.to === "string" ? Date.parse(args.to) : Date.now();
const fromMs =
typeof args.from === "string"
? Date.parse(args.from)
: toMs - Math.max(1, Number.isFinite(hours) ? hours : 24) * 60 * 60 * 1000;
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) {
console.error("Invalid --from/--to timestamp");
process.exit(2);
}
const filterJobId = typeof args.jobId === "string" ? args.jobId.trim() : "";
const filterModel = typeof args.model === "string" ? args.model.trim() : "";
const asJson = args.json === true;
const files = await listJsonlFiles(runsDir);
const totalsByJob: Record<
string,
{
jobId: string;
runs: number;
models: Record<
string,
{
model: string;
runs: number;
input_tokens: number;
output_tokens: number;
total_tokens: number;
missingUsageRuns: number;
}
>;
input_tokens: number;
output_tokens: number;
total_tokens: number;
missingUsageRuns: number;
}
> = {};
for (const file of files) {
const raw = await fs.readFile(file, "utf-8").catch(() => "");
if (!raw.trim()) {
continue;
}
const lines = raw.split("\n");
for (const line of lines) {
const entry = safeParseLine(line.trim());
if (!entry) {
continue;
}
if (entry.ts < fromMs || entry.ts > toMs) {
continue;
}
if (filterJobId && entry.jobId !== filterJobId) {
continue;
}
const model = (entry.model ?? "<unknown>").trim() || "<unknown>";
if (filterModel && model !== filterModel) {
continue;
}
const jobId = entry.jobId;
const usage = entry.usage;
const hasUsage = Boolean(
usage && (usage.total_tokens ?? usage.input_tokens ?? usage.output_tokens) !== undefined,
);
const jobAgg = (totalsByJob[jobId] ??= {
jobId,
runs: 0,
models: {},
input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
missingUsageRuns: 0,
});
jobAgg.runs++;
const modelAgg = (jobAgg.models[model] ??= {
model,
runs: 0,
input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
missingUsageRuns: 0,
});
modelAgg.runs++;
if (!hasUsage) {
jobAgg.missingUsageRuns++;
modelAgg.missingUsageRuns++;
continue;
}
const input = Math.max(0, Math.trunc(usage?.input_tokens ?? 0));
const output = Math.max(0, Math.trunc(usage?.output_tokens ?? 0));
const total = Math.max(0, Math.trunc(usage?.total_tokens ?? input + output));
jobAgg.input_tokens += input;
jobAgg.output_tokens += output;
jobAgg.total_tokens += total;
modelAgg.input_tokens += input;
modelAgg.output_tokens += output;
modelAgg.total_tokens += total;
}
}
const rows = Object.values(totalsByJob)
.map((r) =>
Object.assign({}, r, {
models: Object.values(r.models).toSorted((a, b) => b.total_tokens - a.total_tokens),
}),
)
.toSorted((a, b) => b.total_tokens - a.total_tokens);
if (asJson) {
process.stdout.write(
JSON.stringify(
{
from: new Date(fromMs).toISOString(),
to: new Date(toMs).toISOString(),
runsDir,
jobs: rows,
},
null,
2,
) + "\n",
);
return;
}
console.log(`Cron usage report`);
console.log(` runsDir: ${runsDir}`);
console.log(` window: ${new Date(fromMs).toISOString()} → ${new Date(toMs).toISOString()}`);
if (filterJobId) {
console.log(` filter jobId: ${filterJobId}`);
}
if (filterModel) {
console.log(` filter model: ${filterModel}`);
}
console.log("");
if (rows.length === 0) {
console.log("No matching cron run entries found.");
return;
}
for (const job of rows) {
console.log(`jobId: ${job.jobId}`);
console.log(` runs: ${fmtInt(job.runs)} (missing usage: ${fmtInt(job.missingUsageRuns)})`);
console.log(
` tokens: total ${fmtInt(job.total_tokens)} (in ${fmtInt(job.input_tokens)} / out ${fmtInt(job.output_tokens)})`,
);
for (const m of job.models) {
console.log(
` model ${m.model}: runs ${fmtInt(m.runs)} (missing usage: ${fmtInt(m.missingUsageRuns)}), total ${fmtInt(m.total_tokens)} (in ${fmtInt(m.input_tokens)} / out ${fmtInt(m.output_tokens)})`,
);
}
console.log("");
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
void main();
}

View file

@ -0,0 +1,389 @@
import { execFileSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { normalizeOptionalString } from "../src/shared/string-coerce.ts";
type Args = {
agentId: string;
reveal: boolean;
sessionKey?: string;
};
const mask = (value: string) => {
const compact = value.trim();
if (!compact) {
return "missing";
}
const edge = compact.length >= 12 ? 6 : 4;
return `${compact.slice(0, edge)}…${compact.slice(-edge)}`;
};
const parseArgs = (): Args => {
const args = process.argv.slice(2);
let agentId = "main";
let reveal = false;
let sessionKey: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--agent" && args[i + 1]) {
agentId = args[++i].trim() || "main";
continue;
}
if (arg === "--reveal") {
reveal = true;
continue;
}
if (arg === "--session-key" && args[i + 1]) {
sessionKey = normalizeOptionalString(args[++i]);
continue;
}
}
return { agentId, reveal, sessionKey };
};
const loadAuthProfiles = (agentId: string) => {
const stateRoot = process.env.OPENCLAW_STATE_DIR?.trim() || path.join(os.homedir(), ".openclaw");
const authPath = path.join(stateRoot, "agents", agentId, "agent", "auth-profiles.json");
if (!fs.existsSync(authPath)) {
throw new Error(`Missing: ${authPath}`);
}
const store = JSON.parse(fs.readFileSync(authPath, "utf8")) as {
profiles?: Record<string, { provider?: string; type?: string; token?: string; key?: string }>;
};
return { authPath, store };
};
const pickAnthropicTokens = (store: {
profiles?: Record<string, { provider?: string; type?: string; token?: string; key?: string }>;
}): Array<{ profileId: string; token: string }> => {
const profiles = store.profiles ?? {};
const found: Array<{ profileId: string; token: string }> = [];
for (const [id, cred] of Object.entries(profiles)) {
if (cred?.provider !== "anthropic") {
continue;
}
const token = cred.type === "token" ? cred.token?.trim() : undefined;
if (token) {
found.push({ profileId: id, token });
}
}
return found;
};
const fetchAnthropicOAuthUsage = async (token: string) => {
const res = await fetch("https://api.anthropic.com/api/oauth/usage", {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
"anthropic-version": "2023-06-01",
"anthropic-beta": "oauth-2025-04-20",
"User-Agent": "openclaw-debug",
},
});
const text = await res.text();
return { status: res.status, contentType: res.headers.get("content-type"), text };
};
const readClaudeCliKeychain = (): {
accessToken: string;
expiresAt?: number;
scopes?: string[];
} | null => {
if (process.platform !== "darwin") {
return null;
}
try {
const raw = execFileSync(
"security",
["find-generic-password", "-s", "Claude Code-credentials", "-w"],
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 },
);
const parsed = JSON.parse(raw.trim()) as Record<string, unknown>;
const oauth = parsed?.claudeAiOauth as Record<string, unknown> | undefined;
if (!oauth || typeof oauth !== "object") {
return null;
}
const accessToken = oauth.accessToken;
if (typeof accessToken !== "string" || !accessToken.trim()) {
return null;
}
const expiresAt = typeof oauth.expiresAt === "number" ? oauth.expiresAt : undefined;
const scopes = Array.isArray(oauth.scopes)
? oauth.scopes.filter((v): v is string => typeof v === "string")
: undefined;
return { accessToken, expiresAt, scopes };
} catch {
return null;
}
};
const chromeServiceNameForPath = (cookiePath: string): string => {
if (cookiePath.includes("/Arc/")) {
return "Arc Safe Storage";
}
if (cookiePath.includes("/BraveSoftware/")) {
return "Brave Safe Storage";
}
if (cookiePath.includes("/Microsoft Edge/")) {
return "Microsoft Edge Safe Storage";
}
if (cookiePath.includes("/Chromium/")) {
return "Chromium Safe Storage";
}
return "Chrome Safe Storage";
};
const readKeychainPassword = (service: string): string | null => {
try {
const out = execFileSync("security", ["find-generic-password", "-w", "-s", service], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 5000,
});
const pw = out.trim();
return pw ? pw : null;
} catch {
return null;
}
};
const decryptChromeCookieValue = (encrypted: Buffer, service: string): string | null => {
if (encrypted.length < 4) {
return null;
}
const prefix = encrypted.subarray(0, 3).toString("utf8");
if (prefix !== "v10" && prefix !== "v11") {
return null;
}
const password = readKeychainPassword(service);
if (!password) {
return null;
}
const key = crypto.pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1");
const iv = Buffer.alloc(16, 0x20);
const data = encrypted.subarray(3);
try {
const decipher = crypto.createDecipheriv("aes-128-cbc", key, iv);
decipher.setAutoPadding(true);
const decrypted = Buffer.concat([decipher.update(data), decipher.final()]);
const text = decrypted.toString("utf8").trim();
return text ? text : null;
} catch {
return null;
}
};
const queryChromeCookieDb = (cookieDb: string): string | null => {
try {
const out = execFileSync(
"sqlite3",
[
"-readonly",
cookieDb,
`
SELECT
COALESCE(NULLIF(value,''), hex(encrypted_value))
FROM cookies
WHERE (host_key LIKE '%claude.ai%' OR host_key = '.claude.ai')
AND name = 'sessionKey'
LIMIT 1;
`,
],
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 },
).trim();
if (!out) {
return null;
}
if (out.startsWith("sk-ant-")) {
return out;
}
const hex = out.replace(/[^0-9A-Fa-f]/g, "");
if (!hex) {
return null;
}
const buf = Buffer.from(hex, "hex");
const service = chromeServiceNameForPath(cookieDb);
const decrypted = decryptChromeCookieValue(buf, service);
return decrypted && decrypted.startsWith("sk-ant-") ? decrypted : null;
} catch {
return null;
}
};
const queryFirefoxCookieDb = (cookieDb: string): string | null => {
try {
const out = execFileSync(
"sqlite3",
[
"-readonly",
cookieDb,
`
SELECT value
FROM moz_cookies
WHERE (host LIKE '%claude.ai%' OR host = '.claude.ai')
AND name = 'sessionKey'
LIMIT 1;
`,
],
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 },
).trim();
return out && out.startsWith("sk-ant-") ? out : null;
} catch {
return null;
}
};
const findClaudeSessionKey = (): { sessionKey: string; source: string } | null => {
if (process.platform !== "darwin") {
return null;
}
const firefoxRoot = path.join(
os.homedir(),
"Library",
"Application Support",
"Firefox",
"Profiles",
);
if (fs.existsSync(firefoxRoot)) {
for (const entry of fs.readdirSync(firefoxRoot)) {
const db = path.join(firefoxRoot, entry, "cookies.sqlite");
if (!fs.existsSync(db)) {
continue;
}
const value = queryFirefoxCookieDb(db);
if (value) {
return { sessionKey: value, source: `firefox:${db}` };
}
}
}
const chromeCandidates = [
path.join(os.homedir(), "Library", "Application Support", "Google", "Chrome"),
path.join(os.homedir(), "Library", "Application Support", "Chromium"),
path.join(os.homedir(), "Library", "Application Support", "Arc"),
path.join(os.homedir(), "Library", "Application Support", "BraveSoftware", "Brave-Browser"),
path.join(os.homedir(), "Library", "Application Support", "Microsoft Edge"),
];
for (const root of chromeCandidates) {
if (!fs.existsSync(root)) {
continue;
}
const profiles = fs
.readdirSync(root)
.filter((name) => name === "Default" || name.startsWith("Profile "));
for (const profile of profiles) {
const db = path.join(root, profile, "Cookies");
if (!fs.existsSync(db)) {
continue;
}
const value = queryChromeCookieDb(db);
if (value) {
return { sessionKey: value, source: `chromium:${db}` };
}
}
}
return null;
};
const fetchClaudeWebUsage = async (sessionKey: string) => {
const headers = {
Cookie: `sessionKey=${sessionKey}`,
Accept: "application/json",
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
};
const orgRes = await fetch("https://claude.ai/api/organizations", { headers });
const orgText = await orgRes.text();
if (!orgRes.ok) {
return { ok: false as const, step: "organizations", status: orgRes.status, body: orgText };
}
const orgs = JSON.parse(orgText) as Array<{ uuid?: string }>;
const orgId = orgs?.[0]?.uuid;
if (!orgId) {
return { ok: false as const, step: "organizations", status: 200, body: orgText };
}
const usageRes = await fetch(`https://claude.ai/api/organizations/${orgId}/usage`, { headers });
const usageText = await usageRes.text();
return usageRes.ok
? { ok: true as const, orgId, body: usageText }
: { ok: false as const, step: "usage", status: usageRes.status, body: usageText };
};
const main = async () => {
const opts = parseArgs();
const { authPath, store } = loadAuthProfiles(opts.agentId);
console.log(`Auth file: ${authPath}`);
const keychain = readClaudeCliKeychain();
if (keychain) {
console.log(
`Claude Code CLI keychain: accessToken=${opts.reveal ? keychain.accessToken : mask(keychain.accessToken)} scopes=${keychain.scopes?.join(",") ?? "(unknown)"}`,
);
const oauth = await fetchAnthropicOAuthUsage(keychain.accessToken);
console.log(
`OAuth usage (keychain): HTTP ${oauth.status} (${oauth.contentType ?? "no content-type"})`,
);
console.log(oauth.text.slice(0, 200).replace(/\s+/g, " ").trim());
} else {
console.log("Claude Code CLI keychain: missing/unreadable");
}
const anthropic = pickAnthropicTokens(store);
if (anthropic.length === 0) {
console.log("Auth profiles: no Anthropic token profiles found");
} else {
for (const entry of anthropic) {
console.log(
`Auth profiles: ${entry.profileId} token=${opts.reveal ? entry.token : mask(entry.token)}`,
);
const oauth = await fetchAnthropicOAuthUsage(entry.token);
console.log(
`OAuth usage (${entry.profileId}): HTTP ${oauth.status} (${oauth.contentType ?? "no content-type"})`,
);
console.log(oauth.text.slice(0, 200).replace(/\s+/g, " ").trim());
}
}
const sessionKey =
opts.sessionKey?.trim() ||
process.env.CLAUDE_AI_SESSION_KEY?.trim() ||
process.env.CLAUDE_WEB_SESSION_KEY?.trim() ||
findClaudeSessionKey()?.sessionKey;
const source = opts.sessionKey
? "--session-key"
: process.env.CLAUDE_AI_SESSION_KEY || process.env.CLAUDE_WEB_SESSION_KEY
? "env"
: (findClaudeSessionKey()?.source ?? "auto");
if (!sessionKey) {
console.log(
"Claude web: no sessionKey found (try --session-key or export CLAUDE_AI_SESSION_KEY)",
);
return;
}
console.log(
`Claude web: sessionKey=${opts.reveal ? sessionKey : mask(sessionKey)} (source: ${source})`,
);
const web = await fetchClaudeWebUsage(sessionKey);
if (!web.ok) {
console.log(`Claude web: ${web.step} HTTP ${web.status}`);
console.log(web.body.slice(0, 400).replace(/\s+/g, " ").trim());
return;
}
console.log(`Claude web: org=${web.orgId} OK`);
console.log(web.body.slice(0, 400).replace(/\s+/g, " ").trim());
};
await main();

View file

@ -0,0 +1,839 @@
#!/usr/bin/env bun
import { execFile } from "node:child_process";
// Manual ACP thread smoke for plain-language routing.
// Keep this script available for regression/debug validation. Do not delete.
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { formatErrorMessage } from "../../src/infra/errors.ts";
function writeStdoutLine(message: string): void {
process.stdout.write(`${message}\n`);
}
function writeStdoutJson(value: unknown): void {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
function writeStderrLine(message: string): void {
process.stderr.write(`${message}\n`);
}
type ThreadBindingRecord = {
accountId?: string;
channelId?: string;
threadId?: string;
targetKind?: string;
targetSessionKey?: string;
agentId?: string;
boundBy?: string;
boundAt?: number;
};
type ThreadBindingsPayload = {
version?: number;
bindings?: Record<string, ThreadBindingRecord>;
};
type DiscordMessage = {
id: string;
content?: string;
timestamp?: string;
author?: {
id?: string;
username?: string;
bot?: boolean;
};
};
type DiscordUser = {
id: string;
username: string;
bot?: boolean;
};
const execFileAsync = promisify(execFile);
type DriverMode = "token" | "webhook" | "openclaw";
type Args = {
channelId: string;
driverMode: DriverMode;
driverToken: string;
driverTokenPrefix: string;
botToken: string;
botTokenPrefix: string;
targetAgent: string;
timeoutMs: number;
pollMs: number;
mentionUserId?: string;
instruction?: string;
threadBindingsPath: string;
openclawBin: string;
json: boolean;
};
type SuccessResult = {
ok: true;
smokeId: string;
ackToken: string;
sentMessageId: string;
binding: {
threadId: string;
targetSessionKey: string;
targetKind: string;
agentId: string;
boundAt: number;
accountId?: string;
channelId?: string;
};
ackMessage: {
id: string;
authorId?: string;
authorUsername?: string;
timestamp?: string;
content?: string;
};
};
type FailureResult = {
ok: false;
smokeId: string;
stage: "validation" | "send-message" | "wait-binding" | "wait-ack" | "discord-api" | "unexpected";
error: string;
diagnostics?: {
parentChannelRecent?: Array<{
id: string;
author?: string;
bot?: boolean;
content?: string;
}>;
bindingCandidates?: Array<{
threadId: string;
targetSessionKey: string;
targetKind?: string;
agentId?: string;
boundAt?: number;
}>;
};
};
const DISCORD_API_BASE = "https://discord.com/api/v10";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parseNumber(value: string | undefined, fallback: number): number {
if (!value) {
return fallback;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function resolveStateDir(): string {
const override = process.env.OPENCLAW_STATE_DIR?.trim();
if (override) {
return override.startsWith("~")
? path.resolve(process.env.HOME || "", override.slice(1))
: path.resolve(override);
}
const home = process.env.OPENCLAW_HOME?.trim() || process.env.HOME || "";
return path.join(home, ".openclaw");
}
function resolveArg(flag: string): string | undefined {
const argv = process.argv.slice(2);
const eq = argv.find((entry) => entry.startsWith(`${flag}=`));
if (eq) {
return eq.slice(flag.length + 1);
}
const idx = argv.indexOf(flag);
if (idx >= 0 && idx + 1 < argv.length) {
return argv[idx + 1];
}
return undefined;
}
function hasFlag(flag: string): boolean {
return process.argv.slice(2).includes(flag);
}
function usage(): string {
return (
"Usage: bun scripts/dev/discord-acp-plain-language-smoke.ts " +
"--channel <discord-channel-id> [--token <driver-token> | --driver webhook --bot-token <bot-token> | --driver openclaw] [options]\n\n" +
"Manual live smoke only (not CI). Sends a plain-language instruction in Discord and verifies:\n" +
"1) OpenClaw spawned an ACP thread binding\n" +
"2) agent replied in that bound thread with the expected ACK token\n\n" +
"Options:\n" +
" --channel <id> Parent Discord channel id (required)\n" +
" --driver <token|webhook|openclaw> Driver transport mode (default: token)\n" +
" --token <token> Driver Discord token (required for driver=token)\n" +
" --token-prefix <prefix> Auth prefix for --token (default: Bot)\n" +
" --bot-token <token> Bot token for webhook driver mode\n" +
" --bot-token-prefix <prefix> Auth prefix for --bot-token (default: Bot)\n" +
" --agent <id> Expected ACP agent id (default: codex)\n" +
" --mention <user-id> Mention this user in the instruction (optional)\n" +
" --instruction <text> Custom instruction template (optional)\n" +
" --timeout-ms <n> Total timeout in ms (default: 240000)\n" +
" --poll-ms <n> Poll interval in ms (default: 1500)\n" +
" --thread-bindings-path <p> Override thread-bindings json path\n" +
" --openclaw-bin <path> OpenClaw CLI binary for driver=openclaw (default: openclaw)\n" +
" --json Emit JSON output\n" +
"\n" +
"Environment fallbacks:\n" +
" OPENCLAW_DISCORD_SMOKE_CHANNEL_ID\n" +
" OPENCLAW_DISCORD_SMOKE_DRIVER\n" +
" OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN\n" +
" OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN_PREFIX\n" +
" OPENCLAW_DISCORD_SMOKE_BOT_TOKEN\n" +
" OPENCLAW_DISCORD_SMOKE_BOT_TOKEN_PREFIX\n" +
" OPENCLAW_DISCORD_SMOKE_AGENT\n" +
" OPENCLAW_DISCORD_SMOKE_MENTION_USER_ID\n" +
" OPENCLAW_DISCORD_SMOKE_TIMEOUT_MS\n" +
" OPENCLAW_DISCORD_SMOKE_POLL_MS\n" +
" OPENCLAW_DISCORD_SMOKE_THREAD_BINDINGS_PATH\n" +
" OPENCLAW_DISCORD_SMOKE_OPENCLAW_BIN"
);
}
function parseArgs(): Args {
const channelId = resolveArg("--channel") || process.env.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID || "";
const driverModeRaw =
resolveArg("--driver") || process.env.OPENCLAW_DISCORD_SMOKE_DRIVER || "token";
const normalizedDriverMode = driverModeRaw.trim().toLowerCase();
const driverMode: DriverMode =
normalizedDriverMode === "webhook"
? "webhook"
: normalizedDriverMode === "openclaw"
? "openclaw"
: normalizedDriverMode === "token"
? "token"
: "token";
const driverToken =
resolveArg("--token") || process.env.OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN || "";
const driverTokenPrefix =
resolveArg("--token-prefix") || process.env.OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN_PREFIX || "Bot";
const botToken =
resolveArg("--bot-token") ||
process.env.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN ||
process.env.DISCORD_BOT_TOKEN ||
"";
const botTokenPrefix =
resolveArg("--bot-token-prefix") ||
process.env.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN_PREFIX ||
"Bot";
const targetAgent = resolveArg("--agent") || process.env.OPENCLAW_DISCORD_SMOKE_AGENT || "codex";
const mentionUserId =
resolveArg("--mention") || process.env.OPENCLAW_DISCORD_SMOKE_MENTION_USER_ID || undefined;
const instruction =
resolveArg("--instruction") || process.env.OPENCLAW_DISCORD_SMOKE_INSTRUCTION || undefined;
const timeoutMs = parseNumber(
resolveArg("--timeout-ms") || process.env.OPENCLAW_DISCORD_SMOKE_TIMEOUT_MS,
240_000,
);
const pollMs = parseNumber(
resolveArg("--poll-ms") || process.env.OPENCLAW_DISCORD_SMOKE_POLL_MS,
1_500,
);
const defaultBindingsPath = path.join(resolveStateDir(), "discord", "thread-bindings.json");
const threadBindingsPath =
resolveArg("--thread-bindings-path") ||
process.env.OPENCLAW_DISCORD_SMOKE_THREAD_BINDINGS_PATH ||
defaultBindingsPath;
const openclawBin =
resolveArg("--openclaw-bin") || process.env.OPENCLAW_DISCORD_SMOKE_OPENCLAW_BIN || "openclaw";
const json = hasFlag("--json");
if (!channelId) {
throw new Error(usage());
}
if (driverMode === "token" && !driverToken) {
throw new Error(usage());
}
if (driverMode === "webhook" && !botToken) {
throw new Error(usage());
}
return {
channelId,
driverMode,
driverToken,
driverTokenPrefix,
botToken,
botTokenPrefix,
targetAgent,
timeoutMs,
pollMs,
mentionUserId,
instruction,
threadBindingsPath,
openclawBin,
json,
};
}
async function openclawCliJson<T>(params: { openclawBin: string; args: string[] }): Promise<T> {
const result = await execFileAsync(params.openclawBin, params.args, {
maxBuffer: 8 * 1024 * 1024,
env: process.env,
});
const stdout = (result.stdout || "").trim();
if (!stdout) {
throw new Error(`openclaw ${params.args.join(" ")} returned empty stdout`);
}
return JSON.parse(stdout) as T;
}
async function readMessagesWithOpenclaw(params: {
openclawBin: string;
target: string;
limit: number;
}): Promise<DiscordMessage[]> {
const response = await openclawCliJson<{
payload?: {
messages?: DiscordMessage[];
};
}>({
openclawBin: params.openclawBin,
args: [
"message",
"read",
"--channel",
"discord",
"--target",
params.target,
"--limit",
String(params.limit),
"--json",
],
});
return Array.isArray(response.payload?.messages) ? response.payload.messages : [];
}
function resolveAuthorizationHeader(params: { token: string; tokenPrefix: string }): string {
const token = params.token.trim();
if (!token) {
throw new Error("Missing Discord driver token.");
}
if (token.includes(" ")) {
return token;
}
return `${params.tokenPrefix.trim() || "Bot"} ${token}`;
}
async function discordApi<T>(params: {
method: "GET" | "POST";
path: string;
authHeader: string;
body?: unknown;
retries?: number;
}): Promise<T> {
return requestDiscordJson<T>({
method: params.method,
path: params.path,
headers: {
Authorization: params.authHeader,
"Content-Type": "application/json",
},
body: params.body,
retries: params.retries,
errorPrefix: "Discord API",
});
}
async function discordWebhookApi<T>(params: {
method: "POST" | "DELETE";
webhookId: string;
webhookToken: string;
body?: unknown;
query?: string;
retries?: number;
}): Promise<T> {
const suffix = params.query ? `?${params.query}` : "";
const path = `/webhooks/${encodeURIComponent(params.webhookId)}/${encodeURIComponent(params.webhookToken)}${suffix}`;
return requestDiscordJson<T>({
method: params.method,
path,
headers: {
"Content-Type": "application/json",
},
body: params.body,
retries: params.retries,
errorPrefix: "Discord webhook API",
});
}
async function requestDiscordJson<T>(params: {
method: string;
path: string;
headers: Record<string, string>;
body?: unknown;
retries?: number;
errorPrefix: string;
}): Promise<T> {
const retries = params.retries ?? 6;
for (let attempt = 0; attempt <= retries; attempt += 1) {
const response = await fetch(`${DISCORD_API_BASE}${params.path}`, {
method: params.method,
headers: params.headers,
body: params.body === undefined ? undefined : JSON.stringify(params.body),
});
if (response.status === 429) {
const body = (await response.json().catch(() => ({}))) as { retry_after?: number };
const waitSeconds = typeof body.retry_after === "number" ? body.retry_after : 1;
await sleep(Math.ceil(waitSeconds * 1000));
continue;
}
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(
`${params.errorPrefix} ${params.method} ${params.path} failed: ${response.status} ${response.statusText}${text ? ` :: ${text}` : ""}`,
);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
throw new Error(`${params.errorPrefix} ${params.method} ${params.path} exceeded retry budget.`);
}
async function readThreadBindings(filePath: string): Promise<ThreadBindingRecord[]> {
const raw = await fs.readFile(filePath, "utf8");
const payload = JSON.parse(raw) as ThreadBindingsPayload;
const entries = Object.values(payload.bindings ?? {});
return entries.filter((entry) => Boolean(entry?.threadId && entry?.targetSessionKey));
}
function normalizeBoundAt(record: ThreadBindingRecord): number {
if (typeof record.boundAt === "number" && Number.isFinite(record.boundAt)) {
return record.boundAt;
}
return 0;
}
function resolveCandidateBindings(params: {
entries: ThreadBindingRecord[];
minBoundAt: number;
targetAgent: string;
}): ThreadBindingRecord[] {
const normalizedTargetAgent = params.targetAgent.trim().toLowerCase();
return params.entries
.filter((entry) => {
const targetKind = (entry.targetKind || "").trim().toLowerCase();
if (targetKind !== "acp") {
return false;
}
if (normalizeBoundAt(entry) < params.minBoundAt) {
return false;
}
const agentId = (entry.agentId || "").trim().toLowerCase();
if (normalizedTargetAgent && agentId && agentId !== normalizedTargetAgent) {
return false;
}
return true;
})
.toSorted((a, b) => normalizeBoundAt(b) - normalizeBoundAt(a));
}
function buildInstruction(params: {
smokeId: string;
ackToken: string;
targetAgent: string;
mentionUserId?: string;
template?: string;
}): string {
const mentionPrefix = params.mentionUserId?.trim() ? `<@${params.mentionUserId.trim()}> ` : "";
if (params.template?.trim()) {
return mentionPrefix + params.template.trim();
}
return (
mentionPrefix +
`Manual smoke ${params.smokeId}: Please spawn a ${params.targetAgent} ACP coding agent in a thread for this request, keep it persistent, and in that thread reply with exactly "${params.ackToken}" and nothing else.`
);
}
function toRecentMessageRow(message: DiscordMessage) {
return {
id: message.id,
author: message.author?.username || message.author?.id || "unknown",
bot: Boolean(message.author?.bot),
content: (message.content || "").slice(0, 500),
};
}
async function loadParentRecentMessages(params: {
args: Args;
readAuthHeader: string;
}): Promise<DiscordMessage[]> {
if (params.args.driverMode === "openclaw") {
return await readMessagesWithOpenclaw({
openclawBin: params.args.openclawBin,
target: params.args.channelId,
limit: 20,
});
}
return await discordApi<DiscordMessage[]>({
method: "GET",
path: `/channels/${encodeURIComponent(params.args.channelId)}/messages?limit=20`,
authHeader: params.readAuthHeader,
});
}
function printOutput(params: { json: boolean; payload: SuccessResult | FailureResult }) {
if (params.json) {
writeStdoutJson(params.payload);
return;
}
if (params.payload.ok) {
const success = params.payload;
writeStdoutLine("PASS");
writeStdoutLine(`smokeId: ${success.smokeId}`);
writeStdoutLine(`sentMessageId: ${success.sentMessageId}`);
writeStdoutLine(`threadId: ${success.binding.threadId}`);
writeStdoutLine(`sessionKey: ${success.binding.targetSessionKey}`);
writeStdoutLine(`ackMessageId: ${success.ackMessage.id}`);
writeStdoutLine(
`ackAuthor: ${success.ackMessage.authorUsername || success.ackMessage.authorId || "unknown"}`,
);
return;
}
const failure = params.payload;
writeStderrLine("FAIL");
writeStderrLine(`stage: ${failure.stage}`);
writeStderrLine(`smokeId: ${failure.smokeId}`);
writeStderrLine(`error: ${failure.error}`);
if (failure.diagnostics?.bindingCandidates?.length) {
writeStderrLine("binding candidates:");
for (const candidate of failure.diagnostics.bindingCandidates) {
writeStderrLine(
` thread=${candidate.threadId} kind=${candidate.targetKind || "?"} agent=${candidate.agentId || "?"} boundAt=${candidate.boundAt || 0} session=${candidate.targetSessionKey}`,
);
}
}
if (failure.diagnostics?.parentChannelRecent?.length) {
writeStderrLine("recent parent channel messages:");
for (const row of failure.diagnostics.parentChannelRecent) {
writeStderrLine(` ${row.id} ${row.author}${row.bot ? " [bot]" : ""}: ${row.content || ""}`);
}
}
}
async function run(): Promise<SuccessResult | FailureResult> {
let args: Args;
try {
args = parseArgs();
} catch (err) {
return {
ok: false,
stage: "validation",
smokeId: "n/a",
error: formatErrorMessage(err),
};
}
const smokeId = `acp-smoke-${Date.now()}-${randomUUID().slice(0, 8)}`;
const ackToken = `ACP_SMOKE_ACK_${smokeId}`;
const instruction = buildInstruction({
smokeId,
ackToken,
targetAgent: args.targetAgent,
mentionUserId: args.mentionUserId,
template: args.instruction,
});
let readAuthHeader = "";
let sentMessageId = "";
let setupStage: "discord-api" | "send-message" = "discord-api";
let senderAuthorId: string | undefined;
let webhookForCleanup:
| {
id: string;
token: string;
}
| undefined;
try {
if (args.driverMode === "token") {
const authHeader = resolveAuthorizationHeader({
token: args.driverToken,
tokenPrefix: args.driverTokenPrefix,
});
readAuthHeader = authHeader;
const driverUser = await discordApi<DiscordUser>({
method: "GET",
path: "/users/@me",
authHeader,
});
senderAuthorId = driverUser.id;
setupStage = "send-message";
const sent = await discordApi<DiscordMessage>({
method: "POST",
path: `/channels/${encodeURIComponent(args.channelId)}/messages`,
authHeader,
body: {
content: instruction,
allowed_mentions: args.mentionUserId
? { parse: [], users: [args.mentionUserId] }
: { parse: [] },
},
});
sentMessageId = sent.id;
} else if (args.driverMode === "webhook") {
const botAuthHeader = resolveAuthorizationHeader({
token: args.botToken,
tokenPrefix: args.botTokenPrefix,
});
readAuthHeader = botAuthHeader;
await discordApi<DiscordUser>({
method: "GET",
path: "/users/@me",
authHeader: botAuthHeader,
});
setupStage = "send-message";
const webhook = await discordApi<{ id: string; token?: string | null }>({
method: "POST",
path: `/channels/${encodeURIComponent(args.channelId)}/webhooks`,
authHeader: botAuthHeader,
body: {
name: `openclaw-acp-smoke-${smokeId.slice(-8)}`,
},
});
if (!webhook.id || !webhook.token) {
return {
ok: false,
stage: "send-message",
smokeId,
error:
"Discord webhook creation succeeded but no webhook token was returned; cannot post smoke message.",
};
}
webhookForCleanup = { id: webhook.id, token: webhook.token };
const sent = await discordWebhookApi<DiscordMessage>({
method: "POST",
webhookId: webhook.id,
webhookToken: webhook.token,
query: "wait=true",
body: {
content: instruction,
allowed_mentions: args.mentionUserId
? { parse: [], users: [args.mentionUserId] }
: { parse: [] },
},
});
sentMessageId = sent.id;
senderAuthorId = sent.author?.id;
} else {
setupStage = "send-message";
const sent = await openclawCliJson<{
payload?: {
result?: {
messageId?: string;
};
};
}>({
openclawBin: args.openclawBin,
args: [
"message",
"send",
"--channel",
"discord",
"--target",
args.channelId,
"--message",
instruction,
"--json",
],
});
sentMessageId = sent.payload?.result?.messageId || "";
if (!sentMessageId) {
throw new Error("openclaw message send did not return payload.result.messageId");
}
}
} catch (err) {
return {
ok: false,
stage: setupStage,
smokeId,
error: formatErrorMessage(err),
};
}
const startedAt = Date.now();
const deadline = startedAt + args.timeoutMs;
let winningBinding: ThreadBindingRecord | undefined;
let latestCandidates: ThreadBindingRecord[] = [];
try {
while (Date.now() < deadline && !winningBinding) {
try {
const entries = await readThreadBindings(args.threadBindingsPath);
latestCandidates = resolveCandidateBindings({
entries,
minBoundAt: startedAt - 3_000,
targetAgent: args.targetAgent,
});
winningBinding = latestCandidates[0];
} catch {
// Keep polling; file may not exist yet or may be mid-write.
}
if (!winningBinding) {
await sleep(args.pollMs);
}
}
if (!winningBinding?.threadId || !winningBinding?.targetSessionKey) {
let parentRecent: DiscordMessage[] = [];
try {
parentRecent = await loadParentRecentMessages({ args, readAuthHeader });
} catch {
// Best effort diagnostics only.
}
return {
ok: false,
stage: "wait-binding",
smokeId,
error: `Timed out waiting for new ACP thread binding (path: ${args.threadBindingsPath}).`,
diagnostics: {
bindingCandidates: latestCandidates.slice(0, 6).map((entry) => ({
threadId: entry.threadId || "",
targetSessionKey: entry.targetSessionKey || "",
targetKind: entry.targetKind,
agentId: entry.agentId,
boundAt: entry.boundAt,
})),
parentChannelRecent: parentRecent.map(toRecentMessageRow),
},
};
}
const threadId = winningBinding.threadId;
let ackMessage: DiscordMessage | undefined;
while (Date.now() < deadline && !ackMessage) {
try {
const threadMessages =
args.driverMode === "openclaw"
? await readMessagesWithOpenclaw({
openclawBin: args.openclawBin,
target: threadId,
limit: 50,
})
: await discordApi<DiscordMessage[]>({
method: "GET",
path: `/channels/${encodeURIComponent(threadId)}/messages?limit=50`,
authHeader: readAuthHeader,
});
ackMessage = threadMessages.find((message) => {
const content = message.content || "";
if (!content.includes(ackToken)) {
return false;
}
const authorId = message.author?.id || "";
return !senderAuthorId || authorId !== senderAuthorId;
});
} catch {
// Keep polling; thread can appear before read permissions settle.
}
if (!ackMessage) {
await sleep(args.pollMs);
}
}
if (!ackMessage) {
let parentRecent: DiscordMessage[] = [];
try {
parentRecent = await loadParentRecentMessages({ args, readAuthHeader });
} catch {
// Best effort diagnostics only.
}
return {
ok: false,
stage: "wait-ack",
smokeId,
error: `Thread bound (${threadId}) but timed out waiting for ACK token "${ackToken}" from OpenClaw.`,
diagnostics: {
bindingCandidates: [
{
threadId: winningBinding.threadId || "",
targetSessionKey: winningBinding.targetSessionKey || "",
targetKind: winningBinding.targetKind,
agentId: winningBinding.agentId,
boundAt: winningBinding.boundAt,
},
],
parentChannelRecent: parentRecent.map(toRecentMessageRow),
},
};
}
return {
ok: true,
smokeId,
ackToken,
sentMessageId,
binding: {
threadId,
targetSessionKey: winningBinding.targetSessionKey,
targetKind: winningBinding.targetKind || "acp",
agentId: winningBinding.agentId || args.targetAgent,
boundAt: normalizeBoundAt(winningBinding),
accountId: winningBinding.accountId,
channelId: winningBinding.channelId,
},
ackMessage: {
id: ackMessage.id,
authorId: ackMessage.author?.id,
authorUsername: ackMessage.author?.username,
timestamp: ackMessage.timestamp,
content: ackMessage.content,
},
};
} finally {
if (webhookForCleanup) {
await discordWebhookApi<void>({
method: "DELETE",
webhookId: webhookForCleanup.id,
webhookToken: webhookForCleanup.token,
}).catch(() => {
// Best-effort cleanup only.
});
}
}
}
if (hasFlag("--help") || hasFlag("-h")) {
writeStdoutLine(usage());
process.exit(0);
}
const result = await run().catch(
(err): FailureResult => ({
ok: false,
stage: "unexpected",
smokeId: "n/a",
error: formatErrorMessage(err),
}),
);
printOutput({
json: hasFlag("--json"),
payload: result,
});
process.exit(result.ok ? 0 : 1);

View file

@ -0,0 +1,78 @@
import { createArgReader, createGatewayWsClient, resolveGatewayUrl } from "./gateway-ws-client.ts";
function writeStdoutLine(message: string): void {
process.stdout.write(`${message}\n`);
}
function writeStderrLine(message: string): void {
process.stderr.write(`${message}\n`);
}
const { get: getArg } = createArgReader();
const urlRaw = getArg("--url") ?? process.env.OPENCLAW_GATEWAY_URL;
const token = getArg("--token") ?? process.env.OPENCLAW_GATEWAY_TOKEN;
if (!urlRaw || !token) {
writeStderrLine(
"Usage: bun scripts/dev/gateway-smoke.ts --url <wss://host[:port]> --token <gateway.auth.token>\n" +
"Or set env: OPENCLAW_GATEWAY_URL / OPENCLAW_GATEWAY_TOKEN",
);
process.exit(1);
}
async function main() {
const url = resolveGatewayUrl(urlRaw);
const { request, waitOpen, close } = createGatewayWsClient({
url: url.toString(),
onEvent: (evt) => {
// Ignore noisy connect handshakes.
if (evt.event === "connect.challenge") {
return;
}
},
});
await waitOpen();
// Match iOS "operator" session defaults: token auth, no device identity.
const connectRes = await request("connect", {
minProtocol: 3,
maxProtocol: 3,
client: {
id: "openclaw-ios",
displayName: "openclaw gateway smoke test",
version: "dev",
platform: "dev",
mode: "ui",
instanceId: "openclaw-dev-smoke",
},
locale: "en-US",
userAgent: "gateway-smoke",
role: "operator",
scopes: ["operator.read", "operator.write", "operator.admin"],
caps: [],
auth: { token },
});
if (!connectRes.ok) {
writeStderrLine(`connect failed: ${String(connectRes.error)}`);
process.exit(2);
}
const healthRes = await request("health");
if (!healthRes.ok) {
writeStderrLine(`health failed: ${String(healthRes.error)}`);
process.exit(3);
}
const historyRes = await request("chat.history", { sessionKey: "main" }, 15000);
if (!historyRes.ok) {
writeStderrLine(`chat.history failed: ${String(historyRes.error)}`);
process.exit(4);
}
writeStdoutLine("ok: connected + health + chat.history");
close();
}
await main();

View file

@ -0,0 +1,132 @@
import { randomUUID } from "node:crypto";
import WebSocket from "ws";
export type GatewayReqFrame = { type: "req"; id: string; method: string; params?: unknown };
export type GatewayResFrame = {
type: "res";
id: string;
ok: boolean;
payload?: unknown;
error?: unknown;
};
export type GatewayEventFrame = { type: "event"; event: string; seq?: number; payload?: unknown };
export type GatewayFrame =
| GatewayReqFrame
| GatewayResFrame
| GatewayEventFrame
| { type: string; [key: string]: unknown };
export function createArgReader(argv = process.argv.slice(2)) {
const get = (flag: string) => {
const idx = argv.indexOf(flag);
if (idx !== -1 && idx + 1 < argv.length) {
return argv[idx + 1];
}
return undefined;
};
const has = (flag: string) => argv.includes(flag);
return { argv, get, has };
}
export function resolveGatewayUrl(urlRaw: string): URL {
const url = new URL(urlRaw.includes("://") ? urlRaw : `wss://${urlRaw}`);
if (!url.port) {
url.port = url.protocol === "wss:" ? "443" : "80";
}
return url;
}
function toText(data: WebSocket.RawData): string {
if (typeof data === "string") {
return data;
}
if (data instanceof ArrayBuffer) {
return Buffer.from(data).toString("utf8");
}
if (Array.isArray(data)) {
return Buffer.concat(data.map((chunk) => Buffer.from(chunk))).toString("utf8");
}
return Buffer.from(data as Buffer).toString("utf8");
}
export function createGatewayWsClient(params: {
url: string;
handshakeTimeoutMs?: number;
openTimeoutMs?: number;
onEvent?: (evt: GatewayEventFrame) => void;
}) {
const ws = new WebSocket(params.url, { handshakeTimeout: params.handshakeTimeoutMs ?? 8000 });
const pending = new Map<
string,
{
resolve: (res: GatewayResFrame) => void;
reject: (err: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}
>();
const request = (method: string, paramsObj?: unknown, timeoutMs = 12_000) =>
new Promise<GatewayResFrame>((resolve, reject) => {
const id = randomUUID();
const frame: GatewayReqFrame = { type: "req", id, method, params: paramsObj };
const timeout = setTimeout(() => {
pending.delete(id);
reject(new Error(`timeout waiting for ${method}`));
}, timeoutMs);
pending.set(id, { resolve, reject, timeout });
ws.send(JSON.stringify(frame));
});
const waitOpen = () =>
new Promise<void>((resolve, reject) => {
const t = setTimeout(
() => reject(new Error("ws open timeout")),
params.openTimeoutMs ?? 8000,
);
ws.once("open", () => {
clearTimeout(t);
resolve();
});
ws.once("error", (err) => {
clearTimeout(t);
reject(err instanceof Error ? err : new Error(String(err)));
});
});
ws.on("message", (data) => {
const text = toText(data);
let frame: GatewayFrame | null = null;
try {
frame = JSON.parse(text) as GatewayFrame;
} catch {
return;
}
if (!frame || typeof frame !== "object" || !("type" in frame)) {
return;
}
if (frame.type === "res") {
const res = frame as GatewayResFrame;
const waiter = pending.get(res.id);
if (waiter) {
pending.delete(res.id);
clearTimeout(waiter.timeout);
waiter.resolve(res);
}
return;
}
if (frame.type === "event") {
const evt = frame as GatewayEventFrame;
params.onEvent?.(evt);
}
});
const close = () => {
for (const waiter of pending.values()) {
clearTimeout(waiter.timeout);
}
pending.clear();
ws.close();
};
return { ws, request, waitOpen, close };
}

View file

@ -0,0 +1,279 @@
import { createArgReader, createGatewayWsClient, resolveGatewayUrl } from "./gateway-ws-client.ts";
function writeStdoutLine(message = ""): void {
process.stdout.write(`${message}\n`);
}
function writeStdoutJson(value: unknown): void {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
function writeStderrLine(message: string): void {
process.stderr.write(`${message}\n`);
}
type NodeListPayload = {
ts?: number;
nodes?: Array<{
nodeId: string;
displayName?: string;
platform?: string;
connected?: boolean;
paired?: boolean;
commands?: string[];
permissions?: unknown;
}>;
};
type NodeListNode = NonNullable<NodeListPayload["nodes"]>[number];
const { get: getArg, has: hasFlag } = createArgReader();
const urlRaw = getArg("--url") ?? process.env.OPENCLAW_GATEWAY_URL;
const token = getArg("--token") ?? process.env.OPENCLAW_GATEWAY_TOKEN;
const nodeHint = getArg("--node");
const dangerous = hasFlag("--dangerous") || process.env.OPENCLAW_RUN_DANGEROUS === "1";
const jsonOut = hasFlag("--json");
if (!urlRaw || !token) {
writeStderrLine(
"Usage: bun scripts/dev/ios-node-e2e.ts --url <wss://host[:port]> --token <gateway.auth.token> [--node <id|name-substring>] [--dangerous] [--json]\n" +
"Or set env: OPENCLAW_GATEWAY_URL / OPENCLAW_GATEWAY_TOKEN",
);
process.exit(1);
}
const url = resolveGatewayUrl(urlRaw);
const isoNow = () => new Date().toISOString();
const isoMinusMs = (ms: number) => new Date(Date.now() - ms).toISOString();
type TestCase = {
id: string;
command: string;
params?: unknown;
timeoutMs?: number;
dangerous?: boolean;
};
function formatErr(err: unknown): string {
if (!err) {
return "error";
}
if (typeof err === "string") {
return err;
}
if (err instanceof Error) {
return err.message || String(err);
}
try {
return JSON.stringify(err);
} catch {
return Object.prototype.toString.call(err);
}
}
function pickIosNode(list: NodeListPayload, hint?: string): NodeListNode | null {
const nodes = (list.nodes ?? []).filter((n) => n && n.connected);
const ios = nodes.filter((n) => (n.platform ?? "").toLowerCase().includes("ios"));
if (ios.length === 0) {
return null;
}
if (!hint) {
return ios[0] ?? null;
}
const h = hint.toLowerCase();
return (
ios.find((n) => n.nodeId.toLowerCase() === h) ??
ios.find((n) => (n.displayName ?? "").toLowerCase().includes(h)) ??
ios.find((n) => n.nodeId.toLowerCase().includes(h)) ??
ios[0] ??
null
);
}
async function main() {
const { request, waitOpen, close } = createGatewayWsClient({ url: url.toString() });
await waitOpen();
const connectRes = await request("connect", {
minProtocol: 3,
maxProtocol: 3,
client: {
id: "cli",
displayName: "openclaw ios node e2e",
version: "dev",
platform: "dev",
mode: "cli",
instanceId: "openclaw-dev-ios-node-e2e",
},
locale: "en-US",
userAgent: "ios-node-e2e",
role: "operator",
scopes: ["operator.read", "operator.write", "operator.admin"],
caps: [],
auth: { token },
});
if (!connectRes.ok) {
writeStderrLine(`connect failed: ${String(connectRes.error)}`);
close();
process.exit(2);
}
const healthRes = await request("health");
if (!healthRes.ok) {
writeStderrLine(`health failed: ${String(healthRes.error)}`);
close();
process.exit(3);
}
const nodesRes = await request("node.list");
if (!nodesRes.ok) {
writeStderrLine(`node.list failed: ${String(nodesRes.error)}`);
close();
process.exit(4);
}
const listPayload = (nodesRes.payload ?? {}) as NodeListPayload;
let node = pickIosNode(listPayload, nodeHint);
if (!node) {
const waitSeconds = Number.parseInt(getArg("--wait-seconds") ?? "25", 10);
const deadline = Date.now() + Math.max(1, waitSeconds) * 1000;
while (!node && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 1000));
const res = await request("node.list").catch(() => null);
if (!res?.ok) {
continue;
}
node = pickIosNode((res.payload ?? {}) as NodeListPayload, nodeHint);
}
}
if (!node) {
writeStderrLine("No connected iOS nodes found. (Is the iOS app connected to the gateway?)");
close();
process.exit(5);
}
const tests: TestCase[] = [
{ id: "device.info", command: "device.info" },
{ id: "device.status", command: "device.status" },
{
id: "system.notify",
command: "system.notify",
params: { title: "OpenClaw E2E", body: `ios-node-e2e @ ${isoNow()}`, delivery: "system" },
},
{
id: "contacts.search",
command: "contacts.search",
params: { query: null, limit: 5 },
},
{
id: "calendar.events",
command: "calendar.events",
params: { startISO: isoMinusMs(6 * 60 * 60 * 1000), endISO: isoNow(), limit: 10 },
},
{
id: "reminders.list",
command: "reminders.list",
params: { status: "incomplete", limit: 10 },
},
{
id: "motion.pedometer",
command: "motion.pedometer",
params: { startISO: isoMinusMs(60 * 60 * 1000), endISO: isoNow() },
},
{
id: "photos.latest",
command: "photos.latest",
params: { limit: 1, maxWidth: 512, quality: 0.7 },
},
{
id: "camera.snap",
command: "camera.snap",
params: { facing: "back", maxWidth: 768, quality: 0.7, format: "jpeg" },
dangerous: true,
timeoutMs: 20_000,
},
{
id: "screen.record",
command: "screen.record",
params: { durationMs: 2_000, fps: 15, includeAudio: false },
dangerous: true,
timeoutMs: 30_000,
},
];
const run = tests.filter((t) => dangerous || !t.dangerous);
const results: Array<{
id: string;
ok: boolean;
error?: unknown;
payload?: unknown;
}> = [];
for (const t of run) {
const invokeRes = await request(
"node.invoke",
{
nodeId: node.nodeId,
command: t.command,
params: t.params,
timeoutMs: t.timeoutMs ?? 12_000,
idempotencyKey: randomUUID(),
},
(t.timeoutMs ?? 12_000) + 2_000,
).catch((err) => {
results.push({ id: t.id, ok: false, error: formatErr(err) });
return null;
});
if (!invokeRes) {
continue;
}
if (!invokeRes.ok) {
results.push({ id: t.id, ok: false, error: invokeRes.error });
continue;
}
results.push({ id: t.id, ok: true, payload: invokeRes.payload });
}
if (jsonOut) {
writeStdoutJson({
gateway: url.toString(),
node: {
nodeId: node.nodeId,
displayName: node.displayName,
platform: node.platform,
},
dangerous,
results,
});
} else {
const pad = (s: string, n: number) => (s.length >= n ? s : s + " ".repeat(n - s.length));
const rows = results.map((r) => ({
cmd: r.id,
ok: r.ok ? "ok" : "fail",
note: r.ok ? "" : formatErr(r.error ?? "error"),
}));
const width = Math.min(64, Math.max(12, ...rows.map((r) => r.cmd.length)));
writeStdoutLine(`node: ${node.displayName ?? node.nodeId} (${node.platform ?? "unknown"})`);
writeStdoutLine(`dangerous: ${dangerous ? "on" : "off"}`);
writeStdoutLine();
for (const r of rows) {
writeStdoutLine(`${pad(r.cmd, width)} ${pad(r.ok, 4)} ${r.note}`);
}
}
const failed = results.filter((r) => !r.ok);
close();
if (failed.length > 0) {
process.exit(10);
}
}
await main();

View file

@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
DEVICE_UDID="${1:-00008130-000630CE0146001C}"
BUNDLE_ID="${2:-ai.openclaw.ios.dev.mariano.test}"
DEST="${3:-/tmp/openclaw-gateway.log}"
xcrun devicectl device copy from \
--device "$DEVICE_UDID" \
--domain-type appDataContainer \
--domain-identifier "$BUNDLE_ID" \
--source Documents/openclaw-gateway.log \
--destination "$DEST" >/dev/null
echo "Pulled to: $DEST"
tail -n 200 "$DEST"

View file

@ -0,0 +1,67 @@
import { sendMessageTelegram } from "../../extensions/telegram/runtime-api.js";
import { loadConfig } from "../../src/config/config.js";
import { matchPluginCommand, executePluginCommand } from "../../src/plugins/commands.js";
import { loadOpenClawPlugins } from "../../src/plugins/loader.js";
function writeStdoutLine(...parts: string[]): void {
process.stdout.write(`${parts.join(" ")}\n`);
}
function writeStderrLine(message: string): void {
process.stderr.write(`${message}\n`);
}
const args = process.argv.slice(2);
const getArg = (flag: string, short?: string) => {
const idx = args.indexOf(flag);
if (idx !== -1 && idx + 1 < args.length) {
return args[idx + 1];
}
if (short) {
const sidx = args.indexOf(short);
if (sidx !== -1 && sidx + 1 < args.length) {
return args[sidx + 1];
}
}
return undefined;
};
const chatId = getArg("--chat", "-c");
const accountId = getArg("--account", "-a");
if (!chatId) {
writeStderrLine(
"Usage: bun scripts/dev/test-device-pair-telegram.ts --chat <telegram-chat-id> [--account <accountId>]",
);
process.exit(1);
}
const cfg = loadConfig();
loadOpenClawPlugins({ config: cfg });
const match = matchPluginCommand("/pair");
if (!match) {
writeStderrLine("/pair plugin command not registered.");
process.exit(1);
}
const result = await executePluginCommand({
command: match.command,
args: match.args,
senderId: chatId,
channel: "telegram",
channelId: "telegram",
isAuthorizedSender: true,
commandBody: "/pair",
config: cfg,
from: `telegram:${chatId}`,
to: `telegram:${chatId}`,
accountId: accountId,
});
if (result.text) {
await sendMessageTelegram(chatId, result.text, {
accountId: accountId,
});
}
writeStdoutLine("Sent split /pair messages to", chatId, accountId ? `(${accountId})` : "");

View file

@ -0,0 +1,34 @@
# syntax=docker/dockerfile:1.7
FROM node:24-bookworm-slim@sha256:b4687aef2571c632a1953695ce4d61d6462a7eda471fe6e272eebf0418f276ba
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN --mount=type=cache,id=openclaw-cleanup-smoke-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-cleanup-smoke-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --no-install-recommends \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
git
WORKDIR /repo
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY openclaw.mjs ./
COPY ui/package.json ./ui/package.json
COPY packages ./packages
COPY extensions ./extensions
COPY patches ./patches
COPY scripts/postinstall-bundled-plugins.mjs scripts/preinstall-package-manager-warning.mjs scripts/npm-runner.mjs scripts/windows-cmd-helpers.mjs ./scripts/
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
corepack enable \
&& if ! pnpm install --frozen-lockfile >/tmp/openclaw-cleanup-pnpm-install.log 2>&1; then \
cat /tmp/openclaw-cleanup-pnpm-install.log; \
exit 1; \
fi
COPY . .
COPY --chmod=755 scripts/docker/cleanup-smoke/run.sh /usr/local/bin/openclaw-cleanup-smoke
ENTRYPOINT ["/usr/local/bin/openclaw-cleanup-smoke"]

View file

@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
cd /repo
export OPENCLAW_STATE_DIR="/tmp/openclaw-test"
export OPENCLAW_CONFIG_PATH="${OPENCLAW_STATE_DIR}/openclaw.json"
echo "==> Build"
if ! pnpm build >/tmp/openclaw-cleanup-build.log 2>&1; then
cat /tmp/openclaw-cleanup-build.log
exit 1
fi
echo "==> Seed state"
mkdir -p "${OPENCLAW_STATE_DIR}/credentials"
mkdir -p "${OPENCLAW_STATE_DIR}/agents/main/sessions"
echo '{}' >"${OPENCLAW_CONFIG_PATH}"
echo 'creds' >"${OPENCLAW_STATE_DIR}/credentials/marker.txt"
echo 'session' >"${OPENCLAW_STATE_DIR}/agents/main/sessions/sessions.json"
echo "==> Reset (config+creds+sessions)"
if ! pnpm openclaw reset --scope config+creds+sessions --yes --non-interactive >/tmp/openclaw-cleanup-reset.log 2>&1; then
cat /tmp/openclaw-cleanup-reset.log
exit 1
fi
test ! -f "${OPENCLAW_CONFIG_PATH}"
test ! -d "${OPENCLAW_STATE_DIR}/credentials"
test ! -d "${OPENCLAW_STATE_DIR}/agents/main/sessions"
echo "==> Recreate minimal config"
mkdir -p "${OPENCLAW_STATE_DIR}/credentials"
echo '{}' >"${OPENCLAW_CONFIG_PATH}"
echo "==> Uninstall (state only)"
if ! pnpm openclaw uninstall --state --yes --non-interactive >/tmp/openclaw-cleanup-uninstall.log 2>&1; then
cat /tmp/openclaw-cleanup-uninstall.log
exit 1
fi
test ! -d "${OPENCLAW_STATE_DIR}"
echo "OK"

View file

@ -0,0 +1,53 @@
#!/usr/bin/env bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./version-parse.sh
source "$SCRIPT_DIR/version-parse.sh"
verify_installed_cli() {
local package_name="$1"
local expected_version="$2"
local cli_name="$package_name"
local cmd_path=""
local entry_path=""
local npm_root=""
local installed_version=""
cmd_path="$(command -v "$cli_name" || true)"
if [[ -z "$cmd_path" && -x "$HOME/.npm-global/bin/$package_name" ]]; then
cmd_path="$HOME/.npm-global/bin/$package_name"
fi
if [[ -z "$cmd_path" ]]; then
npm_root="$(quiet_npm root -g 2>/dev/null || true)"
if [[ -n "$npm_root" && -f "$npm_root/$package_name/dist/entry.js" ]]; then
entry_path="$npm_root/$package_name/dist/entry.js"
fi
fi
if [[ -z "$cmd_path" && -z "$entry_path" ]]; then
echo "ERROR: $package_name is not on PATH" >&2
return 1
fi
if [[ -n "$cmd_path" ]]; then
installed_version="$("$cmd_path" --version 2>/dev/null | head -n 1 | tr -d '\r')"
else
installed_version="$(node "$entry_path" --version 2>/dev/null | head -n 1 | tr -d '\r')"
fi
installed_version="$(extract_openclaw_semver "$installed_version")"
echo "cli=$cli_name installed=$installed_version expected=$expected_version"
if [[ "$installed_version" != "$expected_version" ]]; then
echo "ERROR: expected ${cli_name}@${expected_version}, got ${cli_name}@${installed_version}" >&2
return 1
fi
echo "==> Sanity: CLI runs"
if [[ -n "$cmd_path" ]]; then
"$cmd_path" --help >/dev/null
else
node "$entry_path" --help >/dev/null
fi
}

View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
extract_openclaw_semver() {
local raw="${1:-}"
local parsed=""
parsed="$(
printf '%s\n' "$raw" \
| tr -d '\r' \
| grep -Eo 'v?[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?(\+[0-9A-Za-z.-]+)?' \
| head -n 1 \
|| true
)"
printf '%s' "${parsed#v}"
}
quiet_npm() {
npm \
--loglevel=error \
--logs-max=0 \
--no-update-notifier \
--no-fund \
--no-audit \
--no-progress \
"$@"
}

View file

@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1.7
FROM node:24-bookworm-slim@sha256:b4687aef2571c632a1953695ce4d61d6462a7eda471fe6e272eebf0418f276ba
RUN --mount=type=cache,id=openclaw-install-sh-e2e-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-install-sh-e2e-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --no-install-recommends \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git
COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh
COPY --chmod=755 install-sh-e2e/run.sh /usr/local/bin/openclaw-install-e2e
RUN useradd --create-home --shell /bin/bash appuser
USER appuser
ENTRYPOINT ["/usr/local/bin/openclaw-install-e2e"]

View file

@ -0,0 +1,607 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VERIFY_HELPER_PATH="/usr/local/install-sh-common/version-parse.sh"
if [[ ! -f "$VERIFY_HELPER_PATH" ]]; then
VERIFY_HELPER_PATH="${SCRIPT_DIR}/../install-sh-common/version-parse.sh"
fi
# shellcheck source=../install-sh-common/version-parse.sh
source "$VERIFY_HELPER_PATH"
INSTALL_URL="${OPENCLAW_INSTALL_URL:-https://openclaw.bot/install.sh}"
MODELS_MODE="${OPENCLAW_E2E_MODELS:-both}" # both|openai|anthropic
INSTALL_TAG="${OPENCLAW_INSTALL_TAG:-latest}"
E2E_PREVIOUS_VERSION="${OPENCLAW_INSTALL_E2E_PREVIOUS:-}"
SKIP_PREVIOUS="${OPENCLAW_INSTALL_E2E_SKIP_PREVIOUS:-0}"
OPENAI_API_KEY="${OPENAI_API_KEY:-}"
ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}"
ANTHROPIC_API_TOKEN="${ANTHROPIC_API_TOKEN:-}"
# This image runs as a non-root user, so seed a user-local npm prefix before we
# preinstall an older global version to exercise the upgrade path.
export NPM_CONFIG_PREFIX="${NPM_CONFIG_PREFIX:-$HOME/.npm-global}"
mkdir -p "$NPM_CONFIG_PREFIX"
export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"
if [[ "$MODELS_MODE" != "both" && "$MODELS_MODE" != "openai" && "$MODELS_MODE" != "anthropic" ]]; then
echo "ERROR: OPENCLAW_E2E_MODELS must be one of: both|openai|anthropic" >&2
exit 2
fi
if [[ "$MODELS_MODE" == "both" ]]; then
if [[ -z "$OPENAI_API_KEY" ]]; then
echo "ERROR: OPENCLAW_E2E_MODELS=both requires OPENAI_API_KEY." >&2
exit 2
fi
if [[ -z "$ANTHROPIC_API_TOKEN" && -z "$ANTHROPIC_API_KEY" ]]; then
echo "ERROR: OPENCLAW_E2E_MODELS=both requires ANTHROPIC_API_TOKEN or ANTHROPIC_API_KEY." >&2
exit 2
fi
elif [[ "$MODELS_MODE" == "openai" && -z "$OPENAI_API_KEY" ]]; then
echo "ERROR: OPENCLAW_E2E_MODELS=openai requires OPENAI_API_KEY." >&2
exit 2
elif [[ "$MODELS_MODE" == "anthropic" && -z "$ANTHROPIC_API_TOKEN" && -z "$ANTHROPIC_API_KEY" ]]; then
echo "ERROR: OPENCLAW_E2E_MODELS=anthropic requires ANTHROPIC_API_TOKEN or ANTHROPIC_API_KEY." >&2
exit 2
fi
echo "==> Resolve npm versions"
EXPECTED_VERSION="$(quiet_npm view "openclaw@${INSTALL_TAG}" version)"
if [[ -z "$EXPECTED_VERSION" || "$EXPECTED_VERSION" == "undefined" || "$EXPECTED_VERSION" == "null" ]]; then
echo "ERROR: unable to resolve openclaw@${INSTALL_TAG} version" >&2
exit 2
fi
if [[ -n "$E2E_PREVIOUS_VERSION" ]]; then
PREVIOUS_VERSION="$E2E_PREVIOUS_VERSION"
else
PREVIOUS_VERSION="$(VERSIONS_JSON="$(quiet_npm view openclaw versions --json)" node - <<'NODE'
const versions = JSON.parse(process.env.VERSIONS_JSON || "[]");
if (!Array.isArray(versions) || versions.length === 0) process.exit(1);
process.stdout.write(versions.length >= 2 ? versions[versions.length - 2] : versions[0]);
NODE
)"
fi
echo "expected=$EXPECTED_VERSION previous=$PREVIOUS_VERSION"
if [[ "$SKIP_PREVIOUS" == "1" ]]; then
echo "==> Skip preinstall previous (OPENCLAW_INSTALL_E2E_SKIP_PREVIOUS=1)"
else
echo "==> Preinstall previous (forces installer upgrade path; avoids read() prompt)"
quiet_npm install -g "openclaw@${PREVIOUS_VERSION}"
fi
echo "==> Run official installer one-liner"
if [[ "$INSTALL_TAG" == "beta" ]]; then
OPENCLAW_BETA=1 curl -fsSL "$INSTALL_URL" | bash
elif [[ "$INSTALL_TAG" != "latest" ]]; then
OPENCLAW_VERSION="$INSTALL_TAG" curl -fsSL "$INSTALL_URL" | bash
else
curl -fsSL "$INSTALL_URL" | bash
fi
echo "==> Verify installed version"
INSTALLED_VERSION="$(openclaw --version 2>/dev/null | head -n 1 | tr -d '\r')"
INSTALLED_VERSION="$(extract_openclaw_semver "$INSTALLED_VERSION")"
echo "installed=$INSTALLED_VERSION expected=$EXPECTED_VERSION"
if [[ "$INSTALLED_VERSION" != "$EXPECTED_VERSION" ]]; then
echo "ERROR: expected openclaw@$EXPECTED_VERSION, got openclaw@$INSTALLED_VERSION" >&2
exit 1
fi
set_image_model() {
local profile="$1"
shift
local candidate
for candidate in "$@"; do
if openclaw --profile "$profile" models set-image "$candidate" >/dev/null 2>&1; then
echo "$candidate"
return 0
fi
done
echo "ERROR: could not set an image model (tried: $*)" >&2
return 1
}
set_agent_model() {
local profile="$1"
local candidate
shift
for candidate in "$@"; do
if openclaw --profile "$profile" models set "$candidate" >/dev/null 2>&1; then
echo "$candidate"
return 0
fi
done
echo "ERROR: could not set agent model (tried: $*)" >&2
return 1
}
write_png_lr_rg() {
local out="$1"
node - <<'NODE' "$out"
const fs = require("node:fs");
const zlib = require("node:zlib");
const out = process.argv[2];
const width = 96;
const height = 64;
const crcTable = (() => {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
table[i] = c >>> 0;
}
return table;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) c = crcTable[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const typeBuf = Buffer.from(type, "ascii");
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const crcBuf = Buffer.alloc(4);
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
return Buffer.concat([len, typeBuf, data, crcBuf]);
}
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // color type: truecolor
ihdr[10] = 0; // compression
ihdr[11] = 0; // filter
ihdr[12] = 0; // interlace
const rows = [];
for (let y = 0; y < height; y++) {
const row = Buffer.alloc(1 + width * 3);
row[0] = 0; // filter: none
for (let x = 0; x < width; x++) {
const i = 1 + x * 3;
const left = x < width / 2;
row[i + 0] = left ? 255 : 0;
row[i + 1] = left ? 0 : 255;
row[i + 2] = 0;
}
rows.push(row);
}
const raw = Buffer.concat(rows);
const idat = zlib.deflateSync(raw, { level: 9 });
const png = Buffer.concat([
sig,
chunk("IHDR", ihdr),
chunk("IDAT", idat),
chunk("IEND", Buffer.alloc(0)),
]);
fs.writeFileSync(out, png);
NODE
}
run_agent_turn() {
local profile="$1"
local session_id="$2"
local prompt="$3"
local out_json="$4"
# Installer E2E validates install + onboard + embedded agent tooling. It does
# not need a paired Gateway control-plane hop, which is flaky/non-deterministic
# in the isolated container and already covered by gateway-specific lanes.
openclaw --profile "$profile" agent \
--local \
--session-id "$session_id" \
--message "$prompt" \
--thinking off \
--json >"$out_json" 2>&1
node - <<'NODE' "$out_json"
const fs = require("node:fs");
const path = process.argv[2];
const raw = fs.readFileSync(path, "utf8");
function extractTrailingJsonObject(input) {
const trimmed = input.trim();
if (!trimmed) {
throw new Error("agent output was empty");
}
try {
return JSON.parse(trimmed);
} catch {
// Some local runs emit stderr diagnostics before the final JSON payload.
// Walk backward and keep the last parseable top-level object.
for (let index = trimmed.lastIndexOf("{"); index >= 0; index = trimmed.lastIndexOf("{", index - 1)) {
const candidate = trimmed.slice(index);
try {
return JSON.parse(candidate);
} catch {
// keep scanning
}
}
throw new Error(`could not extract JSON payload from agent output:\n${trimmed}`);
}
}
const parsed = extractTrailingJsonObject(raw);
fs.writeFileSync(path, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
NODE
}
assert_agent_json_has_text() {
local path="$1"
node - <<'NODE' "$path"
const fs = require("node:fs");
const p = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const payloads =
Array.isArray(p?.result?.payloads) ? p.result.payloads :
Array.isArray(p?.payloads) ? p.payloads :
[];
const texts = payloads.map((x) => String(x?.text ?? "").trim()).filter(Boolean);
if (texts.length === 0) process.exit(1);
NODE
}
assert_agent_json_ok() {
local json_path="$1"
local expect_provider="$2"
node - <<'NODE' "$json_path" "$expect_provider"
const fs = require("node:fs");
const jsonPath = process.argv[2];
const expectProvider = process.argv[3];
const p = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
if (typeof p?.status === "string" && p.status !== "ok" && p.status !== "accepted") {
console.error(`ERROR: gateway status=${p.status}`);
process.exit(1);
}
const result = p?.result ?? p;
const payloads = Array.isArray(result?.payloads) ? result.payloads : [];
const anyError = payloads.some((pl) => pl && pl.isError === true);
const combinedText = payloads.map((pl) => String(pl?.text ?? "")).filter(Boolean).join("\n").trim();
if (anyError) {
console.error(`ERROR: agent returned error payload: ${combinedText}`);
process.exit(1);
}
if (/rate_limit_error/i.test(combinedText) || /^429\\b/.test(combinedText)) {
console.error(`ERROR: agent rate limited: ${combinedText}`);
process.exit(1);
}
const meta = result?.meta;
const provider =
(typeof meta?.agentMeta?.provider === "string" && meta.agentMeta.provider.trim()) ||
(typeof meta?.provider === "string" && meta.provider.trim()) ||
"";
if (expectProvider && provider && provider !== expectProvider) {
console.error(`ERROR: expected provider=${expectProvider}, got provider=${provider}`);
process.exit(1);
}
NODE
}
extract_matching_text() {
local path="$1"
local expected="$2"
node - <<'NODE' "$path" "$expected"
const fs = require("node:fs");
const p = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const expected = String(process.argv[3] ?? "");
const payloads =
Array.isArray(p?.result?.payloads) ? p.result.payloads :
Array.isArray(p?.payloads) ? p.payloads :
[];
const texts = payloads.map((x) => String(x?.text ?? "").trim()).filter(Boolean);
const match = texts.find((text) => text === expected);
process.stdout.write(match ?? texts[0] ?? "");
NODE
}
assert_session_used_tools() {
local jsonl="$1"
shift
node - <<'NODE' "$jsonl" "$@"
const fs = require("node:fs");
const jsonl = process.argv[2];
const required = new Set(process.argv.slice(3));
const raw = fs.readFileSync(jsonl, "utf8");
const lines = raw.split("\n").map((l) => l.trim()).filter(Boolean);
const seen = new Set();
const toolTypes = new Set([
"tool_use",
"tool_result",
"tool",
"tool-call",
"tool_call",
"tooluse",
"tool-use",
"toolresult",
"tool-result",
]);
function walk(node, parent) {
if (!node) return;
if (Array.isArray(node)) {
for (const item of node) walk(item, node);
return;
}
if (typeof node !== "object") return;
const obj = node;
const t = typeof obj.type === "string" ? obj.type : null;
if (t && (toolTypes.has(t) || /tool/i.test(t))) {
const name =
typeof obj.name === "string" ? obj.name :
typeof obj.toolName === "string" ? obj.toolName :
typeof obj.tool_name === "string" ? obj.tool_name :
(obj.tool && typeof obj.tool.name === "string") ? obj.tool.name :
null;
if (name) seen.add(name);
}
if (typeof obj.name === "string" && typeof obj.input === "object" && obj.input) {
// Many tool-use blocks look like { type: "...", name: "exec", input: {...} }
// but some transcripts omit/rename type.
seen.add(obj.name);
}
// OpenAI-ish tool call shapes.
if (Array.isArray(obj.tool_calls)) {
for (const c of obj.tool_calls) {
const fn = c?.function;
if (fn && typeof fn.name === "string") seen.add(fn.name);
}
}
if (obj.function && typeof obj.function.name === "string") seen.add(obj.function.name);
for (const v of Object.values(obj)) walk(v, obj);
}
for (const line of lines) {
try {
const entry = JSON.parse(line);
walk(entry, null);
} catch {
// ignore unparsable lines
}
}
const missing = [...required].filter((t) => !seen.has(t));
if (missing.length > 0) {
console.error(`Missing tools in transcript: ${missing.join(", ")}`);
console.error(`Seen tools: ${[...seen].sort().join(", ")}`);
console.error("Transcript head:");
console.error(lines.slice(0, 5).join("\n"));
process.exit(1);
}
NODE
}
run_profile() {
local profile="$1"
local port="$2"
local workspace="$3"
local agent_model_provider="$4" # "openai"|"anthropic"
echo "==> Onboard ($profile)"
if [[ "$agent_model_provider" == "openai" ]]; then
openclaw --profile "$profile" onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--auth-choice openai-api-key \
--openai-api-key "$OPENAI_API_KEY" \
--gateway-port "$port" \
--gateway-bind loopback \
--gateway-auth token \
--workspace "$workspace" \
--skip-health
elif [[ -n "$ANTHROPIC_API_KEY" ]]; then
openclaw --profile "$profile" onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--auth-choice apiKey \
--anthropic-api-key "$ANTHROPIC_API_KEY" \
--gateway-port "$port" \
--gateway-bind loopback \
--gateway-auth token \
--workspace "$workspace" \
--skip-health
elif [[ -n "$ANTHROPIC_API_TOKEN" ]]; then
openclaw --profile "$profile" onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--auth-choice token \
--token-provider anthropic \
--token "$ANTHROPIC_API_TOKEN" \
--gateway-port "$port" \
--gateway-bind loopback \
--gateway-auth token \
--workspace "$workspace" \
--skip-health
else
openclaw --profile "$profile" onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--auth-choice apiKey \
--anthropic-api-key "$ANTHROPIC_API_KEY" \
--gateway-port "$port" \
--gateway-bind loopback \
--gateway-auth token \
--workspace "$workspace" \
--skip-health
fi
echo "==> Verify workspace identity files ($profile)"
test -f "$workspace/AGENTS.md"
test -f "$workspace/IDENTITY.md"
test -f "$workspace/USER.md"
test -f "$workspace/SOUL.md"
test -f "$workspace/TOOLS.md"
echo "==> Configure models ($profile)"
local agent_model
local image_model
if [[ "$agent_model_provider" == "openai" ]]; then
agent_model="$(set_agent_model "$profile" \
"openai/gpt-5.4" \
"openai/gpt-4o-mini" \
"openai/gpt-4o")"
image_model="$(set_image_model "$profile" \
"openai/gpt-4o-mini" \
"openai/gpt-4o")"
else
agent_model="$(set_agent_model "$profile" \
"anthropic/claude-opus-4-6" \
"claude-opus-4-6")"
image_model="$(set_image_model "$profile" \
"anthropic/claude-opus-4-6" \
"claude-opus-4-6")"
fi
echo "model=$agent_model"
echo "imageModel=$image_model"
echo "==> Prepare tool fixtures ($profile)"
PROOF_TXT="$workspace/proof.txt"
PROOF_COPY="$workspace/copy.txt"
HOSTNAME_TXT="$workspace/hostname.txt"
IMAGE_PNG="$workspace/proof.png"
IMAGE_TXT="$workspace/image.txt"
SESSION_ID="e2e-tools-${profile}"
SESSION_JSONL="$HOME/.openclaw-${profile}/agents/main/sessions/${SESSION_ID}.jsonl"
PROOF_VALUE="$(node -e 'console.log(require("node:crypto").randomBytes(16).toString("hex"))')"
echo -n "$PROOF_VALUE" >"$PROOF_TXT"
write_png_lr_rg "$IMAGE_PNG"
EXPECTED_HOSTNAME="$(hostname | tr -d '\r\n')"
echo "==> Start gateway ($profile)"
GATEWAY_LOG="$workspace/gateway.log"
openclaw --profile "$profile" gateway --port "$port" --bind loopback >"$GATEWAY_LOG" 2>&1 &
GATEWAY_PID="$!"
cleanup_profile() {
if kill -0 "$GATEWAY_PID" 2>/dev/null; then
kill "$GATEWAY_PID" 2>/dev/null || true
wait "$GATEWAY_PID" 2>/dev/null || true
fi
}
trap cleanup_profile EXIT
echo "==> Wait for health ($profile)"
for _ in $(seq 1 240); do
if openclaw --profile "$profile" health --timeout 5000 --json >/dev/null 2>&1; then
break
fi
sleep 0.25
done
openclaw --profile "$profile" health --timeout 60000 --json >/dev/null
echo "==> Agent turns ($profile)"
TURN1_JSON="/tmp/agent-${profile}-1.json"
TURN2_JSON="/tmp/agent-${profile}-2.json"
TURN2B_JSON="/tmp/agent-${profile}-2b.json"
TURN3_JSON="/tmp/agent-${profile}-3.json"
TURN3B_JSON="/tmp/agent-${profile}-3b.json"
TURN4_JSON="/tmp/agent-${profile}-4.json"
run_agent_turn "$profile" "$SESSION_ID" \
"Use the read tool (not exec) to read ${PROOF_TXT}. Reply with the exact contents only (no extra whitespace)." \
"$TURN1_JSON"
assert_agent_json_has_text "$TURN1_JSON"
assert_agent_json_ok "$TURN1_JSON" "$agent_model_provider"
local reply1
reply1="$(extract_matching_text "$TURN1_JSON" "$PROOF_VALUE" | tr -d '\r\n')"
if [[ "$reply1" != "$PROOF_VALUE" ]]; then
echo "ERROR: agent did not read proof.txt correctly ($profile): $reply1" >&2
exit 1
fi
local prompt2
prompt2=$'Use the write tool (not exec) to write exactly this string into '"${PROOF_COPY}"$':\n'"${reply1}"$'\nReply with exactly: WROTE'
run_agent_turn "$profile" "$SESSION_ID" "$prompt2" "$TURN2_JSON"
assert_agent_json_has_text "$TURN2_JSON"
assert_agent_json_ok "$TURN2_JSON" "$agent_model_provider"
local copy_value
copy_value="$(cat "$PROOF_COPY" 2>/dev/null | tr -d '\r\n' || true)"
if [[ "$copy_value" != "$PROOF_VALUE" ]]; then
echo "ERROR: copy.txt did not match proof.txt ($profile)" >&2
exit 1
fi
run_agent_turn "$profile" "$SESSION_ID" \
"Use the read tool (not exec) to read ${PROOF_COPY}. Reply with the exact contents only (no extra whitespace)." \
"$TURN2B_JSON"
assert_agent_json_has_text "$TURN2B_JSON"
assert_agent_json_ok "$TURN2B_JSON" "$agent_model_provider"
local reply2
reply2="$(extract_matching_text "$TURN2B_JSON" "$PROOF_VALUE" | tr -d '\r\n')"
if [[ "$reply2" != "$PROOF_VALUE" ]]; then
echo "ERROR: agent did not read copy.txt correctly ($profile): $reply2" >&2
exit 1
fi
run_agent_turn "$profile" "$SESSION_ID" \
"Use the exec tool to run this command: hostname. Reply with the exact stdout only (trim trailing newline)." \
"$TURN3_JSON"
assert_agent_json_has_text "$TURN3_JSON"
assert_agent_json_ok "$TURN3_JSON" "$agent_model_provider"
local reply3
reply3="$(extract_matching_text "$TURN3_JSON" "$EXPECTED_HOSTNAME" | tr -d '\r\n')"
if [[ "$reply3" != "$EXPECTED_HOSTNAME" ]]; then
echo "ERROR: agent did not run hostname correctly ($profile): $reply3" >&2
exit 1
fi
local prompt3b
prompt3b=$'Use the write tool to write exactly this string into '"${HOSTNAME_TXT}"$':\n'"${reply3}"$'\nReply with exactly: WROTE'
run_agent_turn "$profile" "$SESSION_ID" "$prompt3b" "$TURN3B_JSON"
assert_agent_json_has_text "$TURN3B_JSON"
assert_agent_json_ok "$TURN3B_JSON" "$agent_model_provider"
if [[ "$(cat "$HOSTNAME_TXT" 2>/dev/null | tr -d '\r\n' || true)" != "$EXPECTED_HOSTNAME" ]]; then
echo "ERROR: hostname.txt did not match hostname output ($profile)" >&2
exit 1
fi
run_agent_turn "$profile" "$SESSION_ID" \
"Use the image tool on ${IMAGE_PNG}. Determine which color is on the left half and which is on the right half. Then use the write tool to write exactly: LEFT=RED RIGHT=GREEN into ${IMAGE_TXT}. Reply with exactly: LEFT=RED RIGHT=GREEN" \
"$TURN4_JSON"
assert_agent_json_has_text "$TURN4_JSON"
assert_agent_json_ok "$TURN4_JSON" "$agent_model_provider"
if [[ "$(cat "$IMAGE_TXT" 2>/dev/null | tr -d '\r\n' || true)" != "LEFT=RED RIGHT=GREEN" ]]; then
echo "ERROR: image.txt did not contain expected marker ($profile)" >&2
exit 1
fi
local reply4
reply4="$(extract_matching_text "$TURN4_JSON" "LEFT=RED RIGHT=GREEN")"
if [[ "$reply4" != "LEFT=RED RIGHT=GREEN" ]]; then
echo "ERROR: agent reply did not contain expected marker ($profile): $reply4" >&2
exit 1
fi
echo "==> Verify tool usage via session transcript ($profile)"
# Give the gateway a moment to flush transcripts.
sleep 1
if [[ ! -f "$SESSION_JSONL" ]]; then
echo "ERROR: missing session transcript ($profile): $SESSION_JSONL" >&2
ls -la "$HOME/.openclaw-${profile}/agents/main/sessions" >&2 || true
exit 1
fi
assert_session_used_tools "$SESSION_JSONL" read write exec image
cleanup_profile
trap - EXIT
}
if [[ "$MODELS_MODE" == "openai" || "$MODELS_MODE" == "both" ]]; then
run_profile "e2e-openai" "18789" "/tmp/openclaw-e2e-openai" "openai"
fi
if [[ "$MODELS_MODE" == "anthropic" || "$MODELS_MODE" == "both" ]]; then
run_profile "e2e-anthropic" "18799" "/tmp/openclaw-e2e-anthropic" "anthropic"
fi
echo "OK"

View file

@ -0,0 +1,47 @@
# syntax=docker/dockerfile:1.7
FROM ubuntu:24.04@sha256:cd1dba651b3080c3686ecf4e3c4220f026b521fb76978881737d24f200828b2b
# Smoke images are pinned and short-lived, so skip distro upgrades here and
# spend the time budget on installer coverage instead.
RUN --mount=type=cache,id=openclaw-install-sh-nonroot-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-install-sh-nonroot-apt-lists,target=/var/lib/apt,sharing=locked \
set -eux; \
for attempt in 1 2 3; do \
if apt-get update -o Acquire::Retries=3; then break; fi; \
echo "apt-get update failed (attempt ${attempt})" >&2; \
if [ "${attempt}" -eq 3 ]; then exit 1; fi; \
sleep 3; \
done; \
apt-get -o Acquire::Retries=3 install -y --no-install-recommends \
bash \
ca-certificates \
curl \
g++ \
make \
python3 \
sudo
# Preinstall the supported Node runtime in a cacheable build layer so the
# non-root smoke covers user-local npm prefixing and missing git without paying
# the full NodeSource bootstrap cost on every container run.
RUN --mount=type=cache,id=openclaw-install-sh-nonroot-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-install-sh-nonroot-apt-lists,target=/var/lib/apt,sharing=locked \
set -eux; \
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -; \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends nodejs
RUN useradd -m -s /bin/bash app \
&& echo "app ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/app
USER app
WORKDIR /home/app
ENV NPM_CONFIG_FUND=false
ENV NPM_CONFIG_AUDIT=false
COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh
COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh
COPY --chmod=755 install-sh-nonroot/run.sh /usr/local/bin/openclaw-install-nonroot
ENTRYPOINT ["/usr/local/bin/openclaw-install-nonroot"]

View file

@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
INSTALL_URL="${OPENCLAW_INSTALL_URL:-https://openclaw.bot/install.sh}"
DEFAULT_PACKAGE="openclaw"
PACKAGE_NAME="${OPENCLAW_INSTALL_PACKAGE:-$DEFAULT_PACKAGE}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../install-sh-common/cli-verify.sh
source "$SCRIPT_DIR/../install-sh-common/cli-verify.sh"
echo "==> Pre-flight: ensure git absent"
if command -v git >/dev/null; then
echo "git is present unexpectedly" >&2
exit 1
fi
echo "==> Pre-flight: ensure supported Node is already present"
node -e '
const version = process.versions.node.split(".").map(Number);
const ok =
version.length >= 2 &&
(version[0] > 22 || (version[0] === 22 && version[1] >= 16));
if (!ok) {
process.stderr.write(`unsupported node ${process.versions.node}\n`);
process.exit(1);
}
'
command -v npm >/dev/null
echo "==> Run installer (non-root user)"
curl -fsSL "$INSTALL_URL" | bash
# Ensure PATH picks up user npm prefix
export PATH="$HOME/.npm-global/bin:$PATH"
echo "==> Verify git installed"
command -v git >/dev/null
EXPECTED_VERSION="${OPENCLAW_INSTALL_EXPECT_VERSION:-}"
if [[ -n "$EXPECTED_VERSION" ]]; then
LATEST_VERSION="$EXPECTED_VERSION"
else
LATEST_VERSION="$(quiet_npm view "$PACKAGE_NAME" version)"
fi
echo "==> Verify CLI installed"
verify_installed_cli "$PACKAGE_NAME" "$LATEST_VERSION"
echo "OK"

View file

@ -0,0 +1,30 @@
# syntax=docker/dockerfile:1.7
FROM node:24-bookworm-slim@sha256:b4687aef2571c632a1953695ce4d61d6462a7eda471fe6e272eebf0418f276ba
# Smoke images are pinned and short-lived, so skip distro upgrades here and
# spend the time budget on installer coverage instead.
RUN --mount=type=cache,id=openclaw-install-sh-smoke-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-install-sh-smoke-apt-lists,target=/var/lib/apt,sharing=locked \
set -eux; \
for attempt in 1 2 3; do \
if apt-get update -o Acquire::Retries=3; then break; fi; \
echo "apt-get update failed (attempt ${attempt})" >&2; \
if [ "${attempt}" -eq 3 ]; then exit 1; fi; \
sleep 3; \
done; \
apt-get -o Acquire::Retries=3 install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
g++ \
make \
python3 \
sudo
COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh
COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh
COPY --chmod=755 install-sh-smoke/run.sh /usr/local/bin/openclaw-install-smoke
ENTRYPOINT ["/usr/local/bin/openclaw-install-smoke"]

View file

@ -0,0 +1,345 @@
#!/usr/bin/env bash
set -euo pipefail
INSTALL_URL="${OPENCLAW_INSTALL_URL:-https://openclaw.bot/install.sh}"
SMOKE_MODE="${OPENCLAW_INSTALL_SMOKE_MODE:-install}"
SMOKE_PREVIOUS_VERSION="${OPENCLAW_INSTALL_SMOKE_PREVIOUS:-}"
SKIP_PREVIOUS="${OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS:-0}"
DEFAULT_PACKAGE="openclaw"
PACKAGE_NAME="${OPENCLAW_INSTALL_PACKAGE:-$DEFAULT_PACKAGE}"
FRESH_VERSION="${OPENCLAW_INSTALL_FRESH_VERSION:-}"
FRESH_TAG_URL="${OPENCLAW_INSTALL_FRESH_TAG_URL:-}"
UPDATE_BASELINE_VERSION="${OPENCLAW_INSTALL_UPDATE_BASELINE:-2026.4.10}"
UPDATE_BASELINE_TAG_URL="${OPENCLAW_INSTALL_UPDATE_BASELINE_TAG_URL:-}"
UPDATE_EXPECT_VERSION="${OPENCLAW_INSTALL_UPDATE_EXPECT_VERSION:-}"
UPDATE_TAG_URL="${OPENCLAW_INSTALL_UPDATE_TAG_URL:-}"
HEARTBEAT_INTERVAL="${OPENCLAW_INSTALL_SMOKE_HEARTBEAT_INTERVAL:-60}"
INSTALL_COMMAND_TIMEOUT="${OPENCLAW_INSTALL_SMOKE_COMMAND_TIMEOUT:-300}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=../install-sh-common/cli-verify.sh
source "$SCRIPT_DIR/../install-sh-common/cli-verify.sh"
emit_status() {
if [[ -w /dev/tty ]]; then
printf "%s\n" "$*" >/dev/tty
else
printf "%s\n" "$*" >&2
fi
}
global_package_root() {
local npm_root
npm_root="$(quiet_npm root -g 2>/dev/null || true)"
if [[ -n "$npm_root" ]]; then
printf "%s/%s" "$npm_root" "$PACKAGE_NAME"
fi
}
describe_installed_package() {
local root="$1"
local files="missing"
local size="missing"
local version="missing"
if [[ -d "$root" ]]; then
files="$(find "$root" -type f 2>/dev/null | wc -l | tr -d " ")"
size="$(du -sh "$root" 2>/dev/null | cut -f1 || true)"
version="$(
node -e '
try {
process.stdout.write(String(require(`${process.argv[1]}/package.json`).version ?? "missing"));
} catch {
process.stdout.write("missing");
}
' "$root"
)"
fi
printf "version=%s size=%s files=%s root=%s" "$version" "$size" "$files" "$root"
}
print_install_audit() {
local label="$1"
local root
root="$(global_package_root)"
if [[ -n "$root" ]]; then
echo "==> Install audit (${label}): $(describe_installed_package "$root")"
fi
}
run_with_heartbeat() {
local label="$1"
shift
local interval="$HEARTBEAT_INTERVAL"
if ! [[ "$interval" =~ ^[0-9]+$ ]] || [[ "$interval" == "0" ]]; then
"$@"
return
fi
local start
local command_pid
local heartbeat_pid
local status
start="$(date +%s)"
set +e
"$@" &
command_pid=$!
(
while true; do
sleep "$interval"
kill -0 "$command_pid" >/dev/null 2>&1 || exit 0
local now
local elapsed
local root
now="$(date +%s)"
elapsed=$((now - start))
root="$(global_package_root)"
if [[ -n "$root" ]]; then
emit_status "==> Still running (${label}, ${elapsed}s): $(describe_installed_package "$root")"
else
emit_status "==> Still running (${label}, ${elapsed}s)"
fi
done
) &
heartbeat_pid=$!
wait "$command_pid"
status=$?
kill "$heartbeat_pid" >/dev/null 2>&1 || true
wait "$heartbeat_pid" >/dev/null 2>&1 || true
set -e
return "$status"
}
npm_install_global() {
local label="$1"
shift
run_with_heartbeat "$label" \
timeout --foreground "${INSTALL_COMMAND_TIMEOUT}s" \
npm \
--loglevel=error \
--logs-max=0 \
--no-update-notifier \
--no-fund \
--no-audit \
--no-progress \
install -g "$@"
}
run_install_smoke() {
if [[ -n "$FRESH_VERSION" && -n "$FRESH_TAG_URL" ]]; then
echo "package=$PACKAGE_NAME latest=$FRESH_VERSION source=$FRESH_TAG_URL"
echo "==> Install latest release tarball"
npm_install_global "install latest release tarball" --omit=optional "$FRESH_TAG_URL"
print_install_audit "fresh install"
echo "==> Verify installed version"
if [[ -n "${OPENCLAW_INSTALL_LATEST_OUT:-}" ]]; then
# Non-root installer smoke uses the public install script path, which
# resolves npm "latest" rather than this host-served candidate tarball.
local latest_npm_version
latest_npm_version="$(quiet_npm view "$PACKAGE_NAME" version 2>/dev/null || true)"
if [[ -n "$latest_npm_version" ]]; then
printf "%s" "$latest_npm_version" > "${OPENCLAW_INSTALL_LATEST_OUT:-}"
else
printf "%s" "$FRESH_VERSION" > "${OPENCLAW_INSTALL_LATEST_OUT:-}"
fi
fi
verify_installed_cli "$PACKAGE_NAME" "$FRESH_VERSION"
echo "OK"
return 0
fi
echo "==> Resolve npm versions"
if [[ "$SKIP_PREVIOUS" == "1" ]]; then
LATEST_VERSION="$(quiet_npm view "$PACKAGE_NAME" version)"
PREVIOUS_VERSION="$LATEST_VERSION"
elif [[ -n "$SMOKE_PREVIOUS_VERSION" ]]; then
LATEST_VERSION="$(quiet_npm view "$PACKAGE_NAME" version)"
PREVIOUS_VERSION="$SMOKE_PREVIOUS_VERSION"
else
LATEST_VERSION="$(quiet_npm view "$PACKAGE_NAME" dist-tags.latest)"
VERSIONS_JSON="$(quiet_npm view "$PACKAGE_NAME" versions --json)"
PREVIOUS_VERSION="$(LATEST_VERSION="$LATEST_VERSION" VERSIONS_JSON="$VERSIONS_JSON" node - <<'NODE'
const latest = String(process.env.LATEST_VERSION || "");
const raw = process.env.VERSIONS_JSON || "[]";
let versions;
try {
versions = JSON.parse(raw);
} catch {
versions = raw ? [raw] : [];
}
if (!Array.isArray(versions)) {
versions = [versions];
}
if (versions.length === 0 || latest.length === 0) {
process.exit(1);
}
const latestIndex = versions.lastIndexOf(latest);
if (latestIndex <= 0) {
process.stdout.write(latest);
process.exit(0);
}
process.stdout.write(String(versions[latestIndex - 1] ?? latest));
NODE
)"
fi
echo "package=$PACKAGE_NAME latest=$LATEST_VERSION previous=$PREVIOUS_VERSION"
if [[ "$SKIP_PREVIOUS" == "1" ]]; then
echo "==> Skip preinstall previous (OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS=1)"
else
echo "==> Preinstall previous (forces installer upgrade path)"
npm_install_global "preinstall previous release" "${PACKAGE_NAME}@${PREVIOUS_VERSION}"
print_install_audit "previous install"
fi
echo "==> Run official installer one-liner"
curl -fsSL "$INSTALL_URL" | bash -s -- --no-prompt
echo "==> Verify installed version"
if [[ -n "${OPENCLAW_INSTALL_LATEST_OUT:-}" ]]; then
printf "%s" "$LATEST_VERSION" > "${OPENCLAW_INSTALL_LATEST_OUT:-}"
fi
verify_installed_cli "$PACKAGE_NAME" "$LATEST_VERSION"
echo "OK"
}
run_update_smoke() {
if [[ -z "$UPDATE_EXPECT_VERSION" ]]; then
echo "ERROR: OPENCLAW_INSTALL_UPDATE_EXPECT_VERSION is required for update mode" >&2
return 1
fi
if [[ -z "$UPDATE_TAG_URL" ]]; then
echo "ERROR: OPENCLAW_INSTALL_UPDATE_TAG_URL is required for update mode" >&2
return 1
fi
echo "package=$PACKAGE_NAME baseline=$UPDATE_BASELINE_VERSION target=$UPDATE_EXPECT_VERSION"
echo "==> Install baseline release"
if [[ -n "$UPDATE_BASELINE_TAG_URL" ]]; then
npm_install_global "install baseline release" --omit=optional "$UPDATE_BASELINE_TAG_URL"
else
npm_install_global "install baseline release" --omit=optional "${PACKAGE_NAME}@${UPDATE_BASELINE_VERSION}"
fi
print_install_audit "baseline install"
verify_installed_cli "$PACKAGE_NAME" "$UPDATE_BASELINE_VERSION"
echo "==> Run openclaw update from host-served tgz"
local update_status
local update_stderr_file
local update_stderr
update_stderr_file="$(mktemp)"
set +e
UPDATE_JSON="$(
run_with_heartbeat "openclaw update" \
env npm_config_omit=optional NPM_CONFIG_OMIT=optional \
openclaw update --tag "$UPDATE_TAG_URL" --yes --json 2>"$update_stderr_file"
)"
update_status=$?
set -e
update_stderr="$(cat "$update_stderr_file")"
rm -f "$update_stderr_file"
printf "%s\n" "$UPDATE_JSON"
if [[ -n "$update_stderr" ]]; then
printf "%s\n" "$update_stderr" >&2
fi
if [[ "$update_status" -ne 0 ]]; then
echo "ERROR: openclaw update failed with exit code $update_status" >&2
return "$update_status"
fi
UPDATE_JSON="$UPDATE_JSON" \
UPDATE_EXPECT_VERSION="$UPDATE_EXPECT_VERSION" \
UPDATE_BASELINE_VERSION="$UPDATE_BASELINE_VERSION" \
UPDATE_TAG_URL="$UPDATE_TAG_URL" \
node - <<'NODE'
const payload = JSON.parse(process.env.UPDATE_JSON || "{}");
const expectedVersion = String(process.env.UPDATE_EXPECT_VERSION || "");
const baselineVersion = String(process.env.UPDATE_BASELINE_VERSION || "");
const expectedUrl = String(process.env.UPDATE_TAG_URL || "");
if (payload.status !== "ok") {
throw new Error(`expected update status ok, got ${JSON.stringify(payload.status)}`);
}
if ((payload.before?.version ?? null) !== baselineVersion) {
throw new Error(
`expected before.version ${baselineVersion}, got ${JSON.stringify(payload.before?.version)}`,
);
}
if ((payload.after?.version ?? null) !== expectedVersion) {
throw new Error(
`expected after.version ${expectedVersion}, got ${JSON.stringify(payload.after?.version)}`,
);
}
if (payload.reason != null) {
throw new Error(`expected no failure reason, got ${JSON.stringify(payload.reason)}`);
}
const steps = Array.isArray(payload.steps) ? payload.steps : [];
const updateStep = steps.find((step) => step?.name === "global update");
if (!updateStep) {
throw new Error("missing global update step in update JSON");
}
if (Number(updateStep.exitCode ?? 1) !== 0) {
throw new Error(`global update step failed: ${JSON.stringify(updateStep)}`);
}
if (typeof updateStep.command !== "string" || !updateStep.command.includes(expectedUrl)) {
throw new Error(`global update step missing expected tgz URL: ${JSON.stringify(updateStep)}`);
}
NODE
echo "==> Verify updated version"
print_install_audit "updated install"
verify_installed_cli "$PACKAGE_NAME" "$UPDATE_EXPECT_VERSION"
echo "OK"
}
run_npm_global_smoke() {
if [[ -z "$UPDATE_EXPECT_VERSION" ]]; then
echo "ERROR: OPENCLAW_INSTALL_UPDATE_EXPECT_VERSION is required for npm-global mode" >&2
return 1
fi
if [[ -z "$UPDATE_TAG_URL" ]]; then
echo "ERROR: OPENCLAW_INSTALL_UPDATE_TAG_URL is required for npm-global mode" >&2
return 1
fi
echo "package=$PACKAGE_NAME baseline=$UPDATE_BASELINE_VERSION target=$UPDATE_EXPECT_VERSION"
echo "==> Direct npm global install candidate"
npm_install_global "direct npm global install candidate" "$UPDATE_TAG_URL"
print_install_audit "direct npm fresh install"
verify_installed_cli "$PACKAGE_NAME" "$UPDATE_EXPECT_VERSION"
echo "==> Direct npm global install baseline"
if [[ -n "$UPDATE_BASELINE_TAG_URL" ]]; then
npm_install_global "direct npm global install baseline" "$UPDATE_BASELINE_TAG_URL"
else
npm_install_global "direct npm global install baseline" "${PACKAGE_NAME}@${UPDATE_BASELINE_VERSION}"
fi
print_install_audit "direct npm baseline install"
verify_installed_cli "$PACKAGE_NAME" "$UPDATE_BASELINE_VERSION"
echo "==> Direct npm global update candidate"
npm_install_global "direct npm global update candidate" "$UPDATE_TAG_URL"
print_install_audit "direct npm updated install"
verify_installed_cli "$PACKAGE_NAME" "$UPDATE_EXPECT_VERSION"
echo "OK"
}
case "$SMOKE_MODE" in
install)
run_install_smoke
;;
update)
run_update_smoke
;;
npm-global)
run_npm_global_smoke
;;
*)
echo "ERROR: unsupported OPENCLAW_INSTALL_SMOKE_MODE=$SMOKE_MODE" >&2
exit 1
;;
esac

View file

@ -0,0 +1,654 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
COMPOSE_FILE="$ROOT_DIR/docker-compose.yml"
EXTRA_COMPOSE_FILE="$ROOT_DIR/docker-compose.extra.yml"
IMAGE_NAME="${OPENCLAW_IMAGE:-openclaw:local}"
EXTRA_MOUNTS="${OPENCLAW_EXTRA_MOUNTS:-}"
HOME_VOLUME_NAME="${OPENCLAW_HOME_VOLUME:-}"
RAW_SANDBOX_SETTING="${OPENCLAW_SANDBOX:-}"
SANDBOX_ENABLED=""
DOCKER_SOCKET_PATH="${OPENCLAW_DOCKER_SOCKET:-}"
TIMEZONE="${OPENCLAW_TZ:-}"
fail() {
echo "ERROR: $*" >&2
exit 1
}
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing dependency: $1" >&2
exit 1
fi
}
run_docker_build() {
# Dockerfile uses BuildKit-only syntax (RUN --mount=type=cache). Force
# BuildKit so hosts defaulting to the legacy builder do not fail.
DOCKER_BUILDKIT=1 docker build "$@"
}
is_truthy_value() {
local raw="${1:-}"
raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
case "$raw" in
1 | true | yes | on) return 0 ;;
*) return 1 ;;
esac
}
read_config_gateway_token() {
local config_path="$OPENCLAW_CONFIG_DIR/openclaw.json"
if [[ ! -f "$config_path" ]]; then
return 0
fi
if command -v python3 >/dev/null 2>&1; then
python3 - "$config_path" <<'PY'
import json
import sys
path = sys.argv[1]
try:
with open(path, "r", encoding="utf-8") as f:
cfg = json.load(f)
except Exception:
raise SystemExit(0)
gateway = cfg.get("gateway")
if not isinstance(gateway, dict):
raise SystemExit(0)
auth = gateway.get("auth")
if not isinstance(auth, dict):
raise SystemExit(0)
token = auth.get("token")
if isinstance(token, str):
token = token.strip()
if token:
print(token)
PY
return 0
fi
if command -v node >/dev/null 2>&1; then
node - "$config_path" <<'NODE'
const fs = require("node:fs");
const configPath = process.argv[2];
try {
const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
const token = cfg?.gateway?.auth?.token;
if (typeof token === "string" && token.trim().length > 0) {
process.stdout.write(token.trim());
}
} catch {
// Keep docker-setup resilient when config parsing fails.
}
NODE
fi
}
read_env_gateway_token() {
local env_path="$1"
local line=""
local token=""
if [[ ! -f "$env_path" ]]; then
return 0
fi
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%$'\r'}"
if [[ "$line" == OPENCLAW_GATEWAY_TOKEN=* ]]; then
token="${line#OPENCLAW_GATEWAY_TOKEN=}"
fi
done <"$env_path"
if [[ -n "$token" ]]; then
printf '%s' "$token"
fi
}
sync_gateway_config() {
local allowed_origin_json=""
local current_allowed_origins=""
local batch_json=""
if [[ "${OPENCLAW_GATEWAY_BIND}" != "loopback" ]]; then
allowed_origin_json="$(printf '["http://localhost:%s","http://127.0.0.1:%s"]' "$OPENCLAW_GATEWAY_PORT" "$OPENCLAW_GATEWAY_PORT")"
current_allowed_origins="$(
run_prestart_cli config get gateway.controlUi.allowedOrigins 2>/dev/null || true
)"
current_allowed_origins="${current_allowed_origins//$'\r'/}"
fi
batch_json="$(printf '[{"path":"gateway.mode","value":"local"},{"path":"gateway.bind","value":"%s"}' "$OPENCLAW_GATEWAY_BIND")"
if [[ -n "$allowed_origin_json" ]]; then
if [[ -n "$current_allowed_origins" && "$current_allowed_origins" != "null" && "$current_allowed_origins" != "[]" ]]; then
echo "Control UI allowlist already configured; leaving gateway.controlUi.allowedOrigins unchanged."
else
batch_json+=",{\"path\":\"gateway.controlUi.allowedOrigins\",\"value\":$allowed_origin_json}"
fi
fi
batch_json+="]"
run_prestart_cli config set --batch-json "$batch_json" >/dev/null
echo "Pinned gateway.mode=local and gateway.bind=$OPENCLAW_GATEWAY_BIND for Docker setup."
if [[ -n "$allowed_origin_json" ]]; then
if [[ -z "$current_allowed_origins" || "$current_allowed_origins" == "null" || "$current_allowed_origins" == "[]" ]]; then
echo "Set gateway.controlUi.allowedOrigins to $allowed_origin_json for non-loopback bind."
fi
fi
}
run_prestart_gateway() {
docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps "$@"
}
run_prestart_cli() {
# During setup, avoid the shared-network openclaw-cli service because it
# requires the gateway container's network namespace to already exist. That
# creates a circular dependency for config writes that are needed before the
# gateway can start cleanly.
run_prestart_gateway --entrypoint node openclaw-gateway \
dist/index.js "$@"
}
run_runtime_cli() {
local compose_scope="${1:-current}"
local deps_mode="${2:-with-deps}"
shift 2
local -a compose_args
local -a run_args=(run --rm)
case "$compose_scope" in
current) compose_args=("${COMPOSE_ARGS[@]}") ;;
base) compose_args=("${BASE_COMPOSE_ARGS[@]}") ;;
*) fail "Unknown runtime CLI compose scope: $compose_scope" ;;
esac
case "$deps_mode" in
with-deps) ;;
no-deps) run_args+=(--no-deps) ;;
*) fail "Unknown runtime CLI deps mode: $deps_mode" ;;
esac
docker compose "${compose_args[@]}" "${run_args[@]}" openclaw-cli "$@"
}
contains_disallowed_chars() {
local value="$1"
[[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]]
}
is_valid_timezone() {
local value="$1"
[[ -e "/usr/share/zoneinfo/$value" && ! -d "/usr/share/zoneinfo/$value" ]]
}
validate_mount_path_value() {
local label="$1"
local value="$2"
if [[ -z "$value" ]]; then
fail "$label cannot be empty."
fi
if contains_disallowed_chars "$value"; then
fail "$label contains unsupported control characters."
fi
if [[ "$value" =~ [[:space:]] ]]; then
fail "$label cannot contain whitespace."
fi
}
validate_named_volume() {
local value="$1"
if [[ ! "$value" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then
fail "OPENCLAW_HOME_VOLUME must match [A-Za-z0-9][A-Za-z0-9_.-]* when using a named volume."
fi
}
validate_mount_spec() {
local mount="$1"
if contains_disallowed_chars "$mount"; then
fail "OPENCLAW_EXTRA_MOUNTS entries cannot contain control characters."
fi
# Keep mount specs strict to avoid YAML structure injection.
# Expected format: source:target[:options]
if [[ ! "$mount" =~ ^[^[:space:],:]+:[^[:space:],:]+(:[^[:space:],:]+)?$ ]]; then
fail "Invalid mount format '$mount'. Expected source:target[:options] without spaces."
fi
}
require_cmd docker
if ! docker compose version >/dev/null 2>&1; then
echo "Docker Compose not available (try: docker compose version)" >&2
exit 1
fi
if [[ -z "$DOCKER_SOCKET_PATH" && "${DOCKER_HOST:-}" == unix://* ]]; then
DOCKER_SOCKET_PATH="${DOCKER_HOST#unix://}"
fi
if [[ -z "$DOCKER_SOCKET_PATH" ]]; then
DOCKER_SOCKET_PATH="/var/run/docker.sock"
fi
if is_truthy_value "$RAW_SANDBOX_SETTING"; then
SANDBOX_ENABLED="1"
fi
OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw}"
OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}"
validate_mount_path_value "OPENCLAW_CONFIG_DIR" "$OPENCLAW_CONFIG_DIR"
validate_mount_path_value "OPENCLAW_WORKSPACE_DIR" "$OPENCLAW_WORKSPACE_DIR"
if [[ -n "$HOME_VOLUME_NAME" ]]; then
if [[ "$HOME_VOLUME_NAME" == *"/"* ]]; then
validate_mount_path_value "OPENCLAW_HOME_VOLUME" "$HOME_VOLUME_NAME"
else
validate_named_volume "$HOME_VOLUME_NAME"
fi
fi
if contains_disallowed_chars "$EXTRA_MOUNTS"; then
fail "OPENCLAW_EXTRA_MOUNTS cannot contain control characters."
fi
if [[ -n "$SANDBOX_ENABLED" ]]; then
validate_mount_path_value "OPENCLAW_DOCKER_SOCKET" "$DOCKER_SOCKET_PATH"
fi
if [[ -n "$TIMEZONE" ]]; then
if contains_disallowed_chars "$TIMEZONE"; then
fail "OPENCLAW_TZ contains unsupported control characters."
fi
if [[ ! "$TIMEZONE" =~ ^[A-Za-z0-9/_+\-]+$ ]]; then
fail "OPENCLAW_TZ must be a valid IANA timezone string (e.g. Asia/Shanghai)."
fi
if ! is_valid_timezone "$TIMEZONE"; then
fail "OPENCLAW_TZ must match a timezone in /usr/share/zoneinfo (e.g. Asia/Shanghai)."
fi
fi
mkdir -p "$OPENCLAW_CONFIG_DIR"
mkdir -p "$OPENCLAW_WORKSPACE_DIR"
# Seed directory tree eagerly so bind mounts work even on Docker Desktop/Windows
# where the container (even as root) cannot create new host subdirectories.
mkdir -p "$OPENCLAW_CONFIG_DIR/identity"
mkdir -p "$OPENCLAW_CONFIG_DIR/agents/main/agent"
mkdir -p "$OPENCLAW_CONFIG_DIR/agents/main/sessions"
export OPENCLAW_CONFIG_DIR
export OPENCLAW_WORKSPACE_DIR
export OPENCLAW_GATEWAY_PORT="${OPENCLAW_GATEWAY_PORT:-18789}"
export OPENCLAW_BRIDGE_PORT="${OPENCLAW_BRIDGE_PORT:-18790}"
export OPENCLAW_GATEWAY_BIND="${OPENCLAW_GATEWAY_BIND:-lan}"
export OPENCLAW_IMAGE="$IMAGE_NAME"
export OPENCLAW_DOCKER_APT_PACKAGES="${OPENCLAW_DOCKER_APT_PACKAGES:-}"
export OPENCLAW_EXTENSIONS="${OPENCLAW_EXTENSIONS:-}"
export OPENCLAW_EXTRA_MOUNTS="$EXTRA_MOUNTS"
export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME"
export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}"
export OPENCLAW_SANDBOX="$SANDBOX_ENABLED"
export OPENCLAW_DOCKER_SOCKET="$DOCKER_SOCKET_PATH"
export OPENCLAW_TZ="$TIMEZONE"
# Detect Docker socket GID for sandbox group_add.
DOCKER_GID=""
if [[ -n "$SANDBOX_ENABLED" && -S "$DOCKER_SOCKET_PATH" ]]; then
DOCKER_GID="$(stat -c '%g' "$DOCKER_SOCKET_PATH" 2>/dev/null || stat -f '%g' "$DOCKER_SOCKET_PATH" 2>/dev/null || echo "")"
fi
export DOCKER_GID
if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then
EXISTING_CONFIG_TOKEN="$(read_config_gateway_token || true)"
if [[ -n "$EXISTING_CONFIG_TOKEN" ]]; then
OPENCLAW_GATEWAY_TOKEN="$EXISTING_CONFIG_TOKEN"
echo "Reusing gateway token from $OPENCLAW_CONFIG_DIR/openclaw.json"
else
DOTENV_GATEWAY_TOKEN="$(read_env_gateway_token "$ROOT_DIR/.env" || true)"
if [[ -n "$DOTENV_GATEWAY_TOKEN" ]]; then
OPENCLAW_GATEWAY_TOKEN="$DOTENV_GATEWAY_TOKEN"
echo "Reusing gateway token from $ROOT_DIR/.env"
elif command -v openssl >/dev/null 2>&1; then
OPENCLAW_GATEWAY_TOKEN="$(openssl rand -hex 32)"
else
OPENCLAW_GATEWAY_TOKEN="$(python3 - <<'PY'
import secrets
print(secrets.token_hex(32))
PY
)"
fi
fi
fi
export OPENCLAW_GATEWAY_TOKEN
COMPOSE_FILES=("$COMPOSE_FILE")
COMPOSE_ARGS=()
write_extra_compose() {
local home_volume="$1"
shift
local mount
local gateway_home_mount
local gateway_config_mount
local gateway_workspace_mount
cat >"$EXTRA_COMPOSE_FILE" <<'YAML'
services:
openclaw-gateway:
volumes:
YAML
if [[ -n "$home_volume" ]]; then
gateway_home_mount="${home_volume}:/home/node"
gateway_config_mount="${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw"
gateway_workspace_mount="${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace"
validate_mount_spec "$gateway_home_mount"
validate_mount_spec "$gateway_config_mount"
validate_mount_spec "$gateway_workspace_mount"
printf ' - %s\n' "$gateway_home_mount" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s\n' "$gateway_config_mount" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s\n' "$gateway_workspace_mount" >>"$EXTRA_COMPOSE_FILE"
fi
for mount in "$@"; do
validate_mount_spec "$mount"
printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE"
done
cat >>"$EXTRA_COMPOSE_FILE" <<'YAML'
openclaw-cli:
volumes:
YAML
if [[ -n "$home_volume" ]]; then
printf ' - %s\n' "$gateway_home_mount" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s\n' "$gateway_config_mount" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s\n' "$gateway_workspace_mount" >>"$EXTRA_COMPOSE_FILE"
fi
for mount in "$@"; do
validate_mount_spec "$mount"
printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE"
done
if [[ -n "$home_volume" && "$home_volume" != *"/"* ]]; then
validate_named_volume "$home_volume"
cat >>"$EXTRA_COMPOSE_FILE" <<YAML
volumes:
${home_volume}:
YAML
fi
}
# When sandbox is requested, ensure Docker CLI build arg is set for local builds.
# Docker socket mount is deferred until sandbox prerequisites are verified.
if [[ -n "$SANDBOX_ENABLED" ]]; then
if [[ -z "${OPENCLAW_INSTALL_DOCKER_CLI:-}" ]]; then
export OPENCLAW_INSTALL_DOCKER_CLI=1
fi
fi
VALID_MOUNTS=()
if [[ -n "$EXTRA_MOUNTS" ]]; then
IFS=',' read -r -a mounts <<<"$EXTRA_MOUNTS"
for mount in "${mounts[@]}"; do
mount="${mount#"${mount%%[![:space:]]*}"}"
mount="${mount%"${mount##*[![:space:]]}"}"
if [[ -n "$mount" ]]; then
VALID_MOUNTS+=("$mount")
fi
done
fi
if [[ -n "$HOME_VOLUME_NAME" || ${#VALID_MOUNTS[@]} -gt 0 ]]; then
# Bash 3.2 + nounset treats "${array[@]}" on an empty array as unbound.
if [[ ${#VALID_MOUNTS[@]} -gt 0 ]]; then
write_extra_compose "$HOME_VOLUME_NAME" "${VALID_MOUNTS[@]}"
else
write_extra_compose "$HOME_VOLUME_NAME"
fi
COMPOSE_FILES+=("$EXTRA_COMPOSE_FILE")
fi
for compose_file in "${COMPOSE_FILES[@]}"; do
COMPOSE_ARGS+=("-f" "$compose_file")
done
# Keep a base compose arg set without sandbox overlay so rollback paths can
# force a known-safe gateway service definition (no docker.sock mount).
BASE_COMPOSE_ARGS=("${COMPOSE_ARGS[@]}")
COMPOSE_HINT="docker compose"
for compose_file in "${COMPOSE_FILES[@]}"; do
COMPOSE_HINT+=" -f ${compose_file}"
done
ENV_FILE="$ROOT_DIR/.env"
upsert_env() {
local file="$1"
shift
local -a keys=("$@")
local tmp
tmp="$(mktemp)"
# Use a delimited string instead of an associative array so the script
# works with Bash 3.2 (macOS default) which lacks `declare -A`.
local seen=" "
if [[ -f "$file" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
local key="${line%%=*}"
local replaced=false
for k in "${keys[@]}"; do
if [[ "$key" == "$k" ]]; then
printf '%s=%s\n' "$k" "${!k-}" >>"$tmp"
seen="$seen$k "
replaced=true
break
fi
done
if [[ "$replaced" == false ]]; then
printf '%s\n' "$line" >>"$tmp"
fi
done <"$file"
fi
for k in "${keys[@]}"; do
if [[ "$seen" != *" $k "* ]]; then
printf '%s=%s\n' "$k" "${!k-}" >>"$tmp"
fi
done
mv "$tmp" "$file"
}
upsert_env "$ENV_FILE" \
OPENCLAW_CONFIG_DIR \
OPENCLAW_WORKSPACE_DIR \
OPENCLAW_GATEWAY_PORT \
OPENCLAW_BRIDGE_PORT \
OPENCLAW_GATEWAY_BIND \
OPENCLAW_GATEWAY_TOKEN \
OPENCLAW_IMAGE \
OPENCLAW_EXTRA_MOUNTS \
OPENCLAW_HOME_VOLUME \
OPENCLAW_DOCKER_APT_PACKAGES \
OPENCLAW_EXTENSIONS \
OPENCLAW_SANDBOX \
OPENCLAW_DOCKER_SOCKET \
DOCKER_GID \
OPENCLAW_INSTALL_DOCKER_CLI \
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS \
OPENCLAW_TZ
if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then
echo "==> Building Docker image: $IMAGE_NAME"
run_docker_build \
--build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \
--build-arg "OPENCLAW_EXTENSIONS=${OPENCLAW_EXTENSIONS}" \
--build-arg "OPENCLAW_INSTALL_DOCKER_CLI=${OPENCLAW_INSTALL_DOCKER_CLI:-}" \
-t "$IMAGE_NAME" \
-f "$ROOT_DIR/Dockerfile" \
"$ROOT_DIR"
else
echo "==> Pulling Docker image: $IMAGE_NAME"
if ! docker pull "$IMAGE_NAME"; then
echo "ERROR: Failed to pull image $IMAGE_NAME. Please check the image name and your access permissions." >&2
exit 1
fi
fi
# Ensure bind-mounted data directories are writable by the container's `node`
# user (uid 1000). Host-created dirs inherit the host user's uid which may
# differ, causing EACCES when the container tries to mkdir/write.
# Running a brief root container to chown is the portable Docker idiom --
# it works regardless of the host uid and doesn't require host-side root.
echo ""
echo "==> Fixing data-directory permissions"
# Use -xdev to restrict chown to the config-dir mount only — without it,
# the recursive chown would cross into the workspace bind mount and rewrite
# ownership of all user project files on Linux hosts.
# After fixing the config dir, only the OpenClaw metadata subdirectory
# (.openclaw/) inside the workspace gets chowned, not the user's project files.
run_prestart_gateway --user root --entrypoint sh openclaw-gateway -c \
'find /home/node/.openclaw -xdev -exec chown node:node {} +; \
[ -d /home/node/.openclaw/workspace/.openclaw ] && chown -R node:node /home/node/.openclaw/workspace/.openclaw || true'
echo ""
echo "==> Onboarding (interactive)"
echo "Docker setup pins Gateway mode to local."
echo "Gateway runtime bind comes from OPENCLAW_GATEWAY_BIND (default: lan)."
echo "Current runtime bind: $OPENCLAW_GATEWAY_BIND"
echo "Gateway token: $OPENCLAW_GATEWAY_TOKEN"
echo "Tailscale exposure: Off (use host-level tailnet/Tailscale setup separately)."
echo "Install Gateway daemon: No (managed by Docker Compose)"
echo ""
run_prestart_cli onboard --mode local --no-install-daemon
echo ""
echo "==> Docker gateway defaults"
sync_gateway_config
echo ""
echo "==> Provider setup (optional)"
echo "WhatsApp (QR):"
echo " ${COMPOSE_HINT} run --rm openclaw-cli channels login"
echo "Telegram (bot token):"
echo " ${COMPOSE_HINT} run --rm openclaw-cli channels add --channel telegram --token <token>"
echo "Discord (bot token):"
echo " ${COMPOSE_HINT} run --rm openclaw-cli channels add --channel discord --token <token>"
echo "Docs: https://docs.openclaw.ai/channels"
echo ""
echo "==> Starting gateway"
docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway
# --- Sandbox setup (opt-in via OPENCLAW_SANDBOX=1) ---
if [[ -n "$SANDBOX_ENABLED" ]]; then
echo ""
echo "==> Sandbox setup"
# Build sandbox image if Dockerfile.sandbox exists.
if [[ -f "$ROOT_DIR/Dockerfile.sandbox" ]]; then
echo "Building sandbox image: openclaw-sandbox:bookworm-slim"
run_docker_build \
-t "openclaw-sandbox:bookworm-slim" \
-f "$ROOT_DIR/Dockerfile.sandbox" \
"$ROOT_DIR"
else
echo "WARNING: Dockerfile.sandbox not found in $ROOT_DIR" >&2
echo " Sandbox config will be applied but no sandbox image will be built." >&2
echo " Agent exec may fail if the configured sandbox image does not exist." >&2
fi
# Defense-in-depth: verify Docker CLI in the running image before enabling
# sandbox. This avoids claiming sandbox is enabled when the image cannot
# launch sandbox containers.
if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --entrypoint docker openclaw-gateway --version >/dev/null 2>&1; then
echo "WARNING: Docker CLI not found inside the container image." >&2
echo " Sandbox requires Docker CLI. Rebuild with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1" >&2
echo " or use a local build (OPENCLAW_IMAGE=openclaw:local). Skipping sandbox setup." >&2
SANDBOX_ENABLED=""
fi
fi
# Apply sandbox config only if prerequisites are met.
if [[ -n "$SANDBOX_ENABLED" ]]; then
# Mount Docker socket via a dedicated compose overlay. This overlay is
# created only after sandbox prerequisites pass, so the socket is never
# exposed when sandbox cannot actually run.
if [[ -S "$DOCKER_SOCKET_PATH" ]]; then
SANDBOX_COMPOSE_FILE="$ROOT_DIR/docker-compose.sandbox.yml"
cat >"$SANDBOX_COMPOSE_FILE" <<YAML
services:
openclaw-gateway:
volumes:
- ${DOCKER_SOCKET_PATH}:/var/run/docker.sock
YAML
if [[ -n "${DOCKER_GID:-}" ]]; then
cat >>"$SANDBOX_COMPOSE_FILE" <<YAML
group_add:
- "${DOCKER_GID}"
YAML
fi
COMPOSE_ARGS+=("-f" "$SANDBOX_COMPOSE_FILE")
echo "==> Sandbox: added Docker socket mount"
else
echo "WARNING: OPENCLAW_SANDBOX enabled but Docker socket not found at $DOCKER_SOCKET_PATH." >&2
echo " Sandbox requires Docker socket access. Skipping sandbox setup." >&2
SANDBOX_ENABLED=""
fi
fi
if [[ -n "$SANDBOX_ENABLED" ]]; then
# Enable sandbox in OpenClaw config.
sandbox_config_ok=true
if ! run_runtime_cli current no-deps \
config set agents.defaults.sandbox.mode "non-main" >/dev/null; then
echo "WARNING: Failed to set agents.defaults.sandbox.mode" >&2
sandbox_config_ok=false
fi
if ! run_runtime_cli current no-deps \
config set agents.defaults.sandbox.scope "agent" >/dev/null; then
echo "WARNING: Failed to set agents.defaults.sandbox.scope" >&2
sandbox_config_ok=false
fi
if ! run_runtime_cli current no-deps \
config set agents.defaults.sandbox.workspaceAccess "none" >/dev/null; then
echo "WARNING: Failed to set agents.defaults.sandbox.workspaceAccess" >&2
sandbox_config_ok=false
fi
if [[ "$sandbox_config_ok" == true ]]; then
echo "Sandbox enabled: mode=non-main, scope=agent, workspaceAccess=none"
echo "Docs: https://docs.openclaw.ai/gateway/sandboxing"
# Restart gateway with sandbox compose overlay to pick up socket mount + config.
docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway
else
echo "WARNING: Sandbox config was partially applied. Check errors above." >&2
echo " Skipping gateway restart to avoid exposing Docker socket without a full sandbox policy." >&2
if ! run_runtime_cli base no-deps \
config set agents.defaults.sandbox.mode "off" >/dev/null; then
echo "WARNING: Failed to roll back agents.defaults.sandbox.mode to off" >&2
else
echo "Sandbox mode rolled back to off due to partial sandbox config failure."
fi
if [[ -n "${SANDBOX_COMPOSE_FILE:-}" ]]; then
rm -f "$SANDBOX_COMPOSE_FILE"
fi
# Ensure gateway service definition is reset without sandbox overlay mount.
docker compose "${BASE_COMPOSE_ARGS[@]}" up -d --force-recreate openclaw-gateway
fi
else
# Keep reruns deterministic: if sandbox is not active for this run, reset
# persisted sandbox mode so future execs do not require docker.sock by stale
# config alone.
if ! run_runtime_cli current with-deps \
config set agents.defaults.sandbox.mode "off" >/dev/null; then
echo "WARNING: Failed to reset agents.defaults.sandbox.mode to off" >&2
fi
if [[ -f "$ROOT_DIR/docker-compose.sandbox.yml" ]]; then
rm -f "$ROOT_DIR/docker-compose.sandbox.yml"
fi
fi
echo ""
echo "Gateway running with host port mapping."
echo "Access from tailnet devices via the host's tailnet IP."
echo "Config: $OPENCLAW_CONFIG_DIR"
echo "Workspace: $OPENCLAW_WORKSPACE_DIR"
echo "Token: $OPENCLAW_GATEWAY_TOKEN"
echo ""
echo "Commands:"
echo " ${COMPOSE_HINT} logs -f openclaw-gateway"
echo " ${COMPOSE_HINT} exec openclaw-gateway node dist/index.js health --token \"$OPENCLAW_GATEWAY_TOKEN\""

View file

@ -0,0 +1,278 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
type behaviorReplacePair struct {
From string `json:"from"`
To string `json:"to"`
}
type behaviorRule struct {
Method string `json:"method"`
MatchAll []string `json:"match_all"`
ResponseFile string `json:"response_file,omitempty"`
ReplacePairs []behaviorReplacePair `json:"replace_pairs,omitempty"`
}
type behaviorFixture struct {
Name string `json:"name"`
Mode string `json:"mode"`
RelPath string `json:"rel_path"`
SourceFile string `json:"source_file"`
ExpectedFile string `json:"expected_file,omitempty"`
ExpectedErrorContains string `json:"expected_error_contains,omitempty"`
ExpectedOutputContains []string `json:"expected_output_contains,omitempty"`
ExpectedOutputNotContains []string `json:"expected_output_not_contains,omitempty"`
Rules []behaviorRule `json:"rules"`
}
type behaviorFixtureTranslator struct {
t *testing.T
dir string
rules []behaviorRule
}
func (tr *behaviorFixtureTranslator) Translate(_ context.Context, text, _, _ string) (string, error) {
return tr.run("masked", text), nil
}
func (tr *behaviorFixtureTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
return tr.run("raw", text), nil
}
func (tr *behaviorFixtureTranslator) Close() {}
func (tr *behaviorFixtureTranslator) run(method, text string) string {
tr.t.Helper()
for _, rule := range tr.rules {
if rule.Method != method {
continue
}
if !matchesAll(text, rule.MatchAll) {
continue
}
switch {
case rule.ResponseFile != "":
return readFixtureTextInDir(tr.t, tr.dir, rule.ResponseFile)
case len(rule.ReplacePairs) > 0:
out := text
for _, pair := range rule.ReplacePairs {
out = strings.ReplaceAll(out, pair.From, pair.To)
}
return out
default:
return text
}
}
return text
}
func matchesAll(text string, fragments []string) bool {
for _, fragment := range fragments {
if !strings.Contains(text, fragment) {
return false
}
}
return true
}
func TestDocsI18nBehaviorBaselines(t *testing.T) {
t.Parallel()
root := filepath.Join("testdata", "behavior")
entries, err := os.ReadDir(root)
if err != nil {
t.Fatalf("ReadDir(%q): %v", root, err)
}
found := false
for _, entry := range entries {
if !entry.IsDir() {
continue
}
found = true
dir := filepath.Join(root, entry.Name())
fixture := loadBehaviorFixture(t, dir)
name := fixture.Name
if name == "" {
name = entry.Name()
}
t.Run(name, func(t *testing.T) {
t.Parallel()
runBehaviorFixture(t, dir, fixture)
})
}
if !found {
t.Fatalf("no behavior fixtures found under %s", root)
}
}
func loadBehaviorFixture(t *testing.T, dir string) behaviorFixture {
t.Helper()
data, err := os.ReadFile(filepath.Join(dir, "case.json"))
if err != nil {
t.Fatalf("ReadFile(case.json): %v", err)
}
var fixture behaviorFixture
if err := json.Unmarshal(data, &fixture); err != nil {
t.Fatalf("Unmarshal(case.json): %v", err)
}
return fixture
}
func runBehaviorFixture(t *testing.T, dir string, fixture behaviorFixture) {
t.Helper()
source := readFixtureTextInDir(t, dir, fixture.SourceFile)
translator := &behaviorFixtureTranslator{
t: t,
dir: dir,
rules: fixture.Rules,
}
var (
got string
err error
)
switch fixture.Mode {
case "doc_body_chunked":
got, err = translateDocBodyChunked(context.Background(), translator, fixture.RelPath, source, "en", "zh-CN")
case "frontmatter_scalar":
got, err = translateSnippet(
context.Background(),
translator,
&TranslationMemory{entries: map[string]TMEntry{}},
fixture.RelPath+":frontmatter:title",
source,
"en",
"zh-CN",
)
default:
t.Fatalf("unsupported fixture mode %q", fixture.Mode)
}
if fixture.ExpectedErrorContains != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", fixture.ExpectedErrorContains)
}
if !strings.Contains(err.Error(), fixture.ExpectedErrorContains) {
t.Fatalf("expected error containing %q, got %v", fixture.ExpectedErrorContains, err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fixture.ExpectedFile != "" {
want := readFixtureTextInDir(t, dir, fixture.ExpectedFile)
if normalizeBehaviorText(got) != normalizeBehaviorText(want) {
t.Fatalf("unexpected output\nwant:\n%s\n\ngot:\n%s", want, got)
}
}
for _, fragment := range fixture.ExpectedOutputContains {
if !strings.Contains(got, fragment) {
t.Fatalf("expected output to contain %q\noutput:\n%s", fragment, got)
}
}
for _, fragment := range fixture.ExpectedOutputNotContains {
if strings.Contains(got, fragment) {
t.Fatalf("expected output to exclude %q\noutput:\n%s", fragment, got)
}
}
}
func readFixtureText(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%q): %v", path, err)
}
return string(data)
}
func readFixtureTextInDir(t *testing.T, dir, name string) string {
t.Helper()
resolvedPath, err := resolveFixturePathInDir(dir, name)
if err != nil {
t.Fatal(err)
}
return readFixtureText(t, resolvedPath)
}
func resolveFixturePathInDir(dir, name string) (string, error) {
if filepath.IsAbs(name) {
return "", fmt.Errorf("absolute fixture paths are not allowed: %q", name)
}
clean := filepath.Clean(name)
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("fixture path escapes dir: %q", name)
}
joined := filepath.Join(dir, clean)
resolvedDir, err := filepath.EvalSymlinks(dir)
if err != nil {
return "", fmt.Errorf("EvalSymlinks(%q): %w", dir, err)
}
resolvedPath, err := filepath.EvalSymlinks(joined)
if err != nil {
return "", fmt.Errorf("EvalSymlinks(%q): %w", joined, err)
}
rel, err := filepath.Rel(resolvedDir, resolvedPath)
if err != nil {
return "", fmt.Errorf("Rel(%q, %q): %w", resolvedDir, resolvedPath, err)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("fixture path resolves outside dir: %q", name)
}
return resolvedPath, nil
}
func normalizeBehaviorText(value string) string {
return strings.TrimSpace(strings.ReplaceAll(value, "\r\n", "\n"))
}
func TestResolveFixturePathInDirRejectsSymlinkEscape(t *testing.T) {
t.Parallel()
root := t.TempDir()
fixtureDir := filepath.Join(root, "fixture")
if err := os.MkdirAll(fixtureDir, 0o755); err != nil {
t.Fatalf("MkdirAll(%q): %v", fixtureDir, err)
}
outsidePath := filepath.Join(root, "outside.txt")
if err := os.WriteFile(outsidePath, []byte("outside\n"), 0o644); err != nil {
t.Fatalf("WriteFile(%q): %v", outsidePath, err)
}
linkPath := filepath.Join(fixtureDir, "outside-link.txt")
if err := os.Symlink(outsidePath, linkPath); err != nil {
if os.IsPermission(err) || runtime.GOOS == "windows" {
t.Skipf("symlink creation unavailable in this test environment: %v", err)
}
t.Fatalf("Symlink(%q, %q): %v", outsidePath, linkPath, err)
}
_, err := resolveFixturePathInDir(fixtureDir, "outside-link.txt")
if err == nil {
t.Fatal("expected symlink escape to fail")
}
if !strings.Contains(err.Error(), "resolves outside dir") {
t.Fatalf("expected outside-dir error, got %v", err)
}
}

View file

@ -0,0 +1,830 @@
package main
import (
"context"
"fmt"
"log"
"os"
"regexp"
"slices"
"strconv"
"strings"
)
const defaultDocChunkMaxBytes = 12000
const defaultDocChunkPromptBudget = 15000
var (
docsFenceRE = regexp.MustCompile(`^\s*(` + "```" + `|~~~)`)
docsComponentTagRE = regexp.MustCompile(`<(/?)([A-Z][A-Za-z0-9]*)\b[^>]*?/?>`)
)
var docsProtocolTokens = []string{
frontmatterTagStart,
frontmatterTagEnd,
bodyTagStart,
bodyTagEnd,
"[[[FM_",
}
type docChunkStructure struct {
fenceCount int
tagCounts map[string]int
}
type docChunkSplitPlan struct {
groups [][]string
reason string
}
func translateDocBodyChunked(ctx context.Context, translator docsTranslator, relPath, body, srcLang, tgtLang string) (string, error) {
if strings.TrimSpace(body) == "" {
return body, nil
}
blocks := splitDocBodyIntoBlocks(body)
groups := groupDocBlocks(blocks, docsI18nDocChunkMaxBytes())
logDocChunkPlan(relPath, blocks, groups)
out := strings.Builder{}
for index, group := range groups {
chunkID := fmt.Sprintf("%s.chunk-%03d", relPath, index+1)
translated, err := translateDocBlockGroup(ctx, translator, chunkID, group, srcLang, tgtLang)
if err != nil {
return "", err
}
out.WriteString(translated)
}
return out.String(), nil
}
func translateDocBlockGroup(ctx context.Context, translator docsTranslator, chunkID string, blocks []string, srcLang, tgtLang string) (string, error) {
source := strings.Join(blocks, "")
if strings.TrimSpace(source) == "" {
return source, nil
}
if plan, ok := planDocChunkSplit(blocks, docsI18nDocChunkMaxBytes(), docsI18nDocChunkPromptBudget()); ok {
logDocChunkPlanSplit(chunkID, plan, source)
return translatePlannedDocChunkGroups(ctx, translator, chunkID, plan.groups, srcLang, tgtLang)
}
normalizedSource, commonIndent := stripCommonIndent(source)
log.Printf("docs-i18n: chunk start %s blocks=%d bytes=%d", chunkID, len(blocks), len(source))
translated, err := translator.TranslateRaw(ctx, normalizedSource, srcLang, tgtLang)
if err == nil {
translated = sanitizeDocChunkProtocolWrappers(source, translated)
translated = reapplyCommonIndent(translated, commonIndent)
if validationErr := validateDocChunkTranslation(source, translated); validationErr == nil {
log.Printf("docs-i18n: chunk done %s out_bytes=%d", chunkID, len(translated))
return translated, nil
} else {
err = validationErr
}
}
if len(blocks) <= 1 {
if fallback, fallbackErr := translateDocLeafBlock(ctx, translator, chunkID, source, srcLang, tgtLang); fallbackErr == nil {
return fallback, nil
}
if plan, ok := planSingletonDocChunkRetry(source, docsI18nDocChunkMaxBytes(), docsI18nDocChunkPromptBudget()); ok {
logDocChunkPlanSplit(chunkID, plan, source)
return translatePlannedDocChunkGroups(ctx, translator, chunkID, plan.groups, srcLang, tgtLang)
}
return "", fmt.Errorf("%s: %w", chunkID, err)
}
if plan, ok := planDocChunkSplit(blocks, docsI18nDocChunkMaxBytes(), docsI18nDocChunkPromptBudget()); ok {
logDocChunkSplit(chunkID, len(blocks), err)
return translatePlannedDocChunkGroups(ctx, translator, chunkID, plan.groups, srcLang, tgtLang)
}
if plan, ok := splitDocChunkBlocksMidpointSimple(blocks); ok {
logDocChunkSplit(chunkID, len(blocks), err)
return translatePlannedDocChunkGroups(ctx, translator, chunkID, plan.groups, srcLang, tgtLang)
}
return "", fmt.Errorf("%s: %w", chunkID, err)
}
func translateDocLeafBlock(ctx context.Context, translator docsTranslator, chunkID, source, srcLang, tgtLang string) (string, error) {
sourceStructure := summarizeDocChunkStructure(source)
if sourceStructure.fenceCount != 0 {
return "", fmt.Errorf("%s: raw leaf fallback not applicable", chunkID)
}
normalizedSource, commonIndent := stripCommonIndent(source)
maskedSource, placeholders := maskDocComponentTags(normalizedSource)
translated, err := translator.Translate(ctx, maskedSource, srcLang, tgtLang)
if err != nil {
return "", err
}
translated, err = restoreDocComponentTags(translated, placeholders)
if err != nil {
return "", err
}
translated = sanitizeDocChunkProtocolWrappers(source, translated)
translated = reapplyCommonIndent(translated, commonIndent)
if validationErr := validateDocChunkTranslation(source, translated); validationErr != nil {
return "", validationErr
}
log.Printf("docs-i18n: chunk leaf-fallback done %s out_bytes=%d", chunkID, len(translated))
return translated, nil
}
func splitDocBodyIntoBlocks(body string) []string {
if body == "" {
return nil
}
lines := strings.SplitAfter(body, "\n")
blocks := make([]string, 0, len(lines))
var current strings.Builder
fenceDelimiter := ""
for _, line := range lines {
current.WriteString(line)
fenceDelimiter, _ = updateFenceDelimiter(fenceDelimiter, line)
inFence := fenceDelimiter != ""
if !inFence && strings.TrimSpace(line) == "" {
blocks = append(blocks, current.String())
current.Reset()
}
}
if current.Len() > 0 {
blocks = append(blocks, current.String())
}
if len(blocks) == 0 {
return []string{body}
}
return blocks
}
func groupDocBlocks(blocks []string, maxBytes int) [][]string {
if len(blocks) == 0 {
return nil
}
if maxBytes <= 0 {
maxBytes = defaultDocChunkMaxBytes
}
groups := make([][]string, 0, len(blocks))
current := make([]string, 0, 8)
currentBytes := 0
flush := func() {
if len(current) == 0 {
return
}
groups = append(groups, current)
current = make([]string, 0, 8)
currentBytes = 0
}
for _, block := range blocks {
blockBytes := len(block)
if len(current) > 0 && currentBytes+blockBytes > maxBytes {
flush()
}
if blockBytes > maxBytes {
groups = append(groups, []string{block})
continue
}
current = append(current, block)
currentBytes += blockBytes
}
flush()
return groups
}
func validateDocChunkTranslation(source, translated string) error {
if hasUnexpectedTopLevelProtocolWrapper(source, translated) {
return fmt.Errorf("protocol token leaked: top-level wrapper")
}
sourceLower := strings.ToLower(source)
translatedLower := strings.ToLower(translated)
for _, token := range docsProtocolTokens {
tokenLower := strings.ToLower(token)
if strings.Contains(sourceLower, tokenLower) {
continue
}
if strings.Contains(translatedLower, tokenLower) {
return fmt.Errorf("protocol token leaked: %s", token)
}
}
sourceStructure := summarizeDocChunkStructure(source)
translatedStructure := summarizeDocChunkStructure(translated)
if sourceStructure.fenceCount != translatedStructure.fenceCount {
return fmt.Errorf("code fence mismatch: source=%d translated=%d", sourceStructure.fenceCount, translatedStructure.fenceCount)
}
if !slices.Equal(sortedKeys(sourceStructure.tagCounts), sortedKeys(translatedStructure.tagCounts)) {
return fmt.Errorf("component tag set mismatch")
}
for _, key := range sortedKeys(sourceStructure.tagCounts) {
if sourceStructure.tagCounts[key] != translatedStructure.tagCounts[key] {
return fmt.Errorf("component tag mismatch for %s: source=%d translated=%d", key, sourceStructure.tagCounts[key], translatedStructure.tagCounts[key])
}
}
return nil
}
func sanitizeDocChunkProtocolWrappers(source, translated string) string {
if !containsProtocolWrapperToken(translated) {
return translated
}
trimmedTranslated := strings.TrimSpace(translated)
if !hasUnexpectedTopLevelProtocolWrapper(source, trimmedTranslated) {
return translated
}
if !hasAmbiguousTaggedBodyClose(source, trimmedTranslated) {
_, body, err := parseTaggedDocument(trimmedTranslated)
if err == nil {
if strings.TrimSpace(body) == "" {
return translated
}
return body
}
}
body, ok := stripBodyOnlyWrapper(source, trimmedTranslated)
if !ok || strings.TrimSpace(body) == "" {
return translated
}
return body
}
func stripBodyOnlyWrapper(source, text string) (string, bool) {
sourceLower := strings.ToLower(source)
// When the source itself documents <body> tokens, a bare body-only payload is
// ambiguous: the trailing </body> can be literal translated content instead of
// a real wrapper close. Keep it for validation/retry instead of truncating.
if strings.Contains(sourceLower, strings.ToLower(bodyTagStart)) || strings.Contains(sourceLower, strings.ToLower(bodyTagEnd)) {
return "", false
}
lower := strings.ToLower(text)
bodyStartLower := strings.ToLower(bodyTagStart)
bodyEndLower := strings.ToLower(bodyTagEnd)
if !strings.HasPrefix(lower, bodyStartLower) || !strings.HasSuffix(lower, bodyEndLower) {
return "", false
}
body := text[len(bodyTagStart) : len(text)-len(bodyTagEnd)]
bodyLower := lower[len(bodyTagStart) : len(lower)-len(bodyTagEnd)]
if strings.Contains(bodyLower, bodyStartLower) || strings.Contains(bodyLower, bodyEndLower) {
return "", false
}
return trimTagNewlines(body), true
}
func hasAmbiguousTaggedBodyClose(source, translated string) bool {
sourceLower := strings.ToLower(source)
if !strings.Contains(sourceLower, strings.ToLower(bodyTagStart)) && !strings.Contains(sourceLower, strings.ToLower(bodyTagEnd)) {
return false
}
translatedLower := strings.ToLower(translated)
if !strings.Contains(translatedLower, strings.ToLower(frontmatterTagStart)) {
return false
}
return strings.Count(translatedLower, strings.ToLower(bodyTagEnd)) == 1
}
func maskDocComponentTags(text string) (string, []string) {
placeholders := make([]string, 0, 4)
masked := docsComponentTagRE.ReplaceAllStringFunc(text, func(match string) string {
placeholder := fmt.Sprintf("__OC_DOC_TAG_%03d__", len(placeholders))
placeholders = append(placeholders, match)
return placeholder
})
return masked, placeholders
}
func restoreDocComponentTags(text string, placeholders []string) (string, error) {
restored := text
for index, original := range placeholders {
placeholder := fmt.Sprintf("__OC_DOC_TAG_%03d__", index)
if !strings.Contains(restored, placeholder) {
return "", fmt.Errorf("component tag placeholder missing: %s", placeholder)
}
restored = strings.ReplaceAll(restored, placeholder, original)
}
return restored, nil
}
func logDocChunkSplit(chunkID string, blockCount int, err error) {
if docsI18nVerboseLogs() || blockCount >= 16 {
log.Printf("docs-i18n: chunk split %s blocks=%d err=%v", chunkID, blockCount, err)
}
}
func logDocChunkPlanSplit(chunkID string, plan docChunkSplitPlan, source string) {
if plan.reason == "" {
plan.reason = "unknown"
}
log.Printf("docs-i18n: chunk pre-split %s reason=%s groups=%d bytes=%d", chunkID, plan.reason, len(plan.groups), len(source))
}
func summarizeDocChunkStructure(text string) docChunkStructure {
counts := map[string]int{}
lines := strings.Split(text, "\n")
fenceDelimiter := ""
for _, line := range lines {
var toggled bool
fenceDelimiter, toggled = updateFenceDelimiter(fenceDelimiter, line)
if toggled {
counts["__fence_toggle__"]++
}
for _, match := range docsComponentTagRE.FindAllStringSubmatch(line, -1) {
if len(match) < 3 {
continue
}
fullToken := match[0]
tagName := match[2]
direction := "open"
if match[1] == "/" {
direction = "close"
}
if strings.HasSuffix(fullToken, "/>") {
direction = "self"
}
counts[tagName+":"+direction]++
}
}
return docChunkStructure{
fenceCount: counts["__fence_toggle__"],
tagCounts: countsWithoutFence(counts),
}
}
func countsWithoutFence(counts map[string]int) map[string]int {
filtered := map[string]int{}
for key, value := range counts {
if key == "__fence_toggle__" {
continue
}
filtered[key] = value
}
return filtered
}
func sortedKeys(counts map[string]int) []string {
keys := make([]string, 0, len(counts))
for key := range counts {
keys = append(keys, key)
}
slices.Sort(keys)
return keys
}
func updateFenceDelimiter(current, line string) (string, bool) {
delimiter := leadingFenceDelimiter(line)
if delimiter == "" {
return current, false
}
if current == "" {
return delimiter, true
}
if delimiter[0] == current[0] && len(delimiter) >= len(current) && isClosingFenceLine(line, delimiter) {
return "", true
}
return current, false
}
func leadingFenceDelimiter(line string) string {
trimmed := strings.TrimLeft(line, " \t")
if len(trimmed) < 3 {
return ""
}
switch trimmed[0] {
case '`', '~':
default:
return ""
}
marker := trimmed[0]
index := 0
for index < len(trimmed) && trimmed[index] == marker {
index++
}
if index < 3 {
return ""
}
return trimmed[:index]
}
func isClosingFenceLine(line, delimiter string) bool {
trimmed := strings.TrimLeft(line, " \t")
if !strings.HasPrefix(trimmed, delimiter) {
return false
}
return strings.TrimSpace(trimmed[len(delimiter):]) == ""
}
func hasUnexpectedTopLevelProtocolWrapper(source, translated string) bool {
sourceTrimmed := strings.ToLower(strings.TrimSpace(source))
translatedTrimmed := strings.ToLower(strings.TrimSpace(translated))
checks := []struct {
token string
match func(string) bool
}{
{token: frontmatterTagStart, match: func(text string) bool { return strings.HasPrefix(text, strings.ToLower(frontmatterTagStart)) }},
{token: bodyTagStart, match: func(text string) bool { return strings.HasPrefix(text, strings.ToLower(bodyTagStart)) }},
{token: frontmatterTagEnd, match: func(text string) bool { return strings.HasSuffix(text, strings.ToLower(frontmatterTagEnd)) }},
{token: bodyTagEnd, match: func(text string) bool { return strings.HasSuffix(text, strings.ToLower(bodyTagEnd)) }},
}
for _, check := range checks {
if check.match(translatedTrimmed) && !check.match(sourceTrimmed) {
return true
}
}
return false
}
func containsProtocolWrapperToken(text string) bool {
lower := strings.ToLower(text)
return strings.Contains(lower, strings.ToLower(bodyTagStart)) || strings.Contains(lower, strings.ToLower(frontmatterTagStart))
}
func translatePlannedDocChunkGroups(ctx context.Context, translator docsTranslator, chunkID string, groups [][]string, srcLang, tgtLang string) (string, error) {
var out strings.Builder
for index, group := range groups {
translated, err := translateDocBlockGroup(ctx, translator, fmt.Sprintf("%s.%02d", chunkID, index+1), group, srcLang, tgtLang)
if err != nil {
return "", err
}
out.WriteString(translated)
}
return out.String(), nil
}
func planDocChunkSplit(blocks []string, maxBytes, promptBudget int) (docChunkSplitPlan, bool) {
if len(blocks) == 0 {
return docChunkSplitPlan{}, false
}
source := strings.Join(blocks, "")
if strings.TrimSpace(source) == "" {
return docChunkSplitPlan{}, false
}
normalizedSource, _ := stripCommonIndent(source)
estimatedPromptCost := estimateDocPromptCost(normalizedSource)
if len(blocks) > 1 && promptBudget > 0 && estimatedPromptCost > promptBudget {
return splitDocChunkBlocksMidpoint(blocks, estimatedPromptCost, promptBudget)
}
if len(blocks) == 1 {
return planSingletonDocChunk(blocks[0], maxBytes, promptBudget)
}
return docChunkSplitPlan{}, false
}
func splitDocChunkBlocksMidpoint(blocks []string, estimatedPromptCost, promptBudget int) (docChunkSplitPlan, bool) {
if len(blocks) <= 1 {
return docChunkSplitPlan{}, false
}
mid := len(blocks) / 2
if mid <= 0 || mid >= len(blocks) {
return docChunkSplitPlan{}, false
}
return docChunkSplitPlan{
groups: [][]string{blocks[:mid], blocks[mid:]},
reason: fmt.Sprintf("prompt-budget:%d>%d", estimatedPromptCost, promptBudget),
}, true
}
func splitDocChunkBlocksMidpointSimple(blocks []string) (docChunkSplitPlan, bool) {
if len(blocks) <= 1 {
return docChunkSplitPlan{}, false
}
mid := len(blocks) / 2
if mid <= 0 || mid >= len(blocks) {
return docChunkSplitPlan{}, false
}
return docChunkSplitPlan{
groups: [][]string{blocks[:mid], blocks[mid:]},
reason: "retry-midpoint",
}, true
}
func planSingletonDocChunk(block string, maxBytes, promptBudget int) (docChunkSplitPlan, bool) {
normalizedBlock, _ := stripCommonIndent(block)
estimatedPromptCost := estimateDocPromptCost(normalizedBlock)
overBytes := maxBytes > 0 && len(block) > maxBytes
overPrompt := promptBudget > 0 && estimatedPromptCost > promptBudget
if !overBytes && !overPrompt {
return docChunkSplitPlan{}, false
}
return planSingletonDocChunkWithMode(block, maxBytes, promptBudget, false)
}
func planSingletonDocChunkRetry(block string, maxBytes, promptBudget int) (docChunkSplitPlan, bool) {
return planSingletonDocChunkWithMode(block, maxBytes, promptBudget, true)
}
func planSingletonDocChunkWithMode(block string, maxBytes, promptBudget int, force bool) (docChunkSplitPlan, bool) {
if sections := splitDocBlockSections(block); len(sections) > 1 {
if groups := wrapDocChunkSections(sections); len(groups) > 1 {
reason := "singleton-structural"
if force {
reason = "singleton-retry-structural"
}
return docChunkSplitPlan{
groups: groups,
reason: reason,
}, true
}
}
if groups, ok := splitPureFencedDocSectionWithMode(block, maxBytes, promptBudget, force); ok {
reason := "singleton-fence"
if force {
reason = "singleton-retry-fence"
}
return docChunkSplitPlan{
groups: groups,
reason: reason,
}, true
}
if groups, ok := splitPlainDocSectionWithMode(block, maxBytes, promptBudget, force); ok {
reason := "singleton-lines"
if force {
reason = "singleton-retry-lines"
}
return docChunkSplitPlan{
groups: groups,
reason: reason,
}, true
}
return docChunkSplitPlan{}, false
}
func wrapDocChunkSections(sections []string) [][]string {
groups := make([][]string, 0, len(sections))
for _, section := range sections {
if strings.TrimSpace(section) == "" {
continue
}
groups = append(groups, []string{section})
}
return groups
}
func splitDocBlockSections(block string) []string {
lines := strings.SplitAfter(block, "\n")
if len(lines) == 0 {
return nil
}
sections := make([]string, 0, len(lines))
var current strings.Builder
fenceDelimiter := ""
for _, line := range lines {
lineDelimiter := leadingFenceDelimiter(line)
if fenceDelimiter == "" && lineDelimiter != "" {
if current.Len() > 0 {
sections = append(sections, current.String())
current.Reset()
}
current.WriteString(line)
fenceDelimiter = lineDelimiter
continue
}
current.WriteString(line)
if fenceDelimiter != "" {
if lineDelimiter != "" && lineDelimiter[0] == fenceDelimiter[0] && len(lineDelimiter) >= len(fenceDelimiter) && isClosingFenceLine(line, fenceDelimiter) {
sections = append(sections, current.String())
current.Reset()
fenceDelimiter = ""
}
continue
}
if strings.TrimSpace(line) == "" {
sections = append(sections, current.String())
current.Reset()
}
}
if current.Len() > 0 {
sections = append(sections, current.String())
}
if len(sections) <= 1 {
return nil
}
return sections
}
func splitPureFencedDocSection(block string, maxBytes, promptBudget int) ([][]string, bool) {
return splitPureFencedDocSectionWithMode(block, maxBytes, promptBudget, false)
}
func splitPureFencedDocSectionWithMode(block string, maxBytes, promptBudget int, force bool) ([][]string, bool) {
lines := strings.SplitAfter(block, "\n")
if len(lines) < 2 {
return nil, false
}
openingIndex := firstNonEmptyLineIndex(lines)
closingIndex := lastNonEmptyLineIndex(lines)
if openingIndex == -1 || closingIndex <= openingIndex {
return nil, false
}
opening := lines[openingIndex]
delimiter := leadingFenceDelimiter(opening)
if delimiter == "" || !isClosingFenceLine(lines[closingIndex], delimiter) {
return nil, false
}
prefix := strings.Join(lines[:openingIndex], "")
suffix := strings.Join(lines[closingIndex+1:], "")
if strings.TrimSpace(prefix) != "" || strings.TrimSpace(suffix) != "" {
return nil, false
}
closing := lines[closingIndex]
inner := strings.Join(lines[openingIndex+1:closingIndex], "")
groups, ok := splitPlainDocSectionWithMode(inner, maxBytes-len(opening)-len(closing), promptBudget, force)
if !ok {
return nil, false
}
for index, group := range groups {
joined := strings.Join(group, "")
groups[index] = []string{opening + joined + closing}
}
return groups, true
}
func splitPlainDocSection(text string, maxBytes, promptBudget int) ([][]string, bool) {
return splitPlainDocSectionWithMode(text, maxBytes, promptBudget, false)
}
func splitPlainDocSectionWithMode(text string, maxBytes, promptBudget int, force bool) ([][]string, bool) {
if maxBytes <= 0 {
maxBytes = len(text)
}
if promptBudget <= 0 {
promptBudget = defaultDocChunkPromptBudget
}
lines := strings.SplitAfter(text, "\n")
if len(lines) <= 1 {
return nil, false
}
groups := make([][]string, 0, len(lines))
var current strings.Builder
currentBytes := 0
currentPrompt := 0
for _, line := range lines {
linePrompt := estimateDocPromptCost(line)
if len(line) > maxBytes || linePrompt > promptBudget {
return nil, false
}
if currentBytes > 0 && (currentBytes+len(line) > maxBytes || currentPrompt+linePrompt > promptBudget) {
groups = append(groups, []string{current.String()})
current.Reset()
currentBytes = 0
currentPrompt = 0
}
current.WriteString(line)
currentBytes += len(line)
currentPrompt += linePrompt
}
if current.Len() > 0 {
groups = append(groups, []string{current.String()})
}
if len(groups) <= 1 {
if !force {
return nil, false
}
return splitPlainDocSectionMidpoint(lines)
}
return groups, true
}
func splitPlainDocSectionMidpoint(lines []string) ([][]string, bool) {
if len(lines) <= 1 {
return nil, false
}
mid := len(lines) / 2
if mid <= 0 || mid >= len(lines) {
return nil, false
}
left := strings.Join(lines[:mid], "")
right := strings.Join(lines[mid:], "")
if strings.TrimSpace(left) == "" || strings.TrimSpace(right) == "" {
return nil, false
}
return [][]string{{left}, {right}}, true
}
func firstNonEmptyLineIndex(lines []string) int {
for index, line := range lines {
if strings.TrimSpace(line) != "" {
return index
}
}
return -1
}
func lastNonEmptyLineIndex(lines []string) int {
for index := len(lines) - 1; index >= 0; index-- {
if strings.TrimSpace(lines[index]) != "" {
return index
}
}
return -1
}
func docsI18nDocChunkMaxBytes() int {
value := strings.TrimSpace(os.Getenv("OPENCLAW_DOCS_I18N_DOC_CHUNK_MAX_BYTES"))
if value == "" {
return defaultDocChunkMaxBytes
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
return defaultDocChunkMaxBytes
}
return parsed
}
func docsI18nDocChunkPromptBudget() int {
value := strings.TrimSpace(os.Getenv("OPENCLAW_DOCS_I18N_DOC_CHUNK_PROMPT_BUDGET"))
if value == "" {
return defaultDocChunkPromptBudget
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
return defaultDocChunkPromptBudget
}
return parsed
}
func estimateDocPromptCost(text string) int {
cost := len(text)
cost += strings.Count(text, "`") * 6
cost += strings.Count(text, "|") * 4
cost += strings.Count(text, "{") * 4
cost += strings.Count(text, "}") * 4
cost += strings.Count(text, "[") * 4
cost += strings.Count(text, "]") * 4
cost += strings.Count(text, ":") * 2
cost += strings.Count(text, "<") * 4
cost += strings.Count(text, ">") * 4
return cost
}
func stripCommonIndent(text string) (string, string) {
lines := strings.SplitAfter(text, "\n")
common := ""
for _, line := range lines {
trimmed := strings.TrimRight(line, "\r\n")
if strings.TrimSpace(trimmed) == "" {
continue
}
indent := leadingIndent(trimmed)
if common == "" {
common = indent
continue
}
common = commonIndentPrefix(common, indent)
if common == "" {
return text, ""
}
}
if common == "" {
return text, ""
}
var out strings.Builder
for _, line := range lines {
trimmed := strings.TrimRight(line, "\r\n")
if strings.TrimSpace(trimmed) == "" {
out.WriteString(line)
continue
}
if strings.HasPrefix(line, common) {
out.WriteString(strings.TrimPrefix(line, common))
continue
}
out.WriteString(line)
}
return out.String(), common
}
func reapplyCommonIndent(text, indent string) string {
if indent == "" || text == "" {
return text
}
lines := strings.SplitAfter(text, "\n")
var out strings.Builder
for _, line := range lines {
trimmed := strings.TrimRight(line, "\r\n")
if strings.TrimSpace(trimmed) == "" {
out.WriteString(line)
continue
}
out.WriteString(indent)
out.WriteString(line)
}
return out.String()
}
func leadingIndent(line string) string {
index := 0
for index < len(line) {
if line[index] != ' ' && line[index] != '\t' {
break
}
index++
}
return line[:index]
}
func commonIndentPrefix(a, b string) string {
limit := len(a)
if len(b) < limit {
limit = len(b)
}
index := 0
for index < limit && a[index] == b[index] {
index++
}
return a[:index]
}

View file

@ -0,0 +1,200 @@
package main
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
const (
frontmatterTagStart = "<frontmatter>"
frontmatterTagEnd = "</frontmatter>"
bodyTagStart = "<body>"
bodyTagEnd = "</body>"
)
func processFileDoc(ctx context.Context, translator docsTranslator, docsRoot, filePath, srcLang, tgtLang string, overwrite bool) (bool, string, error) {
absPath, relPath, err := resolveDocsPath(docsRoot, filePath)
if err != nil {
return false, "", err
}
content, err := os.ReadFile(absPath)
if err != nil {
return false, "", err
}
currentHash := hashBytes(content)
outputPath := filepath.Join(docsRoot, tgtLang, relPath)
if !overwrite {
skip, err := shouldSkipDoc(outputPath, currentHash)
if err != nil {
return false, "", err
}
if skip {
return true, "", nil
}
}
sourceFront, sourceBody := splitFrontMatter(string(content))
frontData := map[string]any{}
if strings.TrimSpace(sourceFront) != "" {
if err := yaml.Unmarshal([]byte(sourceFront), &frontData); err != nil {
return false, "", fmt.Errorf("frontmatter parse failed for %s: %w", relPath, err)
}
}
docTM := &TranslationMemory{entries: map[string]TMEntry{}}
if err := translateFrontMatter(ctx, translator, docTM, frontData, relPath, srcLang, tgtLang); err != nil {
return false, "", fmt.Errorf("frontmatter translation failed for %s: %w", relPath, err)
}
updatedFront, err := encodeFrontMatter(frontData, relPath, content)
if err != nil {
return false, "", err
}
translatedBody, err := translateDocBodyChunked(ctx, translator, relPath, sourceBody, srcLang, tgtLang)
if err != nil {
return false, "", fmt.Errorf("body translate failed for %s: %w", relPath, err)
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return false, "", err
}
output := updatedFront + translatedBody
return false, outputPath, os.WriteFile(outputPath, []byte(output), 0o644)
}
func formatTaggedDocument(frontMatter, body string) string {
return fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s", frontmatterTagStart, frontMatter, frontmatterTagEnd, bodyTagStart, body, bodyTagEnd)
}
func parseTaggedDocument(text string) (string, string, error) {
frontStart := strings.Index(text, frontmatterTagStart)
if frontStart == -1 {
return "", "", fmt.Errorf("missing %s", frontmatterTagStart)
}
frontStart += len(frontmatterTagStart)
frontEnd := strings.Index(text[frontStart:], frontmatterTagEnd)
if frontEnd == -1 {
return "", "", fmt.Errorf("missing %s", frontmatterTagEnd)
}
frontEnd += frontStart
bodyStart := strings.Index(text[frontEnd:], bodyTagStart)
if bodyStart == -1 {
return "", "", fmt.Errorf("missing %s", bodyTagStart)
}
bodyStart += frontEnd + len(bodyTagStart)
bodyEnd := findTaggedBodyEnd(text, bodyStart)
if bodyEnd == -1 {
return "", "", fmt.Errorf("missing %s", bodyTagEnd)
}
body := trimTagNewlines(text[bodyStart:bodyEnd])
suffix := strings.TrimSpace(text[bodyEnd+len(bodyTagEnd):])
prefix := strings.TrimSpace(text[:frontStart-len(frontmatterTagStart)])
if prefix != "" || suffix != "" {
return "", "", fmt.Errorf("unexpected text outside tagged sections")
}
frontMatter := trimTagNewlines(text[frontStart:frontEnd])
return frontMatter, body, nil
}
func findTaggedBodyEnd(text string, bodyStart int) int {
if bodyStart < 0 || bodyStart > len(text) {
return -1
}
search := text[bodyStart:]
candidate := -1
offset := 0
for {
index := strings.Index(search[offset:], bodyTagEnd)
if index == -1 {
return candidate
}
index += offset
absolute := bodyStart + index
suffix := strings.TrimSpace(text[absolute+len(bodyTagEnd):])
if suffix == "" {
candidate = absolute
}
offset = index + len(bodyTagEnd)
if offset >= len(search) {
return candidate
}
}
}
func trimTagNewlines(value string) string {
value = strings.TrimPrefix(value, "\n")
value = strings.TrimSuffix(value, "\n")
return value
}
func shouldSkipDoc(outputPath string, sourceHash string) (bool, error) {
data, err := os.ReadFile(outputPath)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
frontMatter, _ := splitFrontMatter(string(data))
if frontMatter == "" {
return false, nil
}
frontData := map[string]any{}
if err := yaml.Unmarshal([]byte(frontMatter), &frontData); err != nil {
return false, nil
}
storedHash := extractSourceHash(frontData)
if storedHash == "" {
return false, nil
}
return strings.EqualFold(storedHash, sourceHash), nil
}
func extractSourceHash(frontData map[string]any) string {
xi, ok := frontData["x-i18n"].(map[string]any)
if !ok {
return ""
}
value, ok := xi["source_hash"].(string)
if !ok {
return ""
}
return strings.TrimSpace(value)
}
func logDocChunkPlan(relPath string, blocks []string, groups [][]string) {
totalBytes := 0
for _, block := range blocks {
totalBytes += len(block)
}
log.Printf("docs-i18n: body-chunks %s blocks=%d groups=%d bytes=%d", relPath, len(blocks), len(groups), totalBytes)
}
func resolveDocsPath(docsRoot, filePath string) (string, string, error) {
absPath, err := filepath.Abs(filePath)
if err != nil {
return "", "", err
}
relPath, err := filepath.Rel(docsRoot, absPath)
if err != nil {
return "", "", err
}
if relPath == "." || relPath == "" {
return "", "", fmt.Errorf("file %s resolves to docs root %s", absPath, docsRoot)
}
if filepath.IsAbs(relPath) || relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) {
return "", "", fmt.Errorf("file %s not under docs root %s", absPath, docsRoot)
}
return absPath, relPath, nil
}

View file

@ -0,0 +1,904 @@
package main
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
type docChunkTranslator struct{}
func (docChunkTranslator) Translate(_ context.Context, text, _, _ string) (string, error) {
return text, nil
}
func (docChunkTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
switch {
case strings.Contains(text, "Alpha block") && strings.Contains(text, "Beta block"):
return strings.ReplaceAll(text, "</Accordion>", ""), nil
default:
replacer := strings.NewReplacer(
"Alpha block", "阿尔法段",
"Beta block", "贝塔段",
"Code sample", "代码示例",
)
return replacer.Replace(text), nil
}
}
func (docChunkTranslator) Close() {}
type docLeafFallbackTranslator struct{}
func (docLeafFallbackTranslator) Translate(_ context.Context, text, _, _ string) (string, error) {
replacer := strings.NewReplacer(
"Gateway refuses to start unless `local`.", "Gateway 只有在 `local` 时才会启动。",
"`gateway.auth.mode: \"trusted-proxy\"`", "`gateway.auth.mode: \"trusted-proxy\"`",
)
return replacer.Replace(text), nil
}
func (docLeafFallbackTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
if strings.Contains(text, "Gateway refuses to start unless `local`.") {
return strings.Replace(text, "Gateway refuses to start unless `local`.", "<Tip>Gateway only starts in local mode.</Tip>", 1), nil
}
return text, nil
}
func (docLeafFallbackTranslator) Close() {}
type docFrontmatterTranslator struct{}
func (docFrontmatterTranslator) Translate(_ context.Context, text, _, _ string) (string, error) {
replacer := strings.NewReplacer(
"Step-by-step Fly.io deployment for OpenClaw with persistent storage and HTTPS", "在 Fly.io 上逐步部署 OpenClaw,包含持久化存储和 HTTPS",
"Deploying OpenClaw on Fly.io", "在 Fly.io 上部署 OpenClaw",
"Setting up Fly volumes, secrets, and first-run config", "设置 Fly volume、密钥和首次运行配置",
)
return replacer.Replace(text), nil
}
func (docFrontmatterTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
return "extra text outside tagged sections", nil
}
func (docFrontmatterTranslator) Close() {}
type docFrontmatterFallbackTranslator struct{}
func (docFrontmatterFallbackTranslator) Translate(_ context.Context, text, _, _ string) (string, error) {
switch text {
case "Step-by-step Fly.io deployment for OpenClaw with persistent storage and HTTPS":
return strings.Join([]string{
"<frontmatter>",
"title: Fly.io",
"summary: \"在 Fly.io 上部署 OpenClaw 的逐步指南,包含持久化存储和 HTTPS 设置\"",
"read_when:",
" - 在 Fly.io 上部署 OpenClaw",
" - 设置 Fly 卷、机密和初始运行配置",
"</frontmatter>",
"",
"<body>",
"# Fly.io 部署",
"</body>",
}, "\n"), nil
case "Deploying OpenClaw on Fly.io":
return "在 Fly.io 上部署 OpenClaw", nil
case "Setting up Fly volumes, secrets, and first-run config":
return "设置 Fly 卷、机密和初始运行配置", nil
default:
return text, nil
}
}
func (docFrontmatterFallbackTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
return text, nil
}
func (docFrontmatterFallbackTranslator) Close() {}
type docProtocolLeakTranslator struct{}
func (docProtocolLeakTranslator) Translate(_ context.Context, text, _, _ string) (string, error) {
return text, nil
}
func (docProtocolLeakTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
switch {
case strings.Contains(text, "First chunk") && strings.Contains(text, "Second chunk"):
return strings.Join([]string{
"<frontmatter>",
"title: leaked",
"</frontmatter>",
"",
"<body>",
"First translated",
"",
"Second translated",
"</body>",
}, "\n"), nil
default:
replacer := strings.NewReplacer(
"First chunk", "First translated",
"Second chunk", "Second translated",
)
return replacer.Replace(text), nil
}
}
func (docProtocolLeakTranslator) Close() {}
type docWrappedLeafTranslator struct{}
func (docWrappedLeafTranslator) Translate(_ context.Context, text, _, _ string) (string, error) {
return text, nil
}
func (docWrappedLeafTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
return strings.Join([]string{
"<frontmatter>",
"title: leaked",
"</frontmatter>",
"",
"<body>",
"# Fly.io 部署",
"</body>",
}, "\n"), nil
}
func (docWrappedLeafTranslator) Close() {}
type docComponentLeafFallbackTranslator struct{}
func (docComponentLeafFallbackTranslator) Translate(_ context.Context, text, _, _ string) (string, error) {
return strings.ReplaceAll(text, "Yes.", "是的。"), nil
}
func (docComponentLeafFallbackTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
if strings.Contains(text, "Can I use Claude Max subscription without an API key?") {
return strings.ReplaceAll(text, "Yes.\n", "Yes.\n</Accordion>\n"), nil
}
return text, nil
}
func (docComponentLeafFallbackTranslator) Close() {}
type docPromptBudgetTranslator struct {
rawInputs []string
}
func (t *docPromptBudgetTranslator) Translate(_ context.Context, text, _, _ string) (string, error) {
return text, nil
}
func (t *docPromptBudgetTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
t.rawInputs = append(t.rawInputs, text)
replacer := strings.NewReplacer(
"First chunk with `json5` and { braces }", "第一块,含 `json5` 和 { braces }",
"Second chunk with | table | pipes |", "第二块,含 | table | pipes |",
)
return replacer.Replace(text), nil
}
func (t *docPromptBudgetTranslator) Close() {}
type uppercaseWrapperTranslator struct{}
func (uppercaseWrapperTranslator) Translate(_ context.Context, text, _, _ string) (string, error) {
return text, nil
}
func (uppercaseWrapperTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
return "<BODY>\n" + strings.ReplaceAll(text, "Regular paragraph.", "Translated paragraph.") + "\n</BODY>\n", nil
}
func (uppercaseWrapperTranslator) Close() {}
type oversizedBlockTranslator struct {
rawInputs []string
}
func (t *oversizedBlockTranslator) Translate(_ context.Context, text, _, _ string) (string, error) {
return text, nil
}
func (t *oversizedBlockTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
t.rawInputs = append(t.rawInputs, text)
return strings.ReplaceAll(text, "Line ", "Translated line "), nil
}
func (t *oversizedBlockTranslator) Close() {}
type singletonFenceRetryTranslator struct {
rawInputs []string
}
func (t *singletonFenceRetryTranslator) Translate(_ context.Context, text, _, _ string) (string, error) {
return text, nil
}
func (t *singletonFenceRetryTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
t.rawInputs = append(t.rawInputs, text)
if strings.Contains(text, "Line 01") && strings.Contains(text, "Line 04") {
return strings.Replace(text, "\n```\n", "\n", 1), nil
}
return strings.ReplaceAll(text, "Line ", "Translated line "), nil
}
func (t *singletonFenceRetryTranslator) Close() {}
func TestParseTaggedDocumentRejectsMissingBodyCloseAtEOF(t *testing.T) {
t.Parallel()
input := "<frontmatter>\ntitle: Test\n</frontmatter>\n<body>\nTranslated body\n"
_, _, err := parseTaggedDocument(input)
if err == nil {
t.Fatal("expected error for missing </body>")
}
}
func TestParseTaggedDocumentRejectsTrailingTextOutsideTags(t *testing.T) {
t.Parallel()
input := "<frontmatter>\ntitle: Test\n</frontmatter>\n<body>\nTranslated body\n</body>\nextra"
_, _, err := parseTaggedDocument(input)
if err == nil {
t.Fatal("expected error for trailing text")
}
}
func TestFindTaggedBodyEndSearchesFromBodyStart(t *testing.T) {
t.Parallel()
text := strings.Join([]string{
"<frontmatter>",
"summary: literal </body> token in frontmatter",
"</frontmatter>",
"<body>",
"Translated body",
"</body>",
}, "\n")
bodyStart := strings.Index(text, bodyTagStart)
if bodyStart == -1 {
t.Fatal("expected body tag in test input")
}
bodyStart += len(bodyTagStart)
bodyEnd := findTaggedBodyEnd(text, bodyStart)
if bodyEnd == -1 {
t.Fatal("expected closing body tag to be found")
}
body := trimTagNewlines(text[bodyStart:bodyEnd])
if body != "Translated body" {
t.Fatalf("expected body slice to ignore pre-body literal token, got %q", body)
}
}
func TestSplitDocBodyIntoBlocksKeepsFenceTogether(t *testing.T) {
t.Parallel()
body := strings.Join([]string{
"<Accordion title=\"Alpha block\">",
"",
"Code sample:",
"```ts",
"console.log('hello')",
"```",
"",
"Beta block",
"",
"</Accordion>",
"",
}, "\n")
blocks := splitDocBodyIntoBlocks(body)
if len(blocks) != 4 {
t.Fatalf("expected 4 blocks, got %d", len(blocks))
}
if !strings.Contains(blocks[1], "```ts") || !strings.Contains(blocks[1], "```") {
t.Fatalf("expected code fence to stay in a single block:\n%s", blocks[1])
}
if !strings.Contains(blocks[2], "Beta block") {
t.Fatalf("expected Beta paragraph in its own block:\n%s", blocks[2])
}
}
func TestSplitDocBodyIntoBlocksKeepsNestedTripleBackticksInsideFourBacktickFence(t *testing.T) {
t.Parallel()
body := strings.Join([]string{
"````md",
"```ts",
"console.log('nested example')",
"```",
"````",
"",
"Outside paragraph",
"",
}, "\n")
blocks := splitDocBodyIntoBlocks(body)
if len(blocks) != 2 {
t.Fatalf("expected 2 blocks, got %d", len(blocks))
}
if !strings.Contains(blocks[0], "console.log('nested example')") || !strings.Contains(blocks[0], "````") {
t.Fatalf("expected the full fenced example to stay in one block:\n%s", blocks[0])
}
if !strings.Contains(blocks[1], "Outside paragraph") {
t.Fatalf("expected trailing paragraph in second block:\n%s", blocks[1])
}
}
func TestSanitizeDocChunkProtocolWrappersStripsOuterWrapperAroundBodyExamples(t *testing.T) {
t.Parallel()
source := strings.Join([]string{
"Paragraph mentioning literal tokens `<body>` and `</body>`.",
"",
"<html>",
" <body>",
" literal example",
" </body>",
"</html>",
}, "\n")
translated := strings.Join([]string{
"<frontmatter>",
"title: leaked",
"</frontmatter>",
"",
"<body>",
"提到字面量 `<body>` 和 `</body>` 的段落。",
"",
"<html>",
" <body>",
" literal example",
" </body>",
"</html>",
"</body>",
}, "\n")
sanitized := sanitizeDocChunkProtocolWrappers(source, translated)
if strings.Contains(sanitized, frontmatterTagStart) || strings.HasPrefix(strings.TrimSpace(sanitized), bodyTagStart) {
t.Fatalf("expected outer wrapper stripped, got:\n%s", sanitized)
}
if !strings.Contains(sanitized, "<html>") || !strings.Contains(sanitized, "<body>") || !strings.Contains(sanitized, "</body>") {
t.Fatalf("expected inner HTML example preserved, got:\n%s", sanitized)
}
}
func TestTranslateDocBodyChunkedFallsBackToSmallerChunks(t *testing.T) {
body := strings.Join([]string{
"<Accordion title=\"Alpha block\">",
"Alpha block",
"</Accordion>",
"",
"Beta block",
"",
}, "\n")
t.Setenv("OPENCLAW_DOCS_I18N_DOC_CHUNK_MAX_BYTES", "4096")
translated, err := translateDocBodyChunked(context.Background(), docChunkTranslator{}, "help/faq.md", body, "en", "zh-CN")
if err != nil {
t.Fatalf("translateDocBodyChunked returned error: %v", err)
}
if !strings.Contains(translated, "阿尔法段") || !strings.Contains(translated, "贝塔段") {
t.Fatalf("expected translated text after chunk split, got:\n%s", translated)
}
if strings.Count(translated, "</Accordion>") != 1 {
t.Fatalf("expected closing Accordion tag to be preserved after fallback split:\n%s", translated)
}
}
func TestStripAndReapplyCommonIndent(t *testing.T) {
t.Parallel()
source := strings.Join([]string{
" <Step title=\"Example\">",
" - item one",
" - item two",
" </Step>",
"",
}, "\n")
normalized, indent := stripCommonIndent(source)
if indent != " " {
t.Fatalf("expected common indent of four spaces, got %q", indent)
}
if strings.HasPrefix(normalized, " ") {
t.Fatalf("expected normalized text without common indent:\n%s", normalized)
}
roundTrip := reapplyCommonIndent(normalized, indent)
if roundTrip != source {
t.Fatalf("expected indent round-trip to preserve source\nwant:\n%s\ngot:\n%s", source, roundTrip)
}
}
func TestTranslateDocBodyChunkedFallsBackToMaskedTranslateForLeafValidationFailure(t *testing.T) {
body := strings.Join([]string{
"- `mode`: `local` or `remote`. Gateway refuses to start unless `local`.",
"- `gateway.auth.mode: \"trusted-proxy\"`: delegate auth to a reverse proxy.",
"",
}, "\n")
t.Setenv("OPENCLAW_DOCS_I18N_DOC_CHUNK_MAX_BYTES", "4096")
translated, err := translateDocBodyChunked(
context.Background(),
docLeafFallbackTranslator{},
"gateway/configuration-reference.md",
body,
"en",
"zh-CN",
)
if err != nil {
t.Fatalf("translateDocBodyChunked returned error: %v", err)
}
if strings.Contains(translated, "<Tip>") {
t.Fatalf("expected masked fallback to remove hallucinated component tags:\n%s", translated)
}
if !strings.Contains(translated, "Gateway 只有在 `local` 时才会启动。") {
t.Fatalf("expected fallback translation to be applied:\n%s", translated)
}
}
func TestValidateDocChunkTranslationRejectsProtocolTokenLeakage(t *testing.T) {
t.Parallel()
source := "Regular paragraph.\n\n"
translated := "<frontmatter>\ntitle: leaked\n</frontmatter>\n<body>\nRegular paragraph.\n</body>\n"
err := validateDocChunkTranslation(source, translated)
if err == nil {
t.Fatal("expected protocol token leakage to be rejected")
}
if !strings.Contains(err.Error(), "protocol token leaked") {
t.Fatalf("expected protocol token leakage error, got %v", err)
}
}
func TestValidateDocChunkTranslationRejectsTopLevelBodyWrapperLeakEvenWhenSourceMentionsBodyTag(t *testing.T) {
t.Parallel()
source := "Use `<body>` in examples, but keep prose outside wrappers.\n"
translated := "<body>\nTranslated paragraph.\n"
err := validateDocChunkTranslation(source, translated)
if err == nil {
t.Fatal("expected top-level wrapper leakage to be rejected")
}
if !strings.Contains(err.Error(), "protocol token leaked") {
t.Fatalf("expected protocol token leakage error, got %v", err)
}
}
func TestTranslateDocBodyChunkedSplitsOnProtocolTokenLeakage(t *testing.T) {
body := strings.Join([]string{
"First chunk",
"",
"Second chunk",
"",
}, "\n")
t.Setenv("OPENCLAW_DOCS_I18N_DOC_CHUNK_MAX_BYTES", "4096")
translated, err := translateDocBodyChunked(context.Background(), docProtocolLeakTranslator{}, "gateway/configuration-reference.md", body, "en", "zh-CN")
if err != nil {
t.Fatalf("translateDocBodyChunked returned error: %v", err)
}
if strings.Contains(translated, "<frontmatter>") || strings.Contains(translated, "<body>") || strings.Contains(translated, "[[[FM_") {
t.Fatalf("expected protocol wrapper leakage to be removed after split:\n%s", translated)
}
if !strings.Contains(translated, "First translated") || !strings.Contains(translated, "Second translated") {
t.Fatalf("expected split chunks to translate successfully:\n%s", translated)
}
}
func TestTranslateDocBodyChunkedStripsUppercaseBodyWrapper(t *testing.T) {
body := "Regular paragraph.\n"
t.Setenv("OPENCLAW_DOCS_I18N_DOC_CHUNK_MAX_BYTES", "4096")
translated, err := translateDocBodyChunked(context.Background(), uppercaseWrapperTranslator{}, "gateway/configuration-reference.md", body, "en", "zh-CN")
if err != nil {
t.Fatalf("translateDocBodyChunked returned error: %v", err)
}
if strings.Contains(strings.ToLower(translated), "<body>") {
t.Fatalf("expected uppercase wrapper to be stripped:\n%s", translated)
}
if !strings.Contains(translated, "Translated paragraph.") {
t.Fatalf("expected translated body content to survive unwrap:\n%s", translated)
}
}
func TestSanitizeDocChunkProtocolWrappersKeepsBodyOnlyWrapperWhenSourceMentionsBodyTag(t *testing.T) {
t.Parallel()
source := "Use `<body>` and `</body>` in examples, but keep the paragraph text plain.\n"
translated := "<body>\nTranslated paragraph.\n</body>\n"
got := sanitizeDocChunkProtocolWrappers(source, translated)
if got != translated {
t.Fatalf("expected ambiguous body-only wrapper to remain unchanged for retry\nwant:\n%s\ngot:\n%s", translated, got)
}
}
func TestSanitizeDocChunkProtocolWrappersKeepsLegitimateTopLevelBodyBlock(t *testing.T) {
t.Parallel()
source := "<body>\nLiteral HTML block.\n</body>\n"
translated := "<body>\nLiteral HTML block.\n</body>\n"
got := sanitizeDocChunkProtocolWrappers(source, translated)
if got != translated {
t.Fatalf("expected legitimate top-level body block to remain unchanged\nwant:\n%s\ngot:\n%s", translated, got)
}
}
func TestSanitizeDocChunkProtocolWrappersStripsBodyOnlyWrapperWhenSourceHasNoBodyTokens(t *testing.T) {
t.Parallel()
source := "Regular paragraph.\n"
translated := "<body>\nTranslated paragraph.\n</body>\n"
got := sanitizeDocChunkProtocolWrappers(source, translated)
if strings.Contains(got, "<body>") || strings.Contains(got, "</body>") {
t.Fatalf("expected body-only wrapper to be stripped, got %q", got)
}
if strings.TrimSpace(got) != "Translated paragraph." {
t.Fatalf("unexpected sanitized body %q", got)
}
}
func TestSanitizeDocChunkProtocolWrappersKeepsAmbiguousTaggedWrapperForRetry(t *testing.T) {
t.Parallel()
source := strings.Join([]string{
"Paragraph mentioning literal tokens `<body>` and `</body>`.",
"",
"Closing example:",
"</body>",
}, "\n")
translated := strings.Join([]string{
"<frontmatter>",
"title: leaked",
"</frontmatter>",
"",
"<body>",
"提到字面量 `<body>` 和 `</body>` 的段落。",
}, "\n")
got := sanitizeDocChunkProtocolWrappers(source, translated)
if got != translated {
t.Fatalf("expected ambiguous tagged wrapper to remain unchanged for retry\nwant:\n%s\ngot:\n%s", translated, got)
}
}
func TestSplitDocBodyIntoBlocksKeepsInfoStringExampleInsideFence(t *testing.T) {
t.Parallel()
body := strings.Join([]string{
"```md",
"```ts",
"console.log('inside example')",
"```",
"",
"Outside paragraph",
"",
}, "\n")
blocks := splitDocBodyIntoBlocks(body)
if len(blocks) != 2 {
t.Fatalf("expected 2 blocks, got %d", len(blocks))
}
if !strings.Contains(blocks[0], "console.log('inside example')") || !strings.Contains(blocks[0], "```ts") {
t.Fatalf("expected fenced example to stay together:\n%s", blocks[0])
}
if !strings.Contains(blocks[1], "Outside paragraph") {
t.Fatalf("expected trailing paragraph in second block:\n%s", blocks[1])
}
}
func TestTranslateDocBodyChunkedPreSplitsOversizedPromptBudget(t *testing.T) {
body := strings.Join([]string{
"First chunk with `json5` and { braces }",
"",
"Second chunk with | table | pipes |",
"",
}, "\n")
t.Setenv("OPENCLAW_DOCS_I18N_DOC_CHUNK_MAX_BYTES", "4096")
t.Setenv("OPENCLAW_DOCS_I18N_DOC_CHUNK_PROMPT_BUDGET", "60")
translator := &docPromptBudgetTranslator{}
translated, err := translateDocBodyChunked(
context.Background(),
translator,
"gateway/configuration-reference.md",
body,
"en",
"zh-CN",
)
if err != nil {
t.Fatalf("translateDocBodyChunked returned error: %v", err)
}
for _, input := range translator.rawInputs {
if strings.Contains(input, "First chunk with `json5` and { braces }") && strings.Contains(input, "Second chunk with | table | pipes |") {
t.Fatalf("expected prompt budget guard to split before raw translation, saw combined input:\n%s", input)
}
}
if !strings.Contains(translated, "第一块") || !strings.Contains(translated, "第二块") {
t.Fatalf("expected split chunks to translate successfully:\n%s", translated)
}
}
func TestTranslateDocBodyChunkedSplitsOversizedSingletonBlock(t *testing.T) {
body := strings.Join([]string{
"Line 01",
"Line 02",
"Line 03",
"Line 04",
"Line 05",
"Line 06",
"",
}, "\n")
t.Setenv("OPENCLAW_DOCS_I18N_DOC_CHUNK_MAX_BYTES", "24")
translator := &oversizedBlockTranslator{}
translated, err := translateDocBodyChunked(context.Background(), translator, "gateway/configuration-reference.md", body, "en", "zh-CN")
if err != nil {
t.Fatalf("translateDocBodyChunked returned error: %v", err)
}
if len(translator.rawInputs) < 2 {
t.Fatalf("expected oversized singleton block to be split before translation, saw %d input(s)", len(translator.rawInputs))
}
for _, input := range translator.rawInputs {
if len(input) > 24 {
t.Fatalf("expected split chunk under byte budget, got %d bytes:\n%s", len(input), input)
}
}
if !strings.Contains(translated, "Translated line 01") || !strings.Contains(translated, "Translated line 06") {
t.Fatalf("expected translated singleton parts to be reassembled:\n%s", translated)
}
}
func TestTranslateDocBodyChunkedSplitsSingletonBlockWhenPromptBudgetExceeded(t *testing.T) {
lineA := "Alpha chunk with { braces }\n"
lineB := "Beta chunk with | pipes |\n"
body := lineA + lineB + "\n"
budget := max(estimateDocPromptCost(lineA), estimateDocPromptCost(lineB)) + 1
if estimateDocPromptCost(body) <= budget {
t.Fatalf("test setup expected combined singleton prompt cost to exceed budget; cost=%d budget=%d", estimateDocPromptCost(body), budget)
}
t.Setenv("OPENCLAW_DOCS_I18N_DOC_CHUNK_MAX_BYTES", "4096")
t.Setenv("OPENCLAW_DOCS_I18N_DOC_CHUNK_PROMPT_BUDGET", strconv.Itoa(budget))
translator := &oversizedBlockTranslator{}
translated, err := translateDocBodyChunked(context.Background(), translator, "gateway/configuration-reference.md", body, "en", "zh-CN")
if err != nil {
t.Fatalf("translateDocBodyChunked returned error: %v", err)
}
if len(translator.rawInputs) < 2 {
t.Fatalf("expected prompt-budget singleton split before translation, saw %d input(s)", len(translator.rawInputs))
}
for _, input := range translator.rawInputs {
if estimateDocPromptCost(input) > budget {
t.Fatalf("expected split chunk under prompt budget, got cost=%d budget=%d:\n%s", estimateDocPromptCost(input), budget, input)
}
}
if !strings.Contains(translated, "Alpha chunk") || !strings.Contains(translated, "Beta chunk") {
t.Fatalf("expected translated singleton parts to be reassembled:\n%s", translated)
}
}
func TestTranslateDocBodyChunkedSplitsOversizedFenceBeforeTrailingProse(t *testing.T) {
body := strings.Join([]string{
"```md",
"Line 01",
"Line 02",
"Line 03",
"Line 04",
"```",
"Trailing paragraph after the fence.",
"",
}, "\n")
t.Setenv("OPENCLAW_DOCS_I18N_DOC_CHUNK_MAX_BYTES", "24")
translator := &oversizedBlockTranslator{}
translated, err := translateDocBodyChunked(context.Background(), translator, "gateway/configuration-reference.md", body, "en", "zh-CN")
if err != nil {
t.Fatalf("translateDocBodyChunked returned error: %v", err)
}
if len(translator.rawInputs) < 3 {
t.Fatalf("expected oversized fenced block with trailing prose to split, saw %d input(s)", len(translator.rawInputs))
}
for _, input := range translator.rawInputs {
if strings.Contains(input, "Line 01") || strings.Contains(input, "Line 02") || strings.Contains(input, "Line 03") || strings.Contains(input, "Line 04") {
if !strings.Contains(input, "```md") || !strings.Contains(input, "```") {
t.Fatalf("expected fenced split input to keep matched fence wrappers:\n%s", input)
}
}
}
if !strings.Contains(translated, "Translated line 01") || !strings.Contains(translated, "Trailing paragraph after the fence.") {
t.Fatalf("expected fence content and trailing prose to survive split:\n%s", translated)
}
}
func TestTranslateDocBodyChunkedRetriesSingletonFenceAfterValidationFailure(t *testing.T) {
body := strings.Join([]string{
"```md",
"Line 01",
"Line 02",
"Line 03",
"Line 04",
"```",
"",
}, "\n")
t.Setenv("OPENCLAW_DOCS_I18N_DOC_CHUNK_MAX_BYTES", "4096")
t.Setenv("OPENCLAW_DOCS_I18N_DOC_CHUNK_PROMPT_BUDGET", "4096")
translator := &singletonFenceRetryTranslator{}
translated, err := translateDocBodyChunked(context.Background(), translator, "gateway/configuration-reference.md", body, "en", "zh-CN")
if err != nil {
t.Fatalf("translateDocBodyChunked returned error: %v", err)
}
if len(translator.rawInputs) < 3 {
t.Fatalf("expected singleton fence retry to split after validation failure, saw %d input(s)", len(translator.rawInputs))
}
if !strings.Contains(translator.rawInputs[0], "Line 01") || !strings.Contains(translator.rawInputs[0], "Line 04") {
t.Fatalf("expected first raw attempt to include the original fenced block:\n%s", translator.rawInputs[0])
}
for _, input := range translator.rawInputs[1:] {
if strings.Contains(input, "Line 01") || strings.Contains(input, "Line 02") || strings.Contains(input, "Line 03") || strings.Contains(input, "Line 04") {
if !strings.Contains(input, "```md") || !strings.Contains(input, "```") {
t.Fatalf("expected split retry inputs to preserve fence wrappers:\n%s", input)
}
}
}
if !strings.Contains(translated, "Translated line 01") || !strings.Contains(translated, "Translated line 04") {
t.Fatalf("expected singleton fence retry to reassemble translated output:\n%s", translated)
}
}
func TestTranslateDocBodyChunkedUnwrapsTaggedLeafProtocolLeakage(t *testing.T) {
body := "# Fly.io Deployment\n\n"
t.Setenv("OPENCLAW_DOCS_I18N_DOC_CHUNK_MAX_BYTES", "4096")
translated, err := translateDocBodyChunked(
context.Background(),
docWrappedLeafTranslator{},
"install/fly.md",
body,
"en",
"zh-CN",
)
if err != nil {
t.Fatalf("translateDocBodyChunked returned error: %v", err)
}
if strings.Contains(translated, "<frontmatter>") || strings.Contains(translated, "<body>") {
t.Fatalf("expected wrapped leaf translation to unwrap protocol tags:\n%s", translated)
}
if !strings.Contains(translated, "# Fly.io 部署") {
t.Fatalf("expected unwrapped body translation:\n%s", translated)
}
}
func TestTranslateDocBodyChunkedFallsBackForComponentLeafValidationFailure(t *testing.T) {
body := " <Accordion title=\"Can I use Claude Max subscription without an API key?\">\n Yes.\n\n"
t.Setenv("OPENCLAW_DOCS_I18N_DOC_CHUNK_MAX_BYTES", "4096")
translated, err := translateDocBodyChunked(
context.Background(),
docComponentLeafFallbackTranslator{},
"help/faq.md",
body,
"en",
"zh-CN",
)
if err != nil {
t.Fatalf("translateDocBodyChunked returned error: %v", err)
}
if strings.Contains(translated, "</Accordion>") {
t.Fatalf("expected component leaf fallback to avoid hallucinated closing tag:\n%s", translated)
}
if !strings.Contains(translated, "是的。") {
t.Fatalf("expected body text to be translated after component leaf fallback:\n%s", translated)
}
if !strings.Contains(translated, "<Accordion title=\"Can I use Claude Max subscription without an API key?\">") {
t.Fatalf("expected Accordion opening tag to be preserved:\n%s", translated)
}
}
func TestProcessFileDocUsesFieldLevelFrontmatterTranslation(t *testing.T) {
t.Parallel()
docsRoot := t.TempDir()
sourcePath := filepath.Join(docsRoot, "install")
if err := os.MkdirAll(sourcePath, 0o755); err != nil {
t.Fatalf("mkdir failed: %v", err)
}
sourceFile := filepath.Join(sourcePath, "fly.md")
source := strings.Join([]string{
"---",
"title: Fly.io",
"summary: \"Step-by-step Fly.io deployment for OpenClaw with persistent storage and HTTPS\"",
"read_when:",
" - Deploying OpenClaw on Fly.io",
" - Setting up Fly volumes, secrets, and first-run config",
"---",
"",
}, "\n")
if err := os.WriteFile(sourceFile, []byte(source), 0o644); err != nil {
t.Fatalf("write failed: %v", err)
}
skipped, outputPath, err := processFileDoc(context.Background(), docFrontmatterTranslator{}, docsRoot, sourceFile, "en", "zh-CN", true)
if err != nil {
t.Fatalf("processFileDoc returned error: %v", err)
}
if skipped {
t.Fatal("expected file to be processed")
}
if outputPath == "" {
t.Fatal("expected output path")
}
output, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("read output failed: %v", err)
}
text := string(output)
if !strings.Contains(text, "在 Fly.io 上逐步部署 OpenClaw,包含持久化存储和 HTTPS") {
t.Fatalf("expected translated summary in output:\n%s", text)
}
if !strings.Contains(text, "在 Fly.io 上部署 OpenClaw") {
t.Fatalf("expected translated read_when entry in output:\n%s", text)
}
}
func TestProcessFileDocRejectsSuspiciousFrontmatterScalarExpansion(t *testing.T) {
t.Parallel()
docsRoot := t.TempDir()
sourcePath := filepath.Join(docsRoot, "install")
if err := os.MkdirAll(sourcePath, 0o755); err != nil {
t.Fatalf("mkdir failed: %v", err)
}
sourceFile := filepath.Join(sourcePath, "fly.md")
source := strings.Join([]string{
"---",
"title: Fly.io",
"summary: \"Step-by-step Fly.io deployment for OpenClaw with persistent storage and HTTPS\"",
"read_when:",
" - Deploying OpenClaw on Fly.io",
" - Setting up Fly volumes, secrets, and first-run config",
"---",
"",
}, "\n")
if err := os.WriteFile(sourceFile, []byte(source), 0o644); err != nil {
t.Fatalf("write failed: %v", err)
}
skipped, outputPath, err := processFileDoc(context.Background(), docFrontmatterFallbackTranslator{}, docsRoot, sourceFile, "en", "zh-CN", true)
if err != nil {
t.Fatalf("processFileDoc returned error: %v", err)
}
if skipped {
t.Fatal("expected file to be processed")
}
output, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("read output failed: %v", err)
}
text := string(output)
if strings.Contains(text, "<frontmatter>") || strings.Contains(text, "<body>") {
t.Fatalf("expected suspicious frontmatter expansion to be rejected:\n%s", text)
}
if !strings.Contains(text, "summary: Step-by-step Fly.io deployment for OpenClaw with persistent storage and HTTPS") {
t.Fatalf("expected original summary to be preserved after fallback:\n%s", text)
}
if !strings.Contains(text, "在 Fly.io 上部署 OpenClaw") {
t.Fatalf("expected read_when translation to survive fallback:\n%s", text)
}
}

Some files were not shown because too many files have changed in this diff Show more