mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 05:50:44 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
26
openclaw/scripts/AGENTS.md
Normal file
26
openclaw/scripts/AGENTS.md
Normal 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`.
|
||||
1
openclaw/scripts/CLAUDE.md
Normal file
1
openclaw/scripts/CLAUDE.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
AGENTS.md
|
||||
9
openclaw/scripts/analyze-plugin-sdk-usage.ts
Normal file
9
openclaw/scripts/analyze-plugin-sdk-usage.ts
Normal 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);
|
||||
}
|
||||
662
openclaw/scripts/anthropic-prompt-probe.ts
Normal file
662
openclaw/scripts/anthropic-prompt-probe.ts
Normal 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();
|
||||
1038
openclaw/scripts/audit-seams.mjs
Normal file
1038
openclaw/scripts/audit-seams.mjs
Normal file
File diff suppressed because it is too large
Load diff
89
openclaw/scripts/auth-monitor.sh
Normal file
89
openclaw/scripts/auth-monitor.sh
Normal 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
|
||||
520
openclaw/scripts/bench-cli-startup.ts
Normal file
520
openclaw/scripts/bench-cli-startup.ts
Normal 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();
|
||||
145
openclaw/scripts/bench-model.ts
Normal file
145
openclaw/scripts/bench-model.ts
Normal 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();
|
||||
187
openclaw/scripts/bench-test-changed.mjs
Normal file
187
openclaw/scripts/bench-test-changed.mjs
Normal 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"
|
||||
}`,
|
||||
);
|
||||
388
openclaw/scripts/build-all.mjs
Normal file
388
openclaw/scripts/build-all.mjs
Normal 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);
|
||||
}
|
||||
}
|
||||
18
openclaw/scripts/build-and-run-mac.sh
Normal file
18
openclaw/scripts/build-and-run-mac.sh
Normal 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"
|
||||
14
openclaw/scripts/build-docs-list.mjs
Normal file
14
openclaw/scripts/build-docs-list.mjs
Normal 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 });
|
||||
22
openclaw/scripts/build-stamp.d.mts
Normal file
22
openclaw/scripts/build-stamp.d.mts
Normal 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;
|
||||
50
openclaw/scripts/build-stamp.mjs
Normal file
50
openclaw/scripts/build-stamp.mjs
Normal 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);
|
||||
}
|
||||
}
|
||||
59
openclaw/scripts/build_icon.sh
Normal file
59
openclaw/scripts/build_icon.sh
Normal 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"
|
||||
246
openclaw/scripts/bundle-a2ui.mjs
Normal file
246
openclaw/scripts/bundle-a2ui.mjs
Normal 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));
|
||||
});
|
||||
}
|
||||
4
openclaw/scripts/bundle-a2ui.sh
Normal file
4
openclaw/scripts/bundle-a2ui.sh
Normal 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" "$@"
|
||||
46
openclaw/scripts/canvas-a2ui-copy.ts
Normal file
46
openclaw/scripts/canvas-a2ui-copy.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
65
openclaw/scripts/changelog-add-unreleased.ts
Normal file
65
openclaw/scripts/changelog-add-unreleased.ts
Normal 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}).`);
|
||||
}
|
||||
91
openclaw/scripts/changelog-to-html.sh
Normal file
91
openclaw/scripts/changelog-to-html.sh
Normal 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>"
|
||||
259
openclaw/scripts/check-architecture-smells.mjs
Normal file
259
openclaw/scripts/check-architecture-smells.mjs
Normal 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);
|
||||
353
openclaw/scripts/check-channel-agnostic-boundaries.mjs
Normal file
353
openclaw/scripts/check-channel-agnostic-boundaries.mjs
Normal 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);
|
||||
184
openclaw/scripts/check-cli-startup-memory.mjs
Normal file
184
openclaw/scripts/check-cli-startup-memory.mjs
Normal 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 });
|
||||
}
|
||||
78
openclaw/scripts/check-codex-app-server-protocol.ts
Normal file
78
openclaw/scripts/check-codex-app-server-protocol.ts
Normal 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}`,
|
||||
);
|
||||
|
|
@ -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())
|
||||
237
openclaw/scripts/check-docs-i18n-glossary.mjs
Normal file
237
openclaw/scripts/check-docs-i18n-glossary.mjs
Normal 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();
|
||||
198
openclaw/scripts/check-dynamic-import-warts.mjs
Normal file
198
openclaw/scripts/check-dynamic-import-warts.mjs
Normal 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);
|
||||
818
openclaw/scripts/check-extension-package-tsc-boundary.mjs
Normal file
818
openclaw/scripts/check-extension-package-tsc-boundary.mjs
Normal 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();
|
||||
}
|
||||
363
openclaw/scripts/check-extension-plugin-sdk-boundary.mjs
Normal file
363
openclaw/scripts/check-extension-plugin-sdk-boundary.mjs
Normal 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();
|
||||
}
|
||||
63
openclaw/scripts/check-file-utils.ts
Normal file
63
openclaw/scripts/check-file-utils.ts
Normal 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);
|
||||
}
|
||||
700
openclaw/scripts/check-gateway-watch-regression.mjs
Normal file
700
openclaw/scripts/check-gateway-watch-regression.mjs
Normal 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();
|
||||
278
openclaw/scripts/check-import-cycles.ts
Normal file
278
openclaw/scripts/check-import-cycles.ts
Normal 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();
|
||||
42
openclaw/scripts/check-ingress-agent-owner-context.mjs
Normal file
42
openclaw/scripts/check-ingress-agent-owner-context.mjs
Normal 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);
|
||||
18
openclaw/scripts/check-live-cache.ts
Normal file
18
openclaw/scripts/check-live-cache.ts
Normal 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");
|
||||
}
|
||||
37
openclaw/scripts/check-madge-import-cycles.ts
Normal file
37
openclaw/scripts/check-madge-import-cycles.ts
Normal 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();
|
||||
85
openclaw/scripts/check-no-conflict-markers.mjs
Normal file
85
openclaw/scripts/check-no-conflict-markers.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
43
openclaw/scripts/check-no-extension-src-imports.ts
Normal file
43
openclaw/scripts/check-no-extension-src-imports.ts
Normal 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();
|
||||
73
openclaw/scripts/check-no-extension-test-core-imports.ts
Normal file
73
openclaw/scripts/check-no-extension-test-core-imports.ts
Normal 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();
|
||||
117
openclaw/scripts/check-no-monolithic-plugin-sdk-entry-imports.ts
Normal file
117
openclaw/scripts/check-no-monolithic-plugin-sdk-entry-imports.ts
Normal 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();
|
||||
180
openclaw/scripts/check-no-pairing-store-group-auth.mjs
Normal file
180
openclaw/scripts/check-no-pairing-store-group-auth.mjs
Normal 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);
|
||||
86
openclaw/scripts/check-no-random-messaging-tmp.mjs
Normal file
86
openclaw/scripts/check-no-random-messaging-tmp.mjs
Normal 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);
|
||||
108
openclaw/scripts/check-no-raw-channel-fetch.mjs
Normal file
108
openclaw/scripts/check-no-raw-channel-fetch.mjs
Normal 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);
|
||||
86
openclaw/scripts/check-no-raw-window-open.mjs
Normal file
86
openclaw/scripts/check-no-raw-window-open.mjs
Normal 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);
|
||||
36
openclaw/scripts/check-no-register-http-handler.mjs
Normal file
36
openclaw/scripts/check-no-register-http-handler.mjs
Normal 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);
|
||||
100
openclaw/scripts/check-pairing-account-scope.mjs
Normal file
100
openclaw/scripts/check-pairing-account-scope.mjs
Normal 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);
|
||||
222
openclaw/scripts/check-plugin-extension-import-boundary.mjs
Normal file
222
openclaw/scripts/check-plugin-extension-import-boundary.mjs
Normal 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);
|
||||
127
openclaw/scripts/check-plugin-sdk-exports.mjs
Normal file
127
openclaw/scripts/check-plugin-sdk-exports.mjs
Normal 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.`);
|
||||
126
openclaw/scripts/check-plugin-sdk-subpath-exports.mjs
Normal file
126
openclaw/scripts/check-plugin-sdk-subpath-exports.mjs
Normal 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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
26
openclaw/scripts/check-src-extension-import-boundary.mjs
Normal file
26
openclaw/scripts/check-src-extension-import-boundary.mjs
Normal 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);
|
||||
|
|
@ -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);
|
||||
3
openclaw/scripts/check-timed.mjs
Normal file
3
openclaw/scripts/check-timed.mjs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { main } from "./check.mjs";
|
||||
|
||||
await main([...process.argv.slice(2), "--timed"]);
|
||||
83
openclaw/scripts/check-ts-max-loc.ts
Normal file
83
openclaw/scripts/check-ts-max-loc.ts
Normal 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();
|
||||
59
openclaw/scripts/check-tsgo-core-boundary.mjs
Normal file
59
openclaw/scripts/check-tsgo-core-boundary.mjs
Normal 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);
|
||||
}
|
||||
104
openclaw/scripts/check-web-fetch-provider-boundaries.mjs
Normal file
104
openclaw/scripts/check-web-fetch-provider-boundaries.mjs
Normal 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;
|
||||
});
|
||||
242
openclaw/scripts/check-web-search-provider-boundaries.mjs
Normal file
242
openclaw/scripts/check-web-search-provider-boundaries.mjs
Normal 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);
|
||||
62
openclaw/scripts/check-webhook-auth-body-order.mjs
Normal file
62
openclaw/scripts/check-webhook-auth-body-order.mjs
Normal 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
131
openclaw/scripts/check.mjs
Normal 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();
|
||||
}
|
||||
13
openclaw/scripts/ci-changed-scope.d.mts
Normal file
13
openclaw/scripts/ci-changed-scope.d.mts
Normal 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;
|
||||
208
openclaw/scripts/ci-changed-scope.mjs
Normal file
208
openclaw/scripts/ci-changed-scope.mjs
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
84
openclaw/scripts/ci-hydrate-live-auth.sh
Normal file
84
openclaw/scripts/ci-hydrate-live-auth.sh
Normal 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
|
||||
280
openclaw/scripts/claude-auth-status.sh
Normal file
280
openclaw/scripts/claude-auth-status.sh
Normal 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
|
||||
355
openclaw/scripts/clawdock/README.md
Normal file
355
openclaw/scripts/clawdock/README.md
Normal 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
|
||||
```
|
||||
512
openclaw/scripts/clawdock/clawdock-helpers.sh
Normal file
512
openclaw/scripts/clawdock/clawdock-helpers.sh
Normal 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
321
openclaw/scripts/clawlog.sh
Normal 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
|
||||
39
openclaw/scripts/clawtributors-map.json
Normal file
39
openclaw/scripts/clawtributors-map.json
Normal 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"
|
||||
}
|
||||
}
|
||||
289
openclaw/scripts/codesign-mac-app.sh
Normal file
289
openclaw/scripts/codesign-mac-app.sh
Normal 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"
|
||||
3
openclaw/scripts/codespell-dictionary.txt
Normal file
3
openclaw/scripts/codespell-dictionary.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
messagesNcontentXtooluseinput->messages.content.tool_use.input
|
||||
groupsthreads->groups/threads
|
||||
startstoprestart->start/stop/restart
|
||||
9
openclaw/scripts/codespell-ignore.txt
Normal file
9
openclaw/scripts/codespell-ignore.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
iTerm
|
||||
FO
|
||||
Nam
|
||||
Lins
|
||||
Vai
|
||||
OptionA
|
||||
CAF
|
||||
overlayed
|
||||
re-use
|
||||
232
openclaw/scripts/committer
Normal file
232
openclaw/scripts/committer
Normal 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[@]}"
|
||||
1323
openclaw/scripts/control-ui-i18n.ts
Normal file
1323
openclaw/scripts/control-ui-i18n.ts
Normal file
File diff suppressed because it is too large
Load diff
7
openclaw/scripts/copy-bundled-plugin-metadata.d.mts
Normal file
7
openclaw/scripts/copy-bundled-plugin-metadata.d.mts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export function rewritePackageExtensions(entries: unknown): string[] | undefined;
|
||||
|
||||
export function copyBundledPluginMetadata(params?: {
|
||||
repoRoot?: string;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): void;
|
||||
323
openclaw/scripts/copy-bundled-plugin-metadata.mjs
Normal file
323
openclaw/scripts/copy-bundled-plugin-metadata.mjs
Normal 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();
|
||||
}
|
||||
54
openclaw/scripts/copy-export-html-templates.ts
Normal file
54
openclaw/scripts/copy-export-html-templates.ts
Normal 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();
|
||||
52
openclaw/scripts/copy-hook-metadata.ts
Normal file
52
openclaw/scripts/copy-hook-metadata.ts
Normal 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();
|
||||
16
openclaw/scripts/copy-plugin-sdk-root-alias.mjs
Normal file
16
openclaw/scripts/copy-plugin-sdk-root-alias.mjs
Normal 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();
|
||||
}
|
||||
176
openclaw/scripts/create-dmg.sh
Normal file
176
openclaw/scripts/create-dmg.sh
Normal 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"
|
||||
274
openclaw/scripts/cron_usage_report.ts
Normal file
274
openclaw/scripts/cron_usage_report.ts
Normal 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();
|
||||
}
|
||||
389
openclaw/scripts/debug-claude-usage.ts
Normal file
389
openclaw/scripts/debug-claude-usage.ts
Normal 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();
|
||||
839
openclaw/scripts/dev/discord-acp-plain-language-smoke.ts
Normal file
839
openclaw/scripts/dev/discord-acp-plain-language-smoke.ts
Normal 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);
|
||||
78
openclaw/scripts/dev/gateway-smoke.ts
Normal file
78
openclaw/scripts/dev/gateway-smoke.ts
Normal 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();
|
||||
132
openclaw/scripts/dev/gateway-ws-client.ts
Normal file
132
openclaw/scripts/dev/gateway-ws-client.ts
Normal 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 };
|
||||
}
|
||||
279
openclaw/scripts/dev/ios-node-e2e.ts
Normal file
279
openclaw/scripts/dev/ios-node-e2e.ts
Normal 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();
|
||||
17
openclaw/scripts/dev/ios-pull-gateway-log.sh
Normal file
17
openclaw/scripts/dev/ios-pull-gateway-log.sh
Normal 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"
|
||||
|
||||
67
openclaw/scripts/dev/test-device-pair-telegram.ts
Normal file
67
openclaw/scripts/dev/test-device-pair-telegram.ts
Normal 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})` : "");
|
||||
34
openclaw/scripts/docker/cleanup-smoke/Dockerfile
Normal file
34
openclaw/scripts/docker/cleanup-smoke/Dockerfile
Normal 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"]
|
||||
44
openclaw/scripts/docker/cleanup-smoke/run.sh
Normal file
44
openclaw/scripts/docker/cleanup-smoke/run.sh
Normal 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"
|
||||
53
openclaw/scripts/docker/install-sh-common/cli-verify.sh
Normal file
53
openclaw/scripts/docker/install-sh-common/cli-verify.sh
Normal 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
|
||||
}
|
||||
25
openclaw/scripts/docker/install-sh-common/version-parse.sh
Normal file
25
openclaw/scripts/docker/install-sh-common/version-parse.sh
Normal 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 \
|
||||
"$@"
|
||||
}
|
||||
21
openclaw/scripts/docker/install-sh-e2e/Dockerfile
Normal file
21
openclaw/scripts/docker/install-sh-e2e/Dockerfile
Normal 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"]
|
||||
607
openclaw/scripts/docker/install-sh-e2e/run.sh
Normal file
607
openclaw/scripts/docker/install-sh-e2e/run.sh
Normal 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"
|
||||
47
openclaw/scripts/docker/install-sh-nonroot/Dockerfile
Normal file
47
openclaw/scripts/docker/install-sh-nonroot/Dockerfile
Normal 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"]
|
||||
49
openclaw/scripts/docker/install-sh-nonroot/run.sh
Normal file
49
openclaw/scripts/docker/install-sh-nonroot/run.sh
Normal 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"
|
||||
30
openclaw/scripts/docker/install-sh-smoke/Dockerfile
Normal file
30
openclaw/scripts/docker/install-sh-smoke/Dockerfile
Normal 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"]
|
||||
345
openclaw/scripts/docker/install-sh-smoke/run.sh
Normal file
345
openclaw/scripts/docker/install-sh-smoke/run.sh
Normal 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
|
||||
654
openclaw/scripts/docker/setup.sh
Normal file
654
openclaw/scripts/docker/setup.sh
Normal 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\""
|
||||
278
openclaw/scripts/docs-i18n/behavior_baseline_test.go
Normal file
278
openclaw/scripts/docs-i18n/behavior_baseline_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
830
openclaw/scripts/docs-i18n/doc_chunked_raw.go
Normal file
830
openclaw/scripts/docs-i18n/doc_chunked_raw.go
Normal 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]
|
||||
}
|
||||
200
openclaw/scripts/docs-i18n/doc_mode.go
Normal file
200
openclaw/scripts/docs-i18n/doc_mode.go
Normal 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
|
||||
}
|
||||
904
openclaw/scripts/docs-i18n/doc_mode_test.go
Normal file
904
openclaw/scripts/docs-i18n/doc_mode_test.go
Normal 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
Loading…
Add table
Add a link
Reference in a new issue