refactor(scripts): deslop tooling scripts (#160798)

This commit is contained in:
Peter Steinberger 2026-09-28 17:20:56 -07:00 • committed by GitHub
parent a9cb020f56
commit 80fd8670e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 1599 additions and 2388 deletions

View file

@ -20,7 +20,7 @@ General model auth is covered in [Authentication](/gateway/authentication). The
- `scripts/setup-auth-system.sh` - one-time setup: checks current auth, helps generate a long-lived `claude setup-token`, and prints systemd/Termux install steps.
- `scripts/claude-auth-status.sh [full|json|simple]` - checks Claude Code + OpenClaw auth status.
- `scripts/auth-monitor.sh` - polls status and sends a notification (via OpenClaw send, and/or ntfy.sh) when the token nears expiry. Env: `WARN_HOURS` (default `2`), `NOTIFY_PHONE`, `NOTIFY_NTFY`. Run on a schedule via the bundled `scripts/systemd/openclaw-auth-monitor.{service,timer}` (every 30 minutes).
- `scripts/auth-monitor.sh` - polls status and sends a notification (via `openclaw message send`, and/or ntfy.sh) when the token nears expiry, including its final hour while authentication remains usable. Env: `WARN_HOURS` (default `2`), `NOTIFY_PHONE`, `NOTIFY_NTFY`. Run on a schedule via the bundled `scripts/systemd/openclaw-auth-monitor.{service,timer}` (every 30 minutes).
- `scripts/mobile-reauth.sh` - re-runs `claude setup-token` and prints URLs to open on a phone, for use over SSH from Termux.
- `scripts/termux-quick-auth.sh`, `scripts/termux-auth-widget.sh`, `scripts/termux-sync-widget.sh` - Termux:Widget scripts. They SSH to the host and show a status toast. They open the re-auth console/instructions when auth has expired.

View file

@ -206,6 +206,7 @@ openclaw doctor --json
## Troubleshooting
- **Token generation fails:** Setup and launch stop before saving a generated token or starting the container when the selected random source (`openssl`, Python, or `od`) fails. Repair that command and retry.
- **Init executable missing (`lookup init binary` / `container-init binary not found on the host`):** Install `catatonit` on the Podman engine host or repair its configured `init_path`/`helper_binaries_dir` in `containers.conf`, then retry. Installing the helper inside the Gateway or sandbox image does not repair the engine host. Keep `--init` enabled; see [Host init prerequisite](/gateway/sandboxing/podman-backend#host-init-prerequisite).
- **Permission denied (EACCES) on config or workspace:** The container runs with `--userns=keep-id` and `--user <your uid>:<your gid>` by default. Ensure the host config/workspace paths are owned by your current user.
- **Gateway start blocked (missing `gateway.mode=local`):** Ensure `~/.openclaw/openclaw.json` exists and sets `gateway.mode="local"`. `scripts/podman/setup.sh` creates this if missing.

View file

@ -128,14 +128,18 @@ async function waitForEvent(params: {
controlDir: string;
afterIndex?: number;
description: string;
predicate: (event: AdapterEvent) => boolean;
match: Partial<AdapterEvent>;
timeoutMs?: number;
}): Promise<{ event: AdapterEvent; index: number; events: AdapterEvent[] }> {
const startedAt = Date.now();
const afterIndex = params.afterIndex ?? -1;
while (Date.now() - startedAt < (params.timeoutMs ?? EVENT_WAIT_TIMEOUT_MS)) {
const events = await readEvents(params.controlDir);
const relativeIndex = events.slice(afterIndex + 1).findIndex(params.predicate);
const relativeIndex = events
.slice(afterIndex + 1)
.findIndex((event) =>
Object.entries(params.match).every(([key, value]) => event[key] === value),
);
if (relativeIndex >= 0) {
const index = afterIndex + 1 + relativeIndex;
return { event: events[index]!, index, events };
@ -495,8 +499,7 @@ async function sendAndObserveTurn(params: {
controlDir: params.controlDir,
afterIndex: params.afterIndex,
description: `completed turn ${params.text}`,
predicate: (event) =>
event.event === "turn_end" && event.text === params.text && event.stopReason === "end_turn",
match: { event: "turn_end", text: params.text, stopReason: "end_turn" },
});
}
@ -526,7 +529,7 @@ async function spawnAndBindAcpSession(params: {
await waitForEvent({
controlDir: params.controlDir,
description: "spawned ACP adapter session creation",
predicate: (event) => event.event === "session_create",
match: { event: "session_create" },
});
await logDriverEvent(params.scenarioDir, "acp_spawn_bound", {
sourceSessionKey: CONTROL_SESSION_KEY,
@ -706,12 +709,15 @@ async function runScenario(params: {
agentRows: readAgentSessionRows(state.stateDir, acpSessionKey),
});
const baseline = await sendAndObserveTurn({
const turn = {
client,
controlDir,
scenarioDir,
stateDir: state.stateDir,
acpSessionKey,
};
const baseline = await sendAndObserveTurn({
...turn,
text: `baseline-${params.scenario}`,
});
const oldIdentity = identityFromTurn(baseline.event);
@ -726,13 +732,12 @@ async function runScenario(params: {
identity: oldIdentity,
});
if (params.scenario === "cancel-timeout") {
await writeMarker(controlDir, "hang-cancel");
} else if (params.scenario === "runtime-option-timeout") {
await writeMarker(controlDir, "hang-close");
await writeMarker(
controlDir,
params.scenario === "cancel-timeout" ? "hang-cancel" : "hang-close",
);
if (params.scenario === "runtime-option-timeout") {
await writeMarker(controlDir, "hang-set-mode");
} else {
await writeMarker(controlDir, "hang-close");
}
if (params.scenario === "late-turn") {
await writeMarker(controlDir, "cancel-no-abort");
@ -741,14 +746,21 @@ async function runScenario(params: {
let heldTurnRunId: string | undefined;
let runtimeOptionRunId: string | undefined;
let eventCursor = baseline.index;
const waitForOldSessionEvent = (
description: string,
match: Partial<AdapterEvent>,
afterIndex = eventCursor,
) =>
waitForEvent({
controlDir,
afterIndex,
description,
match: { ...match, sessionId: oldIdentity.sessionId },
});
if (params.scenario === "runtime-option-timeout") {
runtimeOptionRunId = await sendChat(client, "/acp set-mode plan");
const setModeStarted = await waitForEvent({
controlDir,
afterIndex: eventCursor,
description: "old runtime-option operation start",
predicate: (event) =>
event.event === "set_mode_start" && event.sessionId === oldIdentity.sessionId,
const setModeStarted = await waitForOldSessionEvent("old runtime-option operation start", {
event: "set_mode_start",
});
eventCursor = setModeStarted.index;
await logDriverEvent(scenarioDir, "held_runtime_option_started", {
@ -757,14 +769,9 @@ async function runScenario(params: {
});
} else if (params.scenario !== "close-timeout") {
heldTurnRunId = await sendChat(client, "hold-turn");
const heldTurn = await waitForEvent({
controlDir,
afterIndex: eventCursor,
description: "held old turn start",
predicate: (event) =>
event.event === "turn_start" &&
event.text === "hold-turn" &&
event.sessionId === oldIdentity.sessionId,
const heldTurn = await waitForOldSessionEvent("held old turn start", {
event: "turn_start",
text: "hold-turn",
});
eventCursor = heldTurn.index;
await logDriverEvent(scenarioDir, "held_turn_started", {
@ -799,11 +806,7 @@ async function runScenario(params: {
});
const fresh = await sendAndObserveTurn({
client,
controlDir,
scenarioDir,
stateDir: state.stateDir,
acpSessionKey,
...turn,
text: `fresh-${params.scenario}`,
afterIndex: eventCursor,
directToAcpSession: true,
@ -833,53 +836,33 @@ async function runScenario(params: {
let lateRuntimeOptionCompletedAt: string | undefined;
if (params.scenario === "close-timeout") {
await writeMarker(controlDir, "release-close");
lateCompletion = await waitForEvent({
controlDir,
afterIndex: eventCursor,
description: "late close completion",
predicate: (event) =>
event.event === "close_end" && event.sessionId === oldIdentity.sessionId,
lateCompletion = await waitForOldSessionEvent("late close completion", {
event: "close_end",
});
} else if (params.scenario === "cancel-timeout") {
await writeMarker(controlDir, "release-cancel");
lateCompletion = await waitForEvent({
controlDir,
afterIndex: eventCursor,
description: "late cancel completion",
predicate: (event) =>
event.event === "cancel_end" && event.sessionId === oldIdentity.sessionId,
lateCompletion = await waitForOldSessionEvent("late cancel completion", {
event: "cancel_end",
});
await waitForEvent({
controlDir,
afterIndex: eventCursor,
description: "cancelled old turn completion",
predicate: (event) =>
event.event === "turn_end" &&
event.sessionId === oldIdentity.sessionId &&
event.stopReason === "cancelled",
await waitForOldSessionEvent("cancelled old turn completion", {
event: "turn_end",
stopReason: "cancelled",
});
} else if (params.scenario === "runtime-option-timeout") {
await writeMarker(controlDir, "release-set-mode");
await writeMarker(controlDir, "release-close");
const lateRuntimeOption = await waitForEvent({
controlDir,
afterIndex: eventCursor,
description: "late runtime-option completion",
predicate: (event) =>
event.event === "set_mode_end" && event.sessionId === oldIdentity.sessionId,
const lateRuntimeOption = await waitForOldSessionEvent("late runtime-option completion", {
event: "set_mode_end",
});
assert(
lateRuntimeOption.index > fresh.index,
"runtime-option completion was not observed after the fresh turn",
);
lateRuntimeOptionCompletedAt = lateRuntimeOption.event.at;
lateCompletion = await waitForEvent({
controlDir,
afterIndex: eventCursor,
description: "late close completion after runtime-option operation",
predicate: (event) =>
event.event === "close_end" && event.sessionId === oldIdentity.sessionId,
});
lateCompletion = await waitForOldSessionEvent(
"late close completion after runtime-option operation",
{ event: "close_end" },
);
if (runtimeOptionRunId) {
await client
.request(
@ -896,24 +879,17 @@ async function runScenario(params: {
});
} else {
await writeMarker(controlDir, "release-turn");
lateCompletion = await waitForEvent({
controlDir,
afterIndex: eventCursor,
description: "late old turn completion",
predicate: (event) =>
event.event === "turn_end" &&
event.sessionId === oldIdentity.sessionId &&
event.text === "hold-turn" &&
event.stopReason === "end_turn",
lateCompletion = await waitForOldSessionEvent("late old turn completion", {
event: "turn_end",
text: "hold-turn",
stopReason: "end_turn",
});
await writeMarker(controlDir, "release-close");
await waitForEvent({
controlDir,
afterIndex: lateCompletion.index,
description: "old close completion after late turn",
predicate: (event) =>
event.event === "close_end" && event.sessionId === oldIdentity.sessionId,
});
await waitForOldSessionEvent(
"old close completion after late turn",
{ event: "close_end" },
lateCompletion.index,
);
}
eventCursor = lateCompletion.index;
@ -946,11 +922,7 @@ async function runScenario(params: {
}
const followup = await sendAndObserveTurn({
client,
controlDir,
scenarioDir,
stateDir: state.stateDir,
acpSessionKey,
...turn,
text: `followup-${params.scenario}`,
afterIndex: eventCursor,
directToAcpSession: true,

View file

@ -9,6 +9,7 @@ import postcss, { type Rule } from "postcss";
import selectorParser, { type ClassName, type Selector } from "postcss-selector-parser";
import * as ts from "typescript/unstable/ast";
import { createNativeTypeScriptParser } from "./lib/native-typescript.mts";
import { getPropertyNameText } from "./lib/ts-guard-utils.mts";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(SCRIPT_DIR, "..");
@ -82,12 +83,9 @@ function groupBy<T, K>(values: Iterable<T>, keyFor: (value: T) => K): Map<K, T[]
const groups = new Map<K, T[]>();
for (const value of values) {
const key = keyFor(value);
const group = groups.get(key);
if (group) {
group.push(value);
} else {
groups.set(key, [value]);
}
const group = groups.get(key) ?? [];
group.push(value);
groups.set(key, group);
}
return groups;
}
@ -146,11 +144,7 @@ function classMapPropertyName(node: ts.ObjectLiteralElementLike): string | null
if (!ts.isPropertyAssignment(node) && !ts.isShorthandPropertyAssignment(node)) {
return null;
}
const name = node.name;
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
return name.text;
}
return null;
return getPropertyNameText(node.name);
}
/** Collect literal class tokens and dynamic class stems from TypeScript source. */

View file

@ -44,10 +44,13 @@ send_notification() {
# 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
local auth_status="" auth_exit=0
auth_status="$("$SCRIPT_DIR/claude-auth-status.sh" simple 2>/dev/null)" || auth_exit=$?
# Expiring credentials remain usable; the status helper reports them with exit 2.
if [[ "$auth_exit" -eq 0 && "$auth_status" == "OK" ]] ||
[[ "$auth_exit" -eq 2 && ( "$auth_status" == "CLAUDE_EXPIRING" || "$auth_status" == "OPENCLAW_EXPIRING" ) ]]; then
echo "Sending via OpenClaw to $NOTIFY_PHONE..."
if openclaw send --to "$NOTIFY_PHONE" --message "$message" 2>/dev/null; then
if openclaw message send --target "$NOTIFY_PHONE" --message "$message" 2>/dev/null; then
notification_sent=1
fi
fi

View file

@ -7,8 +7,11 @@ 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)
RUN_LINE_RE = re.compile(r"^(\s*(?:-\s+)?)run:\s*(.*)$")
USING_COMPOSITE_RE = re.compile(
r"""^\s*using:\s*(?:composite|'composite'|"composite")\s*(?:#.*)?$""",
re.MULTILINE,
)
def indentation(line: str) -> int:

View file

@ -21,16 +21,13 @@ const KNIP_SCANS = [
},
] as const;
/** Parses compact Knip output into unused file paths. */
export function parseKnipCompactUnusedFiles(output: string) {
const files = [];
let inUnusedFilesSection = false;
let sawUnusedFilesSection = false;
for (const line of output.split(/\r?\n/u)) {
if (/^Unused files \(\d+\)$/u.test(line)) {
inUnusedFilesSection = true;
sawUnusedFilesSection = true;
continue;
}
if (inUnusedFilesSection && line.trim() === "") {
@ -38,7 +35,7 @@ export function parseKnipCompactUnusedFiles(output: string) {
}
const separatorIndex = line.lastIndexOf(": ");
if (separatorIndex === -1 || (sawUnusedFilesSection && !inUnusedFilesSection)) {
if (separatorIndex === -1) {
continue;
}
const file = line.slice(separatorIndex + 2).trim();
@ -50,7 +47,6 @@ export function parseKnipCompactUnusedFiles(output: string) {
return uniqueSorted(files);
}
/** Rejects every unused file reported by Knip. */
export function checkUnusedFiles(output: string) {
const files = parseKnipCompactUnusedFiles(output);
return {
@ -67,7 +63,6 @@ export function checkUnusedFiles(output: string) {
};
}
/** Validates both Knip process completion and the unused-file report. */
export function checkKnipUnusedFileScanResult(result: KnipRunResult) {
if (result.errorCode || result.status === null || result.status !== 0) {
return {

View file

@ -5,6 +5,7 @@ import path from "node:path";
import * as ts from "typescript/unstable/ast";
import { createNativeTypeScriptParser } from "./lib/native-typescript.mts";
import { resolveRepoRoot } from "./lib/repo-root.mjs";
import { toLine } from "./lib/ts-guard-utils.mts";
const repoRoot = resolveRepoRoot(import.meta.url);
const SCAN_ROOTS = ["src", "extensions", "packages"];
@ -49,10 +50,6 @@ function normalizeCommentText(comment: string) {
.join(" ");
}
function lineOf(sourceFile: ts.SourceFile, node: ts.Node) {
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
}
function isExported(node: ts.ModifiersBase) {
return (
node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ||
@ -110,7 +107,7 @@ function collectViolations(filePath: string, sourceFile: ts.SourceFile) {
!/@deprecated\b/.test(comment)
) {
violations.push({
line: lineOf(sourceFile, node),
line: toLine(sourceFile, node),
name: symbolName(node),
filePath: path.relative(repoRoot, filePath).replaceAll(path.sep, "/"),
});

View file

@ -166,48 +166,7 @@ export function createExtensionPluginSdkBoundaryChecker(options: { repoRoot?: st
const generatedExtensionAssetSources = new Set(
listGeneratedExtensionAssetSources({ rootDir: repoRoot }),
);
const collectBoundaryEntries: NonNullable<
Parameters<
typeof createExtensionImportBoundaryChecker<CollectedBoundaryEntry>
>[0]["collectEntries"]
> = ({ filePath, relativeFile, references }) => {
const extensionRoot = relativeFile.split("/").slice(0, 2).join("/");
const entries: CollectedBoundaryEntry[] = [];
for (const { kind, line, specifier } of references) {
const resolvedPath = resolveBoundarySpecifier(repoRoot, specifier, filePath);
if (!resolvedPath) {
continue;
}
const modes: BoundaryMode[] = [];
if (
specifier.startsWith(".") &&
resolvedPath !== extensionRoot &&
!resolvedPath.startsWith(extensionRoot + "/")
) {
modes.push("relative-outside-package");
}
if (resolvedPath.startsWith("src/") && !resolvedPath.startsWith("src/plugin-sdk/")) {
modes.push("src-outside-plugin-sdk");
}
if (isNormalizationCoreBypass(specifier, resolvedPath)) {
modes.push("normalization-core-bypass");
}
for (const mode of modes) {
entries.push({
mode,
entry: {
file: relativeFile,
line,
kind,
specifier,
resolvedPath,
reason: classifyReason(mode, kind, resolvedPath, specifier),
},
});
}
}
return entries;
};
const extensionBoundaryChecker = createExtensionImportBoundaryChecker<CollectedBoundaryEntry>({
repoRoot,
roots: [BUNDLED_PLUGIN_ROOT_DIR],
@ -242,7 +201,44 @@ export function createExtensionPluginSdkBoundaryChecker(options: { repoRoot?: st
!resolvedPath.startsWith(extensionRoot + "/"))
);
},
collectEntries: collectBoundaryEntries,
collectEntries({ filePath, relativeFile, references }) {
const extensionRoot = relativeFile.split("/").slice(0, 2).join("/");
const entries: CollectedBoundaryEntry[] = [];
for (const { kind, line, specifier } of references) {
const resolvedPath = resolveBoundarySpecifier(repoRoot, specifier, filePath);
if (!resolvedPath) {
continue;
}
const modes: BoundaryMode[] = [];
if (
specifier.startsWith(".") &&
resolvedPath !== extensionRoot &&
!resolvedPath.startsWith(extensionRoot + "/")
) {
modes.push("relative-outside-package");
}
if (resolvedPath.startsWith("src/") && !resolvedPath.startsWith("src/plugin-sdk/")) {
modes.push("src-outside-plugin-sdk");
}
if (isNormalizationCoreBypass(specifier, resolvedPath)) {
modes.push("normalization-core-bypass");
}
for (const mode of modes) {
entries.push({
mode,
entry: {
file: relativeFile,
line,
kind,
specifier,
resolvedPath,
reason: classifyReason(mode, kind, resolvedPath, specifier),
},
});
}
}
return entries;
},
compareEntries: (left, right) => compareEntries(left.entry, right.entry),
});
let allInventoryByModePromise: Promise<BoundaryInventoryByMode> | undefined;
@ -316,9 +312,6 @@ export function createExtensionPluginSdkBoundaryChecker(options: { repoRoot?: st
const defaultBoundaryChecker = createExtensionPluginSdkBoundaryChecker();
/**
* Entrypoint wrapper for the extension plugin SDK boundary check.
*/
async function main(argv?: string[], io?: BoundaryCheckIo): Promise<0 | 1> {
const exitCode = await defaultBoundaryChecker.main(argv, io);
if (!io) {

View file

@ -1,6 +1,5 @@
#!/usr/bin/env node
// Runs gateway startup and QA scenarios while checking hot CPU observations.
import {
spawnSync as defaultSpawnSync,
type SpawnSyncOptions,
@ -179,92 +178,56 @@ function readJsonIfExists(filePath: string): unknown {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function validateStartupReport(report: unknown): string | null {
function parseStartupReport(report: unknown) {
if (!isRecord(report)) {
return "startup report must be a JSON object";
throw new Error("startup report must be a JSON object");
}
if (!Array.isArray(report.results)) {
return "startup report missing results array";
throw new Error("startup report missing results array");
}
if (report.results.length === 0) {
return "startup report has no measured results";
throw new Error("startup report has no measured results");
}
return null;
return report;
}
function readStartupReport(startupOutput: string) {
if (!fs.existsSync(startupOutput)) {
function parseConcurrencyReport(report: unknown): ConcurrencyReport {
if (!isRecord(report)) {
throw new Error("concurrency report must be a JSON object");
}
if (report.mode !== "mock-streaming-agent") {
throw new Error("concurrency report has an unexpected mode");
}
if (!Array.isArray(report.runs) || report.runs.length === 0) {
throw new Error("concurrency report has no measured runs");
}
if (!isRecord(report.summary)) {
throw new Error("concurrency report missing summary");
}
return report as ConcurrencyReport;
}
function readBenchReport<T>(
output: string,
kind: "startup" | "concurrency",
parse: (report: unknown) => T,
) {
if (!fs.existsSync(output)) {
return {
diagnosticFailure: "startup-report-missing",
diagnosticDetail: `expected startup bench report at ${startupOutput}`,
diagnosticFailure: `${kind}-report-missing`,
diagnosticDetail: `expected ${kind} bench report at ${output}`,
report: null,
};
}
try {
const report = readJsonIfExists(startupOutput);
const invalidReason = validateStartupReport(report);
if (invalidReason) {
return {
diagnosticFailure: "startup-report-invalid",
diagnosticDetail: invalidReason,
report: null,
};
}
return {
diagnosticFailure: null,
diagnosticDetail: null,
report,
report: parse(readJsonIfExists(output)),
};
} catch (error) {
return {
diagnosticFailure: "startup-report-invalid",
diagnosticDetail: error instanceof Error ? error.message : String(error),
report: null,
};
}
}
function validateConcurrencyReport(report: unknown): string | null {
if (!isRecord(report)) {
return "concurrency report must be a JSON object";
}
if (report.mode !== "mock-streaming-agent") {
return "concurrency report has an unexpected mode";
}
if (!Array.isArray(report.runs) || report.runs.length === 0) {
return "concurrency report has no measured runs";
}
if (!isRecord(report.summary)) {
return "concurrency report missing summary";
}
return null;
}
function readConcurrencyReport(concurrencyOutput: string) {
if (!fs.existsSync(concurrencyOutput)) {
return {
diagnosticFailure: "concurrency-report-missing",
diagnosticDetail: `expected concurrency bench report at ${concurrencyOutput}`,
report: null,
};
}
try {
const report = readJsonIfExists(concurrencyOutput);
const invalidReason = validateConcurrencyReport(report);
return invalidReason
? {
diagnosticFailure: "concurrency-report-invalid",
diagnosticDetail: invalidReason,
report: null,
}
: {
diagnosticFailure: null,
diagnosticDetail: null,
report: report as ConcurrencyReport,
};
} catch (error) {
return {
diagnosticFailure: "concurrency-report-invalid",
diagnosticFailure: `${kind}-report-invalid`,
diagnosticDetail: error instanceof Error ? error.message : String(error),
report: null,
};
@ -421,58 +384,49 @@ async function runGatewayCpuScenarios(
params,
);
steps.push(startupBuild);
steps.push(
startupBuild.status === 0
? runStep(
"startup bench",
process.execPath,
[
"--import",
"tsx",
"scripts/bench-gateway-startup.ts",
"--runs",
String(options.runs),
"--warmup",
String(options.warmup),
"--output",
startupOutput,
...options.startupCases.flatMap((id) => ["--case", id]),
],
{ env: baseEnv },
params,
)
: { name: "startup bench", signal: null, status: 1 },
);
steps.push(
startupBuild.status === 0
? runStep(
"concurrency bench",
process.execPath,
[
"--import",
"tsx",
"scripts/bench-gateway-concurrency.ts",
"--concurrency",
String(DEFAULT_GATEWAY_CONCURRENCY),
"--workspace-fanout",
// Post-fix readyz/sessions.list p100 is 1.3-2.6s across environments;
// 4s still catches the pre-fix 8s+ stalls and handshake timeouts.
"--max-control-ms",
"4000",
"--max-handshake-ms",
"2000",
"--runs",
String(options.runs),
"--warmup",
String(options.warmup),
"--output",
concurrencyOutput,
],
{ env: baseEnv },
params,
)
: { name: "concurrency bench", signal: null, status: 1 },
);
const runArgs = ["--runs", String(options.runs), "--warmup", String(options.warmup)];
for (const bench of [
{
name: "startup bench",
script: "scripts/bench-gateway-startup.ts",
args: [
...runArgs,
"--output",
startupOutput,
...options.startupCases.flatMap((id) => ["--case", id]),
],
},
{
name: "concurrency bench",
script: "scripts/bench-gateway-concurrency.ts",
args: [
"--concurrency",
String(DEFAULT_GATEWAY_CONCURRENCY),
"--workspace-fanout",
// Post-fix readyz/sessions.list p100 is 1.3-2.6s across environments;
// 4s still catches the pre-fix 8s+ stalls and handshake timeouts.
"--max-control-ms",
"4000",
"--max-handshake-ms",
"2000",
...runArgs,
"--output",
concurrencyOutput,
],
},
]) {
steps.push(
startupBuild.status === 0
? runStep(
bench.name,
process.execPath,
["--import", "tsx", bench.script, ...bench.args],
{ env: baseEnv },
params,
)
: { name: bench.name, signal: null, status: 1 },
);
}
}
let privateQaBuildFailed = false;
@ -528,7 +482,9 @@ async function runGatewayCpuScenarios(
steps.push(qaStep);
}
const startupReportResult = options.skipStartup ? null : readStartupReport(startupOutput);
const startupReportResult = options.skipStartup
? null
: readBenchReport(startupOutput, "startup", parseStartupReport);
const startupReportFailure =
steps.find((step) => step.name === "startup bench")?.status === 0
? (startupReportResult?.diagnosticFailure ?? null)
@ -536,7 +492,7 @@ async function runGatewayCpuScenarios(
const startup = startupReportResult?.report ?? null;
const concurrencyReportResult = options.skipStartup
? null
: readConcurrencyReport(concurrencyOutput);
: readBenchReport(concurrencyOutput, "concurrency", parseConcurrencyReport);
const concurrencyReportFailure =
steps.find((step) => step.name === "concurrency bench")?.status === 0
? (concurrencyReportResult?.diagnosticFailure ?? null)

View file

@ -1,14 +1,12 @@
#!/usr/bin/env node
// Enforces Kysely and SQLite guardrails in infrastructure code.
import { promises as fs } from "node:fs";
import path from "node:path";
import * as ts from "typescript/unstable/ast";
import type { Expression, ImportDeclaration, Node, SourceFile } from "typescript/unstable/ast";
import { createNativeTypeScriptParser } from "./lib/native-typescript.mts";
import { resolveRepoRoot } from "./lib/repo-root.mjs";
import {
collectTypeScriptFilesFromRoots,
collectFileViolations,
getPropertyNameText,
runAsScript,
toLine,
@ -350,11 +348,7 @@ function isLikelySqliteReceiver(expression: Expression) {
function isPersistedRowExpression(expression: Expression) {
const unwrapped = unwrapExpression(expression);
if (ts.isPropertyAccessExpression(unwrapped)) {
const owner = unwrapExpression(unwrapped.expression);
return ts.isIdentifier(owner) && /^(?:row|record|entry)$/u.test(owner.text);
}
if (ts.isElementAccessExpression(unwrapped)) {
if (ts.isPropertyAccessExpression(unwrapped) || ts.isElementAccessExpression(unwrapped)) {
const owner = unwrapExpression(unwrapped.expression);
return ts.isIdentifier(owner) && /^(?:row|record|entry)$/u.test(owner.text);
}
@ -368,9 +362,6 @@ function isPersistedStringCastType(typeText: string) {
].some((pattern) => pattern.test(typeText));
}
/**
* Collects Kysely/raw SQLite violations from one source file.
*/
function collectKyselyGuardrailViolations(sourceFile: SourceFile, relativePath: string) {
const imports = collectImports(sourceFile);
const violations: GuardViolation[] = [];
@ -491,38 +482,31 @@ function collectKyselyGuardrailViolations(sourceFile: SourceFile, relativePath:
return violations;
}
/**
* Collects Kysely guardrail violations across configured source roots.
*/
async function collectKyselyGuardrails() {
using parser = createNativeTypeScriptParser({ cwd: repoRoot });
const files = await collectTypeScriptFilesFromRoots(sourceRoots, { includeTests: true });
const violations: Array<GuardViolation & { path: string }> = [];
for (const filePath of files) {
const relativePath = path.relative(repoRoot, filePath).split(path.sep).join("/");
const content = await fs.readFile(filePath, "utf8");
const sourceFile = parser.parseSourceFile(filePath, content);
for (const violation of collectKyselyGuardrailViolations(sourceFile, relativePath)) {
violations.push({ path: relativePath, ...violation });
}
}
const nodeSqliteFiles = await collectTypeScriptFilesFromRoots(nodeSqliteBoundaryRoots, {
includeTests: false,
});
for (const filePath of nodeSqliteFiles) {
const relativePath = path.relative(repoRoot, filePath).split(path.sep).join("/");
const content = await fs.readFile(filePath, "utf8");
const sourceFile = parser.parseSourceFile(filePath, content);
for (const violation of collectNodeSqliteBoundaryViolations(sourceFile, relativePath)) {
violations.push({ path: relativePath, ...violation });
for (const scan of [
{ sourceRoots, includeTests: true, collect: collectKyselyGuardrailViolations },
{
sourceRoots: nodeSqliteBoundaryRoots,
includeTests: false,
collect: collectNodeSqliteBoundaryViolations,
},
]) {
const found = await collectFileViolations({
repoRoot,
sourceRoots: scan.sourceRoots,
includeTests: scan.includeTests,
findViolations: (_content, filePath, sourceFile) =>
scan.collect(sourceFile, path.relative(repoRoot, filePath).split(path.sep).join("/")),
});
for (const violation of found) {
violation.path = violation.path.split(path.sep).join("/");
violations.push(violation);
}
}
return violations;
}
/**
* Runs the Kysely guardrail check.
*/
async function main() {
const violations = await collectKyselyGuardrails();
if (violations.length === 0) {

View file

@ -24,18 +24,6 @@ type CoverageConfig = {
readonly classifications: readonly CoverageClassification[];
};
type ConfigDocBaseline = {
readonly coreEntries: readonly ConfigDocBaselineEntry[];
readonly channelEntries: readonly ConfigDocBaselineEntry[];
readonly pluginEntries: readonly ConfigDocBaselineEntry[];
};
function flattenConfigDocBaselineEntries(
baseline: ConfigDocBaseline,
): readonly ConfigDocBaselineEntry[] {
return [...baseline.coreEntries, ...baseline.channelEntries, ...baseline.pluginEntries];
}
type ClassifiedEntry = {
readonly path: string;
readonly kind: ConfigDocBaselineEntry["kind"];
@ -67,11 +55,14 @@ const configPath = path.join(repoRoot, "scripts/lib/policy-config-coverage.jsonc
const config = JSON5.parse(await fs.readFile(configPath, "utf8")) as CoverageConfig;
const { baseline } = await renderConfigDocBaselineArtifacts();
const monitoredEntries = flattenConfigDocBaselineEntries(baseline)
.filter((entry) => !entry.hasChildren)
.filter((entry) => matchesAny(config.monitored, entry.path))
const leafEntries = [
...baseline.coreEntries,
...baseline.channelEntries,
...baseline.pluginEntries,
].filter((entry) => !entry.hasChildren);
const monitoredEntries = leafEntries
.filter((entry) => config.monitored.some((pattern) => pathMatchesPattern(pattern, entry.path)))
.toSorted((left, right) => left.path.localeCompare(right.path));
const leafEntries = flattenConfigDocBaselineEntries(baseline).filter((entry) => !entry.hasChildren);
const unmatchedMonitored = config.monitored
.filter(
(pattern) =>
@ -196,10 +187,6 @@ function summarize(entries: readonly ClassifiedEntry[]): Record<string, number>
return counts;
}
function matchesAny(patterns: readonly string[], value: string): boolean {
return patterns.some((pattern) => pathMatchesPattern(pattern, value));
}
function pathMatchesPattern(pattern: string, value: string): boolean {
const patternParts = pattern.split(".");
const valueParts = value.split(".");

View file

@ -1,13 +1,11 @@
#!/usr/bin/env node
// Finds hidden local runtime sidecar loaders missing tsdown entries.
import { promises as fs } from "node:fs";
import path from "node:path";
import * as ts from "typescript/unstable/ast";
import { createNativeTypeScriptParser } from "./lib/native-typescript.mts";
import { resolveRepoRoot } from "./lib/repo-root.mjs";
import {
collectTypeScriptFilesFromRoots,
collectFileViolations,
runAsScript,
toLine,
unwrapExpression,
@ -72,10 +70,6 @@ function isCreateRequireCall(node: ts.Node, createRequireNames: Set<string>): bo
);
}
function isLocalRuntimeSpecifier(specifier: string): boolean {
return localRuntimeSpecifierPattern.test(specifier);
}
function resolveRuntimeSpecifierSource(importerPath: string, specifier: string): string {
const importerDir = path.posix.dirname(normalizeRelativePath(importerPath));
const resolved = path.posix.normalize(path.posix.join(importerDir, specifier));
@ -89,9 +83,6 @@ function readObjectEntrySources(entry: unknown): string[] {
return Object.values(entry).filter((value): value is string => typeof value === "string");
}
/**
* Collects explicit source entry files from tsdown configuration.
*/
export function collectTsdownEntrySources(config: unknown): Set<string> {
const configs = Array.isArray(config) ? config : [config];
return new Set(
@ -105,9 +96,6 @@ export function collectTsdownEntrySources(config: unknown): Set<string> {
);
}
/**
* Finds local runtime require loaders not represented as explicit tsdown entries.
*/
export function findRuntimeSidecarLoaderViolations(
_content: string,
importerPath: string,
@ -118,22 +106,12 @@ export function findRuntimeSidecarLoaderViolations(
const requireNames = new Set<string>();
const stringConstants = new Map<string, string>();
const stringArrays = new Map<string, string[]>();
const forOfRuntimeValues: Array<Map<string, string[]>> = [];
const forOfRuntimeValues: Array<{ name: string; values: string[] }> = [];
const violations: RuntimeSidecarLoaderViolation[] = [];
const seen = new Set<string>();
const currentForOfValueMap = (): Map<string, string[]> => {
const merged = new Map<string, string[]>();
for (const scope of forOfRuntimeValues) {
for (const [name, values] of scope) {
merged.set(name, values);
}
}
return merged;
};
const addSpecifier = (specifier: string, node: ts.Node): void => {
if (!isLocalRuntimeSpecifier(specifier)) {
if (!localRuntimeSpecifierPattern.test(specifier)) {
return;
}
const sourcePath = resolveRuntimeSpecifierSource(importerPath, specifier);
@ -166,7 +144,9 @@ export function findRuntimeSidecarLoaderViolations(
return [literal];
}
if (ts.isIdentifier(unwrapped)) {
const loopValues = currentForOfValueMap().get(unwrapped.text);
const loopValues = forOfRuntimeValues.findLast(
(scope) => scope.name === unwrapped.text,
)?.values;
if (loopValues) {
return loopValues;
}
@ -222,7 +202,7 @@ export function findRuntimeSidecarLoaderViolations(
) {
const values = stringArrays.get(expression.text);
if (values) {
forOfRuntimeValues.push(new Map([[initializer.declarations[0].name.text, values]]));
forOfRuntimeValues.push({ name: initializer.declarations[0].name.text, values });
node.statement.forEachChild(visit);
forOfRuntimeValues.pop();
return;
@ -247,33 +227,26 @@ export function findRuntimeSidecarLoaderViolations(
return violations;
}
/**
* Collects runtime sidecar loader violations across configured roots.
*/
async function collectRuntimeSidecarLoaderViolations(params: {
repoRoot: string;
sourceRoots: string[];
explicitEntrySources: Set<string>;
}): Promise<LocatedRuntimeSidecarLoaderViolation[]> {
using parser = createNativeTypeScriptParser({ cwd: params.repoRoot });
const files = await collectTypeScriptFilesFromRoots(params.sourceRoots, {
const violations = await collectFileViolations({
repoRoot: params.repoRoot,
sourceRoots: params.sourceRoots,
extraTestSuffixes: [".test-support.ts", ".test-helpers.ts"],
skipFile: (filePath) => filePath.endsWith(".d.ts"),
findViolations: (content, filePath, sourceFile) =>
findRuntimeSidecarLoaderViolations(
content,
normalizeRelativePath(path.relative(params.repoRoot, filePath)),
params.explicitEntrySources,
sourceFile,
),
});
const violations: LocatedRuntimeSidecarLoaderViolation[] = [];
for (const filePath of files) {
if (filePath.endsWith(".d.ts")) {
continue;
}
const relativePath = normalizeRelativePath(path.relative(params.repoRoot, filePath));
const content = await fs.readFile(filePath, "utf8");
for (const violation of findRuntimeSidecarLoaderViolations(
content,
relativePath,
params.explicitEntrySources,
parser.parseSourceFile(filePath, content),
)) {
violations.push({ path: relativePath, ...violation });
}
for (const violation of violations) {
violation.path = normalizeRelativePath(violation.path);
}
return violations;
}

View file

@ -388,12 +388,9 @@ function findNamedBoundaryViolations(
return violations;
}
function findNamedSessionStoreViolations(
sourceFile: ts.SourceFile,
legacyNames: ReadonlySet<string>,
legacyKind: string,
) {
return findNamedBoundaryViolations(sourceFile, legacyNames, `legacy session store ${legacyKind}`);
function namedBoundaryRule(legacyNames: ReadonlySet<string>, subject: string) {
return (_content: string, _fileName: string, sourceFile: ts.SourceFile) =>
findNamedBoundaryViolations(sourceFile, legacyNames, subject);
}
export function collectSessionStoreRuntimeFileBackedCompatExports(
@ -475,20 +472,13 @@ export function findSessionAccessorBoundaryViolations(
) {
const legacyNames = legacyNamesForFile(fileName);
const legacyKind = legacyNames === legacyWholeStoreAccessNames ? "access" : "reader";
return findNamedSessionStoreViolations(sourceFile, legacyNames, legacyKind);
return findNamedBoundaryViolations(sourceFile, legacyNames, `legacy session store ${legacyKind}`);
}
export function findReadOnlySessionAccessorViolations(
_content: string,
_fileName: string,
sourceFile: ts.SourceFile,
) {
return findNamedBoundaryViolations(
sourceFile,
materializingSessionEntryAccessorNames,
"materializing session entry accessor",
);
}
export const findReadOnlySessionAccessorViolations = namedBoundaryRule(
materializingSessionEntryAccessorNames,
"materializing session entry accessor",
);
export function findEmbeddedAgentSessionTargetViolations(
_content: string,
@ -512,16 +502,10 @@ export function findEmbeddedAgentSessionTargetViolations(
const visitRunOptions = (options: ts.ObjectLiteralExpression) => {
for (const property of options.properties) {
if (
ts.isPropertyAssignment(property) &&
(ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) &&
getPropertyNameText(property.name) === "sessionFile"
) {
recordDeprecatedSessionFile(property.name);
} else if (
ts.isShorthandPropertyAssignment(property) &&
ts.isIdentifier(property.name) &&
property.name.text === "sessionFile"
) {
recordDeprecatedSessionFile(property.name);
}
}
};
@ -542,25 +526,15 @@ export function findEmbeddedAgentSessionTargetViolations(
return violations;
}
export function findSessionAccessorWriteBoundaryViolations(
_content: string,
_fileName: string,
sourceFile: ts.SourceFile,
) {
return findNamedSessionStoreViolations(sourceFile, legacyWriterNames, "writer");
}
export const findSessionAccessorWriteBoundaryViolations = namedBoundaryRule(
legacyWriterNames,
"legacy session store writer",
);
export function findTranscriptWriterBoundaryViolations(
_content: string,
_fileName: string,
sourceFile: ts.SourceFile,
) {
return findNamedBoundaryViolations(
sourceFile,
legacyTranscriptWriterNames,
"legacy transcript writer",
);
}
export const findTranscriptWriterBoundaryViolations = namedBoundaryRule(
legacyTranscriptWriterNames,
"legacy transcript writer",
);
export function findGatewaySessionCreateLifecycleViolations(
_content: string,
@ -598,29 +572,15 @@ export function findGatewaySessionCreateLifecycleViolations(
return violations;
}
export function findSessionCompactManualTrimBoundaryViolations(
_content: string,
_fileName: string,
sourceFile: ts.SourceFile,
) {
return findNamedSessionStoreViolations(
sourceFile,
legacyManualCompactTrimNames,
"manual compact trim",
);
}
export const findSessionCompactManualTrimBoundaryViolations = namedBoundaryRule(
legacyManualCompactTrimNames,
"legacy session store manual compact trim",
);
export function findSessionLifecycleCleanupBoundaryViolations(
_content: string,
_fileName: string,
sourceFile: ts.SourceFile,
) {
return findNamedSessionStoreViolations(
sourceFile,
legacyLifecycleCleanupNames,
"lifecycle cleanup",
);
}
export const findSessionLifecycleCleanupBoundaryViolations = namedBoundaryRule(
legacyLifecycleCleanupNames,
"legacy session store lifecycle cleanup",
);
// Source roots shared by the enforced boundary checks in main() and the debt
// ratchet below; keeping one list prevents the two scans from drifting apart.

View file

@ -216,31 +216,22 @@ export function findSessionTranscriptReaderBoundaryViolations(
}
}
if (ts.isPropertyAccessExpression(node)) {
if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
const receiver = unwrapExpression(node.expression);
const name = ts.isPropertyAccessExpression(node)
? node.name
: ts.isStringLiteral(node.argumentExpression)
? node.argumentExpression
: undefined;
if (
ts.isIdentifier(receiver) &&
legacyNamespaces.has(receiver.text) &&
transcriptReaderNames.has(node.name.text)
name &&
transcriptReaderNames.has(name.text)
) {
violations.push({
line: toLine(sourceFile, node.name),
reason: `references legacy transcript reader "${node.name.text}"`,
});
}
}
if (ts.isElementAccessExpression(node)) {
const receiver = unwrapExpression(node.expression);
if (
ts.isIdentifier(receiver) &&
legacyNamespaces.has(receiver.text) &&
ts.isStringLiteral(node.argumentExpression) &&
transcriptReaderNames.has(node.argumentExpression.text)
) {
violations.push({
line: toLine(sourceFile, node.argumentExpression),
reason: `references legacy transcript reader "${node.argumentExpression.text}"`,
line: toLine(sourceFile, name),
reason: `references legacy transcript reader "${name.text}"`,
});
}
}

View file

@ -1,13 +1,9 @@
#!/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'
@ -16,11 +12,7 @@ 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)"
STATUS_JSON="$(openclaw models status --json 2>/dev/null || true)"
USE_JSON=0
if [ -n "$STATUS_JSON" ]; then
USE_JSON=1
@ -56,6 +48,27 @@ format_epoch_seconds() {
date -r "$epoch_seconds" 2>/dev/null || date -d "@$epoch_seconds"
}
print_full_expiry() {
local expires_at="$1" expired_hint="$2" expiring_hint="${3:-}"
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 [ "$diff_ms" -lt 0 ]; then
echo -e " Status: ${RED}EXPIRED${NC}"
echo "$expired_hint"
elif [ "$diff_ms" -lt 3600000 ]; then
echo -e " Status: ${YELLOW}EXPIRING SOON (${mins}m remaining)${NC}"
if [ -n "$expiring_hint" ]; then
echo "$expiring_hint"
fi
else
echo -e " Status: ${GREEN}OK${NC}"
echo " Expires: $(format_epoch_seconds "$((expires_at/1000))") (${hours}h ${mins}m)"
fi
}
json_expires_for_claude_cli() {
echo "$STATUS_JSON" | jq -r '
[.auth.oauth.profiles[]
@ -91,57 +104,44 @@ json_anthropic_api_key_count() {
}
check_claude_code_auth() {
local expires_at
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
elif [ -f "$CLAUDE_CREDS" ]; then
expires_at=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS" 2>/dev/null || echo "0")
else
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() {
local expires_at
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
elif [ -f "$OPENCLAW_AUTH" ]; then
expires_at=$(jq -r '
[.profiles | to_entries[] | select(.value.provider == "anthropic") | .value.expires]
| max // 0
' "$OPENCLAW_AUTH" 2>/dev/null || echo "0")
else
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"
calc_status_from_expires "$expires_at"
}
# 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)
@ -169,7 +169,6 @@ if [ "$OUTPUT_MODE" = "json" ]; then
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)
@ -192,11 +191,9 @@ if [ "$OUTPUT_MODE" = "simple" ]; then
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)
@ -215,21 +212,9 @@ 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: $(format_epoch_seconds "$((expires_at/1000))") (${hours}h ${mins}m)"
fi
print_full_expiry "$expires_at" \
" Action needed: Run 'claude setup-token' or re-authenticate" \
" Consider running: claude setup-token"
fi
echo ""
@ -260,20 +245,7 @@ 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: $(format_epoch_seconds "$((expires/1000))") (${hours}h ${mins}m)"
fi
print_full_expiry "$expires" " Note: Run 'openclaw doctor --yes' to sync from Claude Code"
fi
echo ""

View file

@ -199,19 +199,20 @@ function managedImageUrl(fileName: string): string {
export function buildChatAttachmentHistory(baseTime: number): unknown[] {
const assets = getChatAttachmentAssets();
const assetSize = (fileName: string): number => {
const fixtureAsset = (fileName: string): FixtureAsset => {
const asset = assets[fileName];
if (!asset) {
throw new Error(`Missing chat attachment fixture asset: ${fileName}`);
}
return asset.body.byteLength;
return asset;
};
const documentAttachment = (fileName: string, mimeType: string) => ({
const assetSize = (fileName: string) => fixtureAsset(fileName).body.byteLength;
const documentAttachment = (fileName: string) => ({
type: "attachment",
attachment: {
kind: "document",
label: fileName,
mimeType,
mimeType: fixtureAsset(fileName).contentType,
url: fixtureUrl(fileName),
sizeBytes: assetSize(fileName),
},
@ -251,57 +252,42 @@ export function buildChatAttachmentHistory(baseTime: number): unknown[] {
],
timestamp: baseTime,
},
textMessage("Documents", baseTime + 1),
{
role: "assistant",
content: [
documentAttachment("notes.md", "text/markdown"),
documentAttachment("notes.txt", "text/plain"),
documentAttachment("styles.css", "text/css"),
documentAttachment("settings.json", "application/json"),
documentAttachment("script.js", "text/javascript"),
documentAttachment("brief.pdf", "application/pdf"),
documentAttachment(
...[
{
title: "Documents",
files: [
"notes.md",
"notes.txt",
"styles.css",
"settings.json",
"script.js",
"brief.pdf",
"brief.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
),
],
timestamp: baseTime + 2,
},
textMessage("File icon families", baseTime + 3),
{
role: "assistant",
content: [
documentAttachment("mystery.blob", "application/octet-stream"),
documentAttachment("sample-image.png", "image/png"),
documentAttachment("config.xml", "application/xml"),
documentAttachment("deploy.yaml", "application/yaml"),
documentAttachment("worker.py", "text/x-python"),
documentAttachment("vector.svg", "image/svg+xml"),
documentAttachment("broken-vector.svg", "image/svg+xml"),
documentAttachment("readme.rtf", "application/rtf"),
],
timestamp: baseTime + 4,
},
textMessage("HTML", baseTime + 5),
{
role: "assistant",
content: [documentAttachment("preview.html", "text/html")],
timestamp: baseTime + 6,
},
textMessage("CSV / XLSX", baseTime + 7),
{
role: "assistant",
content: [
documentAttachment("rows.csv", "text/csv"),
documentAttachment("wide.csv", "text/csv"),
documentAttachment(
"report.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
),
],
timestamp: baseTime + 8,
},
],
},
{
title: "File icon families",
files: [
"mystery.blob",
"sample-image.png",
"config.xml",
"deploy.yaml",
"worker.py",
"vector.svg",
"broken-vector.svg",
"readme.rtf",
],
},
{ title: "HTML", files: ["preview.html"] },
{ title: "CSV / XLSX", files: ["rows.csv", "wide.csv", "report.xlsx"] },
].flatMap(({ title, files }, index) => [
textMessage(title, baseTime + index * 2 + 1),
{
role: "assistant",
content: files.map(documentAttachment),
timestamp: baseTime + index * 2 + 2,
},
]),
textMessage("Before — current generic delivery cards", baseTime + 9),
{
role: "assistant",
@ -310,12 +296,12 @@ export function buildChatAttachmentHistory(baseTime: number): unknown[] {
type: "text",
text: "Current WebChat delivery removes inline playback from every media file.",
},
documentAttachment("voice---a75c70c7-0112-4d07-8fb5-40c82c979ee8.mp3", "audio/mpeg"),
documentAttachment("reply.ogg", "audio/ogg"),
documentAttachment("reply.m4a", "audio/x-m4a"),
documentAttachment("reply.flac", "audio/flac"),
documentAttachment("sample-video.mp4", "video/mp4"),
documentAttachment("sample-video.webm", "video/webm"),
documentAttachment("voice---a75c70c7-0112-4d07-8fb5-40c82c979ee8.mp3"),
documentAttachment("reply.ogg"),
documentAttachment("reply.m4a"),
documentAttachment("reply.flac"),
documentAttachment("sample-video.mp4"),
documentAttachment("sample-video.webm"),
],
timestamp: baseTime + 10,
},
@ -369,41 +355,23 @@ export function buildChatAttachmentHistory(baseTime: number): unknown[] {
textMessage("Archive", baseTime + 13),
{
role: "assistant",
content: [documentAttachment("bundle.zip", "application/zip")],
content: [documentAttachment("bundle.zip")],
timestamp: baseTime + 14,
},
textMessage("Unavailable / failed / removed", baseTime + 15),
{
role: "assistant",
content: [
{ label: "temporarily-unavailable.pdf", mimeType: "application/pdf" },
{ label: "download-failed.zip", mimeType: "application/zip" },
{
type: "attachment",
attachment: {
kind: "document",
label: "temporarily-unavailable.pdf",
mimeType: "application/pdf",
url: fixtureUrl("temporarily-unavailable.pdf"),
},
label: "removed-file.docx",
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
},
{
type: "attachment",
attachment: {
kind: "document",
label: "download-failed.zip",
mimeType: "application/zip",
url: fixtureUrl("download-failed.zip"),
},
},
{
type: "attachment",
attachment: {
kind: "document",
label: "removed-file.docx",
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
url: fixtureUrl("removed-file.docx"),
},
},
],
].map(({ label, mimeType }) => ({
type: "attachment",
attachment: { kind: "document", label, mimeType, url: fixtureUrl(label) },
})),
timestamp: baseTime + 16,
},
];

View file

@ -303,36 +303,17 @@ export function buildCronMocks(
),
};
const runByJobId = new Map(runs.map((entry) => [entry.jobId, entry]));
const sortedJobLists = [
{
match: { sortBy: "nextRunAtMs", sortDir: "asc" },
jobs: jobs.toSorted(
(left, right) => (left.state?.nextRunAtMs ?? 0) - (right.state?.nextRunAtMs ?? 0),
),
},
{
match: { sortBy: "nextRunAtMs", sortDir: "desc" },
jobs: jobs.toSorted(
(left, right) => (right.state?.nextRunAtMs ?? 0) - (left.state?.nextRunAtMs ?? 0),
),
},
{
match: { sortBy: "updatedAtMs", sortDir: "asc" },
jobs: jobs.toSorted((left, right) => (left.updatedAtMs ?? 0) - (right.updatedAtMs ?? 0)),
},
{
match: { sortBy: "updatedAtMs", sortDir: "desc" },
jobs: jobs.toSorted((left, right) => (right.updatedAtMs ?? 0) - (left.updatedAtMs ?? 0)),
},
{
match: { sortBy: "name", sortDir: "asc" },
jobs: jobs.toSorted((left, right) => left.name.localeCompare(right.name)),
},
{
match: { sortBy: "name", sortDir: "desc" },
jobs: jobs.toSorted((left, right) => right.name.localeCompare(left.name)),
},
];
const sortComparators: Record<string, (left: CronJob, right: CronJob) => number> = {
nextRunAtMs: (left, right) => (left.state?.nextRunAtMs ?? 0) - (right.state?.nextRunAtMs ?? 0),
updatedAtMs: (left, right) => (left.updatedAtMs ?? 0) - (right.updatedAtMs ?? 0),
name: (left, right) => left.name.localeCompare(right.name),
};
const sortedJobLists = Object.entries(sortComparators).flatMap(([sortBy, compare]) =>
["asc", "desc"].map((sortDir) => ({
match: { sortBy, sortDir },
jobs: jobs.toSorted(sortDir === "asc" ? compare : (left, right) => compare(right, left)),
})),
);
return {
"cron.status": status,

View file

@ -1,4 +1,3 @@
// Control Ui Mock Dev script supports OpenClaw repository automation.
import { createHash } from "node:crypto";
import fs, { rmSync } from "node:fs";
import { mkdir, mkdtemp } from "node:fs/promises";
@ -74,37 +73,32 @@ import {
} from "./control-ui-mock-skill-workshop.js";
import { buildProfileUsageMocks } from "./control-ui-mock-usage.ts";
const FIXTURES = [
"approval",
"attachments",
"avatars",
"board",
"code-fences",
"dashboards",
"goal",
"plugins-dense",
"sidebar-roster",
"swarm",
"update-available",
"update-blocked",
"update-failed",
"workboard",
"workboard-states",
] as const;
type CliOptions = {
allowedHosts: string[];
fixture?:
| "approval"
| "attachments"
| "avatars"
| "board"
| "code-fences"
| "dashboards"
| "goal"
| "plugins-dense"
| "sidebar-roster"
| "swarm"
| "update-available"
| "update-blocked"
| "update-failed"
| "workboard"
| "workboard-states";
fixture?: (typeof FIXTURES)[number];
host: string;
operatorScopes?: string[];
port: number;
};
type SessionListOptions = {
owners?: readonly SessionActorFixture[];
hasMore: boolean;
nextOffset: number | null;
offset?: number;
totalCount: number;
};
type SessionActorFixture = { type: "human" | "agent"; id: string; label: string };
const MOCK_ACTOR_PETER: SessionActorFixture = {
@ -398,26 +392,11 @@ function parseFixture(value: string | undefined): CliOptions["fixture"] {
if (!value) {
return undefined;
}
if (
value !== "approval" &&
value !== "attachments" &&
value !== "avatars" &&
value !== "board" &&
value !== "code-fences" &&
value !== "dashboards" &&
value !== "goal" &&
value !== "plugins-dense" &&
value !== "sidebar-roster" &&
value !== "swarm" &&
value !== "update-available" &&
value !== "update-blocked" &&
value !== "update-failed" &&
value !== "workboard" &&
value !== "workboard-states"
) {
const fixture = FIXTURES.find((candidate) => candidate === value);
if (!fixture) {
throw new Error(`Unknown Control UI mock fixture: ${value}`);
}
return value;
return fixture;
}
function parsePort(value: string | undefined, fallback: number): number {
@ -433,27 +412,6 @@ function parseOperatorScopes(value: string | undefined): string[] | undefined {
return scopes.length > 0 ? scopes : undefined;
}
function sessionsListResponse(sessions: Array<{ key: string }>, options: SessionListOptions) {
return {
count: sessions.length,
defaults: {
contextTokens: 200_000,
model: "gpt-5-mini",
modelProvider: "openai",
},
hasMore: options.hasMore,
limitApplied: 50,
nextOffset: options.nextOffset,
...(options.owners ? { owners: options.owners } : {}),
offset: options.offset ?? 0,
path: "",
// Cases select membership; canonical metadata comes from the scenario's rows.
sessions: sessions.map(({ key }) => ({ key })),
totalCount: options.totalCount,
ts: Date.now(),
};
}
function pagedSessionsListResponse(
sessions: Array<{ key: string }>,
offset: number,
@ -462,13 +420,24 @@ function pagedSessionsListResponse(
const normalizedOffset = Math.max(0, Math.floor(offset));
const page = sessions.slice(normalizedOffset, normalizedOffset + SESSION_PAGE_SIZE);
const nextOffset = normalizedOffset + SESSION_PAGE_SIZE;
return sessionsListResponse(page, {
owners,
return {
count: page.length,
defaults: {
contextTokens: 200_000,
model: "gpt-5-mini",
modelProvider: "openai",
},
hasMore: nextOffset < sessions.length,
limitApplied: 50,
nextOffset: nextOffset < sessions.length ? nextOffset : null,
...(owners ? { owners } : {}),
offset: normalizedOffset,
path: "",
// Cases select membership; canonical metadata comes from the scenario's rows.
sessions: page.map(({ key }) => ({ key })),
totalCount: sessions.length,
});
ts: Date.now(),
};
}
function buildSessionRows(params: {
@ -636,6 +605,124 @@ function buildSessionDiffMock() {
};
}
function buildSessionCatalogMocks(baseTime: number) {
const catalogs = [
{
id: "codex",
label: "Codex",
capabilities: { continueSession: true, archive: false, startTerminal: true },
sessions: [
{
threadId: "codex-thread-1",
name: "Release checklist sweep",
cwd: "/Users/demo/projects/openclaw",
updatedAt: baseTime - 10 * 60_000,
items: [
{
id: "release-checklist-answer",
type: "agentMessage",
text: "The release checklist is complete and ready for review.",
},
{
id: "release-checklist-request",
type: "userMessage",
text: "Please sweep the release checklist for anything we missed.",
},
],
},
{
threadId: "codex-thread-2",
name: "Sidebar context-menu proof",
cwd: "/Users/demo/projects/openclaw",
updatedAt: baseTime - 45 * 60_000,
items: [
{
id: "sidebar-context-menu-answer",
type: "agentMessage",
text: "The sidebar context menu behaves as expected.",
},
],
},
],
},
{
id: "claude",
label: "Claude Code",
capabilities: { continueSession: true, archive: false },
sessions: [
{
threadId: "claude-thread-1",
name: "Docs refresh",
cwd: "/Users/demo/projects/peekaboo",
updatedAt: baseTime - 30 * 60_000,
items: [
{
id: "docs-refresh-answer",
type: "agentMessage",
text: "The documentation refresh is ready for review.",
},
],
},
],
},
];
return {
"sessions.catalog.list": {
catalogs: catalogs.map(({ id, label, capabilities, sessions }) => ({
id,
label,
capabilities,
hosts: [
{
hostId: "gateway",
label: "This Mac",
kind: "gateway",
connected: true,
sessions: sessions.map(({ threadId, name, cwd, updatedAt }) => ({
threadId,
name,
cwd,
status: "idle",
updatedAt,
archived: false,
canContinue: true,
canArchive: false,
})),
},
],
})),
},
"sessions.catalog.read": {
cases: catalogs.flatMap(({ id, sessions }) =>
sessions.map(({ threadId, items }) => ({
match: { catalogId: id, hostId: "gateway", threadId },
response: { hostId: "gateway", threadId, items },
})),
),
},
};
}
function directoryListCase(
directory: string,
names: string[],
match: Record<string, unknown> = { path: directory },
) {
return {
match,
response: {
path: directory,
parent: path.posix.dirname(directory),
home: "/Users/demo",
entries: names.map((name) => ({
name,
path: `${directory}/${name}`,
...(name.startsWith(".") ? { hidden: true } : {}),
})),
},
};
}
function buildModelProviderMocks(baseTime: number) {
const hour = 60 * 60 * 1000;
const expiry = (remainingMs: number, label: string) => ({
@ -2453,125 +2540,7 @@ async function createChatPickerScenario(
// right-click menu, hide/restore preference) are exercised in the mock.
// Ids must match registered plugin catalogs (`claude`, `codex`) or the
// sidebar cannot resolve bundled brand marks.
"sessions.catalog.list": {
catalogs: [
{
id: "codex",
label: "Codex",
capabilities: { continueSession: true, archive: false, startTerminal: true },
hosts: [
{
hostId: "gateway",
label: "This Mac",
kind: "gateway",
connected: true,
sessions: [
{
threadId: "codex-thread-1",
name: "Release checklist sweep",
cwd: "/Users/demo/projects/openclaw",
status: "idle",
updatedAt: baseTime - 10 * 60_000,
archived: false,
canContinue: true,
canArchive: false,
},
{
threadId: "codex-thread-2",
name: "Sidebar context-menu proof",
cwd: "/Users/demo/projects/openclaw",
status: "idle",
updatedAt: baseTime - 45 * 60_000,
archived: false,
canContinue: true,
canArchive: false,
},
],
},
],
},
{
id: "claude",
label: "Claude Code",
capabilities: { continueSession: true, archive: false },
hosts: [
{
hostId: "gateway",
label: "This Mac",
kind: "gateway",
connected: true,
sessions: [
{
threadId: "claude-thread-1",
name: "Docs refresh",
cwd: "/Users/demo/projects/peekaboo",
status: "idle",
updatedAt: baseTime - 30 * 60_000,
archived: false,
canContinue: true,
canArchive: false,
},
],
},
],
},
],
},
"sessions.catalog.read": {
cases: [
{
match: { catalogId: "codex", hostId: "gateway", threadId: "codex-thread-1" },
response: {
hostId: "gateway",
threadId: "codex-thread-1",
items: [
{
id: "release-checklist-answer",
type: "agentMessage",
text: "The release checklist is complete and ready for review.",
},
{
id: "release-checklist-request",
type: "userMessage",
text: "Please sweep the release checklist for anything we missed.",
},
],
},
},
{
match: { catalogId: "codex", hostId: "gateway", threadId: "codex-thread-2" },
response: {
hostId: "gateway",
threadId: "codex-thread-2",
items: [
{
id: "sidebar-context-menu-answer",
type: "agentMessage",
text: "The sidebar context menu behaves as expected.",
},
],
},
},
{
match: {
catalogId: "claude",
hostId: "gateway",
threadId: "claude-thread-1",
},
response: {
hostId: "gateway",
threadId: "claude-thread-1",
items: [
{
id: "docs-refresh-answer",
type: "agentMessage",
text: "The documentation refresh is ready for review.",
},
],
},
},
],
},
...buildSessionCatalogMocks(baseTime),
"system.info": {
machineName: "Mock-Workstation",
hostname: "mock-workstation.invalid",
@ -2596,78 +2565,33 @@ async function createChatPickerScenario(
},
"fs.listDir": {
cases: [
{
match: { path: "/Users/demo/Projects/openclaw" },
response: {
path: "/Users/demo/Projects/openclaw",
parent: "/Users/demo/Projects",
home: "/Users/demo",
entries: [
{ name: "ui", path: "/Users/demo/Projects/openclaw/ui" },
{ name: "src", path: "/Users/demo/Projects/openclaw/src" },
{ name: "docs", path: "/Users/demo/Projects/openclaw/docs" },
{ name: "packages", path: "/Users/demo/Projects/openclaw/packages" },
],
},
},
{
match: { path: "/Users/demo/Projects" },
response: {
path: "/Users/demo/Projects",
parent: "/Users/demo",
home: "/Users/demo",
entries: [
{ name: "openclaw", path: "/Users/demo/Projects/openclaw" },
{ name: "clawdbot", path: "/Users/demo/Projects/clawdbot" },
{ name: "sweetistics", path: "/Users/demo/Projects/sweetistics" },
{ name: "Peekaboo", path: "/Users/demo/Projects/Peekaboo" },
],
},
},
{
match: {},
response: {
path: "/Users/demo",
parent: "/Users",
home: "/Users/demo",
entries: [
{ name: "Projects", path: "/Users/demo/Projects" },
{ name: "Downloads", path: "/Users/demo/Downloads" },
{ name: ".config", path: "/Users/demo/.config", hidden: true },
],
},
},
directoryListCase("/Users/demo/Projects/openclaw", ["ui", "src", "docs", "packages"]),
directoryListCase("/Users/demo/Projects", [
"openclaw",
"clawdbot",
"sweetistics",
"Peekaboo",
]),
directoryListCase("/Users/demo", ["Projects", "Downloads", ".config"], {}),
],
},
"worktrees.branches": {
cases: [
{
match: { repoRoot: "/Users/demo/Projects/openclaw" },
response: {
repoRoot: "/Users/demo/Projects/openclaw",
branches: [
{ kind: "local", name: "main" },
{ kind: "local", name: "steipete/place-picker" },
],
repositoryStatus: "git",
defaultBranch: "main",
headBranch: "main",
},
{ repoRoot: "/Users/demo/Projects/openclaw", branch: "steipete/place-picker" },
{ repoRoot: "/Users/demo/Projects/clawdbot", branch: "steipete/storage-selector-design" },
].map(({ repoRoot: repositoryRoot, branch }) => ({
match: { repoRoot: repositoryRoot },
response: {
repoRoot: repositoryRoot,
branches: [
{ kind: "local", name: "main" },
{ kind: "local", name: branch },
],
repositoryStatus: "git",
defaultBranch: "main",
headBranch: "main",
},
{
match: { repoRoot: "/Users/demo/Projects/clawdbot" },
response: {
repoRoot: "/Users/demo/Projects/clawdbot",
branches: [
{ kind: "local", name: "main" },
{ kind: "local", name: "steipete/storage-selector-design" },
],
repositoryStatus: "git",
defaultBranch: "main",
headBranch: "main",
},
},
],
})),
},
"environments.list": {
environments: [

View file

@ -260,25 +260,23 @@ export function buildPluginCatalogMock(options: PluginCatalogMockOptions = {}) {
...(params.hasIcon ? { hasIcon: true } : {}),
...(params.install ? { install: params.install } : {}),
});
const bundledChannel = (id: string, name: string, description: string) =>
entry({
id,
name,
description,
category: "channel",
origin: "bundled",
installed: true,
hasIcon: true,
});
const plugins = [
entry({
id: "whatsapp",
name: "WhatsApp",
description: "OpenClaw WhatsApp channel plugin for WhatsApp Web chats.",
category: "channel",
origin: "bundled",
installed: true,
hasIcon: true,
}),
entry({
id: "telegram",
name: "Telegram",
description: "OpenClaw Telegram channel plugin.",
category: "channel",
origin: "bundled",
installed: true,
hasIcon: true,
}),
bundledChannel(
"whatsapp",
"WhatsApp",
"OpenClaw WhatsApp channel plugin for WhatsApp Web chats.",
),
bundledChannel("telegram", "Telegram", "OpenClaw Telegram channel plugin."),
entry({
id: "discord",
name: "Discord",
@ -290,51 +288,27 @@ export function buildPluginCatalogMock(options: PluginCatalogMockOptions = {}) {
enabled: false,
hasIcon: true,
}),
entry({
id: "googlechat",
name: "Google Chat",
description: "OpenClaw Google Chat channel plugin for spaces and direct messages.",
category: "channel",
origin: "bundled",
installed: true,
hasIcon: true,
}),
entry({
id: "slack",
name: "Slack",
description: "OpenClaw Slack channel plugin for channels, DMs, commands, and app events.",
category: "channel",
origin: "bundled",
installed: true,
hasIcon: true,
}),
entry({
id: "signal",
name: "Signal",
description: "OpenClaw Signal channel plugin.",
category: "channel",
origin: "bundled",
installed: true,
hasIcon: true,
}),
entry({
id: "imessage",
name: "iMessage",
description: "OpenClaw iMessage channel plugin using imsg on a signed-in Mac.",
category: "channel",
origin: "bundled",
installed: true,
hasIcon: true,
}),
entry({
id: "nostr",
name: "Nostr",
description: "OpenClaw Nostr channel plugin for NIP-04 encrypted direct messages.",
category: "channel",
origin: "bundled",
installed: true,
hasIcon: true,
}),
bundledChannel(
"googlechat",
"Google Chat",
"OpenClaw Google Chat channel plugin for spaces and direct messages.",
),
bundledChannel(
"slack",
"Slack",
"OpenClaw Slack channel plugin for channels, DMs, commands, and app events.",
),
bundledChannel("signal", "Signal", "OpenClaw Signal channel plugin."),
bundledChannel(
"imessage",
"iMessage",
"OpenClaw iMessage channel plugin using imsg on a signed-in Mac.",
),
bundledChannel(
"nostr",
"Nostr",
"OpenClaw Nostr channel plugin for NIP-04 encrypted direct messages.",
),
entry({
id: "memory-wiki",
name: "Memory Wiki",

View file

@ -1,4 +1,5 @@
import { spawn, type SpawnOptions } from "node:child_process";
import { StringDecoder } from "node:string_decoder";
import { createPnpmRunnerSpawnSpec } from "./pnpm-runner.mts";
const KNIP_VERSION = "6.32.2";
@ -146,6 +147,8 @@ export async function runKnip(knipArgs: string[], params: KnipRunParams = {}) {
let bufferExceeded = false;
let outputBytes = 0;
const output: string[] = [];
const stdoutDecoder = new StringDecoder("utf8");
const stderrDecoder = new StringDecoder("utf8");
let killTimer: ReturnType<typeof setTimeout> | undefined;
let exitStatus: number | null = null;
let exitSignal: string | null = null;
@ -212,6 +215,10 @@ export async function runKnip(knipArgs: string[], params: KnipRunParams = {}) {
clearInterval(heartbeatTimer);
clearTimeout(killTimer);
cleanupParentSignalHandlers();
// A clipped final code point is not malformed child output; leave it out at the byte cap.
if (!bufferExceeded) {
output.push(stdoutDecoder.end(), stderrDecoder.end());
}
resolve({ ...result, output: output.join("") });
};
const finishAfterProcessTreeCleanup = async (result: Omit<KnipRunResult, "output">) => {
@ -225,27 +232,27 @@ export async function runKnip(knipArgs: string[], params: KnipRunParams = {}) {
finish(result);
};
const appendOutput = (chunk: string | Uint8Array) => {
const appendOutput = (decoder: StringDecoder, chunk: string | Uint8Array) => {
if (settled || bufferExceeded) {
return;
}
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
const remainingBytes = maxBufferBytes - outputBytes;
if (buffer.length <= remainingBytes) {
output.push(buffer.toString("utf8"));
output.push(decoder.write(buffer));
outputBytes += buffer.length;
return;
}
if (remainingBytes > 0) {
output.push(buffer.subarray(0, remainingBytes).toString("utf8"));
output.push(decoder.write(buffer.subarray(0, remainingBytes)));
outputBytes = maxBufferBytes;
}
bufferExceeded = true;
writeStatus(
`[deadcode] Knip ${scanName} exceeded ${maxBufferBytes} output bytes; terminating.`,
);
child.stdout?.off?.("data", appendOutput);
child.stderr?.off?.("data", appendOutput);
child.stdout?.off?.("data", appendStdout);
child.stderr?.off?.("data", appendStderr);
child.stdout?.destroy?.();
child.stderr?.destroy?.();
clearInterval(heartbeatTimer);
@ -253,8 +260,10 @@ export async function runKnip(knipArgs: string[], params: KnipRunParams = {}) {
killTimer = setTimeout(() => signalProcessTree(child, "SIGKILL"), killGraceMs);
};
child.stdout?.on("data", appendOutput);
child.stderr?.on("data", appendOutput);
const appendStdout = (chunk: string | Uint8Array) => appendOutput(stdoutDecoder, chunk);
const appendStderr = (chunk: string | Uint8Array) => appendOutput(stderrDecoder, chunk);
child.stdout?.on("data", appendStdout);
child.stderr?.on("data", appendStderr);
child.on("error", (error) =>
finish({
errorCode: spawnErrorCode(error),

View file

@ -1,6 +1,5 @@
#!/usr/bin/env node
// Builds dependency change reports from lockfile and manifest diffs.
import { execFileSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";
@ -33,20 +32,12 @@ const DEPENDENCY_DIFF_PATHS = [
type DependencyPayload = Record<string, string[]>;
type DependencyFileChange = { oldPath: string | null; path: string; status: string };
const nullableString = (value: string | null) => value;
function payloadFromLockfile(lockfileText: string): DependencyPayload {
const packages = collectAllResolvedPackagesFromLockfile(lockfileText);
return createBulkAdvisoryPayload(packages) satisfies DependencyPayload;
}
function versionsFor(payload: DependencyPayload, packageName: string) {
return new Set(payload[packageName] ?? []);
}
/**
* Creates a structured dependency diff report from base/head payloads.
*/
export function createDependencyChangesReport({
basePayload,
headPayload,
@ -74,8 +65,8 @@ export function createDependencyChangesReport({
}> = [];
for (const packageName of packageNames) {
const baseVersions = versionsFor(basePayload, packageName);
const headVersions = versionsFor(headPayload, packageName);
const baseVersions = new Set(basePayload[packageName] ?? []);
const headVersions = new Set(headPayload[packageName] ?? []);
if (baseVersions.size === 0) {
addedPackages.push({
packageName,
@ -166,16 +157,15 @@ function renderMarkdownReport(report: ReturnType<typeof createDependencyChangesR
lines.push("");
}
if (report.addedPackages.length > 0) {
lines.push("## Added Resolved Packages", "");
for (const item of report.addedPackages) {
lines.push(`- ${markdownCode(item.packageName)}: ${item.versions.join(", ")}`);
for (const [title, packages] of [
["Added", report.addedPackages],
["Removed", report.removedPackages],
] as const) {
if (packages.length === 0) {
continue;
}
lines.push("");
}
if (report.removedPackages.length > 0) {
lines.push("## Removed Resolved Packages", "");
for (const item of report.removedPackages) {
lines.push(`## ${title} Resolved Packages`, "");
for (const item of packages) {
lines.push(`- ${markdownCode(item.packageName)}: ${item.versions.join(", ")}`);
}
lines.push("");
@ -201,9 +191,6 @@ function readGitFile(ref: string, filePath: string, cwd: string) {
});
}
/**
* Reports whether a path is a dependency-related file.
*/
export function isDependencyFile(filePath: unknown) {
if (typeof filePath !== "string") {
return false;
@ -211,9 +198,6 @@ export function isDependencyFile(filePath: unknown) {
return DEPENDENCY_FILE_PATTERNS.some((pattern) => pattern.test(filePath));
}
/**
* Returns git pathspecs used for dependency diff collection.
*/
export function dependencyDiffPathspecs() {
return [...DEPENDENCY_DIFF_PATHS];
}
@ -254,13 +238,20 @@ function gitDiffDependencyFiles(baseRef: string, cwd: string) {
}
export function parseArgs(argv: string[]) {
const options = {
const options: {
rootDir: string;
baseRef: string | null;
baseLockfile: string | null;
headLockfile: string;
jsonPath: string | null;
markdownPath: string | null;
} = {
rootDir: process.cwd(),
baseRef: nullableString(null),
baseLockfile: nullableString(null),
baseRef: null,
baseLockfile: null,
headLockfile: "pnpm-lock.yaml",
jsonPath: nullableString(null),
markdownPath: nullableString(null),
jsonPath: null,
markdownPath: null,
};
const flagEntries = [
["--root", "rootDir"],
@ -295,9 +286,6 @@ export function parseArgs(argv: string[]) {
throw new Error("Expected --base-ref <git-ref> or --base-lockfile <path>.");
}
/**
* Generates and writes dependency change report artifacts.
*/
async function runDependencyChangesReport(options: ReturnType<typeof parseArgs>) {
const headLockfileText = await readFile(path.join(options.rootDir, options.headLockfile), "utf8");
const baseLockfileText =
@ -315,9 +303,6 @@ async function runDependencyChangesReport(options: ReturnType<typeof parseArgs>)
});
}
/**
* Runs the dependency changes report CLI.
*/
export async function main(argv = process.argv.slice(2)) {
const options = parseArgs(argv);
const report = await runDependencyChangesReport(options);

View file

@ -1,6 +1,5 @@
#!/usr/bin/env node
// Reports dependency ownership, closure, and risk surface from lockfile data.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
@ -12,16 +11,14 @@ import { pnpmLockfileDocuments } from "./lib/pnpm-lockfile-documents.mjs";
import { collectRootDependencyOwnershipAudit } from "./root-dependency-ownership-audit.mts";
const DEFAULT_OWNERSHIP_PATH = "scripts/lib/dependency-ownership.json";
const PROD_IMPORTER_SECTIONS = ["dependencies", "optionalDependencies"];
const TRANSITIVE_SECTIONS = ["dependencies", "optionalDependencies"];
const DEPENDENCY_SECTIONS = ["dependencies", "optionalDependencies"];
const compareStrings = (left: string, right: string) => left.localeCompare(right);
type JsonObject = Record<string, unknown>;
type ImporterRecord = Record<string, unknown>;
type Lockfile = {
importers?: Record<string, ImporterRecord>;
importers?: Record<string, JsonObject>;
packages?: Record<string, JsonObject>;
snapshots?: Record<string, ImporterRecord>;
snapshots?: Record<string, JsonObject>;
};
type RootDependency = {
name: string;
@ -47,9 +44,9 @@ function readJson(filePath: string): JsonObject {
return value;
}
function normalizeDependencies(record: ImporterRecord = {}): RootDependency[] {
function normalizeDependencies(record: JsonObject = {}): RootDependency[] {
const entries: RootDependency[] = [];
for (const section of PROD_IMPORTER_SECTIONS) {
for (const section of DEPENDENCY_SECTIONS) {
const sectionRecord = record[section];
if (!isRecord(sectionRecord)) {
continue;
@ -67,21 +64,14 @@ function normalizeDependencies(record: ImporterRecord = {}): RootDependency[] {
return entries.toSorted((left, right) => left.name.localeCompare(right.name));
}
/**
* Extracts the package name from a pnpm lockfile package key.
*/
export function packageNameFromLockKey(lockKey: unknown) {
if (typeof lockKey !== "string") {
return lockKey;
}
const peerSuffixIndex = lockKey.indexOf("(");
const baseKey = peerSuffixIndex >= 0 ? lockKey.slice(0, peerSuffixIndex) : lockKey;
if (baseKey.startsWith("@")) {
const secondAt = baseKey.indexOf("@", 1);
return secondAt >= 0 ? baseKey.slice(0, secondAt) : baseKey;
}
const firstAt = baseKey.indexOf("@");
return firstAt >= 0 ? baseKey.slice(0, firstAt) : baseKey;
const versionAt = baseKey.indexOf("@", baseKey.startsWith("@") ? 1 : 0);
return versionAt >= 0 ? baseKey.slice(0, versionAt) : baseKey;
}
function lockKeyForDependency(name: string, version: string) {
@ -100,9 +90,9 @@ function lockKeyForDependency(name: string, version: string) {
return `${name}@${version}`;
}
function dependencyEntriesFromSnapshot(snapshot: ImporterRecord = {}) {
function dependencyEntriesFromSnapshot(snapshot: JsonObject = {}) {
const entries: Array<{ name: string; version: string }> = [];
for (const section of TRANSITIVE_SECTIONS) {
for (const section of DEPENDENCY_SECTIONS) {
const sectionRecord = snapshot[section];
if (!isRecord(sectionRecord)) {
continue;
@ -120,11 +110,7 @@ function collectClosure(lockfile: Lockfile, rootKeys: Array<string | undefined>)
const seen = new Set<string>();
const missing = new Set<string>();
const queue = rootKeys.filter((key): key is string => typeof key === "string");
while (queue.length > 0) {
const key = queue.shift();
if (key === undefined) {
break;
}
for (const key of queue) {
if (seen.has(key)) {
continue;
}
@ -208,9 +194,6 @@ function collectReportTarget({
};
}
/**
* Collects dependency ownership and transitive surface metadata.
*/
export function collectDependencyOwnershipSurfaceReport(params: ReportParams = {}) {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
const packageJson = readJson(path.join(repoRoot, "package.json"));
@ -349,9 +332,6 @@ export function collectDependencyOwnershipSurfaceReport(params: ReportParams = {
type DependencyOwnershipReport = ReturnType<typeof collectDependencyOwnershipSurfaceReport>;
/**
* Collects policy errors from a dependency ownership surface report.
*/
export function collectDependencyOwnershipSurfaceCheckErrors(report: DependencyOwnershipReport) {
return report.ownershipGaps.map(
(name) => `root dependency '${name}' is missing from ${DEFAULT_OWNERSHIP_PATH}`,
@ -359,16 +339,7 @@ export function collectDependencyOwnershipSurfaceCheckErrors(report: DependencyO
}
function renderTargetPackage(target: DependencyOwnershipReport["target"]) {
if (!target?.packageName && !target?.packageVersion) {
return "unknown";
}
if (!target.packageName) {
return target.packageVersion ?? "unknown";
}
if (!target.packageVersion) {
return target.packageName;
}
return `${target.packageName}@${target.packageVersion}`;
return [target?.packageName, target?.packageVersion].filter(Boolean).join("@") || "unknown";
}
function markdownCode(value: unknown) {
@ -379,9 +350,6 @@ function pluralize(count: number, singular: string, plural = `${singular}s`) {
return `${count} ${count === 1 ? singular : plural}`;
}
/**
* Renders a dependency ownership surface report as Markdown.
*/
export function renderDependencyOwnershipSurfaceMarkdownReport(
typedReport: DependencyOwnershipReport,
) {
@ -473,10 +441,6 @@ export function renderDependencyOwnershipSurfaceMarkdownReport(
return `${lines.join("\n")}\n`;
}
function printTextReport(report: DependencyOwnershipReport) {
process.stdout.write(renderDependencyOwnershipSurfaceMarkdownReport(report));
}
export function parseArgs(argv: string[]): ParseOptions {
const options: ParseOptions = {
rootDir: process.cwd(),
@ -573,7 +537,7 @@ function main(argv: string[] = process.argv.slice(2)) {
);
return;
}
printTextReport(report);
process.stdout.write(renderDependencyOwnershipSurfaceMarkdownReport(report));
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {

View file

@ -111,14 +111,15 @@ function resultText(result: ToolResult): string {
function wireResult(result: ToolResult): JsonRecord {
const details = result.details as { result?: unknown } | undefined;
if (details?.result && typeof details.result === "object" && !Array.isArray(details.result)) {
return details.result as JsonRecord;
const structured = record(details?.result);
if (structured) {
return structured;
}
for (const line of resultText(result).split("\n")) {
try {
const parsed = JSON.parse(line) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as JsonRecord;
const parsed = record(JSON.parse(line));
if (parsed) {
return parsed;
}
} catch {
// Mutating actions prefix their follow-up screenshot with one JSON result line.
@ -170,7 +171,7 @@ function summarizeOutcome(outcome: ActionOutcome): JsonRecord {
async function saveImage(name: string, result: ToolResult): Promise<string> {
const image = result.content.find((block) => block.type === "image");
if (!image || image.type !== "image") {
if (!image) {
throw new Error(`missing model-visible image in ${name}`);
}
const extension = image.mimeType === "image/jpeg" ? "jpeg" : "png";

View file

@ -46,6 +46,19 @@ validate_provider() {
esac
}
validate_preparation() {
local profile="$1" port="$2" scratch="$3"
[[ "$profile" =~ ^[A-Za-z0-9][A-Za-z0-9_-]+$ ]] ||
fail "profile must contain only letters, digits, underscores, and dashes"
case "$profile" in
default | main | local) fail "choose a fresh, explicitly isolated profile" ;;
esac
[[ "$port" =~ ^[0-9]+$ ]] || fail "port must be numeric"
((port >= 1024 && port <= 65535)) || fail "port must be between 1024 and 65535"
((port != 18789)) || fail "port 18789 belongs to the operator gateway"
[[ "$scratch" = /* ]] || fail "scratch path must be absolute"
}
require_unoccupied_port() {
local port="$1"
if ! node - "$port" >/dev/null 2>&1 <<'NODE'; then
@ -199,15 +212,7 @@ prepare() {
local scratch="$4"
local provider="${5:-peekaboo}"
[[ "$profile" =~ ^[A-Za-z0-9][A-Za-z0-9_-]+$ ]] ||
fail "profile must contain only letters, digits, underscores, and dashes"
case "$profile" in
default | main | local) fail "choose a fresh, explicitly isolated profile" ;;
esac
[[ "$port" =~ ^[0-9]+$ ]] || fail "port must be numeric"
((port >= 1024 && port <= 65535)) || fail "port must be between 1024 and 65535"
((port != 18789)) || fail "port 18789 belongs to the operator gateway"
[[ "$scratch" = /* ]] || fail "scratch path must be absolute"
validate_preparation "$profile" "$port" "$scratch"
validate_provider "$provider"
require_unoccupied_port "$port"
@ -271,15 +276,7 @@ prepare_linux() {
local scratch="$3"
require_linux_x11
[[ "$profile" =~ ^[A-Za-z0-9][A-Za-z0-9_-]+$ ]] ||
fail "profile must contain only letters, digits, underscores, and dashes"
case "$profile" in
default | main | local) fail "choose a fresh, explicitly isolated profile" ;;
esac
[[ "$port" =~ ^[0-9]+$ ]] || fail "port must be numeric"
((port >= 1024 && port <= 65535)) || fail "port must be between 1024 and 65535"
((port != 18789)) || fail "port 18789 belongs to the operator gateway"
[[ "$scratch" = /* ]] || fail "scratch path must be absolute"
validate_preparation "$profile" "$port" "$scratch"
require_unoccupied_port "$port"
git -C "$repo_root" diff --quiet -- src packages extensions scripts ||

View file

@ -1,4 +1,3 @@
// Test Device Pair Telegram script supports OpenClaw repository automation.
import { pathToFileURL } from "node:url";
import { getRuntimeConfig } from "../../src/config/config.js";
import { matchPluginCommand, executePluginCommand } from "../../src/plugins/commands.js";
@ -51,11 +50,7 @@ function writeStdoutLine(...parts: string[]): void {
process.stdout.write(`${parts.join(" ")}\n`);
}
function writeStderrLine(message: string): void {
process.stderr.write(`${message}\n`);
}
function readArg(args: string[], flag: string, short?: string): string | undefined {
function readArg(args: readonly string[], flag: string, short?: string): string | undefined {
const idx = args.indexOf(flag);
if (idx !== -1 && idx + 1 < args.length) {
return args[idx + 1];
@ -101,8 +96,8 @@ function validateArgs(args: readonly string[]): void {
function parseDevicePairTelegramArgs(args: readonly string[]): DevicePairTelegramArgs {
validateArgs(args);
return {
accountId: readArg([...args], "--account", "-a"),
chatId: readArg([...args], "--chat", "-c"),
accountId: readArg(args, "--account", "-a"),
chatId: readArg(args, "--chat", "-c"),
help: args.includes("--help") || args.includes("-h"),
};
}
@ -134,10 +129,7 @@ async function runDevicePairTelegram(
deps: DevicePairTelegramDeps = createDefaultDeps(),
): Promise<DevicePairTelegramResult> {
const { accountId, chatId, help } = parseDevicePairTelegramArgs(args);
if (help) {
throw new UsageError(usage());
}
if (!chatId) {
if (help || !chatId) {
throw new UsageError(usage());
}
@ -195,7 +187,7 @@ async function main(): Promise<void> {
result.messageId ? `message=${result.messageId}` : "",
);
} catch (error) {
writeStderrLine(error instanceof Error ? error.message : String(error));
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = error instanceof UsageError ? error.exitCode : 1;
}
}

View file

@ -1,4 +1,3 @@
// Tui Pty Test Watch script supports OpenClaw repository automation.
import { mkdir, open, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
@ -50,10 +49,6 @@ type ChildStopper = {
type SignalChild = (child: KillableChild, signal: NodeJS.Signals) => void;
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
(timer as { unref?: () => void }).unref?.();
}
function readOption(args: string[], name: string): string | undefined {
const idx = args.indexOf(name);
if (idx < 0) {
@ -116,10 +111,6 @@ function parseOptions(args = process.argv.slice(2)): Options {
};
}
function shouldUseAltScreen(options: Options) {
return options.altScreen && process.stdout.isTTY;
}
function resolveVitestCliEntry(): string {
const vitestPackageJson = require.resolve("vitest/package.json");
return path.join(path.dirname(vitestPackageJson), "vitest.mjs");
@ -165,19 +156,14 @@ function createChildStopper(
killTimer = setTimeout(() => {
signalChild(child, "SIGKILL");
}, CHILD_SIGKILL_GRACE_MS);
unrefTimer(killTimer);
killTimer.unref();
}, CHILD_SIGTERM_GRACE_MS);
unrefTimer(termTimer);
termTimer.unref();
};
return { cancel, stop };
}
async function createMirrorFile(mirrorPath: string): Promise<void> {
await mkdir(path.dirname(mirrorPath), { recursive: true });
await writeFile(mirrorPath, "", "utf8");
}
async function readNewMirrorData(
mirrorPath: string,
offset: number,
@ -234,8 +220,9 @@ async function main(): Promise<void> {
process.stdout.write(`${usage()}\n`);
return;
}
const useAltScreen = shouldUseAltScreen(options);
await createMirrorFile(options.mirrorPath);
const useAltScreen = options.altScreen && process.stdout.isTTY;
await mkdir(path.dirname(options.mirrorPath), { recursive: true });
await writeFile(options.mirrorPath, "", "utf8");
const { child, completion } = spawnOwnedVitestProcess({
homeMode: resolveVitestHomeSelection(
@ -270,7 +257,6 @@ async function main(): Promise<void> {
let childStdout: Buffer = Buffer.alloc(0);
let childStderr: Buffer = Buffer.alloc(0);
let restored = false;
let mirrorOffset = 0;
let mirrorFilterPending = "";
let sawMirrorOutput = false;
@ -309,16 +295,6 @@ async function main(): Promise<void> {
process.stdout.write(filteredChunk);
};
const restoreScreen = () => {
if (restored) {
return;
}
restored = true;
if (useAltScreen) {
process.stdout.write("\x1b[?1049l");
}
};
const childStopper = createChildStopper(child);
const stopChild = childStopper.stop;
@ -345,13 +321,6 @@ async function main(): Promise<void> {
}
};
const drainParentInput = async () => {
if (!useAltScreen || !process.stdin.isTTY) {
return;
}
await delay(100);
};
const renderWaitingStatus = () => {
if (!useAltScreen || sawMirrorOutput) {
return;
@ -416,7 +385,7 @@ async function main(): Promise<void> {
await delay(sawMirrorOutput ? 25 : 250);
}
mirrorOffset = await drainNewMirrorData(options.mirrorPath, mirrorOffset, writeMirrorChunk);
await drainNewMirrorData(options.mirrorPath, mirrorOffset, writeMirrorChunk);
} finally {
if (!childFinished) {
stopChild();
@ -425,12 +394,14 @@ async function main(): Promise<void> {
for (const signal of parentSignals) {
process.off(signal, stopChild);
}
await drainParentInput();
if (useAltScreen && process.stdin.isTTY) {
await delay(100);
}
restoreInput();
if (useAltScreen) {
process.stdout.write("\x1b[?2026l\x1b[?2004l\x1b[>4;0m\x1b[?25h");
process.stdout.write("\x1b[?1049l");
}
restoreScreen();
}
const outcome = await childOutcome;

View file

@ -113,9 +113,6 @@ func setPostprocessVersion(frontMatter, version string) string {
if xi18nLine == -1 {
return frontMatter
}
if insertAt == -1 {
insertAt = len(lines)
}
lines = append(lines[:insertAt], append([]string{childIndent + "postprocess_version: " + version}, lines[insertAt:]...)...)
return strings.Join(lines, "\n")

View file

@ -285,10 +285,6 @@ function isLocalizedDocPath(p: string) {
return /^\/?[a-z]{2}(?:-[A-Za-z]{2,8})+\//.test(p);
}
function isGeneratedTranslatedDoc(relPath: string) {
return isLocalizedDocPath(relPath);
}
function createRedirectMap(docsConfig: Record<string, unknown>): Map<string, string> {
const redirects = new Map<string, string>();
const redirectEntries = Array.isArray(docsConfig.redirects) ? docsConfig.redirects : [];
@ -323,7 +319,7 @@ function buildAuditIndex(
return false;
}
const rel = normalizeSlashes(path.relative(docsDir, abs));
return !isGeneratedTranslatedDoc(rel);
return !isLocalizedDocPath(rel);
});
const routes = new Set<string>();
@ -348,7 +344,6 @@ function buildAuditIndex(
docsDir,
docsConfig,
redirects,
allFiles,
relAllFiles,
markdownFiles,
routes,
@ -443,9 +438,6 @@ function mirroredFragmentReason(terminal: string, hash: string) {
return `fragment unverified without the ClawHub source checkout (terminal: ${terminal}${hash}); set ${CLAWHUB_REPO_ENV}`;
}
/**
* Audits local docs links against route, file, and redirect indexes.
*/
export function auditDocsLinks(
options: { docsDir?: string; allowExternalClawHubRoutes?: boolean; anchors?: boolean } = {},
) {
@ -630,7 +622,7 @@ export function auditDocsLinks(
}
for (const page of collectNavPageEntries(index.docsConfig.navigation || [])) {
if (isGeneratedTranslatedDoc(page)) {
if (isLocalizedDocPath(page)) {
continue;
}
checked++;
@ -654,7 +646,6 @@ export function auditDocsLinks(
return { checked, broken, collisions, unverifiedMirroredFragments };
}
/** Runs the docs link audit CLI. */
function runDocsLinkAuditCli() {
const args = process.argv.slice(2);
if (args[0] === "--prepare-external-links") {

View file

@ -51,12 +51,10 @@ function pass(label: string) {
console.log(`✅ ${label}`);
}
// ── Load production code ──────────────────────────────────────────────
const { resolveSandboxMountSelection }: WorkspaceMountModule = await import(
path.join(repoRoot, "src/agents/sandbox/workspace-mounts.js")
);
// ── Resolve protected skill mounts ────────────────────────────────────
console.log("\n--- Protected skill mounts ---");
const selection = resolveSandboxMountSelection({
workspaceDir,
@ -70,7 +68,6 @@ console.log(
selection.readOnlyWorkspaceSkillMounts.map((m) => `${m.hostPath} -> ${m.containerPath}`),
);
// ── Filter user binds ─────────────────────────────────────────────────
console.log("\nUser binds:", userBinds);
const safeBinds = selection.custom;
console.log(
@ -78,14 +75,12 @@ console.log(
safeBinds.length === 0 ? "(none)" : safeBinds,
);
// Conflicting bind should be filtered out (no safe binds remain)
if (safeBinds.length > 0) {
fail("conflicting user bind was not filtered out");
} else {
pass("conflicting user bind correctly skipped");
}
// ── Build container create args ───────────────────────────────────────
const createArgs = [
"create",
"--name",
@ -101,7 +96,6 @@ const createArgs = [
];
createArgs.push(image, "sleep", "infinity");
// ── Duplicate check ───────────────────────────────────────────────────
console.log(`\n--- ${engine} args ---`);
let nextIsMount = false;
for (const a of createArgs) {
@ -110,8 +104,6 @@ for (const a of createArgs) {
nextIsMount = false;
} else if (a === "-v") {
nextIsMount = true;
} else if (a.startsWith("-")) {
console.log(` ${a}`);
} else {
console.log(` ${a}`);
}
@ -134,7 +126,6 @@ if (dupes === 0) {
fail(`found ${dupes} duplicate container paths`);
}
// ── Helper: run the selected engine with argv (no shell string) ───────
function runEngine(args: string[], opts: { stdio?: "pipe" | "inherit" } = {}) {
return spawnSync(useSudo ? "sudo" : engine, useSudo ? [engine, ...args] : args, {
encoding: "utf8",
@ -143,7 +134,6 @@ function runEngine(args: string[], opts: { stdio?: "pipe" | "inherit" } = {}) {
});
}
// ── Container create ──────────────────────────────────────────────────
console.log(`\n--- ${engine} create ${containerName} ---`);
let created = false;
try {
@ -187,7 +177,6 @@ try {
fail(`/workspace/skills appears ${skillsCount} times (expected ≤1)`);
}
// Verify protected mount source (not user bind)
const skillMount = mounts.find((mount) => mount.Destination === "/workspace/skills");
const mountSrc = typeof skillMount?.Source === "string" ? skillMount.Source : "";
console.log(`Mount source for /workspace/skills: ${mountSrc}`);

View file

@ -1,5 +1,4 @@
#!/usr/bin/env -S pnpm tsx
// Linux Smoke script supports OpenClaw repository automation.
import { mkdir, readFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
@ -27,9 +26,7 @@ import {
withProgressOnStderr,
writeJson,
writeSummaryMarkdown,
type Mode,
type PackageArtifact,
type Provider,
type ProviderAuth,
type SnapshotInfo,
} from "./common.ts";
@ -90,33 +87,6 @@ interface LinuxOptions extends SmokeCliOptions {
vmNameExplicit: boolean;
}
interface LinuxSummary {
vm: string;
snapshotHint: string;
snapshotId: string;
mode: Mode;
provider: Provider;
latestVersion: string;
installVersion: string;
targetPackageSpec: string;
currentHead: string;
runDir: string;
daemon: string;
freshMain: {
status: string;
version: string;
gateway: string;
agent: string;
};
upgrade: {
status: string;
latestVersionInstalled: string;
mainVersion: string;
gateway: string;
agent: string;
};
}
const defaultOptions = (): LinuxOptions => ({
apiKeyEnv: undefined,
hostIp: undefined,
@ -218,7 +188,7 @@ class LinuxSmoke extends SmokeRunController<LinuxOptions> {
this.phases = new PhaseRunner(this.runDir);
this.tgzDir = await makeTempDir("openclaw-parallels-linux-tgz.");
try {
this.options.vmName = this.resolveVmName();
this.options.vmName = resolveUbuntuVmName(this.options.vmName, this.options.vmNameExplicit);
validateSnapshotRestoreMode(this.options.mode, "Linux smoke");
this.snapshot = shouldSkipSnapshotRestore()
? currentRunningSnapshotInfo(this.options.vmName)
@ -252,94 +222,84 @@ class LinuxSmoke extends SmokeRunController<LinuxOptions> {
return this.options.targetPackageSpec ? "target package tgz" : "current main tgz";
}
private resolveVmName(): string {
return resolveUbuntuVmName(this.options.vmName, this.options.vmNameExplicit);
}
protected async runFreshLane(): Promise<void> {
await this.phase("fresh.restore-snapshot", 180, () => this.restoreSnapshot());
await this.phase("fresh.bootstrap-guest", BOOTSTRAP_TIMEOUT_SECONDS, () =>
await this.phases.phase("fresh.restore-snapshot", 180, () => this.restoreSnapshot());
await this.phases.phase("fresh.bootstrap-guest", BOOTSTRAP_TIMEOUT_SECONDS, () =>
this.bootstrapGuest(),
);
await this.phase("fresh.reset-state", 180, () => this.resetState());
await this.phase("fresh.preflight", 90, () => this.logGuestPreflight());
await this.phase("fresh.ensure-runtime", 420, () =>
await this.phases.phase("fresh.reset-state", 180, () => this.resetState());
await this.phases.phase("fresh.preflight", 90, () => this.logGuestPreflight());
await this.phases.phase("fresh.ensure-runtime", 420, () =>
ensureSmokeGuestRuntime({
runShell: (script) => this.guestBash(script),
runShell: (script) => this.guest.bash(script),
bootstrap: () => this.installLatestRelease(),
}),
);
await this.phase("fresh.install-main", 420, () =>
await this.phases.phase("fresh.install-main", 420, () =>
this.installMainTgz("openclaw-main-fresh.tgz"),
);
this.status.freshVersion = await this.extractLastVersion("fresh.install-main");
await this.phase("fresh.verify-main-version", 90, () => this.verifyTargetVersion());
await this.phase("fresh.install-companions", 600, () =>
await this.phases.phase("fresh.verify-main-version", 90, () => this.verifyTargetVersion());
await this.phases.phase("fresh.install-companions", 600, () =>
installSmokeRuntimeCompanions({
provider: this.options.provider,
readCli: (args) => this.guestExec(["openclaw", ...args]),
readCli: (args) => this.guest.exec(["openclaw", ...args]),
installCli: (args) => {
this.guestExec(["openclaw", ...args]);
this.guest.exec(["openclaw", ...args]);
},
}),
);
await this.phase("fresh.onboard-ref", 420, () => this.runRefOnboard());
await this.phase("fresh.inject-bad-plugin", 90, () =>
await this.phases.phase("fresh.onboard-ref", 420, () => this.runRefOnboard());
await this.phases.phase("fresh.inject-bad-plugin", 90, () =>
this.maybeInjectBadPluginFixture("fresh"),
);
await this.phase("fresh.gateway-start", 240, () => this.startGatewayBackground());
await this.phase("fresh.bad-plugin-diagnostic", 90, () =>
await this.phases.phase("fresh.gateway-start", 240, () => this.startGatewayBackground());
await this.phases.phase("fresh.bad-plugin-diagnostic", 90, () =>
this.maybeVerifyBadPluginDiagnostic("fresh"),
);
await this.phase("fresh.gateway-status", 240, () => this.verifyGatewayStatus());
await this.phases.phase("fresh.gateway-status", 240, () => this.verifyGatewayStatus());
this.status.freshGateway = "pass";
await this.phase("fresh.first-local-agent-turn", this.agentTimeoutSeconds, () =>
await this.phases.phase("fresh.first-local-agent-turn", this.agentTimeoutSeconds, () =>
this.verifyLocalTurn(),
);
this.status.freshAgent = "pass";
}
protected async runUpgradeLane(): Promise<void> {
await this.phase("upgrade.restore-snapshot", 180, () => this.restoreSnapshot());
await this.phase("upgrade.bootstrap-guest", BOOTSTRAP_TIMEOUT_SECONDS, () =>
await this.phases.phase("upgrade.restore-snapshot", 180, () => this.restoreSnapshot());
await this.phases.phase("upgrade.bootstrap-guest", BOOTSTRAP_TIMEOUT_SECONDS, () =>
this.bootstrapGuest(),
);
await this.phase("upgrade.reset-state", 180, () => this.resetState());
await this.phase("upgrade.preflight", 90, () => this.logGuestPreflight());
await this.phase("upgrade.install-latest", 420, () => this.installLatestRelease());
await this.phases.phase("upgrade.reset-state", 180, () => this.resetState());
await this.phases.phase("upgrade.preflight", 90, () => this.logGuestPreflight());
await this.phases.phase("upgrade.install-latest", 420, () => this.installLatestRelease());
this.status.latestInstalledVersion = await this.extractLastVersion("upgrade.install-latest");
await this.phase("upgrade.verify-latest-version", 90, () =>
await this.phases.phase("upgrade.verify-latest-version", 90, () =>
this.verifyVersionContains(this.latestVersion),
);
await this.phase("upgrade.install-main", 420, () =>
await this.phases.phase("upgrade.install-main", 420, () =>
this.installMainTgz("openclaw-main-upgrade.tgz"),
);
this.status.upgradeVersion = await this.extractLastVersion("upgrade.install-main");
await this.phase("upgrade.verify-main-version", 90, () => this.verifyTargetVersion());
await this.phase("upgrade.inject-bad-plugin", 90, () =>
await this.phases.phase("upgrade.verify-main-version", 90, () => this.verifyTargetVersion());
await this.phases.phase("upgrade.inject-bad-plugin", 90, () =>
this.maybeInjectBadPluginFixture("upgrade"),
);
await this.phase("upgrade.onboard-ref", 420, () => this.runRefOnboard());
await this.phase("upgrade.gateway-start", 240, () => this.startGatewayBackground());
await this.phase("upgrade.bad-plugin-diagnostic", 90, () =>
await this.phases.phase("upgrade.onboard-ref", 420, () => this.runRefOnboard());
await this.phases.phase("upgrade.gateway-start", 240, () => this.startGatewayBackground());
await this.phases.phase("upgrade.bad-plugin-diagnostic", 90, () =>
this.maybeVerifyBadPluginDiagnostic("upgrade"),
);
await this.phase("upgrade.gateway-status", 240, () => this.verifyGatewayStatus());
await this.phases.phase("upgrade.gateway-status", 240, () => this.verifyGatewayStatus());
this.status.upgradeGateway = "pass";
await this.phase("upgrade.first-local-agent-turn", this.agentTimeoutSeconds, () =>
await this.phases.phase("upgrade.first-local-agent-turn", this.agentTimeoutSeconds, () =>
this.verifyLocalTurn(),
);
this.status.upgradeAgent = "pass";
}
private phase = async (name: string, timeoutSeconds: number, fn: () => Promise<void> | void) =>
await this.phases.phase(name, timeoutSeconds, fn);
private remainingPhaseTimeoutMs = (fallbackMs?: number): number | undefined =>
this.phases.remainingTimeoutMs(fallbackMs);
private logGuestPreflight(): void {
this.guestBash(String.raw`set -euo pipefail
this.guest.bash(String.raw`set -euo pipefail
printf 'preflight.user=%s\n' "$(whoami)"
printf 'preflight.home=%s\n' "$HOME"
printf 'preflight.path=%s\n' "$PATH"
@ -347,17 +307,6 @@ printf 'preflight.umask=%s\n' "$(umask)"
printf 'preflight.npmRoot=%s\n' "$(npm root -g 2>/dev/null || true)"`);
}
private log = (text: string): void => this.phases.append(text);
private guestExec = (
args: string[],
options: { check?: boolean; timeoutMs?: number } = {},
): string => this.guest.exec(args, options);
private guestBash(script: string): string {
return this.guest.bash(script);
}
private waitForGuestReady(timeoutSeconds = 180): void {
const deadline = Date.now() + timeoutSeconds * 1000;
while (Date.now() < deadline) {
@ -365,7 +314,7 @@ printf 'preflight.npmRoot=%s\n' "$(npm root -g 2>/dev/null || true)"`);
run("prlctl", ["exec", this.options.vmName, "/usr/bin/env", "HOME=/root", "/bin/true"], {
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(),
timeoutMs: this.phases.remainingTimeoutMs(),
}).status === 0
) {
return;
@ -386,21 +335,21 @@ printf 'preflight.npmRoot=%s\n' "$(npm root -g 2>/dev/null || true)"`);
say(`Restore snapshot ${this.options.snapshotHint} (${this.snapshot.id})`);
run("prlctl", ["snapshot-switch", this.options.vmName, "--id", this.snapshot.id], {
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(),
timeoutMs: this.phases.remainingTimeoutMs(),
});
ensureVmRunning(this.options.vmName, 180, {
probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000),
transitionTimeoutMs: () => this.remainingPhaseTimeoutMs(120_000),
probeTimeoutMs: () => this.phases.remainingTimeoutMs(30_000),
transitionTimeoutMs: () => this.phases.remainingTimeoutMs(120_000),
});
this.waitForGuestReady();
}
private bootstrapGuest(): void {
const hostNow = `@${Math.floor(Date.now() / 1000)}`;
this.guestExec(["date", "-u", "-s", hostNow]);
this.guestExec(["hwclock", "--systohc"], { check: false });
this.guestExec(["timedatectl", "set-ntp", "true"], { check: false });
this.guestExec(["systemctl", "restart", "systemd-timesyncd"], { check: false });
this.guest.exec(["date", "-u", "-s", hostNow]);
this.guest.exec(["hwclock", "--systohc"], { check: false });
this.guest.exec(["timedatectl", "set-ntp", "true"], { check: false });
this.guest.exec(["systemctl", "restart", "systemd-timesyncd"], { check: false });
this.guest.bash(`
set -e
if command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1; then
@ -438,7 +387,7 @@ run_apt_with_lock_retry apt-get -o DPkg::Lock::Timeout=30 install -y curl ca-cer
}
private resetState(): void {
this.guestBash(String.raw`set -euo pipefail
this.guest.bash(String.raw`set -euo pipefail
pkill -f '[o]penclaw.*gateway run' >/dev/null 2>&1 || true
pkill -f '[o]penclaw-gateway' >/dev/null 2>&1 || true
pkill -f '[o]penclaw.mjs gateway' >/dev/null 2>&1 || true
@ -449,26 +398,15 @@ rm -f /tmp/openclaw-parallels-linux-gateway.log`);
private installLatestRelease(): void {
this.downloadGuestFile(this.options.installUrl, "/tmp/openclaw-install.sh");
if (this.options.installVersion) {
this.guestExec([
"/usr/bin/env",
"OPENCLAW_NO_ONBOARD=1",
"bash",
"/tmp/openclaw-install.sh",
"--version",
this.options.installVersion,
"--no-onboard",
]);
} else {
this.guestExec([
"/usr/bin/env",
"OPENCLAW_NO_ONBOARD=1",
"bash",
"/tmp/openclaw-install.sh",
"--no-onboard",
]);
}
this.guestExec(["openclaw", "--version"]);
this.guest.exec([
"/usr/bin/env",
"OPENCLAW_NO_ONBOARD=1",
"bash",
"/tmp/openclaw-install.sh",
...(this.options.installVersion ? ["--version", this.options.installVersion] : []),
"--no-onboard",
]);
this.guest.exec(["openclaw", "--version"]);
}
private downloadGuestFile(url: string, outputPath: string): void {
@ -490,8 +428,8 @@ fi`);
this.guestEnv = npmRegistryEnv(this.options.npmRegistry ?? this.server.registry?.url);
const tgzUrl = this.server.urlFor(this.artifact.path);
this.downloadGuestFile(tgzUrl, `/tmp/${tempName}`);
this.guestExec(["npm", "install", "-g", `/tmp/${tempName}`, "--no-fund", "--no-audit"]);
this.guestExec(["openclaw", "--version"]);
this.guest.exec(["npm", "install", "-g", `/tmp/${tempName}`, "--no-fund", "--no-audit"]);
this.guest.exec(["openclaw", "--version"]);
}
private async verifyTargetVersion(): Promise<void> {
@ -506,14 +444,14 @@ fi`);
}
private verifyVersionContains(needle: string): void {
const version = this.guestExec(["openclaw", "--version"]);
const version = this.guest.exec(["openclaw", "--version"]);
if (!version.includes(needle)) {
throw new Error(`version mismatch: expected substring ${needle}`);
}
}
private runRefOnboard(): void {
this.guestExec([
this.guest.exec([
"/usr/bin/env",
`${this.auth.apiKeyEnv}=${this.auth.apiKeyValue}`,
"openclaw",
@ -538,7 +476,7 @@ fi`);
}
private injectBadPluginFixture(): void {
this.guestBash(String.raw`set -euo pipefail
this.guest.bash(String.raw`set -euo pipefail
plugin_dir=/root/.openclaw/test-bad-plugin
mkdir -p "$plugin_dir"
cat >"$plugin_dir/package.json" <<'JSON'
@ -593,7 +531,7 @@ PY`);
private maybeInjectBadPluginFixture(lane: "fresh" | "upgrade"): void {
if (!this.shouldExpectBadPluginDiagnostic(lane)) {
this.log(
this.phases.append(
`Skipping bad plugin diagnostic fixture for ${lane}: installed ${this.versionForLane(lane)} predates ${BAD_PLUGIN_DIAGNOSTIC_MIN_VERSION}\n`,
);
return;
@ -603,7 +541,7 @@ PY`);
private startGatewayBackground(): void {
const bonjourEnv = this.disableBonjour ? " OPENCLAW_DISABLE_BONJOUR=1" : "";
this.guestBash(
this.guest.bash(
String.raw`pkill -f "openclaw gateway run" >/dev/null 2>&1 || true
rm -f /tmp/openclaw-parallels-linux-gateway.log
setsid sh -lc ` +
@ -625,7 +563,7 @@ setsid sh -lc ` +
}
private showGatewayStatusCompat(check = true): boolean {
const help = this.guestExec(["openclaw", "gateway", "status", "--help"], { check: false });
const help = this.guest.exec(["openclaw", "gateway", "status", "--help"], { check: false });
const args = help.includes("--require-rpc")
? ["openclaw", "gateway", "status", "--deep", "--require-rpc"]
: ["openclaw", "gateway", "status", "--deep"];
@ -655,7 +593,7 @@ setsid sh -lc ` +
private async maybeVerifyBadPluginDiagnostic(lane: "fresh" | "upgrade"): Promise<void> {
if (!this.shouldExpectBadPluginDiagnostic(lane)) {
this.log(
this.phases.append(
`Skipping bad plugin diagnostic assertion for ${lane}: installed ${this.versionForLane(lane)} predates ${BAD_PLUGIN_DIAGNOSTIC_MIN_VERSION}\n`,
);
return;
@ -669,8 +607,8 @@ setsid sh -lc ` +
if (!gatewayStartLog.includes(warning)) {
throw new Error(`bad plugin diagnostic missing: ${warning}`);
}
this.log(warning);
this.guestBash(String.raw`set -euo pipefail
this.phases.append(warning);
this.guest.bash(String.raw`set -euo pipefail
python3 - <<'PY'
import json
from pathlib import Path
@ -690,7 +628,7 @@ rm -rf /root/.openclaw/test-bad-plugin`);
}
private restrictAgentTurnPlugins(): void {
this.guestBash(
this.guest.bash(
posixProviderOnlyPluginIsolationScript({
fallbackPluginId: this.options.provider,
modelId: this.auth.modelId,
@ -699,18 +637,18 @@ rm -rf /root/.openclaw/test-bad-plugin`);
}
private verifyLocalTurn(): void {
this.guestBash(`set -euo pipefail\n${posixStopGatewayScript()}`);
this.guestExec(["openclaw", "models", "set", this.auth.modelId]);
this.guest.bash(`set -euo pipefail\n${posixStopGatewayScript()}`);
this.guest.exec(["openclaw", "models", "set", this.auth.modelId]);
const modelProviderConfigBatch = modelProviderConfigBatchJson(this.auth.modelId, "linux");
if (modelProviderConfigBatch) {
this.guestBash(`provider_config_batch="$(mktemp)"
this.guest.bash(`provider_config_batch="$(mktemp)"
cat >"$provider_config_batch" <<'JSON'
${modelProviderConfigBatch}
JSON
openclaw config set --batch-file "$provider_config_batch" --strict-json
rm -f "$provider_config_batch"`);
}
this.guestExec([
this.guest.exec([
"openclaw",
"config",
"set",
@ -718,10 +656,10 @@ rm -f "$provider_config_batch"`);
"true",
"--strict-json",
]);
this.guestExec(["openclaw", "config", "set", "tools.profile", "minimal"]);
this.guest.exec(["openclaw", "config", "set", "tools.profile", "minimal"]);
this.restrictAgentTurnPlugins();
this.prepareAgentWorkspace();
this.guestBash(
this.guest.bash(posixAgentWorkspaceScript("Parallels Linux smoke test assistant."));
this.guest.bash(
`${posixCodexPlatformPackageRepairFunction()}
${posixAgentTurnScript({
command: `/usr/bin/env OPENCLAW_ALLOW_ROOT=1 ${shellQuote(`${this.auth.apiKeyEnv}=${this.auth.apiKeyValue}`)} openclaw agent --local --agent main --session-id "$session_id" --message ${shellQuote(
@ -734,10 +672,6 @@ ${posixAgentTurnScript({
);
}
private prepareAgentWorkspace(): void {
this.guestBash(posixAgentWorkspaceScript("Parallels Linux smoke test assistant."));
}
private async extractLastVersion(phaseId: string): Promise<string> {
return await extractLastOpenClawVersion(
this.runDir,
@ -748,7 +682,7 @@ ${posixAgentTurnScript({
protected async writeSummary(): Promise<string> {
const summaryPath = path.join(this.runDir, "summary.json");
const summary: LinuxSummary = {
const summary = {
daemon: this.status.daemon,
...buildCommonSmokeSummary({
artifact: this.artifact,

View file

@ -1,7 +1,5 @@
#!/usr/bin/env -S pnpm tsx
// Macos Smoke script supports OpenClaw repository automation.
import { readFileSync } from "node:fs";
import { readFile, rm } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { posixAgentWorkspaceScript } from "./agent-workspace.ts";
@ -33,15 +31,11 @@ import {
withProgressOnStderr,
writeJson,
writeSummaryMarkdown,
type HostServer,
type Mode,
type PackageArtifact,
type Provider,
type ProviderAuth,
type SnapshotInfo,
} from "./common.ts";
import { MacosGuest } from "./guest-transports.ts";
import { runSmokeLane, type SmokeLane, type SmokeLaneStatus } from "./lane-runner.ts";
import { MacosDiscordSmoke } from "./macos-discord.ts";
import { runMacosHostCommand as run } from "./macos-exec.ts";
import { resolveMacosVmName, waitForVmStatus } from "./parallels-vm.ts";
@ -52,8 +46,10 @@ import {
npmRegistryEnv,
packAndServeSmokeArtifact,
parseSmokeCliArgs,
printSmokeTargetSummary,
posixAgentTurnScript,
posixStopGatewayScript,
SmokeRunController,
type SmokeCliOptions,
} from "./smoke-common.ts";
@ -65,38 +61,6 @@ interface MacosOptions extends SmokeCliOptions {
discordChannelId?: string;
}
interface MacosSummary {
vm: string;
snapshotHint: string;
snapshotId: string;
mode: Mode;
provider: Provider;
latestVersion: string;
installVersion: string;
targetPackageSpec: string;
currentHead: string;
runDir: string;
freshMain: {
status: string;
version: string;
gateway: string;
dashboard: string;
agent: string;
discord: string;
};
upgrade: {
precheck: string;
status: string;
path: string;
latestVersionInstalled: string;
mainVersion: string;
gateway: string;
dashboard: string;
agent: string;
discord: string;
};
}
const guestPath =
"/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/usr/local/bin:/usr/local/sbin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin";
const guestOpenClaw = "openclaw";
@ -181,15 +145,10 @@ export function parseArgs(argv: string[]): MacosOptions {
});
}
class MacosSmoke {
class MacosSmoke extends SmokeRunController<MacosOptions> {
private agentTimeoutSeconds: number;
private auth: ProviderAuth;
private discordToken = "";
private hostIp = "";
private hostPort = 0;
private server: HostServer | null = null;
private runDir = "";
private tgzDir = "";
private artifact: PackageArtifact | null = null;
private targetExpectVersion = "";
private latestVersion = "";
@ -204,9 +163,7 @@ class MacosSmoke {
private modelTimeoutSeconds: number;
private updateDevTimeoutSeconds: number;
private devTargetCommit: string | undefined;
private options: MacosOptions;
private status = {
protected status = {
freshAgent: "skip",
freshDashboard: "skip",
freshDiscord: "skip",
@ -224,7 +181,7 @@ class MacosSmoke {
};
constructor(options: MacosOptions) {
this.options = options;
super(options);
this.auth = resolveProviderAuth({
apiKeyEnv: options.apiKeyEnv,
modelId: options.modelId,
@ -311,28 +268,10 @@ class MacosSmoke {
).stdout.trim();
}
if (this.options.mode === "fresh" || this.options.mode === "both") {
await this.runLane("fresh", async () => this.runFreshLane());
}
if (this.options.mode === "upgrade" || this.options.mode === "both") {
await this.runLane("upgrade", async () => this.runUpgradeLane());
}
const summaryPath = await this.writeSummary();
if (this.options.json) {
process.stdout.write(await readFile(summaryPath, "utf8"));
} else {
this.printSummary(summaryPath);
}
if (this.status.freshMain === "fail" || this.status.upgrade === "fail") {
process.exitCode = 1;
}
await this.runLanesAndFinish();
} finally {
if (!this.options.keepServer) {
await this.server?.stop().catch(() => undefined);
await rm(this.tgzDir, { force: true, recursive: true }).catch(() => undefined);
}
await this.cleanupDiscordMessages().catch(() => undefined);
await this.cleanupArtifacts();
await this.discord?.cleanupMessages().catch(() => undefined);
await this.stopVmAfterSuccessfulDiscordSmoke().catch(() => undefined);
}
}
@ -403,65 +342,61 @@ class MacosSmoke {
return this.options.targetPackageSpec ? "target package tgz" : "current main tgz";
}
private async runLane(name: "fresh" | "upgrade", fn: () => Promise<void>): Promise<void> {
await runSmokeLane(name, fn, (lane, status) => this.setLaneStatus(lane, status));
}
private setLaneStatus(name: SmokeLane, status: SmokeLaneStatus): void {
if (name === "fresh") {
this.status.freshMain = status;
} else {
this.status.upgrade = status;
}
}
private async runFreshLane(): Promise<void> {
await this.phase("fresh.restore-snapshot", 780, () => this.restoreSnapshot());
await this.phase("fresh.reset-state", 180, () => this.resetState());
await this.phase("fresh.install-main", this.targetInstallsDirectly() ? 420 : 420, () =>
protected async runFreshLane(): Promise<void> {
await this.phases.phase("fresh.restore-snapshot", 780, () => this.restoreSnapshot());
await this.phases.phase("fresh.reset-state", 180, () => this.resetState());
await this.phases.phase("fresh.install-main", 420, () =>
this.installMain("openclaw-main-fresh.tgz"),
);
this.status.freshVersion = await this.extractLastVersion("fresh.install-main");
await this.phase("fresh.verify-main-version", 60, () => this.verifyTargetVersion());
await this.phase("fresh.verify-bundle-permissions", 180, () => this.verifyBundlePermissions());
await this.phase("fresh.install-companions", 600, () =>
await this.phases.phase("fresh.verify-main-version", 60, () => this.verifyTargetVersion());
await this.phases.phase("fresh.verify-bundle-permissions", 180, () =>
this.verifyBundlePermissions(),
);
await this.phases.phase("fresh.install-companions", 600, () =>
installSmokeRuntimeCompanions({
provider: this.options.provider,
readCli: (args) => this.guestExec([guestOpenClaw, ...args]),
readCli: (args) => this.guest.exec([guestOpenClaw, ...args]),
installCli: (args) => {
this.guestExec([guestOpenClaw, ...args]);
this.guest.exec([guestOpenClaw, ...args]);
},
}),
);
await this.phase("fresh.onboard-ref", 420, () => this.runRefOnboard());
await this.phase("fresh.gateway-start", 180, () => this.startManualGatewayIfNeeded());
await this.phase("fresh.gateway-status", 180, () => this.verifyGateway());
await this.phases.phase("fresh.onboard-ref", 420, () => this.runRefOnboard());
await this.phases.phase("fresh.gateway-start", 180, () => this.startManualGatewayIfNeeded());
await this.phases.phase("fresh.gateway-status", 180, () => this.verifyGateway());
this.status.freshGateway = "pass";
await this.phase("fresh.dashboard-load", 180, () => this.verifyDashboardLoad());
await this.phases.phase("fresh.dashboard-load", 180, () => this.verifyDashboardLoad());
this.status.freshDashboard = "pass";
await this.phase("fresh.first-agent-turn", this.agentTimeoutSeconds, () => this.verifyTurn());
await this.phases.phase("fresh.first-agent-turn", this.agentTimeoutSeconds, () =>
this.verifyTurn(),
);
this.status.freshAgent = "pass";
if (this.discordEnabled()) {
this.status.freshDiscord = "fail";
await this.phase("fresh.discord-config", 600, () => this.configureDiscord());
await this.phase("fresh.discord-gateway-ready", 180, () => this.ensureDiscordGatewayReady());
await this.phase("fresh.discord-roundtrip", 180, () => this.runDiscordRoundtrip("fresh"));
await this.phases.phase("fresh.discord-config", 600, () => this.discord?.configure());
await this.phases.phase("fresh.discord-gateway-ready", 180, () =>
this.ensureDiscordGatewayReady(),
);
await this.phases.phase("fresh.discord-roundtrip", 180, () =>
this.runDiscordRoundtrip("fresh"),
);
this.status.freshDiscord = "pass";
}
}
private async runUpgradeLane(): Promise<void> {
await this.phase("upgrade.restore-snapshot", 780, () => this.restoreSnapshot());
await this.phase("upgrade.reset-state", 180, () => this.resetState());
await this.phase("upgrade.install-latest", 420, () => this.installLatestRelease());
protected async runUpgradeLane(): Promise<void> {
await this.phases.phase("upgrade.restore-snapshot", 780, () => this.restoreSnapshot());
await this.phases.phase("upgrade.reset-state", 180, () => this.resetState());
await this.phases.phase("upgrade.install-latest", 420, () => this.installLatestRelease());
this.status.latestInstalledVersion = await this.extractLastVersion("upgrade.install-latest");
await this.phase("upgrade.verify-latest-version", 60, () =>
await this.phases.phase("upgrade.verify-latest-version", 60, () =>
this.verifyVersionContains(this.installVersion),
);
if (this.options.skipLatestRefCheck) {
this.status.upgradePrecheck = "skipped";
} else if (
await this.phaseReturns("upgrade.latest-ref-precheck", 180, () =>
await this.phases.phaseReturns("upgrade.latest-ref-precheck", 180, () =>
this.captureLatestRefFailure(),
)
) {
@ -470,77 +405,52 @@ class MacosSmoke {
this.status.upgradePrecheck = "latest-ref-fail";
}
if (this.options.targetPackageSpec) {
await this.phase("upgrade.install-main", this.targetInstallsDirectly() ? 420 : 420, () =>
await this.phases.phase("upgrade.install-main", 420, () =>
this.installMain("openclaw-main-upgrade.tgz"),
);
this.status.upgradeVersion = await this.extractLastVersion("upgrade.install-main");
await this.phase("upgrade.verify-main-version", 60, () => this.verifyTargetVersion());
await this.phase("upgrade.verify-bundle-permissions", 180, () =>
await this.phases.phase("upgrade.verify-main-version", 60, () => this.verifyTargetVersion());
await this.phases.phase("upgrade.verify-bundle-permissions", 180, () =>
this.verifyBundlePermissions(),
);
} else {
await this.phase("upgrade.update-dev", this.updateDevTimeoutSeconds, () =>
await this.phases.phase("upgrade.update-dev", this.updateDevTimeoutSeconds, () =>
this.runDevChannelUpdate(),
);
this.status.upgradeVersion = await this.extractLastVersion("upgrade.update-dev");
await this.phase("upgrade.verify-dev-channel", 60, () => this.verifyDevChannelUpdate());
await this.phases.phase("upgrade.verify-dev-channel", 60, () =>
this.verifyDevChannelUpdate(),
);
}
await this.phase("upgrade.onboard-ref", 420, () => this.runRefOnboard());
await this.phase("upgrade.gateway-start", 180, () => this.startManualGatewayIfNeeded());
await this.phase("upgrade.gateway-status", 180, () => this.verifyGateway());
await this.phases.phase("upgrade.onboard-ref", 420, () => this.runRefOnboard());
await this.phases.phase("upgrade.gateway-start", 180, () => this.startManualGatewayIfNeeded());
await this.phases.phase("upgrade.gateway-status", 180, () => this.verifyGateway());
this.status.upgradeGateway = "pass";
await this.phase("upgrade.dashboard-load", 180, () => this.verifyDashboardLoad());
await this.phases.phase("upgrade.dashboard-load", 180, () => this.verifyDashboardLoad());
this.status.upgradeDashboard = "pass";
await this.phase("upgrade.first-agent-turn", this.agentTimeoutSeconds, () => this.verifyTurn());
await this.phases.phase("upgrade.first-agent-turn", this.agentTimeoutSeconds, () =>
this.verifyTurn(),
);
this.status.upgradeAgent = "pass";
if (this.discordEnabled()) {
this.status.upgradeDiscord = "fail";
await this.phase("upgrade.discord-config", 600, () => this.configureDiscord());
await this.phase("upgrade.discord-gateway-ready", 180, () =>
await this.phases.phase("upgrade.discord-config", 600, () => this.discord?.configure());
await this.phases.phase("upgrade.discord-gateway-ready", 180, () =>
this.ensureDiscordGatewayReady(),
);
await this.phase("upgrade.discord-roundtrip", 180, () => this.runDiscordRoundtrip("upgrade"));
await this.phases.phase("upgrade.discord-roundtrip", 180, () =>
this.runDiscordRoundtrip("upgrade"),
);
this.status.upgradeDiscord = "pass";
}
}
private async phase(
name: string,
timeoutSeconds: number,
fn: () => Promise<void> | void,
): Promise<void> {
await this.phases.phase(name, timeoutSeconds, fn);
}
private remainingPhaseTimeoutMs(fallbackMs?: number): number | undefined {
return this.phases.remainingTimeoutMs(fallbackMs);
}
private async phaseReturns(
name: string,
timeoutSeconds: number,
fn: () => Promise<void> | void,
): Promise<boolean> {
return await this.phases.phaseReturns(name, timeoutSeconds, fn);
}
private log(text: string): void {
this.phases.append(text);
}
private guestExec(
args: string[],
options: { check?: boolean; env?: Record<string, string> } = {},
): string {
return this.guest.exec(args, options);
}
private guestOpenClawEntryExec(
args: string[],
options: { check?: boolean; env?: Record<string, string> } = {},
): string {
const argv = args.map((arg) => shellQuote(arg)).join(" ");
return this.guestSh(
return this.guest.sh(
`set -e
entry="$(npm root -g)/openclaw/openclaw.mjs"
exec node "$entry" ${argv}`,
@ -548,10 +458,6 @@ exec node "$entry" ${argv}`,
);
}
private guestSh(script: string, env: Record<string, string> = {}): string {
return this.guest.sh(script, env);
}
private waitForCurrentUser(timeoutSeconds = 360): void {
const prlctlDeadline = Date.now() + 45_000;
const deadline = Date.now() + timeoutSeconds * 1000;
@ -559,7 +465,7 @@ exec node "$entry" ${argv}`,
const result = run("prlctl", ["exec", this.options.vmName, "--current-user", "whoami"], {
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(),
timeoutMs: this.phases.remainingTimeoutMs(),
});
const user = result.stdout.trim().replaceAll("\r", "").split("\n").at(-1) ?? "";
if (result.status === 0 && /^[A-Za-z0-9._-]+$/.test(user)) {
@ -582,7 +488,7 @@ exec node "$entry" ${argv}`,
const result = run("prlctl", ["exec", this.options.vmName, "--current-user", "whoami"], {
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(),
timeoutMs: this.phases.remainingTimeoutMs(),
});
const user = result.stdout.trim().replaceAll("\r", "").split("\n").at(-1) ?? "";
if (result.status === 0 && /^[A-Za-z0-9._-]+$/.test(user)) {
@ -600,7 +506,7 @@ exec node "$entry" ${argv}`,
run("prlctl", ["exec", this.options.vmName, "/usr/bin/stat", "-f", "%Su", "/dev/console"], {
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(30_000),
timeoutMs: this.phases.remainingTimeoutMs(30_000),
})
.stdout.trim()
.replaceAll("\r", "")
@ -619,7 +525,7 @@ exec node "$entry" ${argv}`,
{
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(30_000),
timeoutMs: this.phases.remainingTimeoutMs(30_000),
},
).stdout.replaceAll("\r", "");
for (const line of users.split("\n")) {
@ -650,7 +556,7 @@ exec node "$entry" ${argv}`,
`/Users/${user}`,
"NFSHomeDirectory",
],
{ check: false, quiet: true, timeoutMs: this.remainingPhaseTimeoutMs(30_000) },
{ check: false, quiet: true, timeoutMs: this.phases.remainingTimeoutMs(30_000) },
).stdout.replaceAll("\r", "");
const match = /^NFSHomeDirectory:\s+(.+)$/m.exec(output);
return match?.[1]?.trim() || `/Users/${user}`;
@ -670,10 +576,10 @@ exec node "$entry" ${argv}`,
const result = run(
"prlctl",
["snapshot-switch", this.options.vmName, "--id", this.snapshot.id],
{ check: false, quiet: true, timeoutMs: this.remainingPhaseTimeoutMs(360_000) },
{ check: false, quiet: true, timeoutMs: this.phases.remainingTimeoutMs(360_000) },
);
this.log(result.stdout);
this.log(result.stderr);
this.phases.append(result.stdout);
this.phases.append(result.stderr);
if (result.status === 0) {
restored = true;
break;
@ -682,16 +588,16 @@ exec node "$entry" ${argv}`,
const status = run("prlctl", ["status", this.options.vmName], {
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(60_000),
timeoutMs: this.phases.remainingTimeoutMs(60_000),
}).stdout;
if (status.includes(" running") || status.includes(" suspended")) {
run("prlctl", ["stop", this.options.vmName, "--kill"], {
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(120_000),
timeoutMs: this.phases.remainingTimeoutMs(120_000),
});
waitForVmStatus(this.options.vmName, "stopped", 360, {
probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000),
probeTimeoutMs: () => this.phases.remainingTimeoutMs(30_000),
});
}
run("sleep", ["3"], { quiet: true });
@ -702,29 +608,29 @@ exec node "$entry" ${argv}`,
const status = run("prlctl", ["status", this.options.vmName], {
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(60_000),
timeoutMs: this.phases.remainingTimeoutMs(60_000),
}).stdout;
if (this.snapshot.state === "poweroff" || status.includes(" stopped")) {
waitForVmStatus(this.options.vmName, "stopped", 360, {
probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000),
probeTimeoutMs: () => this.phases.remainingTimeoutMs(30_000),
});
say(`Start restored poweroff snapshot ${this.snapshot.name}`);
run("prlctl", ["start", this.options.vmName], {
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(120_000),
timeoutMs: this.phases.remainingTimeoutMs(120_000),
});
} else if (status.includes(" suspended")) {
say(`Resume restored snapshot ${this.snapshot.name}`);
run("prlctl", ["start", this.options.vmName], {
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(120_000),
timeoutMs: this.phases.remainingTimeoutMs(120_000),
});
}
this.waitForCurrentUser();
}
private resetState(): void {
this.guestSh(String.raw`/usr/bin/pkill -f 'openclaw.*gateway run' >/dev/null 2>&1 || true
this.guest.sh(String.raw`/usr/bin/pkill -f 'openclaw.*gateway run' >/dev/null 2>&1 || true
/usr/bin/pkill -f 'openclaw-gateway' >/dev/null 2>&1 || true
/usr/bin/pkill -f 'openclaw.mjs gateway' >/dev/null 2>&1 || true
printf 'preflight.user=%s\n' "$(whoami)"
@ -740,7 +646,7 @@ rm -f /tmp/openclaw-parallels-macos-gateway.log`);
}
private installLatestRelease(): void {
this.guestSh(
this.guest.sh(
`export OPENCLAW_NO_ONBOARD=1
curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${shellQuote(
this.options.installUrl,
@ -753,8 +659,8 @@ ${guestOpenClaw} --version`,
private installMain(tempName: string): void {
this.guestEnv = npmRegistryEnv(this.options.npmRegistry ?? this.server?.registry?.url);
if (this.targetInstallsDirectly()) {
this
.guestSh(`printf 'install-source: registry-spec %s\\n' ${shellQuote(this.options.targetPackageSpec || "")}
this.guest
.sh(`printf 'install-source: registry-spec %s\\n' ${shellQuote(this.options.targetPackageSpec || "")}
for attempt in 1 2; do
if ${guestNpm} install -g ${shellQuote(this.options.targetPackageSpec || "")}; then
break
@ -772,7 +678,7 @@ ${guestOpenClaw} --version`);
die("package artifact/server missing");
}
const tgzUrl = this.server.urlFor(this.artifact.path);
this.guestSh(`printf 'install-source: host-tgz %s\\n' ${shellQuote(tgzUrl)}
this.guest.sh(`printf 'install-source: host-tgz %s\\n' ${shellQuote(tgzUrl)}
curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${shellQuote(
tgzUrl,
)} -o /tmp/${tempName}
@ -795,14 +701,14 @@ ${guestOpenClaw} --version`);
}
private verifyVersionContains(needle: string): void {
const version = this.guestExec([guestOpenClaw, "--version"]);
const version = this.guest.exec([guestOpenClaw, "--version"]);
if (!version.includes(needle)) {
throw new Error(`version mismatch: expected substring ${needle}`);
}
}
private verifyBundlePermissions(): void {
this.guestSh(String.raw`set -eu
this.guest.sh(String.raw`set -eu
root=$(npm root -g)
check_path() {
path="$1"
@ -825,7 +731,7 @@ fi`);
private runRefOnboard(): void {
const daemonFlag = this.guestTransport === "sudo" ? "--skip-health" : "--install-daemon";
this.guestExec([
this.guest.exec([
"/usr/bin/env",
`${this.auth.apiKeyEnv}=${this.auth.apiKeyValue}`,
guestOpenClaw,
@ -860,7 +766,7 @@ fi`);
) as { packageManager: string };
const spec = packageManager.replace(/\+.*$/u, "");
const version = spec.slice("pnpm@".length);
this.guestSh(String.raw`set -eu
this.guest.sh(String.raw`set -eu
bootstrap_root=/tmp/openclaw-smoke-pnpm-bootstrap
bootstrap_bin="$bootstrap_root/node_modules/.bin"
if [ -x "$bootstrap_bin/pnpm" ] && [ "$("$bootstrap_bin/pnpm" --version)" = ${shellQuote(version)} ]; then
@ -907,7 +813,7 @@ ${guestOpenClawEntryRunner} update status --json`,
private verifyDevChannelUpdate(): void {
const status = this.guestOpenClawEntryExec(["update", "status", "--json"]);
assertDevChannelUpdate(status, this.devTargetCommit, () =>
this.guestSh(`git -C ${shellQuote(`${this.guestHome()}/openclaw`)} rev-parse HEAD`),
this.guest.sh(`git -C ${shellQuote(`${this.guestHome()}/openclaw`)} rev-parse HEAD`),
);
}
@ -916,7 +822,7 @@ ${guestOpenClawEntryRunner} update status --json`,
return;
}
const home = this.guestHome();
this.guestSh(
this.guest.sh(
`set -euo pipefail
trap '' HUP
/usr/bin/pkill -f 'openclaw.*gateway run' >/dev/null 2>&1 || true
@ -933,10 +839,14 @@ sleep 1`,
private verifyGateway(): void {
for (let attempt = 1; attempt <= 8; attempt++) {
const result = this.guestOpenClaw(
["gateway", "status", "--deep", "--require-rpc", "--timeout", "15000"],
false,
);
const result = this.guestOpenClaw([
"gateway",
"status",
"--deep",
"--require-rpc",
"--timeout",
"15000",
]);
if (result) {
return;
}
@ -949,25 +859,22 @@ sleep 1`,
}
private showGatewayStatusCompat(): void {
const help = this.guestExec([guestOpenClaw, "gateway", "status", "--help"], { check: false });
const help = this.guest.exec([guestOpenClaw, "gateway", "status", "--help"], { check: false });
const args = help.includes("--require-rpc")
? ["gateway", "status", "--deep", "--require-rpc"]
: ["gateway", "status", "--deep"];
if (!this.guestOpenClaw(args, false)) {
if (!this.guestOpenClaw(args)) {
throw new Error("gateway status failed");
}
}
private guestOpenClaw(args: string[], check: boolean): boolean {
private guestOpenClaw(args: string[]): boolean {
const result = this.guest.run([guestOpenClaw, ...args], { check: false });
if (check && result.status !== 0) {
throw new Error(`openclaw ${args.join(" ")} failed`);
}
return result.status === 0;
}
private verifyDashboardLoad(): void {
this.guestSh(String.raw`set -eu
this.guest.sh(String.raw`set -eu
deadline=$((SECONDS + 120))
while [ $SECONDS -lt $deadline ]; do
if curl -fsSL --connect-timeout 2 --max-time 5 http://127.0.0.1:18789/ >/tmp/openclaw-dashboard-smoke.html 2>/dev/null; then
@ -1004,7 +911,7 @@ exit 1`);
}
private restrictAgentTurnPlugins(): void {
this.guestSh(
this.guest.sh(
posixProviderOnlyPluginIsolationScript({
fallbackPluginId: this.options.provider,
homeFallback: this.guestHome(),
@ -1015,7 +922,7 @@ exit 1`);
}
private verifyTurn(): void {
this.guestSh(
this.guest.sh(
`set -euo pipefail\n${posixStopGatewayScript(this.guestTransport === "sudo" ? undefined : guestOpenClawEntryRunner)}`,
);
this.guestOpenClawEntryExec(["models", "set", this.auth.modelId]);
@ -1025,7 +932,7 @@ exit 1`);
this.modelTimeoutSeconds,
);
if (modelProviderConfigBatch) {
this.guestSh(`provider_config_batch="$(mktemp)"
this.guest.sh(`provider_config_batch="$(mktemp)"
cat >"$provider_config_batch" <<'JSON'
${modelProviderConfigBatch}
JSON
@ -1041,7 +948,7 @@ rm -f "$provider_config_batch"`);
]);
this.guestOpenClawEntryExec(["config", "set", "tools.profile", "minimal"]);
this.restrictAgentTurnPlugins();
this.guestSh(
this.guest.sh(
`${posixAgentWorkspaceScript("Parallels macOS smoke test assistant.")}
${posixCodexPlatformPackageRepairFunction()}
${posixAgentTurnScript({
@ -1055,10 +962,6 @@ ${posixAgentTurnScript({
);
}
private configureDiscord(): void {
this.discord?.configure();
}
private ensureDiscordGatewayReady(): void {
this.startManualGatewayIfNeeded();
this.verifyGateway();
@ -1075,10 +978,6 @@ ${posixAgentTurnScript({
await this.discord.runRoundtrip(phase);
}
private async cleanupDiscordMessages(): Promise<void> {
await this.discord?.cleanupMessages();
}
private async stopVmAfterSuccessfulDiscordSmoke(): Promise<void> {
this.discord?.stopVmAfterSuccessfulSmoke(this.status.freshDiscord, this.status.upgradeDiscord);
}
@ -1089,7 +988,7 @@ ${posixAgentTurnScript({
}
return this.guestTransport === "sudo"
? this.resolveDesktopHome(this.guestUser)
: this.guestExec(["/usr/bin/id", "-P"]).split(":")[8] || `/Users/${this.guestUser}`;
: this.guest.exec(["/usr/bin/id", "-P"]).split(":")[8] || `/Users/${this.guestUser}`;
}
private async extractLastVersion(phaseName: string): Promise<string> {
@ -1100,8 +999,8 @@ ${posixAgentTurnScript({
return this.options.targetPackageSpec ? "latest->target-package" : "latest->dev";
}
private async writeSummary(): Promise<string> {
const summary: MacosSummary = {
protected async writeSummary(): Promise<string> {
const summary = {
currentHead:
this.artifact?.buildCommitShort ||
run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim(),
@ -1151,14 +1050,12 @@ ${posixAgentTurnScript({
return summaryPath;
}
private printSummary(summaryPath: string): void {
protected printSummary(summaryPath: string): void {
process.stdout.write("\nSummary:\n");
if (this.options.targetPackageSpec) {
process.stdout.write(` target-package: ${this.options.targetPackageSpec}\n`);
}
if (this.installVersion) {
process.stdout.write(` baseline-install-version: ${this.installVersion}\n`);
}
printSmokeTargetSummary({
targetPackageSpec: this.options.targetPackageSpec,
installVersion: this.installVersion,
});
process.stdout.write(
` fresh-main: ${this.status.freshMain} (${this.status.freshVersion}) discord=${this.status.freshDiscord}\n`,
);

View file

@ -1,5 +1,4 @@
#!/usr/bin/env -S pnpm tsx
// Windows Smoke script supports OpenClaw repository automation.
import path from "node:path";
import { pathToFileURL } from "node:url";
import { windowsAgentWorkspaceScript } from "./agent-workspace.ts";
@ -21,9 +20,7 @@ import {
withProgressOnStderr,
writeSummaryMarkdown,
writeJson,
type Mode,
type PackageArtifact,
type Provider,
type ProviderAuth,
type SnapshotInfo,
} from "./common.ts";
@ -62,33 +59,6 @@ interface WindowsOptions extends SmokeCliOptions {
skipLatestRefCheck: boolean;
}
interface WindowsSummary {
vm: string;
snapshotHint: string;
snapshotId: string;
mode: Mode;
provider: Provider;
latestVersion: string;
installVersion: string;
targetPackageSpec: string;
currentHead: string;
runDir: string;
freshMain: {
status: string;
version: string;
gateway: string;
agent: string;
};
upgrade: {
precheck: string;
status: string;
latestVersionInstalled: string;
mainVersion: string;
gateway: string;
agent: string;
};
}
const WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS = 900;
const WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS = WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS * 1000;
@ -286,18 +256,18 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
}
protected async runFreshLane(): Promise<void> {
await this.phase("fresh.restore-snapshot", 240, () => this.restoreSnapshot());
await this.phase("fresh.wait-for-user", 240, () => this.waitForGuestReady());
await this.phase("fresh.ensure-git", 1200, () =>
await this.phases.phase("fresh.restore-snapshot", 240, () => this.restoreSnapshot());
await this.phases.phase("fresh.wait-for-user", 240, () => this.waitForGuestReady());
await this.phases.phase("fresh.ensure-git", 1200, () =>
ensureGuestGit({ guest: this.guest, minGitZipPath: this.minGitZipPath, server: this.server }),
);
await this.phase("fresh.preflight", 120, () => this.logGuestPreflight(true));
await this.phase("fresh.install-main", WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS, () =>
await this.phases.phase("fresh.preflight", 120, () => this.logGuestPreflight(true));
await this.phases.phase("fresh.install-main", WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS, () =>
this.installMain("openclaw-main-fresh.tgz"),
);
this.status.freshVersion = await this.extractLastVersion("fresh.install-main");
await this.phase("fresh.verify-main-version", 120, () => this.verifyTargetVersion());
await this.phase("fresh.install-companions", 600, () =>
await this.phases.phase("fresh.verify-main-version", 120, () => this.verifyTargetVersion());
await this.phases.phase("fresh.install-companions", 600, () =>
installSmokeRuntimeCompanions({
provider: this.options.provider,
readCli: (args) =>
@ -310,26 +280,28 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
),
}),
);
await this.phase("fresh.onboard-ref", 720, () => this.runRefOnboard());
await this.phase("fresh.gateway-restart", 420, () => this.gatewayAction("restart"));
await this.phase("fresh.gateway-status", 420, () => this.verifyGatewayReachable());
await this.phases.phase("fresh.onboard-ref", 720, () => this.runRefOnboard());
await this.phases.phase("fresh.gateway-restart", 420, () => this.gatewayAction("restart"));
await this.phases.phase("fresh.gateway-status", 420, () => this.verifyGatewayReachable());
this.status.freshGateway = "pass";
await this.phase("fresh.gateway-stop-before-local-agent", 420, () =>
await this.phases.phase("fresh.gateway-stop-before-local-agent", 420, () =>
this.gatewayAction("stop"),
);
await this.phase("fresh.first-agent-turn", this.agentTimeoutSeconds, () => this.verifyTurn());
await this.phases.phase("fresh.first-agent-turn", this.agentTimeoutSeconds, () =>
this.verifyTurn(),
);
this.status.freshAgent = "pass";
}
protected async runUpgradeLane(): Promise<void> {
await this.phase("upgrade.restore-snapshot", 240, () => this.restoreSnapshot());
await this.phase("upgrade.wait-for-user", 240, () => this.waitForGuestReady());
await this.phase("upgrade.ensure-git", 1200, () =>
await this.phases.phase("upgrade.restore-snapshot", 240, () => this.restoreSnapshot());
await this.phases.phase("upgrade.wait-for-user", 240, () => this.waitForGuestReady());
await this.phases.phase("upgrade.ensure-git", 1200, () =>
ensureGuestGit({ guest: this.guest, minGitZipPath: this.minGitZipPath, server: this.server }),
);
await this.phase("upgrade.preflight", 120, () => this.logGuestPreflight(false));
await this.phases.phase("upgrade.preflight", 120, () => this.logGuestPreflight(false));
if (this.options.targetPackageSpec || this.options.upgradeFromPackedMain) {
await this.phase(
await this.phases.phase(
"upgrade.install-baseline-package",
WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS,
() => this.installMain("openclaw-main-upgrade.tgz"),
@ -337,24 +309,26 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
this.status.latestInstalledVersion = await this.extractLastVersion(
"upgrade.install-baseline-package",
);
await this.phase("upgrade.verify-baseline-package-version", 120, () =>
await this.phases.phase("upgrade.verify-baseline-package-version", 120, () =>
this.verifyTargetVersion(),
);
} else {
await this.phase("upgrade.install-baseline", WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS, () =>
this.installLatestRelease(),
await this.phases.phase(
"upgrade.install-baseline",
WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS,
() => this.installLatestRelease(),
);
this.status.latestInstalledVersion = await this.extractLastVersion(
"upgrade.install-baseline",
);
await this.phase("upgrade.verify-baseline-version", 120, () =>
await this.phases.phase("upgrade.verify-baseline-version", 120, () =>
this.verifyVersionContains(this.installVersion),
);
}
if (this.options.skipLatestRefCheck) {
this.status.upgradePrecheck = "skipped";
} else if (
await this.phaseReturns("upgrade.latest-ref-precheck", 720, () =>
await this.phases.phaseReturns("upgrade.latest-ref-precheck", 720, () =>
this.captureLatestRefFailure(),
)
) {
@ -362,38 +336,28 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
} else {
this.status.upgradePrecheck = "latest-ref-fail";
}
await this.phase("upgrade.gateway-stop-before-update", 420, () => this.gatewayAction("stop"));
await this.phase("upgrade.update-dev", this.updateTimeoutSeconds, () =>
await this.phases.phase("upgrade.gateway-stop-before-update", 420, () =>
this.gatewayAction("stop"),
);
await this.phases.phase("upgrade.update-dev", this.updateTimeoutSeconds, () =>
this.runDevChannelUpdate(),
);
this.status.upgradeVersion = await this.extractLastVersion("upgrade.update-dev");
await this.phase("upgrade.verify-dev-channel", 120, () => this.verifyDevChannelUpdate());
await this.phase("upgrade.gateway-stop", 420, () => this.gatewayAction("stop"));
await this.phase("upgrade.onboard-ref", 720, () => this.runRefOnboard());
await this.phase("upgrade.gateway-restart", 420, () => this.gatewayAction("restart"));
await this.phase("upgrade.gateway-status", 420, () => this.verifyGatewayReachable());
await this.phases.phase("upgrade.verify-dev-channel", 120, () => this.verifyDevChannelUpdate());
await this.phases.phase("upgrade.gateway-stop", 420, () => this.gatewayAction("stop"));
await this.phases.phase("upgrade.onboard-ref", 720, () => this.runRefOnboard());
await this.phases.phase("upgrade.gateway-restart", 420, () => this.gatewayAction("restart"));
await this.phases.phase("upgrade.gateway-status", 420, () => this.verifyGatewayReachable());
this.status.upgradeGateway = "pass";
await this.phase("upgrade.gateway-stop-before-local-agent", 420, () =>
await this.phases.phase("upgrade.gateway-stop-before-local-agent", 420, () =>
this.gatewayAction("stop"),
);
await this.phase("upgrade.first-agent-turn", this.agentTimeoutSeconds, () => this.verifyTurn());
await this.phases.phase("upgrade.first-agent-turn", this.agentTimeoutSeconds, () =>
this.verifyTurn(),
);
this.status.upgradeAgent = "pass";
}
private phase = async (name: string, timeoutSeconds: number, fn: () => Promise<void> | void) =>
await this.phases.phase(name, timeoutSeconds, fn);
private remainingPhaseTimeoutMs = (fallbackMs?: number): number | undefined =>
this.phases.remainingTimeoutMs(fallbackMs);
private phaseReturns = async (
name: string,
timeoutSeconds: number,
fn: () => Promise<void> | void,
): Promise<boolean> => await this.phases.phaseReturns(name, timeoutSeconds, fn);
private log = (text: string): void => this.phases.append(text);
private guestPowerShell(
script: string,
options: { check?: boolean; timeoutMs?: number } = {},
@ -418,11 +382,11 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
{
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(),
timeoutMs: this.phases.remainingTimeoutMs(),
},
);
this.log(result.stdout);
this.log(result.stderr);
this.phases.append(result.stdout);
this.phases.append(result.stderr);
if (result.status === 0) {
restored = true;
break;
@ -439,8 +403,8 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
}
this.waitForVmNotRestoring(240);
ensureVmRunning(this.options.vmName, 240, {
probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000),
transitionTimeoutMs: () => this.remainingPhaseTimeoutMs(120_000),
probeTimeoutMs: () => this.phases.remainingTimeoutMs(30_000),
transitionTimeoutMs: () => this.phases.remainingTimeoutMs(120_000),
});
}
@ -450,7 +414,7 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
const status = run("prlctl", ["status", this.options.vmName], {
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(30_000),
timeoutMs: this.phases.remainingTimeoutMs(30_000),
}).stdout;
if (!status.includes(" restoring")) {
return;
@ -469,7 +433,7 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
{
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(),
timeoutMs: this.phases.remainingTimeoutMs(),
},
);
if (result.status === 0) {
@ -505,7 +469,7 @@ $script = Invoke-RestMethod -Uri ${psSingleQuote(this.options.installUrl)} -Time
if ($LASTEXITCODE -ne 0) { throw "installer failed with exit code $LASTEXITCODE" }
Invoke-OpenClaw --version
if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LASTEXITCODE" }`,
this.remainingPhaseTimeoutMs(WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS) ??
this.phases.remainingTimeoutMs(WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS) ??
WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS,
);
}
@ -525,23 +489,20 @@ npm.cmd install -g $tgz --no-fund --no-audit --loglevel=error
if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE" }
Invoke-OpenClaw --version
if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LASTEXITCODE" }`,
this.remainingPhaseTimeoutMs(WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS) ??
this.phases.remainingTimeoutMs(WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS) ??
WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS,
);
}
private async verifyTargetVersion(): Promise<void> {
if (this.options.targetPackageSpec) {
if (!this.artifact) {
die("package artifact missing");
}
this.verifyVersionContains(await expectedPackageTargetVersion(this.artifact));
return;
}
if (!this.artifact) {
die("package artifact missing");
}
this.verifyVersionContains(await expectedPackageBuildCommit(this.artifact));
this.verifyVersionContains(
await (this.options.targetPackageSpec
? expectedPackageTargetVersion(this.artifact)
: expectedPackageBuildCommit(this.artifact)),
);
}
private verifyVersionContains(needle: string): void {
@ -586,7 +547,7 @@ ${this.windowsPluginIsolationScript()}`,
): Promise<void> {
await runWindowsBackgroundPowerShell({
append: (chunk) =>
this.log(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")),
this.phases.append(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")),
beforeLaunchAttempt: () => {
ensureVmRunning(this.options.vmName, 120);
this.waitForGuestReady(120);
@ -595,7 +556,7 @@ ${this.windowsPluginIsolationScript()}`,
env: this.guestEnv,
onLaunchRetry: warn,
script: `${windowsOpenClawResolver}\n${script}`,
timeoutMs: this.remainingPhaseTimeoutMs(timeoutMs) ?? timeoutMs,
timeoutMs: this.phases.remainingTimeoutMs(timeoutMs) ?? timeoutMs,
vmName: this.options.vmName,
});
}
@ -774,7 +735,7 @@ if (-not $agentOk) { throw 'openclaw agent finished without OK response' }`,
status: this.status,
vmName: this.options.vmName,
});
const summary: WindowsSummary = {
const summary = {
...common,
upgrade: {
...common.upgrade,

View file

@ -111,18 +111,14 @@ function generateTypes(db: DatabaseSync): string {
return lines.join("\n");
}
function readUtf8(file: string): string {
return fs.readFileSync(file, "utf8");
}
function generate(schema: SchemaTarget): void {
const db = new DatabaseSync(":memory:");
try {
db.exec(readUtf8(schema.schema));
db.exec(fs.readFileSync(schema.schema, "utf8"));
const typesSource = generateTypes(db);
if (verify) {
if (typesSource !== readUtf8(schema.outFile)) {
if (typesSource !== fs.readFileSync(schema.outFile, "utf8")) {
console.error(`${schema.outFile} is out of date. Run pnpm db:kysely:gen.`);
process.exitCode = 1;
}

View file

@ -23,16 +23,14 @@ export async function runCancelableCommand(run: (signal: AbortSignal) => Promise
process.on(signal, handler);
}
try {
try {
const status = await run(controller.signal);
return received ? signalExitCode(received) : status;
} catch (error) {
// Unverified extinction must reach artifact ownership, never become an exit code.
if (!received || !isCommandCancellation(error)) {
throw error;
}
return signalExitCode(received);
const status = await run(controller.signal);
return received ? signalExitCode(received) : status;
} catch (error) {
// Unverified extinction must reach artifact ownership, never become an exit code.
if (!received || !isCommandCancellation(error)) {
throw error;
}
return signalExitCode(received);
} finally {
for (const [signal, handler] of handlers) {
process.off(signal, handler);

View file

@ -261,6 +261,10 @@ function price(row: ModeResult): number | null {
return row.accounting?.costComplete ? (row.accounting.costUsd ?? null) : null;
}
function measuredRatio(direct: number | null, code: number | null): number | null {
return direct !== null && direct > 0 && code !== null ? code / direct : null;
}
function percentile(sorted: readonly number[], quantile: number): number | null {
if (sorted.length === 0) {
return null;
@ -322,7 +326,7 @@ function pairMeasurement(pair: ModePair, measure: (row: ModeResult) => number |
const direct = measure(pair.direct);
const code = measure(pair.code);
return completedTask(pair.direct) && completedTask(pair.code) && direct !== null && code !== null
? { direct, code, delta: code - direct, ratio: direct > 0 ? code / direct : null }
? { direct, code, delta: code - direct, ratio: measuredRatio(direct, code) }
: null;
}
@ -335,14 +339,11 @@ function summarizeModeGroup(rows: readonly ModeResult[], pairs: readonly ModePai
);
const usageComplete = unmatched === 0 && direct.usageMissing === 0 && code.usageMissing === 0;
const costComplete = usageComplete && direct.costMissing === 0 && code.costMissing === 0;
const tokenDeltas = successfulPairs.flatMap((pair) => {
const observation = pairMeasurement(pair, tokens);
return observation ? [observation.delta] : [];
});
const costDeltas = successfulPairs.flatMap((pair) => {
const observation = pairMeasurement(pair, price);
return observation ? [observation.delta] : [];
});
const pairedDeltas = (measure: (row: ModeResult) => number | null) =>
successfulPairs.flatMap((pair) => {
const observation = pairMeasurement(pair, measure);
return observation ? [observation.delta] : [];
});
const latenciesComplete = successfulPairs.every(
(pair) =>
pair.direct.gateway?.taskElapsedMs !== undefined &&
@ -361,8 +362,8 @@ function summarizeModeGroup(rows: readonly ModeResult[], pairs: readonly ModePai
code,
pairedSuccessDifference: distribution(successDeltas),
pairedSuccessfulDeltas: {
totalTokens: usageComplete ? distribution(tokenDeltas) : null,
costUsd: costComplete ? distribution(costDeltas) : null,
totalTokens: usageComplete ? distribution(pairedDeltas(tokens)) : null,
costUsd: costComplete ? distribution(pairedDeltas(price)) : null,
taskElapsedMs:
unmatched === 0 && latenciesComplete
? distribution(
@ -373,20 +374,12 @@ function summarizeModeGroup(rows: readonly ModeResult[], pairs: readonly ModePai
: null,
},
operationalRatios: {
totalTokensPerCompletedTask:
usageComplete &&
direct.tokensPerCompletedTask !== null &&
direct.tokensPerCompletedTask > 0 &&
code.tokensPerCompletedTask !== null
? code.tokensPerCompletedTask / direct.tokensPerCompletedTask
: null,
costPerCompletedTask:
costComplete &&
direct.costPerCompletedTask !== null &&
direct.costPerCompletedTask > 0 &&
code.costPerCompletedTask !== null
? code.costPerCompletedTask / direct.costPerCompletedTask
: null,
totalTokensPerCompletedTask: usageComplete
? measuredRatio(direct.tokensPerCompletedTask, code.tokensPerCompletedTask)
: null,
costPerCompletedTask: costComplete
? measuredRatio(direct.costPerCompletedTask, code.costPerCompletedTask)
: null,
},
};
}

View file

@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { safeParseJson } from "@openclaw/normalization-core/json-coercion";
import { createGatewayMatrixPluginSource } from "./code-mode-matrix-gateway-fixtures.ts";
import type { MatrixPerformanceFixture } from "./code-mode-matrix-performance-types.ts";
@ -245,12 +246,7 @@ api.registerTool({
fs.readFile(path.join(params.workspace, name), "utf8").catch(() => undefined),
),
);
let actual: unknown;
try {
actual = JSON.parse(jsonText ?? "");
} catch {
actual = undefined;
}
const actual = safeParseJson(jsonText ?? "");
const receipts = params.receipts.filter(
(row): row is Record<string, unknown> => typeof row === "object" && row !== null,
);

View file

@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { safeParseJson } from "@openclaw/normalization-core/json-coercion";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { createGatewayMatrixPluginSource } from "./code-mode-matrix-gateway-fixtures.ts";
import type {
@ -58,14 +59,6 @@ const REPORT_SCHEMA = {
additionalProperties: false,
};
function parseJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return undefined;
}
}
function finite(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
@ -308,7 +301,7 @@ Every report uses nonce ${nonce}. After all children finish, write ${ARTIFACT} w
});
const raw = await fs.readFile(path.join(params.workspace, ARTIFACT), "utf8").catch(() => "");
return {
finalArtifact: isDeepStrictEqual(parseJson(raw), expected),
finalArtifact: isDeepStrictEqual(safeParseJson(raw), expected),
exactSubmissions: submissions.length === 7 && submittedByChildren,
collectorLaunches:
launches.length === 7 &&

View file

@ -6,6 +6,7 @@ import os from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { isDeepStrictEqual } from "node:util";
import { safeParseJson } from "@openclaw/normalization-core/json-coercion";
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { isRecord as record } from "@openclaw/normalization-core/record-coerce";
import { parse } from "acorn";
@ -350,6 +351,11 @@ function outputDetails(message: RecordValue): RecordValue {
return record(message.details) ? message.details : {};
}
function addObservedNumber(total: number | undefined, value: unknown): number | undefined {
const number = asFiniteNumber(value);
return total !== undefined && number !== undefined ? total + number : undefined;
}
/** Only actual assistant calls and persisted terminal activity count as execution. */
export function collectGatewayMatrixTrace(events: readonly unknown[]): GatewayMatrixTrace {
const calls: ToolCall[] = [];
@ -358,16 +364,12 @@ export function collectGatewayMatrixTrace(events: readonly unknown[]): GatewayMa
const models = new Set<string>();
let assistantTurns = 0;
let usageSamples = 0;
let costSamples = 0;
let totalSamples = 0;
let cacheReadSamples = 0;
let cacheWriteSamples = 0;
let input = 0;
let output = 0;
let total = 0;
let costUsd = 0;
let cacheRead = 0;
let cacheWrite = 0;
let total: number | undefined = 0;
let costUsd: number | undefined = 0;
let cacheRead: number | undefined = 0;
let cacheWrite: number | undefined = 0;
for (const [eventIndex, event] of events.entries()) {
if (!record(event)) {
continue;
@ -387,26 +389,10 @@ export function collectGatewayMatrixTrace(events: readonly unknown[]): GatewayMa
usageSamples += 1;
input += inputTokens;
output += outputTokens;
const totalTokens = asFiniteNumber(usage.totalTokens);
if (totalTokens !== undefined) {
totalSamples += 1;
total += totalTokens;
}
const readTokens = asFiniteNumber(usage.cacheRead);
if (readTokens !== undefined) {
cacheReadSamples += 1;
cacheRead += readTokens;
}
const writeTokens = asFiniteNumber(usage.cacheWrite);
if (writeTokens !== undefined) {
cacheWriteSamples += 1;
cacheWrite += writeTokens;
}
const cost = record(usage.cost) ? asFiniteNumber(usage.cost.total) : undefined;
if (cost !== undefined) {
costSamples += 1;
costUsd += cost;
}
total = addObservedNumber(total, usage.totalTokens);
cacheRead = addObservedNumber(cacheRead, usage.cacheRead);
cacheWrite = addObservedNumber(cacheWrite, usage.cacheWrite);
costUsd = addObservedNumber(costUsd, record(usage.cost) ? usage.cost.total : undefined);
}
for (const block of Array.isArray(message.content) ? message.content : []) {
if (
@ -488,24 +474,18 @@ export function collectGatewayMatrixTrace(events: readonly unknown[]): GatewayMa
usage: {
input,
output,
...(totalSamples === assistantTurns ? { total } : {}),
...(cacheReadSamples === assistantTurns ? { cacheRead } : {}),
...(cacheWriteSamples === assistantTurns ? { cacheWrite } : {}),
...(total !== undefined ? { total } : {}),
...(cacheRead !== undefined ? { cacheRead } : {}),
...(cacheWrite !== undefined ? { cacheWrite } : {}),
},
}
: {}),
...(costSamples === assistantTurns && assistantTurns > 0 ? { costUsd } : {}),
...(usageSamples === assistantTurns && assistantTurns > 0 && costUsd !== undefined
? { costUsd }
: {}),
};
}
function jsonAnswer(text: string): unknown {
try {
return JSON.parse(text.trim());
} catch {
return undefined;
}
}
function callOutcomes(trace: GatewayMatrixTrace, call: ToolCall): ToolOutcome[] {
let outcome = trace.outcomes.findLast((item) => item.id === call.id);
const outcomes = outcome ? [outcome] : [];
@ -652,7 +632,7 @@ export function evaluateGatewayMatrixTask(params: {
trace.calls.some((call) => call.id === item.parentId && call.name === "exec");
const activity = trace.activities.filter((item) => !item.isError);
const checks: BehaviorChecks = {
answer: isDeepStrictEqual(jsonAnswer(params.final), expected),
answer: isDeepStrictEqual(safeParseJson(params.final.trim()), expected),
actualCodeMode:
trace.calls.some((call) => call.name === "exec") &&
trace.outcomes.some((outcome) =>
@ -1097,7 +1077,7 @@ export function evaluateGatewayMatrixInterview(
.map((reference) => reference.previewComplete),
);
const previewComplete = previewCoverage.size === 1 ? [...previewCoverage][0] : null;
const answer = jsonAnswer(final);
const answer = safeParseJson(final.trim());
const facts = record(answer) && record(answer.facts) ? answer.facts : {};
return {
answered:
@ -1362,6 +1342,8 @@ export async function runGatewayMatrixCell(
const pluginDir = path.join(root, "fixture");
const receiptsPath = path.join(root, "receipts.jsonl");
const artifactDir = path.join(params.outputDir, "cells", params.cell.id);
const artifactPath = (name: string) =>
path.relative(params.outputDir, path.join(artifactDir, name));
const rootSessionKey = `agent:qa:matrix:${randomUUID()}`;
const token = `synthetic-matrix-${randomUUID()}`;
const redact = (value: string) =>
@ -1893,32 +1875,19 @@ export async function runGatewayMatrixCell(
traceAvailable: ledger !== undefined && interviewStartedAt !== undefined,
...(interviewElapsedMs !== undefined ? { elapsedMs: interviewElapsedMs } : {}),
...(interviewAccounting ? { accounting: interviewAccounting } : {}),
answer: jsonAnswer(interview.final) ?? interview.final,
answer: safeParseJson(interview.final.trim()) ?? interview.final,
rationaleReview: "required",
checks: interviewChecks,
...(interviewStartedAt !== undefined ? { trace: interviewTrace } : {}),
},
artifacts: {
taskTrace: path.relative(params.outputDir, path.join(artifactDir, "task-transcript.json")),
interviewTrace: path.relative(
params.outputDir,
path.join(artifactDir, "interview-transcript.json"),
),
receipts: path.relative(params.outputDir, path.join(artifactDir, "receipts.json")),
taskReceipts: path.relative(params.outputDir, path.join(artifactDir, "task-receipts.json")),
interviewReceipts: path.relative(
params.outputDir,
path.join(artifactDir, "interview-receipts.json"),
),
log: path.relative(params.outputDir, path.join(artifactDir, "gateway.log")),
...(deliveredFiles
? {
deliveredFiles: path.relative(
params.outputDir,
path.join(artifactDir, "delivered-files.json"),
),
}
: {}),
taskTrace: artifactPath("task-transcript.json"),
interviewTrace: artifactPath("interview-transcript.json"),
receipts: artifactPath("receipts.json"),
taskReceipts: artifactPath("task-receipts.json"),
interviewReceipts: artifactPath("interview-receipts.json"),
log: artifactPath("gateway.log"),
...(deliveredFiles ? { deliveredFiles: artifactPath("delivered-files.json") } : {}),
},
};
await write("evidence.json", {

View file

@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { safeParseJson } from "@openclaw/normalization-core/json-coercion";
import { isRecord as isRow } from "@openclaw/normalization-core/record-coerce";
import { createGatewayMatrixPluginSource } from "./code-mode-matrix-gateway-fixtures.ts";
import type { MatrixPerformanceFixture } from "./code-mode-matrix-performance-types.ts";
@ -199,12 +200,9 @@ Write ${REPORT_PATH} with exactly these JSON fields: {batchId,sourceLineCount,se
.filter(isRow)
.toSorted((a, b) => String(a.operationId).localeCompare(String(b.operationId)))
: [];
let report: unknown;
try {
report = JSON.parse(await fs.readFile(path.join(workspace, REPORT_PATH), "utf8"));
} catch {
report = undefined;
}
const report = safeParseJson(
await fs.readFile(path.join(workspace, REPORT_PATH), "utf8").catch(() => ""),
);
return {
completeSourceRead: rows.some((row) => row.kind === "call" && row.tool === TOOLS.batch),
requiredFailuresObserved: allFaultsObserved && uncertain.length === 4,

View file

@ -0,0 +1,32 @@
export type JsonSchema = {
"~openclawClosedObjectIdentity"?: symbol;
type?: string | string[];
const?: boolean | number | string | null;
properties?: Record<string, JsonSchema>;
required?: string[];
items?: JsonSchema;
enum?: Array<boolean | number | string | null>;
patternProperties?: Record<string, JsonSchema>;
anyOf?: JsonSchema[];
oneOf?: JsonSchema[];
additionalProperties?: boolean | JsonSchema;
};
function stableJson(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(stableJson);
}
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
return Object.fromEntries(
Object.keys(record)
.toSorted()
.map((key) => [key, stableJson(record[key])]),
);
}
return value;
}
export function schemaSignature(schema: JsonSchema): string {
return JSON.stringify(stableJson(schema));
}

View file

@ -17,7 +17,6 @@ type PublicationExit = ReliabilityReport["publicationInterruptionProof"]["before
type CrashPointResult = {
exit: PublicationExit;
sourceStatePreserved: true;
stagingEntries: number;
targetState: ReliabilityStateProof | null;
targetVisibleAfterCrash: boolean;
@ -105,11 +104,10 @@ async function runCrashPoint(params: {
child.once("exit", (code, signal) => resolve({ code, signal }));
});
let crashStagingEntries: string[];
try {
await waitForCrashPoint({ child, markerPath, readStderr: () => stderr });
const targetVisibleAfterCrash = fs.existsSync(targetPath);
crashStagingEntries = listCrashStagingEntries(params.scratchPath);
const crashStagingEntries = listCrashStagingEntries(params.scratchPath);
if (crashStagingEntries.length === 0) {
throw new Error(`SQLite publication worker reached ${params.crashPoint} without staging.`);
}
@ -136,8 +134,6 @@ async function runCrashPoint(params: {
targetPath,
});
assertNoSqliteSidecars(targetPath);
const retryState = params.verifyDatabase(targetPath);
assertSameReliabilityState(retryState, params.expectedState, `${params.crashPoint} retry`);
} else {
const targetHash = hashFile(targetPath);
let retryError: unknown;
@ -158,13 +154,9 @@ async function runCrashPoint(params: {
if (hashFile(targetPath) !== targetHash) {
throw new Error("SQLite retry changed the already-published target.");
}
const preservedState = params.verifyDatabase(targetPath);
assertSameReliabilityState(
preservedState,
params.expectedState,
`${params.crashPoint} retry`,
);
}
const retryState = params.verifyDatabase(targetPath);
assertSameReliabilityState(retryState, params.expectedState, `${params.crashPoint} retry`);
for (const entry of crashStagingEntries) {
if (!fs.existsSync(path.join(params.scratchPath, entry))) {
throw new Error(`SQLite retry removed crash staging it did not own: ${entry}`);
@ -173,7 +165,6 @@ async function runCrashPoint(params: {
return {
exit,
sourceStatePreserved: true,
stagingEntries: crashStagingEntries.length,
targetState,
targetVisibleAfterCrash,

View file

@ -25,6 +25,7 @@ import {
PROFILES,
STRESS_TABLE_SQL,
type CliOptions,
type CompactionPayloadProof,
type ReliabilityReport,
type ReliabilityStateProof,
} from "./sqlite-reliability-contract.js";
@ -43,17 +44,7 @@ import {
type WriterHandle,
} from "./sqlite-reliability-writer.js";
type TargetDatabase = {
identity: SnapshotDatabaseIdentity;
label: string;
path: string;
};
type IterationMetric = {
restoreMs: number;
snapshotBytes: number;
snapshotMs: number;
};
type TargetDatabase = ReturnType<typeof resolveTargetDatabase>;
type CompactionProof = ReliabilityReport["maintenanceProof"]["compaction"];
@ -100,7 +91,7 @@ function fileSize(pathname: string): number {
}
}
function resolveTargetDatabase(options: CliOptions, env: NodeJS.ProcessEnv): TargetDatabase {
function resolveTargetDatabase(options: CliOptions, env: NodeJS.ProcessEnv) {
if (options.agentId) {
const database = openOpenClawAgentDatabase({ agentId: options.agentId, env });
const target = {
@ -123,14 +114,24 @@ function resolveTargetDatabase(options: CliOptions, env: NodeJS.ProcessEnv): Tar
}
function setupStressTable(databasePath: string): void {
const database = openNodeSqliteDatabase(databasePath);
try {
withReliabilityDatabase(databasePath, (database) => {
database.exec("PRAGMA journal_mode = WAL;");
database.exec("PRAGMA busy_timeout = 30000;");
database.exec(STRESS_TABLE_SQL);
database.exec("DROP TABLE IF EXISTS openclaw_reliability_compaction_bloat;");
database.prepare("DELETE FROM openclaw_reliability_entries").run();
database.prepare("DELETE FROM openclaw_reliability_sentinel").run();
});
}
function withReliabilityDatabase<T>(
databasePath: string,
operation: (database: DatabaseSync) => T,
options?: Parameters<typeof openNodeSqliteDatabase>[1],
): T {
const database = openNodeSqliteDatabase(databasePath, options);
try {
return operation(database);
} finally {
database.close();
}
@ -215,49 +216,48 @@ function verifyRestoredDatabase(params: {
rowsPerBatch: number;
uncommittedBatch: number | null;
}): ReliabilityStateProof {
const database = openNodeSqliteDatabase(params.path, {
readOnly: params.readOnly ?? true,
});
try {
database.exec("PRAGMA trusted_schema = OFF;");
assertPragmaOk(database, "quick_check");
assertPragmaOk(database, "integrity_check");
const foreignKeys = database.prepare("PRAGMA foreign_key_check;").all();
if (foreignKeys.length > 0) {
throw new Error(`foreign_key_check failed with ${foreignKeys.length} row(s)`);
}
if (params.identity.role === "global") {
assertOpenClawStateDatabaseForMaintenance(database, { pathname: params.path });
} else if (params.identity.role === "agent") {
assertOpenClawAgentDatabaseForMaintenance(database, {
agentId: params.identity.agentId,
pathname: params.path,
});
}
const sentinel = database
.prepare("SELECT payload FROM openclaw_reliability_sentinel WHERE id = 1")
.get() as { payload?: unknown } | undefined;
if (sentinel?.payload !== COMMITTED_WAL_SENTINEL) {
throw new Error("committed WAL sentinel is missing after restore");
}
const state = readReliabilityState(database, params.rowsPerBatch);
if (params.uncommittedBatch !== null) {
const held = database
.prepare("SELECT COUNT(*) AS rows FROM openclaw_reliability_entries WHERE batch = ?")
.get(params.uncommittedBatch) as { rows?: unknown };
if (Number(held.rows) !== 0) {
throw new Error(
`uncommitted transaction became visible after restore: batch=${params.uncommittedBatch} rows=${String(held.rows)}`,
);
return withReliabilityDatabase(
params.path,
(database) => {
database.exec("PRAGMA trusted_schema = OFF;");
assertPragmaOk(database, "quick_check");
assertPragmaOk(database, "integrity_check");
const foreignKeys = database.prepare("PRAGMA foreign_key_check;").all();
if (foreignKeys.length > 0) {
throw new Error(`foreign_key_check failed with ${foreignKeys.length} row(s)`);
}
}
if (params.expectedState) {
assertSameReliabilityState(state, params.expectedState, params.path);
}
return state;
} finally {
database.close();
}
if (params.identity.role === "global") {
assertOpenClawStateDatabaseForMaintenance(database, { pathname: params.path });
} else if (params.identity.role === "agent") {
assertOpenClawAgentDatabaseForMaintenance(database, {
agentId: params.identity.agentId,
pathname: params.path,
});
}
const sentinel = database
.prepare("SELECT payload FROM openclaw_reliability_sentinel WHERE id = 1")
.get() as { payload?: unknown } | undefined;
if (sentinel?.payload !== COMMITTED_WAL_SENTINEL) {
throw new Error("committed WAL sentinel is missing after restore");
}
const state = readReliabilityState(database, params.rowsPerBatch);
if (params.uncommittedBatch !== null) {
const held = database
.prepare("SELECT COUNT(*) AS rows FROM openclaw_reliability_entries WHERE batch = ?")
.get(params.uncommittedBatch) as { rows?: unknown };
if (Number(held.rows) !== 0) {
throw new Error(
`uncommitted transaction became visible after restore: batch=${params.uncommittedBatch} rows=${String(held.rows)}`,
);
}
}
if (params.expectedState) {
assertSameReliabilityState(state, params.expectedState, params.path);
}
return state;
},
{ readOnly: params.readOnly ?? true },
);
}
function writeCompactionBloatRange(
@ -266,9 +266,8 @@ function writeCompactionBloatRange(
lastId: number,
reset = false,
): void {
const database = openNodeSqliteDatabase(databasePath);
const payload = "b".repeat(COMPACTION_BLOAT_PAYLOAD_BYTES);
try {
withReliabilityDatabase(databasePath, (database) => {
const payload = "b".repeat(COMPACTION_BLOAT_PAYLOAD_BYTES);
database.exec("PRAGMA journal_mode = WAL;");
database.exec("PRAGMA wal_autocheckpoint = 0;");
database.exec("PRAGMA busy_timeout = 30000;");
@ -295,40 +294,34 @@ function writeCompactionBloatRange(
throw error;
}
database.exec("PRAGMA wal_checkpoint(TRUNCATE);");
} finally {
database.close();
}
});
}
function readCompactionPayload(databasePath: string): {
bytes: number;
idSum: number;
rows: number;
} {
const database = openNodeSqliteDatabase(databasePath, { readOnly: true });
try {
const row = database
.prepare(
`SELECT
function readCompactionPayload(databasePath: string): CompactionPayloadProof {
return withReliabilityDatabase(
databasePath,
(database) => {
const row = database
.prepare(
`SELECT
COUNT(*) AS rows,
COALESCE(SUM(id), 0) AS id_sum,
COALESCE(SUM(length(payload)), 0) AS bytes
FROM openclaw_reliability_compaction_bloat`,
)
.get() as { bytes?: unknown; id_sum?: unknown; rows?: unknown };
return {
bytes: sqliteSafeInteger(row.bytes, "compaction payload bytes"),
idSum: sqliteSafeInteger(row.id_sum, "compaction payload id sum"),
rows: sqliteSafeInteger(row.rows, "compaction payload rows"),
};
} finally {
database.close();
}
)
.get() as { bytes?: unknown; id_sum?: unknown; rows?: unknown };
return {
bytes: sqliteSafeInteger(row.bytes, "compaction payload bytes"),
idSum: sqliteSafeInteger(row.id_sum, "compaction payload id sum"),
rows: sqliteSafeInteger(row.rows, "compaction payload rows"),
};
},
{ readOnly: true },
);
}
function deleteCompactionBloat(databasePath: string, retainThroughId?: number): void {
const database = openNodeSqliteDatabase(databasePath);
try {
withReliabilityDatabase(databasePath, (database) => {
if (retainThroughId === undefined) {
database.exec("DELETE FROM openclaw_reliability_compaction_bloat;");
} else {
@ -337,29 +330,27 @@ function deleteCompactionBloat(databasePath: string, retainThroughId?: number):
.run(retainThroughId);
}
database.exec("PRAGMA wal_checkpoint(TRUNCATE);");
} finally {
database.close();
}
});
}
function readAutoVacuum(databasePath: string): number {
const database = openNodeSqliteDatabase(databasePath, { readOnly: true });
try {
const row = database.prepare("PRAGMA auto_vacuum;").get() as
| Record<string, unknown>
| undefined;
return sqliteSafeInteger(
row?.auto_vacuum ?? (row ? Object.values(row)[0] : undefined),
"auto_vacuum",
);
} finally {
database.close();
}
return withReliabilityDatabase(
databasePath,
(database) => {
const row = database.prepare("PRAGMA auto_vacuum;").get() as
| Record<string, unknown>
| undefined;
return sqliteSafeInteger(
row?.auto_vacuum ?? (row ? Object.values(row)[0] : undefined),
"auto_vacuum",
);
},
{ readOnly: true },
);
}
function prepareVacuumRollbackSentinel(databasePath: string): number {
const database = openNodeSqliteDatabase(databasePath);
try {
withReliabilityDatabase(databasePath, (database) => {
database.exec(`
PRAGMA busy_timeout = 30000;
PRAGMA wal_checkpoint(TRUNCATE);
@ -369,9 +360,7 @@ function prepareVacuumRollbackSentinel(databasePath: string): number {
PRAGMA journal_mode = WAL;
PRAGMA wal_checkpoint(TRUNCATE);
`);
} finally {
database.close();
}
});
const autoVacuum = readAutoVacuum(databasePath);
if (autoVacuum !== 0) {
throw new Error(`failed to prepare VACUUM rollback sentinel: auto_vacuum=${autoVacuum}`);
@ -448,9 +437,6 @@ async function compactTargetDatabase(
walBytesBefore: report.before.walSizeBytes,
});
}
if (target.identity.role !== "agent") {
throw new Error(`unsupported reliability target role: ${target.identity.role}`);
}
const autoVacuumBefore = readAutoVacuum(target.path);
const report = await compactDoctorSessionSqliteTarget(
{ agentId: target.identity.agentId, storePath: target.path },
@ -490,6 +476,15 @@ async function runMaintenanceRoundTrip(params: {
rowsPerBatch: params.rowsPerBatch,
uncommittedBatch: null,
});
const verifyState = (databasePath: string, readOnly = true) =>
verifyRestoredDatabase({
expectedState,
identity: params.target.identity,
path: databasePath,
readOnly,
rowsPerBatch: params.rowsPerBatch,
uncommittedBatch: null,
});
const expectedPayload = readCompactionPayload(params.target.path);
if (
expectedPayload.rows !== COMPACTION_BLOAT_ROWS ||
@ -516,14 +511,7 @@ async function runMaintenanceRoundTrip(params: {
sourcePath: params.target.path,
validationRootPath: params.validationRoot,
verifyPayload: readCompactionPayload,
verifyState: (databasePath) =>
verifyRestoredDatabase({
expectedState,
identity: params.target.identity,
path: databasePath,
rowsPerBatch: params.rowsPerBatch,
uncommittedBatch: null,
}),
verifyState,
}),
runRestoreInterruptionProof({
expectedPayload,
@ -534,14 +522,7 @@ async function runMaintenanceRoundTrip(params: {
snapshotPath: interruptedCopiedPath,
validationRootPath: params.validationRoot,
verifyPayload: readCompactionPayload,
verifyState: (databasePath) =>
verifyRestoredDatabase({
expectedState,
identity: params.target.identity,
path: databasePath,
rowsPerBatch: params.rowsPerBatch,
uncommittedBatch: null,
}),
verifyState,
}),
);
let vacuumInterruption: ReliabilityReport["maintenanceProof"]["vacuumInterruption"];
@ -563,15 +544,7 @@ async function runMaintenanceRoundTrip(params: {
expectedState,
readAutoVacuum: () => readAutoVacuum(params.target.path),
readPayload: () => readCompactionPayload(params.target.path),
recoverAndVerifyDatabase: () =>
verifyRestoredDatabase({
expectedState,
identity: params.target.identity,
path: params.target.path,
readOnly: false,
rowsPerBatch: params.rowsPerBatch,
uncommittedBatch: null,
}),
recoverAndVerifyDatabase: () => verifyState(params.target.path, false),
target: params.target,
});
} catch (error) {
@ -584,43 +557,24 @@ async function runMaintenanceRoundTrip(params: {
}
deleteCompactionBloat(params.target.path);
const compaction = await compactTargetDatabase(params.target, params.env);
verifyRestoredDatabase({
expectedState,
identity: params.target.identity,
path: params.target.path,
rowsPerBatch: params.rowsPerBatch,
uncommittedBatch: null,
});
verifyState(params.target.path);
const snapshotStarted = nowMs();
const snapshot = await params.repositoryProvider.create({
identity: params.target.identity,
path: params.target.path,
});
const snapshotMs = nowMs() - snapshotStarted;
const copiedPath = copySnapshotDirectory(snapshot.ref.path, params.syncedRepository);
const copiedRef = { path: copiedPath };
await params.syncedProvider.verify(copiedRef);
const restorePath = path.join(params.restoreRoot, "post-compact.sqlite");
const restoreStarted = nowMs();
await params.syncedProvider.restoreFresh(copiedRef, restorePath);
const restoreMs = nowMs() - restoreStarted;
const state = verifyRestoredDatabase({
const restored = await runSnapshotRoundTrip({
...params,
cleanupArtifacts: false,
expectedState,
identity: params.target.identity,
path: restorePath,
rowsPerBatch: params.rowsPerBatch,
restorePath: path.join(params.restoreRoot, "post-compact.sqlite"),
uncommittedBatch: null,
});
return {
bloatBytes: vacuumInterruption.payloadBeforeKill.bytes,
compaction,
postCompact: {
restoreMs: Number(restoreMs.toFixed(3)),
restoreMs: restored.restoreMs,
restoreVerified: true,
snapshotBytes: snapshot.manifest.artifact.sizeBytes,
snapshotMs: Number(snapshotMs.toFixed(3)),
state,
snapshotBytes: restored.snapshotBytes,
snapshotMs: restored.snapshotMs,
state: restored.state,
},
repositoryInterruption,
restoreInterruption,
@ -628,17 +582,17 @@ async function runMaintenanceRoundTrip(params: {
};
}
async function runSnapshotIteration(params: {
async function runSnapshotRoundTrip(params: {
cleanupArtifacts: boolean;
iteration: number;
expectedState?: ReliabilityStateProof;
repositoryProvider: ReturnType<typeof createLocalSqliteSnapshotProvider>;
restoreRoot: string;
restorePath: string;
rowsPerBatch: number;
syncedProvider: ReturnType<typeof createLocalSqliteSnapshotProvider>;
syncedRepository: string;
target: TargetDatabase;
uncommittedBatch: number | null;
}): Promise<IterationMetric> {
}) {
const snapshotStarted = nowMs();
const snapshot = await params.repositoryProvider.create({
identity: params.target.identity,
@ -648,25 +602,26 @@ async function runSnapshotIteration(params: {
const copiedPath = copySnapshotDirectory(snapshot.ref.path, params.syncedRepository);
const copiedRef = { path: copiedPath };
await params.syncedProvider.verify(copiedRef);
const restorePath = path.join(params.restoreRoot, `restore-${params.iteration}.sqlite`);
const restoreStarted = nowMs();
await params.syncedProvider.restoreFresh(copiedRef, restorePath);
await params.syncedProvider.restoreFresh(copiedRef, params.restorePath);
const restoreMs = nowMs() - restoreStarted;
verifyRestoredDatabase({
const state = verifyRestoredDatabase({
expectedState: params.expectedState,
identity: params.target.identity,
path: restorePath,
path: params.restorePath,
rowsPerBatch: params.rowsPerBatch,
uncommittedBatch: params.uncommittedBatch,
});
if (params.cleanupArtifacts) {
fs.rmSync(snapshot.ref.path, { force: true, recursive: true });
fs.rmSync(copiedPath, { force: true, recursive: true });
fs.rmSync(restorePath, { force: true });
fs.rmSync(params.restorePath, { force: true });
}
return {
restoreMs: Number(restoreMs.toFixed(3)),
snapshotBytes: snapshot.manifest.artifact.sizeBytes,
snapshotMs: Number(snapshotMs.toFixed(3)),
state,
};
}
@ -688,6 +643,18 @@ export async function runReliabilityStress(options: CliOptions): Promise<Reliabi
fs.mkdirSync(validationRoot, { recursive: true, mode: 0o700 });
const target = resolveTargetDatabase(options, env);
setupStressTable(target.path);
const verifyDatabase = (
databasePath: string,
expectedState?: ReliabilityStateProof,
uncommittedBatch: number | null = null,
) =>
verifyRestoredDatabase({
expectedState,
identity: target.identity,
path: databasePath,
rowsPerBatch: profile.rowsPerBatch,
uncommittedBatch,
});
const repositoryProvider = createLocalSqliteSnapshotProvider({
repositoryPath: repository,
validationRootPath: validationRoot,
@ -703,15 +670,10 @@ export async function runReliabilityStress(options: CliOptions): Promise<Reliabi
const partial = await waitForWriterMessage(writer, "partial", () => {
writer?.child.send?.({ kind: "hold-partial" });
});
const stateBeforeKill = verifyRestoredDatabase({
identity: target.identity,
path: target.path,
rowsPerBatch: profile.rowsPerBatch,
uncommittedBatch: partial.batch,
});
const stateBeforeKill = verifyDatabase(target.path, undefined, partial.batch);
let crashExit: ReliabilityWorkerExit | undefined;
let stateAfterRecovery: ReliabilityStateProof | undefined;
const metrics: IterationMetric[] = [];
const metrics: Awaited<ReturnType<typeof runSnapshotRoundTrip>>[] = [];
for (let iteration = 0; iteration < profile.iterations; iteration += 1) {
const iterationProof = await monitorSqliteWalDuring({
maxWalBytes: profile.maxWalBytes,
@ -722,12 +684,11 @@ export async function runReliabilityStress(options: CliOptions): Promise<Reliabi
// The operation still fails on the recorded peak if the writer exited first.
}
},
operation: async () =>
await runSnapshotIteration({
operation: () =>
runSnapshotRoundTrip({
cleanupArtifacts: cleanupIterationArtifacts,
iteration,
repositoryProvider,
restoreRoot,
restorePath: path.join(restoreRoot, `restore-${iteration}.sqlite`),
rowsPerBatch: profile.rowsPerBatch,
syncedProvider,
syncedRepository,
@ -740,13 +701,7 @@ export async function runReliabilityStress(options: CliOptions): Promise<Reliabi
peakWalBytes = Math.max(peakWalBytes, iterationProof.peakWalBytes);
if (iteration === 0) {
crashExit = await crashWriter(writer);
stateAfterRecovery = verifyRestoredDatabase({
expectedState: stateBeforeKill,
identity: target.identity,
path: target.path,
rowsPerBatch: profile.rowsPerBatch,
uncommittedBatch: partial.batch,
});
stateAfterRecovery = verifyDatabase(target.path, stateBeforeKill, partial.batch);
writer = startWriter(target.path, profile);
await waitForWriterMessage(writer, "ready");
}
@ -755,26 +710,14 @@ export async function runReliabilityStress(options: CliOptions): Promise<Reliabi
throw new Error("SQLite reliability stress did not execute its crash recovery proof.");
}
const writerResult = await stopWriter(writer);
const stableState = verifyRestoredDatabase({
identity: target.identity,
path: target.path,
rowsPerBatch: profile.rowsPerBatch,
uncommittedBatch: null,
});
const stableState = verifyDatabase(target.path);
const [publicationInterruptionProof, indexRepairInterruptionProof] =
await runProofsConcurrently(
runPublicationInterruptionProof({
expectedState: stableState,
scratchPath: path.join(runScratch, "publication-interruptions"),
sourcePath: target.path,
verifyDatabase: (databasePath) =>
verifyRestoredDatabase({
expectedState: stableState,
identity: target.identity,
path: databasePath,
rowsPerBatch: profile.rowsPerBatch,
uncommittedBatch: null,
}),
verifyDatabase: (databasePath) => verifyDatabase(databasePath, stableState),
}),
runIndexRepairInterruptionProof(path.join(runScratch, "index-repair-interruptions")),
);
@ -789,6 +732,8 @@ export async function runReliabilityStress(options: CliOptions): Promise<Reliabi
validationRoot,
});
const snapshotBytes = metrics.map((metric) => metric.snapshotBytes);
const restoreTimes = metrics.map((metric) => metric.restoreMs);
const snapshotTimes = metrics.map((metric) => metric.snapshotMs);
return {
arch: process.arch,
concurrentRestoresVerified: metrics.length,
@ -823,22 +768,10 @@ export async function runReliabilityStress(options: CliOptions): Promise<Reliabi
},
target: target.label,
timingsMs: {
restoreP50: percentile(
metrics.map((metric) => metric.restoreMs),
50,
),
restoreP95: percentile(
metrics.map((metric) => metric.restoreMs),
95,
),
snapshotP50: percentile(
metrics.map((metric) => metric.snapshotMs),
50,
),
snapshotP95: percentile(
metrics.map((metric) => metric.snapshotMs),
95,
),
restoreP50: percentile(restoreTimes, 50),
restoreP95: percentile(restoreTimes, 95),
snapshotP50: percentile(snapshotTimes, 50),
snapshotP95: percentile(snapshotTimes, 95),
total: Number((nowMs() - started).toFixed(3)),
},
transactionProof: {

View file

@ -15,40 +15,24 @@ import {
} from "./sqlite-reliability-process.js";
import { resolveForwardedNodeCompilerArgs } from "./tsx-cli-shim.mjs";
type WriterReadyMessage = {
kind: "ready";
};
type WriterPartialMessage = {
batch: number;
batchesCommitted: number;
kind: "partial";
rows: number;
rowsCommitted: number;
};
type WriterReleasedMessage = {
batch: number;
kind: "released";
};
type WriterResultMessage = {
batchesCommitted: number;
kind: "result";
rowsCommitted: number;
};
type WriterErrorMessage = {
error: string;
kind: "error";
};
type WriterMessage =
| WriterReadyMessage
| WriterPartialMessage
| WriterReleasedMessage
| { kind: "ready" }
| {
batch: number;
batchesCommitted: number;
kind: "partial";
rows: number;
rowsCommitted: number;
}
| { batch: number; kind: "released" }
| WriterResultMessage
| WriterErrorMessage;
| { error: string; kind: "error" };
export type WriterHandle = {
child: ChildProcess;
@ -80,9 +64,7 @@ export function startWriter(databasePath: string, profile: ProfileConfig): Write
);
const stderr: string[] = [];
child.stderr?.setEncoding("utf8");
child.stderr?.on("data", (chunk: string) => {
stderr.push(chunk);
});
child.stderr?.on("data", (chunk: string) => stderr.push(chunk));
return { child, stderr, stopped: false };
}
@ -145,15 +127,7 @@ export async function terminateWriter(writer: WriterHandle): Promise<void> {
writer.stopped = true;
}
function parseWriterChildArgs(argv: string[]): {
databasePath: string;
payloadBytes: number;
retainedBatches: number;
rowsPerBatch: number;
walAutoCheckpointPages: number;
walSizeLimitBytes: number;
writerPauseMs: number;
} {
function parseWriterChildArgs(argv: string[]) {
const [
databasePath,
rowsRaw,
@ -173,18 +147,16 @@ function parseWriterChildArgs(argv: string[]): {
if (
!databasePath ||
extra.length > 0 ||
!Number.isSafeInteger(rowsPerBatch) ||
rowsPerBatch < 2 ||
!Number.isSafeInteger(payloadBytes) ||
payloadBytes < 1 ||
!Number.isSafeInteger(retainedBatches) ||
retainedBatches < 1 ||
!Number.isSafeInteger(walAutoCheckpointPages) ||
walAutoCheckpointPages < 1 ||
!Number.isSafeInteger(walSizeLimitBytes) ||
walSizeLimitBytes < 1 ||
!Number.isSafeInteger(writerPauseMs) ||
writerPauseMs < 0
(
[
[rowsPerBatch, 2],
[payloadBytes, 1],
[retainedBatches, 1],
[walAutoCheckpointPages, 1],
[walSizeLimitBytes, 1],
[writerPauseMs, 0],
] as const
).some(([value, minimum]) => !Number.isSafeInteger(value) || value < minimum)
) {
throw new Error("invalid SQLite reliability writer arguments");
}
@ -202,7 +174,6 @@ function parseWriterChildArgs(argv: string[]): {
async function runWriterChild(argv: string[]): Promise<void> {
const options = parseWriterChildArgs(argv);
const database = openNodeSqliteDatabase(options.databasePath);
let nextBatch = 0;
let batchesCommitted = 0;
let rowsCommitted = 0;
let stopping = false;
@ -229,7 +200,7 @@ async function runWriterChild(argv: string[]): Promise<void> {
"SELECT COALESCE(MAX(batch), -1) + 1 AS next_batch FROM openclaw_reliability_entries",
)
.get() as { next_batch?: number | bigint };
nextBatch = Number(next.next_batch ?? 0);
let nextBatch = Number(next.next_batch ?? 0);
const insert = database.prepare(
"INSERT INTO openclaw_reliability_entries (batch, ordinal, payload) VALUES (?, ?, ?)",
);
@ -284,7 +255,7 @@ async function runWriterChild(argv: string[]): Promise<void> {
};
commitBatch(true);
sendMessage({ kind: "ready" } satisfies WriterReadyMessage);
sendMessage({ kind: "ready" });
while (!shouldStop()) {
if (holdPartial) {
holdPartial = false;
@ -301,7 +272,7 @@ async function runWriterChild(argv: string[]): Promise<void> {
kind: "partial",
rows: heldRows,
rowsCommitted,
} satisfies WriterPartialMessage);
});
while (!shouldReleasePartial()) {
await delay(1);
}
@ -317,7 +288,7 @@ async function runWriterChild(argv: string[]): Promise<void> {
database.exec("ROLLBACK;");
}
releasePartial = undefined;
sendMessage({ batch: heldBatch, kind: "released" } satisfies WriterReleasedMessage);
sendMessage({ batch: heldBatch, kind: "released" });
} catch (error) {
database.exec("ROLLBACK;");
throw error;
@ -335,12 +306,12 @@ async function runWriterChild(argv: string[]): Promise<void> {
batchesCommitted,
kind: "result",
rowsCommitted,
} satisfies WriterResultMessage);
});
} catch (error) {
sendMessage({
error: error instanceof Error ? (error.stack ?? error.message) : String(error),
kind: "error",
} satisfies WriterErrorMessage);
});
process.exitCode = 1;
} finally {
database.close();

View file

@ -26,11 +26,8 @@ function normalizeStatus(value) {
const normalized = String(value ?? "")
.trim()
.toLowerCase();
if (normalized === "pass") {
return "pass";
}
if (normalized === "fail") {
return "fail";
if (normalized === "pass" || normalized === "fail") {
return normalized;
}
throw new Error(`Unsupported web UI chat proof status: ${value}`);
}
@ -66,7 +63,7 @@ function buildWebUiChatEvidenceManifest({ candidateRef, candidateSha, status, ca
status,
fixed: passed,
},
outcome: passed ? "pass" : "fail",
outcome: status,
pass: passed,
},
artifacts: [
@ -91,18 +88,10 @@ function buildWebUiChatEvidenceManifest({ candidateRef, candidateSha, status, ca
required: passed && complete,
}),
]),
artifactEntry({
kind: "metadata",
label: "Control UI web chat Vitest log",
path: "vitest.log",
required: false,
}),
artifactEntry({
kind: "metadata",
label: "Control UI web chat setup log",
path: "setup.log",
required: false,
}),
...[
{ label: "Control UI web chat Vitest log", path: "vitest.log" },
{ label: "Control UI web chat setup log", path: "setup.log" },
].map((artifact) => artifactEntry({ ...artifact, kind: "metadata", required: false })),
{
kind: "report",
lane: "run",

View file

@ -18,16 +18,20 @@ function unwrapExpression(node) {
return current;
}
function repositoryPath(context) {
const filename = context.physicalFilename.replaceAll("\\", "/");
const cwd = context.cwd.replaceAll("\\", "/");
return filename.startsWith(`${cwd}/`) ? filename.slice(cwd.length + 1) : filename;
}
function restrictedCallRule({ allowedFiles = [], message, objects, property, roots }) {
return {
create(context) {
const filename = context.physicalFilename.replaceAll("\\", "/");
const cwd = context.cwd.replaceAll("\\", "/");
const repoPath = filename.startsWith(`${cwd}/`) ? filename.slice(cwd.length + 1) : filename;
const repoPath = repositoryPath(context);
if (
!filename.endsWith(".ts") ||
!repoPath.endsWith(".ts") ||
!roots.some((root) => pathMatchesTypeAssertionRoot(repoPath, root)) ||
TYPE_ASSERTION_TEST_FILE_SUFFIXES.some((suffix) => filename.endsWith(suffix)) ||
TYPE_ASSERTION_TEST_FILE_SUFFIXES.some((suffix) => repoPath.endsWith(suffix)) ||
allowedFiles.includes(repoPath)
) {
return {};
@ -108,9 +112,7 @@ function noChainedTypeAssertionsRule({ excludedRoots = [], roots }) {
},
},
create(context) {
const filename = context.physicalFilename.replaceAll("\\", "/");
const cwd = context.cwd.replaceAll("\\", "/");
const repoPath = filename.startsWith(`${cwd}/`) ? filename.slice(cwd.length + 1) : filename;
const repoPath = repositoryPath(context);
if (
!roots.some((root) => pathMatchesTypeAssertionRoot(repoPath, root)) ||
excludedRoots.some((root) => pathMatchesTypeAssertionRoot(repoPath, root)) ||
@ -237,7 +239,7 @@ function assertedExpression(node) {
function assertedIdentifier(node) {
let expression = assertedExpression(node);
while (expression.type === "TSAsExpression" || expression.type === "TSTypeAssertion") {
while (isTypeAssertionExpression(expression)) {
expression = assertedExpression(expression);
}
return expression.type === "Identifier" ? expression : null;
@ -248,17 +250,12 @@ function isNestedAssertion(node) {
while (parent?.type === "ParenthesizedExpression") {
parent = parent.parent;
}
return (
(parent?.type === "TSAsExpression" || parent?.type === "TSTypeAssertion") &&
assertedExpression(parent) === node
);
return parent && isTypeAssertionExpression(parent) && assertedExpression(parent) === node;
}
function assertionFromExpression(expression) {
const unwrapped = unwrapExpressionParentheses(expression);
return unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion"
? unwrapped
: null;
return isTypeAssertionExpression(unwrapped) ? unwrapped : null;
}
function normalizedTypeText(sourceText, type) {
@ -354,7 +351,7 @@ function variableDeclarator(variable) {
function knownValueEvidence(expression, scopes, boundary, visitedVariables) {
const unwrapped = unwrapExpressionParentheses(expression);
if (unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion") {
if (isTypeAssertionExpression(unwrapped)) {
if (broadTypeKind(unwrapped.typeAnnotation) !== null) {
return null;
}
@ -511,9 +508,7 @@ function noWidenThenAssertRule({ roots }) {
},
},
create(context) {
const filename = context.physicalFilename.replaceAll("\\", "/");
const cwd = context.cwd.replaceAll("\\", "/");
const repoPath = filename.startsWith(`${cwd}/`) ? filename.slice(cwd.length + 1) : filename;
const repoPath = repositoryPath(context);
if (!roots.some((root) => repoPath === root || repoPath.startsWith(`${root}/`))) {
return {};
}

View file

@ -121,18 +121,18 @@ resolve_user_home() {
generate_token_hex_32() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex 32
return 0
return $?
fi
if command -v python3 >/dev/null 2>&1; then
python3 - <<'PY'
import secrets
print(secrets.token_hex(32))
PY
return 0
return $?
fi
if command -v od >/dev/null 2>&1; then
od -An -N32 -tx1 /dev/urandom | tr -d " \n"
return 0
return $?
fi
echo "Missing dependency: need openssl or python3 (or od) to generate OPENCLAW_GATEWAY_TOKEN." >&2
exit 1

View file

@ -140,21 +140,19 @@ export function parseArgs(argv: string[]): {
break;
}
case "--concurrency":
options.concurrency = parsePositiveInt(args[index + 1] ?? "", arg);
index += 1;
break;
case "--timeout-ms":
options.timeoutMs = parsePositiveInt(args[index + 1] ?? "", arg);
index += 1;
break;
case "--combined-timeout-ms":
options.combinedTimeoutMs = parsePositiveInt(args[index + 1] ?? "", arg);
index += 1;
break;
case "--top":
options.top = parsePositiveInt(args[index + 1] ?? "", arg);
case "--top": {
const key = {
"--concurrency": "concurrency",
"--timeout-ms": "timeoutMs",
"--combined-timeout-ms": "combinedTimeoutMs",
"--top": "top",
} as const;
options[key[arg]] = parsePositiveInt(args[index + 1] ?? "", arg);
index += 1;
break;
}
case "--json": {
const next = args[index + 1];
if (!next || next.startsWith("-")) {
@ -680,10 +678,7 @@ async function main(): Promise<void> {
hookPath,
name: "combined",
completionKind: "imports",
body: buildImportBody(
selectedEntries.map((entry) => entry.file),
"IMPORTED_ALL",
),
body: buildImportBody(entryFiles, "IMPORTED_ALL"),
timeoutMs: options.combinedTimeoutMs,
});
@ -841,26 +836,19 @@ async function main(): Promise<void> {
};
const failures = [];
if (report.baseline.status !== "ok") {
failures.push(`baseline import ${report.baseline.status}: ${report.baseline.error}`);
}
if (report.baseline.maxRssMb === null) {
failures.push("baseline import did not report RSS");
}
if (report.combined !== null) {
if (report.combined.status !== "ok") {
failures.push(`combined import ${report.combined.status}: ${report.combined.error}`);
for (const [name, result] of [
["baseline", report.baseline],
["combined", report.combined],
...report.results.map((entry) => [entry.dir, entry] as const),
] as const) {
if (result === null) {
continue;
}
if (report.combined.maxRssMb === null) {
failures.push("combined import did not report RSS");
}
}
for (const result of report.results) {
if (result.status !== "ok") {
failures.push(`${result.dir} import ${result.status}: ${result.error}`);
failures.push(`${name} import ${result.status}: ${result.error}`);
}
if (result.maxRssMb === null) {
failures.push(`${result.dir} import did not report RSS`);
failures.push(`${name} import did not report RSS`);
}
}
if (failures.length > 0) {

View file

@ -234,20 +234,8 @@ function classifyFile(relativePath: string): string {
const nodeModulesIndex = parts.indexOf("node_modules");
return `node_modules/${packageNameFromNodeModule(parts, nodeModulesIndex)}`;
}
if (first === "extensions") {
return `extensions/${parts[1] ?? "(root)"}`;
}
if (first === "packages") {
return `packages/${parts[1] ?? "(root)"}`;
}
if (first === "src") {
return `src/${parts[1] ?? "(root)"}`;
}
if (first === "ui") {
return `ui/${parts[1] ?? "(root)"}`;
}
if (first === "test") {
return `test/${parts[1] ?? "(root)"}`;
if (first && ["extensions", "packages", "src", "ui", "test"].includes(first)) {
return `${first}/${parts[1] ?? "(root)"}`;
}
if (first?.startsWith("/") || (first !== undefined && /^[A-Za-z]:/u.test(first))) {
return "(external)";
@ -474,19 +462,13 @@ async function main(argv: string[]): Promise<void> {
text: path.relative(repoRoot, textPath),
};
fs.writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`);
fs.writeFileSync(textPath, renderTextReport(report));
fs.writeFileSync(
path.join(options.outDir, "latest.json"),
`${JSON.stringify(report, null, 2)}\n`,
);
fs.writeFileSync(path.join(options.outDir, "latest.md"), renderTextReport(report));
if (options.json) {
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
} else {
process.stdout.write(renderTextReport(report));
}
const json = `${JSON.stringify(report, null, 2)}\n`;
const text = renderTextReport(report);
fs.writeFileSync(jsonPath, json);
fs.writeFileSync(textPath, text);
fs.writeFileSync(path.join(options.outDir, "latest.json"), json);
fs.writeFileSync(path.join(options.outDir, "latest.md"), text);
process.stdout.write(options.json ? json : text);
}
try {

View file

@ -1,4 +1,3 @@
// Protocol Gen Kotlin script supports OpenClaw repository automation.
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@ -9,18 +8,7 @@ import {
} from "../packages/gateway-protocol/src/version.js";
import { listCoreGatewayMethodNames } from "../src/gateway/methods/core-method-policy.js";
import { extractGatewayEventNames } from "./check-protocol-event-coverage.mts";
type JsonSchema = {
type?: string | string[];
const?: boolean | number | string | null;
properties?: Record<string, JsonSchema>;
required?: string[];
items?: JsonSchema;
enum?: Array<boolean | number | string | null>;
patternProperties?: Record<string, JsonSchema>;
anyOf?: JsonSchema[];
oneOf?: JsonSchema[];
};
import { type JsonSchema, schemaSignature } from "./lib/protocol-codegen-schema.js";
type EnumSpec = {
name: string;
@ -38,7 +26,7 @@ const constantsOutputPath = path.join(
repoRoot,
"apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt",
);
const protocolSchemas = ProtocolSchemas as unknown as Record<string, JsonSchema>;
const protocolSchemas = ProtocolSchemas as Record<string, JsonSchema>;
const schemaNames = new Map<string, string>([
["ErrorShape", "GatewayProtocolError"],
@ -192,25 +180,6 @@ function lowerCamel(value: string): string {
return name[0]!.toLowerCase() + name.slice(1);
}
function stableJson(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(stableJson);
}
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
return Object.fromEntries(
Object.keys(record)
.toSorted()
.map((key) => [key, stableJson(record[key])]),
);
}
return value;
}
function schemaSignature(schema: JsonSchema): string {
return JSON.stringify(stableJson(schema));
}
function literalValue(schema: JsonSchema): boolean | number | string | null | undefined {
if ("const" in schema) {
return schema.const;
@ -222,9 +191,6 @@ function kotlinLiteral(value: boolean | number | string | null): string {
if (typeof value === "string") {
return JSON.stringify(value);
}
if (value === null) {
return "null";
}
return String(value);
}
@ -331,28 +297,26 @@ function emitWireModels(): string[] {
}
const required = new Set(schema.required ?? []);
const variant = unionVariants.get(schemaSignature(schema));
const properties = Object.entries(schema.properties)
const fields = Object.entries(schema.properties)
.filter(([wireName]) => wireName !== variant?.discriminator)
.map(([wireName, propertySchema]) => {
.flatMap(([wireName, propertySchema]) => {
const propertyName = lowerCamel(wireName);
const type = kotlinType(propertySchema, `${name}${upperCamel(wireName)}`);
const literal = literalValue(propertySchema);
const optional = !required.has(wireName);
const useLiteralDefault =
literal !== undefined && (optional || typeof literal !== "boolean");
return {
annotation:
propertyName === wireName ? [] : [` @SerialName(${JSON.stringify(wireName)})`],
declaration: ` val ${propertyName}: ${type}${optional ? "?" : ""}${
const lines = [
` val ${propertyName}: ${type}${optional ? "?" : ""}${
useLiteralDefault ? ` = ${kotlinLiteral(literal)}` : optional ? " = null" : ""
},`,
};
];
if (propertyName !== wireName) {
lines.unshift(` @SerialName(${JSON.stringify(wireName)})`);
}
return lines;
});
const fields: string[] = [];
for (const property of properties) {
fields.push(...property.annotation, property.declaration);
}
if (properties.length === 0 && variant) {
if (fields.length === 0 && variant) {
return [
`@SerialName(${JSON.stringify(variant.literal)})`,
"@Serializable",

View file

@ -1,4 +1,3 @@
// Protocol Gen Swift script supports OpenClaw repository automation.
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@ -10,20 +9,7 @@ import {
PROTOCOL_VERSION,
} from "../packages/gateway-protocol/src/version.js";
import { writeGeneratedOutput } from "./lib/generated-output-utils.mts";
type JsonSchema = {
"~openclawClosedObjectIdentity"?: symbol;
type?: string | string[];
const?: boolean | number | string | null;
properties?: Record<string, JsonSchema>;
required?: string[];
items?: JsonSchema;
enum?: Array<string | null>;
patternProperties?: Record<string, JsonSchema>;
anyOf?: JsonSchema[];
oneOf?: JsonSchema[];
additionalProperties?: boolean | JsonSchema;
};
import { type JsonSchema, schemaSignature } from "./lib/protocol-codegen-schema.js";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "..");
@ -172,27 +158,8 @@ function resolveSchemaObjectAliases(
return result;
}
function stableJson(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(stableJson);
}
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
return Object.fromEntries(
Object.keys(record)
.toSorted()
.map((key) => [key, stableJson(record[key])]),
);
}
return value;
}
function schemaSignature(schema: JsonSchema): string {
return JSON.stringify(stableJson(schema));
}
function registerNamedSchema(name: string, schema: JsonSchema, objectName: string): void {
schemaNameByObject.set(schema as object, objectName);
schemaNameByObject.set(schema, objectName);
const signature = schemaSignature(schema);
registerUniqueName(schemaNameBySignature, signature, name);
const identity = schema["~openclawClosedObjectIdentity"];
@ -217,7 +184,7 @@ function namedSchema(
identity?: symbol,
): string | undefined {
return (
schemaNameByObject.get(schema as object) ??
schemaNameByObject.get(schema) ??
(identity
? schemaNamesByIdentity.get(identity)?.get(schemaSignature(schema))
: allowStructuralFallback
@ -236,7 +203,7 @@ function swiftType(schema: JsonSchema, required: boolean, allowStructuralNamed =
? {
...schema,
type: schemaTypes.find((type) => type !== "null"),
enum: schema.enum?.filter((value): value is string => value !== null),
enum: schema.enum?.filter((value) => value !== null),
anyOf: schema.anyOf?.filter((branch) => branch.type !== "null"),
oneOf: schema.oneOf?.filter((branch) => branch.type !== "null"),
}
@ -333,60 +300,49 @@ function emitStruct(
): string {
const props = schema.properties ?? {};
const required = new Set(schema.required ?? []);
const literalProps = Object.entries(props)
.map(([key, propSchema]) => ({
key,
propSchema,
literal: literalSchemaValue(propSchema),
}))
.filter(
(
entry,
): entry is {
key: string;
propSchema: JsonSchema;
literal: boolean | number | string | null;
} => entry.literal !== undefined,
);
const lines: string[] = [];
const literalPropByKey = new Map(
Object.entries(props).flatMap(([key, propSchema]) => {
const literal = literalSchemaValue(propSchema);
return literal === undefined ? [] : [[key, literal] as const];
}),
);
if (Object.keys(props).length === 0) {
return `public struct ${name}: Codable, Sendable {}\n`;
}
if (strictLiterals && literalProps.length > 0) {
const literalPropByKey = new Map(literalProps.map((entry) => [entry.key, entry.literal]));
lines.push(`public struct ${name}: Codable, Sendable {`);
const codingKeys: string[] = [];
for (const [key, propSchema] of Object.entries(props)) {
const propName = safeName(key);
const propType = swiftType(propSchema, required.has(key), true);
lines.push(` public let ${propName}: ${propType}`);
if (propName !== key) {
codingKeys.push(` case ${propName} = "${key}"`);
} else {
codingKeys.push(` case ${propName}`);
}
const strict = strictLiterals && literalPropByKey.size > 0;
const properties = Object.entries(props).map(([key, propertySchema]) => ({
key,
schema: propertySchema,
name: strict ? safeName(key) : swiftStoredPropertyName(name, key),
required: required.has(key),
}));
const lines = [`public struct ${name}: Codable, Sendable {`];
const codingKeys = properties.map((property) =>
property.name === property.key
? ` case ${property.name}`
: ` case ${property.name} = "${property.key}"`,
);
for (const property of properties) {
lines.push(
` public let ${property.name}: ${swiftType(property.schema, property.required, true)}`,
);
if (!strict) {
lines.push(...swiftCompatibilityPropertyLines(name, property.key));
}
const initializerParams = Object.entries(props)
.filter(([key]) => !literalPropByKey.has(key) || !required.has(key))
.map(([key, prop]) => {
const propName = safeName(key);
const req = required.has(key);
return ` ${swiftInitializerParam({
name: propName,
schema: prop,
required: req,
})}`;
});
}
if (strict) {
const initializerParams = properties
.filter((property) => !literalPropByKey.has(property.key) || !property.required)
.map((property) => ` ${swiftInitializerParam(property)}`);
const initializerDeclaration =
initializerParams.length > 0
? `\n public init(\n${initializerParams.join(",\n")}\n )\n {\n`
: "\n public init()\n {\n";
lines.push(
initializerDeclaration +
Object.entries(props)
.map(([key]) => {
const propName = safeName(key);
if (literalPropByKey.has(key) && required.has(key)) {
properties
.map(({ key, name: propName, required: isRequired }) => {
if (literalPropByKey.has(key) && isRequired) {
return ` self.${propName} = ${swiftLiteralSource(literalPropByKey.get(key)!)}`;
}
return ` self.${propName} = ${propName}`;
@ -423,10 +379,8 @@ function emitStruct(
const absent = required.has(key) ? "" : `${decodedName} == nil || `;
return ` let ${decodedName} = ${decodedValue}\n guard ${absent}${decodedName} == ${swiftLiteralSource(literal)} else {\n throw DecodingError.dataCorruptedError(\n forKey: .${propName},\n in: container,\n debugDescription: "Expected ${key} to equal ${String(literal)}"\n )\n }\n self.${propName} = ${required.has(key) ? swiftLiteralSource(literal) : decodedName}`;
}
if (required.has(key)) {
return ` self.${propName} = try container.decode(${swiftType(propSchema, true, true)}.self, forKey: .${propName})`;
}
return ` self.${propName} = try container.decodeIfPresent(${swiftType(propSchema, true, true)}.self, forKey: .${propName})`;
const decode = required.has(key) ? "decode" : "decodeIfPresent";
return ` self.${propName} = try container.${decode}(${swiftType(propSchema, true, true)}.self, forKey: .${propName})`;
})
.join("\n") +
"\n }\n\n" +
@ -454,48 +408,23 @@ function emitStruct(
lines.push("");
return lines.join("\n");
}
lines.push(`public struct ${name}: Codable, Sendable {`);
const codingKeys: string[] = [];
let needsCodingKeys = false;
for (const [key, propSchema] of Object.entries(props)) {
const propName = swiftStoredPropertyName(name, key);
const propType = swiftType(propSchema, required.has(key), true);
lines.push(` public let ${propName}: ${propType}`);
lines.push(...swiftCompatibilityPropertyLines(name, key));
if (propName !== key) {
needsCodingKeys = true;
codingKeys.push(` case ${propName} = "${key}"`);
} else {
codingKeys.push(` case ${propName}`);
}
}
const needsCodingKeys = properties.some((property) => property.name !== property.key);
const customCodable = emitStructCustomCodable(name, props, required);
lines.push(
"\n public init(\n" +
Object.entries(props)
.map(([key, prop]) => {
const propName = swiftStoredPropertyName(name, key);
const req = required.has(key);
if (name === "AgentsUpdateParams" && key === "model") {
properties
.map((property) => {
if (name === "AgentsUpdateParams" && property.key === "model") {
// Keep the raw nullable value explicit so the source-compatible initializer stays
// unambiguous when callers omit model.
return " modelvalue: AnyCodable?";
}
return ` ${swiftInitializerParam({
name: propName,
schema: prop,
required: req,
})}`;
return ` ${swiftInitializerParam(property)}`;
})
.join(",\n") +
")\n" +
" {\n" +
Object.entries(props)
.map(([key]) => {
const propName = swiftStoredPropertyName(name, key);
return ` self.${propName} = ${propName}`;
})
.join("\n") +
properties.map((property) => ` self.${property.name} = ${property.name}`).join("\n") +
"\n }" +
emitStructCompatibilityInitializer(name, props, required) +
(needsCodingKeys || customCodable.length > 0
@ -546,17 +475,13 @@ function emitStructCustomCodable(
// preserves the Gateway patch distinction between clearing and omitting the model.
return ` self.${propName} = container.contains(.${propName})\n ? try container.decode(AnyCodable.self, forKey: .${propName})\n : nil`;
}
if (required.has(key)) {
return ` self.${propName} = try container.decode(${swiftType(propSchema, true, true)}.self, forKey: .${propName})`;
}
return ` self.${propName} = try container.decodeIfPresent(${swiftType(propSchema, true, true)}.self, forKey: .${propName})`;
const decode = required.has(key) ? "decode" : "decodeIfPresent";
return ` self.${propName} = try container.${decode}(${swiftType(propSchema, true, true)}.self, forKey: .${propName})`;
});
const encodedProperties = Object.keys(props).map((key) => {
const propName = swiftStoredPropertyName(name, key);
if (required.has(key)) {
return ` try container.encode(${propName}, forKey: .${propName})`;
}
return ` try container.encodeIfPresent(${propName}, forKey: .${propName})`;
const encode = required.has(key) ? "encode" : "encodeIfPresent";
return ` try container.${encode}(${propName}, forKey: .${propName})`;
});
return (
"\n\n public init(from decoder: Decoder) throws {\n" +
@ -575,66 +500,43 @@ function emitStructCompatibilityInitializer(
props: Record<string, JsonSchema>,
required: Set<string>,
): string {
if (name === "AgentsUpdateParams" && props.model) {
const initializerParams = Object.entries(props).map(([key, prop]) => {
const propName = swiftStoredPropertyName(name, key);
if (key === "model") {
return " model: String? = nil";
}
return ` ${swiftInitializerParam({
name: propName,
schema: prop,
required: required.has(key),
})}`;
});
const delegatedArgs = Object.keys(props).map((key) => {
const propName = swiftStoredPropertyName(name, key);
if (key === "model") {
return " modelvalue: model.map { AnyCodable($0) }";
}
return ` ${propName}: ${propName}`;
});
return (
"\n\n public init(\n" +
initializerParams.join(",\n") +
")\n" +
" {\n" +
" self.init(\n" +
delegatedArgs.join(",\n") +
")\n" +
" }"
);
}
if (name !== "ChatSendParams" || !props.fastMode) {
const compatibility =
name === "AgentsUpdateParams" && props.model
? { key: "model", parameter: "model: String? = nil", value: "model", omitted: [] }
: name === "ChatSendParams" && props.fastMode
? {
key: "fastMode",
parameter: "fastmode: Bool?",
value: "fastmode",
omitted: ["fastAutoOnSeconds", "fast_seconds"],
}
: undefined;
if (!compatibility) {
return "";
}
const legacyKeys = Object.keys(props).filter(
(key) => key !== "fastAutoOnSeconds" && key !== "fast_seconds",
);
const initializerParams = legacyKeys.map((key) => {
const prop = props[key];
if (!prop) {
throw new Error(`missing ${name}.${key} schema`);
}
const propName = swiftStoredPropertyName(name, key);
if (key === "fastMode") {
return " fastmode: Bool?";
}
return ` ${swiftInitializerParam({
name: propName,
schema: prop,
required: required.has(key),
})}`;
});
const initializerParams = Object.entries(props)
.filter(([key]) => !compatibility.omitted.includes(key))
.map(
([key, schema]) =>
` ${
key === compatibility.key
? compatibility.parameter
: swiftInitializerParam({
name: swiftStoredPropertyName(name, key),
schema,
required: required.has(key),
})
}`,
);
const delegatedArgs = Object.keys(props).map((key) => {
const propName = swiftStoredPropertyName(name, key);
if (key === "fastMode") {
return " fastmodevalue: fastmode.map { AnyCodable($0) }";
}
if (key === "fastAutoOnSeconds" || key === "fast_seconds") {
return ` ${propName}: nil`;
}
return ` ${propName}: ${propName}`;
const value =
key === compatibility.key
? `${compatibility.value}.map { AnyCodable($0) }`
: compatibility.omitted.includes(key)
? "nil"
: propName;
return ` ${propName}: ${value}`;
});
return (
"\n\n public init(\n" +

View file

@ -125,8 +125,6 @@ if [[ -f "$ENV_FILE" ]]; then
load_podman_env_file "$ENV_FILE"
fi
CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$EFFECTIVE_HOME/.openclaw}"
ENV_FILE="${OPENCLAW_PODMAN_ENV:-$CONFIG_DIR/.env}"
WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$CONFIG_DIR/workspace}"
CONTAINER_NAME="${OPENCLAW_PODMAN_CONTAINER:-openclaw}"
OPENCLAW_IMAGE="${OPENCLAW_PODMAN_IMAGE:-${OPENCLAW_IMAGE:-openclaw:local}}"
@ -302,7 +300,7 @@ if not isinstance(allowed, list):
allowed = []
cleaned = []
seen = set()
for origin in allowed:
for origin in allowed + desired:
if not isinstance(origin, str):
continue
normalized = origin.strip()
@ -310,10 +308,6 @@ for origin in allowed:
continue
cleaned.append(normalized)
seen.add(normalized)
for origin in desired:
if origin not in seen:
cleaned.append(origin)
seen.add(origin)
if not inherits_public_origin:
control_ui["allowedOrigins"] = cleaned
with open(tmp, "w", encoding="utf-8") as fh:
@ -342,7 +336,8 @@ cleanup_token_env_file() {
trap cleanup_token_env_file EXIT
if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then
export OPENCLAW_GATEWAY_TOKEN="$(generate_token_hex_32)"
OPENCLAW_GATEWAY_TOKEN="$(generate_token_hex_32)"
export OPENCLAW_GATEWAY_TOKEN
mkdir -p "$(dirname "$ENV_FILE")"
ensure_safe_existing_dir "env file directory" "$(dirname "$ENV_FILE")"
upsert_env_var "$ENV_FILE" "OPENCLAW_GATEWAY_TOKEN" "$OPENCLAW_GATEWAY_TOKEN"
@ -395,8 +390,8 @@ else
[[ -n "$SELINUX_MOUNT_OPTS" ]] && SELINUX_MOUNT_OPTS=",$SELINUX_MOUNT_OPTS"
fi
TOKEN_ENV_FILE="$(create_token_env_file "$ENV_FILE" "$OPENCLAW_GATEWAY_TOKEN")"
if [[ "$RUN_SETUP" == true ]]; then
TOKEN_ENV_FILE="$(create_token_env_file "$ENV_FILE" "$OPENCLAW_GATEWAY_TOKEN")"
podman run --pull="$PODMAN_PULL" --rm -it \
--init \
${USERNS_ARGS[@]+"${USERNS_ARGS[@]}"} ${RUN_USER_ARGS[@]+"${RUN_USER_ARGS[@]}"} \
@ -411,7 +406,6 @@ if [[ "$RUN_SETUP" == true ]]; then
exit 0
fi
TOKEN_ENV_FILE="$(create_token_env_file "$ENV_FILE" "$OPENCLAW_GATEWAY_TOKEN")"
run_podman_detached --pull="$PODMAN_PULL" -d --replace \
--name "$CONTAINER_NAME" \
--init \

View file

@ -7,6 +7,7 @@ import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const NOW_SECONDS = 1_800_000_000;
const AUTH_MONITOR_PATH = "scripts/auth-monitor.sh";
const MOBILE_REAUTH_PATH = "scripts/mobile-reauth.sh";
const SETUP_AUTH_SYSTEM_PATH = "scripts/setup-auth-system.sh";
@ -29,6 +30,11 @@ function createAuthMonitorHarness() {
const openclawLog = join(home, "openclaw.log");
const stateFile = join(home, ".openclaw", "auth-monitor-state");
mkdirSync(binDir);
writeFileSync(
join(binDir, "date"),
`#!/bin/sh\nif [ "$1" = "+%s" ]; then printf '%s\\n' '${NOW_SECONDS}'; else /bin/date "$@"; fi\n`,
{ mode: 0o755 },
);
writeFileSync(
join(binDir, "curl"),
'#!/bin/sh\nprintf "called\\n" >> "$FAKE_CURL_LOG"\nexit "$FAKE_CURL_EXIT_CODE"\n',
@ -38,10 +44,9 @@ function createAuthMonitorHarness() {
join(binDir, "openclaw"),
[
"#!/bin/sh",
'if [ "$1" = "models" ]; then',
" exit 1",
"fi",
'printf "called\\n" >> "$FAKE_OPENCLAW_LOG"',
'if [ "$1" = "models" ]; then exit 1; fi',
'if [ "$#" -ne 6 ] || [ "$1" != "message" ] || [ "$2" != "send" ] || [ "$3" != "--target" ] || [ "$5" != "--message" ]; then exit 64; fi',
`jq -cn --arg target "$4" --arg message "$6" '{target:$target,message:$message}' >> "$FAKE_OPENCLAW_LOG"`,
'exit "$FAKE_OPENCLAW_EXIT_CODE"',
"",
].join("\n"),
@ -53,8 +58,8 @@ function createAuthMonitorHarness() {
home,
openclawLog,
stateFile,
enablePhoneAuth: () => {
const expiresAt = Date.now() + 90 * 60 * 1000;
enablePhoneAuth: (minutes = 90) => {
const expiresAt = (NOW_SECONDS + minutes * 60) * 1000;
mkdirSync(join(home, ".claude"), { recursive: true });
mkdirSync(join(home, ".openclaw", "agents", "main", "agent"), { recursive: true });
writeFileSync(
@ -163,25 +168,41 @@ describe("auth monitoring scripts", () => {
expect(readFileSync(harness.curlLog, "utf8").trim().split("\n")).toHaveLength(1);
});
it("rate-limits after any configured notification channel succeeds", () => {
const harness = createAuthMonitorHarness();
harness.enablePhoneAuth();
it.each([
{
minutes: 90,
message: "Claude Code auth expires in 1h 30m. Consider re-auth soon.",
},
{
minutes: 30,
message: "Claude Code auth expires in 0h 30m. Consider re-auth soon.",
},
])(
"delivers a phone alert and rate-limits with $minutes minutes left",
({ minutes, message }) => {
const harness = createAuthMonitorHarness();
harness.enablePhoneAuth(minutes);
const delivered = harness.run({
curlExitCode: 22,
notifyPhone: "+15550000000",
});
expect(delivered.status).toBe(0);
expect(existsSync(harness.stateFile)).toBe(true);
const delivered = harness.run({
curlExitCode: 22,
notifyPhone: "+15550000000",
});
expect(delivered.status).toBe(0);
expect(existsSync(harness.stateFile)).toBe(true);
expect(JSON.parse(readFileSync(harness.openclawLog, "utf8"))).toEqual({
target: "+15550000000",
message,
});
const throttled = harness.run({
curlExitCode: 22,
notifyPhone: "+15550000000",
});
expect(throttled.stdout).toContain("Skipping notification (sent recently)");
expect(readFileSync(harness.openclawLog, "utf8").trim().split("\n")).toHaveLength(1);
expect(readFileSync(harness.curlLog, "utf8").trim().split("\n")).toHaveLength(1);
});
const throttled = harness.run({
curlExitCode: 22,
notifyPhone: "+15550000000",
});
expect(throttled.stdout).toContain("Skipping notification (sent recently)");
expect(readFileSync(harness.openclawLog, "utf8").trim().split("\n")).toHaveLength(1);
expect(readFileSync(harness.curlLog, "utf8").trim().split("\n")).toHaveLength(1);
},
);
it("retries when all configured notification channels fail", () => {
const harness = createAuthMonitorHarness();

View file

@ -46,6 +46,32 @@ describe("check-composite-action-input-interpolation", () => {
expect(result.stdout).toContain("Use env: and reference shell variables instead.");
});
it.each([
{ name: "first step key", using: "composite", firstKey: true },
{ name: "double-quoted using", using: '"composite"', firstKey: false },
{ name: "single-quoted using", using: "'composite'", firstKey: false },
{ name: "quoted using with a comment", using: '"composite" # runtime', firstKey: true },
])("rejects direct interpolation with $name", ({ using, firstKey }) => {
const rootDir = createTempDir("openclaw-composite-action-inputs-");
writeAction(
rootDir,
"unsafe",
[
"name: unsafe",
"runs:",
` using: ${using}`,
" steps:",
...(firstKey
? [" - run: |", ' echo "${{ inputs.value }}"', " shell: bash"]
: [" - shell: bash", ' run: echo "${{ inputs.value }}"']),
].join("\n"),
);
const result = runCheck(rootDir);
expect(result.status).toBe(1);
expect(result.stdout).toContain(".github/actions/unsafe/action.yml:6");
expect(result.stdout).toContain("Disallowed direct inputs interpolation");
});
it("allows env indirection and ignores non-composite actions", () => {
const rootDir = createTempDir("openclaw-composite-action-inputs-");
writeAction(
@ -56,11 +82,11 @@ describe("check-composite-action-input-interpolation", () => {
"runs:",
" using: composite",
" steps:",
" - shell: bash",
" - run: |",
' echo "$TOKEN"',
" shell: bash",
" env:",
" TOKEN: ${{ inputs.token }}",
" run: |",
' echo "$TOKEN"',
].join("\n"),
);
writeAction(

View file

@ -465,6 +465,56 @@ Delete the files or model their real entrypoints in Knip.`,
});
});
it.each([1, 2, 3])(
"preserves split UTF-8 for interleaved Knip streams at byte %i",
async (split) => {
const child = new FakeKnipProcess();
child.pid = 0;
const resultPromise = runKnip(KNIP_UNUSED_FILE_ARGS, {
maxBufferBytes: 8,
spawnCommand: () => child,
writeStatus: () => {},
});
const stdout = Buffer.from("🦞");
const stderr = Buffer.from("📦");
child.stdout.emit("data", stdout.subarray(0, split));
child.stderr.emit("data", stderr.subarray(0, split));
child.stdout.emit("data", stdout.subarray(split));
child.stderr.emit("data", stderr.subarray(split));
finishFakeProcess(child, 0, null);
await expect(resultPromise).resolves.toMatchObject({
errorCode: undefined,
output: "🦞📦",
signal: null,
status: 0,
});
},
);
it("does not flush a UTF-8 character cut by the Knip output byte cap", async () => {
const child = new FakeKnipProcess();
// This output-only child owns no OS process; the existing cap test covers signal delivery.
child.pid = 0;
const resultPromise = runKnip(KNIP_UNUSED_FILE_ARGS, {
maxBufferBytes: 4,
spawnCommand: () => child,
writeStatus: () => {},
});
const character = Buffer.from("🦞");
child.stdout.emit("data", "ab");
child.stdout.emit("data", character.subarray(0, 2));
child.stdout.emit("data", character.subarray(2));
finishFakeProcess(child, null, "SIGTERM");
await expect(resultPromise).resolves.toMatchObject({
errorCode: "ENOBUFS",
output: "ab",
signal: "SIGTERM",
status: null,
});
});
it("bounds captured Knip output", async () => {
const child = new FakeKnipProcess();
await withFakeProcessSignals(child, async (kills) => {

View file

@ -674,10 +674,10 @@ ensure_vm_running`,
it("resets Linux product state before both install lanes", () => {
for (const lane of ["fresh", "upgrade"]) {
const restoreIndex = linux.indexOf(`this.phase("${lane}.restore-snapshot"`);
const resetIndex = linux.indexOf(`this.phase("${lane}.reset-state"`);
const restoreIndex = linux.indexOf(`"${lane}.restore-snapshot"`);
const resetIndex = linux.indexOf(`"${lane}.reset-state"`);
const installIndex = linux.indexOf(
`this.phase("${lane}.${lane === "fresh" ? "install-main" : "install-latest"}"`,
`"${lane}.${lane === "fresh" ? "install-main" : "install-latest"}"`,
);
expect(restoreIndex).toBeGreaterThanOrEqual(0);
expect(resetIndex).toBeGreaterThan(restoreIndex);
@ -963,9 +963,7 @@ ensure_vm_running`,
const script = readFileSync(scriptPath, "utf8");
expect(script, scriptPath).toContain("resolveSnapshot");
expect(script, scriptPath).toContain(
scriptPath === TS_PATHS.macos ? "runSmokeLane" : "SmokeRunController",
);
expect(script, scriptPath).toContain("SmokeRunController");
expect(script, scriptPath).not.toContain("def aliases(name: str)");
}
});
@ -1692,7 +1690,7 @@ if (commandArgs[0] === "list") {
expect(script, scriptPath).toContain("PhaseRunner");
expect(script, scriptPath).toContain("validateSnapshotRestoreMode(this.options.mode");
expect(script, scriptPath).toContain("remainingPhaseTimeoutMs");
expect(script, scriptPath).toContain("this.phases.remainingTimeoutMs");
expect(script, scriptPath).toContain("timeoutMs:");
}
@ -1700,10 +1698,10 @@ if (commandArgs[0] === "list") {
expect(macos).toContain("shouldSkipSnapshotRestore()");
expect(macos).toContain("Skip snapshot restore; using current running VM");
expect(linux).toContain("probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000)");
expect(windows).toContain("probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000)");
expect(macos).toContain("probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000)");
expect(macos).toContain("timeoutMs: this.remainingPhaseTimeoutMs(360_000)");
expect(linux).toContain("probeTimeoutMs: () => this.phases.remainingTimeoutMs(30_000)");
expect(windows).toContain("probeTimeoutMs: () => this.phases.remainingTimeoutMs(30_000)");
expect(macos).toContain("probeTimeoutMs: () => this.phases.remainingTimeoutMs(30_000)");
expect(macos).toContain("timeoutMs: this.phases.remainingTimeoutMs(360_000)");
});
it("cleans POSIX guest scripts after the phase deadline is exhausted", () => {

View file

@ -0,0 +1,116 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createScriptTestHarness } from "./test-helpers.js";
const { createTempDir } = createScriptTestHarness();
// Synthetic fixture bytes only; never used by a real Gateway.
const fixtureToken = "ab".repeat(32);
const entrypoints = ["scripts/podman/setup.sh", "scripts/run-openclaw-podman.sh"] as const;
const backends = ["openssl", "python3", "od"] as const;
function createFixture(backend: (typeof backends)[number], generatorExit: number) {
const root = createTempDir("openclaw-podman-token-");
const bin = path.join(root, "bin");
const home = path.join(root, "home");
const config = path.join(home, ".openclaw");
const calls = path.join(root, "podman-calls");
fs.mkdirSync(bin);
fs.mkdirSync(home);
for (const relative of [
...entrypoints,
"scripts/podman/common.sh",
"scripts/lib/host-timeout.sh",
"scripts/lib/build-metadata.sh",
]) {
const target = path.join(root, relative);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(path.resolve(relative), target);
}
for (const name of [
"awk",
"cat",
"chmod",
"dirname",
"install",
"mkdir",
"mktemp",
"mv",
"rm",
"tr",
]) {
const executable = ["/usr/bin", "/bin"].map((dir) => path.join(dir, name)).find(fs.existsSync);
if (!executable) {
throw new Error(`Missing shell fixture tool: ${name}`);
}
fs.symlinkSync(executable, path.join(bin, name));
}
const command = (name: string, body: string) =>
fs.writeFileSync(path.join(bin, name), `#!/bin/bash\n${body}\n`, { mode: 0o755 });
// Fixed rootless identity also works when CI itself runs as root. Files and
// persistence remain real; no service, container, or host credentials are used.
command("id", 'case "$1" in -un) printf "fixture\\n";; *) printf "1000\\n";; esac');
command(
"stat",
'case "$2" in %u) printf "1000\\n";; %Lp|%a) printf "700\\n";; *) exit 97;; esac',
);
command("uname", 'printf "Linux\\n"');
command("podman", 'printf "%s\\n" "$*" >> "$PODMAN_CALLS"');
const emitted = backend === "od" ? `${" ab".repeat(32)}\n` : `${fixtureToken}\n`;
command(
backend,
[
// Python is also an optional config-normalization helper after success;
// that separate operation may decline without preventing startup.
...(backend === "python3" ? ['[[ "$#" -eq 1 ]] || exit 0'] : []),
'if [[ "$GENERATOR_EXIT" -ne 0 ]]; then',
' printf "fixture random source failed\\n" >&2',
' exit "$GENERATOR_EXIT"',
"fi",
`printf '%s' '${emitted}'`,
].join("\n"),
);
const env: NodeJS.ProcessEnv = {
HOME: home,
PATH: bin,
OPENCLAW_REPO_PATH: root,
OPENCLAW_CONFIG_DIR: config,
OPENCLAW_IMAGE: "fixture:local",
OPENCLAW_BUILD_TIMESTAMP: "2026-09-28T12:00:00Z",
GENERATOR_EXIT: String(generatorExit),
PODMAN_CALLS: calls,
};
return { root, config, calls, env };
}
describe("Podman generated gateway tokens", () => {
for (const entrypoint of entrypoints) {
it.each(backends)(`${entrypoint} persists a successful %s token`, (backend) => {
const fixture = createFixture(backend, 0);
const result = spawnSync("/bin/bash", [path.join(fixture.root, entrypoint)], {
env: fixture.env,
encoding: "utf8",
});
expect(result.status, result.stderr).toBe(0);
expect(fs.readFileSync(path.join(fixture.config, ".env"), "utf8")).toContain(
`OPENCLAW_GATEWAY_TOKEN=${fixtureToken}\n`,
);
expect(`${result.stdout}${result.stderr}`).not.toContain(fixtureToken);
});
it.each(backends)(`${entrypoint} stops when %s token generation fails`, (backend) => {
const fixture = createFixture(backend, 7);
const result = spawnSync("/bin/bash", [path.join(fixture.root, entrypoint)], {
env: fixture.env,
encoding: "utf8",
});
expect(result.stderr).toContain("fixture random source failed");
expect(result.status).toBe(7);
expect(fs.existsSync(path.join(fixture.config, ".env"))).toBe(false);
expect(`${result.stdout}${result.stderr}`).not.toContain("Generated OPENCLAW_GATEWAY_TOKEN");
const calls = fs.existsSync(fixture.calls) ? fs.readFileSync(fixture.calls, "utf8") : "";
expect(calls).not.toMatch(/^run /m);
});
}
});