diff --git a/docs/help/scripts.md b/docs/help/scripts.md index 7a0315906780..daeeebb7fedb 100644 --- a/docs/help/scripts.md +++ b/docs/help/scripts.md @@ -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. diff --git a/docs/install/podman.md b/docs/install/podman.md index aa0678429793..1409c0592615 100644 --- a/docs/install/podman.md +++ b/docs/install/podman.md @@ -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 :` 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. diff --git a/scripts/acp-reset-timeout-proof.ts b/scripts/acp-reset-timeout-proof.ts index b95e8a13f98d..e97b65114421 100644 --- a/scripts/acp-reset-timeout-proof.ts +++ b/scripts/acp-reset-timeout-proof.ts @@ -128,14 +128,18 @@ async function waitForEvent(params: { controlDir: string; afterIndex?: number; description: string; - predicate: (event: AdapterEvent) => boolean; + match: Partial; 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, + 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, diff --git a/scripts/audit-control-ui-dead-css.mts b/scripts/audit-control-ui-dead-css.mts index e7b27028c5fd..ed2ef094dfad 100644 --- a/scripts/audit-control-ui-dead-css.mts +++ b/scripts/audit-control-ui-dead-css.mts @@ -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(values: Iterable, keyFor: (value: T) => K): Map(); 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. */ diff --git a/scripts/auth-monitor.sh b/scripts/auth-monitor.sh index 8a3c6020640c..ff0a31d53c9c 100755 --- a/scripts/auth-monitor.sh +++ b/scripts/auth-monitor.sh @@ -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 diff --git a/scripts/check-composite-action-input-interpolation.py b/scripts/check-composite-action-input-interpolation.py index 18c52501b85e..5932f3f9a000 100644 --- a/scripts/check-composite-action-input-interpolation.py +++ b/scripts/check-composite-action-input-interpolation.py @@ -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: diff --git a/scripts/check-deadcode-unused-files.mts b/scripts/check-deadcode-unused-files.mts index 7bfaeaf3d83e..cf6d99ec01b4 100644 --- a/scripts/check-deadcode-unused-files.mts +++ b/scripts/check-deadcode-unused-files.mts @@ -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 { diff --git a/scripts/check-deprecated-jsdoc.mts b/scripts/check-deprecated-jsdoc.mts index a52855b1d817..cd72fa56543d 100644 --- a/scripts/check-deprecated-jsdoc.mts +++ b/scripts/check-deprecated-jsdoc.mts @@ -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, "/"), }); diff --git a/scripts/check-extension-plugin-sdk-boundary.mts b/scripts/check-extension-plugin-sdk-boundary.mts index 6ccc73a778d2..d1ba7e478702 100644 --- a/scripts/check-extension-plugin-sdk-boundary.mts +++ b/scripts/check-extension-plugin-sdk-boundary.mts @@ -166,48 +166,7 @@ export function createExtensionPluginSdkBoundaryChecker(options: { repoRoot?: st const generatedExtensionAssetSources = new Set( listGeneratedExtensionAssetSources({ rootDir: repoRoot }), ); - const collectBoundaryEntries: NonNullable< - Parameters< - typeof createExtensionImportBoundaryChecker - >[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({ 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 | 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) { diff --git a/scripts/check-gateway-cpu-scenarios.mts b/scripts/check-gateway-cpu-scenarios.mts index 7ea6e8613d79..ad93dbdc0f02 100644 --- a/scripts/check-gateway-cpu-scenarios.mts +++ b/scripts/check-gateway-cpu-scenarios.mts @@ -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( + 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) diff --git a/scripts/check-kysely-guardrails.mts b/scripts/check-kysely-guardrails.mts index ff027a599893..f5e73e5dfaf5 100644 --- a/scripts/check-kysely-guardrails.mts +++ b/scripts/check-kysely-guardrails.mts @@ -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 = []; - 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) { diff --git a/scripts/check-policy-config-coverage.ts b/scripts/check-policy-config-coverage.ts index f48a566d0a81..3ad22e222cc4 100644 --- a/scripts/check-policy-config-coverage.ts +++ b/scripts/check-policy-config-coverage.ts @@ -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 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("."); diff --git a/scripts/check-runtime-sidecar-loaders.mts b/scripts/check-runtime-sidecar-loaders.mts index 748efdfaa99d..1e830a6ecfa3 100644 --- a/scripts/check-runtime-sidecar-loaders.mts +++ b/scripts/check-runtime-sidecar-loaders.mts @@ -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): 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 { const configs = Array.isArray(config) ? config : [config]; return new Set( @@ -105,9 +96,6 @@ export function collectTsdownEntrySources(config: unknown): Set { ); } -/** - * 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(); const stringConstants = new Map(); const stringArrays = new Map(); - const forOfRuntimeValues: Array> = []; + const forOfRuntimeValues: Array<{ name: string; values: string[] }> = []; const violations: RuntimeSidecarLoaderViolation[] = []; const seen = new Set(); - const currentForOfValueMap = (): Map => { - const merged = new Map(); - 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; }): Promise { - 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; } diff --git a/scripts/check-session-accessor-boundary.mts b/scripts/check-session-accessor-boundary.mts index b4a468d4d36a..0518bf38b0ed 100644 --- a/scripts/check-session-accessor-boundary.mts +++ b/scripts/check-session-accessor-boundary.mts @@ -388,12 +388,9 @@ function findNamedBoundaryViolations( return violations; } -function findNamedSessionStoreViolations( - sourceFile: ts.SourceFile, - legacyNames: ReadonlySet, - legacyKind: string, -) { - return findNamedBoundaryViolations(sourceFile, legacyNames, `legacy session store ${legacyKind}`); +function namedBoundaryRule(legacyNames: ReadonlySet, 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. diff --git a/scripts/check-session-transcript-reader-boundary.mts b/scripts/check-session-transcript-reader-boundary.mts index bd8ebaacbe43..bdea363a319d 100644 --- a/scripts/check-session-transcript-reader-boundary.mts +++ b/scripts/check-session-transcript-reader-boundary.mts @@ -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}"`, }); } } diff --git a/scripts/claude-auth-status.sh b/scripts/claude-auth-status.sh index 6bf8e9759e30..e3141c1ae2f6 100755 --- a/scripts/claude-auth-status.sh +++ b/scripts/claude-auth-status.sh @@ -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 "" diff --git a/scripts/control-ui-mock-attachments.ts b/scripts/control-ui-mock-attachments.ts index f71e7559b3a5..19de552319bb 100644 --- a/scripts/control-ui-mock-attachments.ts +++ b/scripts/control-ui-mock-attachments.ts @@ -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, }, ]; diff --git a/scripts/control-ui-mock-cron.ts b/scripts/control-ui-mock-cron.ts index c2e6acb5c39e..2e8192e56000 100644 --- a/scripts/control-ui-mock-cron.ts +++ b/scripts/control-ui-mock-cron.ts @@ -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 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, diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index 1d0ef1594653..db7b8a4f0285 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -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 = { 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: [ diff --git a/scripts/control-ui-mock-plugins.ts b/scripts/control-ui-mock-plugins.ts index 532f906ac8ab..0a91c1971042 100644 --- a/scripts/control-ui-mock-plugins.ts +++ b/scripts/control-ui-mock-plugins.ts @@ -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", diff --git a/scripts/deadcode-knip-runner.mts b/scripts/deadcode-knip-runner.mts index 387aede02a34..96e571902a6d 100644 --- a/scripts/deadcode-knip-runner.mts +++ b/scripts/deadcode-knip-runner.mts @@ -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 | 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) => { @@ -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), diff --git a/scripts/dependency-changes-report.mts b/scripts/dependency-changes-report.mts index e37e87d7d19d..79a232aabc33 100644 --- a/scripts/dependency-changes-report.mts +++ b/scripts/dependency-changes-report.mts @@ -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; 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 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 or --base-lockfile ."); } -/** - * Generates and writes dependency change report artifacts. - */ async function runDependencyChangesReport(options: ReturnType) { const headLockfileText = await readFile(path.join(options.rootDir, options.headLockfile), "utf8"); const baseLockfileText = @@ -315,9 +303,6 @@ async function runDependencyChangesReport(options: ReturnType) }); } -/** - * Runs the dependency changes report CLI. - */ export async function main(argv = process.argv.slice(2)) { const options = parseArgs(argv); const report = await runDependencyChangesReport(options); diff --git a/scripts/dependency-ownership-surface-report.mts b/scripts/dependency-ownership-surface-report.mts index 5db411fde913..b6876e5cbd00 100644 --- a/scripts/dependency-ownership-surface-report.mts +++ b/scripts/dependency-ownership-surface-report.mts @@ -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; -type ImporterRecord = Record; type Lockfile = { - importers?: Record; + importers?: Record; packages?: Record; - snapshots?: Record; + snapshots?: Record; }; 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) const seen = new Set(); const missing = new Set(); 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; -/** - * 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) { diff --git a/scripts/dev/computer-use-macos-live-proof.ts b/scripts/dev/computer-use-macos-live-proof.ts index 81d255f58e4c..b5598270887e 100644 --- a/scripts/dev/computer-use-macos-live-proof.ts +++ b/scripts/dev/computer-use-macos-live-proof.ts @@ -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 { 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"; diff --git a/scripts/dev/computer-use-macos-live-rig.sh b/scripts/dev/computer-use-macos-live-rig.sh index 4f336290cf4c..2bdd55689963 100755 --- a/scripts/dev/computer-use-macos-live-rig.sh +++ b/scripts/dev/computer-use-macos-live-rig.sh @@ -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 || diff --git a/scripts/dev/test-device-pair-telegram.ts b/scripts/dev/test-device-pair-telegram.ts index 0d56d03ab353..482a3e005813 100644 --- a/scripts/dev/test-device-pair-telegram.ts +++ b/scripts/dev/test-device-pair-telegram.ts @@ -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 { 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 { 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; } } diff --git a/scripts/dev/tui-pty-test-watch.ts b/scripts/dev/tui-pty-test-watch.ts index 8073d9a89d8c..40bb940c4193 100644 --- a/scripts/dev/tui-pty-test-watch.ts +++ b/scripts/dev/tui-pty-test-watch.ts @@ -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): 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 { - 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 { 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 { 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 { 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 { } }; - 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 { 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 { 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; diff --git a/scripts/docs-i18n/relocalize.go b/scripts/docs-i18n/relocalize.go index 3b3aa858e73f..138bea5f99e2 100644 --- a/scripts/docs-i18n/relocalize.go +++ b/scripts/docs-i18n/relocalize.go @@ -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") diff --git a/scripts/docs-link-audit.mts b/scripts/docs-link-audit.mts index 0742fa9e39d5..05fd2edb5e5a 100644 --- a/scripts/docs-link-audit.mts +++ b/scripts/docs-link-audit.mts @@ -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): Map { const redirects = new Map(); 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(); @@ -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") { diff --git a/scripts/e2e-sandbox-bind-conflict.mts b/scripts/e2e-sandbox-bind-conflict.mts index 8f9242392389..ceabc54d0b32 100644 --- a/scripts/e2e-sandbox-bind-conflict.mts +++ b/scripts/e2e-sandbox-bind-conflict.mts @@ -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}`); diff --git a/scripts/e2e/parallels/linux-smoke.ts b/scripts/e2e/parallels/linux-smoke.ts index a13c0b31fc87..f43bd13b9262 100755 --- a/scripts/e2e/parallels/linux-smoke.ts +++ b/scripts/e2e/parallels/linux-smoke.ts @@ -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 { 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 { 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 { - 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 { - 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) => - 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 { @@ -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 { 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 { return await extractLastOpenClawVersion( this.runDir, @@ -748,7 +682,7 @@ ${posixAgentTurnScript({ protected async writeSummary(): Promise { const summaryPath = path.join(this.runDir, "summary.json"); - const summary: LinuxSummary = { + const summary = { daemon: this.status.daemon, ...buildCommonSmokeSummary({ artifact: this.artifact, diff --git a/scripts/e2e/parallels/macos-smoke.ts b/scripts/e2e/parallels/macos-smoke.ts index 06a13fac58e5..0f42c765f4ff 100755 --- a/scripts/e2e/parallels/macos-smoke.ts +++ b/scripts/e2e/parallels/macos-smoke.ts @@ -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 { 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): Promise { - 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 { - 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 { + 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 { - 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 { + 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, - ): Promise { - 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, - ): Promise { - 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 { - return this.guest.exec(args, options); - } - private guestOpenClawEntryExec( args: string[], options: { check?: boolean; env?: Record } = {}, ): 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 { - 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 { - await this.discord?.cleanupMessages(); - } - private async stopVmAfterSuccessfulDiscordSmoke(): Promise { 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 { @@ -1100,8 +999,8 @@ ${posixAgentTurnScript({ return this.options.targetPackageSpec ? "latest->target-package" : "latest->dev"; } - private async writeSummary(): Promise { - const summary: MacosSummary = { + protected async writeSummary(): Promise { + 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`, ); diff --git a/scripts/e2e/parallels/windows-smoke.ts b/scripts/e2e/parallels/windows-smoke.ts index c93054b1a307..1e39d5b8a244 100755 --- a/scripts/e2e/parallels/windows-smoke.ts +++ b/scripts/e2e/parallels/windows-smoke.ts @@ -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 { } protected async runFreshLane(): Promise { - 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 { ), }), ); - 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 { - 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 { 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 { } 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) => - 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, - ): Promise => 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 { { 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 { } 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 { 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 { { 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 { - 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 { 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, diff --git a/scripts/generate-kysely-types.mts b/scripts/generate-kysely-types.mts index b128122a3b8f..b66ca5d4d85e 100644 --- a/scripts/generate-kysely-types.mts +++ b/scripts/generate-kysely-types.mts @@ -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; } diff --git a/scripts/lib/cancelable-command.mts b/scripts/lib/cancelable-command.mts index 380ae1cf59c5..ec1a7d4ae3f5 100644 --- a/scripts/lib/cancelable-command.mts +++ b/scripts/lib/cancelable-command.mts @@ -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); diff --git a/scripts/lib/code-mode-matrix-comparison.ts b/scripts/lib/code-mode-matrix-comparison.ts index 222b1fbf8303..fab79aef9365 100644 --- a/scripts/lib/code-mode-matrix-comparison.ts +++ b/scripts/lib/code-mode-matrix-comparison.ts @@ -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, }, }; } diff --git a/scripts/lib/code-mode-matrix-data-tasks.ts b/scripts/lib/code-mode-matrix-data-tasks.ts index d5a754e3efe0..a8fab1f5563e 100644 --- a/scripts/lib/code-mode-matrix-data-tasks.ts +++ b/scripts/lib/code-mode-matrix-data-tasks.ts @@ -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 => typeof row === "object" && row !== null, ); diff --git a/scripts/lib/code-mode-matrix-fanout-tasks.ts b/scripts/lib/code-mode-matrix-fanout-tasks.ts index 69c77fbefeb6..5d9634c01210 100644 --- a/scripts/lib/code-mode-matrix-fanout-tasks.ts +++ b/scripts/lib/code-mode-matrix-fanout-tasks.ts @@ -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 && diff --git a/scripts/lib/code-mode-matrix-gateway.ts b/scripts/lib/code-mode-matrix-gateway.ts index 1eed31698791..0f96063634b9 100644 --- a/scripts/lib/code-mode-matrix-gateway.ts +++ b/scripts/lib/code-mode-matrix-gateway.ts @@ -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(); 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", { diff --git a/scripts/lib/code-mode-matrix-recovery-tasks.ts b/scripts/lib/code-mode-matrix-recovery-tasks.ts index 99b8d5e0dd61..a1426e2c3fe8 100644 --- a/scripts/lib/code-mode-matrix-recovery-tasks.ts +++ b/scripts/lib/code-mode-matrix-recovery-tasks.ts @@ -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, diff --git a/scripts/lib/protocol-codegen-schema.ts b/scripts/lib/protocol-codegen-schema.ts new file mode 100644 index 000000000000..be3d7380197b --- /dev/null +++ b/scripts/lib/protocol-codegen-schema.ts @@ -0,0 +1,32 @@ +export type JsonSchema = { + "~openclawClosedObjectIdentity"?: symbol; + type?: string | string[]; + const?: boolean | number | string | null; + properties?: Record; + required?: string[]; + items?: JsonSchema; + enum?: Array; + patternProperties?: Record; + 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; + 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)); +} diff --git a/scripts/lib/sqlite-reliability-publication.ts b/scripts/lib/sqlite-reliability-publication.ts index 0ea7343f2f3f..ab40fe862377 100644 --- a/scripts/lib/sqlite-reliability-publication.ts +++ b/scripts/lib/sqlite-reliability-publication.ts @@ -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, diff --git a/scripts/lib/sqlite-reliability-runner.ts b/scripts/lib/sqlite-reliability-runner.ts index a0b9564e293b..1a78a112e40e 100644 --- a/scripts/lib/sqlite-reliability-runner.ts +++ b/scripts/lib/sqlite-reliability-runner.ts @@ -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; 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( + databasePath: string, + operation: (database: DatabaseSync) => T, + options?: Parameters[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 - | 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 + | 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; - restoreRoot: string; + restorePath: string; rowsPerBatch: number; syncedProvider: ReturnType; syncedRepository: string; target: TargetDatabase; uncommittedBatch: number | null; -}): Promise { +}) { 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 + 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 { 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>[] = []; 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 - 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 - 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 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 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: { diff --git a/scripts/lib/sqlite-reliability-writer.ts b/scripts/lib/sqlite-reliability-writer.ts index 48d7eb4d83f5..ddb8b6c1bff5 100644 --- a/scripts/lib/sqlite-reliability-writer.ts +++ b/scripts/lib/sqlite-reliability-writer.ts @@ -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 { 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 { 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 { "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 { }; 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 { kind: "partial", rows: heldRows, rowsCommitted, - } satisfies WriterPartialMessage); + }); while (!shouldReleasePartial()) { await delay(1); } @@ -317,7 +288,7 @@ async function runWriterChild(argv: string[]): Promise { 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 { 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(); diff --git a/scripts/mantis/build-web-ui-chat-evidence.mjs b/scripts/mantis/build-web-ui-chat-evidence.mjs index 803c74c06bc6..8812da7495d0 100644 --- a/scripts/mantis/build-web-ui-chat-evidence.mjs +++ b/scripts/mantis/build-web-ui-chat-evidence.mjs @@ -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", diff --git a/scripts/oxlint-boundary-guards.mjs b/scripts/oxlint-boundary-guards.mjs index b806dfff586f..65a63018f1b4 100644 --- a/scripts/oxlint-boundary-guards.mjs +++ b/scripts/oxlint-boundary-guards.mjs @@ -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 {}; } diff --git a/scripts/podman/common.sh b/scripts/podman/common.sh index a34a8ca235c5..baf44b85d699 100644 --- a/scripts/podman/common.sh +++ b/scripts/podman/common.sh @@ -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 diff --git a/scripts/profile-extension-memory.mts b/scripts/profile-extension-memory.mts index 20114cca59fc..878a11bacd46 100644 --- a/scripts/profile-extension-memory.mts +++ b/scripts/profile-extension-memory.mts @@ -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 { 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 { }; 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) { diff --git a/scripts/profile-tsgo.mts b/scripts/profile-tsgo.mts index 26ad8912d0f7..3851fcb65959 100644 --- a/scripts/profile-tsgo.mts +++ b/scripts/profile-tsgo.mts @@ -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 { 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 { diff --git a/scripts/protocol-gen-kotlin.ts b/scripts/protocol-gen-kotlin.ts index cb7e52575878..24680cde4eaf 100644 --- a/scripts/protocol-gen-kotlin.ts +++ b/scripts/protocol-gen-kotlin.ts @@ -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; - required?: string[]; - items?: JsonSchema; - enum?: Array; - patternProperties?: Record; - 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; +const protocolSchemas = ProtocolSchemas as Record; const schemaNames = new Map([ ["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; - 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", diff --git a/scripts/protocol-gen-swift.ts b/scripts/protocol-gen-swift.ts index 55aa757de4ed..b9a5602672d6 100644 --- a/scripts/protocol-gen-swift.ts +++ b/scripts/protocol-gen-swift.ts @@ -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; - required?: string[]; - items?: JsonSchema; - enum?: Array; - patternProperties?: Record; - 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; - 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, required: Set, ): 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" + diff --git a/scripts/run-openclaw-podman.sh b/scripts/run-openclaw-podman.sh index a160703288fd..fdfca8d50f2c 100755 --- a/scripts/run-openclaw-podman.sh +++ b/scripts/run-openclaw-podman.sh @@ -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 \ diff --git a/test/scripts/auth-monitor.test.ts b/test/scripts/auth-monitor.test.ts index 5f605a3d8fbe..4b78c57f75c4 100644 --- a/test/scripts/auth-monitor.test.ts +++ b/test/scripts/auth-monitor.test.ts @@ -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(); diff --git a/test/scripts/check-composite-action-input-interpolation.test.ts b/test/scripts/check-composite-action-input-interpolation.test.ts index bf9f5d0d5dc9..1e240c4bbe10 100644 --- a/test/scripts/check-composite-action-input-interpolation.test.ts +++ b/test/scripts/check-composite-action-input-interpolation.test.ts @@ -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( diff --git a/test/scripts/check-deadcode-unused-files.test.ts b/test/scripts/check-deadcode-unused-files.test.ts index e0f278a41cab..13553ce2bd69 100644 --- a/test/scripts/check-deadcode-unused-files.test.ts +++ b/test/scripts/check-deadcode-unused-files.test.ts @@ -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) => { diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index 82660303e9f2..c822be1ae45f 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -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", () => { diff --git a/test/scripts/podman-token-generation.test.ts b/test/scripts/podman-token-generation.test.ts new file mode 100644 index 000000000000..bc39a5ef104f --- /dev/null +++ b/test/scripts/podman-token-generation.test.ts @@ -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); + }); + } +});