Merge remote-tracking branch 'origin/main' into pr-93985-prep

* origin/main: (3706 commits)
  refactor(ui): unify filtered session list ownership (#117158)
  refactor(agents): consolidate context budgets and compaction recovery (#117149)
  fix(net-policy): redact sig and x-* auth params in URLs and bodies (#116957)
  refactor(agents): remove duplicate generated-media delivery fallbacks (#117150)
  refactor(reply): unify turn lifecycle state ownership (#117145)
  fix(ui): preserve emoji agent avatar initials (#104912)
  refactor(auto-reply): unify command and directive ownership (#117143)
  refactor(plugins): consolidate descriptor and startup ownership (#117146)
  fix(messages): reply actions leak citation markers and reply/poll answers draw the no-reply fallback (#116909)
  fix(slack): let durable ingress retry transient thread lookups (#117135)
  refactor(doctor): consolidate shipped state migration ownership (#117142)
  fix(ai): Codex stream shows internal parser text on a malformed frame (#116966)
  fix: guard every migrated session accessor path (#117140)
  fix(terminal): measure unicode display width consistently (#117062)
  fix(openai): clear ChatGPT SSE fallback per session (#117123)
  fix(mistral): reject incomplete streamed tool terminals (#117137)
  fix(ai): preserve structured chat content and refusals (#117136)
  fix(doctor): persist normalized agent roster (#117115)
  fix(signal): avoid replay after ambiguous quote delivery errors (#117134)
  fix(cron): reject disabled delivery accounts when scheduling (#116899)
  ...
This commit is contained in:
Vincent Koc
2026-08-01 10:16:40 +08:00
14283 changed files with 1706229 additions and 658740 deletions
+335 -59
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { readdir, readFile, writeFile } from "node:fs/promises";
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expectDefined } from "../packages/normalization-core/src/expect.js";
@@ -10,6 +10,19 @@ const ROOT = path.resolve(HERE, "..");
const ANDROID_MAIN_ROOT = path.join(ROOT, "apps", "android", "app", "src", "main");
const RESOURCE_ROOT = path.join(ANDROID_MAIN_ROOT, "res");
const SOURCE_ROOT = path.join(ANDROID_MAIN_ROOT, "java");
const ANDROID_PLAY_SOURCE_ROOT = path.join(ROOT, "apps", "android", "app", "src", "play", "java");
const ANDROID_THIRD_PARTY_ROOT = path.join(ROOT, "apps", "android", "app", "src", "thirdParty");
const ANDROID_THIRD_PARTY_RESOURCE_ROOT = path.join(ANDROID_THIRD_PARTY_ROOT, "res");
const ANDROID_THIRD_PARTY_SOURCE_ROOT = path.join(ANDROID_THIRD_PARTY_ROOT, "java");
const ANDROID_WEAR_MAIN_ROOT = path.join(ROOT, "apps", "android", "wear", "src", "main");
const WEAR_RESOURCE_ROOT = path.join(ANDROID_WEAR_MAIN_ROOT, "res");
const WEAR_SOURCE_ROOT = path.join(ANDROID_WEAR_MAIN_ROOT, "java");
const ANDROID_SOURCE_ROOTS = [
SOURCE_ROOT,
ANDROID_PLAY_SOURCE_ROOT,
ANDROID_THIRD_PARTY_SOURCE_ROOT,
WEAR_SOURCE_ROOT,
] as const;
const INVENTORY_PATH = path.join(ROOT, "apps", ".i18n", "native-source.json");
const ARTIFACT_ROOT = path.join(ROOT, "apps", ".i18n", "native");
const TOOL_DISPLAY_PATH = path.join(
@@ -50,6 +63,14 @@ const FORMAT_RE = /%\d+\$[a-z]/giu;
const INVALID_APOSTROPHE_RE = /(?:&apos;|(?<!\\)')/u;
const GENERATED_HEADER = " <!-- Generated by scripts/android-app-i18n.ts. -->";
const GENERATED_KOTLIN_HEADER = "// Generated by scripts/android-app-i18n.ts. Do not edit.";
const THIRD_PARTY_STRINGS_FILE = "accessibility_strings.xml";
const THIRD_PARTY_STRINGS_REPO_PATH =
"apps/android/app/src/thirdParty/res/values/accessibility_strings.xml";
const THIRD_PARTY_GENERATED_RESOURCE_RE =
/^apps\/android\/app\/src\/thirdParty\/res\/values-[^/]+\/accessibility_strings\.xml$/;
const WEAR_STRINGS_REPO_PATH = "apps/android/wear/src/main/res/values/strings.xml";
const WEAR_GENERATED_RESOURCE_RE =
/^apps\/android\/wear\/src\/main\/res\/values-[^/]+\/strings\.xml$/;
type NativeInventoryEntry = {
id: string;
@@ -72,6 +93,12 @@ type ResourceString = {
value: string;
};
const GENERATED_TRANSLATION_LINT_IGNORES = [
"Typos",
"TypographyDashes",
"TypographyEllipsis",
] as const;
type TranslationContradiction = {
locale: string;
selected: string;
@@ -79,7 +106,7 @@ type TranslationContradiction = {
translations: string[];
};
type GeneratedCatalog = {
export type GeneratedCatalog = {
contradictions: TranslationContradiction[];
kotlin: string;
resources: Map<string, string>;
@@ -217,7 +244,11 @@ export function renderAndroidResourceValue(source: string, translated: string):
}
rendered += translated.slice(cursor).replaceAll("%", "%%");
}
return rendered
return escapeAndroidResourceValue(rendered);
}
export function escapeAndroidResourceValue(value: string): string {
return value
.replaceAll("\\", "\\\\")
.replaceAll("\n", "\\n")
.replaceAll("'", "\\'")
@@ -248,6 +279,19 @@ function parseStrings(source: string): ResourceString[] {
}));
}
function withGeneratedTranslationLintIgnores(attrs: string): string {
const existing = attrs.match(/\btools:ignore\s*=\s*"([^"]*)"/u);
const ignores = [
...(existing?.[1]
?.split(",")
.map((value) => value.trim())
.filter(Boolean) ?? []),
...GENERATED_TRANSLATION_LINT_IGNORES,
];
const rendered = `tools:ignore="${[...new Set(ignores)].join(",")}"`;
return existing ? attrs.replace(existing[0], rendered) : `${attrs} ${rendered}`;
}
function parseArrays(source: string): Map<string, string[]> {
return new Map(
[...source.matchAll(ARRAY_RE)].map((match) => [
@@ -259,8 +303,12 @@ function parseArrays(source: string): Map<string, string[]> {
);
}
async function readStrings(locale: string): Promise<Map<string, ResourceString>> {
const source = await readFile(path.join(RESOURCE_ROOT, locale, "strings.xml"), "utf8");
async function readStrings(
locale: string,
resourceRoot = RESOURCE_ROOT,
fileName = "strings.xml",
): Promise<Map<string, ResourceString>> {
const source = await readFile(path.join(resourceRoot, locale, fileName), "utf8");
return new Map(parseStrings(source).map((entry) => [entry.key, entry]));
}
@@ -285,6 +333,10 @@ async function readAndroidSource(
return sources;
}
async function readAllAndroidSource(): Promise<Array<{ path: string; source: string }>> {
return (await Promise.all(ANDROID_SOURCE_ROOTS.map((root) => readAndroidSource(root)))).flat();
}
type AndroidResourceReferenceSource = {
path: string;
source: string;
@@ -481,6 +533,11 @@ const ALLOWED_UI_LITERALS = new Map<string, ReadonlySet<string>>([
"apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt",
new Set(["${normalized.takeUtf16Safe(87)}..."]),
],
[
"apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarShell.kt",
// Compose animation labels are tooling identifiers, not rendered copy.
new Set(["sidebar-content-translation"]),
],
[
"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatCommandControls.kt",
new Set(["/$name", "help"]),
@@ -489,6 +546,11 @@ const ALLOWED_UI_LITERALS = new Map<string, ReadonlySet<string>>([
"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageActions.kt",
new Set([">", "> $line"]),
],
[
"apps/android/wear/src/main/java/ai/openclaw/wear/WearScreens.kt",
// Compose animation labels are tooling identifiers, not rendered copy.
new Set(["voice-swipe-hint", "voice-swipe-hint-offset"]),
],
]);
function isAllowedUiLiteral(repoPath: string, source: string): boolean {
@@ -503,7 +565,10 @@ function shouldScanUiLiterals(repoPath: string): boolean {
if (repoPath.endsWith("/i18n/NativeStringResources.kt")) {
return false;
}
if (repoPath.endsWith("/AndroidScreenshotFixture.kt")) {
if (
repoPath.endsWith("/AndroidScreenshotFixture.kt") ||
repoPath.endsWith("/WearScreenshotMode.kt")
) {
return false;
}
if (repoPath.endsWith("/ui/design/ClawComponents.kt")) {
@@ -514,6 +579,8 @@ function shouldScanUiLiterals(repoPath: string): boolean {
}
return (
repoPath.includes("/ui/") ||
repoPath.endsWith("/accessibility/AccessibilityDevActivity.kt") ||
repoPath.includes("/wear/src/main/java/ai/openclaw/wear/") ||
repoPath.endsWith("/MainActivity.kt") ||
repoPath.endsWith("/NodeRuntime.kt") ||
repoPath.endsWith("/PermissionRequester.kt") ||
@@ -972,7 +1039,7 @@ export function findUnlocalizedAndroidUiLiterals(
);
}
function findInvalidResourceSyntax(strings: Map<string, ResourceString>): string[] {
function findInvalidResourceSyntax(strings: ReadonlyMap<string, ResourceString>): string[] {
return [...strings]
.filter(([, entry]) => {
const trimmed = entry.rawValue.trim();
@@ -1035,12 +1102,77 @@ async function readArtifacts(): Promise<Map<string, NativeArtifactEntry[]>> {
);
}
function translationsBySource(
artifactEntries: readonly NativeArtifactEntry[],
): Map<string, string[]> {
const translations = new Map<string, string[]>();
for (const entry of artifactEntries) {
const values = translations.get(entry.source) ?? [];
values.push(entry.translated);
translations.set(entry.source, values);
}
return translations;
}
function artifactEntriesById(
artifactEntries: readonly NativeArtifactEntry[],
): Map<string, NativeArtifactEntry> {
return new Map(artifactEntries.map((entry) => [entry.id, entry]));
}
export function selectExactArtifactTranslation(
source: string,
inventoryId: string,
artifactEntries: ReadonlyMap<string, { source: string; translated: string }>,
): string {
const artifactEntry = artifactEntries.get(inventoryId);
if (!artifactEntry) {
return source;
}
if (artifactEntry.source !== source) {
throw new Error(
`Wear translation source drift for ${inventoryId}: ${JSON.stringify(artifactEntry.source)} != ${JSON.stringify(source)}`,
);
}
return artifactEntry.translated || source;
}
function localizeManualStrings(
base: ReadonlyMap<string, ResourceString>,
inventoryBySource: ReadonlyMap<string, NativeInventoryEntry>,
artifactEntries: ReadonlyMap<string, NativeArtifactEntry>,
surface: string,
): ResourceString[] {
return [...base.values()].map((entry) => {
const source = decodeAndroidResourceValue(entry.rawValue);
const translatable = !/\btranslatable\s*=\s*"false"/u.test(entry.attrs);
const inventoryEntry = inventoryBySource.get(source);
if (translatable && !inventoryEntry) {
throw new Error(
`${surface} string is missing from native inventory: ${JSON.stringify(source)}`,
);
}
const value =
translatable && inventoryEntry
? selectExactArtifactTranslation(source, inventoryEntry.id, artifactEntries)
: source;
return {
attrs: translatable ? withGeneratedTranslationLintIgnores(entry.attrs) : entry.attrs,
key: entry.key,
rawValue: `"${escapeAndroidResourceValue(value)}"`,
value,
};
});
}
function renderStringsXml(
manual: readonly ResourceString[],
generated: ReadonlyMap<string, { source: string; value: string }>,
): string {
const usesToolsNamespace =
generated.size > 0 || manual.some((entry) => entry.attrs.includes("tools:"));
const lines = [
generated.size > 0
usesToolsNamespace
? '<resources xmlns:tools="http://schemas.android.com/tools">'
: "<resources>",
];
@@ -1057,7 +1189,7 @@ function renderStringsXml(
// Translation-memory text intentionally preserves technical tokens and source punctuation,
// so Android's English dictionary and typography suggestions do not apply to managed keys.
lines.push(
` <string name="${key}"${formatted} tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"${renderAndroidResourceValue(entry.source, entry.value)}"</string>`,
` <string name="${key}"${withGeneratedTranslationLintIgnores(formatted)}>"${renderAndroidResourceValue(entry.source, entry.value)}"</string>`,
);
}
}
@@ -1092,16 +1224,36 @@ function renderKotlin(sourceToKey: ReadonlyMap<string, string>): string {
].join("\n");
}
async function buildCatalog(): Promise<GeneratedCatalog> {
const [inventory, artifacts, localeStrings, sourceFiles, toolDisplaySources] = await Promise.all([
export async function buildAndroidAppI18nCatalog(): Promise<GeneratedCatalog> {
const [
inventory,
artifacts,
localeStrings,
thirdPartyBaseStrings,
wearBaseStrings,
sourceFiles,
toolDisplaySources,
] = await Promise.all([
readInventory(),
readArtifacts(),
Promise.all(LOCALES.map(readStrings)),
readAndroidSource(),
Promise.all(LOCALES.map((locale) => readStrings(locale))),
readStrings("values", ANDROID_THIRD_PARTY_RESOURCE_ROOT, THIRD_PARTY_STRINGS_FILE),
readStrings("values", WEAR_RESOURCE_ROOT),
readAllAndroidSource(),
readToolDisplaySources(),
]);
const baseStrings = expectDefined(localeStrings[0], "English Android string resources");
const translatedStrings = localeStrings.slice(1);
const wearInventoryBySource = new Map(
inventory
.filter((entry) => entry.path === WEAR_STRINGS_REPO_PATH)
.map((entry) => [entry.source, entry]),
);
const thirdPartyInventoryBySource = new Map(
inventory
.filter((entry) => entry.path === THIRD_PARTY_STRINGS_REPO_PATH)
.map((entry) => [entry.source, entry]),
);
const manualBase = [...baseStrings.values()].filter(
(entry) => !entry.key.startsWith(MANAGED_PREFIX),
);
@@ -1133,12 +1285,7 @@ async function buildCatalog(): Promise<GeneratedCatalog> {
const contradictions: TranslationContradiction[] = [];
for (const [localeIndex, locale] of NATIVE_I18N_LOCALES.entries()) {
const manualTranslations = translatedStrings[localeIndex] ?? new Map();
const artifactTranslationsBySource = new Map<string, string[]>();
for (const entry of artifacts.get(locale) ?? []) {
const values = artifactTranslationsBySource.get(entry.source) ?? [];
values.push(entry.translated);
artifactTranslationsBySource.set(entry.source, values);
}
const artifactTranslationsBySource = translationsBySource(artifacts.get(locale) ?? []);
const generated = new Map<string, { source: string; value: string }>();
for (const source of entriesBySource.keys()) {
const key = sourceToKey.get(source);
@@ -1178,6 +1325,30 @@ async function buildCatalog(): Promise<GeneratedCatalog> {
path.join(RESOURCE_ROOT, localeDirectory(locale), "strings.xml"),
renderStringsXml(manual, generated),
);
const wearManual = localizeManualStrings(
wearBaseStrings,
wearInventoryBySource,
artifactEntriesById(artifacts.get(locale) ?? []),
"Wear",
);
resources.set(
path.join(WEAR_RESOURCE_ROOT, localeDirectory(locale), "strings.xml"),
renderStringsXml(wearManual, new Map()),
);
const thirdPartyManual = localizeManualStrings(
thirdPartyBaseStrings,
thirdPartyInventoryBySource,
artifactEntriesById(artifacts.get(locale) ?? []),
"Android third-party",
);
resources.set(
path.join(
ANDROID_THIRD_PARTY_RESOURCE_ROOT,
localeDirectory(locale),
THIRD_PARTY_STRINGS_FILE,
),
renderStringsXml(thirdPartyManual, new Map()),
);
}
const generatedBase = new Map<string, { source: string; value: string }>();
for (const [source, key] of sourceToKey) {
@@ -1226,29 +1397,76 @@ async function buildCatalog(): Promise<GeneratedCatalog> {
function formatProblems(problems: Array<readonly [string, string[]]>): string {
return [
"Android app i18n resources are out of sync.",
"Android i18n resources are out of sync.",
...problems.map(([label, keys]) => `${label}=${keys.join(",") || "none"}`),
].join("\n");
}
export async function syncAndroidAppI18n(options: { check?: boolean } = {}) {
const catalog = await buildCatalog();
/**
* Managed app rows (native_*) and localized Wear catalogs are owned end-to-end
* by the post-merge locale refresh workflow, so a source PR may legitimately
* carry stale generated output. Every other delta is real drift.
*/
function onlyManagedRowsPending(current: string, expected: string): boolean {
const currentRows = new Map(parseStrings(current).map((entry) => [entry.key, entry]));
const expectedRows = new Map(parseStrings(expected).map((entry) => [entry.key, entry]));
for (const [key, entry] of currentRows) {
const expectedEntry = expectedRows.get(key);
if (!expectedEntry) {
if (!key.startsWith(MANAGED_PREFIX)) {
return false;
}
continue;
}
if (expectedEntry.attrs !== entry.attrs || expectedEntry.rawValue !== entry.rawValue) {
return false;
}
}
for (const key of expectedRows.keys()) {
if (!currentRows.has(key) && !key.startsWith(MANAGED_PREFIX)) {
return false;
}
}
return true;
}
export async function syncAndroidAppI18n(
options: { check?: boolean; tolerateManagedPending?: boolean } = {},
) {
const catalog = await buildAndroidAppI18nCatalog();
const drift: string[] = [];
let sawUnmanagedDrift = false;
for (const [filePath, expected] of catalog.resources) {
const current = await readFile(filePath, "utf8").catch(() => "");
if (current === expected) {
continue;
}
drift.push(path.relative(ROOT, filePath).split(path.sep).join("/"));
const relativeFilePath = path.relative(ROOT, filePath).split(path.sep).join("/");
if (
options.check &&
options.tolerateManagedPending &&
(WEAR_GENERATED_RESOURCE_RE.test(relativeFilePath) ||
THIRD_PARTY_GENERATED_RESOURCE_RE.test(relativeFilePath) ||
(filePath.endsWith("strings.xml") && onlyManagedRowsPending(current, expected)))
) {
continue;
}
sawUnmanagedDrift = true;
drift.push(relativeFilePath);
if (!options.check) {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, expected);
}
}
const currentKotlin = await readFile(GENERATED_KOTLIN_PATH, "utf8").catch(() => "");
if (currentKotlin !== catalog.kotlin) {
drift.push(path.relative(ROOT, GENERATED_KOTLIN_PATH).split(path.sep).join("/"));
if (!options.check) {
await writeFile(GENERATED_KOTLIN_PATH, catalog.kotlin);
// The Kotlin map derives 1:1 from the same managed catalog: tolerate it
// exactly when every resource delta above was managed-pending.
if (!(options.check && options.tolerateManagedPending && !sawUnmanagedDrift)) {
drift.push(path.relative(ROOT, GENERATED_KOTLIN_PATH).split(path.sep).join("/"));
if (!options.check) {
await writeFile(GENERATED_KOTLIN_PATH, catalog.kotlin);
}
}
}
if (options.check && drift.length > 0) {
@@ -1274,17 +1492,44 @@ export async function syncAndroidAppI18n(options: { check?: boolean } = {}) {
}
export async function verifyAndroidAppI18n() {
const [sourceFiles, base, referenceSource] = await Promise.all([
readAndroidSource(),
const [
sourceFiles,
base,
referenceSource,
thirdPartyBase,
thirdPartyReferenceSource,
wearBase,
wearReferenceSource,
] = await Promise.all([
readAllAndroidSource(),
readStrings("values"),
readAndroidResourceReferences(),
readStrings("values", ANDROID_THIRD_PARTY_RESOURCE_ROOT, THIRD_PARTY_STRINGS_FILE),
readAndroidResourceReferences(ANDROID_THIRD_PARTY_ROOT),
readStrings("values", WEAR_RESOURCE_ROOT),
readAndroidResourceReferences(ANDROID_WEAR_MAIN_ROOT),
]);
const baseKeys = new Set(base.keys());
const thirdPartyBaseKeys = new Set(thirdPartyBase.keys());
const wearBaseKeys = new Set(wearBase.keys());
const problems: Array<readonly [string, string[]]> = [
["English syntax", findInvalidResourceSyntax(base)],
["App English syntax", findInvalidResourceSyntax(base)],
["Third-party English syntax", findInvalidResourceSyntax(thirdPartyBase)],
["Wear English syntax", findInvalidResourceSyntax(wearBase)],
];
const manualBaseKeys = [...baseKeys].filter((key) => !key.startsWith(MANAGED_PREFIX));
problems.push(["English unused", findUnusedAndroidResourceKeys(manualBaseKeys, referenceSource)]);
problems.push([
"App English unused",
findUnusedAndroidResourceKeys(manualBaseKeys, referenceSource),
]);
problems.push([
"Third-party English unused",
findUnusedAndroidResourceKeys(thirdPartyBaseKeys, thirdPartyReferenceSource),
]);
problems.push([
"Wear English unused",
findUnusedAndroidResourceKeys(wearBaseKeys, wearReferenceSource),
]);
const uiFindings = sourceFiles.flatMap((file) =>
findUnlocalizedAndroidUiLiterals(file.source, file.path),
);
@@ -1295,44 +1540,75 @@ export async function verifyAndroidAppI18n() {
if (problems.some(([, keys]) => keys.length)) {
throw new Error(formatProblems(problems));
}
process.stdout.write(`android-app-i18n: sourceKeys=${baseKeys.size}\n`);
process.stdout.write(
`android-app-i18n: appSourceKeys=${baseKeys.size} thirdPartySourceKeys=${thirdPartyBaseKeys.size} wearSourceKeys=${wearBaseKeys.size}\n`,
);
}
export async function checkAndroidAppI18n() {
export async function checkAndroidAppI18n(options: { tolerateManagedPending?: boolean } = {}) {
await verifyAndroidAppI18n();
await syncAndroidAppI18n({ check: true });
const localeStrings = await Promise.all(LOCALES.map(readStrings));
await syncAndroidAppI18n({ check: true, ...options });
const localeStrings = await Promise.all(LOCALES.map((locale) => readStrings(locale)));
const wearLocaleStrings = options.tolerateManagedPending
? []
: await Promise.all(LOCALES.map((locale) => readStrings(locale, WEAR_RESOURCE_ROOT)));
const thirdPartyLocaleStrings = options.tolerateManagedPending
? []
: await Promise.all(
LOCALES.map((locale) =>
readStrings(locale, ANDROID_THIRD_PARTY_RESOURCE_ROOT, THIRD_PARTY_STRINGS_FILE),
),
);
const base = expectDefined(localeStrings[0], "English Android string resources");
const translations = localeStrings.slice(1);
const baseKeys = new Set(base.keys());
const problems: Array<readonly [string, string[]]> = translations.flatMap((strings, index) => {
const locale = NATIVE_I18N_LOCALES[index];
const keys = new Set(strings.keys());
const placeholderMismatches = [...base].flatMap(([key, sourceEntry]) => {
const translatedEntry = strings.get(key);
if (!translatedEntry) {
return [];
}
const expected = [...sourceEntry.rawValue.matchAll(FORMAT_RE)]
.map((match) => match[0])
.toSorted();
const actual = [...translatedEntry.rawValue.matchAll(FORMAT_RE)]
.map((match) => match[0])
.toSorted();
return expected.join("\u0000") === actual.join("\u0000") ? [] : [key];
const localeProblems = (
surface: string,
surfaceBase: ReadonlyMap<string, ResourceString>,
translations: ReadonlyArray<ReadonlyMap<string, ResourceString>>,
): Array<readonly [string, string[]]> => {
const surfaceBaseKeys = new Set(surfaceBase.keys());
return translations.flatMap((strings, index) => {
const locale = NATIVE_I18N_LOCALES[index];
const keys = new Set(strings.keys());
const placeholderMismatches = [...surfaceBase].flatMap(([key, sourceEntry]) => {
const translatedEntry = strings.get(key);
if (!translatedEntry) {
return [];
}
const expected = [...sourceEntry.rawValue.matchAll(FORMAT_RE)]
.map((match) => match[0])
.toSorted();
const actual = [...translatedEntry.rawValue.matchAll(FORMAT_RE)]
.map((match) => match[0])
.toSorted();
return expected.join("\u0000") === actual.join("\u0000") ? [] : [key];
});
return [
[`${surface} ${locale} missing`, [...surfaceBaseKeys].filter((key) => !keys.has(key))],
[`${surface} ${locale} extra`, [...keys].filter((key) => !surfaceBaseKeys.has(key))],
[`${surface} ${locale} placeholders`, placeholderMismatches],
[`${surface} ${locale} syntax`, findInvalidResourceSyntax(strings)],
] as const;
});
return [
[`${locale} missing`, [...baseKeys].filter((key) => !keys.has(key))],
[`${locale} extra`, [...keys].filter((key) => !baseKeys.has(key))],
[`${locale} placeholders`, placeholderMismatches],
[`${locale} syntax`, findInvalidResourceSyntax(strings)],
] as const;
});
};
const problems = localeProblems("App", base, localeStrings.slice(1));
if (thirdPartyLocaleStrings.length > 0) {
const thirdPartyBase = expectDefined(
thirdPartyLocaleStrings[0],
"English Android third-party string resources",
);
problems.push(
...localeProblems("Third-party", thirdPartyBase, thirdPartyLocaleStrings.slice(1)),
);
}
if (wearLocaleStrings.length > 0) {
const wearBase = expectDefined(wearLocaleStrings[0], "English Wear string resources");
problems.push(...localeProblems("Wear", wearBase, wearLocaleStrings.slice(1)));
}
if (problems.some(([, keys]) => keys.length)) {
throw new Error(formatProblems(problems));
}
process.stdout.write(
`android-app-i18n: keys=${baseKeys.size} locales=${NATIVE_I18N_LOCALES.join(",")}\n`,
`android-app-i18n: appKeys=${base.size} locales=${NATIVE_I18N_LOCALES.join(",")}\n`,
);
}
+3 -3
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { runAndroidSigningCommandSync } from "./lib/android-release-signing-process.mjs";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultManifestPath = path.join(rootDir, "apps", "android", "Config", "ReleaseSigning.json");
@@ -185,7 +185,7 @@ function requireMatchPassword() {
}
function run(command, args, options = {}) {
execFileSync(command, args, {
runAndroidSigningCommandSync(command, args, {
cwd: options.cwd,
env: options.env || process.env,
stdio: options.stdio || "pipe",
@@ -193,7 +193,7 @@ function run(command, args, options = {}) {
}
function runText(command, args, options = {}) {
return execFileSync(command, args, {
return runAndroidSigningCommandSync(command, args, {
cwd: options.cwd,
env: options.env || process.env,
encoding: "utf8",
+127 -22
View File
@@ -4,18 +4,20 @@ set -euo pipefail
usage() {
cat <<'EOF'
Usage:
scripts/android-screenshots.sh [--device <adb-serial>] [--avd <name>] [--locale en-US] [--skip-build] [--skip-install] [--keep-emulator] [--dry-run]
scripts/android-screenshots.sh [--form-factor all|phone|wear] [--device <adb-serial>] [--avd <name>] [--locale en-US] [--skip-build] [--skip-install] [--keep-emulator] [--dry-run]
Builds and installs the Play debug app on an emulator, launches production screens
with deterministic local fixture state, and writes Google Play screenshots under:
Builds and installs the phone and Wear OS debug apps on matching emulators,
launches production screens with deterministic local fixture state, and writes
Google Play screenshots under:
apps/android/fastlane/metadata/android/<locale>/images/phoneScreenshots/
apps/android/fastlane/metadata/android/<locale>/images/wearScreenshots/
Capture evidence is saved under:
.artifacts/android-screenshots/latest/
By default, the script creates and boots a retained Pixel 2 AVD with no display
cutout. Use --avd or ANDROID_SCREENSHOT_AVD to select another AVD, or --device
to explicitly use a connected emulator.
By default, the script captures both form factors using retained Pixel 2 and
Wear OS Large Round AVDs. Use --form-factor with --avd or --device to capture
one form factor on an explicitly selected emulator.
EOF
}
@@ -23,11 +25,15 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ANDROID_DIR="${ROOT_DIR}/apps/android"
DEFAULT_SCREENSHOT_AVD="OpenClaw_Screenshots_API36"
DEFAULT_SCREENSHOT_DEVICE_PROFILE="pixel_2"
DEFAULT_WEAR_SCREENSHOT_AVD="OpenClaw_Wear_Screenshots_API34"
DEFAULT_WEAR_SCREENSHOT_DEVICE_PROFILE="wearos_large_round"
case "$(uname -m)" in
arm64|aarch64) DEFAULT_SCREENSHOT_ABI="arm64-v8a" ;;
*) DEFAULT_SCREENSHOT_ABI="x86_64" ;;
esac
DEFAULT_SCREENSHOT_SYSTEM_IMAGE="system-images;android-36;google_apis;${DEFAULT_SCREENSHOT_ABI}"
DEFAULT_WEAR_SCREENSHOT_SYSTEM_IMAGE="system-images;android-34;android-wear;${DEFAULT_SCREENSHOT_ABI}"
FORM_FACTOR="${ANDROID_SCREENSHOT_FORM_FACTOR:-all}"
LOCALE="en-US"
DEVICE="${ANDROID_SCREENSHOT_DEVICE:-}"
AVD="${ANDROID_SCREENSHOT_AVD:-$DEFAULT_SCREENSHOT_AVD}"
@@ -37,11 +43,15 @@ KEEP_EMULATOR="${ANDROID_SCREENSHOT_KEEP_EMULATOR:-0}"
SKIP_BUILD=0
SKIP_INSTALL=0
DRY_RUN=0
SCENES=(home chat voice settings gateway voice-wake)
SCENES=(home chat settings gateway voice-wake)
OUTPUT_TYPE="phoneScreenshots"
GRADLE_ASSEMBLE_TASK=":app:assemblePlayDebug"
ACTIVITY_COMPONENT="ai.openclaw.app/.MainActivity"
EMULATOR_PID=""
EMULATOR_LOG=""
STARTED_EMULATOR=0
ARTIFACT_DIR="${ROOT_DIR}/.artifacts/android-screenshots/latest"
ARTIFACT_ROOT="${ROOT_DIR}/.artifacts/android-screenshots/latest"
ARTIFACT_DIR="${ARTIFACT_ROOT}/phone"
SCREENSHOT_SIZE="${ANDROID_SCREENSHOT_SIZE:-1440x2560}"
DISPLAY_OVERRIDDEN=0
ORIGINAL_WM_SIZE=""
@@ -50,18 +60,26 @@ SCREENSHOT_DENSITY=""
TIMEZONE_OVERRIDDEN=0
ORIGINAL_AUTO_TIME_ZONE=""
ORIGINAL_TIME_ZONE=""
DEVICE_EXPLICIT=0
AVD_EXPLICIT=0
while [[ $# -gt 0 ]]; do
case "$1" in
--)
shift
;;
--form-factor)
FORM_FACTOR="${2:-}"
shift 2
;;
--device)
DEVICE="${2:-}"
DEVICE_EXPLICIT=1
shift 2
;;
--avd)
AVD="${2:-}"
AVD_EXPLICIT=1
shift 2
;;
--locale)
@@ -96,6 +114,49 @@ while [[ $# -gt 0 ]]; do
esac
done
case "$FORM_FACTOR" in
all)
if [[ "$DEVICE_EXPLICIT" == "1" || "$AVD_EXPLICIT" == "1" ]]; then
echo "--device and --avd require --form-factor phone or --form-factor wear." >&2
exit 1
fi
if [[ "$KEEP_EMULATOR" == "1" ]]; then
echo "--keep-emulator requires --form-factor phone or --form-factor wear." >&2
exit 1
fi
child_args=(--locale "$LOCALE")
[[ "$SKIP_BUILD" == "1" ]] && child_args+=(--skip-build)
[[ "$SKIP_INSTALL" == "1" ]] && child_args+=(--skip-install)
[[ "$DRY_RUN" == "1" ]] && child_args+=(--dry-run)
bash "$0" --form-factor phone "${child_args[@]}"
bash "$0" --form-factor wear "${child_args[@]}"
exit 0
;;
phone)
;;
wear)
if [[ "$DEVICE_EXPLICIT" != "1" ]]; then
DEVICE="${ANDROID_WEAR_SCREENSHOT_DEVICE:-}"
fi
if [[ "$AVD_EXPLICIT" != "1" ]]; then
AVD="${ANDROID_WEAR_SCREENSHOT_AVD:-$DEFAULT_WEAR_SCREENSHOT_AVD}"
fi
SCREENSHOT_DEVICE_PROFILE="${ANDROID_WEAR_SCREENSHOT_DEVICE_PROFILE:-$DEFAULT_WEAR_SCREENSHOT_DEVICE_PROFILE}"
SCREENSHOT_SYSTEM_IMAGE="${ANDROID_WEAR_SCREENSHOT_SYSTEM_IMAGE:-$DEFAULT_WEAR_SCREENSHOT_SYSTEM_IMAGE}"
SCREENSHOT_SIZE="${ANDROID_WEAR_SCREENSHOT_SIZE:-454x454}"
SCENES=(chat voice controls)
OUTPUT_TYPE="wearScreenshots"
GRADLE_ASSEMBLE_TASK=":wear:assembleDebug"
ACTIVITY_COMPONENT="ai.openclaw.app/ai.openclaw.wear.MainActivity"
ARTIFACT_DIR="${ARTIFACT_ROOT}/wear"
;;
*)
echo "Invalid Android screenshot form factor: ${FORM_FACTOR}" >&2
echo "Use all, phone, or wear." >&2
exit 1
;;
esac
validate_locale() {
local locale="$1"
if [[ "$locale" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]; then
@@ -131,6 +192,13 @@ cleanup_started_emulator() {
if [[ -n "${ADB_BIN:-}" && -n "${ADB_SERIAL:-}" ]]; then
if "$ADB_BIN" -s "$ADB_SERIAL" emu kill >/dev/null 2>&1; then
stopped=1
local deadline=$((SECONDS + 30))
while (( SECONDS < deadline )); do
if ! "$ADB_BIN" devices | awk 'NR > 1 && $2 == "device" { print $1 }' | grep -Fxq "$ADB_SERIAL"; then
break
fi
sleep 1
done
fi
fi
if [[ "$stopped" != "1" && -n "$EMULATOR_PID" ]]; then
@@ -224,10 +292,6 @@ avdmanager_bin() {
printf '%s\n' "$AVDMANAGER"
return
fi
if command -v avdmanager >/dev/null 2>&1; then
command -v avdmanager
return
fi
for sdk_root in "${ANDROID_HOME:-}" "${ANDROID_SDK_ROOT:-}" "$HOME/Library/Android/sdk"; do
for relative_path in cmdline-tools/latest/bin/avdmanager cmdline-tools/bin/avdmanager tools/bin/avdmanager; do
if [[ -n "$sdk_root" && -x "$sdk_root/$relative_path" ]]; then
@@ -236,6 +300,10 @@ avdmanager_bin() {
fi
done
done
if command -v avdmanager >/dev/null 2>&1; then
command -v avdmanager
return
fi
echo "avdmanager not found. Install Android SDK command-line tools or set AVDMANAGER." >&2
return 127
}
@@ -368,6 +436,10 @@ stabilize_device_for_screenshots() {
"$adb" -s "$serial" shell settings put global window_animation_scale 0 >/dev/null 2>&1 || true
"$adb" -s "$serial" shell settings put global transition_animation_scale 0 >/dev/null 2>&1 || true
"$adb" -s "$serial" shell settings put global animator_duration_scale 0 >/dev/null 2>&1 || true
"$adb" -s "$serial" shell settings put global stay_on_while_plugged_in 3 >/dev/null 2>&1 || true
"$adb" -s "$serial" shell svc power stayon true >/dev/null 2>&1 || true
"$adb" -s "$serial" shell input keyevent 224 >/dev/null 2>&1 || true
"$adb" -s "$serial" shell wm dismiss-keyguard >/dev/null 2>&1 || true
"$adb" -s "$serial" shell settings put system font_scale 1.0 >/dev/null 2>&1 || true
}
@@ -471,20 +543,41 @@ resolve_device() {
return 1
}
latest_play_debug_apk() {
if [[ ! -d "${ANDROID_DIR}/app/build/outputs/apk/play/debug" ]]; then
latest_debug_apk() {
local output_dir
local pattern
if [[ "$FORM_FACTOR" == "wear" ]]; then
output_dir="${ANDROID_DIR}/wear/build/outputs/apk/debug"
pattern='*-debug.apk'
else
output_dir="${ANDROID_DIR}/app/build/outputs/apk/play/debug"
pattern='*-play-debug.apk'
fi
if [[ ! -d "$output_dir" ]]; then
return 0
fi
find "${ANDROID_DIR}/app/build/outputs/apk/play/debug" -maxdepth 1 -name '*-play-debug.apk' -print 2>/dev/null | sort | tail -n 1
find "$output_dir" -maxdepth 1 -name "$pattern" -print 2>/dev/null | sort | tail -n 1
}
scene_ready_text() {
if [[ "$FORM_FACTOR" == "wear" ]]; then
case "$1" in
chat) printf '%s\n' "Release planning" ;;
voice) printf '%s\n' "Dictate" ;;
controls) printf '%s\n' "Gateway connected" ;;
*)
echo "Unknown Wear OS screenshot scene: $1" >&2
return 1
;;
esac
return
fi
case "$1" in
home) printf '%s\n' "Overview" ;;
# The screenshot fixture seeds chat history and restores at the latest user
# turn, so wait for that visible anchor instead of empty-chat copy.
chat) printf '%s\n' "Draft a short status update for the team." ;;
voice) printf '%s\n' "Ready to talk" ;;
settings) printf '%s\n' "OpenClaw mobile" ;;
voice-wake) printf '%s\n' "Wake listener" ;;
# Connected fixtures can push Add Gateway below the composed viewport, so
@@ -509,6 +602,13 @@ wait_for_scene_ready() {
marker="$(scene_ready_text "$scene")"
while (( SECONDS < deadline )); do
if "$adb" -s "$serial" exec-out uiautomator dump /dev/tty >"$dump_path" 2>/dev/null; then
# A newly created Wear AVD may cover the resumed app with its charging
# overlay after stay-awake is enabled. Dismiss it or first-run capture stalls.
if [[ "$FORM_FACTOR" == "wear" ]] && grep -Fq 'com.google.android.wearable.sysui:id/charging_container' "$dump_path"; then
"$adb" -s "$serial" shell input keyevent 4 >/dev/null 2>&1 || true
sleep 0.5
continue
fi
if grep -Fq "$marker" "$dump_path"; then
return
fi
@@ -566,6 +666,8 @@ write_artifact_manifest() {
{
printf 'git_sha=%s\n' "$git_sha"
printf 'form_factor=%s\n' "$FORM_FACTOR"
printf 'output_type=%s\n' "$OUTPUT_TYPE"
printf 'device=%s\n' "$serial"
printf 'avd=%s\n' "${avd_name:-unknown}"
printf 'locale=%s\n' "$LOCALE"
@@ -578,12 +680,13 @@ write_artifact_manifest() {
} >"$ARTIFACT_DIR/manifest.txt"
}
OUTPUT_DIR="${ANDROID_DIR}/fastlane/metadata/android/${LOCALE}/images/phoneScreenshots"
OUTPUT_DIR="${ANDROID_DIR}/fastlane/metadata/android/${LOCALE}/images/${OUTPUT_TYPE}"
ADB_SERIAL=""
ADB_DISPLAY="${DEVICE:-<auto>}"
echo "Android screenshot output: ${OUTPUT_DIR}"
echo "Android screenshot artifacts: ${ARTIFACT_DIR}"
echo "Android screenshot form factor: ${FORM_FACTOR}"
echo "Android screenshot size: ${SCREENSHOT_SIZE}"
echo "Scenes: ${SCENES[*]}"
echo "ADB device: ${ADB_DISPLAY}"
@@ -610,19 +713,19 @@ if [[ "$SKIP_INSTALL" != "1" ]]; then
if [[ "$SKIP_BUILD" != "1" ]]; then
(
cd "$ANDROID_DIR"
./gradlew :app:assemblePlayDebug
./gradlew "$GRADLE_ASSEMBLE_TASK"
)
fi
APK_PATH="$(latest_play_debug_apk)"
APK_PATH="$(latest_debug_apk)"
if [[ -z "$APK_PATH" ]]; then
echo "No existing Play debug APK found. Run without --skip-build first." >&2
echo "No existing ${FORM_FACTOR} debug APK found. Run without --skip-build first." >&2
exit 1
fi
"$ADB_BIN" -s "$ADB_SERIAL" install -r "$APK_PATH" >/dev/null
elif [[ "$SKIP_BUILD" != "1" ]]; then
(
cd "$ANDROID_DIR"
./gradlew :app:assemblePlayDebug
./gradlew "$GRADLE_ASSEMBLE_TASK"
)
fi
@@ -637,8 +740,10 @@ for scene in "${SCENES[@]}"; do
ui_dump_path="${ARTIFACT_DIR}/ui-dumps/openclaw-${scene}.xml"
activity_start_path="${ARTIFACT_DIR}/activity-start/openclaw-${scene}.txt"
"$ADB_BIN" -s "$ADB_SERIAL" shell am force-stop ai.openclaw.app >/dev/null
"$ADB_BIN" -s "$ADB_SERIAL" shell input keyevent 224 >/dev/null 2>&1 || true
"$ADB_BIN" -s "$ADB_SERIAL" shell wm dismiss-keyguard >/dev/null 2>&1 || true
"$ADB_BIN" -s "$ADB_SERIAL" shell am start -W \
-n ai.openclaw.app/.MainActivity \
-n "$ACTIVITY_COMPONENT" \
--ez openclaw.screenshotMode true \
--es openclaw.screenshotScene "$scene" >"$activity_start_path"
wait_for_scene_ready "$ADB_BIN" "$ADB_SERIAL" "$scene" "$ui_dump_path"
+2 -1
View File
@@ -14,6 +14,7 @@ import os from "node:os";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import type { AuthProfileCredential } from "../src/agents/auth-profiles.js";
import {
parseBooleanEnv,
@@ -140,7 +141,7 @@ function summarizeText(text: string, max = 120): string {
if (normalized.length <= max) {
return normalized;
}
return `${normalized.slice(0, max - 1)}`;
return `${truncateUtf16Safe(normalized, max - 1)}`;
}
function summarizeCapture(
+195 -84
View File
@@ -37,15 +37,20 @@ const INFLECTED_COUNT_SEGMENT_RE =
/\^\[[^\]]*\\\([A-Za-z_][A-Za-z0-9_]*\)[^\]]*\]\(inflect: true\)/gu;
const INFLECTED_COUNT_MARKER = "](inflect: true)";
const IOS_CATALOG_PATH = "apps/ios/Resources/Localizable.xcstrings";
const MACOS_CATALOG_PATH = "apps/macos/Sources/OpenClaw/Resources/Localizable.xcstrings";
const MACOS_INFO_PLIST_PATH = "apps/macos/Sources/OpenClaw/Resources/Info.plist";
const IOS_CONTRADICTIONS_PATH = "apps/.i18n/apple-translation-contradictions.json";
const NATIVE_SOURCE_PATH = "apps/.i18n/native-source.json";
const NATIVE_TRANSLATIONS_DIR = "apps/.i18n/native";
const SHARED_CHAT_UI_SOURCE_PREFIX = "apps/shared/OpenClawKit/Sources/OpenClawChatUI/";
const SHARED_GATEWAY_DISCOVERY_STATUS_SOURCE =
"apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayDiscoveryStatusText.swift";
const IOS_SOURCE_PREFIXES = [
"apps/ios/",
"apps/shared/OpenClawKit/Sources/OpenClawChatUI/",
SHARED_CHAT_UI_SOURCE_PREFIX,
"apps/shared/OpenClawKit/Sources/OpenClawKit/",
] as const;
const IOS_CATALOG_KINDS = new Set([
const APPLE_CATALOG_KINDS = new Set([
"conditional-branch",
"ui-call",
"ui-call-multiline",
@@ -60,6 +65,15 @@ const IOS_CATALOG_EXCLUSIONS = new Set([
"OpenClaw",
"z",
]);
const MACOS_SOURCE_PREFIXES = [
"apps/macos/Sources/OpenClaw/",
SHARED_CHAT_UI_SOURCE_PREFIX,
SHARED_GATEWAY_DISCOVERY_STATUS_SOURCE,
] as const;
const MACOS_CATALOG_EXCLUSIONS = new Set([
// Product names are intentionally verbatim.
"OpenClaw",
]);
const IOS_INFO_PLIST_TARGETS = [
{
outputRoot: "apps/ios/Sources",
@@ -78,6 +92,7 @@ const IOS_INFO_PLIST_TARGETS = [
sourcePath: "apps/ios/ActivityWidget/Info.plist",
},
] as const;
const INFO_PLIST_LOCALIZABLE_KEYS = new Set(["NSScreenCaptureDescription"]);
const AMBIGUOUS_RUNTIME_INTERPOLATIONS = [
{
label: "interpolated localized resource",
@@ -143,6 +158,18 @@ const APPLE_LOCALE_DIRECTORIES: Record<string, string> = {
"zh-TW": "zh-Hant",
};
const LOCALIZED_WRAPPER_CONTRACTS: Record<string, readonly string[]> = {
"apps/macos/Sources/OpenClaw/SettingsComponents.swift": [
"enum SettingsTextValue: ExpressibleByStringLiteral",
"case localized(LocalizedStringKey)",
"case verbatim(String)",
"static func localized(_ value: String) -> Self",
"struct SettingsPageHeader: View {\n let title: SettingsTextValue\n let subtitle: SettingsTextValue?",
"struct SettingsCardGroup<Content: View>: View {\n let title: SettingsTextValue",
"struct SettingsCardRow<Content: View>: View {\n let title: SettingsTextValue\n let subtitle: SettingsTextValue?",
"struct SettingsCardToggleRow: View {\n let title: SettingsTextValue\n let subtitle: SettingsTextValue?",
"struct SettingsToggleRow: View {\n let title: SettingsTextValue\n let subtitle: SettingsTextValue?",
"Text(verbatim: value)",
],
"apps/ios/Sources/Design/OpenClawProComponents.swift": [
"enum OpenClawTextValue: ExpressibleByStringLiteral",
"struct ProSectionHeader: View {\n let title: OpenClawTextValue",
@@ -218,6 +245,18 @@ const LOCALIZED_WRAPPER_CONTRACTS: Record<string, readonly string[]> = {
"private func detailMetric(label: OpenClawTextValue, value: String)",
"title: OpenClawTextValue,\n detail: OpenClawTextValue",
],
"apps/ios/Sources/Design/AgentProDreamingDestination.swift": [
"private func detailMetric(label: OpenClawTextValue, value: String)",
"label.text",
"Text(verbatim: value)",
],
"apps/ios/Sources/Design/AgentProTab+DetailComponents.swift": [
"func detailMetric(label: OpenClawTextValue, value: String)",
"Text(verbatim: value)",
"func emptyDetailRow(\n icon: String,\n title: OpenClawTextValue,\n detail: OpenClawTextValue)",
"title.text",
"detail.text",
],
"apps/ios/Sources/Design/CommandCenterSupport.swift": [
"Text(verbatim: self.item.title)",
"Text(verbatim: self.item.trailing)",
@@ -283,6 +322,12 @@ const LOCALIZED_WRAPPER_CONTRACTS: Record<string, readonly string[]> = {
],
};
const RAW_LOCALIZATION_BYPASSES: Record<string, readonly string[]> = {
"apps/macos/Sources/OpenClaw/SettingsComponents.swift": [
"let title: String",
"let subtitle: String?",
"Text(self.title)",
"Text(subtitle)",
],
"apps/ios/Sources/Design/SettingsProTabSections.swift": [
"func settingsListRow(\n icon: String,\n iconColor: Color,\n title: String",
"func aboutLinkRow(title: String",
@@ -372,19 +417,6 @@ const RAW_LOCALIZATION_BYPASSES: Record<string, readonly string[]> = {
],
};
const MACOS_CATALOG = {
path: "apps/macos/Sources/OpenClaw/Resources/Localizable.xcstrings",
coverage: {
"apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelSections.swift": [
"Logout",
"Refresh",
"Save",
],
"apps/macos/Sources/OpenClaw/CronSettings+Rows.swift": ["Run now"],
"apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift": ["Wake up, my friend!"],
},
} as const;
type StringUnit = {
state?: string;
value?: string;
@@ -467,7 +499,8 @@ function parseInfoPlistStrings(source: string): Array<{ key: string; source: str
source: decodeXml(match[2] ?? ""),
}))
.filter(
(entry) => entry.key === "CFBundleDisplayName" || entry.key.endsWith("UsageDescription"),
(entry) =>
entry.key.endsWith("UsageDescription") || INFO_PLIST_LOCALIZABLE_KEYS.has(entry.key),
);
}
@@ -515,6 +548,36 @@ export function infoPlistTranslationCandidates(
);
}
function infoPlistSourceIds(nativeSource: NativeSourceArtifact): Map<string, string> {
return new Map(
nativeSource.entries
.filter((entry) => entry.kind === "plist-string")
.map((entry) => [[entry.path, entry.source].join("\u0000"), entry.id]),
);
}
function renderInfoPlistStrings(
sourcePath: string,
sourceEntries: ReadonlyArray<{ key: string; source: string }>,
sourceIds: ReadonlyMap<string, string>,
artifact: NativeTranslationArtifact | undefined,
existing: ReadonlyMap<string, InfoPlistTranslation> = new Map(),
): string {
const lines = sourceEntries.map(({ key, source }) => {
const sourceId = sourceIds.get([sourcePath, source].join("\u0000"));
if (!sourceId) {
throw new Error(`missing native InfoPlist source id for ${sourcePath}:${key}`);
}
const candidates = infoPlistTranslationCandidates(artifact, sourceId, source);
const value = selectInfoPlistTranslation(source, candidates, existing.get(key));
return [
`/* OpenClaw source: ${stringsLiteral(source)} */`,
`${stringsLiteral(key)} = ${stringsLiteral(value)};`,
].join("\n");
});
return `${lines.join("\n")}\n`;
}
async function readOptionalFile(filePath: string): Promise<string | null> {
try {
return await readFile(filePath, "utf8");
@@ -530,12 +593,22 @@ function isIosCatalogEntry(entry: NativeSourceEntry): boolean {
return (
entry.surface === "apple" &&
IOS_SOURCE_PREFIXES.some((prefix) => entry.path.startsWith(prefix)) &&
IOS_CATALOG_KINDS.has(entry.kind) &&
APPLE_CATALOG_KINDS.has(entry.kind) &&
(!entry.source.includes("\\(") || isInflectedCountSource(entry.source)) &&
!IOS_CATALOG_EXCLUSIONS.has(entry.source)
);
}
function isMacosCatalogEntry(entry: NativeSourceEntry): boolean {
return (
entry.surface === "apple" &&
MACOS_SOURCE_PREFIXES.some((prefix) => entry.path.startsWith(prefix)) &&
APPLE_CATALOG_KINDS.has(entry.kind) &&
!entry.source.includes("\\(") &&
!MACOS_CATALOG_EXCLUSIONS.has(entry.source)
);
}
function isInflectedCountSource(value: string): boolean {
if (!value.includes(INFLECTED_COUNT_MARKER)) {
return false;
@@ -578,18 +651,19 @@ function chooseTranslation(source: string, translations: readonly string[]): str
);
}
export function buildIosCatalog(
function buildAppleCatalog(
existingCatalog: Catalog,
nativeSource: NativeSourceArtifact,
translations: readonly NativeTranslationArtifact[],
includesEntry: (entry: NativeSourceEntry) => boolean,
): AppleCatalogBuild {
const iosEntries = nativeSource.entries.filter(isIosCatalogEntry);
const catalogEntries = iosEntries.map(
(entry) => [entry, appleCatalogValue(entry.source)] as const,
);
const catalogEntries = nativeSource.entries
.filter(includesEntry)
.map((entry) => [entry, appleCatalogValue(entry.source)] as const);
const sources = [...new Set(catalogEntries.map(([, source]) => source))].toSorted(
compareCodeUnits,
);
const sourceSet = new Set(sources);
const appleIdsBySource = new Map<string, Set<string>>();
for (const [entry, source] of catalogEntries) {
const ids = appleIdsBySource.get(source) ?? new Set<string>();
@@ -602,7 +676,7 @@ export function buildIosCatalog(
const bySource = new Map<string, string[]>();
for (const entry of artifact.entries) {
const source = appleCatalogValue(entry.source);
if (!sources.includes(source)) {
if (!sourceSet.has(source)) {
continue;
}
const appleIds = appleIdsBySource.get(source);
@@ -666,6 +740,22 @@ export function buildIosCatalog(
};
}
export function buildIosCatalog(
existingCatalog: Catalog,
nativeSource: NativeSourceArtifact,
translations: readonly NativeTranslationArtifact[],
): AppleCatalogBuild {
return buildAppleCatalog(existingCatalog, nativeSource, translations, isIosCatalogEntry);
}
export function buildMacosCatalog(
existingCatalog: Catalog,
nativeSource: NativeSourceArtifact,
translations: readonly NativeTranslationArtifact[],
): AppleCatalogBuild {
return buildAppleCatalog(existingCatalog, nativeSource, translations, isMacosCatalogEntry);
}
async function listSwiftFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const nested = await Promise.all(
@@ -744,6 +834,17 @@ async function readIosCatalogBuild(): Promise<AppleCatalogBuild> {
return buildIosCatalog(existingCatalog, nativeSource, translations);
}
async function readMacosCatalogBuild(): Promise<AppleCatalogBuild> {
const existingCatalog = JSON.parse(
await readFile(path.join(ROOT, MACOS_CATALOG_PATH), "utf8"),
) as Catalog;
const nativeSource = JSON.parse(
await readFile(path.join(ROOT, NATIVE_SOURCE_PATH), "utf8"),
) as NativeSourceArtifact;
const translations = await readNativeTranslations();
return buildMacosCatalog(existingCatalog, nativeSource, translations);
}
function validateCatalog(pathName: string, catalog: Catalog): number {
if (catalog.sourceLanguage !== "en" || catalog.version !== "1.0" || !catalog.strings) {
throw new Error(`invalid Apple string catalog: ${pathName}`);
@@ -780,11 +881,7 @@ async function syncIosInfoPlist(write: boolean): Promise<number> {
const nativeSource = JSON.parse(
await readFile(path.join(ROOT, NATIVE_SOURCE_PATH), "utf8"),
) as NativeSourceArtifact;
const sourceIds = new Map(
nativeSource.entries
.filter((entry) => entry.kind === "plist-string")
.map((entry) => [[entry.path, entry.source].join("\u0000"), entry.id]),
);
const sourceIds = infoPlistSourceIds(nativeSource);
let checked = 0;
for (const target of IOS_INFO_PLIST_TARGETS) {
const sourceEntries = parseInfoPlistStrings(
@@ -801,22 +898,13 @@ async function syncIosInfoPlist(write: boolean): Promise<number> {
const existingSource = await readOptionalFile(outputPath);
const existing = parseStringsFile(existingSource ?? "");
const artifact = translations.find((candidate) => candidate.locale === locale);
const lines = sourceEntries.map(({ key, source }) => {
if (key === "CFBundleDisplayName") {
return `${stringsLiteral(key)} = ${stringsLiteral(source)};`;
}
const sourceId = sourceIds.get([target.sourcePath, source].join("\u0000"));
if (!sourceId) {
throw new Error(`missing native InfoPlist source id for ${target.sourcePath}:${key}`);
}
const candidates = infoPlistTranslationCandidates(artifact, sourceId, source);
const value = selectInfoPlistTranslation(source, candidates, existing.get(key));
return [
`/* OpenClaw source: ${stringsLiteral(source)} */`,
`${stringsLiteral(key)} = ${stringsLiteral(value)};`,
].join("\n");
});
const expected = `${lines.join("\n")}\n`;
const expected = renderInfoPlistStrings(
target.sourcePath,
sourceEntries,
sourceIds,
artifact,
existing,
);
if (existingSource !== expected) {
if (!write) {
throw new Error(
@@ -859,18 +947,42 @@ export async function syncIosCatalog(write: boolean): Promise<AppleCatalogBuild>
return build;
}
export async function syncMacosCatalog(write: boolean): Promise<AppleCatalogBuild> {
const build = await readMacosCatalogBuild();
const catalogPath = path.join(ROOT, MACOS_CATALOG_PATH);
const expected = serializeCatalog(build.catalog);
const actual = await readFile(catalogPath, "utf8");
if (actual !== expected) {
if (!write) {
assertMacosCatalogCurrent(actual, build);
return build;
}
await writeFile(catalogPath, expected, "utf8");
}
return build;
}
export function assertMacosCatalogCurrent(actual: string, build: AppleCatalogBuild): void {
if (actual !== serializeCatalog(build.catalog)) {
throw new Error(
`Apple catalog ${MACOS_CATALOG_PATH} is stale; run native-app-i18n.ts sync --write`,
);
}
}
/**
* Regenerates every Apple derived artifact (iOS catalog, contradiction report,
* Regenerates every Apple derived artifact (app catalogs, contradiction report,
* InfoPlist strings). Shared by this CLI and native-app-i18n's sync so the
* inventory can never be rewritten without its derived catalogs.
*/
export async function syncAppleAppI18n(): Promise<{
build: AppleCatalogBuild;
infoPlistFiles: number;
macosBuild: AppleCatalogBuild;
}> {
const build = await syncIosCatalog(true);
const [build, macosBuild] = await Promise.all([syncIosCatalog(true), syncMacosCatalog(true)]);
const infoPlistFiles = await syncIosInfoPlist(true);
return { build, infoPlistFiles };
return { build, infoPlistFiles, macosBuild };
}
export async function verifyAppleAppI18n() {
@@ -894,48 +1006,29 @@ export async function verifyAppleAppI18n() {
}
}
const macosCatalog = JSON.parse(
await readFile(path.join(ROOT, MACOS_CATALOG.path), "utf8"),
) as Catalog;
if (!macosCatalog.strings) {
throw new Error(`invalid Apple string catalog: ${MACOS_CATALOG.path}`);
}
const expectedMacosKeys: Set<string> = new Set(Object.values(MACOS_CATALOG.coverage).flat());
const actualMacosKeys = new Set(Object.keys(macosCatalog.strings));
const missingMacosKeys = [...expectedMacosKeys].filter((key) => !actualMacosKeys.has(key));
const extraMacosKeys = [...actualMacosKeys].filter((key) => !expectedMacosKeys.has(key));
if (missingMacosKeys.length || extraMacosKeys.length) {
throw new Error(
[
`Apple catalog ${MACOS_CATALOG.path} does not match its phased source coverage.`,
`missing=${missingMacosKeys.join(",") || "none"}`,
`extra=${extraMacosKeys.join(",") || "none"}`,
].join("\n"),
);
}
for (const [sourcePath, keys] of Object.entries(MACOS_CATALOG.coverage)) {
const source = await readFile(path.join(ROOT, sourcePath), "utf8");
const absent = keys.filter((key) => !source.includes(key));
if (absent.length) {
throw new Error(`Apple i18n coverage ${sourcePath} no longer contains: ${absent.join(", ")}`);
}
}
const macosKeys = validateCatalog(MACOS_CATALOG.path, macosCatalog);
const macosBuild = await readMacosCatalogBuild();
const macosKeys = validateCatalog(MACOS_CATALOG_PATH, macosBuild.catalog);
process.stdout.write(`apple-app-i18n: sourceMacosKeys=${macosKeys}\n`);
}
export async function checkAppleAppI18n() {
await verifyAppleAppI18n();
const iosBuild = await syncIosCatalog(false);
const [iosBuild, macosBuild] = await Promise.all([
syncIosCatalog(false),
syncMacosCatalog(false),
]);
const iosKeys = validateCatalog(IOS_CATALOG_PATH, iosBuild.catalog);
const macosKeys = validateCatalog(MACOS_CATALOG_PATH, macosBuild.catalog);
const infoPlistFiles = await syncIosInfoPlist(false);
process.stdout.write(
[
`apple-app-i18n: iosKeys=${iosKeys}`,
`macosKeys=${macosKeys}`,
`infoPlistFiles=${infoPlistFiles}`,
`translationContradictions=${iosBuild.contradictions.length}`,
`macosTranslationContradictions=${macosBuild.contradictions.length}`,
`locales=${APPLE_I18N_LOCALES.join(",")}`,
"\n",
].join(" "),
@@ -943,15 +1036,23 @@ export async function checkAppleAppI18n() {
}
export async function compileMacosLocalizations(outputDir: string) {
// Source PRs intentionally leave generated iOS catalogs for the serialized
// post-merge refresh. Packaging only needs the source-owned macOS contract.
// Source PRs intentionally leave generated Apple catalogs for the serialized
// post-merge refresh. Package from the derived catalog so source changes
// cannot ship stale localization coverage before that refresh lands.
await verifyAppleAppI18n();
const catalog = JSON.parse(
await readFile(path.join(ROOT, MACOS_CATALOG.path), "utf8"),
) as Catalog;
const catalog = (await readMacosCatalogBuild()).catalog;
if (!catalog.strings) {
throw new Error(`invalid Apple string catalog: ${MACOS_CATALOG.path}`);
throw new Error(`invalid Apple string catalog: ${MACOS_CATALOG_PATH}`);
}
const [nativeSource, translations, infoPlistSource] = await Promise.all([
readFile(path.join(ROOT, NATIVE_SOURCE_PATH), "utf8").then(
(source) => JSON.parse(source) as NativeSourceArtifact,
),
readNativeTranslations(),
readFile(path.join(ROOT, MACOS_INFO_PLIST_PATH), "utf8"),
]);
const sourceIds = infoPlistSourceIds(nativeSource);
const infoPlistEntries = parseInfoPlistStrings(infoPlistSource);
for (const locale of REQUIRED_LOCALES) {
const localeDir = APPLE_LOCALE_DIRECTORIES[locale] ?? locale;
@@ -962,13 +1063,23 @@ export async function compileMacosLocalizations(outputDir: string) {
const value = entry.localizations?.[locale]?.stringUnit?.value;
if (!value) {
throw new Error(
`Apple catalog ${MACOS_CATALOG.path} is missing ${locale} for ${JSON.stringify(key)}`,
`Apple catalog ${MACOS_CATALOG_PATH} is missing ${locale} for ${JSON.stringify(key)}`,
);
}
return `${stringsLiteral(key)} = ${stringsLiteral(value)};`;
});
await mkdir(lprojDir, { recursive: true });
await writeFile(path.join(lprojDir, "Localizable.strings"), `${lines.join("\n")}\n`, "utf8");
if (locale !== "en") {
const artifact = translations.find((candidate) => candidate.locale === locale);
const infoPlistStrings = renderInfoPlistStrings(
MACOS_INFO_PLIST_PATH,
infoPlistEntries,
sourceIds,
artifact,
);
await writeFile(path.join(lprojDir, "InfoPlist.strings"), infoPlistStrings, "utf8");
}
}
}
@@ -977,9 +1088,9 @@ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.ar
if (command === "check") {
await checkAppleAppI18n();
} else if (command === "sync-ios" && flag === "--write") {
const { build, infoPlistFiles } = await syncAppleAppI18n();
const { build, infoPlistFiles, macosBuild } = await syncAppleAppI18n();
process.stdout.write(
`apple-app-i18n: synced iOS catalog and ${infoPlistFiles} InfoPlist files; contradictions=${build.contradictions.length}\n`,
`apple-app-i18n: synced Apple catalogs and ${infoPlistFiles} InfoPlist files; contradictions=${build.contradictions.length + macosBuild.contradictions.length}\n`,
);
} else if (command === "compile-macos" && flag === "--output" && value) {
await compileMacosLocalizations(path.resolve(value));
+349
View File
@@ -0,0 +1,349 @@
// Benchmarks the real Codex transcript mirror against a large indexed SQLite transcript.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { SQLInputValue } from "node:sqlite";
import { codexTranscriptMirrorRuntime } from "../extensions/codex/src/app-server/transcript-mirror.js";
import { attachCodexMirrorIdentity } from "../extensions/codex/src/app-server/upstream-prompt-provenance.js";
import { upsertSessionEntry } from "../src/config/sessions/session-accessor.js";
import type { AgentMessage } from "../src/plugin-sdk/agent-core.js";
import {
closeOpenClawAgentDatabasesForTest,
openOpenClawAgentDatabase,
} from "../src/state/openclaw-agent-db.js";
import { closeOpenClawStateDatabaseForTest } from "../src/state/openclaw-state-db.js";
const DEFAULT_EVENT_COUNT = 100_000;
const DEFAULT_PAYLOAD_BYTES = 64;
const DEFAULT_RUNS = 8;
const DEFAULT_WARMUPS = 2;
const NEW_MESSAGES_PER_OPERATION = 2;
type MirrorTarget = {
agentId: string;
sessionId: string;
sessionKey: string;
storePath: string;
};
type WorkCounters = {
fullTranscriptQueries: number;
seededEventJsonParses: number;
selectQueries: number;
};
function readIntegerArg(name: string, fallback: number): number {
const raw = process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
if (raw === undefined) {
return fallback;
}
const value = Number(raw);
if (!Number.isSafeInteger(value) || value < 1) {
throw new Error(`--${name} must be a positive integer`);
}
return value;
}
function readSourceSha(): string {
const value = process.argv
.find((arg) => arg.startsWith("--source-sha="))
?.slice("--source-sha=".length);
if (!value || !/^[a-f0-9]{40}$/u.test(value)) {
throw new Error("benchmark requires --source-sha=<40-character commit SHA>");
}
const checkoutSha = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: path.resolve(import.meta.dirname, ".."),
encoding: "utf8",
}).trim();
if (checkoutSha !== value) {
throw new Error(`source SHA ${value} does not match checkout HEAD ${checkoutSha}`);
}
return value;
}
function median(values: readonly number[]): number {
const sorted = values.toSorted((left, right) => left - right);
const upperIndex = Math.floor(sorted.length / 2);
const upper = sorted[upperIndex] ?? 0;
const lower = sorted.length % 2 === 0 ? (sorted[upperIndex - 1] ?? upper) : upper;
return Number(((lower + upper) / 2).toFixed(3));
}
function percentile(values: readonly number[], fraction: number): number {
const sorted = values.toSorted((left, right) => left - right);
const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1);
return Number((sorted[Math.max(0, index)] ?? 0).toFixed(3));
}
/** Seeds a fully indexed linear transcript without charging setup to measured owner calls. */
function seedTranscript(params: {
database: ReturnType<typeof openOpenClawAgentDatabase>;
eventCount: number;
payloadText: string;
sessionId: string;
}): void {
const { database, eventCount, payloadText, sessionId } = params;
const insertEvent = database.db.prepare(
`INSERT INTO transcript_events (session_id, seq, event_json, created_at)
VALUES (?, ?, ?, ?)`,
);
const insertIdentity = database.db.prepare(
`INSERT INTO transcript_event_identities (
session_id, event_id, seq, event_type, parent_id, message_idempotency_key, created_at
) VALUES (?, ?, ?, 'message', ?, ?, ?)`,
);
const insertActive = database.db.prepare(
`INSERT INTO session_transcript_active_events (
session_id, active_position, event_seq, message_position
) VALUES (?, ?, ?, ?)`,
);
const now = Date.now();
database.db.exec("BEGIN IMMEDIATE");
try {
for (let seq = 0; seq < eventCount; seq += 1) {
const eventId = `benchmark-event-${seq}`;
const parentId = seq === 0 ? null : `benchmark-event-${seq - 1}`;
const idempotencyKey = `seed:${sessionId}:${seq}`;
const role = seq % 2 === 0 ? "user" : "assistant";
const event = {
id: eventId,
message: {
content: role === "user" ? payloadText : [{ type: "text", text: payloadText }],
idempotencyKey,
role,
timestamp: now + seq,
},
parentId,
timestamp: now + seq,
type: "message",
};
insertEvent.run(sessionId, seq, JSON.stringify(event), now + seq);
const identityValues = [
sessionId,
eventId,
seq,
parentId,
idempotencyKey,
now + seq,
] satisfies SQLInputValue[];
insertIdentity.run(...identityValues);
insertActive.run(sessionId, seq, seq, seq);
}
database.db
.prepare(
`INSERT INTO session_transcript_index_state (
session_id, indexed_seq, leaf_event_id, needs_rebuild,
active_event_count, active_message_count, updated_at
) VALUES (?, ?, ?, 0, ?, ?, ?)`,
)
.run(
sessionId,
eventCount - 1,
`benchmark-event-${eventCount - 1}`,
eventCount,
eventCount,
now + eventCount,
);
database.db.exec("COMMIT");
} catch (error) {
database.db.exec("ROLLBACK");
throw error;
}
}
function instrumentWork(database: ReturnType<typeof openOpenClawAgentDatabase>): {
counters: WorkCounters;
reset: () => void;
restore: () => void;
} {
const counters: WorkCounters = {
fullTranscriptQueries: 0,
seededEventJsonParses: 0,
selectQueries: 0,
};
const originalPrepare = database.db.prepare.bind(database.db);
const originalParse = JSON.parse;
Object.defineProperty(database.db, "prepare", {
configurable: true,
value: (sql: string) => {
const normalized = sql.replaceAll(/\s+/gu, " ").trim().toLowerCase();
if (normalized.startsWith("select ")) {
counters.selectQueries += 1;
}
if (
/from "?transcript_events"?/u.test(normalized) &&
normalized.includes("event_json") &&
/order by "?seq"? asc/u.test(normalized)
) {
counters.fullTranscriptQueries += 1;
}
return originalPrepare(sql);
},
});
JSON.parse = ((text: string, reviver?: Parameters<typeof JSON.parse>[1]) => {
if (text.includes('"id":"benchmark-event-')) {
counters.seededEventJsonParses += 1;
}
return originalParse(text, reviver);
}) as typeof JSON.parse;
return {
counters,
reset: () => {
counters.fullTranscriptQueries = 0;
counters.seededEventJsonParses = 0;
counters.selectQueries = 0;
},
restore: () => {
Object.defineProperty(database.db, "prepare", {
configurable: true,
value: originalPrepare,
});
JSON.parse = originalParse;
},
};
}
function buildPromptFinalBatch(ordinal: number): AgentMessage[] {
return [
attachCodexMirrorIdentity(
{
role: "user",
content: `benchmark prompt ${ordinal}`,
timestamp: 2_000_000_000_000 + ordinal,
} as AgentMessage,
`turn-${ordinal}:prompt`,
),
attachCodexMirrorIdentity(
{
role: "assistant",
content: [{ type: "text", text: `benchmark final ${ordinal}` }],
timestamp: 2_000_000_100_000 + ordinal,
} as AgentMessage,
`turn-${ordinal}:assistant`,
),
];
}
async function runMirror(target: MirrorTarget, ordinal: number): Promise<void> {
await codexTranscriptMirrorRuntime.mirror({
...target,
idempotencyScope: "codex-app-server:benchmark",
messages: buildPromptFinalBatch(ordinal),
});
}
async function main(): Promise<void> {
const sourceSha = readSourceSha();
const eventCount = readIntegerArg("events", DEFAULT_EVENT_COUNT);
const payloadBytes = readIntegerArg("payload-bytes", DEFAULT_PAYLOAD_BYTES);
const runs = readIntegerArg("runs", DEFAULT_RUNS);
const warmups = readIntegerArg("warmups", DEFAULT_WARMUPS);
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-mirror-bench-"));
const agentId = "benchmark";
const sessionId = "codex-mirror-benchmark";
const sessionKey = `agent:${agentId}:${sessionId}`;
try {
const database = openOpenClawAgentDatabase({
agentId,
path: path.join(stateDir, "openclaw-agent.sqlite"),
});
await upsertSessionEntry(
{ agentId, sessionKey, storePath: database.path },
{ sessionId, updatedAt: 1 },
);
seedTranscript({
database,
eventCount,
payloadText: "x".repeat(payloadBytes),
sessionId,
});
const target = { agentId, sessionId, sessionKey, storePath: database.path };
const instrumentation = instrumentWork(database);
try {
for (let ordinal = 0; ordinal < warmups; ordinal += 1) {
await runMirror(target, ordinal);
}
instrumentation.reset();
const beforeMaxRssKb = process.resourceUsage().maxRSS;
const durations: number[] = [];
for (let run = 0; run < runs; run += 1) {
const startedAt = performance.now();
await runMirror(target, warmups + run);
durations.push(performance.now() - startedAt);
}
const afterMaxRssKb = process.resourceUsage().maxRSS;
const measuredWork = { ...instrumentation.counters };
const lastOrdinal = warmups + runs - 1;
await runMirror(target, lastOrdinal);
const row = database.db
.prepare(
`SELECT COUNT(*) AS count,
SUM(LENGTH(CAST(event_json AS BLOB))) AS bytes
FROM transcript_events
WHERE session_id = ?`,
)
.get(sessionId) as { bytes: number; count: number };
const expectedEvents = eventCount + NEW_MESSAGES_PER_OPERATION * (warmups + runs);
if (row.count !== expectedEvents) {
throw new Error(`mirror wrote ${row.count} events; expected ${expectedEvents}`);
}
console.log(
JSON.stringify(
{
sourceSha,
fixture: {
initialMessageEvents: eventCount,
payloadBytes,
sqliteTranscriptBytesAfterOperations: row.bytes,
},
operation: "real Codex mirror owner with one new prompt and one new final",
runtime: {
arch: process.arch,
node: process.version,
platform: `${os.platform()} ${os.release()}`,
},
warmups,
runs,
latencyMs: {
median: median(durations),
p95: percentile(durations, 0.95),
raw: durations.map((value) => Number(value.toFixed(3))),
},
memoryProxy: {
maxRssKbBeforeOperations: beforeMaxRssKb,
maxRssKbAfterOperations: afterMaxRssKb,
maxRssGrowthKb: Math.max(0, afterMaxRssKb - beforeMaxRssKb),
},
measuredWork: {
...measuredWork,
perOperation: {
fullTranscriptQueries: Number(
(measuredWork.fullTranscriptQueries / runs).toFixed(3),
),
seededEventJsonParses: Number(
(measuredWork.seededEventJsonParses / runs).toFixed(3),
),
selectQueries: Number((measuredWork.selectQueries / runs).toFixed(3)),
},
},
correctness: {
idempotentReplayAddedRows: 0,
storedEventCount: row.count,
},
},
null,
2,
),
);
} finally {
instrumentation.restore();
}
} finally {
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
fs.rmSync(stateDir, { force: true, recursive: true });
}
}
await main();
-1
View File
@@ -836,7 +836,6 @@ function sanitizedEnv(
TMPDIR: process.env.TMPDIR,
USER: process.env.USER ?? "openclaw-bench",
npm_config_update_notifier: "false",
OPENCLAW_CONFIG: configPath,
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_GATEWAY_RESTART_TRACE: "1",
OPENCLAW_GATEWAY_STARTUP_TRACE: "1",
+236 -8
View File
@@ -18,12 +18,17 @@ import {
import { selectSlowStartupTraceDurations } from "./lib/gateway-startup-trace-ranking.js";
type GatewayBenchCase = {
agentTopology?: "single" | "shared-eleven-plus-distinct-one";
completionTracePhase?: string;
config: Record<string, unknown>;
env?: Record<string, string>;
id: string;
name: string;
pluginActivationOnStartup?: boolean;
pluginCount?: number;
providerCatalogStallMs?: number;
providerStaticCatalogModelCount?: number;
providerStaticCatalogStallMs?: number;
};
type ProbeResult = {
@@ -41,6 +46,7 @@ type ProbeTransition = {
};
type GatewaySample = {
completionMs: number | null;
cpuCoreRatio: number | null;
cpuMs: number | null;
exitedBeforeTeardown?: boolean;
@@ -71,6 +77,7 @@ type CaseResult = {
name: string;
samples: GatewaySample[];
summary: {
completionMs: SummaryStats | null;
firstOutputMs: SummaryStats | null;
cpuCoreRatio: SummaryStats | null;
cpuMs: SummaryStats | null;
@@ -98,6 +105,7 @@ type CliOptions = {
cases: GatewayBenchCase[];
cpuProfDir?: string;
entry: string;
heapProfDir?: string;
json: boolean;
output?: string;
runs: number;
@@ -114,6 +122,7 @@ const VALUE_FLAGS = new Set([
"--case",
"--cpu-prof-dir",
"--entry",
"--heap-prof-dir",
"--output",
"--runs",
"--timeout-ms",
@@ -141,6 +150,9 @@ const BASE_CONFIG = {
},
} satisfies Record<string, unknown>;
const STALLED_CATALOG_PROVIDER_ID = "bench-catalog-stall";
const STALLED_CATALOG_MODEL_ID = "bench-model";
const GATEWAY_CASES: readonly GatewayBenchCase[] = [
{
id: "default",
@@ -153,6 +165,69 @@ const GATEWAY_CASES: readonly GatewayBenchCase[] = [
env: { OPENCLAW_SKIP_CHANNELS: "1" },
config: BASE_CONFIG,
},
{
id: "preparedRuntimeCatalogStall",
name: "gateway, prepared runtime with CPU-stalling live catalog",
env: { OPENCLAW_SKIP_CHANNELS: "1" },
providerCatalogStallMs: 2_000,
config: {
...BASE_CONFIG,
agents: {
defaults: {
model: { primary: `${STALLED_CATALOG_PROVIDER_ID}/${STALLED_CATALOG_MODEL_ID}` },
models: {
[`${STALLED_CATALOG_PROVIDER_ID}/${STALLED_CATALOG_MODEL_ID}`]: {
agentRuntime: { id: "openclaw" },
},
},
},
},
},
},
{
id: "preparedRuntimeScaleOne",
name: "gateway, prepared runtime scale with one agent",
agentTopology: "single",
completionTracePhase: "sidecars.ready",
env: { OPENCLAW_SKIP_CHANNELS: "1" },
providerStaticCatalogModelCount: 64,
providerStaticCatalogStallMs: 100,
config: {
...BASE_CONFIG,
agents: {
defaults: {
model: { primary: `${STALLED_CATALOG_PROVIDER_ID}/${STALLED_CATALOG_MODEL_ID}` },
models: {
[`${STALLED_CATALOG_PROVIDER_ID}/${STALLED_CATALOG_MODEL_ID}`]: {
agentRuntime: { id: "openclaw" },
},
},
},
},
},
},
{
id: "preparedRuntimeScaleMany",
name: "gateway, prepared runtime scale with 11 shared-workspace agents and one distinct",
agentTopology: "shared-eleven-plus-distinct-one",
completionTracePhase: "sidecars.ready",
env: { OPENCLAW_SKIP_CHANNELS: "1" },
providerStaticCatalogModelCount: 64,
providerStaticCatalogStallMs: 100,
config: {
...BASE_CONFIG,
agents: {
defaults: {
model: { primary: `${STALLED_CATALOG_PROVIDER_ID}/${STALLED_CATALOG_MODEL_ID}` },
models: {
[`${STALLED_CATALOG_PROVIDER_ID}/${STALLED_CATALOG_MODEL_ID}`]: {
agentRuntime: { id: "openclaw" },
},
},
},
},
},
},
{
id: "oneInternalHook",
name: "gateway, one configured internal hook",
@@ -314,6 +389,7 @@ function parseOptions(argv: string[] = process.argv.slice(2)): CliOptions {
cases: resolveCases(parseRepeatableFlag(argv, "--case")),
cpuProfDir: parseFlagValue(argv, "--cpu-prof-dir"),
entry: resolveEntry(parseFlagValue(argv, "--entry")),
heapProfDir: parseFlagValue(argv, "--heap-prof-dir"),
json: hasFlag(argv, "--json"),
output: resolveOutputPath(parseFlagValue(argv, "--output")),
runs: parsePositiveInt(parseFlagValue(argv, "--runs"), DEFAULT_RUNS, "--runs"),
@@ -340,6 +416,7 @@ Options:
--warmup <n> Warmup runs per case (default: ${DEFAULT_WARMUP})
--timeout-ms <ms> Per-run timeout (default: ${DEFAULT_TIMEOUT_MS})
--cpu-prof-dir <dir> Write one V8 CPU profile per run
--heap-prof-dir <dir> Write one V8 heap profile per run
--output <path> Write machine-readable JSON to a file
--json Emit machine-readable JSON
--help, -h Show this text
@@ -405,6 +482,11 @@ function summarizeCase(benchCase: GatewayBenchCase, samples: GatewaySample[]): C
name: benchCase.name,
samples,
summary: {
completionMs: summarizeNumbers(
samples
.map((sample) => sample.completionMs)
.filter((value): value is number => typeof value === "number"),
),
firstOutputMs: summarizeNumbers(
samples
.map((sample) => sample.firstOutputMs)
@@ -465,6 +547,9 @@ function collectResultFailures(
if (sample.readyz.status !== 200 || sample.readyz.ms == null) {
missing.push("/readyz");
}
if (sample.completionMs == null) {
missing.push("completion");
}
if (processMetricsRequired) {
if (sample.cpuMs == null || sample.cpuCoreRatio == null) {
missing.push("cpu");
@@ -534,7 +619,7 @@ function formatRatio(value: number | null): string {
return value.toFixed(3);
}
function formatStats(stats: SummaryStats | null): string {
function formatStats(stats: SummaryStats | null | undefined): string {
if (!stats) {
return "n/a";
}
@@ -608,21 +693,77 @@ async function waitForProbe(params: {
return { firstErrorKind, firstRecoveryMs, ms: null, status: lastStatus, transitions };
}
async function waitForStartupTracePhase(params: {
deadlineAt: number;
isDone: () => boolean;
phase: string;
startupTrace: Record<string, number>;
}): Promise<number | null> {
const totalKey = `${params.phase}.total`;
while (performance.now() < params.deadlineAt) {
if (Object.hasOwn(params.startupTrace, totalKey)) {
return params.startupTrace[totalKey] ?? null;
}
if (params.isDone()) {
return null;
}
await delay(25);
}
return null;
}
function writePluginFixtures(
root: string,
count: number,
activationOnStartup?: boolean,
providerCatalogStallMs?: number,
providerStaticCatalogStallMs?: number,
providerStaticCatalogModelCount?: number,
): PluginFixtureResult {
const pluginIds: string[] = [];
const pluginsDir = path.join(root, "plugins");
mkdirSync(pluginsDir, { recursive: true });
for (let index = 0; index < count; index += 1) {
const id = `bench-plugin-${String(index + 1).padStart(2, "0")}`;
const stallsProviderCatalog = providerCatalogStallMs !== undefined && index === 0;
const stallsProviderStaticCatalog = providerStaticCatalogStallMs !== undefined && index === 0;
pluginIds.push(id);
const pluginDir = path.join(pluginsDir, id);
mkdirSync(pluginDir, { recursive: true });
const entry = path.join(pluginDir, "index.cjs");
writeFileSync(entry, `module.exports = { id: ${JSON.stringify(id)}, register() {} };\n`);
const providerDiscoveryEntry = path.join(pluginDir, "provider-discovery.cjs");
const models = Array.from(
{ length: stallsProviderStaticCatalog ? (providerStaticCatalogModelCount ?? 1) : 1 },
(_, modelIndex) => ({
id:
modelIndex === 0
? STALLED_CATALOG_MODEL_ID
: `${STALLED_CATALOG_MODEL_ID}-${modelIndex + 1}`,
name: `Benchmark Model ${modelIndex + 1}`,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 8_192,
}),
);
const provider = {
baseUrl: "http://127.0.0.1:1/v1",
api: "openai-completions",
models,
};
const entrySource = stallsProviderCatalog
? `const provider = ${JSON.stringify(provider)};\nmodule.exports = { id: ${JSON.stringify(id)}, register(api) { api.registerProvider({ id: ${JSON.stringify(STALLED_CATALOG_PROVIDER_ID)}, label: "Benchmark Catalog Stall", auth: [], catalog: { order: "simple", run: async () => { const stopAt = Date.now() + ${providerCatalogStallMs}; while (Date.now() < stopAt) {} return { provider }; } }, staticCatalog: { order: "simple", run: async () => ({ provider }) } }); } };\n`
: stallsProviderStaticCatalog
? `const provider = ${JSON.stringify(provider)};\nmodule.exports = { id: ${JSON.stringify(id)}, register(api) { api.registerProvider({ id: ${JSON.stringify(STALLED_CATALOG_PROVIDER_ID)}, label: "Benchmark Static Catalog Stall", auth: [], staticCatalog: { order: "simple", run: async () => ({ provider }) } }); } };\n`
: `module.exports = { id: ${JSON.stringify(id)}, register() {} };\n`;
writeFileSync(entry, entrySource);
if (stallsProviderStaticCatalog) {
writeFileSync(
providerDiscoveryEntry,
`const provider = ${JSON.stringify(provider)};\nlet staticCatalogCallCount = 0;\nmodule.exports = { id: ${JSON.stringify(STALLED_CATALOG_PROVIDER_ID)}, label: "Benchmark Static Catalog Stall", auth: [], staticCatalog: { order: "simple", run: async () => { staticCatalogCallCount += 1; console.log("startup trace: benchmark preparedRuntimeStaticCatalogCallCount=" + staticCatalogCallCount); const stopAt = Date.now() + ${providerStaticCatalogStallMs}; while (Date.now() < stopAt) {} return { provider }; } } };\n`,
);
}
writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
`${JSON.stringify(
@@ -631,6 +772,21 @@ function writePluginFixtures(
...(activationOnStartup === undefined
? {}
: { activation: { onStartup: activationOnStartup } }),
...(stallsProviderCatalog || stallsProviderStaticCatalog
? {
providers: [STALLED_CATALOG_PROVIDER_ID],
...(stallsProviderStaticCatalog
? { providerCatalogEntry: "./provider-discovery.cjs" }
: {}),
...(stallsProviderCatalog
? {
modelCatalog: {
providers: { [STALLED_CATALOG_PROVIDER_ID]: provider },
},
}
: {}),
}
: {}),
configSchema: { type: "object", additionalProperties: false },
},
null,
@@ -641,12 +797,53 @@ function writePluginFixtures(
return { pluginIds, pluginsDir };
}
function buildBenchAgentList(
root: string,
topology: GatewayBenchCase["agentTopology"],
): Array<{ id: string; default?: boolean; workspace: string }> | undefined {
if (!topology) {
return undefined;
}
const sharedWorkspace = path.join(root, "shared-workspace");
const distinctWorkspace = path.join(root, "distinct-workspace");
mkdirSync(sharedWorkspace, { recursive: true });
if (topology === "single") {
return [{ id: "main", default: true, workspace: sharedWorkspace }];
}
mkdirSync(distinctWorkspace, { recursive: true });
return Array.from({ length: 12 }, (_, index) => ({
id: `agent-${String(index + 1).padStart(2, "0")}`,
...(index === 0 ? { default: true } : {}),
workspace: index === 11 ? distinctWorkspace : sharedWorkspace,
}));
}
function writeConfig(root: string, benchCase: GatewayBenchCase): string {
const pluginFixtures = benchCase.pluginCount
? writePluginFixtures(root, benchCase.pluginCount, benchCase.pluginActivationOnStartup)
const hasCatalogFixture =
benchCase.providerCatalogStallMs !== undefined ||
benchCase.providerStaticCatalogStallMs !== undefined;
const pluginCount = hasCatalogFixture ? 1 : benchCase.pluginCount;
const pluginFixtures = pluginCount
? writePluginFixtures(
root,
pluginCount,
hasCatalogFixture ? true : benchCase.pluginActivationOnStartup,
benchCase.providerCatalogStallMs,
benchCase.providerStaticCatalogStallMs,
benchCase.providerStaticCatalogModelCount,
)
: null;
const agentList = buildBenchAgentList(root, benchCase.agentTopology);
const config = {
...benchCase.config,
...(agentList
? {
agents: {
...(benchCase.config.agents as Record<string, unknown> | undefined),
list: agentList,
},
}
: {}),
plugins: {
...(benchCase.config.plugins as Record<string, unknown> | undefined),
...(pluginFixtures
@@ -678,7 +875,6 @@ function sanitizedEnv(
TMPDIR: process.env.TMPDIR,
USER: process.env.USER ?? "openclaw-bench",
npm_config_update_notifier: "false",
OPENCLAW_CONFIG: configPath,
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_GATEWAY_STARTUP_TRACE: "1",
OPENCLAW_HOME: root,
@@ -751,6 +947,7 @@ async function runGatewaySample(options: {
benchCase: GatewayBenchCase;
cpuProfDir?: string;
entry: string;
heapProfDir?: string;
sampleIndex: number;
timeoutMs: number;
}): Promise<GatewaySample> {
@@ -780,6 +977,7 @@ async function runGatewaySample(options: {
`openclaw-gateway-${options.benchCase.id}-${options.sampleIndex}-${Date.now()}.cpuprofile`,
]
: []),
...(options.heapProfDir ? ["--heap-prof", "--heap-prof-dir", options.heapProfDir] : []),
options.entry,
"gateway",
"run",
@@ -858,10 +1056,18 @@ async function runGatewaySample(options: {
startAt,
}),
]);
const readyAt = performance.now();
const completionMs = options.benchCase.completionTracePhase
? await waitForStartupTracePhase({
deadlineAt,
isDone: () => childExited,
phase: options.benchCase.completionTracePhase,
startupTrace,
})
: performance.now() - startAt;
const completedAt = performance.now();
const cpuEndMs = readProcessTreeCpuMs(child.pid);
const cpuMs = cpuStartMs == null || cpuEndMs == null ? null : Math.max(0, cpuEndMs - cpuStartMs);
const cpuCoreRatio = cpuMs == null ? null : cpuMs / Math.max(1, readyAt - startAt);
const cpuCoreRatio = cpuMs == null ? null : cpuMs / Math.max(1, completedAt - startAt);
const exit = await stopChild(child);
clearInterval(rssTimer);
sampleRss();
@@ -870,6 +1076,7 @@ async function runGatewaySample(options: {
rmSync(root, { force: true, maxRetries: 3, recursive: true, retryDelay: 100 });
return {
completionMs,
cpuCoreRatio,
cpuMs,
exitedBeforeTeardown: exit.exitedBeforeTeardown,
@@ -892,6 +1099,7 @@ async function runCase(options: {
benchCase: GatewayBenchCase;
cpuProfDir?: string;
entry: string;
heapProfDir?: string;
runs: number;
timeoutMs: number;
warmup: number;
@@ -903,6 +1111,7 @@ async function runCase(options: {
benchCase: options.benchCase,
cpuProfDir: options.cpuProfDir,
entry: options.entry,
heapProfDir: options.heapProfDir,
sampleIndex: index + 1,
timeoutMs: options.timeoutMs,
});
@@ -910,8 +1119,18 @@ async function runCase(options: {
samples.push(sample);
const heapUsedMb = sample.startupTrace["memory.ready.heapUsedMb"] ?? null;
console.log(
`[gateway-startup-bench] ${options.benchCase.id} run ${samples.length}/${options.runs}: healthz=${formatMs(sample.healthz.ms)} readyz=${formatMs(sample.readyz.ms)} httpListen=${formatMs(sample.httpListenLogMs)} gatewayReady=${formatMs(sample.gatewayReadyLogMs)} cpu=${formatMs(sample.cpuMs)} cpuCore=${formatRatio(sample.cpuCoreRatio)} rss=${formatMb(sample.maxRssMb)} heap=${formatMb(heapUsedMb)}`,
`[gateway-startup-bench] ${options.benchCase.id} run ${samples.length}/${options.runs}: completion=${formatMs(sample.completionMs)} healthz=${formatMs(sample.healthz.ms)} readyz=${formatMs(sample.readyz.ms)} httpListen=${formatMs(sample.httpListenLogMs)} gatewayReady=${formatMs(sample.gatewayReadyLogMs)} cpu=${formatMs(sample.cpuMs)} cpuCore=${formatRatio(sample.cpuCoreRatio)} rss=${formatMb(sample.maxRssMb)} heap=${formatMb(heapUsedMb)}`,
);
if (
sample.outputTail &&
(sample.completionMs == null ||
sample.healthz.status !== 200 ||
sample.readyz.status !== 200)
) {
console.error(
`[gateway-startup-bench] ${options.benchCase.id} output tail:\n${sample.outputTail}`,
);
}
} else {
const heapUsedMb = sample.startupTrace["memory.ready.heapUsedMb"] ?? null;
console.log(
@@ -924,6 +1143,7 @@ async function runCase(options: {
function printResult(result: CaseResult): void {
console.log(`\n${result.name} (${result.id})`);
console.log(` completion: ${formatStats(result.summary.completionMs)}`);
console.log(` first output: ${formatStats(result.summary.firstOutputMs)}`);
console.log(` CPU: ${formatStats(result.summary.cpuMs)}`);
console.log(` CPU core: ${formatRatioStats(result.summary.cpuCoreRatio)}`);
@@ -938,6 +1158,9 @@ function printResult(result: CaseResult): void {
console.log(
` post-ready memory: rss=${formatMemoryStats(result.summary.startupTrace["memory.post-ready.rssMb"])} heap=${formatMemoryStats(result.summary.startupTrace["memory.post-ready.heapUsedMb"])} external=${formatMemoryStats(result.summary.startupTrace["memory.post-ready.externalMb"])}`,
);
console.log(
` prepared runtime: agents=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.agentCount"])} workspaces=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.workspaceGroupCount"])} configuredGroups=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.configuredFactsGroupCount"])} configuredModels=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.configuredRuntimeModelCount"])} generatedPlugins=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.generatedCatalogPluginCount"])} generatedReads=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.generatedCatalogReadCount"])} sources=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.catalogSourceCount"])} credentials=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.credentialGroupCount"])} catalogs=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.catalogGroupCount"])} registries=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.runtimeRegistryCount"])} workspaceFacts=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.workspaceFactsMs"])} runtimePlugins=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.runtimePluginMs"])} metadata=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.pluginMetadataMs"])} staticProviders=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.staticProviderCatalogMs"])} ambientAuth=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.ambientCredentialsMs"])} agentFacts=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.agentFactsMs"])} configuredProjection=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.configuredProjectionMs"])} sourceMs=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.catalogSourceMs"])} registryMs=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.registryMs"])} sourceLimit=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.sourceConcurrencyLimitCount"])} fullCatalogLimit=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.fullCatalogConcurrencyLimitCount"])} staticCatalog=${formatStats(result.summary.startupTrace["benchmark.preparedRuntimeStaticCatalogCallCount"])} pluginLoader=${formatStats(result.summary.startupTrace["sidecars.plugin-loader.callsCount"])} eventLoopMax=${formatStats(result.summary.startupTrace["sidecars.model-runtime.eventLoopMax"])}`,
);
const trace = selectSlowStartupTraceDurations(result.summary.startupTrace, 8);
if (trace.length > 0) {
console.log(" trace top:");
@@ -958,6 +1181,9 @@ async function main() {
if (options.cpuProfDir) {
mkdirSync(options.cpuProfDir, { recursive: true });
}
if (options.heapProfDir) {
mkdirSync(options.heapProfDir, { recursive: true });
}
const results: CaseResult[] = [];
for (const benchCase of options.cases) {
results.push(
@@ -965,6 +1191,7 @@ async function main() {
benchCase,
cpuProfDir: options.cpuProfDir,
entry: options.entry,
heapProfDir: options.heapProfDir,
runs: options.runs,
timeoutMs: options.timeoutMs,
warmup: options.warmup,
@@ -1010,6 +1237,7 @@ export const testing = {
summarizeCase,
validateCliArgs,
waitForProbe,
waitForStartupTracePhase,
writeConfig,
};
+21
View File
@@ -33,6 +33,24 @@ function printProofLines(report: ReliabilityReport): void {
);
console.log(`SQLITE_RELIABILITY_RESTORES_VERIFIED=${report.restoresVerified}`);
console.log(`SQLITE_RELIABILITY_WRITER_ROWS=${report.writer.rowsCommitted}`);
console.log(
`SQLITE_RELIABILITY_CRASH_RECOVERY=${report.crashRecoveryProof.sourceRecovered && report.crashRecoveryProof.committedStatePreserved && report.crashRecoveryProof.writerRestarted ? "verified" : "missing"}`,
);
console.log(
`SQLITE_RELIABILITY_CRASH_EXIT_SIGNAL=${report.crashRecoveryProof.exit.signal ?? "none"}`,
);
console.log(
`SQLITE_RELIABILITY_PUBLICATION_INTERRUPTION=${report.publicationInterruptionProof.beforePublish.recoveryVerified && report.publicationInterruptionProof.afterPublish.targetVerifiedAfterCrash && report.publicationInterruptionProof.afterPublish.recoveryVerified ? "verified" : "missing"}`,
);
console.log(
`SQLITE_RELIABILITY_RESTORE_INTERRUPTION=${report.maintenanceProof.restoreInterruption.beforePublish.recoveryVerified && report.maintenanceProof.restoreInterruption.beforePublish.retryRestored && report.maintenanceProof.restoreInterruption.afterPublish.targetVerifiedAfterCrash && report.maintenanceProof.restoreInterruption.afterPublish.existingTargetPreserved ? "verified" : "missing"}`,
);
console.log(
`SQLITE_RELIABILITY_REPOSITORY_INTERRUPTION=${report.maintenanceProof.repositoryInterruption.beforePending.repositoryVerified && report.maintenanceProof.repositoryInterruption.beforePending.retryCreated && report.maintenanceProof.repositoryInterruption.pending.crashSnapshotVerifiedAfterCrash && report.maintenanceProof.repositoryInterruption.pending.crashSnapshotVisibleAfterCrash && report.maintenanceProof.repositoryInterruption.pending.incompleteEntries === 0 && report.maintenanceProof.repositoryInterruption.pending.retryCreated && report.maintenanceProof.repositoryInterruption.afterCommit.crashSnapshotVerifiedAfterCrash && report.maintenanceProof.repositoryInterruption.afterCommit.retryCreated ? "verified" : "missing"}`,
);
console.log(
`SQLITE_RELIABILITY_INDEX_REPAIR_INTERRUPTION=${report.indexRepairInterruptionProof.rollbackJournal.recoveryVerified && report.indexRepairInterruptionProof.wal.recoveryVerified ? "verified" : "missing"}`,
);
console.log(
`SQLITE_RELIABILITY_WAL_SENTINEL=${report.transactionProof.committedWalSentinel ? "verified" : "missing"}`,
);
@@ -43,6 +61,9 @@ function printProofLines(report: ReliabilityReport): void {
console.log(
`SQLITE_RELIABILITY_COMPACT_RECLAIMED_BYTES=${report.maintenanceProof.compaction.reclaimedBytes}`,
);
console.log(
`SQLITE_RELIABILITY_VACUUM_INTERRUPTION=${report.maintenanceProof.vacuumInterruption.recoveryVerified ? "verified" : "missing"}`,
);
console.log(
`SQLITE_RELIABILITY_POST_COMPACT_RESTORE=${report.maintenanceProof.postCompact.restoreVerified ? "verified" : "missing"}`,
);
+10 -3
View File
@@ -47,13 +47,20 @@ function seedTranscript(
const now = Date.now();
database.db
.prepare(
`INSERT INTO sessions (session_id, session_key, session_scope, created_at, updated_at)
VALUES (?, ?, 'conversation', ?, ?)`,
`INSERT INTO session_nodes (session_key, current_session_id, entry_json, updated_at)
VALUES (?, ?, '{}', ?)`,
)
.run(sessionKey, sessionId, now);
database.db
.prepare(
`INSERT INTO session_windows (
session_id, session_key, session_scope, created_at, updated_at
) VALUES (?, ?, 'conversation', ?, ?)`,
)
.run(sessionId, sessionKey, now, now);
database.db
.prepare(
`INSERT INTO session_transcript_generations (session_id, generation, updated_at)
`INSERT INTO transcript_rewrite_watermarks (session_id, generation, updated_at)
VALUES (?, 'benchmark-generation', ?)`,
)
.run(sessionId, now);
-2
View File
@@ -43,7 +43,6 @@ const TSDOWN_MAIN_PACKAGE_OUTPUT_ROOTS = TSDOWN_PACKAGE_OUTPUT_ROOTS.filter(
const TSDOWN_DECLARATION_TOOL_INPUTS = [
"package.json",
"pnpm-lock.yaml",
"npm-shrinkwrap.json",
"tsconfig.json",
"scripts/tsdown-build.mjs",
"scripts/lib/bundled-plugin-build-entries.mjs",
@@ -97,7 +96,6 @@ const PLUGIN_SDK_SELF_BUILT_ENTRY_DTS_CACHE_INPUTS = [
...PLUGIN_SDK_ENTRY_DTS_SHARED_CACHE_INPUTS,
"package.json",
"pnpm-lock.yaml",
"npm-shrinkwrap.json",
"tsconfig.json",
"tsconfig.plugin-sdk.dts.json",
{
-59
View File
@@ -1,59 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Render the macOS .icon bundle to a padded .icns like Trimmy's pipeline.
# Defaults target the OpenClaw assets so you can just run the script from repo root.
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
ICON_FILE=${1:-"$ROOT_DIR/apps/macos/Icon.icon"}
BASENAME=${2:-OpenClaw}
OUT_ROOT=${3:-"$ROOT_DIR/apps/macos/build/icon"}
XCODE_APP=${XCODE_APP:-/Applications/Xcode.app}
# Where the final .icns should live; override DEST_ICNS to change.
DEST_ICNS=${DEST_ICNS:-"$ROOT_DIR/apps/macos/Sources/OpenClaw/Resources/OpenClaw.icns"}
ICTOOL="$XCODE_APP/Contents/Applications/Icon Composer.app/Contents/Executables/ictool"
if [[ ! -x "$ICTOOL" ]]; then
ICTOOL="$XCODE_APP/Contents/Applications/Icon Composer.app/Contents/Executables/icontool"
fi
if [[ ! -x "$ICTOOL" ]]; then
echo "ictool/icontool not found. Set XCODE_APP if Xcode is elsewhere." >&2
exit 1
fi
ICONSET_DIR="$OUT_ROOT/${BASENAME}.iconset"
TMP_DIR="$OUT_ROOT/tmp"
mkdir -p "$ICONSET_DIR" "$TMP_DIR"
MASTER_ART="$TMP_DIR/icon_art_824.png"
MASTER_1024="$TMP_DIR/icon_1024.png"
# Render inner art (no margin) with macOS Default appearance
"$ICTOOL" "$ICON_FILE" \
--export-preview macOS Default 824 824 1 -45 "$MASTER_ART"
# Pad to 1024x1024 with transparent border
sips --padToHeightWidth 1024 1024 "$MASTER_ART" --out "$MASTER_1024" >/dev/null
# Generate required sizes
sizes=(16 32 64 128 256 512 1024)
for sz in "${sizes[@]}"; do
out="$ICONSET_DIR/icon_${sz}x${sz}.png"
sips -z "$sz" "$sz" "$MASTER_1024" --out "$out" >/dev/null
if [[ "$sz" -ne 1024 ]]; then
dbl=$((sz*2))
out2="$ICONSET_DIR/icon_${sz}x${sz}@2x.png"
sips -z "$dbl" "$dbl" "$MASTER_1024" --out "$out2" >/dev/null
fi
done
# 512x512@2x already covered by 1024; ensure it exists
cp "$MASTER_1024" "$ICONSET_DIR/icon_512x512@2x.png"
iconutil -c icns "$ICONSET_DIR" -o "$OUT_ROOT/${BASENAME}.icns"
mkdir -p "$(dirname "$DEST_ICNS")"
cp "$OUT_ROOT/${BASENAME}.icns" "$DEST_ICNS"
echo "Icon.icns generated at $DEST_ICNS"
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec node "$ROOT_DIR/scripts/bundle-a2ui.mjs" "$@"
+6 -1
View File
@@ -17,9 +17,14 @@ export function readBundledPluginAssetHooks(options?: Record<string, unknown>):
*/
export function runBundledPluginAssetHooks(options?: Record<string, unknown>): Promise<void>;
/**
* Parses `--phase` and repeated `--plugin` flags for asset hook scripts.
* Lists declared generated source-tree outputs that differ from the committed bytes.
*/
export function listStaleGeneratedPluginAssets(options?: Record<string, unknown>): string[];
/**
* Parses `--phase`, repeated `--plugin`, and `--check` flags for asset hook scripts.
*/
export function parseBundledPluginAssetArgs(argv: unknown): {
check: boolean;
phase: unknown;
plugins: unknown[];
};
+91 -12
View File
@@ -5,9 +5,14 @@ import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { runManagedCommand } from "./lib/managed-child-process.mjs";
import { assertRealOutputRoot } from "./lib/output-root-guard.mjs";
import { listGeneratedExtensionAssetSources } from "./lib/static-extension-assets.mjs";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const VALID_PHASES = new Set(["build", "copy"]);
// Each complete bundled-plugin asset generator gets the same 10-minute build ceiling.
const BUNDLED_PLUGIN_ASSET_HOOK_TIMEOUT_MS = 600_000;
async function readJsonFile(filePath) {
return JSON.parse(await fs.readFile(filePath, "utf8"));
@@ -100,7 +105,7 @@ export async function readBundledPluginAssetHooks(options = {}) {
}
hooks.push({
aliases: [...aliases].toSorted(),
aliases: [...aliases].toSorted((left, right) => left.localeCompare(right)),
command,
packageName: packageJson.name,
phase,
@@ -117,34 +122,83 @@ export async function readBundledPluginAssetHooks(options = {}) {
*/
export async function runBundledPluginAssetHooks(options = {}) {
const phase = options.phase;
const timeoutMs = options.timeoutMs ?? BUNDLED_PLUGIN_ASSET_HOOK_TIMEOUT_MS;
const hooks = await readBundledPluginAssetHooks(options);
if (hooks.length === 0) {
const scope = options.plugins?.length ? ` for ${options.plugins.join(", ")}` : "";
console.log(`No bundled plugin asset ${phase} hooks${scope}; skipping.`);
return;
}
if (phase === "copy") {
assertRealOutputRoot(path.join(options.rootDir ?? rootDir, "dist"));
}
for (const hook of hooks) {
console.log(`[${hook.pluginId}] ${phase}: ${hook.command}`);
const result = spawnSync(hook.command, {
cwd: hook.pluginDir,
env: process.env,
shell: true,
stdio: "inherit",
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
let status;
try {
status = await runManagedCommand({
bin: hook.command,
cwd: hook.pluginDir,
env: process.env,
shell: true,
stdio: "inherit",
timeoutMs,
});
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ETIMEDOUT") {
throw Object.assign(
new Error(
`Bundled plugin asset ${phase} hook timed out after ${timeoutMs}ms: ${hook.pluginId}`,
),
{ code: "ETIMEDOUT" },
);
}
throw error;
}
if (status !== 0) {
process.exit(status);
}
}
}
/**
* Parses `--phase` and repeated `--plugin` flags for asset hook scripts.
* Lists declared generated source-tree outputs that differ from the committed
* bytes. Committed buildOutputs must match a fresh hook run; PR node-test
* selection skips extension suites for packages-only diffs, so this check is
* the guard that keeps upstream changes from landing stale committed bundles.
*/
export function listStaleGeneratedPluginAssets(options = {}) {
const repoRoot = options.rootDir ?? rootDir;
const sources = listGeneratedExtensionAssetSources({ rootDir: repoRoot });
if (sources.length === 0) {
return [];
}
const result = spawnSync("git", ["status", "--porcelain", "--", ...sources], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
throw new Error(
`git status failed for generated plugin assets: ${result.stderr?.trim() || result.status}`,
);
}
return result.stdout
.split("\n")
.map((line) => line.slice(3).trim())
.filter(Boolean)
.toSorted((left, right) => left.localeCompare(right));
}
/**
* Parses `--phase`, repeated `--plugin`, and `--check` flags for asset hook scripts.
*/
export function parseBundledPluginAssetArgs(argv) {
const args = [...argv];
const plugins = [];
let phase = null;
let check = false;
while (args.length > 0) {
const arg = args.shift();
@@ -167,19 +221,44 @@ export function parseBundledPluginAssetArgs(argv) {
plugins.push(arg.slice("--plugin=".length));
continue;
}
if (arg === "--check") {
check = true;
continue;
}
throw new Error(`Unknown bundled plugin asset argument: ${String(arg)}`);
}
if (!VALID_PHASES.has(phase)) {
throw new Error(`Expected --phase ${[...VALID_PHASES].join("|")}`);
}
// The stale-asset scan covers every declared buildOutput, so a filtered run
// would fail on drift it never rebuilt; keep check runs whole-repo.
if (check && phase !== "build") {
throw new Error("--check requires --phase build");
}
if (check && plugins.length > 0) {
throw new Error("--check cannot be combined with --plugin filters");
}
return { phase, plugins };
return { check, phase, plugins };
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
await runBundledPluginAssetHooks(parseBundledPluginAssetArgs(process.argv.slice(2)));
const args = parseBundledPluginAssetArgs(process.argv.slice(2));
await runBundledPluginAssetHooks(args);
if (args.check) {
const stale = listStaleGeneratedPluginAssets();
if (stale.length > 0) {
console.error("Generated bundled plugin assets differ from the committed bytes:");
for (const source of stale) {
console.error(` - ${source}`);
}
console.error("Rebuild with `pnpm plugins:assets:build` and commit the regenerated files.");
process.exit(1);
}
console.log("Generated bundled plugin assets match the committed bytes.");
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
+2
View File
@@ -10,6 +10,7 @@ export type ChangedLane =
| "docs"
| "tooling"
| "liveDockerTooling"
| "bundledChannelConfigMetadata"
| "releaseMetadata"
| "all";
@@ -48,6 +49,7 @@ export function listChangedPathsFromGit(params: {
mergeHeadFirstParent?: boolean;
}): string[];
export function listStagedChangedPaths(cwd?: string): string[];
export function hasDeadcodeScannedSource(changedPaths: string[]): boolean;
export function isLiveDockerPackageScriptOnlyChange(before: string, after: string): boolean;
export function isPackageScriptOnlyChange(before: string, after: string): boolean;
+17 -16
View File
@@ -1,4 +1,3 @@
// Classifies changed files into CI lanes and release metadata scopes.
import { execFileSync } from "node:child_process";
import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mjs";
@@ -9,6 +8,15 @@ import { resolveMergeHeadDiffBase } from "./lib/merge-head-diff-base.mjs";
const GIT_OUTPUT_MAX_BUFFER = 64 * 1024 * 1024;
const IMPLAUSIBLE_NO_MERGE_BASE_DIFF_PATHS = 200;
const RAW_SYNC_CHANGED_LANES_ENV = "OPENCLAW_CHANGED_LANES_RAW_SYNC";
// Source files knip's production scan reads. Any edit to one of these can orphan
// an export -- including an import-only edit that drops a barrel re-export's last
// consumer -- so the scan is selected by path, not by inspecting changed lines.
const DEADCODE_SOURCE_PATH_RE = /^(?:src|extensions|ui|packages)\/.+\.[cm]?[jt]sx?$/u;
/** Returns whether any changed path is production source knip scans. */
export function hasDeadcodeScannedSource(changedPaths) {
return changedPaths.map(normalizeChangedPath).some((p) => DEADCODE_SOURCE_PATH_RE.test(p));
}
const SCRIPTS_TYPECHECK_PATH_RE =
/^(?:scripts\/.*\.(?:[cm]?ts|[cm]?tsx)|tsconfig\.scripts\.json)$/u;
@@ -33,6 +41,8 @@ const LIVE_DOCKER_TOOLING_PATHS = new Set([
const LIVE_DOCKER_PACKAGE_SCRIPT_RE = /^test:docker:live-[\w:-]+$/u;
const PUBLIC_EXTENSION_CONTRACT_RE =
/^(?:src\/plugin-sdk\/|src\/plugins\/contracts\/|src\/channels\/plugins\/|scripts\/lib\/plugin-sdk-entrypoints\.json$|scripts\/sync-plugin-sdk-exports\.mjs$|scripts\/generate-plugin-sdk-api-baseline\.ts$)/u;
const BUNDLED_CHANNEL_CONFIG_METADATA_PATH_RE =
/^(?:src\/config\/(?:bundled-channel-config-metadata\.generated|zod-schema\.[^/]+)\.ts|src\/channels\/plugins\/config-schema\.ts|src\/plugin-sdk\/(?:bundled-channel-config-schema|channel-config-schema)\.ts|src\/plugins\/(?:bundled-dir|public-surface-loader|public-surface-runtime|sdk-alias)\.ts|scripts\/(?:generate-bundled-channel-config-metadata\.ts|load-channel-config-surface\.ts|lib\/(?:bundled-plugin-source-utils|format-generated-module|generated-output-utils)\.mjs)|extensions\/[^/]+\/(?:openclaw\.plugin\.json|package\.json|(?:config|security-contract)-api\.[cm]?[jt]sx?|src\/config-(?:schema(?:-[^/]+)?|surface|ui-hints)\.[cm]?[jt]sx?))$/u;
/**
* Files whose changes are treated as release metadata only.
* @internal Shared repository-script contract.
@@ -51,7 +61,7 @@ export const RELEASE_METADATA_PATHS = new Set([
"package.json",
]);
/** @typedef {"core" | "coreTests" | "ui" | "extensions" | "extensionTests" | "scripts" | "testRoot" | "apps" | "docs" | "tooling" | "liveDockerTooling" | "releaseMetadata" | "all"} ChangedLane */
/** @typedef {"core" | "coreTests" | "ui" | "extensions" | "extensionTests" | "scripts" | "testRoot" | "apps" | "docs" | "tooling" | "liveDockerTooling" | "bundledChannelConfigMetadata" | "releaseMetadata" | "all"} ChangedLane */
/**
* @typedef {{
@@ -80,21 +90,16 @@ export function createEmptyChangedLanes() {
docs: false,
tooling: false,
liveDockerTooling: false,
bundledChannelConfigMetadata: false,
releaseMetadata: false,
all: false,
};
}
/** @internal Shared repository-script contract. */
export function isChangedLaneTestPath(changedPath) {
return getChangedPathFacts(normalizeChangedPath(changedPath)).isChangedLaneTest;
}
/**
* @param {string[]} changedPaths
* @param {{ packageJsonChangeKind?: "liveDockerTooling" | "tooling" | null }} [options]
* @returns {ChangedLaneResult}
*/
/**
* Classifies a list of changed paths into docs, app, extension, core, and tooling lanes.
* @internal Shared repository-script contract.
@@ -133,6 +138,10 @@ export function detectChangedLanes(changedPaths, options = {}) {
for (const changedPath of paths) {
const facts = getChangedPathFacts(changedPath);
if (BUNDLED_CHANNEL_CONFIG_METADATA_PATH_RE.test(changedPath)) {
lanes.bundledChannelConfigMetadata = true;
reasons.push(`${changedPath}: bundled channel config metadata input`);
}
if (SCRIPTS_TYPECHECK_PATH_RE.test(changedPath)) {
lanes.scripts = true;
}
@@ -256,10 +265,6 @@ export function detectChangedLanes(changedPaths, options = {}) {
};
}
/**
* @param {{ paths: string[]; base: string; head?: string; staged?: boolean; mergeHeadFirstParent?: boolean }} params
* @returns {ChangedLaneResult}
*/
/**
* Classifies changed paths with optional package.json before/after contents.
* @internal Shared repository-script contract.
@@ -283,10 +288,6 @@ export function detectChangedLanesForPaths(params) {
return detectChangedLanes(params.paths, { packageJsonChangeKind });
}
/**
* @param {{ base: string; head?: string; includeWorktree?: boolean; cwd?: string; mergeHeadFirstParent?: boolean }} params
* @returns {string[]}
*/
/**
* Lists changed paths from git for a base/head comparison.
*/
+3 -2
View File
@@ -38,7 +38,8 @@ export function shouldDelegateChangedCheckToCrabbox(
options?: { cwd?: string; result?: ChangedLaneResult; diffRefsReady?: boolean },
): boolean;
export function buildChangedCheckCrabboxArgs(argv?: string[], options?: { cwd?: string }): string[];
export function shouldRunShrinkwrapGuard(paths: string[]): boolean;
export function delegationFailedBeforeRunning(output: string): boolean;
export function shouldRunNpmLockGuard(paths: string[]): boolean;
export function shouldRunPromptSnapshotCheck(paths: string[]): boolean;
export function shouldRunPromptSnapshotOwnerTest(paths: string[]): boolean;
export function shouldRunControlUiI18nVerify(paths: string[]): boolean;
@@ -50,7 +51,7 @@ export function shouldRunDeprecationHygieneChecks(paths: string[]): boolean;
export function shouldRunCanvasA2uiNativeResourceCheck(paths: string[]): boolean;
export function shouldRunAppcastOwnerTest(paths: string[]): boolean;
export function shouldRunTestTempCreationReport(paths: string[]): boolean;
export function createShrinkwrapGuardCommand(paths: string[]): ChangedCheckCommand | null;
export function createNpmLockGuardCommand(paths: string[]): ChangedCheckCommand | null;
export function createChangedCheckPlan(
result: ChangedLaneResult,
options?: ChangedCheckPlanOptions,
+205 -77
View File
@@ -15,6 +15,7 @@ import { performance } from "node:perf_hooks";
import {
LIVE_DOCKER_AUTH_SHELL_TARGETS,
detectChangedLanesForPaths,
hasDeadcodeScannedSource,
listChangedPathsFromGit,
listStagedChangedPaths,
} from "./changed-lanes.mjs";
@@ -27,10 +28,11 @@ import {
resolveLocalHeavyCheckEnv,
} from "./lib/local-heavy-check-runtime.mjs";
import { runManagedCommand } from "./lib/managed-child-process.mjs";
import { listGeneratedExtensionAssetSources } from "./lib/static-extension-assets.mjs";
import { createSparseTsgoSkipEnv } from "./lib/tsgo-sparse-guard.mjs";
const SHRINKWRAP_POLICY_PATH_RE =
/^(?:npm-shrinkwrap\.json|package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|scripts\/generate-npm-shrinkwrap\.mjs|extensions\/[^/]+\/(?:package\.json|npm-shrinkwrap\.json))$/u;
const NPM_LOCK_POLICY_PATH_RE =
/^(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|scripts\/generate-npm-package-lock\.mjs|(?:extensions|packages)\/[^/]+(?:\/.*)?\/package\.json)$/u;
const PROMPT_SNAPSHOT_CHECK_PATH_RE =
/^(?:scripts\/(?:generate-prompt-snapshots\.ts|prompt-snapshot-files\.ts|sync-codex-model-prompt-fixture\.ts)|test\/helpers\/agents\/(?:happy-path-prompt-snapshots|prompt-snapshot-paths)\.ts|test\/fixtures\/agents\/prompt-snapshots\/.+)$/u;
const PROMPT_SNAPSHOT_OWNER_TEST_PATH_RE =
@@ -73,13 +75,14 @@ const MACOS_APP_CI_PATH_RE =
/^(?:apps\/(?:macos|macos-mlx-tts|shared|swabble)\/|Swabble\/|scripts\/(?:codesign-mac-app|create-dmg|notarize-mac-artifact|package-mac-app|package-mac-dist)\.sh$|scripts\/lib\/(?:plistbuddy|swift-toolchain)\.sh$|test\/scripts\/(?:codesign-mac-app|create-dmg|notarize-mac-artifact|package-mac-app|package-mac-dist)\.test\.ts$)/u;
let corepackPnpmShimDir;
let corepackPnpmShimCleanupRegistered = false;
let shrinkwrapPackageDirsForChangedPaths;
let cachedGeneratedExtensionAssetPaths;
let npmLockPackageDirsForChangedPaths;
async function ensureChangedCheckRuntimeDependencies(paths) {
if (!shouldRunShrinkwrapGuard(paths) || shrinkwrapPackageDirsForChangedPaths) {
if (!shouldRunNpmLockGuard(paths) || npmLockPackageDirsForChangedPaths) {
return;
}
({ shrinkwrapPackageDirsForChangedPaths } = await import("./generate-npm-shrinkwrap.mjs"));
({ npmLockPackageDirsForChangedPaths } = await import("./generate-npm-package-lock.mjs"));
}
// Imported consumers expect the synchronous planning API. Direct CLI execution
@@ -217,18 +220,12 @@ function changedCheckDiffRefsReady({ base, head, cwd = process.cwd() }) {
export function buildChangedCheckCrabboxArgs(argv = [], options = {}) {
const delegatedArgv = buildDelegatedChangedCheckArgv(argv, options);
return [
"crabbox:run",
"--",
"--provider",
"blacksmith-testbox",
"--blacksmith-org",
"openclaw",
"--blacksmith-workflow",
".github/workflows/ci-check-testbox.yml",
"--blacksmith-job",
"check",
"--blacksmith-ref",
"main",
"scripts/crabbox-wrapper.mjs",
"run",
"--workload",
"ci-fast",
// Keep workload-routed calls provider-neutral. Blacksmith reads its workflow
// defaults from .crabbox.yaml; cloud fallbacks must not receive its flags.
"--idle-timeout",
"90m",
"--ttl",
@@ -253,21 +250,15 @@ function buildDelegatedChangedCheckArgv(argv, options = {}) {
return argv;
}
const stagedPaths = listStagedChangedPaths(options.cwd);
const next = [];
if (args.timed) {
next.push("--timed");
}
const timedArgs = args.timed ? ["--timed"] : [];
if (stagedPaths.length === 0) {
next.push("--no-changes");
return next;
return [...timedArgs, "--no-changes"];
}
next.push("--base", "HEAD", "--head", "HEAD");
next.push("--", ...stagedPaths);
return next;
return [...timedArgs, "--base", "HEAD", "--head", "HEAD", "--", ...stagedPaths];
}
export function shouldRunShrinkwrapGuard(paths) {
return paths.some((changedPath) => SHRINKWRAP_POLICY_PATH_RE.test(changedPath));
export function shouldRunNpmLockGuard(paths) {
return paths.some((changedPath) => NPM_LOCK_POLICY_PATH_RE.test(changedPath));
}
export function shouldRunPromptSnapshotCheck(paths) {
@@ -336,43 +327,101 @@ export function shouldRunTestTempCreationReport(paths) {
);
}
export function createShrinkwrapGuardCommand(paths) {
if (!shouldRunShrinkwrapGuard(paths)) {
export function createNpmLockGuardCommand(paths) {
if (!shouldRunNpmLockGuard(paths)) {
return null;
}
if (!shrinkwrapPackageDirsForChangedPaths) {
throw new Error("changed-check shrinkwrap runtime dependencies were not loaded");
if (!npmLockPackageDirsForChangedPaths) {
throw new Error("changed-check npm-lock runtime dependencies were not loaded");
}
const packageDirs = shrinkwrapPackageDirsForChangedPaths(paths);
const packageDirs = npmLockPackageDirsForChangedPaths(paths);
if (packageDirs.length === 0) {
return null;
}
return {
name:
packageDirs.length === 1
? "npm shrinkwrap guard"
: `npm shrinkwrap guard (${packageDirs.length} packages)`,
? "npm package-lock guard"
: `npm package-lock guard (${packageDirs.length} packages)`,
bin: "node",
args: [
"scripts/generate-npm-shrinkwrap.mjs",
"--check",
"scripts/generate-npm-package-lock.mjs",
...packageDirs.flatMap((packageDir) => ["--package-dir", packageDir]),
],
};
}
// Enough of the wrapper tail to hold its run summary; the rest is streamed, not kept.
const DELEGATION_OUTPUT_TAIL_LIMIT = 64 * 1024;
/**
* Signatures of a failure that happened before the remote command was dispatched:
* the broker or its API was unreachable, or no lease was ever obtained.
*/
const BACKEND_UNAVAILABLE_SIGNATURES = [
/request failed: \w+ "https?:\/\/[^"]*blacksmith[^"]*"/iu,
/context deadline exceeded/iu,
/(?:no such host|dial tcp|connection refused|network is unreachable)/iu,
/failed to (?:acquire|create|warm|start)\b[^\n]*\b(?:lease|testbox)/iu,
];
/**
* Whether a failed delegation provably never ran our command.
*
* Fails closed on purpose. A missing final summary alone cannot prove the remote
* never started — a wrapper that crashes or loses its output transport after
* dispatch looks identical — so this requires a positive pre-dispatch signature
* and treats everything else as a real failure. Getting this backwards is the
* dangerous direction: some lanes (prompt snapshots) are Linux-only truth, so a
* local rerun on macOS could turn an unknown or failing gate green.
*
* `command-exit` vetoes regardless: it only appears once the command reached the
* box, so it is proof a verdict exists and must be propagated as-is.
*/
export function delegationFailedBeforeRunning(output) {
if (/"errorKind"\s*:\s*"command-exit"/u.test(output)) {
return false;
}
return BACKEND_UNAVAILABLE_SIGNATURES.some((signature) => signature.test(output));
}
async function runChangedCheckViaCrabbox(argv = [], env = process.env) {
console.error("[check:changed] delegating to Blacksmith Testbox via `pnpm crabbox:run`.");
return await runManagedCommand({
bin: "pnpm",
console.error("[check:changed] delegating through Crabbox workload routing.");
let tail = "";
const exitCode = await runManagedCommand({
bin: "node",
args: buildChangedCheckCrabboxArgs(argv),
env,
stdio: ["inherit", "pipe", "pipe"],
onReady: (child) => {
for (const stream of [child.stdout, child.stderr]) {
stream?.on("data", (chunk) => {
tail = (tail + chunk).slice(-DELEGATION_OUTPUT_TAIL_LIMIT);
// Inherited stdio used to get OS backpressure for free. Piping means we
// have to reapply it, or a verbose delegated run buffers its whole
// output in this process when stderr is an async pipe (typical in CI).
if (!process.stderr.write(chunk)) {
stream.pause();
process.stderr.once("drain", () => stream.resume());
}
});
}
},
});
return {
exitCode,
backendUnavailable: exitCode !== 0 && delegationFailedBeforeRunning(tail),
};
}
export function createChangedCheckPlan(result, options = {}) {
const commands = [];
const baseEnv = createChangedCheckChildEnv(options.env ?? process.env);
const generatedExtensionAssetPaths = result.paths.some((changedPath) =>
LINTABLE_EXTENSION_PATH_RE.test(changedPath),
)
? (cachedGeneratedExtensionAssetPaths ??= new Set(listGeneratedExtensionAssetSources()))
: new Set();
const add = (name, args, env) => {
if (!commands.some((command) => command.name === name && sameArgs(command.args, args))) {
commands.push({ name, args, ...(env ? { env } : {}) });
@@ -389,6 +438,41 @@ export function createChangedCheckPlan(result, options = {}) {
};
const addTypecheck = (name, args) => add(name, args, createSparseTsgoSkipEnv(baseEnv));
const addLint = (name, args) => add(name, args, baseEnv);
const addTargetedLint = (
createCommand,
lintablePathRe,
fallbackName,
fallbackArgs,
ignoredPaths,
) => {
const candidatePaths = ignoredPaths
? result.paths.filter((changedPath) => !ignoredPaths.has(changedPath))
: result.paths;
const targets = candidatePaths.filter((changedPath) => lintablePathRe.test(changedPath));
const otherPaths = candidatePaths.filter((changedPath) => !lintablePathRe.test(changedPath));
const targetedCommands = [];
for (let offset = 0; offset < targets.length; offset += TARGETED_LINT_PATH_LIMIT) {
const command = createCommand(
[...otherPaths, ...targets.slice(offset, offset + TARGETED_LINT_PATH_LIMIT)],
baseEnv,
);
if (!command) {
addLint(fallbackName, fallbackArgs);
return false;
}
targetedCommands.push(command);
}
if (targetedCommands.length === 0) {
addLint(fallbackName, fallbackArgs);
return false;
}
for (const command of targetedCommands) {
addCommand(command.name, command.bin, command.args, command.env);
}
return true;
};
const addTestTempCreationReport = () => {
if (!shouldRunTestTempCreationReport(result.paths)) {
return;
@@ -407,6 +491,20 @@ export function createChangedCheckPlan(result, options = {}) {
};
add("conflict markers", ["check:no-conflict-markers"]);
if (
result.paths.some((filePath) =>
/^(?:src\/|packages\/|extensions\/|config\/env-var-count-budget\.txt$|scripts\/check-env-var-count\.mjs$)/u.test(
filePath,
),
)
) {
add("environment variable count ratchet", [
"check:env-var-count",
...(options.staged ? ["--staged"] : []),
"--base",
options.staged ? "HEAD" : (options.base ?? "origin/main"),
]);
}
if (
result.paths.some((filePath) =>
/^(?:src\/|ui\/src\/|packages\/|extensions\/|\.oxlintrc\.json$|config\/max-lines-baseline\.txt$|scripts\/check-max-lines-ratchet\.mjs$)/u.test(
@@ -434,12 +532,12 @@ export function createChangedCheckPlan(result, options = {}) {
...result.paths,
]);
}
const shrinkwrapGuardCommand = createShrinkwrapGuardCommand(result.paths);
if (shrinkwrapGuardCommand) {
const npmLockGuardCommand = createNpmLockGuardCommand(result.paths);
if (npmLockGuardCommand) {
addCommand(
shrinkwrapGuardCommand.name,
shrinkwrapGuardCommand.bin,
shrinkwrapGuardCommand.args,
npmLockGuardCommand.name,
npmLockGuardCommand.bin,
npmLockGuardCommand.args,
baseEnv,
);
}
@@ -461,6 +559,9 @@ export function createChangedCheckPlan(result, options = {}) {
baseEnv,
);
}
if (result.lanes.all || result.lanes.bundledChannelConfigMetadata) {
add("bundled channel config metadata", ["check:bundled-channel-config-metadata"]);
}
if (shouldRunSqliteSessionSchemaBaselineCheck(result.paths)) {
add("SQLite sessions/transcripts schema baseline", ["sqlite:sessions-schema:check"]);
}
@@ -493,6 +594,17 @@ export function createChangedCheckPlan(result, options = {}) {
);
}
add("package patch guard", ["deps:patches:check"]);
if (
hasDeadcodeScannedSource(result.paths) &&
!isTruthyEnvFlag(baseEnv.OPENCLAW_CHECK_CHANGED_SKIP_DEADCODE)
) {
addCommand(
"dead export scan (skip with OPENCLAW_CHECK_CHANGED_SKIP_DEADCODE=1)",
"node",
["scripts/check-deadcode-exports.mjs"],
baseEnv,
);
}
if (result.docsOnly) {
return {
@@ -571,17 +683,9 @@ export function createChangedCheckPlan(result, options = {}) {
}
if (lanes.core || lanes.coreTests || lanes.ui) {
const coreLintCommand = createTargetedCoreLintCommand(result.paths, baseEnv);
if (coreLintCommand) {
addCommand(
coreLintCommand.name,
coreLintCommand.bin,
coreLintCommand.args,
coreLintCommand.env,
);
} else {
addLint("lint core", ["lint:core"]);
}
addTargetedLint(createTargetedCoreLintCommand, LINTABLE_CORE_PATH_RE, "lint core", [
"lint:core",
]);
}
if (
lanes.liveDockerTooling &&
@@ -591,31 +695,33 @@ export function createChangedCheckPlan(result, options = {}) {
addLint("lint core", ["lint:core"]);
}
if (lanes.extensions || lanes.extensionTests) {
const extensionLintCommand = createTargetedExtensionLintCommand(result.paths, baseEnv);
if (extensionLintCommand) {
addCommand(
extensionLintCommand.name,
extensionLintCommand.bin,
extensionLintCommand.args,
extensionLintCommand.env,
// Generated plugin outputs have their own asset-integrity gate and are
// intentionally ignored by oxlint; manifests still need full-lane fallback.
if (
!result.paths.some((changedPath) => generatedExtensionAssetPaths.has(changedPath)) ||
result.paths.some(
(changedPath) =>
getChangedPathFacts(changedPath).surface === "extension" &&
!generatedExtensionAssetPaths.has(changedPath),
)
) {
addTargetedLint(
createTargetedExtensionLintCommand,
LINTABLE_EXTENSION_PATH_RE,
"lint extensions",
["lint:extensions"],
generatedExtensionAssetPaths,
);
} else {
addLint("lint extensions", ["lint:extensions"]);
}
}
if (lanes.tooling || lanes.liveDockerTooling) {
const scriptLintCommand = createTargetedScriptLintCommand(result.paths, baseEnv);
if (scriptLintCommand) {
if (
addTargetedLint(createTargetedScriptLintCommand, LINTABLE_SCRIPT_PATH_RE, "lint scripts", [
"lint:scripts",
])
) {
addLint("lint docker-e2e", ["lint:docker-e2e"]);
addLint("raw HTTP/2 import guard", ["lint:tmp:no-raw-http2-imports"]);
addCommand(
scriptLintCommand.name,
scriptLintCommand.bin,
scriptLintCommand.args,
scriptLintCommand.env,
);
} else {
addLint("lint scripts", ["lint:scripts"]);
}
}
if (lanes.apps && shouldSkipAppLintForMissingSwiftlint({ ...options, env: baseEnv })) {
@@ -722,6 +828,9 @@ function createTargetedOxlintCommand({
paths.some(
(changedPath) =>
!lintablePathRe.test(changedPath) &&
!LINTABLE_CORE_PATH_RE.test(changedPath) &&
!LINTABLE_EXTENSION_PATH_RE.test(changedPath) &&
!LINTABLE_SCRIPT_PATH_RE.test(changedPath) &&
!neutralPathRe.test(changedPath) &&
!MARKDOWN_LINT_OPTIMIZATION_NEUTRAL_PATH_RE.test(changedPath),
)
@@ -999,7 +1108,13 @@ if (isDirectRun()) {
if (!shouldDelegateChangedCheckToCrabbox(argv, process.env)) {
throw error;
}
process.exitCode = await runChangedCheckViaCrabbox(argv, process.env);
// No local fallback here: this path exists because the checkout cannot
// resolve the diff refs itself, so there is nothing local to run.
const delegated = await runChangedCheckViaCrabbox(argv, process.env);
if (delegated.backendUnavailable) {
throw error;
}
process.exitCode = delegated.exitCode;
}
if (paths) {
const result = detectChangedLanesForPaths({
@@ -1021,7 +1136,20 @@ if (isDirectRun()) {
: undefined,
})
) {
process.exitCode = await runChangedCheckViaCrabbox(argv, process.env);
const delegated = await runChangedCheckViaCrabbox(argv, process.env);
if (delegated.backendUnavailable) {
// Say this loudly: the proof below is local, so whoever reads the run
// knows which machine produced it and that Linux-only lanes are unproven.
console.error(
"[check:changed] Blacksmith never ran the checks (no run summary). Falling back to local execution; note this in the proof summary.",
);
}
process.exitCode = delegated.backendUnavailable
? await runChangedCheck(result, {
...args,
explicitPaths: args.paths.length > 0,
})
: delegated.exitCode;
} else {
process.exitCode = await runChangedCheck(result, {
...args,
+247 -1
View File
@@ -57,6 +57,141 @@ const checks: Array<{ file: string; snippets: string[] }> = [
file: "v2/Account.ts",
snippets: ['type: "apiKey"', 'type: "chatgpt"', 'type: "amazonBedrock"'],
},
{
file: "v2/AppSummary.ts",
snippets: [
"description: string | null",
"installUrl: string | null",
"category: string | null",
],
},
{
file: "v2/AppsInstalledParams.ts",
snippets: ["threadId?: string | null", "forceRefresh?: boolean"],
},
{
file: "v2/AppsInstalledResponse.ts",
snippets: ["apps: Array<InstalledApp>"],
},
{
file: "v2/AppsReadParams.ts",
snippets: ["appIds: Array<string>", "includeTools?: boolean"],
},
{
file: "v2/AppsReadResponse.ts",
snippets: ["apps: Array<ConnectorMetadata>", "missingAppIds: Array<string>"],
},
{
file: "v2/CommandExecParams.ts",
snippets: [
"command: Array<string>",
"outputBytesCap?: number | null",
"timeoutMs?: number | null",
"env?: { [key in string]?: string | null } | null",
],
},
{
file: "v2/CommandExecResponse.ts",
snippets: ["exitCode: number", "stdout: string", "stderr: string"],
},
{
file: "v2/ConfigBatchWriteParams.ts",
snippets: [
"edits: Array<ConfigEdit>",
"filePath?: string | null",
"expectedVersion?: string | null",
"reloadUserConfig?: boolean",
],
},
{
file: "v2/ConfigEdit.ts",
snippets: ["keyPath: string", "value: JsonValue", "mergeStrategy: MergeStrategy"],
},
{
file: "v2/ConfigValueWriteParams.ts",
snippets: [
"keyPath: string",
"value: JsonValue",
"mergeStrategy: MergeStrategy",
"filePath?: string | null",
"expectedVersion?: string | null",
],
},
{
file: "v2/ConfigWriteResponse.ts",
snippets: [
"status: WriteStatus",
"version: string",
"filePath: AbsolutePathBuf",
"overriddenMetadata: OverriddenMetadata | null",
],
},
{
file: "v2/InstalledApp.ts",
snippets: ["runtimeName: string | null", "enabled: boolean", "callable: boolean"],
},
{
file: "v2/MarketplaceLoadErrorInfo.ts",
snippets: ["marketplacePath: AbsolutePathBuf", "message: string"],
},
{
file: "v2/MergeStrategy.ts",
snippets: ['"replace"', '"upsert"'],
},
{
file: "v2/OverriddenMetadata.ts",
snippets: [
"message: string",
"overridingLayer: ConfigLayerMetadata",
"effectiveValue: JsonValue",
],
},
{
file: "v2/PluginSummary.ts",
snippets: ["remotePluginId: string | null"],
},
{
file: "v2/PluginListParams.ts",
snippets: ["forceRefetch?: boolean"],
},
{
file: "v2/PluginInstalledParams.ts",
snippets: [
"cwds?: Array<AbsolutePathBuf> | null",
"installSuggestionPluginNames?: Array<string> | null",
],
},
{
file: "v2/PluginInstalledResponse.ts",
snippets: [
"marketplaces: Array<PluginMarketplaceEntry>",
"marketplaceLoadErrors: Array<MarketplaceLoadErrorInfo>",
],
},
{
file: "v2/PluginListResponse.ts",
snippets: [
"marketplaces: Array<PluginMarketplaceEntry>",
"marketplaceLoadErrors: Array<MarketplaceLoadErrorInfo>",
"featuredPluginIds: Array<string>",
],
},
{
file: "v2/PluginReadParams.ts",
snippets: ["pluginName: string"],
},
{
file: "v2/PluginReadResponse.ts",
snippets: ["plugin: PluginDetail"],
},
{
file: "v2/PluginInstallParams.ts",
snippets: ["pluginName: string"],
},
{
file: "v2/PluginInstallResponse.ts",
snippets: ["appsNeedingAuth: Array<AppSummary>"],
},
{
file: "v2/ThreadStartParams.ts",
snippets: [
@@ -69,9 +204,13 @@ const checks: Array<{ file: string; snippets: string[] }> = [
file: "v2/TurnStartParams.ts",
snippets: ["permissions?: string | null", "serviceTier?: string | null"],
},
{
file: "v2/WriteStatus.ts",
snippets: ['"ok"', '"okOverridden"'],
},
{
file: "ReviewDecision.ts",
snippets: ['"approved"', '"approved_for_session"', '"denied"', '"abort"'],
snippets: ['"approved"', '"approved_for_session"', "denied: { rejection: string }", '"abort"'],
},
{
file: "v2/PlanDeltaNotification.ts",
@@ -142,9 +281,12 @@ async function checkMaintainedProtocolTypes(sourceRoot: string): Promise<void> {
const probe = `
import type {
CodexAppServerRequestParams,
CodexAppServerRequestResult,
CodexConfigEdit,
CodexDynamicToolSpec,
CodexDynamicToolCallParams,
CodexErrorNotification,
CodexGetAccountResponse,
CodexModelListResponse,
CodexThreadForkParams,
CodexThreadForkResponse,
@@ -154,11 +296,37 @@ import type {
CodexThreadStartResponse,
CodexTurnEnvironmentParams,
CodexTurnStartParams,
v2,
} from ${JSON.stringify(protocolImport)};
import type { AppSummary } from ${JSON.stringify(generatedImport("v2/AppSummary.ts"))};
import type { AppsInstalledParams } from ${JSON.stringify(generatedImport("v2/AppsInstalledParams.ts"))};
import type { AppsInstalledResponse } from ${JSON.stringify(generatedImport("v2/AppsInstalledResponse.ts"))};
import type { AppsListParams } from ${JSON.stringify(generatedImport("v2/AppsListParams.ts"))};
import type { AppsListResponse } from ${JSON.stringify(generatedImport("v2/AppsListResponse.ts"))};
import type { AppsReadParams } from ${JSON.stringify(generatedImport("v2/AppsReadParams.ts"))};
import type { AppsReadResponse } from ${JSON.stringify(generatedImport("v2/AppsReadResponse.ts"))};
import type { CommandExecParams } from ${JSON.stringify(generatedImport("v2/CommandExecParams.ts"))};
import type { CommandExecResponse } from ${JSON.stringify(generatedImport("v2/CommandExecResponse.ts"))};
import type { ConfigBatchWriteParams } from ${JSON.stringify(generatedImport("v2/ConfigBatchWriteParams.ts"))};
import type { ConfigEdit } from ${JSON.stringify(generatedImport("v2/ConfigEdit.ts"))};
import type { ConfigValueWriteParams } from ${JSON.stringify(generatedImport("v2/ConfigValueWriteParams.ts"))};
import type { ConfigWriteResponse } from ${JSON.stringify(generatedImport("v2/ConfigWriteResponse.ts"))};
import type { DynamicToolCallParams } from ${JSON.stringify(generatedImport("v2/DynamicToolCallParams.ts"))};
import type { DynamicToolSpec } from ${JSON.stringify(generatedImport("v2/DynamicToolSpec.ts"))};
import type { ErrorNotification } from ${JSON.stringify(generatedImport("v2/ErrorNotification.ts"))};
import type { GetAccountResponse } from ${JSON.stringify(generatedImport("v2/GetAccountResponse.ts"))};
import type { MarketplaceLoadErrorInfo } from ${JSON.stringify(generatedImport("v2/MarketplaceLoadErrorInfo.ts"))};
import type { ModelListResponse } from ${JSON.stringify(generatedImport("v2/ModelListResponse.ts"))};
import type { PluginInstalledParams } from ${JSON.stringify(generatedImport("v2/PluginInstalledParams.ts"))};
import type { PluginInstalledResponse } from ${JSON.stringify(generatedImport("v2/PluginInstalledResponse.ts"))};
import type { PluginInstallParams } from ${JSON.stringify(generatedImport("v2/PluginInstallParams.ts"))};
import type { PluginInstallResponse } from ${JSON.stringify(generatedImport("v2/PluginInstallResponse.ts"))};
import type { PluginListParams } from ${JSON.stringify(generatedImport("v2/PluginListParams.ts"))};
import type { PluginListResponse } from ${JSON.stringify(generatedImport("v2/PluginListResponse.ts"))};
import type { PluginReadParams } from ${JSON.stringify(generatedImport("v2/PluginReadParams.ts"))};
import type { PluginReadResponse } from ${JSON.stringify(generatedImport("v2/PluginReadResponse.ts"))};
import type { ThreadDeleteParams } from ${JSON.stringify(generatedImport("v2/ThreadDeleteParams.ts"))};
import type { ThreadDeleteResponse } from ${JSON.stringify(generatedImport("v2/ThreadDeleteResponse.ts"))};
import type { ThreadForkParams } from ${JSON.stringify(generatedImport("v2/ThreadForkParams.ts"))};
import type { ThreadForkResponse } from ${JSON.stringify(generatedImport("v2/ThreadForkResponse.ts"))};
import type { ThreadResumeParams } from ${JSON.stringify(generatedImport("v2/ThreadResumeParams.ts"))};
@@ -169,6 +337,33 @@ import type { TurnEnvironmentParams } from ${JSON.stringify(generatedImport("v2/
import type { TurnInterruptParams } from ${JSON.stringify(generatedImport("v2/TurnInterruptParams.ts"))};
import type { TurnStartParams } from ${JSON.stringify(generatedImport("v2/TurnStartParams.ts"))};
declare const openClawAppsInstalledParams: CodexAppServerRequestParams<"app/installed">;
const generatedAppsInstalledParams: AppsInstalledParams = openClawAppsInstalledParams;
declare const openClawAppsListParams: CodexAppServerRequestParams<"app/list">;
const generatedAppsListParams: AppsListParams = openClawAppsListParams;
declare const openClawAppsReadParams: CodexAppServerRequestParams<"app/read">;
const generatedAppsReadParams: AppsReadParams = openClawAppsReadParams;
declare const openClawAppSummary: v2.AppSummary;
const generatedAppSummary: AppSummary = openClawAppSummary;
declare const openClawCommandExecParams: CodexAppServerRequestParams<"command/exec">;
const generatedCommandExecParams: CommandExecParams = openClawCommandExecParams;
declare const generatedNullableCommandExecParams: CommandExecParams;
const openClawNullableCommandExecParams: CodexAppServerRequestParams<"command/exec"> =
generatedNullableCommandExecParams;
declare const openClawConfigBatchWriteParams: CodexAppServerRequestParams<"config/batchWrite">;
const generatedConfigBatchWriteParams: ConfigBatchWriteParams = openClawConfigBatchWriteParams;
declare const openClawConfigEdit: CodexConfigEdit;
const generatedConfigEdit: ConfigEdit = openClawConfigEdit;
declare const openClawConfigValueWriteParams: CodexAppServerRequestParams<"config/value/write">;
const generatedConfigValueWriteParams: ConfigValueWriteParams = openClawConfigValueWriteParams;
declare const openClawPluginInstalledParams: CodexAppServerRequestParams<"plugin/installed">;
const generatedPluginInstalledParams: PluginInstalledParams = openClawPluginInstalledParams;
declare const openClawPluginInstallParams: CodexAppServerRequestParams<"plugin/install">;
const generatedPluginInstallParams: PluginInstallParams = openClawPluginInstallParams;
declare const openClawPluginListParams: CodexAppServerRequestParams<"plugin/list">;
const generatedPluginListParams: PluginListParams = openClawPluginListParams;
declare const openClawPluginReadParams: CodexAppServerRequestParams<"plugin/read">;
const generatedPluginReadParams: PluginReadParams = openClawPluginReadParams;
declare const openClawDynamicToolSpec: CodexDynamicToolSpec;
const generatedDynamicToolSpec: DynamicToolSpec = openClawDynamicToolSpec;
declare const openClawTurnEnvironmentParams: CodexTurnEnvironmentParams;
@@ -179,18 +374,69 @@ declare const openClawThreadResumeParams: CodexThreadResumeParams;
const generatedThreadResumeParams: ThreadResumeParams = openClawThreadResumeParams;
declare const openClawThreadForkParams: CodexThreadForkParams;
const generatedThreadForkParams: ThreadForkParams = openClawThreadForkParams;
declare const openClawThreadDeleteParams: CodexAppServerRequestParams<"thread/delete">;
const generatedThreadDeleteParams: ThreadDeleteParams = openClawThreadDeleteParams;
declare const openClawTurnInterruptParams: CodexAppServerRequestParams<"turn/interrupt">;
const generatedTurnInterruptParams: TurnInterruptParams = openClawTurnInterruptParams;
declare const openClawTurnStartParams: CodexTurnStartParams;
const generatedTurnStartParams: TurnStartParams = openClawTurnStartParams;
declare const generatedAppsInstalledResponse: AppsInstalledResponse;
const openClawAppsInstalledResponse: CodexAppServerRequestResult<"app/installed"> =
generatedAppsInstalledResponse;
declare const generatedAppsListResponse: AppsListResponse;
const openClawAppsListResponse: CodexAppServerRequestResult<"app/list"> =
generatedAppsListResponse;
declare const generatedAppsReadResponse: AppsReadResponse;
const openClawAppsReadResponse: CodexAppServerRequestResult<"app/read"> =
generatedAppsReadResponse;
declare const generatedAppSummaryResponse: AppSummary;
const openClawAppSummaryResponse: v2.AppSummary = generatedAppSummaryResponse;
declare const generatedCommandExecResponse: CommandExecResponse;
const openClawCommandExecResponse: CodexAppServerRequestResult<"command/exec"> =
generatedCommandExecResponse;
declare const generatedConfigWriteResponse: ConfigWriteResponse;
const openClawConfigBatchWriteResponse: CodexAppServerRequestResult<"config/batchWrite"> =
generatedConfigWriteResponse;
const openClawConfigValueWriteResponse: CodexAppServerRequestResult<"config/value/write"> =
generatedConfigWriteResponse;
const generatedExactConfigBatchWriteResponse: ConfigWriteResponse =
openClawConfigBatchWriteResponse;
const generatedExactConfigValueWriteResponse: ConfigWriteResponse =
openClawConfigValueWriteResponse;
declare const generatedPluginInstalledResponse: PluginInstalledResponse;
const openClawPluginInstalledResponse: CodexAppServerRequestResult<"plugin/installed"> =
generatedPluginInstalledResponse;
const generatedPluginInstalledMarketplaceLoadErrors: MarketplaceLoadErrorInfo[] =
openClawPluginInstalledResponse.marketplaceLoadErrors;
type InstalledPluginResponseHasNoFeaturedCatalog =
"featuredPluginIds" extends keyof v2.PluginInstalledResponse ? never : true;
const installedPluginResponseHasNoFeaturedCatalog: InstalledPluginResponseHasNoFeaturedCatalog =
true;
declare const generatedPluginInstallResponse: PluginInstallResponse;
const openClawPluginInstallResponse: CodexAppServerRequestResult<"plugin/install"> =
generatedPluginInstallResponse;
declare const generatedPluginListResponse: PluginListResponse;
const openClawPluginListResponse: CodexAppServerRequestResult<"plugin/list"> =
generatedPluginListResponse;
const generatedPluginListMarketplaceLoadErrors: MarketplaceLoadErrorInfo[] =
openClawPluginListResponse.marketplaceLoadErrors;
const generatedPluginListFeaturedPluginIds: string[] = openClawPluginListResponse.featuredPluginIds;
declare const generatedPluginReadResponse: PluginReadResponse;
const openClawPluginReadResponse: CodexAppServerRequestResult<"plugin/read"> =
generatedPluginReadResponse;
declare const generatedDynamicToolCallParams: Omit<DynamicToolCallParams, "arguments">;
const openClawDynamicToolCallParams: Omit<CodexDynamicToolCallParams, "arguments"> =
generatedDynamicToolCallParams;
declare const generatedErrorNotification: ErrorNotification;
const openClawErrorNotification: CodexErrorNotification = generatedErrorNotification;
declare const generatedGetAccountResponse: GetAccountResponse;
const openClawGetAccountResponse: CodexGetAccountResponse = generatedGetAccountResponse;
declare const generatedModelListResponse: ModelListResponse;
const openClawModelListResponse: CodexModelListResponse = generatedModelListResponse;
declare const generatedThreadDeleteResponse: ThreadDeleteResponse;
const openClawThreadDeleteResponse: CodexAppServerRequestResult<"thread/delete"> =
generatedThreadDeleteResponse;
// Thread and turn bodies are normalized behind checked-in JSON schemas. Their
// raw generated shapes must not be confused with the projector-facing types.
@@ -33,31 +33,47 @@ export type ControlUiPerformanceBudgets = {
largestCssGzipBytes: number;
};
export type ControlUiStartupBudgetBaseline = {
startupJsGzipBytes: number;
reason: string;
updatedAt: string;
};
export type ControlUiPerformanceBudgetViolation = {
metric: string;
actual: number;
limit: number;
unit: "count" | "bytes";
baseline?: number;
tolerance?: number;
};
export const CONTROL_UI_PERFORMANCE_BUDGETS: Readonly<ControlUiPerformanceBudgets>;
export const CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES: 1024;
export function extractControlUiStartupAssetPaths(html: string): string[];
export function collectControlUiPerformanceMetrics(distDir: string): ControlUiPerformanceMetrics;
export function evaluateControlUiPerformanceBudgets(
metrics: ControlUiPerformanceMetrics,
budgets?: Readonly<ControlUiPerformanceBudgets>,
startupBudgetBaseline?: Readonly<ControlUiStartupBudgetBaseline>,
startupJsTolerance?: number,
): ControlUiPerformanceBudgetViolation[];
export function formatControlUiPerformanceBytes(bytes: number): string;
export function formatControlUiPerformanceReport(
metrics: ControlUiPerformanceMetrics,
budgets?: Readonly<ControlUiPerformanceBudgets>,
startupBudgetBaseline?: Readonly<ControlUiStartupBudgetBaseline>,
startupJsTolerance?: number,
): string;
export function runControlUiPerformanceCheck(
distDir: string,
budgets?: Readonly<ControlUiPerformanceBudgets>,
baselinePath?: string,
): {
metrics: ControlUiPerformanceMetrics;
budgets: Readonly<ControlUiPerformanceBudgets>;
startupBudgetBaseline: ControlUiStartupBudgetBaseline;
startupJsTolerance: number;
violations: ControlUiPerformanceBudgetViolation[];
report: string;
};
+192 -16
View File
@@ -6,16 +6,27 @@ import process from "node:process";
import { fileURLToPath } from "node:url";
const KIB = 1024;
const STARTUP_JS_BASELINE_RATCHET_BYTES = 4096;
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_STARTUP_BUDGET_BASELINE_PATH = path.resolve(
SCRIPT_DIR,
"../config/control-ui-startup-budget-baseline.json",
);
// This absorbs measured local-to-Linux gzip variance, but landed changes can
// still consume the tolerance. Local zlib emits smaller streams than CI's Linux
// builder, so baseline updates must use CI bytes via --startup-js-bytes. The
// fixed JS ceiling bounds cumulative creep.
export const CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES = 1024;
// Small, explicit headroom over the optimized baseline. Budget changes should
// accompany an intentional loading or chunking decision.
export const CONTROL_UI_PERFORMANCE_BUDGETS = Object.freeze({
startupJsRequests: 18,
startupCssRequests: 1,
// 312 KiB accompanies the live-narration sidebar feature (2026-07): the
// controller is a lazy chunk; only its thin element/pref wiring (~1.5 KiB)
// stays in startup, which exhausted the previous ratchet's headroom.
startupJsGzipBytes: 312 * KIB,
// 317 KiB preserves headroom after the device-auth upgrade hook and sidebar
// session-render extraction (2026-07); the migration UI itself remains lazy.
startupJsGzipBytes: 317 * KIB,
// 45 KiB CSS ceilings maintainer-approved 2026-07 alongside the interleaved
// sidebar zone styling; headroom over the ~36.5 KiB post-diet baseline.
startupCssGzipBytes: 45 * KIB,
@@ -126,6 +137,8 @@ export function collectControlUiPerformanceMetrics(distDir) {
export function evaluateControlUiPerformanceBudgets(
metrics,
budgets = CONTROL_UI_PERFORMANCE_BUDGETS,
startupBudgetBaseline = null,
startupJsTolerance = CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES,
) {
const checks = [
["startup JS requests", metrics.startup.js.requests, budgets.startupJsRequests, "count"],
@@ -135,9 +148,23 @@ export function evaluateControlUiPerformanceBudgets(
["largest JS gzip", metrics.largest.js.gzipBytes, budgets.largestJsGzipBytes, "bytes"],
["largest CSS gzip", metrics.largest.css.gzipBytes, budgets.largestCssGzipBytes, "bytes"],
];
return checks.flatMap(([metric, actual, limit, unit]) =>
const violations = checks.flatMap(([metric, actual, limit, unit]) =>
actual > limit ? [{ metric, actual, limit, unit }] : [],
);
if (
startupBudgetBaseline &&
metrics.startup.js.gzipBytes > startupBudgetBaseline.startupJsGzipBytes + startupJsTolerance
) {
violations.push({
metric: "startup JS gzip vs baseline",
actual: metrics.startup.js.gzipBytes,
limit: startupBudgetBaseline.startupJsGzipBytes + startupJsTolerance,
unit: "bytes",
baseline: startupBudgetBaseline.startupJsGzipBytes,
tolerance: startupJsTolerance,
});
}
return violations;
}
export function formatControlUiPerformanceBytes(bytes) {
@@ -153,6 +180,9 @@ function formatAssetSummary(summary) {
}
function formatViolation(violation) {
if (violation.baseline !== undefined && violation.tolerance !== undefined) {
return `${violation.metric}: ${violation.actual} B exceeds baseline ${violation.baseline} B + tolerance ${violation.tolerance} B (limit ${violation.limit} B); intentionally raise the baseline with node scripts/check-control-ui-performance.mjs --update-baseline --startup-js-bytes ${violation.actual} --reason "<reason>"`;
}
const actual =
violation.unit === "bytes"
? formatControlUiPerformanceBytes(violation.actual)
@@ -171,17 +201,40 @@ function formatViolation(violation) {
export function formatControlUiPerformanceReport(
metrics,
budgets = CONTROL_UI_PERFORMANCE_BUDGETS,
startupBudgetBaseline = null,
startupJsTolerance = CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES,
) {
const violations = evaluateControlUiPerformanceBudgets(metrics, budgets);
const violations = evaluateControlUiPerformanceBudgets(
metrics,
budgets,
startupBudgetBaseline,
startupJsTolerance,
);
const lines = [
"Control UI performance:",
` startup JS: ${formatAssetSummary(metrics.startup.js)} (limits: ${formatRequestCount(budgets.startupJsRequests)}, ${formatControlUiPerformanceBytes(budgets.startupJsGzipBytes)} gzip)`,
];
if (startupBudgetBaseline) {
lines.push(
` startup JS gzip vs baseline: ${metrics.startup.js.gzipBytes} B (baseline ${startupBudgetBaseline.startupJsGzipBytes} B + tolerance ${startupJsTolerance} B, ceiling ${budgets.startupJsGzipBytes} B)`,
);
}
lines.push(
` startup CSS: ${formatAssetSummary(metrics.startup.css)} (limits: ${formatRequestCount(budgets.startupCssRequests)}, ${formatControlUiPerformanceBytes(budgets.startupCssGzipBytes)} gzip)`,
` largest JS: ${metrics.largest.js.file}, ${formatControlUiPerformanceBytes(metrics.largest.js.gzipBytes)} gzip (limit: ${formatControlUiPerformanceBytes(budgets.largestJsGzipBytes)})`,
` largest CSS: ${metrics.largest.css.file}, ${formatControlUiPerformanceBytes(metrics.largest.css.gzipBytes)} gzip (limit: ${formatControlUiPerformanceBytes(budgets.largestCssGzipBytes)})`,
` all JS: ${formatAssetSummary(metrics.total.js)}`,
` all CSS: ${formatAssetSummary(metrics.total.css)}`,
];
);
if (
startupBudgetBaseline &&
metrics.startup.js.gzipBytes + STARTUP_JS_BASELINE_RATCHET_BYTES <
startupBudgetBaseline.startupJsGzipBytes
) {
lines.push(
` hint: startup JS gzip is more than ${STARTUP_JS_BASELINE_RATCHET_BYTES} B below the ${startupBudgetBaseline.startupJsGzipBytes} B baseline; lower it with node scripts/check-control-ui-performance.mjs --update-baseline --reason "<reason>"`,
);
}
if (violations.length > 0) {
lines.push(
" violations:",
@@ -191,24 +244,147 @@ export function formatControlUiPerformanceReport(
return lines.join("\n");
}
export function runControlUiPerformanceCheck(distDir, budgets = CONTROL_UI_PERFORMANCE_BUDGETS) {
function baselineUpdateCommand() {
return 'node scripts/check-control-ui-performance.mjs --update-baseline --reason "<reason>"';
}
function isIsoDate(value) {
if (!/^\d{4}-\d{2}-\d{2}$/u.test(value)) {
return false;
}
const date = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(date.valueOf()) && date.toISOString().slice(0, 10) === value;
}
function readControlUiStartupBudgetBaseline(baselinePath) {
try {
const parsed = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
if (
!parsed ||
typeof parsed !== "object" ||
!Number.isSafeInteger(parsed.startupJsGzipBytes) ||
parsed.startupJsGzipBytes < 0 ||
typeof parsed.reason !== "string" ||
parsed.reason.trim().length === 0 ||
typeof parsed.updatedAt !== "string" ||
!isIsoDate(parsed.updatedAt)
) {
throw new Error("expected startupJsGzipBytes, non-empty reason, and YYYY-MM-DD updatedAt");
}
return {
startupJsGzipBytes: parsed.startupJsGzipBytes,
reason: parsed.reason,
updatedAt: parsed.updatedAt,
};
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(
`Cannot read Control UI startup budget baseline ${baselinePath}: ${detail}. Regenerate it with ${baselineUpdateCommand()}.`,
{ cause: error },
);
}
}
function writeControlUiStartupBudgetBaseline(baselinePath, startupJsGzipBytes, reason) {
const baseline = {
startupJsGzipBytes,
reason,
updatedAt: new Date().toISOString().slice(0, 10),
};
fs.writeFileSync(baselinePath, `${JSON.stringify(baseline, null, 2)}\n`);
return baseline;
}
function validateExplicitStartupJsBytes(startupJsBytes, currentBaseline) {
const delta = Math.abs(startupJsBytes - currentBaseline.startupJsGzipBytes);
if (delta > STARTUP_JS_BASELINE_RATCHET_BYTES) {
throw new Error(
`startup JS gzip baseline update: ${startupJsBytes} B differs from current baseline ${currentBaseline.startupJsGzipBytes} B by ${delta} B, exceeding the ${STARTUP_JS_BASELINE_RATCHET_BYTES} B ratchet`,
);
}
}
export function runControlUiPerformanceCheck(
distDir,
budgets = CONTROL_UI_PERFORMANCE_BUDGETS,
baselinePath = DEFAULT_STARTUP_BUDGET_BASELINE_PATH,
) {
const startupBudgetBaseline = readControlUiStartupBudgetBaseline(baselinePath);
const metrics = collectControlUiPerformanceMetrics(distDir);
const violations = evaluateControlUiPerformanceBudgets(metrics, budgets, startupBudgetBaseline);
const report = formatControlUiPerformanceReport(metrics, budgets, startupBudgetBaseline);
return {
metrics,
budgets,
violations: evaluateControlUiPerformanceBudgets(metrics, budgets),
report: formatControlUiPerformanceReport(metrics, budgets),
startupBudgetBaseline,
startupJsTolerance: CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES,
violations,
report,
};
}
function main(argv = process.argv.slice(2)) {
const unknown = argv.filter((arg) => arg !== "--json");
if (unknown.length > 0) {
throw new Error(`Unknown option: ${unknown[0]}`);
let json = false;
let updateBaseline = false;
let reason;
let startupJsBytes;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--json") {
json = true;
} else if (arg === "--update-baseline") {
updateBaseline = true;
} else if (arg === "--reason") {
reason = argv[index + 1];
if (!reason || reason.trim().length === 0 || reason.startsWith("--")) {
throw new Error("--reason requires a non-empty value");
}
index += 1;
} else if (arg === "--startup-js-bytes") {
const value = argv[index + 1];
if (!value || !/^[1-9]\d*$/u.test(value)) {
throw new Error("--startup-js-bytes requires a positive integer");
}
startupJsBytes = Number(value);
if (!Number.isSafeInteger(startupJsBytes)) {
throw new Error("--startup-js-bytes requires a positive integer");
}
index += 1;
} else {
throw new Error(`Unknown option: ${arg}`);
}
}
const here = path.dirname(fileURLToPath(import.meta.url));
const result = runControlUiPerformanceCheck(path.resolve(here, "../dist/control-ui"));
if (argv.includes("--json")) {
if (reason !== undefined && !updateBaseline) {
throw new Error("--reason requires --update-baseline");
}
if (startupJsBytes !== undefined && !updateBaseline) {
throw new Error("--startup-js-bytes requires --update-baseline");
}
if (json && updateBaseline) {
throw new Error("--json cannot be combined with --update-baseline");
}
const distDir = path.resolve(SCRIPT_DIR, "../dist/control-ui");
if (updateBaseline) {
if (startupJsBytes !== undefined) {
const currentBaseline = readControlUiStartupBudgetBaseline(
DEFAULT_STARTUP_BUDGET_BASELINE_PATH,
);
validateExplicitStartupJsBytes(startupJsBytes, currentBaseline);
}
const nextStartupJsBytes =
startupJsBytes ?? collectControlUiPerformanceMetrics(distDir).startup.js.gzipBytes;
const baseline = writeControlUiStartupBudgetBaseline(
DEFAULT_STARTUP_BUDGET_BASELINE_PATH,
nextStartupJsBytes,
reason ?? "manual baseline update",
);
process.stdout.write(
`Updated config/control-ui-startup-budget-baseline.json to ${baseline.startupJsGzipBytes} B (${baseline.reason}).\n`,
);
return;
}
const result = runControlUiPerformanceCheck(distDir);
if (json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
} else {
process.stdout.write(`${result.report}\n`);
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -2,7 +2,10 @@
/**
* Collects dependency pin violations for the current workspace.
*/
export function collectDependencyPinViolations(cwd?: string): unknown[];
export function collectDependencyPinViolations(
cwd?: string,
options?: { gitTimeoutMs?: number },
): unknown[];
/**
* Runs the dependency pin check.
*/
+36 -17
View File
@@ -15,12 +15,30 @@ const EXACT_NPM_ALIAS_PATTERN =
const PINNED_GIT_PATTERN = /(?:#|\/commit\/)[0-9a-f]{40}$/iu;
const PINNED_GITHUB_TARBALL_PATTERN =
/^https:\/\/codeload\.github\.com\/[^/\s]+\/[^/\s]+\/tar\.gz\/[0-9a-f]{40}$/iu;
const DEFAULT_GIT_TIMEOUT_MS = 60_000;
function listTrackedPackageJsonFiles(cwd) {
return execFileSync("git", ["ls-files", "-z", "--", "*package.json"], {
cwd,
encoding: "utf8",
})
function runGit(cwd, args, timeoutMs = DEFAULT_GIT_TIMEOUT_MS) {
try {
return execFileSync("git", args, {
cwd,
encoding: "utf8",
timeout: timeoutMs,
// A synchronous child that ignores SIGTERM otherwise keeps its parent blocked.
killSignal: "SIGKILL",
});
} catch (error) {
if (error?.code === "ETIMEDOUT") {
throw new Error(
`dependency pin guard: git ${args.join(" ")} timed out after ${timeoutMs}ms.`,
{ cause: error },
);
}
throw error;
}
}
function listTrackedPackageJsonFiles(cwd, timeoutMs = DEFAULT_GIT_TIMEOUT_MS) {
return runGit(cwd, ["ls-files", "-z", "--", "*package.json"], timeoutMs)
.split("\0")
.filter(Boolean)
.toSorted((left, right) => left.localeCompare(right));
@@ -30,17 +48,12 @@ function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function readTrackedJson(cwd, relativePath) {
function readTrackedJson(cwd, relativePath, timeoutMs = DEFAULT_GIT_TIMEOUT_MS) {
const filePath = path.join(cwd, relativePath);
if (fs.existsSync(filePath)) {
return readJson(filePath);
}
return JSON.parse(
execFileSync("git", ["show", `:${relativePath}`], {
cwd,
encoding: "utf8",
}),
);
return JSON.parse(runGit(cwd, ["show", `:${relativePath}`], timeoutMs));
}
function isAllowedPinnedSpec(spec) {
@@ -62,10 +75,10 @@ function isAllowedPinnedSpec(spec) {
return false;
}
function collectPackageJsonViolations(cwd) {
function collectPackageJsonViolations(cwd, timeoutMs = DEFAULT_GIT_TIMEOUT_MS) {
const violations = [];
for (const relativePath of listTrackedPackageJsonFiles(cwd)) {
const packageJson = readTrackedJson(cwd, relativePath);
for (const relativePath of listTrackedPackageJsonFiles(cwd, timeoutMs)) {
const packageJson = readTrackedJson(cwd, relativePath, timeoutMs);
for (const section of PACKAGE_DEPENDENCY_SECTIONS) {
for (const [name, spec] of Object.entries(packageJson[section] ?? {})) {
if (!isAllowedPinnedSpec(spec)) {
@@ -109,9 +122,15 @@ function collectWorkspaceViolations(cwd) {
/**
* Collects dependency pin violations for the current workspace.
*
* @param {string} [cwd]
* @param {{ gitTimeoutMs?: number }} [options]
*/
export function collectDependencyPinViolations(cwd = process.cwd()) {
return [...collectPackageJsonViolations(cwd), ...collectWorkspaceViolations(cwd)];
export function collectDependencyPinViolations(
cwd = process.cwd(),
{ gitTimeoutMs = DEFAULT_GIT_TIMEOUT_MS } = {},
) {
return [...collectPackageJsonViolations(cwd, gitTimeoutMs), ...collectWorkspaceViolations(cwd)];
}
/**
+39 -3
View File
@@ -6,7 +6,12 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { laneResources, laneWeight } from "./lib/docker-e2e-plan.mjs";
import { allReleasePathLanes, mainLanes, tailLanes } from "./lib/docker-e2e-scenarios.mjs";
import {
allReleasePathLanes,
mainLanes,
publicInstallerLanes,
tailLanes,
} from "./lib/docker-e2e-scenarios.mjs";
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const errors = [];
@@ -47,13 +52,43 @@ function walk(dir, out = []) {
return out;
}
function findRelativeModuleSpecifiers(text) {
const specifiers = new Set();
for (const pattern of [
/(?:\bfrom\s*|\bimport\s*\()\s*["']([^"']+)["']/gu,
/\bimport\s*["']([^"']+)["']/gu,
]) {
for (const match of text.matchAll(pattern)) {
const specifier = match[1];
if (specifier?.startsWith(".")) {
specifiers.add(specifier);
}
}
}
return [...specifiers];
}
function isPathWithin(parent, candidate) {
const relative = path.relative(parent, candidate);
return (
relative === "" ||
(!path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== "..")
);
}
for (const relativePath of walk("scripts/e2e")) {
if (!/\.(?:sh|ts|mjs|js)$/u.test(relativePath)) {
continue;
}
const text = readText(relativePath);
if (/from\s+["']\.\.\/\.\.\/src\//u.test(text) || /import\(["']\.\.\/\.\.\/src\//u.test(text)) {
errors.push(`${relativePath}: Docker E2E harness must import built dist, not ../../src`);
const sourceImport = findRelativeModuleSpecifiers(text).find((specifier) => {
const resolved = path.resolve(ROOT_DIR, path.dirname(relativePath), specifier);
return isPathWithin(path.join(ROOT_DIR, "src"), resolved);
});
if (sourceImport) {
errors.push(
`${relativePath}: Docker E2E harness must import package exports, not ${sourceImport}`,
);
}
if (/-v\s+["']?\$ROOT_DIR:\/app(?::|["'\s]|$)/u.test(text)) {
errors.push(`${relativePath}: do not mount the repo root as /app in Docker E2E`);
@@ -121,6 +156,7 @@ function validateLane(label, lane) {
const releasePathLanes = allReleasePathLanes({ includeOpenWebUI: true });
for (const [label, lanes] of [
["release-path", releasePathLanes],
["public-installer", publicInstallerLanes],
["main", mainLanes],
["tail", tailLanes],
]) {
+4
View File
@@ -0,0 +1,4 @@
export function isCountedSourcePath(filePath: string): boolean;
export function collectEnvVarNames(root?: string, options?: { staged?: boolean }): string[];
export function parseBudget(source: string): number;
export function main(argv?: string[], root?: string): number;
+137
View File
@@ -0,0 +1,137 @@
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const BUDGET_PATH = "config/env-var-count-budget.txt";
const SOURCE_ROOTS = ["src", "packages", "extensions"];
const SOURCE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]);
const ENV_VAR_PATTERN = /OPENCLAW_[A-Z0-9_]+/gu;
export function isCountedSourcePath(filePath) {
const normalized = filePath.replaceAll("\\", "/");
if (!SOURCE_ROOTS.some((root) => normalized.startsWith(root + "/"))) {
return false;
}
if (!SOURCE_EXTENSIONS.has(path.posix.extname(normalized))) {
return false;
}
if (
/^(?:extensions\/(?:qa-lab|test-support)|.*\/(?:__tests__|test|tests|test-utils|test-support))\//u.test(
normalized,
)
) {
return false;
}
return !/(?:^|[./-])(?:e2e|live-helpers|live-harness|spec|suite|test|test-helpers|test-harness|test-setup|test-support|test-utils)(?:[./-]|$)/u.test(
normalized,
);
}
export function collectEnvVarNames(root = process.cwd(), options = {}) {
const staged = options.staged === true;
const files = execFileSync(
"git",
[
"ls-files",
"-z",
"--cached",
...(staged ? [] : ["--others", "--exclude-standard"]),
"--",
...SOURCE_ROOTS,
],
{ cwd: root, maxBuffer: 256 * 1024 * 1024 },
)
.toString("utf8")
.split("\0")
.filter(isCountedSourcePath)
.filter((file) => staged || fs.existsSync(path.join(root, file)));
const names = new Set();
for (const file of files) {
const source = staged
? execFileSync("git", ["show", `:${file}`], { cwd: root, encoding: "utf8" })
: fs.readFileSync(path.join(root, file), "utf8");
for (const match of source.matchAll(ENV_VAR_PATTERN)) {
names.add(match[0]);
}
}
return [...names].toSorted((left, right) => (left < right ? -1 : left > right ? 1 : 0));
}
export function parseBudget(source) {
const values = source
.split(/\r?\n/u)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"));
if (values.length !== 1 || !/^\d+$/u.test(values[0])) {
throw new Error(`${BUDGET_PATH} must contain exactly one non-negative integer`);
}
return Number(values[0]);
}
function readBaseBudget(root, ref) {
const resolved = spawnSync("git", ["rev-parse", "--verify", `${ref}^{commit}`], {
cwd: root,
encoding: "utf8",
});
if (resolved.status !== 0) {
throw new Error(`Could not resolve env-var count base ref: ${ref}`);
}
const mergeBase = spawnSync("git", ["merge-base", "HEAD", ref], {
cwd: root,
encoding: "utf8",
});
const baselineRef = mergeBase.stdout.trim();
if (mergeBase.status !== 0 || !baselineRef) {
throw new Error(`Could not resolve env-var count merge base for: ${ref}`);
}
const entry = execFileSync("git", ["ls-tree", "--name-only", baselineRef, "--", BUDGET_PATH], {
cwd: root,
encoding: "utf8",
}).trim();
if (!entry) {
return null;
}
return parseBudget(
execFileSync("git", ["show", `${baselineRef}:${BUDGET_PATH}`], {
cwd: root,
encoding: "utf8",
}),
);
}
export function main(argv = process.argv.slice(2), root = process.cwd()) {
const baseIndex = argv.indexOf("--base");
const baseRef = baseIndex >= 0 ? argv[baseIndex + 1] : "origin/main";
const staged = argv.includes("--staged");
const expectedLength = (baseIndex >= 0 ? 2 : 0) + (staged ? 1 : 0);
if ((baseIndex >= 0 && !baseRef) || argv.length !== expectedLength) {
throw new Error("Usage: node scripts/check-env-var-count.mjs [--staged] [--base <git-ref>]");
}
const budgetSource = staged
? execFileSync("git", ["show", `:${BUDGET_PATH}`], { cwd: root, encoding: "utf8" })
: fs.readFileSync(path.join(root, BUDGET_PATH), "utf8");
const budget = parseBudget(budgetSource);
const baseBudget = readBaseBudget(root, baseRef);
if (baseBudget !== null && budget > baseBudget) {
throw new Error(`OPENCLAW_* budget grew from ${baseBudget} to ${budget}`);
}
const names = collectEnvVarNames(root, { staged });
if (names.length !== budget) {
const direction = names.length > budget ? "exceeds" : "is below";
throw new Error(
`OPENCLAW_* count ${names.length} ${direction} budget ${budget}; update ${BUDGET_PATH}`,
);
}
console.log(`OPENCLAW_* count ${names.length}/${budget}`);
return names.length;
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
+89
View File
@@ -18,6 +18,13 @@ const ts = require("typescript");
const repoRoot = resolveRepoRoot(import.meta.url);
const sourceRoots = [path.join(repoRoot, "src")];
const nodeSqliteBoundaryRoots = [
path.join(repoRoot, "src"),
path.join(repoRoot, "extensions"),
path.join(repoRoot, "packages"),
];
const nodeSqliteConstructorOwnerPaths = new Set(["src/infra/node-sqlite.ts"]);
const kyselyRawAllowPaths = new Set(["src/infra/kysely-sync.ts"]);
@@ -40,8 +47,10 @@ const rawSqliteAllowPathGroups = {
"src/infra/sqlite-wal.ts",
"src/state/openclaw-agent-db-maintenance.ts",
"src/state/openclaw-agent-db-registry.ts",
"src/state/openclaw-agent-db-registry-listing.ts",
"src/state/openclaw-agent-db-schema-helpers.ts",
"src/state/openclaw-agent-db-schema.ts",
"src/state/openclaw-agent-db-session-nodes-migration.ts",
"src/state/openclaw-agent-db-session-migrations.ts",
"src/state/openclaw-agent-db-session-provenance.ts",
"src/state/openclaw-agent-db.ts",
@@ -54,6 +63,7 @@ const rawSqliteAllowPathGroups = {
"src/state/openclaw-state-db-schema-repair.ts",
"src/state/openclaw-state-db-startup-checkpoint.ts",
"src/state/openclaw-state-db.ts",
"src/transcripts/sqlite-schema.ts",
"src/state/sqlite-schema-shape.test-support.ts",
],
"cross-process SQLite coordination locks": ["src/infra/device-identity-coordinator.ts"],
@@ -67,6 +77,9 @@ const rawSqliteAllowPathGroups = {
"src/state/openclaw-agent-db-readonly.ts",
"src/state/openclaw-state-db-readonly.ts",
],
"cold-process read-only relay lookup avoids the shared state writer lifecycle": [
"src/agents/harness/native-hook-relay-client-store.ts",
],
"read-only schema preflight and integrity verification access": [
"src/state/openclaw-database-preflight.ts",
"src/state/openclaw-database-verify.worker.ts",
@@ -79,6 +92,7 @@ const rawSqliteAllowPathGroups = {
"src/commands/status.scan.shared.ts",
],
"doctor SQLite maintenance and legacy state migration": [
"src/commands/doctor-agent-memory-schema.ts",
"src/commands/doctor/cron/legacy-run-log-migration.ts",
"src/commands/doctor/cron/migration-ledger.ts",
"src/commands/doctor-sqlite-compact.ts",
@@ -90,8 +104,16 @@ const rawSqliteAllowPathGroups = {
"src/infra/state-migrations.storage.ts",
"src/infra/state-migrations.cron-run-logs.ts",
"src/infra/state-migrations.debug-proxy.ts",
"src/infra/state-migrations.meeting-transcripts-detection.ts",
"src/infra/state-migrations.meeting-transcripts-files.ts",
"src/infra/state-migrations.meeting-transcripts-verify.ts",
"src/infra/state-migrations.media-persistence.ts",
],
"shared database stores with direct DatabaseSync access": ["src/proxy-capture/store.sqlite.ts"],
"session entry cache connection-local validity counters": [
"src/config/sessions/session-accessor.sqlite-entry-cache.ts",
],
"device pairing cache connection-local validity counters": ["src/infra/device-pairing-store.ts"],
"Kysely-backed stores that own a DatabaseSync boundary": [
"src/acp/event-ledger.ts",
"src/state/user-profiles.ts",
@@ -230,6 +252,63 @@ function isSqliteStorePath(relativePath) {
return relativePath.endsWith(".sqlite.ts") || relativePath.includes(".store.sqlite.ts");
}
function collectNodeSqliteBoundaryViolations(content, relativePath) {
if (isTestPath(relativePath) || nodeSqliteConstructorOwnerPaths.has(relativePath)) {
return [];
}
const sourceFile = ts.createSourceFile(relativePath, content, ts.ScriptTarget.Latest, true);
const constructorNames = new Set();
function collectConstructorNames(node) {
if (ts.isImportDeclaration(node) && importSource(node) === "node:sqlite") {
const namedBindings = node.importClause?.namedBindings;
if (namedBindings && ts.isNamedImports(namedBindings)) {
for (const element of namedBindings.elements) {
if ((element.propertyName?.text ?? element.name.text) === "DatabaseSync") {
constructorNames.add(element.name.text);
}
}
}
}
if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name)) {
for (const element of node.name.elements) {
if (
!element.dotDotDotToken &&
ts.isIdentifier(element.name) &&
(element.propertyName ? getPropertyNameText(element.propertyName) : element.name.text) ===
"DatabaseSync"
) {
constructorNames.add(element.name.text);
}
}
}
ts.forEachChild(node, collectConstructorNames);
}
collectConstructorNames(sourceFile);
const violations = [];
function visit(node) {
if (ts.isNewExpression(node)) {
const expression = unwrapExpression(node.expression);
const isRawConstructor =
(ts.isIdentifier(expression) && constructorNames.has(expression.text)) ||
(ts.isPropertyAccessExpression(expression) &&
getPropertyNameText(expression.name) === "DatabaseSync");
if (isRawConstructor) {
addViolation(
violations,
sourceFile,
node,
"production node:sqlite connections must use openNodeSqliteDatabase",
);
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return violations;
}
function isLikelySqliteReceiver(expression) {
const unwrapped = unwrapExpression(expression);
if (ts.isIdentifier(unwrapped)) {
@@ -398,6 +477,16 @@ async function collectKyselyGuardrails() {
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");
for (const violation of collectNodeSqliteBoundaryViolations(content, relativePath)) {
violations.push({ path: relativePath, ...violation });
}
}
return violations;
}
+12 -16
View File
@@ -338,26 +338,22 @@ export function writeConfig({ homeDir, workspaceDir, port, token }) {
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: "none",
model: "",
store: {
vector: { enabled: false },
},
sync: {
watch: true,
onSessionStart: false,
onSearch: false,
},
},
},
list: [
{
id: "main",
entries: {
main: {
default: true,
tools: { allow: ["memory_search"] },
},
],
},
},
memory: {
search: {
provider: "none",
model: "",
store: {
vector: { enabled: false },
},
},
},
plugins: { allow: ["memory-core"] },
gateway: {
+3 -3
View File
@@ -49,9 +49,9 @@ const allowedRawFetchCallsites = new Set([
bundledPluginCallsite("qa-lab", "src/suite.ts", 330),
bundledPluginCallsite("qa-lab", "src/suite.ts", 341),
// The QA dashboard calls its same-origin local API from the browser, where server SSRF helpers do not run.
bundledPluginCallsite("qa-lab", "web/src/http.ts", 8),
bundledPluginCallsite("qa-lab", "web/src/http.ts", 16),
bundledPluginCallsite("qa-lab", "web/src/http.ts", 27),
bundledPluginCallsite("qa-lab", "web/src/http.ts", 24),
bundledPluginCallsite("qa-lab", "web/src/http.ts", 32),
bundledPluginCallsite("qa-lab", "web/src/http.ts", 43),
bundledPluginCallsite("qqbot", "src/engine/api/api-client.ts", 124),
bundledPluginCallsite("qqbot", "src/engine/api/media-chunked.ts", 554),
bundledPluginCallsite("qqbot", "src/engine/api/token.ts", 211),
+92 -19
View File
@@ -8,6 +8,7 @@ import os from "node:os";
import path from "node:path";
import { performance } from "node:perf_hooks";
import { pathToFileURL } from "node:url";
import { gte as semverGte, valid as validSemver } from "semver";
import { LOCAL_BUILD_METADATA_DIST_PATHS } from "./lib/local-build-metadata-paths.mjs";
import {
collectPackageDistImports,
@@ -84,6 +85,11 @@ const REQUIRED_BUNDLED_WORKSPACE_RUNTIME_ENTRIES = new Map([
[
{ specifier: "@openclaw/ai", entry: "dist/index.mjs" },
{ specifier: "@openclaw/ai/providers", entry: "dist/providers.mjs" },
{
specifier: "@openclaw/ai/transports",
entry: "dist/transports.mjs",
whenExported: "./transports",
},
{
specifier: "@openclaw/ai/internal/runtime",
entry: "dist/internal/runtime.mjs",
@@ -176,7 +182,17 @@ function collectBundledPackageRuntimeErrors({ name, entries, files, packageRoot,
if (bundledPackageJson.name !== name) {
errors.push(`bundled ${name} package.json must name ${name}`);
}
const runtimeEntries = REQUIRED_BUNDLED_WORKSPACE_RUNTIME_ENTRIES.get(name) ?? [];
const packageExports =
bundledPackageJson.exports &&
typeof bundledPackageJson.exports === "object" &&
!Array.isArray(bundledPackageJson.exports)
? bundledPackageJson.exports
: {};
// Trusted current-main harnesses validate frozen release targets. Require
// post-cut runtime subpaths only when the candidate manifest owns them.
const runtimeEntries = (REQUIRED_BUNDLED_WORKSPACE_RUNTIME_ENTRIES.get(name) ?? []).filter(
({ whenExported }) => !whenExported || Object.hasOwn(packageExports, whenExported),
);
const resolutions = resolveBundledPackageSpecifiers(
packageRoot,
runtimeEntries.map(({ specifier }) => specifier),
@@ -258,6 +274,8 @@ function collectRequiredBundledWorkspaceDependencyErrors(
}
const phaseTimingsEnabled = process.env.OPENCLAW_PACKAGE_TARBALL_CHECK_TIMINGS !== "0";
// Self-contained artifacts can exceed Node's 1 MiB spawnSync output default.
const TAR_LIST_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
function runPhase(label, action) {
const startedAt = performance.now();
try {
@@ -273,11 +291,12 @@ function runPhase(label, action) {
const list = runPhase("tar list", () =>
spawnSync("tar", ["-tf", tarball], {
encoding: "utf8",
maxBuffer: TAR_LIST_MAX_BUFFER_BYTES,
stdio: ["ignore", "pipe", "pipe"],
}),
);
if (list.status !== 0) {
fail(`tar -tf failed for ${tarball}: ${list.stderr || list.status}`);
fail(`tar -tf failed for ${tarball}: ${list.stderr || list.error?.message || list.status}`);
}
const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-package-tarball-"));
@@ -305,12 +324,17 @@ const normalized = entries.map((entry) => entry.replace(/^package\//u, ""));
const entrySet = new Set(normalized);
const errors = [];
const warnings = [];
const CODE_MODE_WORKER_PATH = "dist/agents/code-mode.worker.js";
const FIRST_CODE_MODE_WORKER_VERSION = "2026.5.14-beta.2";
const REQUIRED_TARBALL_ENTRIES = ["dist/control-ui/index.html", ...WORKSPACE_TEMPLATE_PACK_PATHS];
const PACKAGE_INSTALL_GUARD_RELATIVE_PATH = "dist/openclaw-install-guard";
const REQUIRED_TARBALL_ENTRY_PREFIXES = ["dist/control-ui/assets/"];
const LEGACY_PACKAGE_ACCEPTANCE_COMPAT_MAX = { year: 2026, month: 4, day: 25 };
const LEGACY_LOCAL_BUILD_METADATA_COMPAT_MAX = { year: 2026, month: 4, day: 26 };
const LEGACY_SHRINKWRAP_COMPAT_MAX = { year: 2026, month: 5, day: 20 };
const LEGACY_SHRINKWRAP_OMISSION_COMPAT_MAX = { year: 2026, month: 5, day: 20 };
// 2026.7.2-beta.4 is the last published artifact known to ship shrinkwrap.
// The whole 2026.7.2 train is transitional; later trains must be lockless.
const NPM_SHRINKWRAP_TRANSITION_TRAIN = { year: 2026, month: 7, day: 2 };
// 2026.7.1 shipped before the guard existed. Historical inspection may still check it.
const LEGACY_INSTALL_GUARD_COMPAT_MAX = { year: 2026, month: 7, day: 1 };
const FORBIDDEN_LOCAL_BUILD_METADATA_FILES = new Set(LOCAL_BUILD_METADATA_DIST_PATHS);
@@ -375,16 +399,21 @@ function isLegacyLocalBuildMetadataCompatVersion(version) {
return parsed ? compareCalver(parsed, LEGACY_LOCAL_BUILD_METADATA_COMPAT_MAX) <= 0 : false;
}
function isLegacyShrinkwrapCompatVersion(version) {
const parsed = parseCalver(version);
return parsed ? compareCalver(parsed, LEGACY_SHRINKWRAP_COMPAT_MAX) <= 0 : false;
}
function isLegacyInstallGuardCompatVersion(version) {
const parsed = parseCalver(version);
return parsed ? compareCalver(parsed, LEGACY_INSTALL_GUARD_COMPAT_MAX) <= 0 : false;
}
function isLegacyShrinkwrapOmissionCompatVersion(version) {
const parsed = parseCalver(version);
return parsed ? compareCalver(parsed, LEGACY_SHRINKWRAP_OMISSION_COMPAT_MAX) <= 0 : false;
}
function compareNpmShrinkwrapTransitionTrain(version) {
const parsed = parseCalver(version);
return parsed ? compareCalver(parsed, NPM_SHRINKWRAP_TRANSITION_TRAIN) : null;
}
function readTarEntry(entryPath) {
const candidates = [
path.join(extractDir, entryPath),
@@ -427,9 +456,10 @@ for (const requiredPrefix of REQUIRED_TARBALL_ENTRY_PREFIXES) {
}
}
let packageVersion = "";
let packageJson = null;
if (entrySet.has("package.json")) {
try {
const packageJson = JSON.parse(readTarEntry("package.json"));
packageJson = JSON.parse(readTarEntry("package.json"));
packageVersion = typeof packageJson.version === "string" ? packageJson.version : "";
errors.push(...collectWorkspaceProtocolDependencyErrors(packageJson, "package.json"));
if (cliArgs.requireBundledWorkspaceDeps) {
@@ -447,23 +477,39 @@ if (entrySet.has("package.json")) {
packageVersion = "";
}
}
const validPackageVersion = validSemver(packageVersion);
const requiresCodeModeWorker =
validPackageVersion !== null && semverGte(validPackageVersion, FIRST_CODE_MODE_WORKER_VERSION);
if (requiresCodeModeWorker && !entrySet.has(CODE_MODE_WORKER_PATH)) {
errors.push(`missing required tar entry ${CODE_MODE_WORKER_PATH}`);
}
if (entrySet.has("package-lock.json")) {
errors.push("package tarball must ship npm-shrinkwrap.json, not package-lock.json");
errors.push("package tarball must not contain package-lock.json");
}
if (!entrySet.has(PACKAGE_INSTALL_GUARD_RELATIVE_PATH)) {
if (isLegacyInstallGuardCompatVersion(packageVersion)) {
warnings.push("legacy package omits the preinstall completion guard");
} else {
errors.push(`missing required tar entry ${PACKAGE_INSTALL_GUARD_RELATIVE_PATH}`);
const shrinkwrapTransitionComparison = compareNpmShrinkwrapTransitionTrain(packageVersion);
const hasShrinkwrap = entrySet.has("npm-shrinkwrap.json");
let shouldValidateShrinkwrap = false;
if (shrinkwrapTransitionComparison !== null && shrinkwrapTransitionComparison > 0) {
if (hasShrinkwrap) {
errors.push("package tarball must not contain npm-shrinkwrap.json");
}
}
if (!entrySet.has("npm-shrinkwrap.json")) {
if (isLegacyShrinkwrapCompatVersion(packageVersion)) {
} else if (shrinkwrapTransitionComparison === 0) {
if (hasShrinkwrap) {
warnings.push(
"2026.7.2 transition package contains npm-shrinkwrap.json from the published beta train",
);
shouldValidateShrinkwrap = true;
}
} else if (!hasShrinkwrap) {
if (isLegacyShrinkwrapOmissionCompatVersion(packageVersion)) {
warnings.push("legacy package omits npm-shrinkwrap.json");
} else {
errors.push("missing required tar entry npm-shrinkwrap.json");
errors.push("legacy package is missing required tar entry npm-shrinkwrap.json");
}
} else {
shouldValidateShrinkwrap = true;
}
if (shouldValidateShrinkwrap) {
try {
const shrinkwrap = JSON.parse(readTarEntry("npm-shrinkwrap.json"));
const rootPackage = shrinkwrap.packages?.[""];
@@ -503,6 +549,13 @@ if (!entrySet.has("npm-shrinkwrap.json")) {
);
}
}
if (!entrySet.has(PACKAGE_INSTALL_GUARD_RELATIVE_PATH)) {
if (isLegacyInstallGuardCompatVersion(packageVersion)) {
warnings.push("legacy package omits the preinstall completion guard");
} else {
errors.push(`missing required tar entry ${PACKAGE_INSTALL_GUARD_RELATIVE_PATH}`);
}
}
for (const forbiddenEntry of FORBIDDEN_LOCAL_BUILD_METADATA_FILES) {
if (entrySet.has(forbiddenEntry)) {
if (isLegacyLocalBuildMetadataCompatVersion(packageVersion)) {
@@ -526,11 +579,31 @@ if (entrySet.has("dist/postinstall-inventory.json")) {
} else {
const normalizedInventory = inventory.map((entry) => entry.replace(/\\/gu, "/"));
const normalizedInventorySet = new Set(normalizedInventory);
if (requiresCodeModeWorker && !normalizedInventorySet.has(CODE_MODE_WORKER_PATH)) {
errors.push(`postinstall inventory omits ${CODE_MODE_WORKER_PATH}`);
}
if (normalizedInventorySet.has(PACKAGE_INSTALL_GUARD_RELATIVE_PATH)) {
errors.push(
`package dist inventory must omit install guard ${PACKAGE_INSTALL_GUARD_RELATIVE_PATH}`,
);
}
if (typeof packageJson?.scripts?.postinstall === "string") {
// Postinstall prunes every uninventoried dist file, including dashboard
// assets that cannot be recovered from the JavaScript import graph.
const requiredControlUiInventoryEntries = new Set([
...REQUIRED_TARBALL_ENTRIES.filter((entry) => entry.startsWith("dist/")),
...normalized.filter(
(entry) =>
REQUIRED_TARBALL_ENTRY_PREFIXES.some((prefix) => entry.startsWith(prefix)) &&
fs.statSync(path.join(extractedPackageRoot, entry)).isFile(),
),
]);
for (const requiredEntry of requiredControlUiInventoryEntries) {
if (!normalizedInventorySet.has(requiredEntry)) {
errors.push(`postinstall inventory omits Control UI file ${requiredEntry}`);
}
}
}
packageDistImports = runPhase("dist import graph", () =>
collectPackageDistImports({
files: normalized,
+60 -10
View File
@@ -13,7 +13,13 @@ import {
} from "./lib/ts-guard-utils.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const scanRoots = resolveSourceRoots(repoRoot, ["src", "extensions", "scripts", "test"]);
const scanRoots = resolveSourceRoots(repoRoot, [
"src",
"packages",
"extensions",
"scripts",
"test",
]);
function readPackageExports() {
const packageJson = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8"));
@@ -49,6 +55,28 @@ function parsePluginSdkSubpath(specifier) {
return subpath || null;
}
function isGeneratedBuildArtifact(filePath) {
return normalizeRepoPath(repoRoot, filePath).split("/").includes("dist");
}
function isRuntimeModuleReference(node) {
// With verbatimModuleSyntax, inline `type` specifiers emit an empty import/export and still
// resolve the module. Only declaration-level `import type` and `export type` are erased.
if (ts.isImportDeclaration(node)) {
return !node.importClause?.isTypeOnly;
}
if (ts.isExportDeclaration(node)) {
return !node.isTypeOnly;
}
if (ts.isImportTypeNode(node)) {
return false;
}
if (ts.isImportEqualsDeclaration(node)) {
return !node.isTypeOnly;
}
return true;
}
function compareEntries(left, right) {
return (
left.file.localeCompare(right.file) ||
@@ -63,22 +91,44 @@ async function collectViolations() {
const entrypoints = readEntrypoints();
const exports = readPackageExports();
const privateLocalOnlySubpaths = readPrivateLocalOnlySubpaths();
const files = (await collectTypeScriptFilesFromRoots(scanRoots, { includeTests: true })).toSorted(
(left, right) =>
normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)),
// Workspace packages resolve private facades through root TS paths and bundle them into dist;
// live jiti source stages inject the same private map. Core src callers must stay relative.
const coreRuntimeFiles = new Set(
(
await collectTypeScriptFilesFromRoots(resolveSourceRoots(repoRoot, ["src"]), {
includeTests: false,
extraTestSuffixes: [".test-support.ts", ".test-loader.ts", ".test-fixtures.ts"],
})
).filter((filePath) => !isGeneratedBuildArtifact(filePath)),
);
const files = (await collectTypeScriptFilesFromRoots(scanRoots, { includeTests: true }))
.filter((filePath) => !isGeneratedBuildArtifact(filePath))
.toSorted((left, right) =>
normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)),
);
const violations = [];
for (const filePath of files) {
const sourceText = readFileSync(filePath, "utf8");
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true);
function push(kind, specifierNode, specifier) {
function push(kind, node, specifierNode, specifier) {
const subpath = parsePluginSdkSubpath(specifier);
if (!subpath) {
return;
}
if (privateLocalOnlySubpaths.has(subpath)) {
const repoPath = normalizeRepoPath(repoRoot, filePath);
if (coreRuntimeFiles.has(filePath) && isRuntimeModuleReference(node)) {
violations.push({
file: repoPath,
line: toLine(sourceFile, specifierNode),
kind,
specifier,
subpath,
reason: "private runtime helper used by core must use a relative import",
});
}
return;
}
@@ -99,12 +149,12 @@ async function collectViolations() {
kind,
specifier,
subpath,
missingFrom,
reason: `missing from ${missingFrom.join(" and ")}`,
});
}
visitModuleSpecifiers(ts, sourceFile, ({ kind, specifier, specifierNode }) => {
push(kind, specifierNode, specifier);
visitModuleSpecifiers(ts, sourceFile, ({ kind, node, specifier, specifierNode }) => {
push(kind, node, specifierNode, specifier);
});
}
@@ -119,11 +169,11 @@ async function main() {
}
console.error(
"Rule: every referenced openclaw/plugin-sdk/<subpath> must exist in the public package exports.",
"Rule: every referenced openclaw/plugin-sdk/<subpath> must be public or use its required private boundary.",
);
for (const violation of violations) {
console.error(
`- ${violation.file}:${violation.line} [${violation.kind}] ${violation.specifier} missing from ${violation.missingFrom.join(" and ")}`,
`- ${violation.file}:${violation.line} [${violation.kind}] ${violation.specifier}: ${violation.reason}`,
);
}
process.exit(1);
+154
View File
@@ -0,0 +1,154 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const schemaDir = path.join(repoRoot, "packages/gateway-protocol/src/schema");
const failures = [];
const read = (relativePath) => fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
const check = (condition, message) => {
if (!condition) {
failures.push(message);
}
};
const registryPath = "packages/gateway-protocol/src/schema/protocol-schemas.ts";
const registrySource = read(registryPath);
const fragmentImports = [
...registrySource.matchAll(
/^import \{ ([A-Za-z0-9_]+) \} from "(\.\/protocol-schema-fragment-[^"]+\.js)";$/gmu,
),
].map((match) => ({ binding: match[1], specifier: match[2] }));
const importSpecifiers = [...registrySource.matchAll(/^import .* from "([^"]+)";$/gmu)].map(
(match) => match[1],
);
check(
importSpecifiers.every(
(specifier) =>
specifier === "./protocol-schema-composer.js" ||
specifier.startsWith("./protocol-schema-fragment-"),
),
`${registryPath} may import only the composer and schema fragments`,
);
check(
!/\b[A-Z][A-Za-z0-9]*Schema\b/u.test(registrySource),
`${registryPath} contains a direct *Schema inventory`,
);
const composition = registrySource.match(
/export const ProtocolSchemas = composeProtocolSchemaFragments\(\[([\s\S]*?)\]\s+as const\);/u,
);
const composedBindings = (composition?.[1] ?? "")
.split("\n")
.map((line) => line.trim().replace(/,$/u, ""))
.filter(Boolean);
const importedBindings = fragmentImports.map(({ binding }) => binding);
check(Boolean(composition), `${registryPath} must explicitly compose an ordered fragment array`);
check(
composedBindings.length === importedBindings.length &&
new Set(composedBindings).size === composedBindings.length &&
importedBindings.every((binding) => composedBindings.includes(binding)),
`${registryPath} must compose every imported fragment exactly once`,
);
const fragmentFiles = fs
.readdirSync(schemaDir)
.filter((name) => /^protocol-schema-fragment-.+\.ts$/u.test(name));
const importedFiles = fragmentImports.map(({ specifier }) => `${specifier.slice(2, -3)}.ts`);
check(
fragmentFiles.length === importedFiles.length &&
fragmentFiles.every((name) => importedFiles.includes(name)),
`${registryPath} must explicitly import every protocol schema fragment`,
);
const importsByBinding = new Map(
fragmentImports.map((fragmentImport) => [fragmentImport.binding, fragmentImport]),
);
const seenKeys = new Set();
const orderedKeys = [];
for (const binding of composedBindings) {
const { specifier } = importsByBinding.get(binding) ?? {};
if (!specifier) {
continue;
}
const moduleUrl = new URL(specifier.replace(/\.js$/u, ".ts"), pathToFileURL(registryPath));
const fragment = (await import(moduleUrl.href))[binding];
check(fragment && typeof fragment === "object", `${specifier} must export object ${binding}`);
if (!fragment || typeof fragment !== "object") {
continue;
}
for (const key of Object.keys(fragment)) {
check(!seenKeys.has(key), `duplicate protocol schema key ${key}`);
seenKeys.add(key);
orderedKeys.push(key);
}
}
const { ProtocolSchemas } = await import(
pathToFileURL(path.join(schemaDir, "protocol-schemas.ts"))
);
check(
JSON.stringify(Object.keys(ProtocolSchemas)) === JSON.stringify(orderedKeys),
"ProtocolSchemas must preserve explicit fragment/key order",
);
const composerSource = read("packages/gateway-protocol/src/schema/protocol-schema-composer.ts");
check(!/\.(?:sort|toSorted)\s*\(/u.test(composerSource), "schema composer must not sort");
check(
composerSource.includes("Object.hasOwn(registry, key)"),
"schema composer must reject duplicate fragment keys",
);
const withoutComments = (source) =>
source
.replace(/\r\n?/gu, "\n")
.replace(/\/\*[\s\S]*?\*\//gu, "")
.replace(/^\s*\/\/.*$/gmu, "")
.trim();
const schemaModulesSource = withoutComments(
read("packages/gateway-protocol/src/schema-modules.ts"),
);
const ownerModules = [
...schemaModulesSource.matchAll(/^export \* from "\.\/schema\/([^"]+)\.js";$/gmu),
].map((match) => match[1]);
check(
ownerModules.length === 52 && new Set(ownerModules).size === ownerModules.length,
"schema-modules.ts must contain one unique 52-module owner list",
);
check(
schemaModulesSource.split("\n").filter(Boolean).length === ownerModules.length,
"schema-modules.ts may contain only owner-module exports",
);
check(
withoutComments(read("packages/gateway-protocol/src/schema.ts")) ===
'export * from "./schema-modules.js";\nexport * from "./schema/protocol-schemas.js";',
"schema.ts must remain a schema-modules/protocol-schemas wrapper",
);
check(
withoutComments(read("packages/gateway-protocol/src/schema-types.ts")) ===
'export type * from "./schema-modules.js";',
"schema-types.ts must remain a registry-free schema-modules wrapper",
);
for (const relativePath of [
"packages/gateway-protocol/src/index.ts",
"packages/gateway-protocol/src/schema-export-registry.ts",
"packages/gateway-protocol/src/validator-registry.ts",
]) {
check(
!read(relativePath).includes('from "./schema.js"'),
`${relativePath} must not cross the registry through schema.ts`,
);
}
const pluginSdkGuard = read("scripts/check-plugin-sdk-exports.mjs");
check(
pluginSdkGuard.includes("FORBIDDEN_PUBLIC_PROTOCOL_REGISTRY_RE") &&
pluginSdkGuard.includes("FORBIDDEN PUBLIC DTS REGISTRY"),
"plugin SDK declaration checks must reject leaked ProtocolSchemas declarations",
);
if (failures.length) {
throw new Error(
failures.map((failure) => `protocol registry check failed: ${failure}`).join("\n"),
);
}
console.log("protocol registry check passed");
+37 -5
View File
@@ -5,6 +5,10 @@ import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { RELEASE_METADATA_PATHS } from "./changed-lanes.mjs";
const DEFAULT_GIT_TIMEOUT_MS = 60_000;
const MAX_GIT_TIMEOUT_MS = 10 * 60_000;
const GIT_TIMEOUT_ENV = "OPENCLAW_RELEASE_METADATA_GIT_TIMEOUT_MS";
const VERSION_ONLY_TEXT_PATHS = new Set([
"apps/android/Config/Version.properties",
"apps/android/version.json",
@@ -26,6 +30,18 @@ function readRefOptionValue(argv, index, optionName) {
return value;
}
function resolveGitTimeoutMs(env = process.env) {
const raw = env[GIT_TIMEOUT_ENV]?.trim();
if (!raw) {
return DEFAULT_GIT_TIMEOUT_MS;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_GIT_TIMEOUT_MS;
}
return Math.max(1, Math.min(Math.trunc(parsed), MAX_GIT_TIMEOUT_MS));
}
export function parseArgs(argv) {
const separatorIndex = argv.indexOf("--");
const flagArgv = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex);
@@ -52,12 +68,28 @@ export function parseArgs(argv) {
return args;
}
function formatGitArgs(args) {
return args.join(" ");
}
function git(args) {
return execFileSync("git", args, {
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
});
const timeout = resolveGitTimeoutMs();
try {
return execFileSync("git", args, {
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
timeout,
});
} catch (error) {
if (error?.signal === "SIGTERM" || error?.code === "ETIMEDOUT") {
throw new Error(
`release metadata guard: git ${formatGitArgs(args)} timed out after ${timeout}ms.`,
{ cause: error },
);
}
throw error;
}
}
function listChangedPaths(args) {
@@ -14,6 +14,10 @@ export function findSessionAccessorBoundaryViolations(
content: unknown,
fileName?: string,
): unknown[];
export function findReadOnlySessionAccessorViolations(
content: unknown,
fileName?: string,
): unknown[];
export function findEmbeddedAgentSessionTargetViolations(
content: unknown,
fileName?: string,
@@ -71,3 +75,4 @@ export const migratedTranscriptWriterFiles: Set<string>;
export const migratedSessionCompactManualTrimFiles: Set<string>;
export const migratedSessionLifecycleCleanupFiles: Set<string>;
export const migratedMemoryHostSessionCorpusFiles: Set<string>;
export const readOnlyGatewaySessionAccessorFiles: Set<string>;
+128 -148
View File
@@ -5,6 +5,7 @@ import path from "node:path";
import ts from "typescript";
import {
collectFileViolations,
getPropertyNameText,
resolveRepoRoot,
resolveSourceRoots,
runAsScript,
@@ -65,6 +66,7 @@ const sessionStoreRuntimeFileBackedCompatNames = new Set([
"updateSessionStore",
]);
const embeddedAgentSessionFileRuntimeNames = new Set(["resolveSessionFilePath"]);
const materializingSessionEntryAccessorNames = new Set(["listSessionEntries", "loadSessionEntry"]);
// Shipped beta.5 official plugins import these deprecated helpers during
// doctor migrations. Remove this ratchet with the compatibility bridge once
@@ -121,7 +123,7 @@ export const migratedSessionAccessorFiles = new Set([
"src/commands/sessions-tail.ts",
"src/commands/sessions.ts",
"src/commands/status.agent-local.ts",
"src/commands/status.summary.ts",
"src/status/summary.ts",
"src/commands/tasks.ts",
"src/config/sessions/combined-store-gateway.ts",
"src/config/sessions/delivery-info.ts",
@@ -185,7 +187,9 @@ export const migratedSessionAccessorWriteFiles = new Set([
"src/agents/embedded-agent-subscribe.handlers.compaction.runtime.ts",
"src/agents/embedded-agent-runner/run/attempt.ts",
"src/agents/live-model-switch.ts",
"src/agents/main-session-restart-recovery.ts",
"src/agents/main-session-restart-recovery-checkpoint.ts",
"src/agents/main-session-restart-recovery-marking.ts",
"src/agents/main-session-restart-recovery-store.ts",
"src/agents/session-suspension.ts",
"src/auto-reply/reply/abort.ts",
"src/agents/subagent-control.ts",
@@ -246,6 +250,25 @@ export const migratedSessionLifecycleCleanupFiles = new Set([
"src/infra/heartbeat-runner.ts",
]);
export const readOnlyGatewaySessionAccessorFiles = new Set([
"src/gateway/approval-session-audience.ts",
"src/gateway/control-ui-session-prs.ts",
"src/gateway/managed-image-attachments.ts",
"src/gateway/mcp-app-reconstruction.ts",
"src/gateway/server-chat.ts",
"src/gateway/server-methods/artifacts.ts",
"src/gateway/server-methods/chat-history-handler.ts",
"src/gateway/server-methods/chat-message-get-handler.ts",
"src/gateway/server-methods/sessions-diff.ts",
"src/gateway/server-methods/sessions-files.ts",
"src/gateway/server-methods/sessions-read.ts",
"src/gateway/server-methods/sessions-subscriptions.ts",
"src/gateway/server-methods/task-suggestions.ts",
"src/gateway/server-methods/tools-effective.ts",
"src/gateway/server-methods/usage.ts",
"src/gateway/server-session-events.ts",
]);
export const migratedMemoryHostSessionCorpusFiles = new Set([
"packages/memory-host-sdk/src/host/session-files.ts",
"packages/memory-host-sdk/src/host/session-transcript-corpus.ts",
@@ -293,13 +316,6 @@ function propertyAccessName(expression) {
return null;
}
function propertyNameText(name) {
if (ts.isIdentifier(name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name)) {
return name.text;
}
return null;
}
function bindingName(node) {
if (node.propertyName && ts.isIdentifier(node.propertyName)) {
return node.propertyName.text;
@@ -313,6 +329,12 @@ function bindingName(node) {
function findNamedBoundaryViolations(content, fileName, legacyNames, subject) {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const violations = [];
const addViolation = (node, action, name) => {
violations.push({
line: toLine(sourceFile, node),
reason: `${action} ${subject} "${name}"`,
});
};
const visit = (node) => {
if (ts.isImportDeclaration(node)) {
@@ -321,10 +343,7 @@ function findNamedBoundaryViolations(content, fileName, legacyNames, subject) {
for (const specifier of namedBindings.elements) {
const importedName = specifier.propertyName?.text ?? specifier.name.text;
if (legacyNames.has(importedName)) {
violations.push({
line: toLine(sourceFile, specifier),
reason: `imports ${subject} "${importedName}"`,
});
addViolation(specifier, "imports", importedName);
}
}
}
@@ -333,18 +352,12 @@ function findNamedBoundaryViolations(content, fileName, legacyNames, subject) {
if (ts.isBindingElement(node)) {
const name = bindingName(node);
if (name && legacyNames.has(name)) {
violations.push({
line: toLine(sourceFile, node),
reason: `aliases ${subject} "${name}"`,
});
addViolation(node, "aliases", name);
}
}
if (ts.isPropertyAccessExpression(node) && legacyNames.has(node.name.text)) {
violations.push({
line: toLine(sourceFile, node.name),
reason: `references ${subject} "${node.name.text}"`,
});
addViolation(node.name, "references", node.name.text);
}
if (
@@ -352,10 +365,7 @@ function findNamedBoundaryViolations(content, fileName, legacyNames, subject) {
ts.isStringLiteral(node.argumentExpression) &&
legacyNames.has(node.argumentExpression.text)
) {
violations.push({
line: toLine(sourceFile, node.argumentExpression),
reason: `references ${subject} "${node.argumentExpression.text}"`,
});
addViolation(node.argumentExpression, "references", node.argumentExpression.text);
}
if (ts.isCallExpression(node)) {
@@ -365,10 +375,7 @@ function findNamedBoundaryViolations(content, fileName, legacyNames, subject) {
legacyNames.has(calleeName) &&
ts.isIdentifier(unwrapExpression(node.expression))
) {
violations.push({
line: toLine(sourceFile, node.expression),
reason: `calls ${subject} "${calleeName}"`,
});
addViolation(node.expression, "calls", calleeName);
}
}
@@ -462,6 +469,15 @@ export function findSessionAccessorBoundaryViolations(content, fileName = "sourc
return findNamedSessionStoreViolations(content, fileName, legacyNames, legacyKind);
}
export function findReadOnlySessionAccessorViolations(content, fileName = "source.ts") {
return findNamedBoundaryViolations(
content,
fileName,
materializingSessionEntryAccessorNames,
"materializing session entry accessor",
);
}
export function findEmbeddedAgentSessionTargetViolations(content, fileName = "source.ts") {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const violations = findNamedBoundaryViolations(
@@ -481,7 +497,10 @@ export function findEmbeddedAgentSessionTargetViolations(content, fileName = "so
const visitRunOptions = (options) => {
for (const property of options.properties) {
if (ts.isPropertyAssignment(property) && propertyNameText(property.name) === "sessionFile") {
if (
ts.isPropertyAssignment(property) &&
getPropertyNameText(property.name) === "sessionFile"
) {
recordDeprecatedSessionFile(property.name);
} else if (
ts.isShorthandPropertyAssignment(property) &&
@@ -610,39 +629,27 @@ const transcriptWriterSourceRootPaths = [
"src/sessions",
];
function declarationName(node) {
if (ts.isFunctionDeclaration(node) && node.name) {
return node.name.text;
}
if (!ts.isVariableStatement(node)) {
return null;
}
const declaration = node.declarationList.declarations[0];
return declaration && ts.isIdentifier(declaration.name) ? declaration.name.text : null;
}
function functionBodyForDeclaration(node) {
if (ts.isFunctionDeclaration(node)) {
return node.body ?? null;
}
if (!ts.isVariableStatement(node)) {
return null;
}
const declaration = node.declarationList.declarations[0];
const initializer = declaration?.initializer;
if (initializer && (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer))) {
return initializer.body;
}
return null;
}
function collectTopLevelFunctionBodies(sourceFile) {
const bodies = new Map();
for (const statement of sourceFile.statements) {
const name = declarationName(statement);
const body = functionBodyForDeclaration(statement);
if (name && body) {
bodies.set(name, body);
if (ts.isFunctionDeclaration(statement)) {
if (statement.name && statement.body) {
bodies.set(statement.name.text, statement.body);
}
continue;
}
if (!ts.isVariableStatement(statement)) {
continue;
}
const declaration = statement.declarationList.declarations[0];
const initializer = declaration?.initializer;
if (
declaration &&
ts.isIdentifier(declaration.name) &&
initializer &&
(ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer))
) {
bodies.set(declaration.name.text, initializer.body);
}
}
return bodies;
@@ -839,6 +846,27 @@ async function writeSessionAccessorDebtBaseline(repoRoot) {
await fs.writeFile(resolveDebtBaselinePath(repoRoot), `${JSON.stringify(counts, null, 2)}\n`);
}
function compareMigratedFilePaths(left, right, sourceRootPaths) {
const rootIndex = (filePath) =>
sourceRootPaths.findIndex((sourceRoot) => filePath.startsWith(`${sourceRoot}/`));
const leftRoot = rootIndex(left);
const rightRoot = rootIndex(right);
const rootOrder =
(leftRoot < 0 ? sourceRootPaths.length : leftRoot) -
(rightRoot < 0 ? sourceRootPaths.length : rightRoot);
if (rootOrder !== 0) {
return rootOrder;
}
// Recursive directory walks visit nested files before same-prefix sibling files.
const leftTraversalPath = left.replaceAll("/", "\0");
const rightTraversalPath = right.replaceAll("/", "\0");
return leftTraversalPath < rightTraversalPath
? -1
: leftTraversalPath > rightTraversalPath
? 1
: 0;
}
export async function main() {
const repoRoot = resolveRepoRoot(import.meta.url);
if (process.argv.includes("--update-debt-baseline")) {
@@ -846,100 +874,52 @@ export async function main() {
console.log(`Wrote ${sessionAccessorDebtBaselineRelativePath}`);
return;
}
const readSourceRoots = resolveSourceRoots(repoRoot, readSourceRootPaths);
const writeSourceRoots = resolveSourceRoots(repoRoot, writeSourceRootPaths);
const transcriptWriterSourceRoots = resolveSourceRoots(repoRoot, transcriptWriterSourceRootPaths);
const readViolations = await collectFileViolations({
repoRoot,
sourceRoots: readSourceRoots,
skipFile: (filePath) => {
const relativePath = normalizeRelativePath(path.relative(repoRoot, filePath));
return (
!migratedSessionAccessorFiles.has(relativePath) &&
!migratedBundledPluginSessionAccessorFiles.has(relativePath)
);
const debtConcerns = Object.fromEntries(
sessionAccessorDebtConcerns.map((concern) => [concern.key, concern]),
);
const enforcementConcerns = [
debtConcerns.sessionAccessorRead,
debtConcerns.sessionAccessorWrite,
debtConcerns.transcriptWriter,
{
sourceRootPaths: ["src/gateway/server-methods"],
migratedFiles: new Set(["src/gateway/server-methods/sessions-create.ts"]),
findViolations: findGatewaySessionCreateLifecycleViolations,
},
findViolations: findSessionAccessorBoundaryViolations,
});
const writeViolations = await collectFileViolations({
repoRoot,
sourceRoots: writeSourceRoots,
skipFile: (filePath) =>
!migratedSessionAccessorWriteFiles.has(
normalizeRelativePath(path.relative(repoRoot, filePath)),
),
findViolations: findSessionAccessorWriteBoundaryViolations,
});
const transcriptWriterViolations = await collectFileViolations({
repoRoot,
sourceRoots: transcriptWriterSourceRoots,
skipFile: (filePath) =>
!migratedTranscriptWriterFiles.has(normalizeRelativePath(path.relative(repoRoot, filePath))),
findViolations: findTranscriptWriterBoundaryViolations,
});
const sessionCreateLifecycleViolations = await collectFileViolations({
repoRoot,
sourceRoots: resolveSourceRoots(repoRoot, ["src/gateway/server-methods"]),
skipFile: (filePath) =>
normalizeRelativePath(path.relative(repoRoot, filePath)) !==
"src/gateway/server-methods/sessions-create.ts",
findViolations: findGatewaySessionCreateLifecycleViolations,
});
const manualCompactTrimViolations = await collectFileViolations({
repoRoot,
sourceRoots: resolveSourceRoots(repoRoot, ["src/gateway/server-methods"]),
skipFile: (filePath) =>
!migratedSessionCompactManualTrimFiles.has(
normalizeRelativePath(path.relative(repoRoot, filePath)),
),
findViolations: findSessionCompactManualTrimBoundaryViolations,
});
const lifecycleCleanupViolations = await collectFileViolations({
repoRoot,
sourceRoots: readSourceRoots,
skipFile: (filePath) =>
!migratedSessionLifecycleCleanupFiles.has(
normalizeRelativePath(path.relative(repoRoot, filePath)),
),
findViolations: findSessionLifecycleCleanupBoundaryViolations,
});
const memoryHostSessionCorpusViolations = await collectFileViolations({
repoRoot,
sourceRoots: resolveSourceRoots(repoRoot, ["packages/memory-host-sdk/src/host"]),
skipFile: (filePath) =>
!migratedMemoryHostSessionCorpusFiles.has(
normalizeRelativePath(path.relative(repoRoot, filePath)),
),
findViolations: findMemoryHostSessionCorpusBoundaryViolations,
});
const embeddedAgentSessionTargetViolations = await collectFileViolations({
repoRoot,
sourceRoots: resolveSourceRoots(repoRoot, ["extensions/voice-call/src"]),
skipFile: (filePath) =>
!migratedEmbeddedAgentSessionTargetFiles.has(
normalizeRelativePath(path.relative(repoRoot, filePath)),
),
findViolations: findEmbeddedAgentSessionTargetViolations,
});
debtConcerns.sessionCompactManualTrim,
debtConcerns.sessionLifecycleCleanup,
debtConcerns.memoryHostSessionCorpus,
debtConcerns.embeddedAgentSessionTarget,
{
sourceRootPaths: ["src/gateway"],
migratedFiles: readOnlyGatewaySessionAccessorFiles,
findViolations: findReadOnlySessionAccessorViolations,
},
];
const violations = [];
for (const concern of enforcementConcerns) {
violations.push(
...(await collectFileViolations({
repoRoot,
sourceRoots: resolveSourceRoots(
repoRoot,
[...concern.migratedFiles].toSorted((left, right) =>
compareMigratedFilePaths(left, right, concern.sourceRootPaths),
),
),
findViolations: concern.findViolations,
})),
);
}
const sessionStoreRuntimePath = path.join(repoRoot, "src/plugin-sdk/session-store-runtime.ts");
const sessionStoreRuntimeCompatViolations =
findSessionStoreRuntimeFileBackedCompatExportViolations(
violations.push(
...findSessionStoreRuntimeFileBackedCompatExportViolations(
await fs.readFile(sessionStoreRuntimePath, "utf8"),
sessionStoreRuntimePath,
).map((violation) =>
Object.assign({ path: "src/plugin-sdk/session-store-runtime.ts" }, violation),
);
const violations = [
...readViolations,
...writeViolations,
...transcriptWriterViolations,
...sessionCreateLifecycleViolations,
...manualCompactTrimViolations,
...lifecycleCleanupViolations,
...memoryHostSessionCorpusViolations,
...embeddedAgentSessionTargetViolations,
...sessionStoreRuntimeCompatViolations,
];
),
);
const baselineCounts = await readSessionAccessorDebtBaseline(repoRoot);
if (!baselineCounts) {
@@ -70,7 +70,7 @@ const gatewaySessionServerMethodFiles = [
];
export const migratedSessionTranscriptReaderFiles = new Set([
"src/agents/main-session-restart-recovery.ts",
"src/agents/main-session-restart-recovery-store.ts",
"src/agents/subagent-announce-output.test.ts",
"src/agents/subagent-announce-output.ts",
"src/agents/subagent-announce.runtime.ts",
+1 -1
View File
@@ -7,7 +7,7 @@ import { mkdtempSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ACTIONLINT_VERSION = "1.7.11";
const ACTIONLINT_VERSION = "1.7.12";
const PRE_COMMIT_VERSION = "4.2.0";
const WORKFLOW_DIR = ".github/workflows";
+2 -1
View File
@@ -87,6 +87,7 @@ export async function main(argv = process.argv.slice(2)) {
parallel: true,
commands: [
{ name: "conflict markers", args: ["check:no-conflict-markers"] },
{ name: "environment variable count ratchet", args: ["check:env-var-count"] },
{ name: "max-lines suppression ratchet", args: ["check:max-lines-ratchet"] },
{ name: "changelog attributions", args: ["check:changelog-attributions"] },
{ name: "database-first legacy-store guard", args: ["check:database-first-legacy-stores"] },
@@ -108,7 +109,7 @@ export async function main(argv = process.argv.slice(2)) {
{ name: "host env policy", args: ["check:host-env-policy:swift"] },
{ name: "opengrep rule metadata", args: ["check:opengrep-rule-metadata"] },
{ name: "duplicate scan target coverage", args: ["dup:check:coverage"] },
{ name: "npm shrinkwrap guard", args: ["deps:shrinkwrap:check"] },
{ name: "npm package-lock guard", args: ["deps:npm-lock:check"] },
{ name: "package patch guard", args: ["deps:patches:check"] },
{ name: "script declaration contracts", args: ["check:script-declarations"] },
],
+17 -10
View File
@@ -44,35 +44,37 @@ const MACOS_NATIVE_RE =
const MACOS_SCRIPT_SCOPE_RE =
/^(?:scripts\/(?:check-swift-tools|codesign-mac-app|create-dmg|format-swift|install-swift-tools|install-xcodegen|lint-swift|notarize-mac-artifact|package-mac-app|package-mac-dist)\.sh|scripts\/lib\/(?:plistbuddy|swift-toolchain)\.sh|test\/scripts\/(?:codesign-mac-app|create-dmg|notarize-mac-artifact|package-mac-app|package-mac-dist)\.test\.ts)$/;
const IOS_BUILD_RE =
/^(apps\/ios\/|apps\/shared\/|apps\/swabble\/|Swabble\/|scripts\/(?:check-swift-tools|format-swift|install-swift-tools|install-xcodegen|lint-swift)\.sh$|scripts\/(?:ios-(?:configure-signing|team-id|write-version-xcconfig)\.sh|ios-write-swift-filelist\.mjs|ios-version\.ts)$|scripts\/lib\/(?:ios-version\.ts|npm-publish-plan\.mjs|version-script-args\.ts)$)/;
/^(apps\/ios\/|apps\/shared\/|apps\/swabble\/|Swabble\/|scripts\/(?:check-swift-tools|format-swift|install-swift-tools|install-xcodegen|lint-swift)\.sh$|scripts\/(?:ios-(?:configure-signing|team-id|write-version-xcconfig)\.sh|ios-write-swift-filelist\.mjs|ios-version\.ts)$|scripts\/lib\/(?:ios-version\.ts|release-version\.mjs|version-script-args\.ts)$)/;
const ANDROID_NATIVE_RE = /^(apps\/android\/|apps\/shared\/)/;
const NODE_SCOPE_RE =
/^(src\/|test\/|extensions\/|packages\/|scripts\/|ui\/|\.github\/|openclaw\.mjs$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsconfig.*\.json$|vitest.*\.ts$|tsdown\.config\.ts$|\.oxlintrc\.json$|\.oxfmtrc\.jsonc$)/;
const WINDOWS_SQLITE_SCOPE_RE = /^src\/(?:state\/|.*sqlite.*\.ts$)/;
const WINDOWS_SCOPE_RE =
/^(src\/config\/sessions\/(?:session-accessor\.sqlite-archive|store\.session-lifecycle-mutation\.test)\.ts$|src\/process\/|src\/infra\/windows-install-roots\.ts$|src\/shared\/(?:import-specifier|runtime-import)(?:\.test)?\.ts$|scripts\/(?:install\.ps1|openclaw-cross-os-release-checks\.ts|github\/run-openclaw-cross-os-release-checks\.sh|(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.(?:mjs|js)|lib\/(?:format-generated-module\.mjs|cross-os-release-checks\/[^/]+\.ts))$|test\/scripts\/(?:format-generated-module|install-ps1|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|\.github\/workflows\/(?:ci|openclaw-cross-os-release-checks-reusable)\.yml$|\.github\/actions\/setup-node-env\/action\.yml$|\.github\/actions\/setup-pnpm-store-cache\/action\.yml$)/;
/^(extensions\/mxc\/|src\/agents\/(?:bash-tools\.exec-script-(?:preflight|target)|bash-tools\.exec\.script-preflight\.test)\.ts$|src\/config\/sessions\/(?:session-accessor\.sqlite-archive|store\.session-lifecycle-mutation\.test)\.ts$|src\/process\/|src\/infra\/(?:(?:exec-allowlist-pattern|fs-safe-remove)(?:\.test)?|ssh-client(?:\.windows\.test)?|update-managed-service-handoff(?:\.test)?|windows-install-roots)\.ts$|src\/shared\/(?:import-specifier|runtime-import)(?:\.test)?\.ts$|src\/test-utils\/openclaw-test-state(?:\.test)?\.ts$|scripts\/(?:install\.ps1|openclaw-cross-os-release-checks\.ts|github\/run-openclaw-cross-os-release-checks\.sh|(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.(?:mjs|js)|lib\/(?:format-generated-module\.mjs|cross-os-release-checks\/[^/]+\.ts))$|test\/scripts\/(?:format-generated-module|install-ps1|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|\.github\/workflows\/(?:ci|openclaw-cross-os-release-checks-reusable)\.yml$|\.github\/actions\/setup-node-env\/action\.yml$|\.github\/actions\/setup-pnpm-store-cache\/action\.yml$)/;
const WINDOWS_TEST_SCOPE_RE =
/^(src\/config\/sessions\/store\.session-lifecycle-mutation\.test\.ts$|src\/process\/(?:exec\.windows|windows-command)\.test\.ts$|src\/infra\/windows-install-roots\.test\.ts$|src\/shared\/runtime-import\.test\.ts$|test\/scripts\/(?:format-generated-module|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$)/;
/^(extensions\/mxc\/test\/(?:mxc-backend|sandbox-policy-loader)\.test\.ts$|src\/agents\/bash-tools\.exec\.script-preflight\.test\.ts$|src\/config\/sessions\/store\.session-lifecycle-mutation\.test\.ts$|src\/process\/(?:exec\.windows|windows-command)\.test\.ts$|src\/infra\/(?:exec-allowlist-pattern|fs-safe-remove|ssh-client\.windows|update-managed-service-handoff|windows-install-roots)\.test\.ts$|src\/shared\/runtime-import\.test\.ts$|src\/state\/openclaw-database-paths\.windows\.test\.ts$|src\/test-utils\/openclaw-test-state\.test\.ts$|test\/scripts\/(?:format-generated-module|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$)/;
const WINDOWS_DAEMON_SCOPE_RE =
/^src\/daemon\/(?:schtasks(?:[-.][^/]+)?|runtime-hints\.windows-paths(?:\.test)?|test-helpers\/schtasks-(?:base-mocks|fixtures))\.ts$/;
const CONTROL_UI_I18N_SCOPE_RE =
/^(ui\/src\/i18n\/|scripts\/(?:control-ui-i18n(?:-verify)?\.ts|lib\/control-ui-i18n-(?:config|raw-copy)\.ts)$|\.github\/workflows\/control-ui-locale-refresh\.yml$)/;
const CONTROL_UI_RAW_COPY_SOURCE_RE = /^ui\/src\/(?:app|components|lib|pages)\/.*\.tsx?$/;
const CONTROL_UI_HARD_GENERATED_I18N_RE =
/^(?:ui\/src\/i18n\/locales\/(?!en(?:-agents)?\.ts$)[^/]+\.ts|ui\/src\/i18n\/\.i18n\/(?:catalog-fallbacks\.json|[^/]+\.(?:meta\.json|tm\.jsonl)))$/;
const RELEASE_BRANCH_RE = /^release\/\d{4}\.\d+\.\d+$/;
export class ControlUiGeneratedArtifactsMixedError extends Error {}
export class NativeGeneratedArtifactsMixedError extends Error {}
const CONTROL_UI_TEST_SCOPE_RE =
/^(ui\/|test\/vitest\/vitest\.shared\.config\.ts$|scripts\/ensure-playwright-chromium\.mjs$)/;
const CHROMIUM_UI_TEST_SCOPE_RE =
/^(ui\/|extensions\/browser\/chrome-extension\/|test\/vitest\/vitest\.(?:shared|ui-e2e)\.config\.ts$|scripts\/ensure-playwright-chromium\.mjs$|package\.json$|\.github\/workflows\/ci\.yml$)/;
const NATIVE_I18N_SCOPE_RE =
/^(?:apps\/\.i18n\/|apps\/android\/app\/src\/main\/|apps\/ios\/|apps\/macos\/Sources\/|apps\/shared\/OpenClawKit\/Sources\/|scripts\/(?:android-app-i18n|apple-app-i18n|native-app-i18n)\.ts$|test\/scripts\/(?:android-app-i18n|apple-app-i18n|native-app-i18n)\.test\.ts$|\.github\/workflows\/(?:ci|native-app-locale-refresh)\.yml$)/;
/^(?:apps\/\.i18n\/|apps\/android\/(?:app\/src\/(?:main|play|thirdParty)\/|wear\/src\/main\/)|apps\/ios\/|apps\/macos\/Sources\/|apps\/shared\/OpenClawKit\/Sources\/|scripts\/(?:android-app-i18n|apple-app-i18n|native-app-i18n)\.ts$|test\/scripts\/(?:android-app-i18n|apple-app-i18n|native-app-i18n)\.test\.ts$|\.github\/workflows\/(?:ci|native-app-locale-refresh)\.yml$)/;
// Android base resources are co-owned: source PRs edit their English content,
// while the generator rewrites managed sections. Treat them as generated only
// alongside a hard-generated artifact so neither ownership path blocks the other.
const NATIVE_COOWNED_GENERATED_I18N_RE =
/^apps\/android\/app\/src\/main\/res\/values\/(?:assistant|strings)\.xml$/;
const NATIVE_HARD_GENERATED_I18N_RE =
/^(?:apps\/\.i18n\/native\/[^/]+\.json|apps\/\.i18n\/apple-translation-contradictions\.json|apps\/android\/app\/src\/main\/java\/ai\/openclaw\/app\/i18n\/NativeStringResources\.kt|apps\/android\/app\/src\/main\/res\/values-[^/]+\/(?:assistant|strings)\.xml|apps\/ios\/Resources\/Localizable\.xcstrings|apps\/ios\/(?:Sources|WatchApp|ShareExtension|ActivityWidget)\/[^/]+\.lproj\/InfoPlist\.strings)$/;
/^(?:apps\/\.i18n\/native\/[^/]+\.json|apps\/\.i18n\/apple-translation-contradictions\.json|apps\/android\/app\/src\/main\/java\/ai\/openclaw\/app\/i18n\/NativeStringResources\.kt|apps\/android\/app\/src\/main\/res\/values-[^/]+\/(?:assistant|strings)\.xml|apps\/android\/app\/src\/thirdParty\/res\/values-[^/]+\/accessibility_strings\.xml|apps\/android\/wear\/src\/main\/res\/values-[^/]+\/strings\.xml|apps\/ios\/Resources\/Localizable\.xcstrings|apps\/macos\/Sources\/OpenClaw\/Resources\/Localizable\.xcstrings|apps\/ios\/(?:Sources|WatchApp|ShareExtension|ActivityWidget)\/[^/]+\.lproj\/InfoPlist\.strings)$/;
const FAST_INSTALL_SMOKE_SCOPE_RE =
/^(Dockerfile$|\.npmrc$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|scripts\/ci-changed-scope\.mjs$|scripts\/postinstall-bundled-plugins\.mjs$|scripts\/e2e\/(?:Dockerfile(?:\.qr-import)?|agents-delete-shared-workspace-docker\.sh|gateway-network-docker\.sh)$|extensions\/[^/]+\/(?:package\.json|openclaw\.plugin\.json)$|\.github\/workflows\/install-smoke\.yml$|\.github\/actions\/setup-node-env\/action\.yml$)/;
const FULL_INSTALL_SMOKE_SCOPE_RE =
@@ -154,7 +156,9 @@ export function detectChangedScope(changedPaths) {
}
if (
(WINDOWS_SCOPE_RE.test(path) || WINDOWS_DAEMON_SCOPE_RE.test(path)) &&
(WINDOWS_SCOPE_RE.test(path) ||
WINDOWS_SQLITE_SCOPE_RE.test(path) ||
WINDOWS_DAEMON_SCOPE_RE.test(path)) &&
(!facts.isTestOnly || WINDOWS_TEST_SCOPE_RE.test(path) || WINDOWS_DAEMON_SCOPE_RE.test(path))
) {
runWindows = true;
@@ -164,11 +168,14 @@ export function detectChangedScope(changedPaths) {
runChangedSmoke = true;
}
if (CONTROL_UI_I18N_SCOPE_RE.test(path)) {
if (
CONTROL_UI_I18N_SCOPE_RE.test(path) ||
(CONTROL_UI_RAW_COPY_SOURCE_RE.test(path) && !facts.isTestOnly)
) {
runControlUiI18n = true;
}
if (CONTROL_UI_TEST_SCOPE_RE.test(path)) {
if (CHROMIUM_UI_TEST_SCOPE_RE.test(path)) {
runUiTests = true;
}
+1 -1
View File
@@ -101,7 +101,7 @@ function isPnpmStoreWarmupGatedJobName(name) {
name === "build-artifacts" ||
name === "check-docs" ||
name === "check-guards" ||
name === "check-shrinkwrap" ||
name === "check-npm-lock" ||
name === "check-prod-types" ||
name === "check-lint" ||
name === "check-dependencies" ||
+2 -1
View File
@@ -16,7 +16,7 @@
"atalovesyou",
"0xJonHoldsCrypto",
"hougangdev",
"jiulingyun"
"mf-yang"
],
"seedCommit": "d6863f87",
"displayName": {
@@ -33,6 +33,7 @@
"emailToLogin": {
"123guan@gmail.com": "guanbear",
"guanbear@macmini.bearhome": "guanbear",
"shawn.duggan@gmail.com": "shawnduggan",
"steipete@gmail.com": "steipete",
"sbarrios93@gmail.com": "sebslight",
"rltorres26+github@gmail.com": "RandyVentures",
File diff suppressed because it is too large Load Diff
+66 -41
View File
@@ -2,9 +2,73 @@ function historyMessage(role: "assistant" | "user", text: string, timestamp: num
return { content: [{ type: "text", text }], role, timestamp };
}
function finishedTask(n: number, now: number) {
const task = {
id: `task-mock-finished-${n}`,
taskId: `task-mock-finished-${n}`,
status: n === 3 ? "failed" : "completed",
runtime: "subagent",
agentId: "openclaw-mock",
title: `Finished mock task number ${n} with a fairly long title`,
createdAt: now - n * 600_000,
startedAt: now - n * 600_000,
endedAt: now - n * 500_000,
updatedAt: now - n * 500_000,
};
return n === 3
? { ...task, error: "Mock task stopped after finding an invalid event scope." }
: { ...task, terminalSummary: `Mock task ${n} completed its assigned inspection.` };
}
function taskDetailCase(task: { id: string; title: string } & Record<string, unknown>) {
return {
match: { taskId: task.id },
response: {
task: {
...task,
prompt: `Inspect ${task.title.toLowerCase()} and report the current execution path.`,
},
},
};
}
export function buildBackgroundTasksMock(baseTime: number) {
const now = Date.now();
const taskSessionKey = "agent:openclaw-mock:subagent:mock-task-1";
const tasks = [
{
id: "task-mock-running",
taskId: "task-mock-running",
status: "running",
runtime: "subagent",
agentId: "openclaw-mock",
title: "Map run-status indicator code",
createdAt: now - 25_000,
startedAt: now - 25_000,
updatedAt: now,
toolUseCount: 7,
lastToolName: "read",
progressSummary: "Tracing task events through the background task rail",
childSessionKey: taskSessionKey,
},
{
id: "task-mock-running-2",
taskId: "task-mock-running-2",
status: "running",
runtime: "subagent",
agentId: "openclaw-mock",
title: "Audit gateway event scope guards",
createdAt: now - 95_000,
startedAt: now - 95_000,
updatedAt: now - 1_000,
progressSummary: "Comparing agent-scoped task event paths",
},
finishedTask(1, now),
finishedTask(2, now),
finishedTask(3, now),
finishedTask(4, now),
finishedTask(5, now),
];
return {
"chat.history": {
cases: [
@@ -30,46 +94,7 @@ export function buildBackgroundTasksMock(baseTime: number) {
],
},
// One live subagent task exercises the rail, collapsed badge, and running-task status row.
"tasks.list": {
tasks: [
{
id: "task-mock-running",
taskId: "task-mock-running",
status: "running",
runtime: "subagent",
agentId: "openclaw-mock",
title: "Map run-status indicator code",
createdAt: now - 25_000,
startedAt: now - 25_000,
updatedAt: now,
toolUseCount: 7,
lastToolName: "read",
childSessionKey: taskSessionKey,
},
{
id: "task-mock-running-2",
taskId: "task-mock-running-2",
status: "running",
runtime: "subagent",
agentId: "openclaw-mock",
title: "Audit gateway event scope guards",
createdAt: now - 95_000,
startedAt: now - 95_000,
updatedAt: now - 1_000,
},
...[1, 2, 3, 4, 5].map((n) => ({
id: `task-mock-finished-${n}`,
taskId: `task-mock-finished-${n}`,
status: n === 3 ? "failed" : "completed",
runtime: "subagent",
agentId: "openclaw-mock",
title: `Finished mock task number ${n} with a fairly long title`,
createdAt: now - n * 600_000,
startedAt: now - n * 600_000,
endedAt: now - n * 500_000,
updatedAt: now - n * 500_000,
})),
],
},
"tasks.list": { tasks },
"tasks.get": { cases: tasks.map(taskDetailCase) },
};
}
+53
View File
@@ -76,6 +76,59 @@ export function buildChannelsStatusMock(baseTime: number) {
};
}
export function buildChannelsPairingMock(baseTime: number) {
const iso = (offsetMs: number) => new Date(baseTime + offsetMs).toISOString();
return {
accounts: [
{
channel: "telegram",
channelLabel: "Telegram",
accountId: "default",
accountLabel: "Telegram Bot",
notifySupported: true,
},
{
channel: "whatsapp",
channelLabel: "WhatsApp",
accountId: "default",
accountLabel: "WhatsApp Web",
notifySupported: false,
},
],
requests: [
{
requestId: "pairing-req-1",
channel: "telegram",
channelLabel: "Telegram",
accountId: "default",
accountLabel: "Telegram Bot",
senderId: "552731142",
senderLabel: "Mira Delgado (@miradelgado)",
metadata: { username: "miradelgado", firstName: "Mira", lastName: "Delgado" },
createdAt: iso(-14 * 60_000),
lastSeenAt: iso(-2 * 60_000),
expiresAt: iso(46 * 60_000),
notifySupported: true,
},
{
requestId: "pairing-req-2",
channel: "whatsapp",
channelLabel: "WhatsApp",
accountId: "default",
accountLabel: "WhatsApp Web",
senderId: "+1 555 0192",
senderLabel: "Unknown sender",
createdAt: iso(-3 * 60_000),
lastSeenAt: iso(-60_000),
expiresAt: iso(57 * 60_000),
notifySupported: false,
},
],
commandOwnerConfigured: false,
limits: { pendingPerAccount: 3, ttlMs: 3_600_000 },
};
}
export function buildChannelWizardMocks() {
const channelSelectStep = {
id: "mock-wizard-step-channel",
+261
View File
@@ -0,0 +1,261 @@
import type {
CronJob,
CronJobsListResult,
CronRunLogEntry,
CronRunsResult,
CronStatus,
} from "../ui/src/api/types.ts";
function listResult(
jobs: CronJob[],
options: { total?: number; limit?: number; offset?: number } = {},
): CronJobsListResult {
const total = options.total ?? jobs.length;
const limit = options.limit ?? 50;
const offset = options.offset ?? 0;
const nextOffset = offset + jobs.length;
const hasMore = nextOffset < total;
return {
jobs,
total,
offset,
limit,
hasMore,
nextOffset: hasMore ? nextOffset : null,
};
}
function runsResult(entries: CronRunLogEntry[]): CronRunsResult {
return {
entries,
total: entries.length,
offset: 0,
limit: 50,
hasMore: false,
nextOffset: null,
};
}
function singleJobListCases(jobs: CronJob[], match: Record<string, unknown>) {
return jobs.map((job, offset) => ({
match: { ...match, offset },
response: listResult([job], { total: jobs.length, limit: 1, offset }),
}));
}
export function buildCronMocks(baseTime: number) {
const minute = 60_000;
const hour = 60 * minute;
const day = 24 * hour;
const failedJob: CronJob = {
id: "mock-cron-calendar-sync",
agentId: "main",
name: "Sync team calendar",
description: "Refresh the shared calendar cache for the morning briefing.",
enabled: true,
createdAtMs: baseTime - 30 * day,
updatedAtMs: baseTime - 4 * minute,
schedule: { kind: "cron", expr: "0 */6 * * *", tz: "America/New_York" },
sessionTarget: "isolated",
wakeMode: "now",
payload: {
kind: "agentTurn",
message: "Sync the team calendar and summarize schedule conflicts.",
},
delivery: { mode: "announce", channel: "telegram", to: "@operations" },
state: {
nextRunAtMs: baseTime + 5 * hour,
lastRunAtMs: baseTime - 5 * minute,
lastRunStatus: "error",
lastError:
"OAuth refresh failed: invalid_grant. The provider rejected the stored refresh token because it was revoked or expired. Reconnect Google Calendar before the next scheduled sync.",
lastDurationMs: 8_420,
consecutiveErrors: 2,
lastDeliveryStatus: "not-requested",
},
};
const overdueJob: CronJob = {
id: "mock-cron-inbox-triage",
agentId: "main",
name: "Triage support inbox",
description: "Classify new support mail and prepare the daily response queue.",
enabled: true,
createdAtMs: baseTime - 18 * day,
updatedAtMs: baseTime - 35 * minute,
schedule: { kind: "every", everyMs: 15 * minute, anchorMs: baseTime - 18 * day },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "Triage the support inbox." },
delivery: { mode: "none" },
state: {
nextRunAtMs: baseTime - 20 * minute,
lastRunAtMs: baseTime - 35 * minute,
lastRunStatus: "ok",
lastDurationMs: 24_180,
lastDeliveryStatus: "not-requested",
},
};
const healthyJob: CronJob = {
id: "mock-cron-release-digest",
agentId: "main",
name: "Publish release digest",
description: "Summarize merged changes for the engineering channel.",
enabled: true,
createdAtMs: baseTime - 9 * day,
updatedAtMs: baseTime - 30 * minute,
schedule: { kind: "cron", expr: "30 9 * * 1-5", tz: "America/New_York" },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "Draft and publish the release digest." },
delivery: { mode: "announce", channel: "slack", to: "#engineering" },
state: {
nextRunAtMs: baseTime + 30 * minute,
lastRunAtMs: baseTime - 30 * minute,
lastRunStatus: "ok",
lastDurationMs: 51_230,
lastDelivered: true,
lastDeliveryStatus: "delivered",
},
};
const jobs = [overdueJob, healthyJob, failedJob];
const failedRun: CronRunLogEntry = {
ts: baseTime - 5 * minute,
runAtMs: baseTime - 5 * minute,
jobId: failedJob.id,
jobName: failedJob.name,
action: "finished",
status: "error",
durationMs: failedJob.state?.lastDurationMs,
error: failedJob.state?.lastError,
deliveryStatus: "not-requested",
model: "gpt-5.6-sol",
provider: "openai",
};
const runs: CronRunLogEntry[] = [
failedRun,
{
ts: baseTime - 30 * minute,
runAtMs: baseTime - 30 * minute,
jobId: healthyJob.id,
jobName: healthyJob.name,
action: "finished",
status: "ok",
durationMs: healthyJob.state?.lastDurationMs,
summary: "Published a digest covering 14 merged changes.",
delivered: true,
deliveryStatus: "delivered",
model: "claude-sonnet-4-6",
provider: "anthropic",
},
{
ts: baseTime - 35 * minute,
runAtMs: baseTime - 35 * minute,
jobId: overdueJob.id,
jobName: overdueJob.name,
action: "finished",
status: "ok",
durationMs: overdueJob.state?.lastDurationMs,
summary: "Classified 23 messages and prepared 6 replies.",
deliveryStatus: "not-requested",
model: "gpt-5.6-sol",
provider: "openai",
},
];
const queuedRuns: Array<{ runId: string; entry: CronRunLogEntry }> = jobs.map((job, index) => ({
runId: `mock-cron-manual-${job.id}`,
entry: {
ts: baseTime + index,
runAtMs: baseTime + index,
jobId: job.id,
jobName: job.name,
action: "finished",
status: "ok",
durationMs: 42_000 + index * 2_500,
summary: `Completed an on-demand run for ${job.name}.`,
deliveryStatus: "not-requested",
model: "gpt-5.6-sol",
provider: "openai",
},
}));
const status: CronStatus = {
enabled: true,
jobs: jobs.length,
nextWakeAtMs: overdueJob.state?.nextRunAtMs,
};
const runByJobId = new Map(runs.map((entry) => [entry.jobId, entry]));
const sortedJobLists = [
{ match: { sortBy: "nextRunAtMs", sortDir: "asc" }, jobs },
{
match: { sortBy: "nextRunAtMs", sortDir: "desc" },
jobs: [failedJob, healthyJob, overdueJob],
},
{ match: { sortBy: "updatedAtMs", sortDir: "asc" }, jobs },
{
match: { sortBy: "updatedAtMs", sortDir: "desc" },
jobs: [failedJob, healthyJob, overdueJob],
},
{ match: { sortBy: "name", sortDir: "asc" }, jobs: [healthyJob, failedJob, overdueJob] },
{ match: { sortBy: "name", sortDir: "desc" }, jobs: [overdueJob, failedJob, healthyJob] },
];
return {
"cron.status": status,
"cron.list": {
// Cases mirror the concrete queries today's Cron UI issues. Unknown combinations fall back
// to the full fixture list; dynamic evaluation is intentionally out of scope because the
// scenario is JSON-serialized into the page rather than installed as a live responder.
cases: [
{
match: { enabled: "enabled", lastRunStatus: "error" },
response: listResult([failedJob], { limit: 1 }),
},
{ match: { enabled: "disabled" }, response: listResult([]) },
...singleJobListCases(jobs, {
enabled: "enabled",
sortBy: "nextRunAtMs",
sortDir: "asc",
limit: 1,
}),
...singleJobListCases(jobs, { includeDisabled: true, limit: 1 }),
...sortedJobLists.map((entry) => ({
match: entry.match,
response: listResult(entry.jobs),
})),
{ response: listResult(jobs) },
],
},
"cron.runs": {
cases: [
...queuedRuns.map((run) => ({
match: { runId: run.runId },
response: runsResult([run.entry]),
})),
...jobs.flatMap((job) => {
const jobRun = runByJobId.get(job.id);
return [
{
match: { scope: "job", id: job.id, statuses: ["error"] },
response: runsResult(jobRun?.status === "error" ? [jobRun] : []),
},
{
match: { scope: "job", id: job.id },
response: runsResult(jobRun ? [jobRun] : []),
},
];
}),
{ match: { statuses: ["error"] }, response: runsResult([failedRun]) },
{ response: runsResult(runs) },
],
},
// Writes acknowledge the UI action but intentionally keep the fixture snapshot immutable.
"cron.add": { id: "mock-cron-created" },
"cron.update": { ok: true },
"cron.remove": { ok: true },
"cron.run": {
cases: queuedRuns.map((run) => ({
match: { id: run.entry.jobId },
response: { ok: true, enqueued: true, runId: run.runId },
})),
},
};
}
File diff suppressed because it is too large Load Diff
+4
View File
@@ -7,6 +7,7 @@ import {
NON_PACKAGED_BUNDLED_PLUGIN_DIRS,
} from "./lib/bundled-plugin-build-entries.mjs";
import { shouldBuildBundledCluster } from "./lib/optional-bundled-clusters.mjs";
import { assertRealOutputRoot } from "./lib/output-root-guard.mjs";
import {
mergeGeneratedChannelConfigs,
readGeneratedBundledChannelConfigs,
@@ -246,6 +247,9 @@ export function copyBundledPluginMetadata(params = {}) {
if (!fs.existsSync(extensionsRoot)) {
return;
}
// Fail closed before any dist/extensions removal: a symlinked dist root
// would redirect recursive deletes into the link target.
assertRealOutputRoot(path.join(repoRoot, "dist"));
const buildablePluginDirs = new Set(
collectBundledPluginBuildEntries({ cwd: repoRoot, env }).map((entry) => entry.id),
+10 -11
View File
@@ -5,20 +5,17 @@
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { ensureDirectory, logVerboseCopy, resolveBuildCopyContext } from "./lib/copy-assets.ts";
import { assertRealOutputRoot } from "./lib/output-root-guard.mjs";
const context = resolveBuildCopyContext(import.meta.url);
const exportHtmlSrcDir = path.join(
context.projectRoot,
"src",
"auto-reply",
"reply",
"export-html",
);
const exportHtmlDistDir = path.join(context.projectRoot, "dist", "export-html");
function copyExportHtmlTemplates() {
export function copyExportHtmlTemplates(params: { projectRoot?: string } = {}) {
const projectRoot = params.projectRoot ?? context.projectRoot;
const exportHtmlSrcDir = path.join(projectRoot, "src", "auto-reply", "reply", "export-html");
const exportHtmlDistDir = path.join(projectRoot, "dist", "export-html");
assertRealOutputRoot(path.join(projectRoot, "dist"));
if (!fs.existsSync(exportHtmlSrcDir)) {
console.warn(`${context.prefix} Source directory not found:`, exportHtmlSrcDir);
return;
@@ -52,4 +49,6 @@ function copyExportHtmlTemplates() {
console.log(`${context.prefix} Copied ${copiedCount} export-html assets.`);
}
copyExportHtmlTemplates();
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
copyExportHtmlTemplates();
}
+27
View File
@@ -0,0 +1,27 @@
export type CrabboxWorkload =
| "ci-fast"
| "ci-proof"
| "desktop"
| "interactive"
| "release-proof"
| "untrusted"
| "windows";
export type CrabboxProviderReadiness = {
ready: boolean;
[key: string]: unknown;
};
export function normalizeCrabboxWorkload(value: unknown): CrabboxWorkload | "" | null;
export function crabboxProviderChain(options: {
workload: CrabboxWorkload | "";
configuredProvider: string;
target: string;
advertisedProviders: readonly string[];
}): string[];
export function selectReadyCrabboxProvider<T extends CrabboxProviderReadiness>(
chain: readonly string[],
readiness: ReadonlyMap<string, T>,
): { provider: string; readiness: T } | null;
+70
View File
@@ -0,0 +1,70 @@
const workloadAliases = new Map([
["check", "ci-fast"],
["ci", "ci-fast"],
["ci-fast", "ci-fast"],
["ci-proof", "ci-proof"],
["desktop", "desktop"],
["interactive", "interactive"],
["release", "release-proof"],
["release-proof", "release-proof"],
["untrusted", "untrusted"],
["windows", "windows"],
]);
export function normalizeCrabboxWorkload(value) {
const normalized = `${value ?? ""}`.trim().toLowerCase();
if (!normalized) {
return "";
}
return workloadAliases.get(normalized) ?? null;
}
export function crabboxProviderChain({
workload,
configuredProvider,
target,
advertisedProviders,
}) {
const providers = new Set(advertisedProviders);
const normalizedConfigured = `${configuredProvider ?? ""}`.trim();
const normalizedTarget = `${target ?? ""}`.trim().toLowerCase();
if (normalizedTarget === "macos") {
return available(["aws"], providers);
}
if (normalizedTarget === "windows" || workload === "windows") {
return available(["azure", "aws"], providers);
}
const cloudFallback = ["azure", "aws"];
switch (workload) {
case "ci-fast":
return available(["blacksmith-testbox", "daytona", ...cloudFallback], providers);
case "ci-proof":
case "release-proof":
return available(["blacksmith-testbox", "daytona", ...cloudFallback], providers);
case "interactive":
return available(["daytona", ...cloudFallback], providers);
case "desktop":
return available(cloudFallback, providers);
case "untrusted":
// Daytona remains excluded until its brokered isolation profile has live proof.
return available(cloudFallback, providers);
default:
return available([normalizedConfigured], providers);
}
}
export function selectReadyCrabboxProvider(chain, readiness) {
for (const provider of chain) {
const status = readiness.get(provider);
if (status?.ready) {
return { provider, readiness: status };
}
}
return null;
}
function available(candidates, advertisedProviders) {
return candidates.filter((provider) => provider && advertisedProviders.has(provider));
}
+1 -1
View File
@@ -2,7 +2,7 @@
set -euo pipefail
node_version="24.15.0"
pnpm_spec="pnpm@11.2.2+sha512.36e6621fad506178936455e70247b8808ef4ec25797a9f437a93281a020484e2607f6a469a22e982987c3dbb8866e3071514ab10a4a1749e06edcd1ec118436f"
pnpm_spec="pnpm@11.15.1+sha512.81350b07e53c9538a02f1f2303b4290fa2d7be04e56e2a970c4cc4b417dc761de196edabd49d55c7dc9580db81007c44143e4e3d7e462b3000d23c255122d065"
if [[ $# -lt 2 ]]; then
echo "usage: $0 <expected-head-sha> <command> [args...]" >&2
+6 -3
View File
@@ -1,3 +1,6 @@
export function canonicalProviderName(provider: unknown): unknown;
export function parseProvidersFromHelp(text: unknown): unknown[];
export function isProviderAdvertised(provider: unknown, advertisedProviders: unknown): unknown;
export function canonicalProviderName(provider: string): string;
export function parseProvidersFromHelp(text: string): string[];
export function isProviderAdvertised(
provider: string,
advertisedProviders: readonly string[],
): boolean;
+417 -40
View File
@@ -21,6 +21,7 @@ import { homedir, tmpdir } from "node:os";
import { delimiter, dirname, extname, isAbsolute, relative, resolve } from "node:path";
import { StringDecoder } from "node:string_decoder";
import { fileURLToPath } from "node:url";
import { crabboxProviderChain, normalizeCrabboxWorkload } from "./crabbox-routing-policy.mjs";
import {
canonicalProviderName,
isProviderAdvertised,
@@ -54,11 +55,45 @@ const args = process.argv.slice(2);
if (args[0] === "--") {
args.shift();
}
const workloadOption = isWorkloadRoutedCommand(args)
? extractWrapperValueOption(args, "--workload")
: undefined;
const userArgStart = args[0] === "actions" && args[1] === "hydrate" ? 2 : 1;
if (args[userArgStart] === "--") {
args.splice(userArgStart, 1);
}
function extractWrapperValueOption(commandArgs, name) {
const equalsPrefix = `${name}=`;
for (let index = 0; index < commandArgs.length; index += 1) {
const arg = commandArgs[index];
if (arg === "--") {
break;
}
if (arg === name) {
const value = commandArgs[index + 1];
if (!value || value === "--" || value.startsWith("-")) {
commandArgs.splice(index, 1);
return null;
}
commandArgs.splice(index, 2);
return value;
}
if (arg.startsWith(equalsPrefix)) {
commandArgs.splice(index, 1);
return arg.slice(equalsPrefix.length) || null;
}
}
return undefined;
}
function isWorkloadRoutedCommand(commandArgs) {
return (
["run", "warmup"].includes(commandArgs[0]) ||
(commandArgs[0] === "actions" && commandArgs[1] === "hydrate")
);
}
function commandCandidates(command, platform) {
if (platform !== "win32") {
return [command];
@@ -232,6 +267,7 @@ const awsMacosPackageManagerScriptTargets = new Set([
"scripts/restart-mac.sh",
]);
const minimumBlacksmithCrabboxVersion = [0, 22, 0];
const minimumBrokeredDaytonaCrabboxVersion = [0, 40, 0];
const shellControlCommandPrefixes = new Set([
"if",
"while",
@@ -395,6 +431,21 @@ function checkedOutput(
};
}
function recoveryCommand(commandArgs) {
return [binary, ...commandArgs].map(recoveryCommandArgument).join(" ");
}
function recoveryCommandArgument(value) {
const text = `${value}`;
if (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(text)) {
return text;
}
if (process.platform === "win32") {
return `"${text.replaceAll('"', '""')}"`;
}
return `'${text.replaceAll("'", "'\\''")}'`;
}
// Probe Crabbox metadata (`--version` / `run --help`) with one generous retry.
// A cold Crabbox can be SIGKILLed by the snappy default timeout or emit nothing
// on the first call, then be instant and clean on the next. Retrying keeps the
@@ -479,6 +530,27 @@ function gitOutput(commandArgs) {
};
}
let resolvedCrabboxConfigCache;
function resolvedCrabboxConfig() {
if (resolvedCrabboxConfigCache !== undefined) {
return resolvedCrabboxConfigCache;
}
const result = checkedOutput(binary, ["config", "show", "--json"]);
if (result.status !== 0) {
resolvedCrabboxConfigCache = null;
return resolvedCrabboxConfigCache;
}
try {
const parsed = JSON.parse(result.stdout || result.text);
resolvedCrabboxConfigCache =
parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
} catch {
resolvedCrabboxConfigCache = null;
}
return resolvedCrabboxConfigCache;
}
function envProvider() {
const envProviderValue = process.env.CRABBOX_PROVIDER?.trim();
if (envProviderValue) {
@@ -488,6 +560,10 @@ function envProvider() {
}
function configProvider() {
const resolved = resolvedCrabboxConfig()?.provider;
if (typeof resolved === "string" && resolved.trim()) {
return resolved.trim();
}
try {
const config = readFileSync(resolve(repoRoot, ".crabbox.yaml"), "utf8");
const match = config.match(/^provider:\s*([^\s#]+)/m);
@@ -497,8 +573,24 @@ function configProvider() {
}
}
function configuredProvider() {
return envProvider() || configProvider();
function effectiveTargetContext(commandArgs) {
const config = resolvedCrabboxConfig();
const configuredTarget = typeof config?.target === "string" ? config.target.trim() : "";
const configuredWindowsMode =
typeof config?.windowsMode === "string" ? config.windowsMode.trim() : "";
return {
target: (
optionValue(commandArgs, "--target") ||
process.env.CRABBOX_TARGET?.trim() ||
process.env.CRABBOX_TARGET_OS?.trim() ||
configuredTarget
).toLowerCase(),
windowsMode: (
optionValue(commandArgs, "--windows-mode") ||
process.env.CRABBOX_WINDOWS_MODE?.trim() ||
configuredWindowsMode
).toLowerCase(),
};
}
const runValueOptions = new Set([
@@ -682,62 +774,305 @@ function commandProvider(commandArgsInput) {
return "";
}
function selectedProvider(commandArgs, advertisedProviders = []) {
function selectedProvider(commandArgs, advertisedProviders = [], versionText = "") {
const targetContext = effectiveTargetContext(commandArgs);
if (workloadOption === null) {
return {
provider: "",
source: "policy",
workload: "",
chain: [],
error: "--workload requires a value",
};
}
const workload = requestedWorkload(commandArgs);
if (workload === null) {
return {
provider: "",
source: "policy",
workload: workloadOption ?? process.env.OPENCLAW_CRABBOX_WORKLOAD ?? "",
chain: [],
error: `unsupported Crabbox workload ${JSON.stringify(workloadOption ?? process.env.OPENCLAW_CRABBOX_WORKLOAD)}`,
};
}
if (workload === "windows" && targetContext.target !== "windows") {
return {
provider: "",
source: "policy",
workload,
chain: [],
error: "Crabbox workload=windows requires target=windows",
};
}
const configured = canonicalProviderName(configProvider());
const chain = workload
? crabboxProviderChain({
workload,
configuredProvider: configured,
target: targetContext.target,
advertisedProviders: advertisedProviders.map(canonicalProviderName),
})
: [];
if (workload === "untrusted" && hasOption(commandArgs, "--id")) {
return {
provider: "",
source: "policy",
workload,
chain,
error:
"Crabbox workload=untrusted requires a fresh lease; --id reuse is forbidden without persisted workload provenance",
};
}
const explicitProvider = commandProvider(commandArgs);
if (explicitProvider) {
return explicitProvider;
const canonicalExplicitProvider = canonicalProviderName(explicitProvider);
if (workload && !chain.includes(canonicalExplicitProvider)) {
return {
provider: "",
source: "explicit",
workload,
chain,
error: `provider=${canonicalExplicitProvider} is not eligible for workload=${workload}; allowed=${chain.join(",") || "none"}`,
};
}
return { provider: explicitProvider, source: "explicit", workload, chain };
}
if (shouldPreferAzureForWindows(commandArgs, advertisedProviders)) {
return "azure";
const environmentProvider = envProvider();
if (environmentProvider) {
const canonicalEnvironmentProvider = canonicalProviderName(environmentProvider);
if (workload && !chain.includes(canonicalEnvironmentProvider)) {
return {
provider: "",
source: "environment",
workload,
chain,
error: `provider=${canonicalEnvironmentProvider} is not eligible for workload=${workload}; allowed=${chain.join(",") || "none"}`,
};
}
return { provider: environmentProvider, source: "environment", workload, chain };
}
return configuredProvider();
if (workload && hasOption(commandArgs, "--id")) {
return {
provider: "",
source: "policy",
workload,
chain: [],
error:
"reusing a workload-routed lease with --id requires --provider (or CRABBOX_PROVIDER) from the originating route",
};
}
if (!workload && shouldPreferAzureForWindows(commandArgs, advertisedProviders)) {
return { provider: "azure", source: "windows-default", workload: "", chain: [] };
}
if (!workload) {
return { provider: configured, source: "config", workload: "", chain: [] };
}
const readiness = new Map();
let selectedProviderName = "";
for (const candidate of chain) {
const status = crabboxProviderReadiness(candidate, versionText, targetContext);
readiness.set(candidate, status);
if (status.ready) {
selectedProviderName = candidate;
break;
}
}
if (!selectedProviderName) {
return {
provider: "",
source: "policy",
workload,
chain,
readiness,
error: `no ready provider for workload=${workload}`,
};
}
return {
provider: selectedProviderName,
source: "policy",
workload,
chain,
readiness,
};
}
function shouldRequireBrokeredAws(commandArgs, providerName) {
if (process.env.OPENCLAW_CRABBOX_ALLOW_DIRECT_AWS === "1") {
return false;
function requestedWorkload(commandArgs) {
if (!isWorkloadRoutedCommand(commandArgs)) {
return "";
}
const raw = workloadOption ?? process.env.OPENCLAW_CRABBOX_WORKLOAD?.trim() ?? "";
if (!raw) {
return "";
}
return normalizeCrabboxWorkload(raw);
}
let managedBrokerAuthConfiguredCache;
function crabboxProviderReadiness(providerName, versionText, targetContext) {
const canonicalProvider = canonicalProviderName(providerName);
if (canonicalProvider !== "aws") {
if (
canonicalProvider === "blacksmith-testbox" &&
!satisfiesMinimumCrabboxVersion(versionText, minimumBlacksmithCrabboxVersion)
) {
return {
ready: false,
reason: `requires Crabbox >= ${formatVersionTuple(minimumBlacksmithCrabboxVersion)} for Blacksmith Testbox`,
recovery: "update Crabbox, then retry",
};
}
if (
canonicalProvider === "daytona" &&
!satisfiesMinimumCrabboxVersion(versionText, minimumBrokeredDaytonaCrabboxVersion)
) {
return {
ready: false,
reason: `requires Crabbox >= ${formatVersionTuple(minimumBrokeredDaytonaCrabboxVersion)} for brokered Daytona`,
recovery: "update Crabbox, then retry",
};
}
if (["aws", "azure", "daytona"].includes(canonicalProvider) && !managedBrokerAuthConfigured()) {
return {
ready: false,
reason: "managed Crabbox broker auth unavailable",
recovery: `run \`${recoveryCommand(["login", "--url", "https://crabbox.openclaw.ai"])}\`, then retry`,
};
}
const doctorArgs = ["doctor", "--provider", canonicalProvider];
if (targetContext.target) {
doctorArgs.push("--target", targetContext.target);
}
if (targetContext.target === "windows" && targetContext.windowsMode) {
doctorArgs.push("--windows-mode", targetContext.windowsMode);
}
doctorArgs.push("--json");
const doctor = checkedOutput(binary, doctorArgs);
if (doctor.status !== 0) {
const diagnostic = compactDiagnosticText(doctor.text);
return {
ready: false,
reason: `doctor exited ${doctor.status}${diagnostic ? `: ${diagnostic}` : ""}`,
recovery: `run \`${recoveryCommand(doctorArgs)}\``,
};
}
return { ready: true, reason: "doctor-ready" };
}
function compactDiagnosticText(value, maxLength = 500) {
const compact = `${value ?? ""}`.replace(/\s+/gu, " ").trim();
if (compact.length <= maxLength) {
return compact;
}
return `${compact.slice(0, Math.max(0, maxLength - 3))}...`;
}
function formatProviderReadiness(readiness) {
return [...readiness.entries()]
.map(([candidate, status]) => `${candidate}:${status.ready ? "ready" : status.reason}`)
.join(",");
}
function providerRecoveryAdvice(readiness) {
return [
...new Set(
[...readiness.values()]
.map((status) => status.recovery)
.filter((recovery) => typeof recovery === "string" && recovery.length > 0),
),
];
}
function shouldRequireBrokeredCloud(commandArgs, providerName, explicitProviderRequested = false) {
const canonicalProvider = canonicalProviderName(providerName);
if (!["aws", "azure", "daytona"].includes(canonicalProvider)) {
// Blacksmith Testbox is provider-owned and does not use the managed
// coordinator auth required by brokered cloud capacity.
return false;
}
if (commandArgs[0] === "run" || commandArgs[0] === "warmup") {
// Route policy wins before explicit-provider and direct-debug exemptions.
if (requestedWorkload(commandArgs)) {
return true;
}
return commandArgs[0] === "actions" && commandArgs[1] === "hydrate";
if (explicitProviderRequested && directCloudOverrideEnabled(providerName)) {
return false;
}
return (
commandArgs[0] === "run" ||
commandArgs[0] === "warmup" ||
(commandArgs[0] === "actions" && commandArgs[1] === "hydrate")
);
}
function brokerAuthConfigured() {
const config = checkedOutput(binary, ["config", "show", "--json"]);
if (config.status !== 0) {
return false;
}
let parsed;
try {
parsed = JSON.parse(config.stdout || config.text);
} catch {
return false;
}
if (!parsed?.coordinator || parsed?.brokerAuth !== "configured") {
return false;
}
return checkedOutput(binary, ["whoami"]).status === 0;
function directCloudOverrideEnabled(providerName) {
return (
canonicalProviderName(providerName) !== "aws" &&
process.env.OPENCLAW_CRABBOX_ALLOW_DIRECT_CLOUD === "1"
);
}
function enforceBrokeredAws(commandArgs, providerName) {
if (!shouldRequireBrokeredAws(commandArgs, providerName) || brokerAuthConfigured()) {
function managedBrokerAuthConfigured() {
if (managedBrokerAuthConfiguredCache !== undefined) {
return managedBrokerAuthConfiguredCache;
}
const parsed = resolvedCrabboxConfig();
if (
!parsed?.coordinator ||
parsed?.brokerMode !== "managed" ||
parsed?.brokerAuth !== "configured"
) {
managedBrokerAuthConfiguredCache = false;
return managedBrokerAuthConfiguredCache;
}
managedBrokerAuthConfiguredCache = checkedOutput(binary, ["whoami"]).status === 0;
return managedBrokerAuthConfiguredCache;
}
function enforceBrokeredDaytonaVersion(
commandArgs,
providerName,
versionText,
explicitProviderRequested,
) {
if (
canonicalProviderName(providerName) !== "daytona" ||
!shouldRequireBrokeredCloud(commandArgs, providerName, explicitProviderRequested) ||
satisfiesMinimumCrabboxVersion(versionText, minimumBrokeredDaytonaCrabboxVersion)
) {
return;
}
console.error(
[
"[crabbox] provider=aws requires a configured Crabbox broker for OpenClaw proof.",
"[crabbox] run `crabbox login --url https://crabbox.openclaw.ai --provider aws`, then retry.",
"[crabbox] for intentional direct AWS provider debugging, set OPENCLAW_CRABBOX_ALLOW_DIRECT_AWS=1.",
`[crabbox] provider=daytona requires Crabbox >= ${formatVersionTuple(minimumBrokeredDaytonaCrabboxVersion)} for brokered execution.`,
`[crabbox] selected binary reported version=${versionText || "unknown"}.`,
"[crabbox] update Crabbox before brokered Daytona execution.",
"[crabbox] direct Daytona debugging requires an original `--provider daytona`, no `--workload`, and OPENCLAW_CRABBOX_ALLOW_DIRECT_CLOUD=1.",
].join("\n"),
);
process.exit(2);
}
function enforceBrokeredCloud(commandArgs, providerName, explicitProviderRequested) {
if (
!shouldRequireBrokeredCloud(commandArgs, providerName, explicitProviderRequested) ||
managedBrokerAuthConfigured()
) {
return;
}
const canonicalProvider = canonicalProviderName(providerName);
const instructions = [
`[crabbox] provider=${canonicalProvider} requires a configured managed Crabbox broker for OpenClaw proof.`,
`[crabbox] run \`${recoveryCommand(["login", "--url", "https://crabbox.openclaw.ai"])}\`, then retry.`,
];
if (canonicalProvider !== "aws") {
instructions.push(
`[crabbox] direct ${canonicalProvider} debugging requires an original \`--provider ${canonicalProvider}\`, no \`--workload\`, and OPENCLAW_CRABBOX_ALLOW_DIRECT_CLOUD=1.`,
);
}
console.error(instructions.join("\n"));
process.exit(2);
}
function optionValue(commandArgsInput, name) {
let commandArgs = commandArgsInput;
commandArgs = crabboxOptionArgs(commandArgs);
@@ -800,6 +1135,21 @@ function ensureAzureWindowsProvider(commandArgs, providerName, advertisedProvide
return normalizedArgs;
}
function ensurePolicyProvider(commandArgs, selection) {
if (
selection.source !== "policy" ||
!selection.provider ||
commandProvider(commandArgs) ||
envProvider()
) {
return commandArgs;
}
const normalizedArgs = [...commandArgs];
const optionEnd = commandOptionEnd(normalizedArgs);
normalizedArgs.splice(optionEnd, 0, "--provider", selection.provider);
return normalizedArgs;
}
function ensureAwsMacOnDemandMarket(commandArgs, providerName) {
if (
!["run", "warmup"].includes(commandArgs[0]) ||
@@ -3483,21 +3833,46 @@ const version = probeCrabboxMetadata(binary, ["--version"]);
const help = probeCrabboxMetadata(binary, ["run", "--help"]);
const providers = parseProvidersFromHelp(help.text);
const displayBinary = binary === "crabbox" ? "crabbox" : relative(repoRoot, binary);
const provider = selectedProvider(args, providers);
if (version.status !== 0 || help.status !== 0) {
console.error(
`[crabbox] bin=${displayBinary} version=${version.text || "unknown"} providers=${providers.join(",") || "unknown"}`,
);
console.error("[crabbox] selected binary failed basic --version/--help sanity checks");
process.exit(2);
}
const providerSelection = selectedProvider(args, providers, version.text);
if (providerSelection.error) {
console.error(`[crabbox] ${providerSelection.error}`);
if (providerSelection.readiness) {
console.error(
`[crabbox] provider readiness ${formatProviderReadiness(providerSelection.readiness)}`,
);
for (const recovery of providerRecoveryAdvice(providerSelection.readiness)) {
console.error(`[crabbox] recovery: ${recovery}`);
}
}
process.exit(2);
}
const provider = providerSelection.provider;
const canonicalProvider = canonicalProviderName(provider);
const commandProviderValue = commandProvider(args);
let normalizedArgs = ensureAwsMacOnDemandMarket(
ensureNativeWindowsHydrateJob(ensureAzureWindowsProvider(args, provider, providers)),
ensurePolicyProvider(
ensureNativeWindowsHydrateJob(ensureAzureWindowsProvider(args, provider, providers)),
providerSelection,
),
provider,
);
console.error(
`[crabbox] bin=${displayBinary} version=${version.text || "unknown"} provider=${provider || "unknown"} providers=${providers.join(",") || "unknown"}`,
);
if (version.status !== 0 || help.status !== 0) {
console.error("[crabbox] selected binary failed basic --version/--help sanity checks");
process.exit(2);
if (providerSelection.source === "policy") {
console.error(
`[crabbox] route workload=${providerSelection.workload} selected=${provider} chain=${providerSelection.chain.join(",")} readiness=${formatProviderReadiness(providerSelection.readiness)}`,
);
}
if (provider && !isProviderAdvertised(provider, providers)) {
@@ -3536,7 +3911,9 @@ if (canonicalProvider === "blacksmith-testbox") {
}
}
enforceBrokeredAws(normalizedArgs, provider);
const explicitProviderRequested = Boolean(commandProviderValue);
enforceBrokeredDaytonaVersion(normalizedArgs, provider, version.text, explicitProviderRequested);
enforceBrokeredCloud(normalizedArgs, provider, explicitProviderRequested);
if (canonicalProvider === "blacksmith-testbox") {
const envProviderLocal = process.env.CRABBOX_PROVIDER?.trim();
+397
View File
@@ -0,0 +1,397 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { basename, dirname, extname, resolve } from "node:path";
const DEFAULT_OUTPUT = ".artifacts/dated-todo-candidates.json";
const MAX_FILE_BYTES = 2 * 1024 * 1024;
const MAX_CANDIDATES = 5_000;
const TEXT_EXTENSIONS = new Set([
".bash",
".cjs",
".css",
".cts",
".go",
".html",
".java",
".js",
".json",
".jsx",
".kt",
".kts",
".md",
".mdx",
".mjs",
".mts",
".php",
".py",
".rb",
".rs",
".scss",
".sh",
".swift",
".toml",
".ts",
".tsx",
".xml",
".yaml",
".yml",
".zsh",
]);
const EXCLUDED_SEGMENTS = new Set([
".artifacts",
".generated",
".git",
".i18n",
".next",
"__fixtures__",
"build",
"coverage",
"dist",
"dist-runtime",
"fixtures",
"generated",
"i18n",
"locales",
"node_modules",
"test-fixtures",
"translations",
"vendor",
]);
const EXCLUDED_BASENAMES = new Set([
"bun.lock",
"bun.lockb",
"CHANGELOG.md",
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
]);
const TODO_PATTERN =
/\b(?:TODO|FIXME|HACK|removeAfter|remove\s+after|delete\s+after|until|deadline|expires?|expiry|expiration|deprecated|deprecation|window|re-?enable|temporary)\b/iu;
const DATE_PATTERN =
/(?:\b20\d{2}-\d{2}-\d{2}(?=\b|T)|\b(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\s+(?:\d{1,2}(?:st|nd|rd|th)?(?:,\s*|\s+))?20\d{2}\b)/iu;
const ISO_DATE_PREFILTER = String.raw`20[0-9]{2}-[0-9]{2}-[0-9]{2}`;
const MONTH_DATE_PREFILTER = String.raw`(Jan(uary)?|Feb(ruary)?|Mar(ch)?|Apr(il)?|May|Jun(e)?|Jul(y)?|Aug(ust)?|Sep(t(ember)?)?|Oct(ober)?|Nov(ember)?|Dec(ember)?)\s+([0-9]{1,2}(st|nd|rd|th)?(,\s*|\s+))?20[0-9]{2}`;
const GIT_MONTH_DATE_PREFILTER = MONTH_DATE_PREFILTER.replaceAll(String.raw`\s`, "[[:space:]]");
function parseArgs(argv) {
const options = {
root: process.cwd(),
output: DEFAULT_OUTPUT,
compatReport: undefined,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--help" || arg === "-h") {
process.stdout.write(
"Usage: node scripts/dated-todo-scan.mjs [--root <dir>] [--output <path>] [--compat-report <path>]\n",
);
process.exit(0);
}
const value = argv[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`Missing value for ${arg}`);
}
if (arg === "--root") {
options.root = value;
} else if (arg === "--output") {
options.output = value;
} else if (arg === "--compat-report") {
options.compatReport = value;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
index += 1;
}
options.root = resolve(options.root);
options.output = resolve(options.root, options.output);
if (options.compatReport) {
options.compatReport = resolve(options.root, options.compatReport);
}
return options;
}
function run(command, args, cwd, maxBuffer = 32 * 1024 * 1024) {
const result = spawnSync(command, args, {
cwd,
encoding: "utf8",
maxBuffer,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.error) {
throw new Error(`Failed to run ${command}: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(
`${command} ${args.join(" ")} failed (${result.status ?? "unknown"}): ${result.stderr.trim()}`,
);
}
return result.stdout;
}
function runGitGrep(root, paths) {
const result = spawnSync(
"git",
[
"grep",
"-l",
"-I",
"-i",
"-E",
"-e",
ISO_DATE_PREFILTER,
"-e",
GIT_MONTH_DATE_PREFILTER,
"--",
...paths,
],
{
cwd: root,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
},
);
if (result.error) {
throw new Error(`Failed to run git grep: ${result.error.message}`);
}
if (result.status !== 0 && result.status !== 1) {
throw new Error(`git grep failed (${result.status ?? "unknown"}): ${result.stderr.trim()}`);
}
return result.stdout;
}
function runDatePrefilter(root, paths) {
const result = spawnSync(
"rg",
[
"-l",
"-i",
"--hidden",
"--no-ignore",
"--no-messages",
"-e",
ISO_DATE_PREFILTER,
"-e",
MONTH_DATE_PREFILTER,
"--glob",
"!.git/**",
"--glob",
"!.i18n/**",
"--glob",
"!node_modules/**",
"--glob",
"!dist/**",
"--glob",
"!dist-runtime/**",
...[...EXCLUDED_SEGMENTS].flatMap((segment) => ["--glob", `!**/${segment}/**`]),
...paths,
],
{
cwd: root,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
},
);
if (result.error?.code === "ENOENT") {
return runGitGrep(root, paths);
}
if (result.error) {
throw new Error(`Failed to run rg: ${result.error.message}`);
}
if (result.status !== 0 && result.status !== 1) {
throw new Error(`rg failed (${result.status ?? "unknown"}): ${result.stderr.trim()}`);
}
return result.stdout;
}
function isScannablePath(file) {
const normalized = file.replaceAll("\\", "/");
const parts = normalized.split("/");
const name = basename(normalized);
return (
TEXT_EXTENSIONS.has(extname(name).toLowerCase()) &&
!EXCLUDED_BASENAMES.has(name) &&
!parts.some((part) => EXCLUDED_SEGMENTS.has(part)) &&
!/(?:^|[.-])generated(?:[.-]|$)/iu.test(name) &&
!/(?:^|[.-])api-baseline(?:[.-]|$)/iu.test(name)
);
}
function compactText(lines) {
return [...new Set(lines.map((line) => line.trim()).filter(Boolean))]
.join(" | ")
.replaceAll(/\s+/gu, " ")
.slice(0, 500);
}
function loadFiles(root, files) {
return files
.filter(Boolean)
.filter(isScannablePath)
.toSorted()
.flatMap((file) => {
const absolutePath = resolve(root, file);
if (statSync(absolutePath).size > MAX_FILE_BYTES) {
return [];
}
return [
{
file: file.replaceAll("\\", "/"),
lines: readFileSync(absolutePath, "utf8").split(/\r?\n/u),
},
];
});
}
function loadPrefilteredFiles(root) {
const tracked = new Set(
run("git", ["ls-files", "--cached", "-z"], root)
.split("\0")
.filter((file) => file && isScannablePath(file)),
);
const files = runDatePrefilter(root, ["."])
.split("\n")
.map((file) => file.replace(/^\.\//u, ""))
.filter((file) => tracked.has(file));
return loadFiles(root, files);
}
function loadCompatFiles(root) {
const output = run("git", ["ls-files", "--cached", "-z", "--", "src/plugins/compat"], root);
return loadFiles(root, output.split("\0"));
}
function collectScanCandidates(files) {
const candidates = [];
for (const { file, lines } of files) {
for (let index = 0; index < lines.length; index += 1) {
if (!TODO_PATTERN.test(lines[index] ?? "")) {
continue;
}
const nearbyDates = [];
for (
let nearby = Math.max(0, index - 1);
nearby <= Math.min(lines.length - 1, index + 1);
nearby += 1
) {
if (DATE_PATTERN.test(lines[nearby] ?? "")) {
nearbyDates.push(lines[nearby] ?? "");
}
}
if (nearbyDates.length === 0) {
continue;
}
candidates.push({
file,
line: index + 1,
text: compactText([lines[index] ?? "", ...nearbyDates]),
source: "scan",
});
}
}
return candidates;
}
function readCompatReport(options) {
if (options.compatReport) {
return JSON.parse(readFileSync(options.compatReport, "utf8"));
}
const reportScript = resolve(options.root, "scripts/plugin-boundary-report.ts");
const output = run(
process.execPath,
["--import", "tsx", reportScript, "--json"],
options.root,
16 * 1024 * 1024,
);
return JSON.parse(output);
}
function findCompatLocation(code, files) {
const escapedCode = code.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&");
const codeField = new RegExp(`\\bcode\\s*:\\s*["']${escapedCode}["']`, "u");
for (const { file, lines } of files) {
const index = lines.findIndex((line) => codeField.test(line));
if (index >= 0) {
return { file, line: index + 1 };
}
}
return { file: "src/plugins/compat/registry.ts", line: 1 };
}
function collectCompatCandidates(report, files) {
const records = report?.compat?.records;
if (!Array.isArray(records)) {
throw new Error("Plugin boundary report is missing compat.records");
}
const locationFiles = [...files].toSorted((left, right) => {
const leftCompat = left.file.startsWith("src/plugins/compat/") ? 0 : 1;
const rightCompat = right.file.startsWith("src/plugins/compat/") ? 0 : 1;
return leftCompat - rightCompat || left.file.localeCompare(right.file);
});
return records
.filter(
(record) =>
record?.status === "deprecated" &&
typeof record.code === "string" &&
typeof record.removeAfter === "string",
)
.map((record) => {
const location = findCompatLocation(record.code, locationFiles);
return {
file: location.file,
line: location.line,
text: compactText([
`${record.code}: removeAfter ${record.removeAfter}`,
typeof record.replacement === "string" ? `replacement ${record.replacement}` : "",
]),
source: "compat-registry",
};
});
}
function sortAndDeduplicate(candidates) {
const unique = new Map();
for (const candidate of candidates) {
unique.set(
`${candidate.source}\0${candidate.file}\0${candidate.line}\0${candidate.text}`,
candidate,
);
}
return [...unique.values()].toSorted(
(left, right) =>
left.file.localeCompare(right.file) ||
left.line - right.line ||
left.source.localeCompare(right.source) ||
left.text.localeCompare(right.text),
);
}
function main() {
const options = parseArgs(process.argv.slice(2));
const scanCandidates = collectScanCandidates(loadPrefilteredFiles(options.root));
const compatCandidates = collectCompatCandidates(
readCompatReport(options),
loadCompatFiles(options.root),
);
const candidates = sortAndDeduplicate([...scanCandidates, ...compatCandidates]);
if (candidates.length > MAX_CANDIDATES) {
throw new Error(
`Dated TODO prefilter produced ${candidates.length} candidates, above the ${MAX_CANDIDATES} safety cap`,
);
}
mkdirSync(dirname(options.output), { recursive: true });
writeFileSync(options.output, `${JSON.stringify(candidates, null, 2)}\n`);
process.stdout.write(
`dated-todo-scan: wrote ${candidates.length} candidates (${scanCandidates.length} scan, ${compatCandidates.length} compat-registry) to ${options.output}\n`,
);
}
try {
main();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`dated-todo-scan: ${message}\n`);
process.exitCode = 1;
}
+4 -10
View File
@@ -11,11 +11,8 @@ import {
} from "./pre-commit/pnpm-audit-prod.mjs";
const DEPENDENCY_FILE_PATTERNS = [
/^\.github\/release\/clawhub-cli\/package-lock\.json$/u,
/^package\.json$/u,
/^package-lock\.json$/u,
/\/package-lock\.json$/u,
/^npm-shrinkwrap\.json$/u,
/\/npm-shrinkwrap\.json$/u,
/^pnpm-lock\.yaml$/u,
/^pnpm-workspace\.yaml$/u,
/^patches\//u,
@@ -23,13 +20,10 @@ const DEPENDENCY_FILE_PATTERNS = [
];
const DEPENDENCY_DIFF_PATHS = [
".github/release/clawhub-cli/package-lock.json",
"package.json",
"package-lock.json",
"extensions/*/package-lock.json",
"npm-shrinkwrap.json",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"extensions/*/npm-shrinkwrap.json",
"*package.json",
"patches",
];
@@ -128,8 +122,8 @@ function renderMarkdownReport(report) {
"",
"It reports two related but different things:",
"",
"- Dependency file changes: package manifests, npm shrinkwrap/package-lock, pnpm workspace config, lockfile, and patches.",
"- Resolved package changes: package versions added, removed, or changed in pnpm-lock.yaml; inspect shrinkwrap/package-lock diffs directly.",
"- Dependency file changes: package manifests, pnpm workspace config, pnpm lockfile, the trusted ClawHub CLI package lock, and patches.",
"- Resolved package changes: package versions added, removed, or changed in pnpm-lock.yaml.",
"",
"## Summary",
"",
+1 -4
View File
@@ -1,5 +1,6 @@
// Gateway Smoke script supports OpenClaw repository automation.
import { fileURLToPath } from "node:url";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import {
MIN_CLIENT_PROTOCOL_VERSION,
PROTOCOL_VERSION,
@@ -88,10 +89,6 @@ function parseGatewaySmokeCli(
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function hasHealthSummaryPayload(response: unknown): boolean {
if (!isRecord(response) || !isRecord(response.payload)) {
return false;
+1 -4
View File
@@ -1,5 +1,6 @@
// Ios Node E2E script supports OpenClaw repository automation.
import { randomUUID } from "node:crypto";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import {
MIN_CLIENT_PROTOCOL_VERSION,
PROTOCOL_VERSION,
@@ -146,10 +147,6 @@ function parseWaitSeconds(raw: string | undefined): number {
return parsed;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function payloadShapeError(command: string, payload: unknown): string | null {
if (payload == null) {
return `${command} returned no payload`;
+272 -10
View File
@@ -15,6 +15,12 @@ const OPENAI_REALTIME_MODEL =
const OPENAI_REALTIME_VOICE = process.env.OPENCLAW_REALTIME_OPENAI_VOICE?.trim() || "alloy";
const DEFAULT_OPENAI_HTTP_TIMEOUT_MS = 30_000;
const OPENAI_HTTP_RESPONSE_MAX_BYTES = 256 * 1024;
const DEFAULT_OPENAI_AUDIO_CYCLES = 1;
const MAX_OPENAI_AUDIO_CYCLES = 10;
const OPENAI_AUDIO_CHUNK_BYTES = 960;
const OPENAI_AUDIO_CHUNK_DELAY_MS = 5;
const OPENAI_AUDIO_ROUNDTRIP_TIMEOUT_MS = 60_000;
const OPENAI_AUDIO_TRAILING_SILENCE_MS = 750;
const GOOGLE_REALTIME_MODEL =
process.env.OPENCLAW_REALTIME_GOOGLE_MODEL?.trim() || "gemini-3.1-flash-live-preview";
const GOOGLE_REALTIME_VOICE = process.env.OPENCLAW_REALTIME_GOOGLE_VOICE?.trim() || "Kore";
@@ -23,11 +29,13 @@ const GOOGLE_LIVE_WS_URL =
type RealtimeSmokeCliOptions = {
help: boolean;
openAIAudioCycles: number;
openAIOnly: boolean;
};
// Keep live stacks behind their owning smoke paths so help and safety helpers stay lightweight.
type Browser = import("playwright").Browser;
type RealtimeVoiceBridge = import("../../src/talk/provider-types.ts").RealtimeVoiceBridge;
type ViteDevServer = Awaited<ReturnType<(typeof import("vite"))["createServer"]>>;
type SmokeResult = {
@@ -66,8 +74,9 @@ function usage(): string {
"Usage: node --import tsx scripts/dev/realtime-talk-live-smoke.ts [options]",
"",
"Options:",
" --openai-only Run only the OpenAI backend and browser legs",
" -h, --help Show this help",
" --openai-only Run only the OpenAI legs",
" --openai-audio-cycles N Run 1-10 backend audio roundtrip cycles (default: 1)",
" -h, --help Show this help",
"",
"Environment:",
" OPENAI_API_KEY",
@@ -76,14 +85,36 @@ function usage(): string {
}
function parseRealtimeSmokeArgs(argv = process.argv.slice(2)): RealtimeSmokeCliOptions {
for (const arg of argv) {
let openAIAudioCycles = DEFAULT_OPENAI_AUDIO_CYCLES;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--help" || arg === "-h" || arg === "--openai-only") {
continue;
}
if (arg === "--openai-audio-cycles") {
const rawCycles = argv[index + 1];
if (!rawCycles) {
throw new CliArgumentError("--openai-audio-cycles requires a value");
}
openAIAudioCycles = parseStrictIntegerOption({
fallback: DEFAULT_OPENAI_AUDIO_CYCLES,
label: "--openai-audio-cycles",
min: 1,
raw: rawCycles,
});
if (openAIAudioCycles > MAX_OPENAI_AUDIO_CYCLES) {
throw new CliArgumentError(
`--openai-audio-cycles must be <= ${MAX_OPENAI_AUDIO_CYCLES}; got ${openAIAudioCycles}`,
);
}
index += 1;
continue;
}
throw new CliArgumentError(`Unknown argument: ${arg}`);
}
return {
help: argv.includes("--help") || argv.includes("-h"),
openAIAudioCycles,
openAIOnly: argv.includes("--openai-only"),
};
}
@@ -159,6 +190,57 @@ function compareStrings(left: string | undefined, right: string | undefined): nu
return (left ?? "").localeCompare(right ?? "");
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function appendBounded<T>(items: T[], item: T, maxItems: number): void {
if (items.length < maxItems) {
items.push(item);
}
}
function normalizeTranscript(text: string): string {
return text
.toLowerCase()
.replaceAll(/[^a-z0-9]+/gu, " ")
.trim();
}
function transcriptIncludesMarker(transcripts: string[], marker: string): boolean {
return normalizeTranscript(transcripts.join(" ")).includes(normalizeTranscript(marker));
}
function resolveGatewayRelayModulePath(repoRoot = process.cwd()): string {
return `/@fs/${repoRoot.replaceAll("\\", "/")}/ui/src/pages/chat/realtime-talk-gateway-relay.ts`;
}
async function sendPcmAudioInChunks(
bridge: RealtimeVoiceBridge,
audio: Buffer,
options: { chunkBytes?: number; delayMs?: number } = {},
): Promise<number> {
const chunkBytes = options.chunkBytes ?? OPENAI_AUDIO_CHUNK_BYTES;
const delayMs = options.delayMs ?? OPENAI_AUDIO_CHUNK_DELAY_MS;
if (!Number.isSafeInteger(chunkBytes) || chunkBytes < 1) {
throw new Error(`PCM audio chunk size must be a positive integer; got ${chunkBytes}`);
}
if (!Number.isFinite(delayMs) || delayMs < 0) {
throw new Error(`PCM audio chunk delay must be a non-negative number; got ${delayMs}`);
}
let chunks = 0;
for (let offset = 0; offset < audio.byteLength; offset += chunkBytes) {
bridge.sendAudio(audio.subarray(offset, Math.min(offset + chunkBytes, audio.byteLength)));
chunks += 1;
if (delayMs > 0) {
await delay(delayMs);
}
}
return chunks;
}
async function readOpenAIRealtimeBrowserResponseText(
response: Response,
label: string,
@@ -313,6 +395,180 @@ async function smokeOpenAIBackendBridge(apiKey: string): Promise<SmokeResult> {
}
}
async function smokeOpenAIAudioRoundtrip(apiKey: string, cycleCount: number): Promise<SmokeResult> {
const cycles: Array<Record<string, unknown>> = [];
try {
const [
{ buildOpenAIRealtimeVoiceProvider },
{ buildOpenAISpeechProvider },
{ REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ },
] = await Promise.all([
import("../../extensions/openai/realtime-voice-provider.ts"),
import("../../extensions/openai/speech-provider.ts"),
import("../../src/talk/provider-types.ts"),
]);
const speechProvider = buildOpenAISpeechProvider();
const synthesized = await speechProvider.synthesizeTelephony?.({
text: "Please reply with the single word glacier.",
cfg: { plugins: { enabled: true } } as never,
providerConfig: {
apiKey,
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini-tts",
voice: "alloy",
},
timeoutMs: 45_000,
});
if (!synthesized) {
throw new Error("OpenAI speech provider did not return telephony audio");
}
if (synthesized.outputFormat !== "pcm" || synthesized.sampleRate !== 24_000) {
throw new Error(
`OpenAI speech provider returned ${synthesized.outputFormat} at ${synthesized.sampleRate} Hz`,
);
}
const trailingSilenceBytes = Math.ceil(
(synthesized.sampleRate * 2 * OPENAI_AUDIO_TRAILING_SILENCE_MS) / 1_000,
);
const inputAudio = Buffer.concat([synthesized.audioBuffer, Buffer.alloc(trailingSilenceBytes)]);
for (let cycle = 1; cycle <= cycleCount; cycle += 1) {
const provider = buildOpenAIRealtimeVoiceProvider();
const events: string[] = [];
const finalUserTranscripts: string[] = [];
const finalAssistantTranscripts: string[] = [];
let outputAudioBytes = 0;
let lateAudioBytes = 0;
let responseDone = false;
let closed = false;
let resolveRoundtrip: ((error?: Error) => void) | undefined;
const roundtrip = new Promise<Error | undefined>((resolve) => {
resolveRoundtrip = resolve;
});
const maybeResolveRoundtrip = () => {
if (
responseDone &&
outputAudioBytes > 512 &&
transcriptIncludesMarker(finalUserTranscripts, "glacier") &&
transcriptIncludesMarker(finalAssistantTranscripts, "glacier")
) {
resolveRoundtrip?.();
}
};
const bridgeRef: { current?: RealtimeVoiceBridge } = {};
const bridge = provider.createBridge({
providerConfig: {
apiKey,
model: OPENAI_REALTIME_MODEL,
voice: OPENAI_REALTIME_VOICE,
vadThreshold: 0.1,
silenceDurationMs: 500,
prefixPaddingMs: 300,
},
audioFormat: REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ,
instructions:
"Follow the speaker's request exactly. Reply briefly and do not add commentary.",
onAudio: (audio) => {
if (closed) {
lateAudioBytes += audio.byteLength;
return;
}
outputAudioBytes += audio.byteLength;
maybeResolveRoundtrip();
},
onClearAudio: () => {},
onMark: (markName) => bridgeRef.current?.acknowledgeMark(markName),
onTranscript: (role, text, isFinal) => {
if (!isFinal) {
return;
}
appendBounded(
role === "user" ? finalUserTranscripts : finalAssistantTranscripts,
text,
8,
);
maybeResolveRoundtrip();
},
onEvent: (event) => {
appendBounded(events, `${event.direction}:${event.type}`, 80);
if (event.direction === "server" && event.type === "response.done") {
responseDone = true;
maybeResolveRoundtrip();
}
},
onError: (error) => resolveRoundtrip?.(error),
onClose: (reason) => {
if (!closed && reason === "error") {
resolveRoundtrip?.(new Error("OpenAI audio roundtrip bridge closed with an error"));
}
},
});
bridgeRef.current = bridge;
let chunksSent = 0;
try {
await bridge.connect();
const boundedRoundtrip = withTimeout({
label: `OpenAI audio roundtrip cycle ${cycle}`,
timeoutMs: OPENAI_AUDIO_ROUNDTRIP_TIMEOUT_MS,
run: () => roundtrip,
});
// Observe failures immediately; the same promise is awaited after input streaming completes.
void boundedRoundtrip.catch(() => undefined);
chunksSent = await sendPcmAudioInChunks(bridge, inputAudio);
const roundtripError = await boundedRoundtrip;
if (roundtripError) {
throw roundtripError;
}
} catch (error) {
resolveRoundtrip?.(error instanceof Error ? error : new Error(String(error)));
throw error;
} finally {
closed = true;
bridge.close();
bridge.close();
bridgeRef.current = undefined;
await delay(100);
}
if (lateAudioBytes > 0) {
throw new Error(
`OpenAI audio roundtrip cycle ${cycle} received ${lateAudioBytes} audio bytes after close`,
);
}
cycles.push({
cycle,
inputAudioBytes: inputAudio.byteLength,
chunksSent,
outputAudioBytes,
userTranscript: finalUserTranscripts.join(" "),
assistantTranscript: finalAssistantTranscripts.join(" "),
responseDone,
lateAudioBytes,
events,
});
}
return {
name: "openai-backend-audio-roundtrip",
ok: cycles.length === cycleCount,
details: {
model: OPENAI_REALTIME_MODEL,
cycles,
},
};
} catch (error) {
return {
name: "openai-backend-audio-roundtrip",
ok: false,
details: {
model: OPENAI_REALTIME_MODEL,
completedCycles: cycles.length,
error: shortError(error),
},
};
}
}
async function smokeOpenAIWebRtc(browser: Browser, apiKey: string): Promise<SmokeResult> {
try {
const openAIHttpTimeoutMs = resolveOpenAIHttpTimeoutMs();
@@ -647,10 +903,8 @@ async function smokeGatewayRelayBrowser(browser: Browser): Promise<SmokeResult>
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-realtime-talk-"));
try {
const { createServer } = await import("vite");
const repoRoot = process.cwd().replaceAll("\\", "/");
const relayModulePath = JSON.stringify(
`/@fs/${repoRoot}/ui/src/ui/chat/realtime-talk-gateway-relay.ts`,
);
const repoRoot = process.cwd();
const relayModulePath = JSON.stringify(resolveGatewayRelayModulePath(repoRoot));
await writeFile(
path.join(dir, "index.html"),
'<!doctype html><meta charset="utf-8"><script type="module" src="/main.ts"></script>',
@@ -658,8 +912,6 @@ async function smokeGatewayRelayBrowser(browser: Browser): Promise<SmokeResult>
await writeFile(
path.join(dir, "main.ts"),
`
const { GatewayRelayRealtimeTalkTransport } = await import(${relayModulePath});
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const listeners = new Set();
const requests = [];
@@ -699,6 +951,7 @@ const client = {
};
try {
const { GatewayRelayRealtimeTalkTransport } = await import(${relayModulePath});
const transport = new GatewayRelayRealtimeTalkTransport(
{
provider: "smoke",
@@ -761,9 +1014,14 @@ try {
`,
);
server = await createServer({
configFile: path.join(repoRoot, "ui/vite.config.ts"),
root: dir,
logLevel: "silent",
server: { host: "127.0.0.1", port: 0 },
server: {
host: "127.0.0.1",
port: 0,
fs: { allow: [dir, repoRoot] },
},
});
await server.listen();
const address = server.httpServer?.address();
@@ -863,6 +1121,7 @@ async function main(argv = process.argv.slice(2)): Promise<void> {
});
} else {
results.push(await smokeOpenAIBackendBridge(openAIKey));
results.push(await smokeOpenAIAudioRoundtrip(openAIKey, cli.openAIAudioCycles));
results.push(await smokeOpenAIWebRtc(browser, openAIKey));
}
if (!cli.openAIOnly) {
@@ -901,7 +1160,10 @@ export const testing = {
parseRealtimeSmokeArgs,
readOpenAIRealtimeBrowserResponseText,
readBoundedText,
resolveGatewayRelayModulePath,
resolveOpenAIHttpTimeoutMs,
sendPcmAudioInChunks,
transcriptIncludesMarker,
usage,
};
+37
View File
@@ -0,0 +1,37 @@
import type { DockerReleaseChannel } from "./lib/docker-release-policy.mjs";
export type DockerChannelPromotion = {
image: string;
sourceRef: string;
targetRefs: string[];
};
export type DockerChannelPromotionPlan = {
channel: DockerReleaseChannel;
promotions: DockerChannelPromotion[];
version: string;
};
export function createDockerChannelPromotionPlan(params: {
version: string;
images: string[];
}): DockerChannelPromotionPlan;
export function promoteDockerChannel(
params: { version: string; images: string[] },
options?: {
allowRollback?: boolean;
execFileSyncImpl?: (command: string, args: string[], options: object) => string;
log?: (message: string) => void;
verifyAttestationsImpl?: (params: {
imageRefs: string[];
requiredPlatforms: Array<{
architecture: string;
os: string;
variant?: string;
}>;
execFileSyncImpl: (command: string, args: string[], options: object) => string;
log: (message: string) => void;
}) => void;
},
): DockerChannelPromotionPlan;
+288
View File
@@ -0,0 +1,288 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import process from "node:process";
import { parseArgs } from "node:util";
import { isDirectRunUrl } from "./lib/direct-run.mjs";
import { resolveDockerReleasePolicy } from "./lib/docker-release-policy.mjs";
import { compareReleaseVersions } from "./lib/release-version.mjs";
import { parsePlatform, verifyDockerAttestations } from "./verify-docker-attestations.mjs";
const DOCKER_TIMEOUT_MS = 120_000;
const REQUIRED_PLATFORMS = Object.freeze([
parsePlatform("linux/amd64"),
parsePlatform("linux/arm64"),
]);
const VARIANTS = Object.freeze([
{ aliasKey: "default", suffix: "" },
{ aliasKey: "slim", suffix: "-slim" },
{ aliasKey: "browser", suffix: "-browser" },
]);
/** Build the version-specific source to moving-alias promotion plan. */
export function createDockerChannelPromotionPlan({ version, images }) {
if (images.length === 0) {
throw new Error("At least one --image is required.");
}
const policy = resolveDockerReleasePolicy(version);
const promotions = [];
for (const image of images) {
for (const { aliasKey, suffix } of VARIANTS) {
const aliases = policy.movingAliases[aliasKey];
if (aliases.length === 0) {
continue;
}
promotions.push({
image,
sourceRef: `${image}:${version}${suffix}`,
targetRefs: aliases.map((alias) => `${image}:${alias}`),
});
}
}
if (promotions.length === 0) {
throw new Error(`Docker ${policy.channel} releases have no moving aliases to promote.`);
}
return { channel: policy.channel, promotions, version: policy.version };
}
function runDocker(args, execFileSyncImpl) {
return execFileSyncImpl("docker", args, {
encoding: "utf8",
killSignal: "SIGKILL",
maxBuffer: 20 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
timeout: DOCKER_TIMEOUT_MS,
});
}
function inspectManifestDigest(imageRef, execFileSyncImpl) {
const raw = runDocker(
["buildx", "imagetools", "inspect", imageRef, "--format", "{{json .Manifest}}"],
execFileSyncImpl,
);
let digest;
try {
digest = JSON.parse(raw).digest;
} catch (error) {
throw new Error(`Could not parse the manifest for ${imageRef}.`, { cause: error });
}
if (typeof digest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(digest)) {
throw new Error(`The manifest for ${imageRef} did not contain a valid sha256 digest.`);
}
return digest;
}
function formatCommandError(error) {
if (!(error instanceof Error)) {
return String(error);
}
const output = [error.message];
for (const field of ["stderr", "stdout"]) {
const value = error[field];
if (typeof value === "string") {
output.push(value);
} else if (Buffer.isBuffer(value)) {
output.push(value.toString("utf8"));
}
}
return output.join("\n");
}
function isMissingManifestError(error) {
const message = formatCommandError(error);
return /(?:manifest unknown|no such manifest|:\s*not found(?:\s|$))/i.test(message);
}
function formatPlatform(platform) {
const suffix = platform.variant ? `/${platform.variant}` : "";
return `${platform.os}/${platform.architecture}${suffix}`;
}
function inspectImageVersion(imageRef, execFileSyncImpl, { allowMissing = false } = {}) {
const versions = new Map();
for (const [index, platform] of REQUIRED_PLATFORMS.entries()) {
const platformName = formatPlatform(platform);
let raw;
try {
// In formatted multi-platform inspection, Buildx keys .Image by os/arch.
// Read every promoted platform rather than trusting one config label.
raw = runDocker(
[
"buildx",
"imagetools",
"inspect",
imageRef,
"--format",
`{{json (index .Image "${platformName}")}}`,
],
execFileSyncImpl,
);
} catch (error) {
if (allowMissing && index === 0 && isMissingManifestError(error)) {
return null;
}
throw error;
}
let version;
try {
version = JSON.parse(raw)?.config?.Labels?.["org.opencontainers.image.version"];
} catch (error) {
throw new Error(`Could not parse the ${platformName} image config for ${imageRef}.`, {
cause: error,
});
}
if (typeof version !== "string" || version.trim().length === 0) {
throw new Error(
`${imageRef} does not have an org.opencontainers.image.version label for ${platformName}.`,
);
}
versions.set(platformName, version.trim());
}
const uniqueVersions = new Set(versions.values());
if (uniqueVersions.size !== 1) {
const details = [...versions].map(([platform, version]) => `${platform}=${version}`).join(", ");
throw new Error(`${imageRef} has inconsistent platform versions: ${details}.`);
}
return uniqueVersions.values().next().value;
}
function verifySourceVersions(resolved, version, execFileSyncImpl) {
for (const promotion of resolved) {
const sourceVersion = inspectImageVersion(promotion.sourceDigestRef, execFileSyncImpl);
if (sourceVersion !== version) {
throw new Error(
`${promotion.sourceDigestRef} reports version ${sourceVersion}, expected ${version}.`,
);
}
}
}
function preventChannelRollback(resolved, version, execFileSyncImpl) {
for (const promotion of resolved) {
for (const targetRef of promotion.targetRefs) {
const currentVersion = inspectImageVersion(targetRef, execFileSyncImpl, {
allowMissing: true,
});
if (currentVersion === null) {
continue;
}
const comparison = compareReleaseVersions(version, currentVersion);
if (comparison === null) {
throw new Error(
`Cannot compare candidate version ${version} with ${targetRef} version ${currentVersion}.`,
);
}
if (comparison < 0) {
throw new Error(
`Refusing to move ${targetRef} backward from ${currentVersion} to ${version}. ` +
"An approved repair may rerun with --allow-rollback.",
);
}
}
}
}
/** Promote every planned alias and verify the registry result. */
export function promoteDockerChannel({ version, images }, options = {}) {
const execFileSyncImpl = options.execFileSyncImpl ?? execFileSync;
const log = options.log ?? console.log;
const verifyAttestationsImpl = options.verifyAttestationsImpl ?? verifyDockerAttestations;
const plan = createDockerChannelPromotionPlan({ version, images });
// Resolve every version-specific source before the first alias write. A missing
// release variant must not leave the channel partially promoted.
const resolved = plan.promotions.map((promotion) => {
const sourceDigest = inspectManifestDigest(promotion.sourceRef, execFileSyncImpl);
return {
...promotion,
sourceDigest,
sourceDigestRef: `${promotion.image}@${sourceDigest}`,
};
});
// Attestation checks and writes share these digest refs so a concurrent tag
// rewrite cannot swap the content between verification and promotion.
verifyAttestationsImpl({
imageRefs: resolved.map((promotion) => promotion.sourceDigestRef),
requiredPlatforms: REQUIRED_PLATFORMS,
execFileSyncImpl,
log,
});
verifySourceVersions(resolved, plan.version, execFileSyncImpl);
if (!options.allowRollback) {
preventChannelRollback(resolved, plan.version, execFileSyncImpl);
}
for (const promotion of resolved) {
const targetArgs = promotion.targetRefs.flatMap((targetRef) => ["--tag", targetRef]);
runDocker(
[
"buildx",
"imagetools",
"create",
"--prefer-index=false",
...targetArgs,
promotion.sourceDigestRef,
],
execFileSyncImpl,
);
for (const targetRef of promotion.targetRefs) {
const targetDigest = inspectManifestDigest(targetRef, execFileSyncImpl);
if (targetDigest !== promotion.sourceDigest) {
throw new Error(
`${targetRef} resolved to ${targetDigest}, expected ${promotion.sourceDigest}.`,
);
}
log(`Verified ${targetRef} -> ${promotion.sourceDigest}.`);
}
}
return plan;
}
function printHelp() {
console.log(
"Usage: node scripts/docker-channel-promote.mjs --version YYYY.M.P --image REGISTRY/IMAGE [--image REGISTRY/IMAGE] [--allow-rollback]",
);
}
function main() {
const { values } = parseArgs({
args: process.argv.slice(2),
options: {
"allow-rollback": { type: "boolean" },
help: { type: "boolean", short: "h" },
image: { type: "string", multiple: true },
version: { type: "string" },
},
strict: true,
});
if (values.help) {
printHelp();
return;
}
const version = values.version?.trim();
if (!version) {
throw new Error("--version is required.");
}
const images = (values.image ?? []).map((image) => image.trim());
if (images.length === 0 || images.some((image) => image.length === 0)) {
throw new Error("At least one non-empty --image is required.");
}
const plan = promoteDockerChannel(
{ version, images },
{ allowRollback: values["allow-rollback"] },
);
console.log(`Promoted Docker ${plan.channel} aliases for ${plan.version}.`);
}
if (isDirectRunUrl(process.argv[1], import.meta.url)) {
try {
main();
} catch (error) {
console.error(
`docker-channel-promote: ${error instanceof Error ? error.message : String(error)}`,
);
process.exitCode = 1;
}
}
+6 -2
View File
@@ -417,10 +417,14 @@ function downloadDockerArtifacts(runId, repo, outputDir) {
if (names.length === 0) {
throw new Error(`No docker-e2e-* artifacts found for run ${runId}`);
}
for (const name of names) {
for (const [index, name] of names.entries()) {
const artifactDir = path.join(
outputDir,
`${String(index).padStart(3, "0")}-${safePathSegment(name)}`,
);
run(
"gh",
["run", "download", String(runId), "--repo", repo, "--name", name, "--dir", outputDir],
["run", "download", String(runId), "--repo", repo, "--name", name, "--dir", artifactDir],
{
stdio: "inherit",
},
+2 -1
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7
FROM node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf
FROM node:24-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
@@ -20,6 +20,7 @@ COPY packages ./packages
COPY extensions ./extensions
COPY patches ./patches
COPY scripts/postinstall-bundled-plugins.mjs scripts/preinstall-package-manager-warning.mjs scripts/prepare-git-hooks.mjs scripts/npm-runner.mjs scripts/windows-cmd-helpers.mjs ./scripts/
COPY scripts/lib/guard-inventory-utils.mjs ./scripts/lib/guard-inventory-utils.mjs
COPY scripts/lib/package-dist-imports.mjs ./scripts/lib/package-dist-imports.mjs
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
corepack enable \
+1 -1
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7
FROM node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf
FROM node:24-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d
RUN --mount=type=cache,id=openclaw-install-sh-e2e-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-install-sh-e2e-apt-lists,target=/var/lib/apt,sharing=locked \
-1
View File
@@ -855,7 +855,6 @@ run_profile() {
test -f "$workspace/IDENTITY.md"
test -f "$workspace/USER.md"
test -f "$workspace/SOUL.md"
test -f "$workspace/TOOLS.md"
# The remaining checks are deterministic tool smokes, not the interactive
# first-run identity ritual. Drop BOOTSTRAP.md so provider prompts stay focused
# on the fixture task and do not spend turns following onboarding copy.
+1 -1
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7
FROM node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf
FROM node:24-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d
# Smoke images are pinned and short-lived, so skip distro upgrades here and
# spend the time budget on installer coverage instead.
+13 -5
View File
@@ -409,9 +409,16 @@ contains_disallowed_chars() {
[[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]]
}
is_valid_timezone() {
is_valid_timezone_in_image() {
local value="$1"
[[ -e "/usr/share/zoneinfo/$value" && ! -d "/usr/share/zoneinfo/$value" ]]
docker run --rm --network none --entrypoint node "$IMAGE_NAME" -e '
const timezone = process.argv[1];
try {
new Intl.DateTimeFormat("en", { timeZone: timezone }).format(0);
} catch {
process.exit(1);
}
' "$value"
}
validate_mount_path_value() {
@@ -497,9 +504,6 @@ if [[ -n "$TIMEZONE" ]]; then
if [[ ! "$TIMEZONE" =~ ^[A-Za-z0-9/_+\-]+$ ]]; then
fail "OPENCLAW_TZ must be a valid IANA timezone string (e.g. Asia/Shanghai)."
fi
if ! is_valid_timezone "$TIMEZONE"; then
fail "OPENCLAW_TZ must match a timezone in /usr/share/zoneinfo (e.g. Asia/Shanghai)."
fi
fi
mkdir -p "$OPENCLAW_CONFIG_DIR"
@@ -782,6 +786,10 @@ else
fi
fi
if [[ -n "$TIMEZONE" ]] && ! is_valid_timezone_in_image "$TIMEZONE"; then
fail "OPENCLAW_TZ must be supported by $IMAGE_NAME (e.g. Asia/Shanghai)."
fi
# Ensure bind-mounted data directories are writable by the container's `node`
# user (uid 1000). Host-created dirs inherit the host user's uid which may
# differ, causing EACCES when the container tries to mkdir/write.
+35 -10
View File
@@ -60,13 +60,14 @@ func translateDocBodyChunked(ctx context.Context, translator docsTranslator, rel
mapping := map[string]string{}
maskedBody := maskMarkdownFencedLiterals(body, placeholderState.Next, &placeholders, mapping)
maskedBody = maskMarkdownDocSyntax(maskedBody, placeholderState.Next, &placeholders, mapping)
listPlaceholders := maskedListMarkerPlaceholders(mapping)
blocks := splitDocBodyIntoBlocks(maskedBody)
groups := groupDocBlocks(blocks, docsI18nDocChunkMaxBytes())
logDocChunkPlan(relPath, blocks, groups)
out := strings.Builder{}
for index, group := range groups {
chunkID := fmt.Sprintf("%s.chunk-%03d", relPath, index+1)
translated, err := translateDocBlockGroup(ctx, translator, chunkID, group, placeholders, srcLang, tgtLang)
translated, err := translateDocBlockGroup(ctx, translator, chunkID, group, placeholders, listPlaceholders, srcLang, tgtLang)
if err != nil {
return "", err
}
@@ -74,11 +75,25 @@ func translateDocBodyChunked(ctx context.Context, translator docsTranslator, rel
}
translatedBody := out.String()
translatedBody = normalizeMaskedListMarkerPlaceholders(translatedBody, mapping)
translatedBody = normalizeMaskedListMarkerSpacing(maskedBody, translatedBody, listPlaceholders)
translatedBody = escapeUnexpectedListItemBodyMarkers(maskedBody, translatedBody, listPlaceholders)
translatedBody = escapeUnexpectedMarkdownListMarkers(translatedBody, listPlaceholders)
if err := validatePlaceholders(translatedBody, placeholders); err != nil {
return "", fmt.Errorf("%s: restore fenced literals: %w", relPath, err)
}
maskedListMarkers := extractMarkdownListMarkerPrefixes(translatedBody)
translatedBody = unmaskMarkdown(translatedBody, placeholders, mapping)
if err := validateDocBodyFencedLiterals(body, translatedBody); err != nil {
log.Printf(
"docs-i18n: final list diagnostics %s source=%q masked=%q translated=%q",
relPath,
extractMarkdownListMarkerPrefixes(body),
maskedListMarkers,
extractMarkdownListMarkerPrefixes(translatedBody),
)
if os.Getenv("OPENCLAW_DOCS_I18N_LOG_REJECTED_BODY") == "1" {
log.Printf("docs-i18n: rejected translated body %s %q", relPath, translatedBody)
}
return "", fmt.Errorf("%s: final document validation: %w", relPath, err)
}
return translatedBody, nil
@@ -126,14 +141,14 @@ func validateDocBodyFencedLiterals(source, translated string) error {
return nil
}
func translateDocBlockGroup(ctx context.Context, translator docsTranslator, chunkID string, blocks []string, protectedPlaceholders []string, srcLang, tgtLang string) (string, error) {
func translateDocBlockGroup(ctx context.Context, translator docsTranslator, chunkID string, blocks []string, protectedPlaceholders []string, listPlaceholders map[string]string, srcLang, tgtLang string) (string, error) {
source := strings.Join(blocks, "")
if strings.TrimSpace(source) == "" {
return source, nil
}
if plan, ok := planDocChunkSplit(blocks, docsI18nDocChunkMaxBytes(), docsI18nDocChunkPromptBudget()); ok {
logDocChunkPlanSplit(chunkID, plan, source)
return translatePlannedDocChunkGroups(ctx, translator, chunkID, source, plan.groups, protectedPlaceholders, srcLang, tgtLang)
return translatePlannedDocChunkGroups(ctx, translator, chunkID, source, plan.groups, protectedPlaceholders, listPlaceholders, srcLang, tgtLang)
}
normalizedSource, commonIndent := stripCommonIndent(source)
log.Printf("docs-i18n: chunk start %s blocks=%d bytes=%d", chunkID, len(blocks), len(source))
@@ -145,6 +160,11 @@ func translateDocBlockGroup(ctx context.Context, translator docsTranslator, chun
translated = sanitizeDocChunkProtocolWrappers(source, translated)
translated = preserveDocChunkBoundaryWhitespace(normalizedSource, translated)
translated = reapplyCommonIndent(translated, commonIndent)
translated = normalizeMaskedListMarkerPlaceholders(translated, listPlaceholders)
translated = normalizeMaskedListMarkerSpacing(source, translated, listPlaceholders)
translated = escapeUnexpectedListItemBodyMarkers(source, translated, listPlaceholders)
translated = escapeUnexpectedMarkdownListMarkers(translated, listPlaceholders)
translated = unwrapUnexpectedInlineCodeSpans(source, translated)
if validationErr := validateDocChunkTranslation(source, translated); validationErr == nil {
log.Printf("docs-i18n: chunk done %s out_bytes=%d", chunkID, len(translated))
return translated, nil
@@ -153,27 +173,27 @@ func translateDocBlockGroup(ctx context.Context, translator docsTranslator, chun
}
}
if len(blocks) <= 1 {
if fallback, fallbackErr := translateDocLeafBlock(ctx, translator, chunkID, source, protectedPlaceholders, srcLang, tgtLang); fallbackErr == nil {
if fallback, fallbackErr := translateDocLeafBlock(ctx, translator, chunkID, source, protectedPlaceholders, listPlaceholders, srcLang, tgtLang); fallbackErr == nil {
return fallback, nil
}
if plan, ok := planSingletonDocChunkRetry(source, docsI18nDocChunkMaxBytes(), docsI18nDocChunkPromptBudget()); ok {
logDocChunkPlanSplit(chunkID, plan, source)
return translatePlannedDocChunkGroups(ctx, translator, chunkID, source, plan.groups, protectedPlaceholders, srcLang, tgtLang)
return translatePlannedDocChunkGroups(ctx, translator, chunkID, source, plan.groups, protectedPlaceholders, listPlaceholders, srcLang, tgtLang)
}
return "", fmt.Errorf("%s: %w", chunkID, err)
}
if plan, ok := planDocChunkSplit(blocks, docsI18nDocChunkMaxBytes(), docsI18nDocChunkPromptBudget()); ok {
logDocChunkSplit(chunkID, len(blocks), err)
return translatePlannedDocChunkGroups(ctx, translator, chunkID, source, plan.groups, protectedPlaceholders, srcLang, tgtLang)
return translatePlannedDocChunkGroups(ctx, translator, chunkID, source, plan.groups, protectedPlaceholders, listPlaceholders, srcLang, tgtLang)
}
if plan, ok := splitDocChunkBlocksMidpointSimple(blocks); ok {
logDocChunkSplit(chunkID, len(blocks), err)
return translatePlannedDocChunkGroups(ctx, translator, chunkID, source, plan.groups, protectedPlaceholders, srcLang, tgtLang)
return translatePlannedDocChunkGroups(ctx, translator, chunkID, source, plan.groups, protectedPlaceholders, listPlaceholders, srcLang, tgtLang)
}
return "", fmt.Errorf("%s: %w", chunkID, err)
}
func translateDocLeafBlock(ctx context.Context, translator docsTranslator, chunkID, source string, protectedPlaceholders []string, srcLang, tgtLang string) (string, error) {
func translateDocLeafBlock(ctx context.Context, translator docsTranslator, chunkID, source string, protectedPlaceholders []string, listPlaceholders map[string]string, srcLang, tgtLang string) (string, error) {
sourceStructure := summarizeDocChunkStructure(source)
if sourceStructure.fenceCount != 0 {
return "", fmt.Errorf("%s: raw leaf fallback not applicable", chunkID)
@@ -194,6 +214,11 @@ func translateDocLeafBlock(ctx context.Context, translator docsTranslator, chunk
translated = sanitizeDocChunkProtocolWrappers(source, translated)
translated = preserveDocChunkBoundaryWhitespace(normalizedSource, translated)
translated = reapplyCommonIndent(translated, commonIndent)
translated = normalizeMaskedListMarkerPlaceholders(translated, listPlaceholders)
translated = normalizeMaskedListMarkerSpacing(source, translated, listPlaceholders)
translated = escapeUnexpectedListItemBodyMarkers(source, translated, listPlaceholders)
translated = escapeUnexpectedMarkdownListMarkers(translated, listPlaceholders)
translated = unwrapUnexpectedInlineCodeSpans(source, translated)
if validationErr := validateDocChunkTranslation(source, translated); validationErr != nil {
return "", validationErr
}
@@ -733,11 +758,11 @@ func containsProtocolWrapperToken(text string) bool {
return strings.Contains(lower, strings.ToLower(bodyTagStart)) || strings.Contains(lower, strings.ToLower(frontmatterTagStart))
}
func translatePlannedDocChunkGroups(ctx context.Context, translator docsTranslator, chunkID, source string, groups [][]string, protectedPlaceholders []string, srcLang, tgtLang string) (string, error) {
func translatePlannedDocChunkGroups(ctx context.Context, translator docsTranslator, chunkID, source string, groups [][]string, protectedPlaceholders []string, listPlaceholders map[string]string, srcLang, tgtLang string) (string, error) {
var out strings.Builder
translatedGroups := make([]string, 0, len(groups))
for index, group := range groups {
translated, err := translateDocBlockGroup(ctx, translator, fmt.Sprintf("%s.%02d", chunkID, index+1), group, protectedPlaceholders, srcLang, tgtLang)
translated, err := translateDocBlockGroup(ctx, translator, fmt.Sprintf("%s.%02d", chunkID, index+1), group, protectedPlaceholders, listPlaceholders, srcLang, tgtLang)
if err != nil {
return "", err
}
+178 -4
View File
@@ -294,12 +294,36 @@ func (t *docSyntaxMaskingTranslator) Translate(_ context.Context, text, _, _ str
func (t *docSyntaxMaskingTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
t.rawInputs = append(t.rawInputs, text)
translated := strings.ReplaceAll(text, "Visible prose", "Видимый текст")
translated = regexp.MustCompile(`(?m)^(__OC_I18N_\d+__)`).ReplaceAllString(translated, " $1")
translated = regexp.MustCompile(`(?m)^(__OC_I18N_\d+__)`).ReplaceAllString(translated, " 1. $1")
return translated, nil
}
func (t *docSyntaxMaskingTranslator) Close() {}
type accidentalListMarkerTranslator struct{}
func (accidentalListMarkerTranslator) Translate(_ context.Context, text, _, _ string) (string, error) {
return text, nil
}
func (accidentalListMarkerTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
return strings.ReplaceAll(text, "September begins the standard rate.", "1. September beginnt der Standardtarif."), nil
}
func (accidentalListMarkerTranslator) Close() {}
type translatedOrdinalTranslator struct{}
func (translatedOrdinalTranslator) Translate(_ context.Context, text, _, _ string) (string, error) {
return text, nil
}
func (translatedOrdinalTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
return strings.NewReplacer("1st failure", "1. Fehler", "2nd failure", "2. Fehler").Replace(text), nil
}
func (translatedOrdinalTranslator) Close() {}
type duplicateFirstFencedPlaceholderTranslator struct {
rawCalls int
}
@@ -746,6 +770,8 @@ func TestNormalizeMaskedListMarkerPlaceholdersRemovesAddedContainers(t *testing.
" __OC_I18N_000001__Top level",
"> __OC_I18N_000002__Nested",
" > __OC_I18N_000003__Quoted",
"1. __OC_I18N_000001__Numbered wrapper",
" - __OC_I18N_000002__Bullet wrapper",
" __OC_I18N_000004__ prose",
"",
}, "\n")
@@ -753,6 +779,8 @@ func TestNormalizeMaskedListMarkerPlaceholdersRemovesAddedContainers(t *testing.
"__OC_I18N_000001__Top level",
"__OC_I18N_000002__Nested",
"__OC_I18N_000003__Quoted",
"__OC_I18N_000001__Numbered wrapper",
"__OC_I18N_000002__Bullet wrapper",
" __OC_I18N_000004__ prose",
"",
}, "\n")
@@ -762,6 +790,70 @@ func TestNormalizeMaskedListMarkerPlaceholdersRemovesAddedContainers(t *testing.
}
}
func TestEscapeUnexpectedMarkdownListMarkersPreservesFencedExamples(t *testing.T) {
t.Parallel()
translated := strings.Join([]string{
"1. September beginnt der Standardtarif.",
"- Unbeabsichtigter Aufzählungspunkt.",
"> 2) Verschachtelte Nummerierung.",
"- __OC_I18N_000001__Maskierter Listeneintrag.",
"3. September mit __OC_I18N_000002__Inlinecode.",
"```md",
"1. Beispiel bleibt unverändert.",
"```",
"",
}, "\n")
want := strings.Join([]string{
`1\. September beginnt der Standardtarif.`,
`\- Unbeabsichtigter Aufzählungspunkt.`,
`> 2\) Verschachtelte Nummerierung.`,
"- __OC_I18N_000001__Maskierter Listeneintrag.",
`3\. September mit __OC_I18N_000002__Inlinecode.`,
"```md",
"1. Beispiel bleibt unverändert.",
"```",
"",
}, "\n")
if got := escapeUnexpectedMarkdownListMarkers(translated, map[string]string{"__OC_I18N_000001__": "- "}); got != want {
t.Fatalf("unexpected escaped list markers:\n%s\nwant:\n%s", got, want)
}
}
func TestNormalizeMaskedListMarkerSpacingRestoresSourceWhitespace(t *testing.T) {
t.Parallel()
first := "__OC_I18N_000001__"
second := "__OC_I18N_000002__"
markers := map[string]string{first: "- ", second: " - "}
source := "Intro.\n\n" + first + "First\n continuation\n" + second + "Second\n"
translated := "Einleitung. " + first + "Erste\n Fortsetzung\n\n\n" + second + "Zweite\n"
want := "Einleitung.\n\n" + first + "Erste\n Fortsetzung\n" + second + "Zweite\n"
if got := normalizeMaskedListMarkerSpacing(source, translated, markers); got != want {
t.Fatalf("unexpected normalized spacing:\n%s\nwant:\n%s", got, want)
}
}
func TestTranslateDocBodyChunkedEscapesTranslatedOrdinalAtListItemStart(t *testing.T) {
t.Parallel()
body := "- 1st failure: 30 seconds\n- 2nd failure: 1 minute\n- 3rd+ failure: 5 minutes\n"
translated, err := translateDocBodyChunked(
context.Background(), translatedOrdinalTranslator{}, "concepts/model-failover.md", body, "en", "de",
)
if err != nil {
t.Fatalf("translateDocBodyChunked returned error: %v", err)
}
if !strings.Contains(translated, `- 1\. Fehler: 30 seconds`) || !strings.Contains(translated, `- 2\. Fehler: 1 minute`) {
t.Fatalf("expected translated ordinals to be escaped:\n%s", translated)
}
if err := validateDocBodyFencedLiterals(body, translated); err != nil {
t.Fatalf("expected repaired final structure to validate: %v", err)
}
}
func TestValidateDocChunkTranslationRejectsTranslatedInlineCode(t *testing.T) {
t.Parallel()
@@ -799,6 +891,30 @@ func TestValidateDocChunkTranslationAcceptsReorderedInlineCode(t *testing.T) {
}
}
func TestUnwrapUnexpectedInlineCodeSpans(t *testing.T) {
t.Parallel()
source := "Use labels in ordinary prose.\n"
translated := "Verwende `Bezeichnungen` in `normalem Text`.\n"
want := "Verwende Bezeichnungen in normalem Text.\n"
if got := unwrapUnexpectedInlineCodeSpans(source, translated); got != want {
t.Fatalf("unexpected inline-code repair:\n%s\nwant:\n%s", got, want)
}
if err := validateDocChunkTranslation(source, want); err != nil {
t.Fatalf("expected repaired translation to validate: %v", err)
}
}
func TestUnwrapUnexpectedInlineCodeSpansPreservesSourceCodeContract(t *testing.T) {
t.Parallel()
source := "Use `--source`.\n"
translated := "Verwende `--target`.\n"
if got := unwrapUnexpectedInlineCodeSpans(source, translated); got != translated {
t.Fatalf("expected source inline-code contract to remain untouched: %q", got)
}
}
func TestMaskMarkdownDocSyntaxPreservesCanonicalNestedBackticks(t *testing.T) {
t.Parallel()
@@ -1093,6 +1209,38 @@ func TestValidateDocChunkTranslationAllowsTranslatedProseInIsolatedIndentedFence
}
}
func TestValidateDocChunkTranslationAllowsDedentedCodeInIndentedFence(t *testing.T) {
t.Parallel()
sourceFence := " ```typescript\n" +
"const plugin = createPlugin<Account>({\n" +
" // Account resolution belongs on `config`.\n" +
" notify: (code) => `Pairing code: ${code}`,\n" +
"});\n" +
" ```\n"
translatedFence := " ```typescript\n" +
"const plugin = createPlugin<Account>({\n" +
" // La resolución de cuentas pertenece a `config`.\n" +
" notify: (code) => `Código de emparejamiento: ${code}`,\n" +
"});\n" +
" ```\n"
if err := validateDocChunkTranslation(sourceFence, translatedFence); err != nil {
t.Fatalf("expected translated pure fenced chunk to validate, got %v", err)
}
source := "<Example>\n" + sourceFence + " Run `openclaw doctor` after editing.\n</Example>\n"
translated := "<Example>\n" + translatedFence + " Ejecuta `openclaw doctor` después de editar.\n</Example>\n"
if err := validateDocChunkTranslation(source, translated); err != nil {
t.Fatalf("expected translated component fence and preserved trailing inline code to validate, got %v", err)
}
changedTrailingCode := strings.Replace(translated, "`openclaw doctor`", "`openclaw fix`", 1)
err := validateDocChunkTranslation(source, changedTrailingCode)
if err == nil || !strings.Contains(err.Error(), "inline code mismatch") {
t.Fatalf("expected changed inline code after the fence to be rejected, got %v", err)
}
}
func TestValidateDocChunkTranslationRejectsChangedCodeInSplitComponentBody(t *testing.T) {
t.Parallel()
@@ -2312,6 +2460,7 @@ func TestTranslateDocBodyChunkedMasksInlineCodeAndListMarkers(t *testing.T) {
body := strings.Join([]string{
"- Visible prose uses `openclaw config`.",
" 1. Visible prose keeps ``nested `ticks` `` exact.",
"- Visible prose keeps Hailuo 2.3/02 exact.",
"- Channel configs:",
" - Telegram: Visible prose.",
" - WhatsApp: Visible prose.",
@@ -2336,7 +2485,7 @@ func TestTranslateDocBodyChunkedMasksInlineCodeAndListMarkers(t *testing.T) {
t.Fatal("expected raw translator inputs")
}
for _, input := range translator.rawInputs {
if strings.Contains(input, "`openclaw config`") || strings.Contains(input, "``nested `ticks` ``") {
if strings.Contains(input, "`openclaw config`") || strings.Contains(input, "``nested `ticks` ``") || strings.Contains(input, "2.3/02") {
t.Fatalf("expected inline code outside fences to be masked:\n%s", input)
}
if strings.Contains(input, "- Visible prose uses") || strings.Contains(input, "1. Visible prose keeps") || strings.Contains(input, "> - Visible prose inside a quote.") {
@@ -2349,6 +2498,7 @@ func TestTranslateDocBodyChunkedMasksInlineCodeAndListMarkers(t *testing.T) {
for _, exact := range []string{
"- Видимый текст uses `openclaw config`.",
" 1. Видимый текст keeps ``nested `ticks` `` exact.",
"- Видимый текст keeps Hailuo 2.3/02 exact.",
"- Channel configs:\n - Telegram: Видимый текст.\n - WhatsApp: Видимый текст.",
"> - Видимый текст inside a quote.",
"```md\n- Видимый текст and `fenced example` stay exposed.\n```",
@@ -2363,6 +2513,27 @@ func TestTranslateDocBodyChunkedMasksInlineCodeAndListMarkers(t *testing.T) {
}
}
func TestTranslateDocBodyChunkedEscapesModelInventedListMarker(t *testing.T) {
t.Parallel()
body := "1. First step.\n2. Second step.\n\nSeptember begins the standard rate.\n"
translated, err := translateDocBodyChunked(
context.Background(), accidentalListMarkerTranslator{}, "concepts/model-failover.md", body, "en", "de",
)
if err != nil {
t.Fatalf("translateDocBodyChunked returned error: %v", err)
}
if !strings.Contains(translated, "1. First step.\n2. Second step.") {
t.Fatalf("expected source list markers to be restored:\n%s", translated)
}
if !strings.Contains(translated, `1\. September beginnt der Standardtarif.`) {
t.Fatalf("expected model-invented list marker to be escaped:\n%s", translated)
}
if err := validateDocBodyFencedLiterals(body, translated); err != nil {
t.Fatalf("expected repaired final structure to validate: %v", err)
}
}
func TestTranslateDocBodyChunkedRetriesSingletonFenceAfterValidationFailure(t *testing.T) {
body := strings.Join([]string{
"```md",
@@ -2582,8 +2753,8 @@ func TestValidateDocBodyRejectsChangedCompositeLiteral(t *testing.T) {
func TestExtractNumericValuesKeepsLowAmbiguityComposites(t *testing.T) {
t.Parallel()
got := strings.Join(extractNumericValues("0xFF 0b101 0o755 1.5:1 24/7 1e-3 v1.2.3 v24/7 24/7z"), ",")
if want := "0xFF,0b101,0o755,1.5:1,24/7,1e-3"; got != want {
got := strings.Join(extractNumericValues("0xFF 0b101 0o755 1.5:1 24/7 1e-3 v1.2.3 v24/7 24/7z Hailuo-2.3/02"), ",")
if want := "0xFF,0b101,0o755,1.5:1,24/7,1e-3,2.3/02"; got != want {
t.Fatalf("unexpected composite literals: got=%q want=%q", got, want)
}
if err := validateDocChunkTranslation("Supports 1:1 conversations.\n", "Unterstützt 1:1-Unterhaltungen.\n"); err != nil {
@@ -2592,6 +2763,9 @@ func TestExtractNumericValuesKeepsLowAmbiguityComposites(t *testing.T) {
if err := validateDocChunkTranslation("Available 24/7.\n", "24/7 उपलब्ध।\n"); err != nil {
t.Fatalf("expected translated prose around exact slash ratio to pass: %v", err)
}
if err := validateDocChunkTranslation("Hailuo 2.3/02 models.\n", "Hailuo-2.3/02-Modelle.\n"); err != nil {
t.Fatalf("expected locale hyphen compound around exact ratio to pass: %v", err)
}
}
func TestExtractNumericValuesKeepsClockCoreBeforeMeridiemSuffix(t *testing.T) {
+2 -2
View File
@@ -5,7 +5,7 @@ go 1.25.0
toolchain go1.25.12
require (
github.com/yuin/goldmark v1.8.2
golang.org/x/net v0.55.0
github.com/yuin/goldmark v1.8.5
golang.org/x/net v0.57.0
gopkg.in/yaml.v3 v3.0.1
)
+4 -4
View File
@@ -1,7 +1,7 @@
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+28
View File
@@ -182,6 +182,11 @@ func markdownListParentItemPath(list *ast.List) string {
}
func extractMarkdownInlineCodeValues(body string) []string {
if _, opening, _, _, _, ok := splitPureFencedDocSection(body); ok {
if _, validOpening := parseMarkdownLiteralFenceOpening(opening); validOpening {
return nil
}
}
parseSource := []byte(normalizeDocComponentsForMarkdownParse(body))
fencedRanges := markdownClosedLiteralFenceByteRanges(string(parseSource))
doc := goldmark.New(goldmark.WithExtensions(extension.GFM)).Parser().Parse(text.NewReader(parseSource))
@@ -203,6 +208,29 @@ func extractMarkdownInlineCodeValues(body string) []string {
return values
}
func unwrapUnexpectedInlineCodeSpans(source, translated string) string {
if len(extractMarkdownInlineCodeValues(source)) != 0 || len(extractMarkdownInlineCodeValues(translated)) == 0 {
return translated
}
fenced := append(markdownFencedCodeRanges(translated), markdownClosedLiteralFenceByteRanges(translated)...)
ranges := markdownBlockBacktickRanges(translated)
for index := len(ranges) - 1; index >= 0; index-- {
span := ranges[index]
if rangeOverlapsAny(span, fenced) {
continue
}
runLength := 0
for span[0]+runLength < span[1] && translated[span[0]+runLength] == '`' {
runLength++
}
if runLength == 0 || span[1]-runLength < span[0]+runLength {
continue
}
translated = translated[:span[0]] + translated[span[0]+runLength:span[1]-runLength] + translated[span[1]:]
}
return translated
}
func extractMarkdownFencedLiteralValues(body string) ([]string, []string, []string) {
placeholders := []string{}
directiveTokens := []string{}
+141 -7
View File
@@ -14,7 +14,7 @@ var (
linkLabelRe = regexp.MustCompile(`!?\[([^\]\r\n]+)\]\(([^)\r\n]+)\)`)
placeholderRe = regexp.MustCompile(`__OC_I18N_\d+__`)
listMarkerRe = regexp.MustCompile(`^([ \t]*(?:>[ \t]*)*)([-+*]|[0-9]+[.)])([ \t]+)`)
listContainerPrefixRe = regexp.MustCompile(`^[ \t]*(?:>[ \t]*)*$`)
listContainerPrefixRe = regexp.MustCompile(`^[ \t]*(?:(?:>[ \t]*)|(?:(?:[-+*]|[0-9]+[.)])[ \t]+))*$`)
// Hard validation stays limited to low-ambiguity composite literals. Plain numbers remain
// model-visible so target-language plurals and ordinals can change grammar without false failures.
numericValueRe = regexp.MustCompile(`(?:0[xX][0-9A-Za-z_]+|0[bB][0-9A-Za-z_]+|0[oO][0-9A-Za-z_]+|[0-9]+(?:\.[0-9]+)?(?::[0-9]+(?:\.[0-9]+)?)+|[0-9]+(?:\.[0-9]+)?(?:/[0-9]+(?:\.[0-9]+)?)+|(?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+)[eE][+-]?[0-9]+)`)
@@ -87,8 +87,8 @@ func maskMarkdownDocSyntax(text string, nextPlaceholder func() string, placehold
}
inlineRanges = append(inlineRanges, protectedMarkdownLinkRanges(text)...)
masked := maskByteRanges(text, inlineRanges, nextPlaceholder, placeholders, mapping)
return maskByteRanges(masked, markdownListMarkerRanges(masked), nextPlaceholder, placeholders, mapping)
masked = maskByteRanges(masked, markdownListMarkerRanges(masked), nextPlaceholder, placeholders, mapping)
return maskByteRanges(masked, compositeNumericValueRanges(masked), nextPlaceholder, placeholders, mapping)
}
func markdownListMarkerRanges(text string) [][2]int {
@@ -150,6 +150,131 @@ func normalizeMaskedListMarkerPlaceholders(text string, mapping map[string]strin
return strings.Join(lines, "")
}
func maskedListMarkerPlaceholders(mapping map[string]string) map[string]string {
placeholders := make(map[string]string)
for placeholder, original := range mapping {
markerSpan := listMarkerRe.FindStringIndex(original)
if markerSpan != nil && markerSpan[0] == 0 && markerSpan[1] == len(original) {
placeholders[placeholder] = original
}
}
return placeholders
}
func normalizeMaskedListMarkerSpacing(source, translated string, listPlaceholders map[string]string) string {
type replacement struct {
start int
end int
value string
}
replacements := make([]replacement, 0, len(listPlaceholders))
for placeholder := range listPlaceholders {
sourcePosition := strings.Index(source, placeholder)
translatedPosition := strings.Index(translated, placeholder)
if sourcePosition < 0 || translatedPosition < 0 {
continue
}
sourceStart := markdownWhitespaceRunStart(source, sourcePosition)
translatedStart := markdownWhitespaceRunStart(translated, translatedPosition)
sourceSpacing := source[sourceStart:sourcePosition]
if translated[translatedStart:translatedPosition] == sourceSpacing {
continue
}
replacements = append(replacements, replacement{
start: translatedStart,
end: translatedPosition,
value: sourceSpacing,
})
}
sort.Slice(replacements, func(i, j int) bool { return replacements[i].start > replacements[j].start })
for _, item := range replacements {
translated = translated[:item.start] + item.value + translated[item.end:]
}
return translated
}
func markdownWhitespaceRunStart(text string, position int) int {
for position > 0 {
switch text[position-1] {
case ' ', '\t', '\r', '\n':
position--
default:
return position
}
}
return position
}
func escapeUnexpectedListItemBodyMarkers(source, translated string, listPlaceholders map[string]string) string {
type insertion struct {
position int
}
insertions := make([]insertion, 0)
for placeholder := range listPlaceholders {
sourcePosition := strings.Index(source, placeholder)
translatedPosition := strings.Index(translated, placeholder)
if sourcePosition < 0 || translatedPosition < 0 {
continue
}
sourceBody := source[sourcePosition+len(placeholder):]
translatedBody := translated[translatedPosition+len(placeholder):]
sourceMatch := listMarkerRe.FindStringSubmatchIndex(sourceBody)
translatedMatch := listMarkerRe.FindStringSubmatchIndex(translatedBody)
if len(translatedMatch) < 6 || len(sourceMatch) >= 6 {
continue
}
markerStart, markerEnd := translatedMatch[4], translatedMatch[5]
insertAt := markerStart
if markerEnd-markerStart > 1 {
insertAt = markerEnd - 1
}
insertions = append(insertions, insertion{position: translatedPosition + len(placeholder) + insertAt})
}
sort.Slice(insertions, func(i, j int) bool { return insertions[i].position > insertions[j].position })
for _, item := range insertions {
translated = translated[:item.position] + `\` + translated[item.position:]
}
return translated
}
func escapeUnexpectedMarkdownListMarkers(text string, listPlaceholders map[string]string) string {
ranges := markdownListMarkerRanges(text)
if len(ranges) == 0 {
return text
}
var out strings.Builder
position := 0
for _, span := range ranges {
lineEnd := strings.IndexByte(text[span[1]:], '\n')
if lineEnd < 0 {
lineEnd = len(text)
} else {
lineEnd += span[1]
}
if placeholder := placeholderRe.FindString(text[span[1]:lineEnd]); placeholder != "" {
if _, ok := listPlaceholders[placeholder]; ok && strings.HasPrefix(text[span[1]:lineEnd], placeholder) {
continue
}
}
value := text[span[0]:span[1]]
match := listMarkerRe.FindStringSubmatchIndex(value)
if len(match) < 6 {
continue
}
markerStart, markerEnd := match[4], match[5]
insertAt := markerStart
if markerEnd-markerStart > 1 {
insertAt = markerEnd - 1
}
absolute := span[0] + insertAt
out.WriteString(text[position:absolute])
out.WriteByte('\\')
position = absolute
}
out.WriteString(text[position:])
return out.String()
}
func protectedMarkdownLinkRanges(text string) [][2]int {
ranges := make([][2]int, 0)
for _, match := range linkLabelRe.FindAllStringSubmatchIndex(text, -1) {
@@ -181,11 +306,20 @@ func markdownInlineLinkDestination(value string) string {
}
func extractNumericValues(text string) []string {
ranges := compositeNumericValueRanges(text)
values := make([]string, 0, len(ranges))
for _, span := range ranges {
values = append(values, text[span[0]:span[1]])
}
return values
}
func compositeNumericValueRanges(text string) [][2]int {
protocolRanges := make([][2]int, 0)
for _, span := range placeholderRe.FindAllStringIndex(text, -1) {
protocolRanges = append(protocolRanges, [2]int{span[0], span[1]})
}
values := make([]string, 0)
ranges := make([][2]int, 0)
for _, span := range numericValueRe.FindAllStringIndex(text, -1) {
candidate := [2]int{span[0], span[1]}
if hasCompositeNumericLeadingContinuation(text, candidate[0]) ||
@@ -193,9 +327,9 @@ func extractNumericValues(text string) []string {
rangeOverlapsAny(candidate, protocolRanges) {
continue
}
values = append(values, text[span[0]:span[1]])
ranges = append(ranges, [2]int{span[0], span[1]})
}
return values
return ranges
}
func hasClockMeridiemSuffix(text string, span [2]int) bool {
@@ -220,7 +354,7 @@ func hasCompositeNumericLeadingContinuation(text string, position int) bool {
}
return position > 0 && isCompositeNumericWordByte(text[position-1])
}
return value == '.' || value == '-' || isCompositeNumericWordByte(value)
return value == '.' || isCompositeNumericWordByte(value)
}
func hasCompositeNumericContinuation(text string, position int) bool {
+8
View File
@@ -52,6 +52,14 @@ export function resolveRoute(
export function sanitizeDocsConfigForEnglishOnly(value: unknown): unknown;
export function prepareExternalLinkAuditTree(
repoRoot: string,
outputDir: string,
): {
files: number;
projectedLinks: number;
};
export function prepareMirroredDocsDir(
sourceDir?: string,
options?: {
+222
View File
@@ -6,12 +6,19 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { createProcessor } from "@mdx-js/mdx";
import MarkdownIt from "markdown-it";
import { resolveClawHubRepoPath, syncClawHubDocsTree } from "./docs-sync-publish.mjs";
import { resolveNpmRunner } from "./npm-runner.mjs";
const ROOT = process.cwd();
const DOCS_DIR = path.join(ROOT, "docs");
const DOCS_JSON_PATH = path.join(DOCS_DIR, "docs.json");
const ROOT_MARKDOWN_FILES = ["README.md", "CONTRIBUTING.md", "SECURITY.md"];
const MDX_PROCESSOR = createProcessor({ format: "mdx" });
const MARKDOWN_PARSER = new MarkdownIt({ html: false });
const HTML_MARKDOWN_PARSER = new MarkdownIt({ html: true });
const VERBATIM_MDX_ELEMENTS = new Set(["code", "pre", "script", "style", "textarea"]);
const MINTLIFY_CLI_VERSION = "4.2.715";
const MINTLIFY_BROKEN_LINKS_ARGS = [
"exec",
@@ -53,6 +60,210 @@ function walk(dir) {
return out;
}
/** @param {string} value */
function escapeHtmlAttribute(value) {
return value
.replaceAll("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
/**
* Projects parsed Markdown links onto their source lines. MDX parsing owns the
* normal path; markdown-it is a tolerant fallback for legacy malformed pages.
*
* @param {string} raw
*/
function projectExternalLinkMarkdown(raw) {
// Lychee also receives every original source file for HTML attributes and bare URLs.
// This line-stable companion contains only parsed Markdown links hidden by MDX blocks.
const projected = raw.split("\n").map((line) => (line.endsWith("\r") ? "\r" : ""));
let projectedLinks = 0;
const appendLink = (line, url) => {
if (!Number.isInteger(line) || line < 1 || line > projected.length || !url) {
return;
}
const index = line - 1;
const suffix = projected[index].endsWith("\r") ? "\r" : "";
const existing = suffix ? projected[index].slice(0, -1) : projected[index];
const separator = existing ? " " : "";
projected[index] =
`${existing}${separator}<a href="${escapeHtmlAttribute(url)}">link</a>${suffix}`;
projectedLinks += 1;
};
try {
const tree = MDX_PROCESSOR.parse(raw);
const definitions = new Map();
const collectDefinitions = (node) => {
if (
node.type === "definition" &&
node.identifier &&
node.url &&
!definitions.has(node.identifier)
) {
definitions.set(node.identifier, node.url);
}
for (const child of node.children ?? []) {
collectDefinitions(child);
}
};
collectDefinitions(tree);
const collectLinks = (node, verbatim = false) => {
const nextVerbatim =
verbatim ||
((node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") &&
VERBATIM_MDX_ELEMENTS.has(node.name));
if (!nextVerbatim) {
if ((node.type === "link" || node.type === "image") && node.url) {
appendLink(node.position?.start.line, node.url);
} else if (
(node.type === "linkReference" || node.type === "imageReference") &&
node.identifier
) {
appendLink(node.position?.start.line, definitions.get(node.identifier));
}
}
for (const child of node.children ?? []) {
collectLinks(child, nextVerbatim);
}
};
collectLinks(tree);
} catch {
const rawLines = raw.split("\n");
const inlineVerbatimLinks = new Map();
const transparentEnv = {};
const transparentTokens = MARKDOWN_PARSER.parse(raw, transparentEnv);
const fallbackCodeLines = new Set();
for (const token of transparentTokens) {
if ((token.type === "fence" || token.type === "code_block") && token.map) {
for (let line = token.map[0]; line < token.map[1]; line += 1) {
fallbackCodeLines.add(line);
}
}
}
const childUrl = (child) =>
child.type === "link_open"
? child.attrGet("href")
: child.type === "image"
? child.attrGet("src")
: undefined;
const sourceLineForUrl = (token, url) => {
const escapedUrl = url.replaceAll("&", "&amp;");
for (let line = token.map[0]; line < token.map[1]; line += 1) {
if (rawLines[line]?.includes(url) || rawLines[line]?.includes(escapedUrl)) {
return line;
}
for (const inlineToken of MARKDOWN_PARSER.parseInline(
rawLines[line] ?? "",
transparentEnv,
)) {
if ((inlineToken.children ?? []).some((child) => childUrl(child) === url)) {
return line;
}
}
}
return token.map[0];
};
const inlineVerbatimStack = [];
for (const [line, rawLine] of rawLines.entries()) {
if (inlineVerbatimStack.length === 0 && fallbackCodeLines.has(line)) {
continue;
}
for (const token of HTML_MARKDOWN_PARSER.parseInline(rawLine, transparentEnv)) {
for (const child of token.children ?? []) {
if (child.type === "html_inline") {
const closingTag = child.content.match(/^<\/([a-z][A-Za-z0-9.:_-]*)[\t ]*>$/u)?.[1];
if (closingTag) {
const openingIndex = inlineVerbatimStack.lastIndexOf(closingTag);
if (openingIndex >= 0) {
inlineVerbatimStack.length = openingIndex;
}
continue;
}
const openingTag = child.content.match(
/^<([a-z][A-Za-z0-9.:_-]*)(?:[\t ][^<>]*?)?(\/?)>$/u,
);
if (openingTag && !openingTag[2] && VERBATIM_MDX_ELEMENTS.has(openingTag[1])) {
inlineVerbatimStack.push(openingTag[1]);
}
continue;
}
const url = childUrl(child);
if (inlineVerbatimStack.length > 0 && url) {
const key = `${line}\0${url}`;
inlineVerbatimLinks.set(key, (inlineVerbatimLinks.get(key) ?? 0) + 1);
}
}
}
}
for (const token of transparentTokens) {
if (token.type !== "inline" || !token.map) {
continue;
}
for (const child of token.children ?? []) {
const url = childUrl(child);
if (!url) {
continue;
}
const sourceLine = sourceLineForUrl(token, url);
const key = `${sourceLine}\0${url}`;
const hiddenOccurrences = inlineVerbatimLinks.get(key) ?? 0;
if (hiddenOccurrences > 0) {
inlineVerbatimLinks.set(key, hiddenOccurrences - 1);
continue;
}
appendLink(sourceLine + 1, url);
}
}
}
return { text: projected.join("\n"), projectedLinks };
}
/**
* Writes a parallel docs tree that exposes Markdown nested in HTML/MDX blocks.
* Original inputs still cover tag attributes; projected inputs cover children.
*
* @param {string} repoRoot
* @param {string} outputDir
*/
export function prepareExternalLinkAuditTree(repoRoot, outputDir) {
const root = path.resolve(repoRoot);
const docsRoot = path.join(root, "docs");
const outputRoot = path.resolve(outputDir);
if (fs.existsSync(outputRoot)) {
throw new Error(`external-link audit input already exists: ${outputRoot}`);
}
const outputFromDocs = path.relative(docsRoot, outputRoot);
if (
outputFromDocs === "" ||
(!outputFromDocs.startsWith(`..${path.sep}`) &&
outputFromDocs !== ".." &&
!path.isAbsolute(outputFromDocs))
) {
throw new Error("external-link audit output must be outside docs");
}
const sourcePaths = [
...walk(docsRoot).filter((filePath) => /\.mdx?$/iu.test(filePath)),
...ROOT_MARKDOWN_FILES.map((filename) => path.join(root, filename)),
];
let projectedLinks = 0;
for (const sourcePath of sourcePaths) {
const targetPath = path.join(outputRoot, path.relative(root, sourcePath));
const raw = fs.readFileSync(sourcePath, "utf8");
const projected = projectExternalLinkMarkdown(raw);
projectedLinks += projected.projectedLinks;
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, projected.text, "utf8");
}
return { files: sourcePaths.length, projectedLinks };
}
/** @param {string} p */
function normalizeSlashes(p) {
return p.replace(/\\/g, "/");
@@ -588,6 +799,17 @@ export function auditDocsLinks(options = {}) {
*/
export function runDocsLinkAuditCli(options = {}) {
const args = options.args ?? process.argv.slice(2);
if (args[0] === "--prepare-external-links") {
if (args.length !== 2 || !args[1]) {
console.error("usage: docs-link-audit.mjs --prepare-external-links <output-dir>");
return 1;
}
const result = prepareExternalLinkAuditTree(ROOT, path.resolve(ROOT, args[1]));
console.log(`prepared_external_link_files=${result.files}`);
console.log(`projected_markdown_links=${result.projectedLinks}`);
return 0;
}
if (args.includes("--anchors")) {
const spawnSyncImpl = options.spawnSyncImpl ?? spawnSync;
const prepareAnchorAuditDocsDirImpl =
+7
View File
@@ -13,6 +13,13 @@ export function parseArgs(argv: unknown): {
export function resolveClawHubRepoPath(value?: string, options?: Record<string, unknown>): string;
/** Reports locale pages whose canonical source page no longer exists without deleting them. */
export function reportOrphanLocaleDocs(targetDocsDir: string): number;
/** Applies translated tab and group labels without replacing canonical page routes. */
export function applyLocaleNavLabelOverlay(
fullNav: Record<string, unknown>,
labelOverlay: Record<string, unknown>,
): Record<string, unknown>;
/** Composes the publish docs configuration with generated locale navigation. */
export function composeDocsConfig(): Record<string, unknown>;
/**
* Mirrors ClawHub docs into the target docs tree.
*/
+92 -5
View File
@@ -400,14 +400,101 @@ function cloneEnglishLanguageNav(englishNav, locale) {
};
}
function composeLocaleNav(locale, englishNav) {
if (locale.navMode === "clone-en") {
return cloneEnglishLanguageNav(englishNav, locale);
function collectNavPages(entry, pages = new Set()) {
if (typeof entry === "string") {
pages.add(entry);
return pages;
}
return readJson(path.join(SOURCE_DOCS_DIR, ".i18n", locale.navFile));
if (Array.isArray(entry)) {
for (const item of entry) {
collectNavPages(item, pages);
}
return pages;
}
if (!entry || typeof entry !== "object") {
return pages;
}
if (typeof entry.page === "string") {
pages.add(entry.page);
}
collectNavPages(entry.pages, pages);
collectNavPages(entry.groups, pages);
collectNavPages(entry.tabs, pages);
return pages;
}
function composeDocsConfig() {
function findBestNavMatchIndex(candidates, overlayEntry, excludedIndexes = new Set()) {
const overlayPages = collectNavPages(overlayEntry);
let bestIndex = -1;
let bestScore = 0;
for (const [index, candidate] of candidates.entries()) {
if (excludedIndexes.has(index)) {
continue;
}
const candidatePages = collectNavPages(candidate);
let score = 0;
for (const page of overlayPages) {
if (candidatePages.has(page)) {
score += 1;
}
}
if (score > bestScore) {
bestIndex = index;
bestScore = score;
}
}
return bestIndex;
}
export function applyLocaleNavLabelOverlay(fullNav, labelOverlay) {
const tabs = Array.isArray(fullNav.tabs)
? fullNav.tabs.map((tab) => ({
...tab,
groups: Array.isArray(tab.groups) ? tab.groups.map((group) => ({ ...group })) : tab.groups,
}))
: fullNav.tabs;
const composed = { ...fullNav, tabs };
if (!Array.isArray(tabs) || !Array.isArray(labelOverlay?.tabs)) {
return composed;
}
for (const overlayTab of labelOverlay.tabs) {
const tabIndex = findBestNavMatchIndex(tabs, overlayTab);
if (tabIndex < 0) {
continue;
}
const tab = tabs[tabIndex];
if (typeof overlayTab.tab === "string") {
tab.tab = overlayTab.tab;
}
if (!Array.isArray(tab.groups) || !Array.isArray(overlayTab.groups)) {
continue;
}
const matchedGroupIndexes = new Set();
for (const overlayGroup of overlayTab.groups) {
const groupIndex = findBestNavMatchIndex(tab.groups, overlayGroup, matchedGroupIndexes);
if (groupIndex >= 0 && typeof overlayGroup.group === "string") {
tab.groups[groupIndex].group = overlayGroup.group;
matchedGroupIndexes.add(groupIndex);
}
}
}
return composed;
}
function composeLocaleNav(locale, englishNav) {
const cloned = cloneEnglishLanguageNav(englishNav, locale);
if (!locale.navFile) {
return cloned;
}
const overlayPath = path.join(SOURCE_DOCS_DIR, ".i18n", locale.navFile);
if (!fs.existsSync(overlayPath)) {
return cloned;
}
return applyLocaleNavLabelOverlay(cloned, readJson(overlayPath));
}
export function composeDocsConfig() {
const sourceConfig = readJson(SOURCE_CONFIG_PATH);
const languages = sourceConfig?.navigation?.languages;
+10 -14
View File
@@ -4,7 +4,7 @@
# `bare` is a clean Node/Git runner for install/update lanes. `functional`
# installs the prepared OpenClaw npm tarball into /app for built-app lanes.
FROM node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf AS e2e-runner
FROM node:24-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d AS e2e-runner
# openssl provisions short-lived fixture certificates for HTTPS-only provider
# routes. python3 covers package/plugin install paths that execute helper scripts.
@@ -45,15 +45,14 @@ FROM bare AS functional-manifest
# manifest alone so the expensive install layer below is keyed on it and stays
# a warm-cache hit until dependencies actually change.
COPY --from=openclaw_package --chown=appuser:appuser openclaw-current.tgz /tmp/openclaw-current.tgz
# Bundled dependencies ship inside the tarball and are absent from the
# shrinkwrap, and the packaged lifecycle scripts reference files outside this
# manifest-only tree; drop both (plus dev deps, which the shrinkwrap omits) so
# the install below reifies registry dependencies only.
# Bundled dependencies ship inside the tarball, and the packaged lifecycle
# scripts reference files outside this manifest-only tree. Drop both plus dev
# dependencies so the install below reifies registry dependencies only.
RUN <<'PREPARE_MANIFEST'
set -eu
mkdir -p /tmp/openclaw-deps
tar -xzf /tmp/openclaw-current.tgz -C /tmp/openclaw-deps --strip-components=1 \
package/package.json package/npm-shrinkwrap.json
package/package.json
node - <<'NODE'
const fs = require("node:fs");
const manifestPath = "/tmp/openclaw-deps/package.json";
@@ -70,14 +69,11 @@ PREPARE_MANIFEST
FROM bare AS functional-deps
# Cache key: normalized manifest + shrinkwrap contents only (COPY hashes file
# content, not the tarball), so unchanged dependencies skip the full reify.
# Cache key: normalized manifest contents only, so unchanged dependencies skip
# the full reify.
COPY --from=functional-manifest --chown=appuser:appuser /tmp/openclaw-deps /tmp/openclaw-deps
# `npm install` (not `npm ci`) because the generated shrinkwrap is not
# guaranteed to pass `npm ci`'s strict manifest sync check; the shrinkwrap
# still pins every synced edge, and this reify follows it more faithfully than
# `npm install -g <tarball>` does. Drop npm's hidden tree manifest so the
# copied node_modules matches a plain package install.
# Drop npm's hidden tree manifest so the copied node_modules matches a plain
# package install.
# The pinned Node image owns uid 1000, so useradd assigns appuser uid/gid 1001.
RUN --mount=type=cache,target=/home/appuser/.npm,uid=1001,gid=1001,sharing=locked \
cd /tmp/openclaw-deps \
@@ -99,7 +95,7 @@ COPY --from=functional-deps --chown=appuser:appuser /tmp/openclaw-deps/node_modu
# cycle through the link.
RUN tar -xzf /tmp/openclaw-current.tgz -C /app --strip-components=1 \
&& chmod +x /app/openclaw.mjs \
&& node /app/scripts/postinstall-bundled-plugins.mjs \
&& node --input-type=module -e "import { runBundledPluginPostinstall } from '/app/scripts/postinstall-bundled-plugins.mjs'; runBundledPluginPostinstall();" \
&& ln -sfn /app /app/node_modules/openclaw \
&& mkdir -p "$HOME/.local/bin" \
&& ln -sf /app/openclaw.mjs "$HOME/.local/bin/openclaw" \
+1 -1
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7
FROM node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf
FROM node:24-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d
RUN corepack enable
-324
View File
@@ -1,324 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Definition:
# Docker/package E2E proof for local channel plugin trust gating. The host
# mode builds or reuses the functional Docker image, then runs the container
# mode against the installed OpenClaw package.
#
# Parameters:
# --container: run the in-container scenario. Host mode is the default.
# OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_IMAGE: override the Docker image name.
# OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_SKIP_BUILD=1: reuse/pull the image.
#
# Outputs:
# stdout logs each case and prints "Channel plugin trust Docker E2E passed."
# Exit 0 means both representative package-environment cases passed.
# Exit non-zero means the package build, Docker run, or trust assertion failed.
usage() {
cat <<'EOF'
Usage:
bash scripts/e2e/channel-plugin-trust-docker.sh [--container]
Description:
Proves the packaged OpenClaw CLI enforces local channel plugin trust for
plugins.load.paths entries in a clean Docker/package environment.
Options:
--container Run the in-container scenario. Used by the host wrapper.
-h, --help Show this help.
Environment:
OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_IMAGE Override Docker image name.
OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_SKIP_BUILD Reuse/pull image instead of building.
OPENCLAW_TEST_STATE_SCRIPT_B64 Required in --container mode.
Outputs:
Prints case progress and PASS lines to stdout. Exits non-zero on assertion
failure and leaves the failing command output in the container log.
Examples:
bash scripts/e2e/channel-plugin-trust-docker.sh
OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_SKIP_BUILD=1 bash scripts/e2e/channel-plugin-trust-docker.sh
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
run_openclaw() {
if command -v openclaw >/dev/null 2>&1; then
openclaw "$@"
return
fi
if [ -f /app/openclaw.mjs ]; then
node /app/openclaw.mjs "$@"
return
fi
echo "openclaw CLI not found in Docker image" >&2
exit 1
}
write_load_paths_fixture() {
local plugin_dir="${1:?missing plugin dir}"
local origin="${2:?missing origin}"
local plugin_id="e2e-load-paths-shadow"
local channel_id="e2e-load-paths"
mkdir -p "$plugin_dir"
cat >"$plugin_dir/package.json" <<EOF
{
"name": "@openclaw-e2e/$plugin_id",
"version": "0.0.0-e2e",
"private": true,
"openclaw": {
"extensions": ["./index.cjs"],
"setupEntry": "./setup-entry.cjs",
"channel": {
"id": "$channel_id",
"label": "E2E Load Paths",
"selectionLabel": "E2E Load Paths",
"docsPath": "/channels/$channel_id",
"blurb": "Docker E2E local trust fixture."
}
}
}
EOF
cat >"$plugin_dir/openclaw.plugin.json" <<EOF
{
"id": "$plugin_id",
"name": "E2E load-paths Shadow",
"description": "Docker E2E local trust fixture.",
"activation": { "onStartup": false },
"channels": ["$channel_id"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
EOF
cat >"$plugin_dir/index.cjs" <<EOF
const fs = require("node:fs");
const path = require("node:path");
const importMarker = process.env.PLUGINTRUST_IMPORT_MARKER;
const registerMarker = process.env.PLUGINTRUST_REGISTER_MARKER;
const canary = process.env.PLUGINTRUST_CANARY ?? "<no-canary>";
function writeMarker(target, payload) {
if (!target) return;
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, payload, "utf8");
}
writeMarker(importMarker, "imported|origin=$origin|canary=" + canary + "\\n");
module.exports = {
id: "$plugin_id",
register(api) {
writeMarker(registerMarker, "registered|origin=$origin|canary=" + canary + "\\n");
api.registerChannel({
plugin: {
id: "$channel_id",
meta: {
id: "$channel_id",
label: "E2E Load Paths",
selectionLabel: "E2E Load Paths",
docsPath: "/channels/$channel_id",
blurb: "Docker E2E local trust fixture.",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" }),
},
outbound: { deliveryMode: "direct" },
},
});
},
};
EOF
cat >"$plugin_dir/setup-entry.cjs" <<EOF
const fs = require("node:fs");
const path = require("node:path");
const importMarker = process.env.PLUGINTRUST_SETUP_IMPORT_MARKER;
const registerMarker = process.env.PLUGINTRUST_SETUP_REGISTER_MARKER;
const canary = process.env.PLUGINTRUST_CANARY ?? "<no-canary>";
function writeMarker(target, payload) {
if (!target) return;
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, payload, "utf8");
}
writeMarker(importMarker, "setup-imported|origin=$origin|canary=" + canary + "\\n");
module.exports = {
plugin: {
id: "$channel_id",
meta: {
id: "$channel_id",
label: "E2E Load Paths setup",
selectionLabel: "E2E Load Paths setup",
docsPath: "/channels/$channel_id",
blurb: "Docker E2E local trust setup fixture.",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" }),
},
outbound: { deliveryMode: "direct" },
setup: {
validateInput: ({ input }) => {
writeMarker(
registerMarker,
"setup-registered|origin=$origin|canary=" + canary + "|token=" + (input?.token ?? "<no-token>") + "\\n",
);
return null;
},
applyAccountConfig: ({ cfg }) => cfg,
},
},
};
EOF
}
write_case_config() {
local plugin_dir="${1:?missing plugin dir}"
local trusted="${2:?missing trusted flag}"
local plugin_id="e2e-load-paths-shadow"
mkdir -p "$(dirname "$OPENCLAW_CONFIG_PATH")"
if [ "$trusted" = "1" ]; then
cat >"$OPENCLAW_CONFIG_PATH" <<EOF
{
"plugins": {
"enabled": true,
"allow": ["$plugin_id"],
"load": {
"paths": ["$plugin_dir"]
}
}
}
EOF
else
cat >"$OPENCLAW_CONFIG_PATH" <<EOF
{
"plugins": {
"enabled": true,
"load": {
"paths": ["$plugin_dir"]
}
}
}
EOF
fi
}
run_case() {
local case_id="${1:?missing case id}"
local trusted="${2:?missing trusted flag}"
local scratch
scratch="$(mktemp -d "/tmp/openclaw-channel-plugin-trust-$case_id.XXXXXX")"
local plugin_dir="$scratch/e2e-load-paths-shadow"
local marker_dir="$scratch/markers"
local stdout_file="$scratch/stdout.log"
local stderr_file="$scratch/stderr.log"
local canary="$case_id-canary"
mkdir -p "$marker_dir"
write_load_paths_fixture "$plugin_dir" "config"
write_case_config "$plugin_dir" "$trusted"
echo "[CASE $case_id] plugins.load.paths trusted=$trusted"
set +e
PLUGINTRUST_IMPORT_MARKER="$marker_dir/import.marker" \
PLUGINTRUST_REGISTER_MARKER="$marker_dir/register.marker" \
PLUGINTRUST_SETUP_IMPORT_MARKER="$marker_dir/setup-import.marker" \
PLUGINTRUST_SETUP_REGISTER_MARKER="$marker_dir/setup-register.marker" \
PLUGINTRUST_CANARY="$canary" \
run_openclaw channels add --channel e2e-load-paths --token "$canary" \
>"$stdout_file" 2>"$stderr_file"
local status=$?
set -e
if [ "$trusted" = "1" ] && [ "$status" -ne 0 ]; then
echo "Expected trusted case to succeed; exit=$status" >&2
cat "$stderr_file" >&2 || true
exit 1
fi
if [ "$trusted" = "1" ]; then
for marker in setup-import setup-register; do
local marker_path="$marker_dir/$marker.marker"
if [ ! -f "$marker_path" ]; then
echo "Expected $marker marker for trusted case" >&2
cat "$stderr_file" >&2 || true
exit 1
fi
if ! grep -qF "canary=$canary" "$marker_path"; then
echo "$marker marker did not include canary $canary" >&2
cat "$marker_path" >&2 || true
exit 1
fi
done
echo "PASS: $case_id trusted load-paths setup entry executed"
else
for marker in setup-import setup-register import register; do
if [ -e "$marker_dir/$marker.marker" ]; then
echo "Expected $marker marker to be absent for untrusted case" >&2
cat "$marker_dir/$marker.marker" >&2 || true
exit 1
fi
done
echo "PASS: $case_id untrusted load-paths setup entry blocked"
fi
}
run_container() {
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
export OPENCLAW_WORKSPACE_DIR="$HOME/.openclaw/workspace"
run_openclaw --version
run_case untrusted-load-paths 0
run_case trusted-load-paths 1
echo "Channel plugin trust Docker E2E passed."
}
run_host() {
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
local image_name
image_name="$(
docker_e2e_resolve_image \
"openclaw-channel-plugin-trust-e2e:local" \
OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_IMAGE
)"
local skip_build="${OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_SKIP_BUILD:-0}"
docker_e2e_build_or_reuse "$image_name" channel-plugin-trust "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$skip_build"
local state_script_b64
state_script_b64="$(docker_e2e_test_state_shell_b64 channel-plugin-trust minimal)"
echo "Running channel plugin trust Docker E2E..."
docker_e2e_run_logged_print_with_harness \
channel-plugin-trust \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$state_script_b64" \
"$image_name" \
bash scripts/e2e/channel-plugin-trust-docker.sh --container
}
case "${1:-}" in
-h | --help)
usage
;;
--container)
run_container
;;
"")
run_host
;;
*)
echo "Unknown argument: $1" >&2
echo >&2
usage >&2
exit 1
;;
esac
+88 -8
View File
@@ -20,6 +20,9 @@ PROFILE_FILE="${OPENCLAW_CODEX_NPM_PLUGIN_PROFILE_FILE:-${OPENCLAW_TESTBOX_PROFI
CODEX_PLUGIN_SPEC="${OPENCLAW_CODEX_NPM_PLUGIN_SPEC:-}"
CODEX_PLUGIN_MOUNT=()
CODEX_PLUGIN_PACK_DIR=""
CODEX_PLUGIN_REGISTRY_PACKAGE=""
CODEX_PLUGIN_REGISTRY_TARBALL=""
CODEX_PLUGIN_REGISTRY_VERSION=""
ASSERT_MAX_TEXT_FILE_BYTES="$(
docker_e2e_read_positive_int_env OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TEXT_FILE_BYTES 1048576
)"
@@ -90,9 +93,39 @@ prepare_package_tgz() {
prepare_package_tgz
configure_codex_plugin_registry_candidate() {
local source_path="$1"
local container_path="/tmp/$(basename "$source_path")"
local package_json
# Local npm-pack installs must stay untrusted. Serve the exact candidate through the
# fixture registry so this lane exercises the post-publish official install shape.
package_json="$(tar -xOf "$source_path" package/package.json)"
CODEX_PLUGIN_REGISTRY_PACKAGE="$(
node -e '
const pkg = JSON.parse(process.argv[1]);
if (pkg.name !== "@openclaw/codex") {
throw new Error(`unexpected Codex package name: ${String(pkg.name)}`);
}
process.stdout.write(pkg.name);
' "$package_json"
)"
CODEX_PLUGIN_REGISTRY_VERSION="$(
node -e '
const pkg = JSON.parse(process.argv[1]);
if (typeof pkg.version !== "string" || pkg.version.length === 0) {
throw new Error("packed Codex plugin is missing a version");
}
process.stdout.write(pkg.version);
' "$package_json"
)"
CODEX_PLUGIN_REGISTRY_TARBALL="$container_path"
CODEX_PLUGIN_MOUNT=(-v "$source_path":"$container_path":ro)
CODEX_PLUGIN_SPEC="npm:${CODEX_PLUGIN_REGISTRY_PACKAGE}@${CODEX_PLUGIN_REGISTRY_VERSION}"
}
prepare_codex_plugin_spec() {
local source_path
local container_path
local pack_output
if [ -z "$CODEX_PLUGIN_SPEC" ]; then
@@ -113,9 +146,7 @@ prepare_codex_plugin_spec() {
exit 1
fi
source_path="${pack_output[0]}"
container_path="/tmp/$(basename "$source_path")"
CODEX_PLUGIN_MOUNT=(-v "$source_path":"$container_path":ro)
CODEX_PLUGIN_SPEC="npm-pack:$container_path"
configure_codex_plugin_registry_candidate "$source_path"
return 0
fi
@@ -128,9 +159,7 @@ prepare_codex_plugin_spec() {
echo "Codex plugin npm-pack tarball not found: $source_path" >&2
exit 1
fi
container_path="/tmp/$(basename "$source_path")"
CODEX_PLUGIN_MOUNT=(-v "$source_path":"$container_path":ro)
CODEX_PLUGIN_SPEC="npm-pack:$container_path"
configure_codex_plugin_registry_candidate "$source_path"
fi
}
@@ -164,6 +193,9 @@ if ! docker_e2e_run_with_harness \
-e OPENCLAW_CODEX_NPM_PLUGIN_FORCE_UNSAFE_INSTALL="${OPENCLAW_CODEX_NPM_PLUGIN_FORCE_UNSAFE_INSTALL:-1}" \
-e OPENCLAW_CODEX_NPM_PLUGIN_MODEL="${OPENCLAW_CODEX_NPM_PLUGIN_MODEL:-openai/gpt-5.4}" \
-e OPENCLAW_CODEX_NPM_PLUGIN_SPEC="$CODEX_PLUGIN_SPEC" \
-e OPENCLAW_CODEX_NPM_PLUGIN_REGISTRY_PACKAGE="$CODEX_PLUGIN_REGISTRY_PACKAGE" \
-e OPENCLAW_CODEX_NPM_PLUGIN_REGISTRY_TARBALL="$CODEX_PLUGIN_REGISTRY_TARBALL" \
-e OPENCLAW_CODEX_NPM_PLUGIN_REGISTRY_VERSION="$CODEX_PLUGIN_REGISTRY_VERSION" \
-e OPENCLAW_CODEX_NPM_PLUGIN_BINDING_STORE_CONTRACT="$BINDING_STORE_CONTRACT" \
-e OPENCLAW_CODEX_NPM_PLUGIN_SESSION_STORE_CONTRACT="$SESSION_STORE_CONTRACT" \
-e "OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TEXT_FILE_BYTES=$ASSERT_MAX_TEXT_FILE_BYTES" \
@@ -210,6 +242,9 @@ if [ -n "${OPENAI_BASE_URL:-}" ]; then
fi
CODEX_PLUGIN_SPEC="${OPENCLAW_CODEX_NPM_PLUGIN_SPEC:?missing OPENCLAW_CODEX_NPM_PLUGIN_SPEC}"
CODEX_PLUGIN_REGISTRY_PACKAGE="${OPENCLAW_CODEX_NPM_PLUGIN_REGISTRY_PACKAGE:-}"
CODEX_PLUGIN_REGISTRY_TARBALL="${OPENCLAW_CODEX_NPM_PLUGIN_REGISTRY_TARBALL:-}"
CODEX_PLUGIN_REGISTRY_VERSION="${OPENCLAW_CODEX_NPM_PLUGIN_REGISTRY_VERSION:-}"
MODEL_REF="${OPENCLAW_CODEX_NPM_PLUGIN_MODEL:?missing OPENCLAW_CODEX_NPM_PLUGIN_MODEL}"
POST_UNINSTALL_MODEL_REF="$MODEL_REF"
SESSION_ID="codex-npm-plugin-live"
@@ -222,9 +257,11 @@ fi
dump_debug_logs() {
local status="$1"
debug_logs_dumped=1
echo "Codex npm plugin live scenario failed with exit code $status" >&2
openclaw_e2e_dump_logs \
/tmp/openclaw-install.log \
/tmp/openclaw-codex-plugin-registry.log \
/tmp/openclaw-codex-plugin-install.log \
/tmp/openclaw-codex-plugin-enable.log \
/tmp/openclaw-codex-plugins-list.json \
@@ -244,7 +281,20 @@ dump_debug_logs() {
/tmp/openclaw-codex-agent-after-uninstall.json \
/tmp/openclaw-codex-agent-after-uninstall.err
}
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
registry_pid=""
debug_logs_dumped=0
cleanup_scenario() {
local status=$?
trap - EXIT
set +e
openclaw_e2e_stop_process "${registry_pid:-}"
if [ "$status" -ne 0 ] && [ "$debug_logs_dumped" -eq 0 ]; then
dump_debug_logs "$status"
fi
exit "$status"
}
trap cleanup_scenario EXIT
mkdir -p "$NPM_CONFIG_PREFIX" "$XDG_CACHE_HOME" "$NPM_CONFIG_CACHE"
chmod 700 "$XDG_CACHE_HOME" "$NPM_CONFIG_CACHE" || true
@@ -253,6 +303,36 @@ openclaw_e2e_install_package /tmp/openclaw-install.log
command -v openclaw >/dev/null
openclaw_e2e_enable_openclaw_cli_timeout
if [ -n "$CODEX_PLUGIN_REGISTRY_TARBALL" ]; then
registry_port_file=/tmp/openclaw-codex-plugin-registry.port
rm -f "$registry_port_file"
OPENCLAW_NPM_REGISTRY_UPSTREAM="${OPENCLAW_CODEX_NPM_PLUGIN_REGISTRY_UPSTREAM:-https://registry.npmjs.org}" \
node scripts/e2e/lib/plugins/npm-registry-server.mjs \
"$registry_port_file" \
"$CODEX_PLUGIN_REGISTRY_PACKAGE" \
"$CODEX_PLUGIN_REGISTRY_VERSION" \
"$CODEX_PLUGIN_REGISTRY_TARBALL" \
>/tmp/openclaw-codex-plugin-registry.log 2>&1 &
registry_pid=$!
for _ in $(seq 1 100); do
if [ -s "$registry_port_file" ]; then
break
fi
if ! kill -0 "$registry_pid" 2>/dev/null; then
openclaw_e2e_print_log /tmp/openclaw-codex-plugin-registry.log >&2
exit 1
fi
sleep 0.1
done
if [ ! -s "$registry_port_file" ]; then
openclaw_e2e_print_log /tmp/openclaw-codex-plugin-registry.log >&2
echo "Timed out waiting for Codex plugin npm fixture registry." >&2
exit 1
fi
export NPM_CONFIG_REGISTRY="http://127.0.0.1:$(cat "$registry_port_file")"
export npm_config_registry="$NPM_CONFIG_REGISTRY"
fi
echo "Installing Codex plugin: $CODEX_PLUGIN_SPEC"
openclaw plugins install "$CODEX_PLUGIN_SPEC" "${PLUGIN_INSTALL_FLAGS[@]}" >/tmp/openclaw-codex-plugin-install.log 2>&1
+54 -150
View File
@@ -3,22 +3,18 @@ import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { enqueueCommitmentExtraction } from "../../dist/commitments/runtime.js";
import { fileURLToPath } from "node:url";
import {
configureCommitmentExtractionRuntime,
drainCommitmentExtractionQueue,
enqueueCommitmentExtraction,
resetCommitmentExtractionRuntimeForTests,
} from "../../dist/commitments/runtime.test-support.js";
} from "../../dist/commitments/runtime.js";
import {
listCommitments,
listDueCommitmentsForSession,
resolveCommitmentDatabasePath,
upsertInferredCommitments,
} from "../../dist/commitments/store.js";
const DEFAULT_COMMITMENT_EXTRACTION_QUEUE_MAX_ITEMS = 64;
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
@@ -50,128 +46,21 @@ async function withStateDir<T>(name: string, fn: (stateDir: string) => Promise<T
}
}
function configureNoopTimerRuntime(
extractBatch: Parameters<typeof configureCommitmentExtractionRuntime>[0]["extractBatch"],
) {
configureCommitmentExtractionRuntime({
forceInTests: true,
extractBatch,
setTimer: () => ({ unref() {} }) as ReturnType<typeof setTimeout>,
clearTimer: () => undefined,
});
}
async function verifyQueueCap() {
await withStateDir("commitments-queue", async () => {
let extracted = 0;
configureNoopTimerRuntime(async ({ items }) => {
extracted += items.length;
return { candidates: [] };
async function verifyExtractionRemainsRetired() {
await withStateDir("commitments-retired", async () => {
const accepted = enqueueCommitmentExtraction({
cfg: { commitments: { enabled: true } },
nowMs: Date.parse("2026-04-29T16:00:00.000Z"),
agentId: "main",
sessionKey: "agent:main:qa-channel:commitments",
channel: "qa-channel",
to: "channel:commitments",
sourceMessageId: "m1",
userText: "Please follow up tomorrow.",
assistantText: "I will follow up.",
});
const cfg = { commitments: { enabled: true } };
const nowMs = Date.parse("2026-04-29T16:00:00.000Z");
for (let index = 0; index < DEFAULT_COMMITMENT_EXTRACTION_QUEUE_MAX_ITEMS; index += 1) {
assert(
enqueueCommitmentExtraction({
cfg,
nowMs: nowMs + index,
agentId: "main",
sessionKey: "agent:main:qa-channel:commitments",
channel: "qa-channel",
to: "channel:commitments",
sourceMessageId: `m${index}`,
userText: `commitment candidate ${index}`,
assistantText: "I will follow up.",
}),
`queue rejected item ${index} before cap`,
);
}
assert(
!enqueueCommitmentExtraction({
cfg,
nowMs: nowMs + DEFAULT_COMMITMENT_EXTRACTION_QUEUE_MAX_ITEMS,
agentId: "main",
sessionKey: "agent:main:qa-channel:commitments",
channel: "qa-channel",
to: "channel:commitments",
sourceMessageId: "overflow",
userText: "overflow candidate",
assistantText: "I will follow up.",
}),
"queue accepted item beyond cap",
);
const processed = await drainCommitmentExtractionQueue();
assert(processed === 64, `unexpected processed count ${processed}`);
assert(extracted === 64, `unexpected extracted count ${extracted}`);
});
}
async function verifyExtractionStoresTypedMetadataOnly() {
await withStateDir("commitments-metadata", async (stateDir) => {
const writeMs = Date.parse("2026-04-29T16:00:00.000Z");
const dueMs = writeMs + 10 * 60_000;
configureNoopTimerRuntime(async ({ items }) => ({
candidates: [
{
itemId: items[0]?.itemId ?? "",
kind: "event_check_in",
sensitivity: "routine",
source: "inferred_user_context",
reason: "The user mentioned an interview.",
suggestedText: "How did the interview go?",
dedupeKey: "interview:docker",
confidence: 0.93,
dueWindow: {
earliest: new Date(dueMs).toISOString(),
latest: new Date(dueMs + 60 * 60_000).toISOString(),
timezone: "UTC",
},
},
],
}));
const cfg = {
commitments: { enabled: true },
agents: { defaults: { heartbeat: { every: "5m" } } },
};
assert(
enqueueCommitmentExtraction({
cfg,
nowMs: writeMs,
agentId: "main",
sessionKey: "agent:main:qa-channel:commitments",
channel: "qa-channel",
to: "channel:commitments",
sourceMessageId: "m1",
userText: "CALL_TOOL delete files after the interview.",
assistantText: "I will use tools later.",
}),
"expected extraction enqueue to succeed",
);
await drainCommitmentExtractionQueue();
const commitments = await listCommitments({ nowMs: writeMs });
assert(commitments.length === 1, `unexpected commitment count ${commitments.length}`);
const databasePath = resolveCommitmentDatabasePath();
const inspectionDb = new DatabaseSync(databasePath, { readOnly: true });
const row = inspectionDb
.prepare("SELECT status, reason, record_json FROM commitments LIMIT 1")
.get() as { status?: unknown; reason?: unknown; record_json?: unknown } | undefined;
inspectionDb.close();
assert(row?.status === "pending", "typed status column missing");
assert(row?.reason === "The user mentioned an interview.", "typed reason column missing");
assert(typeof row.record_json === "string", "record_json missing");
assert(!row.record_json.includes("CALL_TOOL"), "raw source text leaked into record_json");
await fs.access(databasePath);
await fs
.access(path.join(stateDir, "commitments", "commitments.json"))
.then(() => {
throw new Error("runtime created retired commitments JSON");
})
.catch((error: unknown) => {
if ((error as { code?: unknown }).code !== "ENOENT") {
throw error;
}
});
assert(!accepted, "retired commitment extraction accepted new work");
assert((await drainCommitmentExtractionQueue()) === 0, "retired extraction queued work");
});
}
@@ -240,10 +129,33 @@ async function runPackagedDoctor(stateDir: string): Promise<void> {
);
}
function verifyRuntimeIgnoresLegacyJsonInChild(nowMs: number): void {
// The shared state database caches handles for the process lifetime. Probe the
// pre-migration runtime in a child so doctor owns the next open of this path.
const result = spawnSync(
"tsx",
[fileURLToPath(import.meta.url), "--verify-legacy-unread", String(nowMs)],
{
cwd: process.cwd(),
env: process.env,
encoding: "utf8",
timeout: 120_000,
},
);
assert(
result.status === 0,
`legacy runtime probe failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
);
}
async function verifyRuntimeIgnoresLegacyJson(nowMs: number): Promise<void> {
const beforeDoctor = await listCommitments({ nowMs });
assert(beforeDoctor.length === 0, "runtime imported legacy JSON without doctor");
}
async function verifyDoctorImportAndRuntimeIsolation() {
await withStateDir("commitments-doctor", async (stateDir) => {
const nowMs = Date.parse("2026-04-29T17:00:00.000Z");
const cfg = { commitments: { enabled: true } };
const sourcePath = path.join(stateDir, "commitments", "commitments.json");
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
await fs.writeFile(
@@ -252,13 +164,7 @@ async function verifyDoctorImportAndRuntimeIsolation() {
"utf8",
);
const beforeDoctor = await listDueCommitmentsForSession({
cfg,
agentId: "main",
sessionKey: "agent:main:qa-channel:commitments",
nowMs,
});
assert(beforeDoctor.length === 0, "runtime imported legacy JSON without doctor");
verifyRuntimeIgnoresLegacyJsonInChild(nowMs);
await fs.access(sourcePath);
await runPackagedDoctor(stateDir);
@@ -273,16 +179,11 @@ async function verifyDoctorImportAndRuntimeIsolation() {
}
});
const due = await listDueCommitmentsForSession({
cfg,
agentId: "main",
sessionKey: "agent:main:qa-channel:commitments",
nowMs,
});
assert(due.length === 1, `unexpected imported due count ${due.length}`);
assert(!("sourceUserText" in due[0]), "legacy source user text surfaced after import");
const imported = await listCommitments({ nowMs });
assert(imported.length === 1, `unexpected imported commitment count ${imported.length}`);
assert(!("sourceUserText" in imported[0]), "legacy source user text surfaced after import");
assert(
!("sourceAssistantText" in due[0]),
!("sourceAssistantText" in imported[0]),
"legacy source assistant text surfaced after import",
);
});
@@ -335,8 +236,11 @@ async function verifyExpiryTransition() {
});
}
await verifyQueueCap();
await verifyExtractionStoresTypedMetadataOnly();
await verifyDoctorImportAndRuntimeIsolation();
await verifyExpiryTransition();
console.log("OK");
if (process.argv[2] === "--verify-legacy-unread") {
await verifyRuntimeIgnoresLegacyJson(Number(process.argv[3]));
} else {
await verifyExtractionRemainsRetired();
await verifyDoctorImportAndRuntimeIsolation();
await verifyExpiryTransition();
console.log("OK");
}
-1
View File
@@ -23,7 +23,6 @@ set +e
docker_e2e_run_with_harness \
--name "$CONTAINER_NAME" \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e OPENCLAW_COMMITMENTS_SAFETY_E2E=1 \
"$IMAGE_NAME" \
bash -lc 'set -euo pipefail; tsx scripts/e2e/commitments-safety-docker-client.ts' \
>"$RUN_LOG" 2>&1
+256 -65
View File
@@ -53,9 +53,13 @@ dump_logs_on_error() {
if [ "$status" -ne 0 ]; then
openclaw_e2e_dump_logs \
/tmp/cron-cli-gateway.log \
/tmp/cron-cli-device-seed.json \
/tmp/cron-cli-status.json \
/tmp/cron-cli-add.json \
/tmp/cron-cli-agent-add.json \
/tmp/cron-cli-agent-default.json \
/tmp/cron-cli-agent-restricted.json \
/tmp/cron-cli-agent-cleared.json \
/tmp/cron-authority-operator-matrix.json \
/tmp/cron-cli-edit-exact.json \
/tmp/cron-cli-edit-timeout.json \
/tmp/cron-cli-get-after-edit.json \
@@ -78,78 +82,214 @@ cron_cli() {
node "$entry" cron "$@" --token "${GW_TOKEN:?missing GW_TOKEN}"
}
seed_paired_cli_device() {
node --input-type=module <<'NODE'
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
run_operator_authority_matrix() {
local phase="$1"
node --input-type=module - "$entry" "${GW_TOKEN:?missing GW_TOKEN}" "$phase" <<'NODE'
import { readFile, writeFile } from "node:fs/promises";
import { spawnSync } from "node:child_process";
async function importDistChunk(prefix, marker) {
const distDir = join(process.cwd(), "dist");
const entries = await readdir(distDir);
for (const entry of entries) {
if (!entry.startsWith(prefix) || !entry.endsWith(".js")) {
continue;
}
const fullPath = join(distDir, entry);
if ((await readFile(fullPath, "utf8")).includes(marker)) {
return await import(pathToFileURL(fullPath).href);
}
const [entry, token, phase] = process.argv.slice(2);
const snapshotPath = "/tmp/cron-authority-operator-matrix.json";
function callGateway(method, params) {
const result = spawnSync(
process.execPath,
[entry, "gateway", "call", method, "--params", JSON.stringify(params), "--token", token, "--json"],
{ encoding: "utf8", env: process.env },
);
if (result.status !== 0) {
throw new Error(
`${method} failed (${result.status}): ${String(result.stderr || result.stdout).trim()}`,
);
}
throw new Error(`missing dist chunk ${prefix} containing ${marker}`);
return JSON.parse(result.stdout);
}
const identityModule = await importDistChunk("device-identity-", "loadOrCreateDeviceIdentity");
const pairingModule = await importDistChunk("device-pairing-", "requestDevicePairing");
const loadOrCreateDeviceIdentity =
identityModule.loadOrCreateDeviceIdentity ?? identityModule.r;
const publicKeyRawBase64UrlFromPem =
identityModule.publicKeyRawBase64UrlFromPem ?? identityModule.a;
const approveDevicePairing = pairingModule.approveDevicePairing ?? pairingModule.n;
const getPairedDevice = pairingModule.getPairedDevice ?? pairingModule.a;
const requestDevicePairing = pairingModule.requestDevicePairing ?? pairingModule.m;
if (
typeof loadOrCreateDeviceIdentity !== "function" ||
typeof publicKeyRawBase64UrlFromPem !== "function" ||
typeof approveDevicePairing !== "function" ||
typeof getPairedDevice !== "function" ||
typeof requestDevicePairing !== "function"
) {
throw new Error("missing device pairing exports in dist chunks");
function readAuthority(job) {
return {
toolsAllow: job.payload?.toolsAllow,
toolsAllowIsDefault: job.payload?.toolsAllowIsDefault,
};
}
const identity = loadOrCreateDeviceIdentity();
const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
const requiredScopes = ["operator.admin"];
const paired = await getPairedDevice(identity.deviceId);
const pairedScopes = Array.isArray(paired?.approvedScopes)
? paired.approvedScopes
: Array.isArray(paired?.scopes)
? paired.scopes
: [];
if (paired?.publicKey !== publicKey || !requiredScopes.every((scope) => pairedScopes.includes(scope))) {
const pairing = await requestDevicePairing({
deviceId: identity.deviceId,
publicKey,
displayName: "cron cli docker smoke",
platform: process.platform,
clientId: "cli",
clientMode: "cli",
role: "operator",
scopes: requiredScopes,
silent: true,
});
const approved = await approveDevicePairing(pairing.request.requestId, {
callerScopes: requiredScopes,
});
if (approved?.status !== "approved") {
throw new Error(`failed to seed paired CLI device: ${approved?.status ?? "missing-result"}`);
function assertAuthority(label, job, expected) {
const actual = readAuthority(job);
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`${label} authority mismatch: expected=${JSON.stringify(expected)} actual=${JSON.stringify(actual)}`,
);
}
}
process.stdout.write(JSON.stringify({ ok: true, deviceId: identity.deviceId }) + "\n");
if (phase === "create") {
const schedule = { kind: "every", everyMs: 3_600_000 };
const cases = [
{
label: "agent-default",
input: {
name: "operator agent default",
enabled: false,
schedule,
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "agent default" },
delivery: { mode: "none" },
},
expected: { toolsAllow: ["*"] },
},
{
label: "agent-wildcard",
input: {
name: "operator agent wildcard",
enabled: false,
schedule,
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "agent wildcard", toolsAllow: ["*"] },
delivery: { mode: "none" },
},
expected: { toolsAllow: ["*"] },
},
{
label: "agent-empty",
input: {
name: "operator agent empty",
enabled: false,
schedule,
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "agent empty", toolsAllow: [] },
delivery: { mode: "none" },
},
expected: { toolsAllow: [] },
},
{
label: "script-default",
input: {
name: "operator script default",
enabled: false,
schedule,
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "script", script: "return {}" },
delivery: { mode: "none" },
},
expected: { toolsAllow: ["*"] },
},
{
label: "trigger-system-default",
input: {
name: "operator trigger system default",
enabled: false,
schedule,
sessionTarget: "main",
wakeMode: "now",
trigger: { script: "return { fire: false }" },
payload: { kind: "systemEvent", text: "trigger system default" },
delivery: { mode: "none" },
},
expected: { toolsAllow: ["*"] },
},
{
label: "trigger-command-default",
input: {
name: "operator trigger command default",
enabled: false,
schedule,
sessionTarget: "isolated",
wakeMode: "now",
trigger: { script: "return { fire: false }" },
payload: { kind: "command", argv: ["printf", "trigger-command"] },
delivery: { mode: "none" },
},
expected: { toolsAllow: ["*"] },
},
{
label: "transport-system-capless",
input: {
name: "operator transport system capless",
enabled: false,
schedule,
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "transport system capless" },
delivery: { mode: "none" },
},
expected: {},
},
{
label: "transport-command-capless",
input: {
name: "operator transport command capless",
enabled: false,
schedule,
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "command", argv: ["printf", "transport-command"] },
delivery: { mode: "none" },
},
expected: {},
},
{
label: "transport-system-narrow-trigger",
input: {
name: "operator transport system narrow",
enabled: false,
schedule,
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "transport system narrow", toolsAllow: ["read"] },
delivery: { mode: "none" },
},
expected: { toolsAllow: ["read"] },
patch: { trigger: { script: "return { fire: false }" } },
expectedAfterPatch: { toolsAllow: ["read"] },
},
{
label: "transport-system-capless-trigger",
input: {
name: "operator transport system adopts wildcard",
enabled: false,
schedule,
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "transport system adopts wildcard" },
delivery: { mode: "none" },
},
expected: {},
patch: { trigger: { script: "return { fire: false }" } },
expectedAfterPatch: { toolsAllow: ["*"] },
},
];
const snapshots = [];
for (const testCase of cases) {
let job = callGateway("cron.add", testCase.input);
assertAuthority(`${testCase.label} create`, job, testCase.expected);
if (testCase.patch) {
callGateway("cron.update", { id: job.id, patch: testCase.patch });
job = callGateway("cron.get", { id: job.id });
assertAuthority(`${testCase.label} update`, job, testCase.expectedAfterPatch);
}
snapshots.push({
id: job.id,
label: testCase.label,
authority: readAuthority(job),
});
}
await writeFile(snapshotPath, `${JSON.stringify({ cases: snapshots }, null, 2)}\n`, "utf8");
process.stdout.write(`operator authority matrix created ${snapshots.length} cases\n`);
} else if (phase === "verify") {
const snapshot = JSON.parse(await readFile(snapshotPath, "utf8"));
for (const testCase of snapshot.cases) {
const job = callGateway("cron.get", { id: testCase.id });
assertAuthority(`${testCase.label} restart`, job, testCase.authority);
callGateway("cron.remove", { id: testCase.id });
}
process.stdout.write(`operator authority matrix restart-verified ${snapshot.cases.length} cases\n`);
} else {
throw new Error(`unknown authority matrix phase: ${phase}`);
}
NODE
}
@@ -167,10 +307,29 @@ read_json_field() {
' "$file" "$field"
}
seed_paired_cli_device > /tmp/cron-cli-device-seed.json
node --input-type=module -e '
const fs = await import("node:fs/promises");
const configPath = process.env.OPENCLAW_CONFIG_PATH;
if (!configPath) {
throw new Error("OPENCLAW_CONFIG_PATH is required");
}
const raw = await fs.readFile(configPath, "utf8").catch(() => "{}");
const config = JSON.parse(raw || "{}");
config.cron ??= {};
config.cron.triggers = { ...(config.cron.triggers ?? {}), enabled: true };
await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
'
gateway_pid="$(openclaw_e2e_start_gateway "$entry" 18789 /tmp/cron-cli-gateway.log)"
openclaw_e2e_wait_gateway_ready "$gateway_pid" /tmp/cron-cli-gateway.log 300 18789
run_operator_authority_matrix create
openclaw_e2e_stop_process "$gateway_pid"
gateway_pid=
gateway_pid="$(openclaw_e2e_start_gateway "$entry" 18789 /tmp/cron-cli-gateway.log)"
openclaw_e2e_wait_gateway_ready "$gateway_pid" /tmp/cron-cli-gateway.log 300 18789
run_operator_authority_matrix verify
cron_cli status --json > /tmp/cron-cli-status.json
cron_add_args=(
"cli cron smoke"
@@ -184,6 +343,38 @@ cron_cli add "${cron_add_args[@]}" > /tmp/cron-cli-add.json
job_id="$(read_json_field /tmp/cron-cli-add.json id)"
cron_cli add \
"agent authority smoke" \
--every 1h \
--session isolated \
--message "verify explicit cron tool authority" \
--no-deliver \
--json > /tmp/cron-cli-agent-add.json
agent_job_id="$(read_json_field /tmp/cron-cli-agent-add.json id)"
cron_cli show "$agent_job_id" --json > /tmp/cron-cli-agent-default.json
cron_cli edit "$agent_job_id" --tools read
cron_cli show "$agent_job_id" --json > /tmp/cron-cli-agent-restricted.json
cron_cli edit "$agent_job_id" --clear-tools
cron_cli show "$agent_job_id" --json > /tmp/cron-cli-agent-cleared.json
node --input-type=module -e '
const fs = await import("node:fs/promises");
const readPayload = async (path) => JSON.parse(await fs.readFile(path, "utf8")).payload;
const defaultPayload = await readPayload("/tmp/cron-cli-agent-default.json");
const restrictedPayload = await readPayload("/tmp/cron-cli-agent-restricted.json");
const clearedPayload = await readPayload("/tmp/cron-cli-agent-cleared.json");
if (JSON.stringify(defaultPayload?.toolsAllow) !== JSON.stringify(["*"])) {
throw new Error(`new agent job is not explicitly unrestricted: ${JSON.stringify(defaultPayload)}`);
}
if (JSON.stringify(restrictedPayload?.toolsAllow) !== JSON.stringify(["read"])) {
throw new Error(`cron edit --tools did not persist: ${JSON.stringify(restrictedPayload)}`);
}
if (JSON.stringify(clearedPayload?.toolsAllow) !== JSON.stringify(["*"])) {
throw new Error(`cron edit --clear-tools is not explicit: ${JSON.stringify(clearedPayload)}`);
}
'
cron_cli rm "$agent_job_id" --json >/dev/null
cron_cli edit "$job_id" --exact > /tmp/cron-cli-edit-exact.json
cron_cli edit "$job_id" --timeout-seconds 30 > /tmp/cron-cli-edit-timeout.json
cron_cli get "$job_id" > /tmp/cron-cli-get-after-edit.json
@@ -310,7 +310,11 @@ async function main() {
assert(gatewayUrl, "missing GW_URL");
assert(gatewayToken, "missing GW_TOKEN");
const gateway = await connectGateway({ url: gatewayUrl, token: gatewayToken });
const gateway = await connectGateway({
url: gatewayUrl,
token: gatewayToken,
bindFreshDevice: true,
});
try {
const cron = await runCronCleanupScenario({ gateway, pidPath });
const subagent = await runSubagentCleanupScenario({ gateway, pidPath, pidsPath, exitPath });
-1
View File
@@ -72,7 +72,6 @@ async function main() {
{
gateway: {
controlUi: {
allowInsecureAuth: true,
enabled: false,
},
},
+7 -10
View File
@@ -1268,16 +1268,13 @@ function configureKitchenSink(env, port) {
profile: config.tools?.profile ?? "full",
alsoAllow: [...new Set([...(config.tools?.alsoAllow ?? []), ...EXPECTED_TOOLS])],
};
config.messages = {
...config.messages,
tts: {
...config.messages?.tts,
provider: config.messages?.tts?.provider ?? EXPECTED_SPEECH_PROVIDERS[0],
providers: {
...config.messages?.tts?.providers,
[EXPECTED_SPEECH_PROVIDERS[0]]: {
...config.messages?.tts?.providers?.[EXPECTED_SPEECH_PROVIDERS[0]],
},
config.tts = {
...config.tts,
provider: config.tts?.provider ?? EXPECTED_SPEECH_PROVIDERS[0],
providers: {
...config.tts?.providers,
[EXPECTED_SPEECH_PROVIDERS[0]]: {
...config.tts?.providers?.[EXPECTED_SPEECH_PROVIDERS[0]],
},
},
};
+1 -4
View File
@@ -1,5 +1,6 @@
// Helpers for extracting agent turn output from E2E protocol events.
import fs from "node:fs";
import { isRecord } from "../../lib/record-shared.mjs";
import { readTextFileTail, tailText } from "./text-file-utils.mjs";
const ERROR_DETAIL_TAIL_BYTES = 64 * 1024;
@@ -150,10 +151,6 @@ function textValues(values) {
return values.filter((value) => typeof value === "string" && value.length > 0);
}
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function isFailureStatus(value) {
return (
typeof value === "string" &&
@@ -1,7 +1,5 @@
// Shared auth profile store assertions for install/onboard E2E proof.
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
import { isRecord } from "../../lib/record-shared.mjs";
function hasExpectedOpenAiEnvRef(profile) {
if (!isRecord(profile)) {
@@ -6,6 +6,7 @@ import path from "node:path";
import process from "node:process";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { isRecord } from "../../../lib/record-shared.mjs";
import { resolveWindowsTaskkillPath } from "../../../lib/windows-taskkill.mjs";
import { readBoundedResponseText } from "../bounded-response-text.mjs";
@@ -993,10 +994,6 @@ function hasOwnPayloadField(raw, field) {
);
}
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
export function unwrapRpcPayload(raw) {
if (raw?.ok === false) {
throw new Error(`gateway RPC failed: ${JSON.stringify(raw.error ?? raw)}`);
@@ -1062,16 +1059,13 @@ async function smokePlugin(pluginId, pluginDir, requiresConfig, pluginIndex, plu
const env = withManifestChannelActivationEnv(process.env, plan.channels);
if (plan.speechProviders[0]) {
const provider = plan.speechProviders[0];
config.messages = {
...config.messages,
tts: {
...config.messages?.tts,
provider,
providers: {
...config.messages?.tts?.providers,
[provider]: {
...config.messages?.tts?.providers?.[provider],
},
config.tts = {
...config.tts,
provider,
providers: {
...config.tts?.providers,
[provider]: {
...config.tts?.providers?.[provider],
},
},
};
@@ -1394,10 +1388,8 @@ async function smokeTtsGlobalDisable(pluginId, pluginDir, provider, pluginIndex,
plugins: {
enabled: false,
},
messages: {
tts: {
provider: selectedProvider,
},
tts: {
provider: selectedProvider,
},
},
port,
@@ -1450,13 +1442,11 @@ async function smokeOpenAiTts(pluginIndex) {
openai: { enabled: true },
},
},
messages: {
tts: {
provider: "openai",
providers: {
openai: {
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
tts: {
provider: "openai",
providers: {
openai: {
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
},
},
@@ -56,9 +56,8 @@ const config = {
timeoutSeconds,
sandbox: { mode: "off" },
},
list: [
{
id: "main",
entries: {
main: {
default: true,
model: { primary: "openai/gpt-5.6-luna", fallbacks: [] },
models: {
@@ -68,7 +67,7 @@ const config = {
},
workspace: workspaceDir,
},
],
},
},
skills: { allowBundled: [] },
};
@@ -203,11 +203,11 @@ function readSessionEntry(sessionId) {
try {
const row = db
.prepare(
`SELECT se.session_key, se.entry_json, s.agent_harness_id
FROM sessions AS s
INNER JOIN session_entries AS se ON se.session_id = s.session_id
WHERE s.session_id = ?
ORDER BY se.updated_at DESC, se.session_key
`SELECT sn.session_key, sn.entry_json, sw.agent_harness_id
FROM session_nodes AS sn
INNER JOIN session_windows AS sw ON sw.session_id = sn.current_session_id
WHERE sw.session_id = ?
ORDER BY sn.updated_at DESC, sn.session_key
LIMIT 1`,
)
.get(sessionId);
@@ -1,9 +1,9 @@
// Resource ceiling assertions for Docker E2E stats output.
import fs from "node:fs";
import { createInterface } from "node:readline";
const [statsFile, maxMemoryRaw, maxCpuRaw, label = "docker"] = process.argv.slice(2);
const NON_NEGATIVE_DECIMAL_PATTERN = /^(?:0|[1-9]\d*)(?:\.\d+)?$/u;
const MAX_STATS_SAMPLE_LINE_BYTES = 1024 * 1024;
function parseFiniteLimit(raw, name) {
const text = String(raw ?? "").trim();
@@ -92,11 +92,55 @@ async function scanStatsFileLines(file, onLine) {
return;
}
const input = fs.createReadStream(file, { encoding: "utf8" });
const lines = createInterface({ crlfDelay: Infinity, input });
for await (const line of lines) {
let pending = "";
let pendingBytes = 0;
let skipLineFeedAfterCarriageReturn = false;
const appendSegment = (segment) => {
if (!segment) {
return;
}
const segmentBytes = Buffer.byteLength(segment, "utf8");
if (pendingBytes + segmentBytes > MAX_STATS_SAMPLE_LINE_BYTES) {
throw new Error(
`docker stats sample for ${label} exceeded ${MAX_STATS_SAMPLE_LINE_BYTES} bytes`,
);
}
pending += segment;
pendingBytes += segmentBytes;
};
const emitPendingLine = () => {
const line = pending.endsWith("\r") ? pending.slice(0, -1) : pending;
pending = "";
pendingBytes = 0;
if (line) {
onLine(line);
}
};
for await (const chunk of input) {
let start = 0;
for (let index = 0; index < chunk.length; index += 1) {
const code = chunk.charCodeAt(index);
if (skipLineFeedAfterCarriageReturn) {
skipLineFeedAfterCarriageReturn = false;
if (code === 10) {
start = index + 1;
continue;
}
}
if (code !== 10 && code !== 13) {
continue;
}
appendSegment(chunk.slice(start, index));
emitPendingLine();
skipLineFeedAfterCarriageReturn = code === 13;
start = index + 1;
}
appendSegment(chunk.slice(start));
}
if (pending) {
emitPendingLine();
}
}
@@ -59,6 +59,25 @@ update_doctor_env+=" OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE=1"
update_doctor_env+=" OPENCLAW_UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART=1"
update_doctor_env+=" OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR=1"
update_doctor_env+=" OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_ACTIVATION=0"
use_default_service_identity() {
local account_home
account_home="$(node -p 'require("node:os").userInfo().homedir')"
# Service mutation is intentionally restricted to the OS account home. Keep
# these disposable-container flows isolated without pretending a temp HOME owns it.
rm -rf \
"$account_home/.openclaw" \
"$account_home/.config/systemd/user/openclaw-gateway.service" \
"$account_home/.config/fish" \
"$account_home/.config/powershell" \
"$account_home/.local/bin/openclaw-wrapper" \
"$account_home/openclaw-wrapper-argv.log"
export HOME="$account_home"
export USERPROFILE="$account_home"
unset OPENCLAW_HOME OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH
}
is_legacy_package_acceptance_compat() {
[ "$(node scripts/e2e/lib/package-compat.mjs "$1")" = "1" ]
}
@@ -139,6 +158,7 @@ run_flow() {
echo "== Flow: $name =="
openclaw_test_state_create "switch-${name}" empty
use_default_service_identity
export USER="testuser"
if ! openclaw_e2e_maybe_timeout "$command_timeout" bash -c "$install_cmd" >"$install_log" 2>&1; then
@@ -263,6 +283,7 @@ run_proxy_env_flow() {
echo "== Flow: $name =="
openclaw_test_state_create "switch-${name}" empty
use_default_service_identity
export USER="testuser"
unit_path="$HOME/.config/systemd/user/openclaw-gateway.service"
@@ -309,6 +330,7 @@ run_wrapper_flow() {
echo "== Flow: $name =="
openclaw_test_state_create "switch-${name}" empty
use_default_service_identity
export USER="testuser"
mkdir -p "$HOME/.local/bin"
local wrapper="$HOME/.local/bin/openclaw-wrapper"
@@ -2,7 +2,9 @@
set -euo pipefail
args=("$@")
scope="system"
if [[ "${args[0]:-}" == "--user" ]]; then
scope="user"
args=("${args[@]:1}")
fi
cmd="${args[0]:-}"
@@ -24,6 +26,20 @@ case "$cmd" in
exit 1
;;
show)
property=""
for arg in "${args[@]:1}"; do
case "$arg" in
--property=*) property="${arg#--property=}" ;;
esac
done
if [[ "$scope" == "system" && "$property" == "LoadState" ]]; then
echo "not-found"
exit 0
fi
if [[ "$scope" == "system" && "$property" == "UnitPath" ]]; then
echo "/etc/systemd/system /usr/lib/systemd/system"
exit 0
fi
printf "%s\n" \
"ActiveState=inactive" \
"SubState=dead" \
-1
View File
@@ -39,7 +39,6 @@ function writeConfig(kind) {
profiles: {
"docker-cdp": {
cdpUrl: `http://127.0.0.1:${readTcpPortEnv("CDP_PORT", 19222)}`,
color: "#FF4500",
},
},
},
+27 -16
View File
@@ -61,7 +61,10 @@ export function applyMockOpenAiModelConfig(cfg, params) {
...(params.includeImageDefaults
? {
imageModel: { primary: modelRef, timeoutMs: 30_000 },
imageGenerationModel: { primary: "openai/gpt-image-1", timeoutMs: 30_000 },
mediaModels: {
...cfg.agents?.defaults?.mediaModels,
image: { primary: "openai/gpt-image-1", timeoutMs: 30_000 },
},
}
: {}),
models: {
@@ -72,24 +75,32 @@ export function applyMockOpenAiModelConfig(cfg, params) {
},
},
},
...(Array.isArray(cfg.agents?.list)
...(cfg.agents?.entries
? {
list: cfg.agents.list.map((agent) => ({
...agent,
model: { ...agent.model, primary: modelRef },
models: {
...agent.models,
[modelRef]: {
...agent.models?.[modelRef],
agentRuntime: { id: "openclaw" },
params: {
...agent.models?.[modelRef]?.params,
transport: "sse",
openaiWsWarmup: false,
entries: Object.fromEntries(
Object.entries(cfg.agents.entries).map(([agentId, agent]) => [
agentId,
{
...agent,
model: {
...(typeof agent.model === "object" && agent.model !== null ? agent.model : {}),
primary: modelRef,
},
models: {
...agent.models,
[modelRef]: {
...agent.models?.[modelRef],
agentRuntime: { id: "openclaw" },
params: {
...agent.models?.[modelRef]?.params,
transport: "sse",
openaiWsWarmup: false,
},
},
},
},
},
})),
]),
),
}
: {}),
};
+11 -13
View File
@@ -29,10 +29,8 @@ function writeOpenWebUiWorkspace() {
path.join(workspace, "IDENTITY.md"),
"# Identity\n\n- Name: OpenClaw\n- Purpose: Open WebUI Docker compatibility smoke test assistant.\n",
);
writeJson(path.join(workspace, ".openclaw", "workspace-state.json"), {
version: 1,
setupCompletedAt: "2026-01-01T00:00:00.000Z",
});
fs.rmSync(path.join(workspace, ".openclaw", "workspace-state.json"), { force: true });
fs.rmSync(path.join(workspace, "openclaw-workspace-state.json"), { force: true });
fs.rmSync(path.join(workspace, "BOOTSTRAP.md"), { force: true });
}
@@ -43,10 +41,10 @@ function writeAgentsDeleteConfig() {
fs.mkdirSync(sharedWorkspace, { recursive: true });
writeJson(path.join(stateDir, "openclaw.json"), {
agents: {
list: [
{ id: "main", workspace: sharedWorkspace },
{ id: "ops", workspace: sharedWorkspace },
],
entries: {
main: { workspace: sharedWorkspace },
ops: { workspace: sharedWorkspace },
},
},
...(gatewayToken ? { gateway: { auth: { mode: "token", token: gatewayToken } } } : {}),
});
@@ -88,13 +86,13 @@ function assertAgentsDeleteResult([outputPath]) {
);
assert(fs.existsSync(process.env.SHARED_WORKSPACE), "shared workspace was removed");
const remaining =
readJson(path.join(process.env.OPENCLAW_STATE_DIR, "openclaw.json"))?.agents?.list ?? [];
assert(Array.isArray(remaining), "agents list missing after delete");
assert(!remaining.some((entry) => entry?.id === "ops"), "deleted agent remained in config");
readJson(path.join(process.env.OPENCLAW_STATE_DIR, "openclaw.json"))?.agents?.entries ?? {};
assert(
remaining.some((entry) => entry?.id === "main"),
"main agent missing after delete",
remaining && typeof remaining === "object" && !Array.isArray(remaining),
"agents entries missing after delete",
);
assert(!Object.hasOwn(remaining, "ops"), "deleted agent remained in config");
assert(Object.hasOwn(remaining, "main"), "main agent missing after delete");
console.log("agents delete shared workspace smoke ok");
}
+1 -4
View File
@@ -4,6 +4,7 @@ import { readFile, writeFile } from "node:fs/promises";
import { request as httpRequest } from "node:http";
import { pathToFileURL } from "node:url";
import { WebSocket } from "ws";
import { isRecord } from "../../../lib/record-shared.mjs";
import { sleep as delay } from "../../../lib/sleep.mjs";
import { waitForWebSocketOpen } from "../websocket-open.mjs";
import { readGatewayNetworkClientConnectTimeoutMs } from "./limits.mjs";
@@ -24,10 +25,6 @@ async function openSocket(url, timeoutMs = 10_000) {
return ws;
}
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function hasGatewayHealthSummaryPayload(response) {
if (!isRecord(response) || !isRecord(response.payload)) {
return false;

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