refactor: consolidate coercion ownership (#122692)

* refactor: consolidate coercion ownership

* test: align shard check with weighted planning

* chore: refresh plugin SDK API baseline
This commit is contained in:
Peter Steinberger
2026-08-12 09:25:28 -07:00
committed by GitHub
parent 7c58151445
commit c23d66e3b5
481 changed files with 1611 additions and 2632 deletions
+7 -14
View File
@@ -9,6 +9,7 @@ import { performance } from "node:perf_hooks";
import { pathToFileURL } from "node:url";
import { asRecord } from "@openclaw/normalization-core/record-coerce";
import prettyMilliseconds from "pretty-ms";
import { resolveBuildIdentityEnvironment } from "./lib/build-identity.mts";
import {
listPluginSdkDeclarationOutputs,
pluginSdkEntrypoints,
@@ -59,7 +60,6 @@ type BuildAllStepParams = {
comSpec?: string;
};
type BuildAllCacheParams = { rootDir?: string; fs?: BuildAllFs; env?: NodeJS.ProcessEnv };
const FULL_GIT_COMMIT_RE = /^[0-9a-f]{40}$/iu;
const BUILD_CACHE_VERSION = 4;
const TSDOWN_DECLARATION_EXTENSIONS = [".d.ts", ".d.mts", ".d.cts"];
const TSDOWN_SOURCE_EXTENSIONS = [
@@ -491,19 +491,12 @@ export function resolveBuildAllEnvironment(
now: () => Date = () => new Date(),
readGitCommit: () => string | null = readCurrentGitCommit,
) {
const explicitTimestamp = env.OPENCLAW_BUILD_TIMESTAMP?.trim();
const explicitCommit = env.GIT_COMMIT?.trim() || env.GIT_SHA?.trim();
const checkedOutCommit = explicitCommit ? null : readGitCommit()?.trim();
// GITHUB_SHA names the workflow invocation and can differ from a checked-out tag.
const commit = explicitCommit || checkedOutCommit || env.GITHUB_SHA?.trim();
if (commit && !FULL_GIT_COMMIT_RE.test(commit)) {
throw new Error("build commit must be a full 40-character hexadecimal SHA");
}
return {
...env,
OPENCLAW_BUILD_TIMESTAMP: explicitTimestamp || now().toISOString(),
...(commit ? { GIT_COMMIT: commit.toLowerCase() } : {}),
};
return resolveBuildIdentityEnvironment({
commitLabel: "build commit",
env,
now,
readGitCommit,
});
}
function resolveStepEnv(step: BuildAllStep, env: NodeJS.ProcessEnv, platform: NodeJS.Platform) {
+5 -17
View File
@@ -1,5 +1,6 @@
import { execFileSync } from "node:child_process";
import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { stableStringify } from "../packages/normalization-core/src/stable-stringify.ts";
import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mts";
import { getChangedPathFacts, normalizeChangedPath } from "./lib/changed-path-facts.mjs";
import { isDirectRunUrl } from "./lib/direct-run.mjs";
@@ -448,8 +449,8 @@ export function isLiveDockerPackageScriptOnlyChange(before: string, after: strin
const afterStripped = stripLiveDockerPackageScripts(afterPackage);
return (
stableJson(beforeStripped) === stableJson(afterStripped) &&
stableJson(beforeAllowed) !== stableJson(afterAllowed)
stableStringify(beforeStripped) === stableStringify(afterStripped) &&
stableStringify(beforeAllowed) !== stableStringify(afterAllowed)
);
}
@@ -469,8 +470,8 @@ export function isPackageScriptOnlyChange(before: string, after: string): boolea
const afterStripped = stripPackageScripts(afterPackage);
return (
stableJson(beforeStripped) === stableJson(afterStripped) &&
stableJson(beforeScripts) !== stableJson(afterScripts)
stableStringify(beforeStripped) === stableStringify(afterStripped) &&
stableStringify(beforeScripts) !== stableStringify(afterScripts)
);
}
@@ -542,19 +543,6 @@ function stripPackageScripts(packageJson: Record<string, unknown>) {
return clone;
}
function stableJson(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(stableJson).join(",")}]`;
}
if (isRecord(value)) {
return `{${Object.keys(value)
.toSorted((left, right) => left.localeCompare(right))
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
.join(",")}}`;
}
return JSON.stringify(value) ?? "undefined";
}
/**
* Writes changed-lane booleans to the GitHub Actions output file.
*/
+11 -11
View File
@@ -20,7 +20,12 @@ import {
listStagedChangedPaths,
} from "./changed-lanes.mts";
import type { ChangedLaneResult } from "./changed-lanes.mts";
import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mts";
import {
booleanFlag,
isOpenEndedTruthyValue,
parseFlagArgs,
stringFlag,
} from "./lib/arg-utils.mts";
import { getChangedPathFacts, normalizeChangedPath } from "./lib/changed-path-facts.mjs";
import { printTimingSummary } from "./lib/check-timing-summary.mts";
import { isDirectRunUrl } from "./lib/direct-run.mjs";
@@ -157,11 +162,6 @@ export function createChangedCheckChildEnv(baseEnv: NodeJS.ProcessEnv = process.
};
}
function isTruthyEnvFlag(value: string | undefined) {
const normalized = (value ?? "").trim().toLowerCase();
return normalized !== "" && normalized !== "0" && normalized !== "false" && normalized !== "no";
}
function hasAndroidVersionSyncPath(paths: string[]) {
return paths.some((changedPath) =>
ANDROID_VERSION_SYNC_PATHS.has(normalizeChangedPath(changedPath)),
@@ -231,10 +231,10 @@ export function shouldDelegateChangedCheckToCrabbox(
env: NodeJS.ProcessEnv = process.env,
options: ChangedCheckDelegateOptions = {},
) {
if (isTruthyEnvFlag(env.OPENCLAW_CHECK_CHANGED_REMOTE_CHILD)) {
if (isOpenEndedTruthyValue(env.OPENCLAW_CHECK_CHANGED_REMOTE_CHILD)) {
return false;
}
if (isTruthyEnvFlag(env.CI) || isTruthyEnvFlag(env.GITHUB_ACTIONS)) {
if (isOpenEndedTruthyValue(env.CI) || isOpenEndedTruthyValue(env.GITHUB_ACTIONS)) {
return false;
}
if (argv.includes("--dry-run")) {
@@ -247,7 +247,7 @@ export function shouldDelegateChangedCheckToCrabbox(
if (result.paths.length === 0) {
return false;
}
if (isTruthyEnvFlag(env.OPENCLAW_TESTBOX)) {
if (isOpenEndedTruthyValue(env.OPENCLAW_TESTBOX)) {
return true;
}
// Release metadata plans diff the supplied commits after classification. A missing
@@ -708,7 +708,7 @@ export function createChangedCheckPlan(
add("package patch guard", ["deps:patches:check"]);
if (
hasDeadcodeScannedSource(result.paths) &&
!isTruthyEnvFlag(baseEnv.OPENCLAW_CHECK_CHANGED_SKIP_DEADCODE)
!isOpenEndedTruthyValue(baseEnv.OPENCLAW_CHECK_CHANGED_SKIP_DEADCODE)
) {
addCommand(
"dead export scan (skip with OPENCLAW_CHECK_CHANGED_SKIP_DEADCODE=1)",
@@ -1066,7 +1066,7 @@ export function createPnpmManagedCommand<T extends ChangedCheckCommand>(
env: NodeJS.ProcessEnv = process.env,
) {
const commandEnv = command.env ?? resolveLocalHeavyCheckEnv(env);
if (isTruthyEnvFlag(commandEnv.CI) || isTruthyEnvFlag(commandEnv.GITHUB_ACTIONS)) {
if (isOpenEndedTruthyValue(commandEnv.CI) || isOpenEndedTruthyValue(commandEnv.GITHUB_ACTIONS)) {
const shimmedEnv = prependCorepackPnpmShim(commandEnv);
return {
...command,
+41 -52
View File
@@ -8,7 +8,7 @@ import { isCodeFile, listRepoFilesSync } from "./check-file-utils.js";
import { isDirectRunUrl } from "./lib/direct-run.mjs";
import { runWithFailedTrailer } from "./lib/failed-trailer.mts";
import { resolveRepoRoot } from "./lib/repo-root.mjs";
import { toLine, unwrapExpression } from "./lib/ts-guard-utils.mts";
import { getPropertyNameText, toLine, unwrapExpression } from "./lib/ts-guard-utils.mts";
const ABSOLUTE_LEGACY_COERCION_HELPER_NAMES = [
"asObject",
@@ -30,6 +30,11 @@ export type CoercionHelperDeclarationKind =
| "variable";
export const CANONICAL_COERCION_HELPER_OWNERS = [
{
file: "packages/normalization-core/src/agent-id.ts",
kind: "function",
names: ["isValidAgentId", "normalizeAgentId"],
},
{
file: "packages/normalization-core/src/string-coerce.ts",
kind: "function",
@@ -181,6 +186,7 @@ export const CANONICAL_COERCION_HELPER_OWNERS = [
}[];
export const CANONICAL_COERCION_MODULES = [
"packages/normalization-core/src/agent-id.ts",
"packages/normalization-core/src/string-coerce.ts",
"packages/normalization-core/src/string-normalization.ts",
"packages/normalization-core/src/number-coercion.ts",
@@ -192,6 +198,8 @@ export const CANONICAL_COERCION_MODULES = [
"src/utils/boolean.ts",
] as const;
const MIXED_CANONICAL_COERCION_MODULES = ["scripts/lib/arg-utils.runtime.mjs"] as const;
export const DEFERRED_CANONICAL_COERCION_EXPORTS = [
{
file: "packages/normalization-core/src/error-coercion.ts",
@@ -210,28 +218,24 @@ const EXCEPTIONAL_COERCION_HELPER_CARVE_OUTS = [
file: "ui/src/test-helpers/control-ui-e2e.ts",
name: "isRecord",
kind: "function",
count: 1,
reason: "Serialized mock Gateway closure cannot capture module imports.",
},
{
file: "scripts/lib/kova-report-gate.mts",
name: "isRecord",
kind: "function",
count: 1,
reason: "Copied standalone report gate cannot rely on workspace package resolution.",
},
{
file: "scripts/lib/record-shared.mjs",
name: "isRecord",
kind: "function",
count: 1,
reason: "Plain-Node shared helper serves MJS and E2E callers without package resolution.",
},
{
file: "scripts/pr-lib/process-group-runner.mjs",
name: "toError",
kind: "function",
count: 1,
reason:
"Bootstrap process supervisor preserves fallback errors without workspace dependencies.",
},
@@ -239,11 +243,9 @@ const EXCEPTIONAL_COERCION_HELPER_CARVE_OUTS = [
file: "scripts/lib/bounded-response.mjs",
name: "toLintErrorObject",
kind: "function",
count: 1,
reason: "Standalone copied response reader cannot resolve workspace packages.",
},
] as const satisfies readonly {
count: number;
file: string;
kind: CoercionHelperDeclarationKind;
name: string;
@@ -294,7 +296,6 @@ export type CanonicalCoercionExportAudit = {
};
export type CoercionHelperCarveOut = {
count: number;
file: string;
kind: CoercionHelperDeclarationKind;
name: BannedCoercionHelperName;
@@ -308,7 +309,6 @@ function canonicalOwnerCarveOuts(
file: owner.file,
kind: owner.kind,
name,
count: 1,
reason: "Canonical coercion helper owned by this module.",
}));
}
@@ -318,14 +318,10 @@ export const COERCION_HELPER_CARVE_OUTS: readonly CoercionHelperCarveOut[] = [
...EXCEPTIONAL_COERCION_HELPER_CARVE_OUTS,
];
type CarveOutMismatch = CoercionHelperCarveOut & {
actualCount: number;
};
type CoercionHelperAudit = {
excessDeclarations: CoercionHelperDeclaration[];
invalidCarveOuts: string[];
staleCarveOuts: CarveOutMismatch[];
staleCarveOuts: CoercionHelperCarveOut[];
};
type ScriptIo = {
@@ -362,16 +358,6 @@ export function isGovernedCoercionHelperPath(filePath: string) {
);
}
function propertyNameText(name: ts.PropertyName | undefined): string | undefined {
if (!name) {
return undefined;
}
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
return name.text;
}
return undefined;
}
function isCallableInitializer(expression: ts.Expression): boolean {
const initializer = unwrapCallableInitializer(expression);
return ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer);
@@ -434,7 +420,7 @@ export function findBannedCoercionHelperDeclarations(
});
}
} else if (ts.isMethodDeclaration(node)) {
const name = propertyNameText(node.name);
const name = getPropertyNameText(node.name);
if (name && BANNED_HELPER_NAMES.has(name)) {
declarations.push({
file,
@@ -444,7 +430,7 @@ export function findBannedCoercionHelperDeclarations(
});
}
} else if (ts.isPropertyDeclaration(node) && node.initializer) {
const name = propertyNameText(node.name);
const name = getPropertyNameText(node.name);
if (name && BANNED_HELPER_NAMES.has(name) && isCallableInitializer(node.initializer)) {
declarations.push({
file,
@@ -454,7 +440,7 @@ export function findBannedCoercionHelperDeclarations(
});
}
} else if (ts.isPropertyAssignment(node)) {
const name = propertyNameText(node.name);
const name = getPropertyNameText(node.name);
if (name && BANNED_HELPER_NAMES.has(name) && isCallableInitializer(node.initializer)) {
declarations.push({
file,
@@ -562,7 +548,7 @@ export function auditCanonicalCoercionExports(
return { invalidClassifications, staleClassifications, unclassifiedExports };
}
/** Checks exact file/name/count carve-outs and rejects stale or excess entries. */
/** Checks exact file/name/kind carve-outs and rejects stale or excess entries. */
export function auditCoercionHelperDeclarations(
declarations: readonly CoercionHelperDeclaration[],
carveOuts: readonly CoercionHelperCarveOut[],
@@ -583,9 +569,6 @@ export function auditCoercionHelperDeclarations(
`${carveOut.file} [${carveOut.name}] has invalid kind ${carveOut.kind}`,
);
}
if (!Number.isInteger(carveOut.count) || carveOut.count < 1) {
invalidCarveOuts.push(`${carveOut.file} [${carveOut.name}] must have a positive count`);
}
if (!carveOut.reason.trim()) {
invalidCarveOuts.push(`${carveOut.file} [${carveOut.name}] needs a non-empty reason`);
}
@@ -602,22 +585,15 @@ export function auditCoercionHelperDeclarations(
const excessDeclarations: CoercionHelperDeclaration[] = [];
for (const [key, actual] of declarationsByKey) {
const allowedCount = carveOutByKey.get(key)?.count ?? 0;
if (actual.length > allowedCount) {
excessDeclarations.push(...actual.slice(allowedCount));
if (carveOutByKey.has(key)) {
excessDeclarations.push(...actual.slice(1));
} else {
excessDeclarations.push(...actual);
}
}
const staleCarveOuts = carveOuts
.map((carveOut): CarveOutMismatch | null => {
const actual = declarationsByKey.get(carveOutKey(carveOut)) ?? [];
return actual.length < carveOut.count
? {
...carveOut,
actualCount: actual.length,
}
: null;
})
.filter((entry): entry is CarveOutMismatch => entry !== null);
const staleCarveOuts = carveOuts.filter(
(carveOut) => !declarationsByKey.has(carveOutKey(carveOut)),
);
return {
excessDeclarations: excessDeclarations.toSorted(
@@ -637,15 +613,28 @@ function writeLine(stream: ScriptIo["stdout"] | ScriptIo["stderr"], value: strin
function auditDefaultCanonicalExports(repoRoot: string): CanonicalCoercionExportAudit {
const canonicalModules = new Set<string>(CANONICAL_COERCION_MODULES);
const mixedModules = new Set<string>(MIXED_CANONICAL_COERCION_MODULES);
const auditedModules = [...CANONICAL_COERCION_MODULES, ...MIXED_CANONICAL_COERCION_MODULES];
const exportsByFile = new Map(
CANONICAL_COERCION_MODULES.map((file) => {
auditedModules.map((file) => {
const source = fs.readFileSync(path.join(repoRoot, file), "utf8");
return [file, findExportedCallableNames(source, file)] as const;
const exportedNames = findExportedCallableNames(source, file);
if (!mixedModules.has(file)) {
return [file, exportedNames] as const;
}
const registeredNames = new Set<string>(
CANONICAL_COERCION_HELPER_OWNERS.filter((owner) => owner.file === file).flatMap(
(owner) => owner.names,
),
);
return [file, exportedNames.filter((name) => registeredNames.has(name))] as const;
}),
);
const classifications: CanonicalCoercionExportClassification[] = [
...CANONICAL_COERCION_HELPER_OWNERS.filter(({ file }) => canonicalModules.has(file)).flatMap(
({ file, names }) => names.map((name) => ({ file, name, status: "enforced" as const })),
...CANONICAL_COERCION_HELPER_OWNERS.filter(
({ file }) => canonicalModules.has(file) || mixedModules.has(file),
).flatMap(({ file, names }) =>
names.map((name) => ({ file, name, status: "enforced" as const })),
),
...DEFERRED_CANONICAL_COERCION_EXPORTS.map(({ file, name, reason }) => ({
file,
@@ -719,7 +708,7 @@ export function runCoercionHelperDeclarationGuard(
for (const carveOut of audit.staleCarveOuts) {
writeLine(
io.stderr,
`- ${carveOut.file} [${carveOut.name}] expected ${carveOut.count} ${carveOut.kind} declaration(s), found ${carveOut.actualCount}; remove or reduce the carve-out`,
`- ${carveOut.file} [${carveOut.name}] has no ${carveOut.kind} declaration; remove the carve-out`,
);
}
}
@@ -743,11 +732,11 @@ export function runCoercionHelperDeclarationGuard(
}
writeLine(
io.stderr,
"Core/package/UI/workspace-script code: use the matching @openclaw/normalization-core coercion subpath.",
"Core/package/UI/workspace-script code: use the matching @openclaw/normalization-core export or module.",
);
writeLine(
io.stderr,
"Plugin production code: use openclaw/plugin-sdk/string-coerce-runtime, number-runtime, or error-runtime.",
"Bundled plugin production code: use the matching openclaw/plugin-sdk runtime; number-runtime is bundled/private-local, not a third-party typed contract.",
);
writeLine(
io.stderr,
+8 -108
View File
@@ -1,9 +1,7 @@
#!/usr/bin/env node
import { promises as fs } from "node:fs";
import path from "node:path";
// Inventories extension imports to enforce plugin SDK boundary rules.
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import {
BUNDLED_PLUGIN_PATH_PREFIX,
BUNDLED_PLUGIN_ROOT_DIR,
@@ -22,7 +20,6 @@ import { runAsScript } from "./lib/ts-guard-utils.mts";
const DEFAULT_REPO_ROOT = resolveRepoRoot(import.meta.url);
type BoundaryMode =
| "src-outside-plugin-sdk"
| "plugin-sdk-internal"
| "relative-outside-package"
| "normalization-core-bypass";
type ModuleReference = { kind: string; line: number; specifier: string };
@@ -36,7 +33,6 @@ type BoundaryCheckIo = {
const MODES = new Set<BoundaryMode>([
"src-outside-plugin-sdk",
"plugin-sdk-internal",
"relative-outside-package",
"normalization-core-bypass",
]);
@@ -44,27 +40,16 @@ const MODES = new Set<BoundaryMode>([
const ruleTextByMode: Record<BoundaryMode, string> = {
"src-outside-plugin-sdk":
"Rule: production bundled plugins must not import src/** outside src/plugin-sdk/**",
"plugin-sdk-internal":
"Rule: production bundled plugins must not import src/plugin-sdk-internal/**",
"relative-outside-package":
"Rule: production bundled plugins must not use relative imports that escape their own package root",
"normalization-core-bypass":
"Rule: production bundled plugins must not import normalization-core directly; use the matching openclaw/plugin-sdk coercion runtime",
};
type BaselineBoundaryMode = "plugin-sdk-internal" | "src-outside-plugin-sdk";
const NORMALIZATION_CORE_PACKAGE = "@openclaw/normalization-core";
const NORMALIZATION_CORE_ROOT = "packages/normalization-core";
const DIRECT_COERCION_OWNER_PATHS = new Set(["src/infra/errors", "src/utils/boolean"]);
function baselinePathForMode(repoRoot: string, mode: BaselineBoundaryMode): string {
const fileName =
mode === "src-outside-plugin-sdk"
? "extension-src-outside-plugin-sdk-inventory.json"
: "extension-plugin-sdk-internal-inventory.json";
return path.join(repoRoot, "test", "fixtures", fileName);
}
function stripModuleExtension(filePath: string): string {
return filePath.replace(/\.(?:[cm]?[jt]s|tsx|jsx)$/u, "");
}
@@ -127,9 +112,12 @@ function classifyReason(mode: BoundaryMode, kind: string, resolved: string, spec
: "imports";
if (mode === "normalization-core-bypass") {
const facade = recommendedCoercionFacade(resolved);
if (facade === "openclaw/plugin-sdk/number-runtime") {
return `${verb} ${specifier} directly; bundled plugin production code must use bundled/private-local ${facade}`;
}
return facade
? `${verb} ${specifier} directly; plugin production code must use ${facade}`
: `${verb} ${specifier} directly; plugin production code must use the matching public openclaw/plugin-sdk facade, adding a narrow public SDK seam if needed`;
? `${verb} ${specifier} directly; bundled plugin production code must use ${facade}`
: `${verb} ${specifier} directly; bundled plugin production code must use the matching openclaw/plugin-sdk facade, adding a narrow public SDK seam if needed`;
}
if (mode === "relative-outside-package") {
if (resolved.startsWith("src/plugin-sdk/")) {
@@ -143,9 +131,6 @@ function classifyReason(mode: BoundaryMode, kind: string, resolved: string, spec
}
return `${verb} relative path ${specifier} outside the extension package`;
}
if (mode === "plugin-sdk-internal") {
return `${verb} src/plugin-sdk-internal from an extension`;
}
if (resolved.startsWith("src/plugin-sdk/")) {
return `${verb} allowed plugin-sdk path`;
}
@@ -163,57 +148,6 @@ function compareEntries(left: BoundaryEntry, right: BoundaryEntry): number {
);
}
function isBoundaryEntry(value: unknown): value is BoundaryEntry {
return (
isRecord(value) &&
typeof value.file === "string" &&
typeof value.line === "number" &&
typeof value.kind === "string" &&
typeof value.specifier === "string" &&
typeof value.resolvedPath === "string" &&
typeof value.reason === "string"
);
}
function isBoundaryEntryArray(value: unknown): value is BoundaryEntry[] {
return Array.isArray(value) && value.every(isBoundaryEntry);
}
async function readExpectedInventoryAtRoot(
repoRoot: string,
mode: BaselineBoundaryMode,
): Promise<BoundaryEntry[]> {
const baselinePath = baselinePathForMode(repoRoot, mode);
try {
const inventory: unknown = JSON.parse(await fs.readFile(baselinePath, "utf8"));
if (!isBoundaryEntryArray(inventory)) {
throw new Error(`Invalid boundary inventory: ${baselinePath}`);
}
return inventory;
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return [];
}
throw error;
}
}
/**
* Diffs expected and actual boundary inventory entries.
*/
export function diffInventory(expected: BoundaryEntry[], actual: BoundaryEntry[]) {
const expectedKeys = new Set(expected.map((entry) => JSON.stringify(entry)));
const actualKeys = new Set(actual.map((entry) => JSON.stringify(entry)));
return {
missing: expected
.filter((entry) => !actualKeys.has(JSON.stringify(entry)))
.toSorted(compareEntries),
unexpected: actual
.filter((entry) => !expectedKeys.has(JSON.stringify(entry)))
.toSorted(compareEntries),
};
}
const formatInventoryHuman = (mode: BoundaryMode, inventory: BoundaryEntry[]): string =>
formatGroupedInventoryHuman(
{
@@ -254,9 +188,6 @@ export function createExtensionPluginSdkBoundaryChecker(options: { repoRoot?: st
if (resolvedPath.startsWith("src/") && !resolvedPath.startsWith("src/plugin-sdk/")) {
modes.push("src-outside-plugin-sdk");
}
if (resolvedPath.startsWith("src/plugin-sdk-internal/")) {
modes.push("plugin-sdk-internal");
}
if (isNormalizationCoreBypass(specifier, resolvedPath)) {
modes.push("normalization-core-bypass");
}
@@ -347,42 +278,16 @@ export function createExtensionPluginSdkBoundaryChecker(options: { repoRoot?: st
}
const actual = await collectInventory(mode);
const strictMode = mode === "normalization-core-bypass" || mode === "relative-outside-package";
if (json) {
writeLine(streams.stdout, JSON.stringify(actual, null, 2));
return strictMode && actual.length > 0 ? 1 : 0;
return actual.length > 0 ? 1 : 0;
}
writeLine(streams.stdout, formatInventoryHuman(mode, actual));
if (strictMode) {
if (actual.length === 0) {
return 0;
}
writeLine(
streams.stderr,
`${ruleTextByMode[mode]} violations found (${actual.length}); this strict mode has no baseline.`,
);
return 1;
}
const expected = await readExpectedInventoryAtRoot(repoRoot, mode);
const diff = diffInventory(expected, actual);
if (diff.missing.length === 0 && diff.unexpected.length === 0) {
writeLine(streams.stdout, `Baseline matches (${actual.length} entries).`);
if (actual.length === 0) {
return 0;
}
if (diff.missing.length > 0) {
writeLine(streams.stderr, `Missing baseline entries (${diff.missing.length}):`);
for (const entry of diff.missing) {
writeLine(streams.stderr, ` - ${entry.file}:${entry.line} ${entry.reason}`);
}
}
if (diff.unexpected.length > 0) {
writeLine(streams.stderr, `Unexpected inventory entries (${diff.unexpected.length}):`);
for (const entry of diff.unexpected) {
writeLine(streams.stderr, ` - ${entry.file}:${entry.line} ${entry.reason}`);
}
}
writeLine(streams.stderr, `${ruleTextByMode[mode]} violations found (${actual.length}).`);
return 1;
}
@@ -391,11 +296,6 @@ export function createExtensionPluginSdkBoundaryChecker(options: { repoRoot?: st
const defaultBoundaryChecker = createExtensionPluginSdkBoundaryChecker();
/** Reads the checked-in expected boundary inventory from the real repository. */
export async function readExpectedInventory(mode: BaselineBoundaryMode): Promise<BoundaryEntry[]> {
return await readExpectedInventoryAtRoot(DEFAULT_REPO_ROOT, mode);
}
/**
* Entrypoint wrapper for the extension plugin SDK boundary check.
*/
+3 -16
View File
@@ -14,9 +14,12 @@ import {
writeBuildStamp,
writeRuntimePostBuildStamp,
} from "./lib/local-build-metadata.mts";
import { parseStrictNonNegativeDecimal as readNonNegativeInteger } from "./lib/numeric-options.mjs";
import { sleep } from "./lib/sleep.mjs";
import { resolveBuildRequirement } from "./run-node.mts";
export { readNonNegativeInteger };
const DEFAULTS = {
outputDir: path.join(process.cwd(), ".local", "gateway-watch-regression"),
windowMs: 10_000,
@@ -52,7 +55,6 @@ const WATCH_GATEWAY_SKIP_ENV = {
export const WATCH_LOG_CAPTURE_MAX_CHARS = 2 * 1024 * 1024;
export const WATCH_LOG_FAILURE_TAIL_CHARS = 12_000;
const WATCH_BUILD_DETECTION_MAX_CHARS = 4096;
const NON_NEGATIVE_INTEGER_PATTERN = /^(0|[1-9]\d*)$/u;
const ANSI_ESCAPE_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g");
type WatchOptions = typeof DEFAULTS;
@@ -204,21 +206,6 @@ export function updateWatchBuildDetection(
};
}
/**
* Parses a safe non-negative integer CLI value.
*/
export function readNonNegativeInteger(value: unknown, label: string): number {
const raw = String(value).trim();
if (!NON_NEGATIVE_INTEGER_PATTERN.test(raw)) {
throw new Error(`${label} must be a non-negative integer`);
}
const parsed = Number(raw);
if (!Number.isSafeInteger(parsed)) {
throw new Error(`${label} must be a safe integer`);
}
return parsed;
}
/**
* Parses gateway watch regression CLI arguments.
*/
+12 -36
View File
@@ -8,10 +8,15 @@ import os from "node:os";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
import { safeParseJson } from "../packages/normalization-core/src/json-coercion.ts";
import { resolveTimerTimeoutMs } from "../packages/normalization-core/src/number-coercion.ts";
import { asNullableRecord as asRecord } from "../packages/normalization-core/src/record-coerce.ts";
import { readNonBlankString } from "../packages/normalization-core/src/string-coerce.ts";
import { stripLeadingPackageManagerSeparator } from "./lib/arg-utils.mts";
import { readBoundedResponseText } from "./lib/bounded-response.mjs";
import { parseStrictNonNegativeDecimal as parseNonNegativeInteger } from "./lib/numeric-options.mjs";
export { parseNonNegativeInteger };
const ISSUE_FILE_COUNTS = [
["memory/transcripts", 9394],
@@ -99,7 +104,6 @@ Options:
`.trim();
}
const NON_NEGATIVE_INTEGER_PATTERN = /^(0|[1-9]\d*)$/u;
const ARGUMENT_FLAGS = new Set([
"--allow-non-darwin",
"--expect-leak",
@@ -123,21 +127,6 @@ function stripPackageManagerSeparatorForKnownFlags(argv: string[]) {
: argv;
}
/**
* Parses a safe non-negative integer option.
*/
export function parseNonNegativeInteger(value: unknown, label: string) {
const raw = String(value).trim();
if (!NON_NEGATIVE_INTEGER_PATTERN.test(raw)) {
throw new Error(`${label} must be a non-negative integer`);
}
const parsed = Number(raw);
if (!Number.isSafeInteger(parsed)) {
throw new Error(`${label} must be a safe integer`);
}
return parsed;
}
/**
* Parses a safe positive integer option.
*/
@@ -615,19 +604,6 @@ async function waitForChildExit(
return hasChildExited(child);
}
function parseJsonValue(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function readStringProperty(record: Record<string, unknown> | null, key: string) {
const value = record?.[key];
return typeof value === "string" && value.trim() ? value : undefined;
}
function parseToolTextContent(result: Record<string, unknown> | null) {
const content = Array.isArray(result?.content) ? result.content : [];
for (const entry of content) {
@@ -636,7 +612,7 @@ function parseToolTextContent(result: Record<string, unknown> | null) {
if (!text) {
continue;
}
const parsed = asRecord(parseJsonValue(text));
const parsed = asRecord(safeParseJson(text));
if (parsed) {
return parsed;
}
@@ -652,7 +628,7 @@ export function classifyMemorySearchInvokeResponse({
status,
bodyText,
}: InvokeResponseOptions) {
const parsedBody = parseJsonValue(bodyText);
const parsedBody = safeParseJson(bodyText);
const body = asRecord(parsedBody);
if (!httpOk) {
const errorRecord = asRecord(body?.error);
@@ -662,8 +638,8 @@ export function classifyMemorySearchInvokeResponse({
status,
gatewayOk: body?.ok === true ? true : body?.ok === false ? false : undefined,
error:
readStringProperty(errorRecord, "message") ??
readStringProperty(body, "error") ??
readNonBlankString(errorRecord?.message) ??
readNonBlankString(body?.error) ??
`memory_search HTTP request failed with status ${status}`,
};
}
@@ -685,8 +661,8 @@ export function classifyMemorySearchInvokeResponse({
status,
gatewayOk,
error:
readStringProperty(errorRecord, "message") ??
readStringProperty(body, "error") ??
readNonBlankString(errorRecord?.message) ??
readNonBlankString(body.error) ??
"memory_search gateway invocation failed",
};
}
@@ -711,7 +687,7 @@ export function classifyMemorySearchInvokeResponse({
const resultCount = Array.isArray(payload.results) ? payload.results.length : undefined;
const toolDisabled = payload.disabled === true;
const toolUnavailable = payload.unavailable === true;
const toolError = readStringProperty(payload, "error");
const toolError = readNonBlankString(payload.error);
const ok = gatewayOk === true && !toolDisabled && !toolUnavailable && !toolError;
return {
+2 -10
View File
@@ -11,6 +11,7 @@ import {
MAX_TIMER_TIMEOUT_MS,
resolveTimerTimeoutMs,
} from "../packages/normalization-core/src/number-coercion.ts";
import { normalizeCsvOrLooseStringList } from "../packages/normalization-core/src/string-normalization.ts";
import { stripLeadingPackageManagerSeparator } from "./lib/arg-utils.mts";
import {
parseNonNegativeInt,
@@ -161,7 +162,7 @@ export function parseArgs(argv: string[]) {
failOnObservation: process.env.OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_FAIL_ON_OBSERVATION === "1",
keepRunRoot: process.env.OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_KEEP_RUN_ROOT === "1",
};
const envIds = normalizeCsv(process.env.OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_IDS);
const envIds = normalizeCsvOrLooseStringList(process.env.OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_IDS);
options.pluginIds.push(...envIds);
const seenSingleValueFlags = new Set<string>();
parseArgv: for (let index = 0; index < args.length; index += 1) {
@@ -331,15 +332,6 @@ Environment:
`);
}
function normalizeCsv(raw: string | undefined) {
return raw
? raw
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
: [];
}
function assertNoDuplicateValues(values: string[], label: string) {
const seen = new Set();
for (const value of values) {
+2 -14
View File
@@ -4,6 +4,7 @@ import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { stableStringify } from "../packages/normalization-core/src/stable-stringify.ts";
import { RELEASE_METADATA_PATHS } from "./changed-lanes.mts";
const DEFAULT_GIT_TIMEOUT_MS = 60_000;
@@ -150,20 +151,7 @@ function stripPackageVersion(raw: string) {
throw new Error("package.json must contain an object");
}
delete parsed.version;
return stableJson(parsed);
}
function stableJson(value: unknown): string | undefined {
if (Array.isArray(value)) {
return `[${value.map(stableJson).join(",")}]`;
}
if (isRecord(value)) {
return `{${Object.keys(value)
.toSorted((left, right) => left.localeCompare(right))
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
return stableStringify(parsed);
}
function normalizeVersionText(raw: string) {
+2 -2
View File
@@ -14,6 +14,7 @@ import {
verifyControlUiGeneratedCatalogs,
verifyRuntimeLocaleConfig,
} from "./control-ui-i18n-verify.ts";
import { isStrictAffirmativeValue } from "./lib/arg-utils.mts";
import {
hashControlUiTranslationText,
loadControlUiTranslationMemory,
@@ -487,8 +488,7 @@ export function isProviderAuthError(error: Error): boolean {
}
function isProviderAuthOptional(): boolean {
const raw = process.env[ENV_AUTH_OPTIONAL]?.trim().toLowerCase();
return raw === "1" || raw === "true" || raw === "yes";
return isStrictAffirmativeValue(process.env[ENV_AUTH_OPTIONAL]);
}
function resolvePromptTimeoutMs(): number {
+12 -15
View File
@@ -13,6 +13,7 @@ import process from "node:process";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath, pathToFileURL } from "node:url";
import { asRecord, isRecord } from "@openclaw/normalization-core/record-coerce";
import { hasNonEmptyString } from "@openclaw/normalization-core/string-coerce";
import {
createBoundedResponseTooLargeError,
readBoundedResponseText,
@@ -885,8 +886,8 @@ function createGatewayClientRequestError(requestError: unknown): GatewayRequestE
const candidate = asRecord(requestError);
if (
candidate.type !== "gateway_request_error" ||
!isNonEmptyString(candidate.code) ||
!isNonEmptyString(candidate.message) ||
!hasNonEmptyString(candidate.code) ||
!hasNonEmptyString(candidate.message) ||
typeof candidate.retryable !== "boolean" ||
(candidate.retryAfterMs !== undefined &&
(typeof candidate.retryAfterMs !== "number" ||
@@ -1710,7 +1711,7 @@ export function extractPluginCommandNames(payload: unknown) {
}
}
return names
.filter(isNonEmptyString)
.filter(hasNonEmptyString)
.map((name) => name.replace(/^\//u, ""))
.filter((name, index, all) => all.indexOf(name) === index)
.toSorted((left, right) => left.localeCompare(right));
@@ -1744,7 +1745,7 @@ export function assertExpectedKitchenSinkToolEntries(
options: { requirePluginProvenance?: boolean } = {},
) {
const { requirePluginProvenance = false } = options;
const ids = entries.map((entry) => asRecord(entry).id).filter(isNonEmptyString);
const ids = entries.map((entry) => asRecord(entry).id).filter(hasNonEmptyString);
assertIncludesAll(ids, EXPECTED_TOOLS, label);
if (requirePluginProvenance) {
const wrongProvenance = entries
@@ -1772,7 +1773,7 @@ export function assertChannelAccountRunning(payload: unknown) {
const accounts = Array.isArray(channelAccounts[CHANNEL_ID]) ? channelAccounts[CHANNEL_ID] : [];
const account = accounts.find((entry) => asRecord(entry).accountId === CHANNEL_ACCOUNT_ID);
if (!account) {
const accountIds = accounts.map((entry) => asRecord(entry).accountId).filter(isNonEmptyString);
const accountIds = accounts.map((entry) => asRecord(entry).accountId).filter(hasNonEmptyString);
throw new Error(
`Kitchen Sink channel account ${CHANNEL_ACCOUNT_ID} was not reported. Available account ids: ${boundedJsonPreview(
accountIds,
@@ -1801,12 +1802,12 @@ export function assertTtsProviderCoverage(payload: unknown, surface: "providers"
`tts.${surface} returned invalid provider list: ${boundedJsonPreview(payload)}`,
);
}
const ids = entries.map((entry) => asRecord(entry).id).filter(isNonEmptyString);
const ids = entries.map((entry) => asRecord(entry).id).filter(hasNonEmptyString);
assertIncludesAny(ids, EXPECTED_SPEECH_PROVIDERS, `tts.${surface}`);
const configuredEntry = entries.find((entry) => {
const provider = asRecord(entry);
return (
isNonEmptyString(provider.id) &&
hasNonEmptyString(provider.id) &&
EXPECTED_SPEECH_PROVIDERS.includes(provider.id) &&
provider.configured === true
);
@@ -1990,7 +1991,7 @@ export async function assertOperatorRpcDenied(
export function assertCreatedKitchenSinkSession(payload: unknown, expectedKey = SESSION_KEY) {
const created = assertObjectPayload(payload, "sessions.create");
if (created.ok !== true || created.key !== expectedKey || !isNonEmptyString(created.sessionId)) {
if (created.ok !== true || created.key !== expectedKey || !hasNonEmptyString(created.sessionId)) {
throw new Error(
`sessions.create did not return the requested Kitchen Sink session: ${boundedJsonPreview(
payload,
@@ -2076,11 +2077,11 @@ export function assertGatewayHealthPayload(payload: unknown) {
[Number.isFinite(health.durationMs), "numeric durationMs"],
[isRecord(health.channels), "channels object"],
[Array.isArray(health.channelOrder), "channelOrder array"],
[isNonEmptyString(health.defaultAgentId), "defaultAgentId"],
[hasNonEmptyString(health.defaultAgentId), "defaultAgentId"],
[Array.isArray(health.agents), "agents array"],
[
isRecord(sessions) &&
isNonEmptyString(sessions.path) &&
hasNonEmptyString(sessions.path) &&
Number.isFinite(sessions.count) &&
Array.isArray(sessions.recent),
"sessions summary",
@@ -2099,7 +2100,7 @@ export function assertGatewayStatusPayload(payload: unknown) {
const problems = failedPayloadChecks([
[
isRecord(heartbeat) &&
isNonEmptyString(heartbeat.defaultAgentId) &&
hasNonEmptyString(heartbeat.defaultAgentId) &&
Array.isArray(heartbeat.agents),
"heartbeat summary",
],
@@ -2730,10 +2731,6 @@ function tailText(text: string) {
return text.split(/\r?\n/u).slice(-120).join("\n");
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
async function main() {
const config = resolveKitchenSinkRpcConfig();
let runner = resolveOpenClawRunner();
+2 -21
View File
@@ -1,21 +1,2 @@
// Limits shared by Codex media-path E2E fixtures.
export function readPositiveIntEnv(name, fallback, env = process.env) {
const text = String(env[name] ?? fallback).trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`invalid ${name}: ${text}`);
}
const value = Number(text);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`invalid ${name}: ${text}`);
}
return value;
}
export function readTcpPortEnv(name, fallback, env = process.env) {
const value = readPositiveIntEnv(name, fallback, env);
if (value > 65_535) {
const text = String(env[name] ?? fallback).trim();
throw new Error(`invalid ${name}: ${text}`);
}
return value;
}
// Compatibility path for Codex media-path E2E fixtures.
export { readPositiveIntEnv, readTcpPortEnv } from "../env-limits.mjs";
+1 -12
View File
@@ -1,6 +1,7 @@
// Workspace fixture writer commands for E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { readPositiveIntEnv } from "../env-limits.mjs";
import { readTextFileTail } from "../text-file-utils.mjs";
import { assert, readJson, requireArg, write, writeJson } from "./common.mjs";
@@ -10,18 +11,6 @@ const AGENTS_DELETE_OUTPUT_MAX_BYTES = readPositiveIntEnv(
);
const ERROR_DETAIL_TAIL_BYTES = 16 * 1024;
function readPositiveIntEnv(name, fallback) {
const text = String(process.env[name] ?? fallback).trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`invalid ${name}: ${text}`);
}
const value = Number(text);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`invalid ${name}: ${text}`);
}
return value;
}
function writeOpenWebUiWorkspace() {
const workspace =
process.env.OPENCLAW_WORKSPACE_DIR || path.join(process.env.HOME, ".openclaw", "workspace");
+1 -11
View File
@@ -1,15 +1,5 @@
// Limits shared by gateway network E2E fixtures.
function readPositiveIntEnv(name: string, fallback: number, env: NodeJS.ProcessEnv) {
const text = String(env[name] ?? fallback).trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`invalid ${name}: ${text}`);
}
const value = Number(text);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`invalid ${name}: ${text}`);
}
return value;
}
import { readPositiveIntEnv } from "../env-limits.mjs";
export function readGatewayNetworkClientConnectTimeoutMs(env: NodeJS.ProcessEnv = process.env) {
if (env.OPENCLAW_GATEWAY_NETWORK_CLIENT_CONNECT_TIMEOUT_MS != null) {
@@ -4,24 +4,13 @@ import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { isRecord } from "../../../lib/record-shared.mjs";
import { extractAgentReplyTexts } from "../agent-turn-output.mjs";
import { readPositiveIntEnv } from "../env-limits.mjs";
import { readPluginInstallRecords } from "../plugin-index-sqlite.mjs";
import { readTextFileTail, tailText } from "../text-file-utils.mjs";
const command = process.argv[2];
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
function readPositiveIntEnv(name, fallback) {
const text = String(process.env[name] ?? fallback).trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`invalid ${name}: ${text}`);
}
const value = Number(text);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`invalid ${name}: ${text}`);
}
return value;
}
const agentTurnTimeoutSeconds = readPositiveIntEnv(
"OPENCLAW_LIVE_PLUGIN_TOOL_TIMEOUT_SECONDS",
300,
+6 -16
View File
@@ -8,18 +8,8 @@ import { pathToFileURL } from "node:url";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { QaProviderMode } from "../../extensions/qa-lab/src/run-config.ts";
import type { QaSuiteRoundTripProbe } from "../../extensions/qa-lab/src/suite-round-trip.ts";
function isTruthyNpmTelegramEnvValue(value: string | undefined) {
const normalized = value?.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes";
}
function splitCsv(value: string | undefined) {
return (value ?? "")
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
import { normalizeCsvOrLooseStringList } from "../../packages/normalization-core/src/string-normalization.ts";
import { isStrictAffirmativeValue } from "../lib/arg-utils.mts";
function parsePositiveIntegerEnv(env: NodeJS.ProcessEnv, name: string) {
const raw = env[name]?.trim();
@@ -89,7 +79,7 @@ function resolvePackageConfigMutation(env: NodeJS.ProcessEnv = process.env) {
}
function resolveRttOptions(env: NodeJS.ProcessEnv, selectedScenarioIds: readonly string[] = []) {
const explicitCheckIds = splitCsv(env.OPENCLAW_NPM_TELEGRAM_RTT_CHECKS);
const explicitCheckIds = normalizeCsvOrLooseStringList(env.OPENCLAW_NPM_TELEGRAM_RTT_CHECKS);
const checkIds = explicitCheckIds.length > 0 ? explicitCheckIds : [DEFAULT_RTT_CHECK_ID];
const unknownCheckIds = checkIds.filter((checkId) => checkId !== DEFAULT_RTT_CHECK_ID);
if (unknownCheckIds.length > 0) {
@@ -147,7 +137,7 @@ async function shouldFailPackageTelegramRun(
result: { summaryPath: string },
env: NodeJS.ProcessEnv = process.env,
) {
if (isTruthyNpmTelegramEnvValue(env.OPENCLAW_NPM_TELEGRAM_ALLOW_FAILURES)) {
if (isStrictAffirmativeValue(env.OPENCLAW_NPM_TELEGRAM_ALLOW_FAILURES)) {
return false;
}
const { readQaSuiteFailedOrSkippedScenarioCountFromFile } =
@@ -204,7 +194,7 @@ async function main() {
const repoRoot = path.resolve(process.env.OPENCLAW_NPM_TELEGRAM_REPO_ROOT ?? process.cwd());
const outputDir = resolvePackageTelegramOutputDir(process.env, repoRoot);
const scenarioIds = splitCsv(process.env.OPENCLAW_NPM_TELEGRAM_SCENARIOS);
const scenarioIds = normalizeCsvOrLooseStringList(process.env.OPENCLAW_NPM_TELEGRAM_SCENARIOS);
const providerMode =
(process.env.OPENCLAW_NPM_TELEGRAM_PROVIDER_MODE as QaProviderMode | undefined) ??
DEFAULT_QA_LIVE_PROVIDER_MODE;
@@ -224,7 +214,7 @@ async function main() {
providerMode,
primaryModel,
alternateModel: process.env.OPENCLAW_NPM_TELEGRAM_ALT_MODEL,
fastMode: isTruthyNpmTelegramEnvValue(process.env.OPENCLAW_NPM_TELEGRAM_FAST),
fastMode: isStrictAffirmativeValue(process.env.OPENCLAW_NPM_TELEGRAM_FAST),
scenarioIds,
resolvedScenarioIds: prioritizeRoundTripProbeScenario(resolvedScenarioIds, rttOptions),
roundTripProbe: createRoundTripProbe(rttOptions),
+1 -4
View File
@@ -3,6 +3,7 @@
import { mkdir, readFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { stripLeadingPackageManagerSeparator } from "../../lib/arg-utils.mts";
import { posixAgentWorkspaceScript } from "./agent-workspace.ts";
import {
die,
@@ -248,10 +249,6 @@ export function parseArgs(argv: string[]): LinuxOptions {
return options;
}
function stripLeadingPackageManagerSeparator(argv: string[]): string[] {
return argv[0] === "--" ? argv.slice(1) : argv;
}
class LinuxSmoke extends SmokeRunController<LinuxOptions> {
private auth: ProviderAuth;
private disableBonjour = parseBoolEnv(process.env.OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR);
+1 -4
View File
@@ -3,6 +3,7 @@
import { readFile, rm } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { stripLeadingPackageManagerSeparator } from "../../lib/arg-utils.mts";
import { posixAgentWorkspaceScript } from "./agent-workspace.ts";
import {
die,
@@ -268,10 +269,6 @@ export function parseArgs(argv: string[]): MacosOptions {
return options;
}
function stripLeadingPackageManagerSeparator(argv: string[]): string[] {
return argv[0] === "--" ? argv.slice(1) : argv;
}
class MacosSmoke {
private agentTimeoutSeconds: number;
private auth: ProviderAuth;
+1 -4
View File
@@ -14,6 +14,7 @@ import {
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { readStringValue } from "@openclaw/normalization-core/string-coerce";
import prettyMilliseconds from "pretty-ms";
import { stripLeadingPackageManagerSeparator } from "../../lib/arg-utils.mts";
import {
die,
ensureValue,
@@ -525,10 +526,6 @@ export function parseArgs(argv: string[]): NpmUpdateOptions {
return options;
}
function stripLeadingPackageManagerSeparator(argv: string[]): string[] {
return argv[0] === "--" ? argv.slice(1) : argv;
}
function platformRecord<T>(value: T): Record<Platform, T> {
return { linux: value, macos: value, windows: value };
}
+1 -4
View File
@@ -2,6 +2,7 @@
// Windows Smoke script supports OpenClaw repository automation.
import path from "node:path";
import { pathToFileURL } from "node:url";
import { stripLeadingPackageManagerSeparator } from "../../lib/arg-utils.mts";
import { windowsAgentWorkspaceScript } from "./agent-workspace.ts";
import {
die,
@@ -246,10 +247,6 @@ export function parseArgs(argv: string[]): WindowsOptions {
return options;
}
function stripLeadingPackageManagerSeparator(argv: string[]): string[] {
return argv[0] === "--" ? argv.slice(1) : argv;
}
class WindowsSmoke extends SmokeRunController<WindowsOptions> {
private auth: ProviderAuth;
private agentTimeoutSeconds = readPositiveIntEnv(
+14 -15
View File
@@ -15,7 +15,7 @@ import { fileURLToPath } from "node:url";
import { clampTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { parseStrictBooleanArg } from "../lib/arg-utils.mts";
import { coerceErrorMessage } from "../lib/error-format.mts";
import { coerceErrorMessage, toStringifiedError } from "../lib/error-format.mts";
import { sleep } from "../lib/sleep.mjs";
import { resolveWindowsTaskkillPath } from "../lib/windows-taskkill.mjs";
import { createPnpmRunnerSpawnSpec } from "../pnpm-runner.mts";
@@ -965,8 +965,7 @@ export function runCommand(params: {
timeoutKillGraceMs,
}).then(
() => reject(error),
(cleanupError: unknown) =>
reject(cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError))),
(cleanupError: unknown) => reject(toStringifiedError(cleanupError)),
);
return;
}
@@ -2070,12 +2069,12 @@ function sshArgs(inspect: CrabboxInspect, sshPort = inspect.sshPort?.trim() || "
}
function isTransientSshFailure(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const message = coerceErrorMessage(error);
return /Connection (?:closed|reset)|Operation timed out|Connection timed out/u.test(message);
}
function isSshConnectionFailure(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const message = coerceErrorMessage(error);
const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
return (
code === "ETIMEDOUT" ||
@@ -3177,7 +3176,7 @@ async function finishSession(root: string, opts: Options, outputDir: string) {
}
desktopSessionTerminationAttempted = true;
await terminateRemoteDesktopSession(root, session.crabbox.inspect).catch((error: unknown) => {
summary.desktopSessionTerminateError = error instanceof Error ? error.message : String(error);
summary.desktopSessionTerminateError = coerceErrorMessage(error);
});
};
try {
@@ -3254,37 +3253,37 @@ async function finishSession(root: string, opts: Options, outputDir: string) {
await stopLocalSutDaemon(session.localSut);
sutQuiesced = true;
} catch (error) {
summary.sutStopError = error instanceof Error ? error.message : String(error);
summary.sutStopError = coerceErrorMessage(error);
summary.status = "fail";
}
if (sutQuiesced) {
try {
preserveLocalSutRuntimeArtifacts(session.localSut, session.outputDir);
} catch (error) {
summary.runtimeArtifactError = error instanceof Error ? error.message : String(error);
summary.runtimeArtifactError = coerceErrorMessage(error);
summary.status = "fail";
}
}
try {
destroyLocalSutRuntime(session.localSut);
} catch (error) {
summary.sutDestroyError = error instanceof Error ? error.message : String(error);
summary.sutDestroyError = coerceErrorMessage(error);
summary.status = "fail";
}
if (session.localSut.funnelBridge) {
await stopTailscaleFunnelBridge(root, session.localSut.funnelBridge).catch(
(error: unknown) => {
summary.funnelResetError = error instanceof Error ? error.message : String(error);
summary.funnelResetError = coerceErrorMessage(error);
},
);
}
await terminateDesktopSession();
await releaseCredential(root, opts, session.credential.leaseFile).catch((error: unknown) => {
summary.credentialReleaseError = error instanceof Error ? error.message : String(error);
summary.credentialReleaseError = coerceErrorMessage(error);
});
if (session.crabbox.createdLease && !opts.keepBox) {
await stopCrabbox(root, opts, session.crabbox.id).catch((error: unknown) => {
summary.crabboxStopError = error instanceof Error ? error.message : String(error);
summary.crabboxStopError = coerceErrorMessage(error);
});
}
if (opts.keepBox) {
@@ -3613,12 +3612,12 @@ async function main() {
killTree(localSut?.mock);
if (credential) {
await releaseCredential(root, opts, credential.leaseFile).catch((error: unknown) => {
summary.credentialReleaseError = error instanceof Error ? error.message : String(error);
summary.credentialReleaseError = coerceErrorMessage(error);
});
}
if (leaseId && createdLease && !opts.keepBox) {
await stopCrabbox(root, opts, leaseId).catch((error: unknown) => {
summary.crabboxStopError = error instanceof Error ? error.message : String(error);
summary.crabboxStopError = coerceErrorMessage(error);
});
}
if (opts.keepBox && leaseId) {
@@ -3685,7 +3684,7 @@ function isMainModule(): boolean {
if (isMainModule()) {
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
console.error(coerceErrorMessage(error));
process.exit(1);
});
}
+3 -12
View File
@@ -12,6 +12,7 @@ import {
createIssueMutationHelpers,
guardTrustedActorCandidates,
isCommentNewerThan,
normalizeGuardLoginSet,
readBoundedGitHubErrorText,
readBoundedGitHubJson,
} from "./guard-shared.mjs";
@@ -264,21 +265,11 @@ export function isDependencyGuardTrustedForHead(comment, currentHeadSha) {
}
export function securityApproverSet(value) {
return new Set(
String(value ?? "")
.split(/[\s,]+/u)
.map((login) => login.trim().toLowerCase())
.filter(Boolean),
);
return normalizeGuardLoginSet(value);
}
export function dependencyGuardCommentAuthors(value) {
return new Set(
String(value ?? "github-actions[bot]")
.split(/[\s,]+/u)
.map((login) => login.trim().toLowerCase())
.filter(Boolean),
);
return normalizeGuardLoginSet(value, "github-actions[bot]");
}
export function isDependencyGuardMarkerComment(comment, marker, trustedAuthors) {
+13
View File
@@ -8,6 +8,19 @@ export const GITHUB_API_REQUEST_TIMEOUT_MS = 30_000;
const githubApiRetryStatuses = new Set([502, 503, 504]);
const githubApiRetryDelaysMs = [1_000, 2_000, 4_000];
/**
* @param {string | null | undefined} value
* @param {string} [fallback]
*/
export function normalizeGuardLoginSet(value, fallback = "") {
return new Set(
(value ?? fallback)
.split(/[\s,]+/u)
.map((login) => login.trim().toLowerCase())
.filter(Boolean),
);
}
export function guardTrustedActorCandidates({ pullRequest, event, currentHeadSha }) {
const eventHeadSha = event?.pull_request?.head?.sha;
const eventAfterSha = event?.after;
+3 -12
View File
@@ -13,6 +13,7 @@ import {
guardCommentHeadSha,
guardTrustedActorCandidates,
isCommentNewerThan,
normalizeGuardLoginSet,
readBoundedGitHubErrorText,
readBoundedGitHubJson,
} from "./guard-shared.mjs";
@@ -170,21 +171,11 @@ export function isSecuritySensitiveGuardTrustedForHead(comment, currentHeadSha)
}
export function securityApproverSet(value) {
return new Set(
String(value ?? "")
.split(/[\s,]+/u)
.map((login) => login.trim().toLowerCase())
.filter(Boolean),
);
return normalizeGuardLoginSet(value);
}
export function securitySensitiveGuardCommentAuthors(value) {
return new Set(
String(value ?? "github-actions[bot]")
.split(/[\s,]+/u)
.map((login) => login.trim().toLowerCase())
.filter(Boolean),
);
return normalizeGuardLoginSet(value, "github-actions[bot]");
}
export function isSecuritySensitiveGuardMarkerComment(comment, trustedAuthors) {
+4 -2
View File
@@ -8,13 +8,13 @@ type StringOptions = {
};
type ConsumedFlag<T extends Record<string, unknown>> = {
flag?: string;
flag: string;
nextIndex: number;
repeatable?: boolean;
apply(target: T): void;
};
type FlagSpec<T extends Record<string, unknown>> = {
export type FlagSpec<T extends Record<string, unknown>> = {
consume(argv: readonly string[], index: number, args: T): ConsumedFlag<T> | null;
};
@@ -40,6 +40,8 @@ export function classifyBoundedUnsignedDecimal(
max: number,
): BoundedUnsignedDecimalResult;
export function parsePermissiveBooleanToken(value: unknown): boolean | undefined;
export function isOpenEndedTruthyValue(value: string | undefined): boolean;
export function isStrictAffirmativeValue(value: string | undefined): boolean;
export function stringFlag<T extends Record<string, unknown>>(
flag: string,
key: string,
+18 -4
View File
@@ -6,7 +6,7 @@
/**
* @template {Record<string, unknown>} T
* @typedef {{
* flag?: string,
* flag: string,
* nextIndex: number,
* repeatable?: boolean,
* apply: ApplyFlag<T>,
@@ -224,6 +224,23 @@ export function parsePermissiveBooleanToken(value) {
}
return PERMISSIVE_BOOLEAN_FALSE_TOKENS.has(normalized) ? false : undefined;
}
const OPEN_ENDED_FALSE_TOKENS = new Set(["", "0", "false", "no"]);
/**
* Treat every non-empty token except the explicit false language as enabled.
* @param {string | undefined} value
*/
export function isOpenEndedTruthyValue(value) {
return !OPEN_ENDED_FALSE_TOKENS.has((value ?? "").trim().toLowerCase());
}
const STRICT_AFFIRMATIVE_TOKENS = new Set(["1", "true", "yes"]);
/**
* Accept only the narrow affirmative token language used by script environment flags.
* @param {string | undefined} value
*/
export function isStrictAffirmativeValue(value) {
return STRICT_AFFIRMATIVE_TOKENS.has(value?.trim().toLowerCase() ?? "");
}
/**
* @param {string} raw
* @param {string} flag
@@ -385,9 +402,6 @@ export function parseFlagArgs(argv, args, specs, options = {}) {
if (!option) {
continue;
}
if (typeof option.flag !== "string" || !option.flag) {
failFlagParse("parseFlagArgs specs must declare a flag for consumed options");
}
if (option.repeatable !== true) {
if (seenFlags.has(option.flag)) {
failFlagParse(
+30
View File
@@ -0,0 +1,30 @@
const FULL_GIT_COMMIT_RE = /^[0-9a-f]{40}$/iu;
type BuildIdentityOptions = {
commitLabel: string;
env?: NodeJS.ProcessEnv;
now?: () => Date;
readGitCommit: () => string | null;
};
/** Pins one timestamp and source commit across every child in a build lifecycle. */
export function resolveBuildIdentityEnvironment({
commitLabel,
env = process.env,
now = () => new Date(),
readGitCommit,
}: BuildIdentityOptions): NodeJS.ProcessEnv {
const explicitTimestamp = env.OPENCLAW_BUILD_TIMESTAMP?.trim();
const explicitCommit = env.GIT_COMMIT?.trim() || env.GIT_SHA?.trim();
const checkedOutCommit = explicitCommit ? null : readGitCommit()?.trim();
// GITHUB_SHA names the workflow invocation and can differ from a checked-out tag.
const commit = explicitCommit || checkedOutCommit || env.GITHUB_SHA?.trim();
if (commit && !FULL_GIT_COMMIT_RE.test(commit)) {
throw new Error(`${commitLabel} must be a full 40-character hexadecimal SHA`);
}
return {
...env,
OPENCLAW_BUILD_TIMESTAMP: explicitTimestamp || now().toISOString(),
...(commit ? { GIT_COMMIT: commit.toLowerCase() } : {}),
};
}
-1
View File
@@ -1047,7 +1047,6 @@ function resolveInfraShardName(file: string): string {
name.startsWith("fixed-window") ||
name.startsWith("format-time/") ||
name.startsWith("http-body") ||
name.startsWith("parse-finite-number") ||
name.startsWith("plain-object") ||
name.startsWith("prototype-keys") ||
name.startsWith("retry") ||
+18
View File
@@ -58,6 +58,24 @@ export function parseNonNegativeInt(raw, label) {
return value;
}
/**
* Parse a safe non-negative integer written in canonical decimal notation.
* @param {unknown} raw
* @param {string} label
* @returns {number}
*/
export function parseStrictNonNegativeDecimal(raw, label) {
const text = String(raw).trim();
if (!/^(0|[1-9]\d*)$/u.test(text)) {
throw new Error(`${label} must be a non-negative integer`);
}
const value = Number(text);
if (!Number.isSafeInteger(value)) {
throw new Error(`${label} must be a safe integer`);
}
return value;
}
/**
* Parse a finite positive number option.
* @param {string | number} raw
+10
View File
@@ -110,6 +110,16 @@ export const deprecatedBarrelPluginSdkEntrypoints = pluginSdkSubpaths.filter((en
deprecatedBarrelPluginSdkSubpathList.includes(entry),
);
/** Supported SDK facades backed by bundled plugins until generic contracts replace them. */
export const supportedBundledFacadeSdkEntrypoints = [
"discord",
"matrix",
"telegram-account",
] as const;
/** Plugin-owned surfaces intentionally public and documented for third-party plugins. */
export const publicPluginOwnedSdkEntrypoints = ["memory-core-host-engine-foundation"] as const;
/**
* Build tsdown entry source paths for plugin SDK entrypoints.
* @internal Shared repository-script contract.
+26 -28
View File
@@ -5,6 +5,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { isRecord as isJsonRecord } from "../../packages/normalization-core/src/record-coerce.ts";
import { normalizeOptionalString } from "../../packages/normalization-core/src/string-coerce.ts";
import { readPublicationArtifactArchive, sha256Digest } from "./actions-artifact-archive.mjs";
import { readBoundedResponseText } from "./bounded-response.mjs";
import { collectClawHubPublishablePluginPackages } from "./plugin-clawhub-release.ts";
@@ -93,16 +94,12 @@ const TRUSTED_TOOLING_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".
const NPM_VIEW_ATTEMPTS = 30;
const NPM_VIEW_RETRY_MAX_DELAY_MS = 10_000;
function normalizeOptionalText(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function compareCodeUnits(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function requireString(value: unknown, label: string): string {
const stringValue = normalizeOptionalText(value);
const stringValue = normalizeOptionalString(value);
if (stringValue === undefined) {
throw new Error(`${label} is missing.`);
}
@@ -197,10 +194,10 @@ export function parseNpmViewFields(raw: string, distTag: string): NpmViewFields
const parsed = parseJson(raw, "npm view");
if (Array.isArray(parsed)) {
return {
version: normalizeOptionalText(parsed[0]),
distTagVersion: normalizeOptionalText(parsed[1]),
integrity: normalizeOptionalText(parsed[2]),
tarball: normalizeOptionalText(parsed[3]),
version: normalizeOptionalString(parsed[0]),
distTagVersion: normalizeOptionalString(parsed[1]),
integrity: normalizeOptionalString(parsed[2]),
tarball: normalizeOptionalString(parsed[3]),
};
}
if (!isJsonRecord(parsed)) {
@@ -209,13 +206,14 @@ export function parseNpmViewFields(raw: string, distTag: string): NpmViewFields
const distTags = isJsonRecord(parsed["dist-tags"]) ? parsed["dist-tags"] : undefined;
const dist = isJsonRecord(parsed.dist) ? parsed.dist : undefined;
return {
version: normalizeOptionalText(parsed.version),
version: normalizeOptionalString(parsed.version),
distTagVersion:
normalizeOptionalText(parsed[`dist-tags.${distTag}`]) ??
normalizeOptionalText(distTags?.[distTag]),
normalizeOptionalString(parsed[`dist-tags.${distTag}`]) ??
normalizeOptionalString(distTags?.[distTag]),
integrity:
normalizeOptionalText(parsed["dist.integrity"]) ?? normalizeOptionalText(dist?.integrity),
tarball: normalizeOptionalText(parsed["dist.tarball"]) ?? normalizeOptionalText(dist?.tarball),
normalizeOptionalString(parsed["dist.integrity"]) ?? normalizeOptionalString(dist?.integrity),
tarball:
normalizeOptionalString(parsed["dist.tarball"]) ?? normalizeOptionalString(dist?.tarball),
};
}
@@ -612,19 +610,19 @@ function verifyWorkflowRun(params: {
if (!isJsonRecord(run)) {
throw new Error(`${params.label}: workflow run returned an unsupported JSON shape.`);
}
const workflowName = normalizeOptionalText(run.workflowName);
const workflowName = normalizeOptionalString(run.workflowName);
if (workflowName !== params.expectedWorkflowName) {
throw new Error(
`${params.label}: run ${params.id} workflow is ${workflowName ?? "<missing>"}, expected ${params.expectedWorkflowName}.`,
);
}
const event = normalizeOptionalText(run.event);
const event = normalizeOptionalString(run.event);
if (event !== "workflow_dispatch") {
throw new Error(
`${params.label}: run ${params.id} event is ${event ?? "<missing>"}, expected workflow_dispatch.`,
);
}
const headBranch = normalizeOptionalText(run.headBranch);
const headBranch = normalizeOptionalString(run.headBranch);
const allowedHeadBranches =
params.allowedHeadBranches ??
(params.expectedHeadBranch !== undefined ? [params.expectedHeadBranch] : []);
@@ -633,11 +631,11 @@ function verifyWorkflowRun(params: {
`${params.label}: run ${params.id} branch is ${headBranch ?? "<missing>"}, expected ${allowedHeadBranches.join(" or ")}.`,
);
}
const status = normalizeOptionalText(run.status);
const conclusion = normalizeOptionalText(run.conclusion);
const status = normalizeOptionalString(run.status);
const conclusion = normalizeOptionalString(run.conclusion);
const jobs = Array.isArray(run.jobs) ? run.jobs.filter(isJsonRecord) : [];
const failedJobs = jobs.filter((job) => {
const jobConclusion = normalizeOptionalText(job.conclusion);
const jobConclusion = normalizeOptionalString(job.conclusion);
return (
jobConclusion !== undefined && jobConclusion !== "success" && jobConclusion !== "skipped"
);
@@ -650,14 +648,14 @@ function verifyWorkflowRun(params: {
}
if (status !== "completed" || conclusion !== "success" || failedJobs.length > 0) {
const failedNames = failedJobs
.map((job) => normalizeOptionalText(job.name) ?? "<unnamed>")
.map((job) => normalizeOptionalString(job.name) ?? "<unnamed>")
.join(", ");
throw new Error(
`${params.label}: run ${params.id} is ${status ?? "<missing>"}/${conclusion ?? "<missing>"}${failedNames ? `; failed jobs: ${failedNames}` : ""}.`,
);
}
const createdAt = normalizeOptionalText(run.createdAt);
const updatedAt = normalizeOptionalText(run.updatedAt);
const createdAt = normalizeOptionalString(run.createdAt);
const updatedAt = normalizeOptionalString(run.updatedAt);
const createdMs = createdAt === undefined ? Number.NaN : Date.parse(createdAt);
const updatedMs = updatedAt === undefined ? Number.NaN : Date.parse(updatedAt);
const durationSeconds =
@@ -667,7 +665,7 @@ function verifyWorkflowRun(params: {
return {
id: params.id,
label: params.label,
url: normalizeOptionalText(run.url),
url: normalizeOptionalString(run.url),
durationSeconds,
};
}
@@ -676,7 +674,7 @@ function requirePositiveIntegerString(value: unknown, label: string): string {
if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) {
return String(value);
}
const stringValue = normalizeOptionalText(value);
const stringValue = normalizeOptionalString(value);
if (stringValue === undefined || !POSITIVE_INTEGER_PATTERN.test(stringValue)) {
throw new Error(`${label} must be a positive integer.`);
}
@@ -1055,14 +1053,14 @@ export function validateClawHubBootstrapEvidence(params: {
);
}
const createdAt = normalizeOptionalText(runBinding.run.created_at);
const updatedAt = normalizeOptionalText(runBinding.run.updated_at);
const createdAt = normalizeOptionalString(runBinding.run.created_at);
const updatedAt = normalizeOptionalString(runBinding.run.updated_at);
const createdMs = createdAt === undefined ? Number.NaN : Date.parse(createdAt);
const updatedMs = updatedAt === undefined ? Number.NaN : Date.parse(updatedAt);
return {
id: runId,
label: "Plugin ClawHub New",
url: normalizeOptionalText(runBinding.run.html_url),
url: normalizeOptionalString(runBinding.run.html_url),
durationSeconds:
Number.isFinite(createdMs) && Number.isFinite(updatedMs)
? Math.max(0, Math.round((updatedMs - createdMs) / 1000))
+1 -1
View File
@@ -41,7 +41,7 @@ const CORE_PROD_REQUIRED_PATHS = [
},
{
path: "scripts/lib/plugin-sdk-entrypoints.json",
whenPresent: "src/plugin-sdk/entrypoints.ts",
whenPresent: "scripts/lib/plugin-sdk-entries.mts",
},
];
+7 -13
View File
@@ -5,6 +5,7 @@ import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSyn
import { tmpdir } from "node:os";
import { delimiter, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { resolveBuildIdentityEnvironment } from "./lib/build-identity.mts";
const WORKSPACE_DIRS_ENV = "OPENCLAW_OCM_WORKSPACE_DEPENDENCY_DIRS";
const REAL_NPM_ENV = "OPENCLAW_OCM_REAL_NPM_BIN";
@@ -12,7 +13,6 @@ const INTERNAL_NPM_BIN_ENV = "OCM_INTERNAL_NPM_BIN";
const ALLOW_UNRELEASED_CHANGELOG_ENV = "OPENCLAW_PREPACK_ALLOW_UNRELEASED_CHANGELOG";
const RUNTIME_BUILD_PROFILE_ENV = "OPENCLAW_OCM_RUNTIME_BUILD_PROFILE";
const supportedRuntimeBuildProfiles = new Set(["sourcePerformance"]);
const fullGitCommitPattern = /^[0-9a-f]{40}$/iu;
type WorkspacePackage = { name: string; version: string; tarball: string };
type WorkspacePackageSource = Omit<WorkspacePackage, "tarball"> & { dir: string };
@@ -118,18 +118,12 @@ export function resolveRuntimePackEnvironment(
return result.status === 0 ? result.stdout.trim() : null;
},
) {
const explicitTimestamp = env.OPENCLAW_BUILD_TIMESTAMP?.trim();
const explicitCommit = env.GIT_COMMIT?.trim() || env.GIT_SHA?.trim();
const checkedOutCommit = explicitCommit ? null : readGitCommit()?.trim();
const commit = explicitCommit || checkedOutCommit || env.GITHUB_SHA?.trim();
if (commit && !fullGitCommitPattern.test(commit)) {
throw new Error("runtime pack commit must be a full 40-character hexadecimal SHA");
}
return {
...env,
OPENCLAW_BUILD_TIMESTAMP: explicitTimestamp || now().toISOString(),
...(commit ? { GIT_COMMIT: commit.toLowerCase() } : {}),
};
return resolveBuildIdentityEnvironment({
commitLabel: "runtime pack commit",
env,
now,
readGitCommit,
});
}
function runTar(args: string[]) {
+7 -17
View File
@@ -6,6 +6,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
import { basename, delimiter, join } from "node:path";
import { pathToFileURL } from "node:url";
import { formatErrorMessage } from "../src/infra/errors.ts";
import { resolveBuildIdentityEnvironment } from "./lib/build-identity.mts";
import { readPositiveEnvInt } from "./lib/numeric-options.mjs";
import { writePackageDistInventoryForPublish } from "./lib/package-dist-inventory.ts";
import { restorePrepackArtifacts } from "./openclaw-postpack.mjs";
@@ -13,7 +14,6 @@ import { preparePackageChangelog } from "./package-changelog.mjs";
import { preparePackageDocsMap } from "./package-docs-map.mjs";
import { preparePackageManifest } from "./package-manifest.mjs";
import { createPnpmRunnerSpawnSpec } from "./pnpm-runner.mts";
const FULL_GIT_COMMIT_RE = /^[0-9a-f]{40}$/iu;
const requiredPreparedPathGroups = [
["dist/index.js", "dist/index.mjs"],
["dist/control-ui/index.html"],
@@ -249,22 +249,12 @@ export function resolvePrepackBuildEnvironment(
return result.status === 0 ? result.stdout.trim() : null;
},
): NodeJS.ProcessEnv {
const explicitTimestamp = env.OPENCLAW_BUILD_TIMESTAMP?.trim();
const explicitCommit = env.GIT_COMMIT?.trim() || env.GIT_SHA?.trim();
const checkedOutCommit = explicitCommit ? null : readGitCommit()?.trim();
// GITHUB_SHA names the workflow invocation and can differ from a checked-out tag.
const commit = explicitCommit || checkedOutCommit || env.GITHUB_SHA?.trim();
if (commit && !FULL_GIT_COMMIT_RE.test(commit)) {
throw new Error("build commit must be a full 40-character hexadecimal SHA");
}
const buildEnv: NodeJS.ProcessEnv = {
...env,
OPENCLAW_BUILD_TIMESTAMP: explicitTimestamp || now().toISOString(),
};
if (commit) {
buildEnv.GIT_COMMIT = commit.toLowerCase();
}
return buildEnv;
return resolveBuildIdentityEnvironment({
commitLabel: "build commit",
env,
now,
readGitCommit,
});
}
function runPnpm(args: string[], env: NodeJS.ProcessEnv): void {
+6 -138
View File
@@ -3,14 +3,13 @@ import { spawnSync } from "node:child_process";
import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { listPluginCompatRecords } from "../src/plugins/compat/registry.ts";
import type { PluginCompatRecord } from "../src/plugins/compat/types.ts";
import {
pluginSdkEntrypoints,
publicPluginOwnedSdkEntrypoints,
reservedBundledPluginSdkEntrypoints,
supportedBundledFacadeSdkEntrypoints,
} from "../src/plugin-sdk/entrypoints.ts";
import { listPluginCompatRecords } from "../src/plugins/compat/registry.ts";
import type { PluginCompatRecord } from "../src/plugins/compat/types.ts";
} from "./lib/plugin-sdk-entries.mts";
const REPO_ROOT = process.cwd();
const SOURCE_ROOTS = ["src", "extensions", "packages", "scripts", "test", "docs"] as const;
@@ -23,16 +22,11 @@ const SKIPPED_DIRS = new Set([
"node_modules",
]);
const TEXT_FILE_PATTERN = /\.(?:[cm]?[jt]sx?|json|mdx?|ya?ml)$/u;
const PLUGIN_SDK_SPECIFIER_PATTERN =
/\b(?:from\s*["']|import\s*\(\s*["']|require\s*\(\s*["']|vi\.(?:mock|doMock)\s*\(\s*["'])(openclaw\/plugin-sdk\/([a-z0-9][a-z0-9-]*))["']/g;
type CliOptions = {
json: boolean;
summary: boolean;
owner?: string;
failOnCrossOwner: boolean;
failOnEligibleCompat: boolean;
failOnUnclassifiedUnusedReserved: boolean;
help: boolean;
};
@@ -73,15 +67,6 @@ type WorkspaceTextFile = {
source: string;
};
type ReservedSdkImport = {
file: string;
specifier: string;
subpath: string;
owner?: string;
consumerOwner?: string;
relation: "owner" | "cross-owner" | "workspace";
};
type BoundaryReport = {
generatedAt: string;
compat: {
@@ -94,12 +79,8 @@ type BoundaryReport = {
};
pluginSdk: {
entrypointCount: number;
reservedCount: number;
supportedBundledFacadeCount: number;
publicPluginOwnedCount: number;
reservedImports: ReservedSdkImport[];
crossOwnerReservedImports: ReservedSdkImport[];
unusedReservedSubpaths: string[];
};
memoryHostSdk: {
privatePackage: boolean;
@@ -123,14 +104,8 @@ type BoundaryReportSummary = {
};
pluginSdk: {
entrypointCount: number;
reservedCount: number;
supportedBundledFacadeCount: number;
publicPluginOwnedCount: number;
reservedImportCount: number;
crossOwnerReservedImportCount: number;
unusedReservedCount: number;
unusedReservedSubpaths: string[];
crossOwnerReservedImports: ReservedSdkImport[];
};
memoryHostSdk: {
privatePackage: boolean;
@@ -276,9 +251,7 @@ function parseArgs(args: readonly string[]): CliOptions {
const options: CliOptions = {
json: false,
summary: false,
failOnCrossOwner: false,
failOnEligibleCompat: false,
failOnUnclassifiedUnusedReserved: false,
help: false,
};
for (let index = 0; index < args.length; index += 1) {
@@ -294,12 +267,8 @@ function parseArgs(args: readonly string[]): CliOptions {
}
options.owner = owner;
index += 1;
} else if (arg === "--fail-on-cross-owner") {
options.failOnCrossOwner = true;
} else if (arg === "--fail-on-eligible-compat") {
options.failOnEligibleCompat = true;
} else if (arg === "--fail-on-unclassified-unused-reserved") {
options.failOnUnclassifiedUnusedReserved = true;
} else if (arg === "--help" || arg === "-h") {
options.help = true;
} else {
@@ -316,30 +285,11 @@ function renderHelp(): string {
"Options:",
" --summary Print compact counts only.",
" --json Emit JSON instead of text.",
" --owner <id> Filter compat/imports/reserved shims by owner id.",
" --fail-on-cross-owner Exit non-zero on cross-owner reserved SDK imports.",
" --owner <id> Filter compatibility records by owner id.",
" --fail-on-eligible-compat Exit non-zero when deprecated compat is due for removal.",
" --fail-on-unclassified-unused-reserved Exit non-zero on unused reserved SDK shims.",
].join("\n");
}
function collectBundledPluginIds(): string[] {
return readdirSync(resolve(REPO_ROOT, "extensions"), { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.toSorted((left, right) => right.length - left.length || left.localeCompare(right));
}
function resolvePluginOwner(entrypoint: string, pluginIds: readonly string[]): string | undefined {
return pluginIds.find(
(pluginId) => entrypoint === pluginId || entrypoint.startsWith(`${pluginId}-`),
);
}
function resolveConsumerOwner(file: string): string | undefined {
return /^extensions\/([^/]+)\//u.exec(file)?.[1];
}
function extractCompatTokensFromValues(values: readonly (string | undefined)[]): string[] {
const tokens = new Set<string>();
for (const value of values) {
@@ -481,32 +431,6 @@ function collectRemovalPendingDebt(
);
}
function collectReservedSdkImports(files: readonly WorkspaceTextFile[]): ReservedSdkImport[] {
const reserved = new Set<string>(reservedBundledPluginSdkEntrypoints);
const pluginIds = collectBundledPluginIds();
const imports: ReservedSdkImport[] = [];
for (const { relativeFile, source } of files) {
for (const match of source.matchAll(PLUGIN_SDK_SPECIFIER_PATTERN)) {
const specifier = match[1];
const subpath = match[2];
if (!specifier || !subpath || !reserved.has(subpath)) {
continue;
}
const owner = resolvePluginOwner(subpath, pluginIds);
const consumerOwner = resolveConsumerOwner(relativeFile);
const relation =
owner && consumerOwner ? (owner === consumerOwner ? "owner" : "cross-owner") : "workspace";
imports.push({ file: relativeFile, specifier, subpath, owner, consumerOwner, relation });
}
}
return imports.toSorted(
(left, right) =>
left.subpath.localeCompare(right.subpath) ||
left.file.localeCompare(right.file) ||
left.specifier.localeCompare(right.specifier),
);
}
function collectMemoryHostBoundary(
files: readonly WorkspaceTextFile[],
): BoundaryReport["memoryHostSdk"] {
@@ -595,14 +519,8 @@ function buildSummary(report: BoundaryReport, owner?: string): BoundaryReportSum
},
pluginSdk: {
entrypointCount: report.pluginSdk.entrypointCount,
reservedCount: report.pluginSdk.reservedCount,
supportedBundledFacadeCount: report.pluginSdk.supportedBundledFacadeCount,
publicPluginOwnedCount: report.pluginSdk.publicPluginOwnedCount,
reservedImportCount: report.pluginSdk.reservedImports.length,
crossOwnerReservedImportCount: report.pluginSdk.crossOwnerReservedImports.length,
unusedReservedCount: report.pluginSdk.unusedReservedSubpaths.length,
unusedReservedSubpaths: report.pluginSdk.unusedReservedSubpaths,
crossOwnerReservedImports: report.pluginSdk.crossOwnerReservedImports,
},
memoryHostSdk: {
privatePackage: report.memoryHostSdk.privatePackage,
@@ -618,25 +536,12 @@ function buildReport(options: Partial<Pick<CliOptions, "owner" | "summary">> = {
const files = options.summary
? collectSummaryWorkspaceTextFileSources()
: collectWorkspaceTextFileSources();
const pluginIds = collectBundledPluginIds();
const compatRecords = collectCompatDebt(files, new Date(), {
includeReferenceFiles: !options.summary,
}).filter((record) => matchesOwner(options.owner, record.owner));
const removalPending = collectRemovalPendingDebt(files).filter((record) =>
matchesOwner(options.owner, record.owner),
);
const reservedImports = collectReservedSdkImports(files).filter(
(entry) =>
matchesOwner(options.owner, entry.owner) || matchesOwner(options.owner, entry.consumerOwner),
);
const usedReserved = new Set(reservedImports.map((entry) => entry.subpath));
const unusedReservedSubpaths = (reservedBundledPluginSdkEntrypoints as readonly string[])
.filter(
(subpath) =>
!usedReserved.has(subpath) &&
matchesOwner(options.owner, resolvePluginOwner(subpath, pluginIds)),
)
.toSorted((a, b) => a.localeCompare(b));
return {
generatedAt: new Date().toISOString(),
compat: {
@@ -649,14 +554,8 @@ function buildReport(options: Partial<Pick<CliOptions, "owner" | "summary">> = {
},
pluginSdk: {
entrypointCount: pluginSdkEntrypoints.length,
reservedCount: reservedBundledPluginSdkEntrypoints.length,
supportedBundledFacadeCount: supportedBundledFacadeSdkEntrypoints.length,
publicPluginOwnedCount: publicPluginOwnedSdkEntrypoints.length,
reservedImports,
crossOwnerReservedImports: reservedImports.filter(
(entry) => entry.relation === "cross-owner",
),
unusedReservedSubpaths,
},
memoryHostSdk: collectMemoryHostBoundary(files),
};
@@ -675,17 +574,8 @@ function renderSummaryText(summary: BoundaryReportSummary): string {
);
}
lines.push(
`plugin-sdk entrypoints=${summary.pluginSdk.entrypointCount} reserved=${summary.pluginSdk.reservedCount}`,
`plugin-sdk entrypoints=${summary.pluginSdk.entrypointCount} supportedBundledFacade=${summary.pluginSdk.supportedBundledFacadeCount} publicPluginOwned=${summary.pluginSdk.publicPluginOwnedCount}`,
);
lines.push(
` reservedImports=${summary.pluginSdk.reservedImportCount} crossOwnerReservedImports=${summary.pluginSdk.crossOwnerReservedImportCount} unusedReserved=${summary.pluginSdk.unusedReservedCount}`,
);
for (const subpath of summary.pluginSdk.unusedReservedSubpaths) {
lines.push(` unused-reserved ${subpath}`);
}
for (const entry of summary.pluginSdk.crossOwnerReservedImports) {
lines.push(` cross-owner ${entry.file}: ${entry.specifier} owner=${entry.owner ?? "unknown"}`);
}
lines.push(
`memory-host-sdk implementation=${summary.memoryHostSdk.implementation} private=${summary.memoryHostSdk.privatePackage} exports=${summary.memoryHostSdk.exportedSubpathCount} sourceBridgeFiles=${summary.memoryHostSdk.sourceBridgeFileCount} coreReferenceFiles=${summary.memoryHostSdk.packageCoreReferenceFileCount}`,
);
@@ -714,17 +604,8 @@ function renderText(report: BoundaryReport, owner?: string): string {
}
lines.push("");
lines.push(
`plugin-sdk entrypoints=${report.pluginSdk.entrypointCount} reserved=${report.pluginSdk.reservedCount} supportedBundledFacade=${report.pluginSdk.supportedBundledFacadeCount} publicPluginOwned=${report.pluginSdk.publicPluginOwnedCount}`,
`plugin-sdk entrypoints=${report.pluginSdk.entrypointCount} supportedBundledFacade=${report.pluginSdk.supportedBundledFacadeCount} publicPluginOwned=${report.pluginSdk.publicPluginOwnedCount}`,
);
lines.push(
` reservedImports=${report.pluginSdk.reservedImports.length} crossOwnerReservedImports=${report.pluginSdk.crossOwnerReservedImports.length} unusedReserved=${report.pluginSdk.unusedReservedSubpaths.length}`,
);
for (const subpath of report.pluginSdk.unusedReservedSubpaths) {
lines.push(` unused-reserved ${subpath}`);
}
for (const entry of report.pluginSdk.crossOwnerReservedImports) {
lines.push(` cross-owner ${entry.file}: ${entry.specifier} owner=${entry.owner ?? "unknown"}`);
}
lines.push("");
lines.push(
`memory-host-sdk implementation=${resolveMemoryHostImplementation(report.memoryHostSdk)} private=${report.memoryHostSdk.privatePackage} exports=${report.memoryHostSdk.exportedSubpaths.length} sourceBridgeFiles=${report.memoryHostSdk.sourceBridgeFiles.length} coreReferenceFiles=${report.memoryHostSdk.packageCoreReferenceFiles.length}`,
@@ -734,19 +615,6 @@ function renderText(report: BoundaryReport, owner?: string): string {
function collectFailures(report: BoundaryReport, options: CliOptions): string[] {
const failures: string[] = [];
if (options.failOnCrossOwner && report.pluginSdk.crossOwnerReservedImports.length > 0) {
failures.push(
`${report.pluginSdk.crossOwnerReservedImports.length} cross-owner reserved SDK import(s) found`,
);
}
if (
options.failOnUnclassifiedUnusedReserved &&
report.pluginSdk.unusedReservedSubpaths.length > 0
) {
failures.push(
`${report.pluginSdk.unusedReservedSubpaths.length} unused reserved SDK subpath(s) found`,
);
}
if (options.failOnEligibleCompat && report.compat.eligibleForRemovalCount > 0) {
failures.push(
`${report.compat.eligibleForRemovalCount} compatibility record(s) are due for removal`,
+2 -1
View File
@@ -365,7 +365,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
// -1: infra-runtime now names its error exports explicitly.
// -1: infra-runtime excludes the internal system-event receipt API.
// -2: text-runtime names record and string coercion compatibility exports explicitly.
76,
// -1: infra-runtime re-exports number coercion directly from its canonical owner.
75,
env,
),
};
+1 -1
View File
@@ -2,7 +2,7 @@
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import { parseStrictPositiveInteger } from "../src/infra/parse-finite-number.js";
import { parseStrictPositiveInteger } from "@openclaw/normalization-core/number-coercion";
const options = {
help: { type: "boolean", short: "h" },
+7 -7
View File
@@ -5,7 +5,12 @@ import fs from "node:fs";
import path from "node:path";
import ts from "typescript";
import { isChangedLaneTestPath } from "./changed-lanes.mts";
import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mts";
import {
booleanFlag,
isOpenEndedTruthyValue,
parseFlagArgs,
stringFlag,
} from "./lib/arg-utils.mts";
import { runAsScript } from "./lib/ts-guard-utils.mts";
type AddedLine = {
@@ -100,11 +105,6 @@ function shouldInspectManualHelperUsage(filePath: string): boolean {
return normalizedPath !== TEMP_DIR_HELPER_TEST_PATH && shouldInspectFile(normalizedPath);
}
function isTruthyEnvFlag(value: string | undefined): boolean {
const normalized = value?.trim().toLowerCase() ?? "";
return normalized !== "" && normalized !== "0" && normalized !== "false" && normalized !== "no";
}
function escapeGithubCommandValue(value: unknown): string {
return String(value).replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
}
@@ -516,7 +516,7 @@ async function main(argv?: string[], io?: ScriptIo): Promise<0 | 1> {
stdout.write(`${JSON.stringify(findings, null, 2)}\n`);
} else if (findings.length === 0) {
stderr.write("No new test temp-directory migration warnings found.\n");
} else if (isTruthyEnvFlag(env.GITHUB_ACTIONS)) {
} else if (isOpenEndedTruthyValue(env.GITHUB_ACTIONS)) {
for (const finding of findings) {
stderr.write(`${formatGithubWarning(finding)}\n`);
}
@@ -100,11 +100,6 @@ export const BOUNDARY_CHECKS = (
"pnpm",
["run", "lint:extensions:no-normalization-core-bypass"],
],
[
"extension-plugin-sdk-internal-boundary",
"pnpm",
["run", "lint:extensions:no-plugin-sdk-internal"],
],
[
"extension-relative-outside-package-boundary",
"pnpm",
+5 -5
View File
@@ -6,7 +6,7 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import pMap from "p-map";
import { coerceErrorMessage as formatSpawnError } from "./lib/error-format.mts";
import { coerceErrorMessage } from "./lib/error-format.mts";
import { parsePositiveInt } from "./lib/numeric-options.mjs";
import {
buildGroupedTestComparison,
@@ -361,7 +361,7 @@ export function signalTestGroupReportChild(
} catch (error) {
if (error && !hasErrorCode(error, "ESRCH")) {
appendDiagnostic(
`[test-group-report] failed to send ${signal} to process group: ${formatSpawnError(error)}\n`,
`[test-group-report] failed to send ${signal} to process group: ${coerceErrorMessage(error)}\n`,
);
}
}
@@ -733,7 +733,7 @@ function readReportInputs(entries: ReportInputEntry[]) {
} catch (error) {
invalid.push({
entry,
reason: error instanceof Error ? error.message : String(error),
reason: coerceErrorMessage(error),
});
}
}
@@ -1134,7 +1134,7 @@ export async function runReportPlans(params: {
`[test-group-report] config failed; keeping partial report from ${run.reportPath}`,
);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
const reason = coerceErrorMessage(error);
console.error(
`[test-group-report] config failed; skipping unusable JSON report from ${run.reportPath} (${reason})`,
);
@@ -1283,7 +1283,7 @@ const isMain =
if (isMain) {
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
console.error(coerceErrorMessage(error));
process.exit(1);
});
}
+12 -11
View File
@@ -1,21 +1,22 @@
// Runs a Vitest config and enforces wall-time regression budgets.
import { pathToFileURL } from "node:url";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { booleanFlag, parseFlagArgs, stringFlag, type FlagSpec } from "./lib/arg-utils.mts";
import {
booleanFlag,
isStrictAffirmativeValue,
parseFlagArgs,
stringFlag,
type FlagSpec,
} from "./lib/arg-utils.mts";
import {
budgetFloatFlag,
parseBudgetNumber,
readBudgetEnvNumber,
} from "./lib/budget-number-args.mts";
import { coerceErrorMessage as formatErrorMessage } from "./lib/error-format.mts";
import { coerceErrorMessage } from "./lib/error-format.mts";
import { formatMs } from "./lib/vitest-report-cli-utils.mts";
import { readJsonFile, runVitestJsonReport } from "./test-report-utils.mts";
function readBooleanEnv(name: string, env = process.env) {
const normalized = env[name]?.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes";
}
type PerfBudgetOptions = {
baselineWallMs: number | null;
config: string;
@@ -61,7 +62,7 @@ function parseArgs(argv: readonly string[], env = process.env) {
maxWallMs: readBudgetEnvNumber("OPENCLAW_TEST_PERF_MAX_WALL_MS", env),
baselineWallMs: readBudgetEnvNumber("OPENCLAW_TEST_PERF_BASELINE_WALL_MS", env),
maxRegressionPct: readBudgetEnvNumber("OPENCLAW_TEST_PERF_MAX_REGRESSION_PCT", env) ?? 10,
reportOnly: readBooleanEnv("OPENCLAW_TEST_PERF_REPORT_ONLY", env),
reportOnly: isStrictAffirmativeValue(env.OPENCLAW_TEST_PERF_REPORT_ONLY),
},
[
stringFlag("--config", "config"),
@@ -85,7 +86,7 @@ function collectPerfReportStats(reportPath: string) {
report = readJsonFile(reportPath);
} catch (error) {
throw new Error(
`[test-perf-budget] failed to read Vitest JSON report ${reportPath}: ${formatErrorMessage(
`[test-perf-budget] failed to read Vitest JSON report ${reportPath}: ${coerceErrorMessage(
error,
)}`,
{ cause: error },
@@ -113,7 +114,7 @@ function main() {
try {
opts = parseArgs(process.argv.slice(2));
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
console.error(coerceErrorMessage(error));
process.exit(1);
}
@@ -128,7 +129,7 @@ function main() {
try {
reportStats = collectPerfReportStats(reportPath);
} catch (error) {
console.error(formatErrorMessage(error));
console.error(coerceErrorMessage(error));
process.exit(1);
}
@@ -9,8 +9,11 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import * as tar from "tar";
import { readPositiveIntEnv } from "./e2e/lib/env-limits.mjs";
import { sleep } from "./lib/sleep.mjs";
export { readPositiveIntEnv };
const DEFAULT_NPM_COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
const DEFAULT_NPM_COMMAND_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
@@ -228,18 +231,6 @@ export function resolveNpmPackFilename(output: string) {
return filename;
}
export function readPositiveIntEnv(name: string, fallback: number, env = process.env) {
const text = String(env[name] ?? fallback).trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`invalid ${name}: ${text}`);
}
const value = Number(text);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`invalid ${name}: ${text}`);
}
return value;
}
export function readPluginNpmCommandOptions(env: NodeJS.ProcessEnv = process.env) {
return {
encoding: "utf8",