fix(runtime): close Bun API cluster loader and fixture gaps (#163052)

Preserve plugin capture conditions, observed missing inputs, and lazy dependency acquisition under Bun. Target portable worker and recovery bundles at Node independently of the build runtime, and migrate runtime fixtures to owned cleanup channels.

Validation: paired Node24/Bun17c9 covering suites; 54 Node plugin cases and 46 Bun cases with eight exact-pin skips; full build, changed-file checks, and import-cycle checks; green exact-head CI. The published 2026.9.7 updater installed the verified candidate, replaced the managed service, completed its durable update run, and passed readiness and build-identity checks.
This commit is contained in:
Peter Steinberger 2026-10-01 16:40:33 -07:00 • committed by GitHub
parent aa9fcc5cea
commit b3cb94506b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 925 additions and 293 deletions

View file

@ -58,6 +58,8 @@ The Gateway reuses its prepared archive for subsequent enrollments with the same
While a prepared worker is provisioning, cache cleanup retains the exact worker bundle recorded at admission, including before readiness produces a bootstrap receipt. After the environment reaches a terminal state, normal bundle cleanup can reclaim those bytes when no other environment or placement needs them.
Worker bundles include their JavaScript dependencies, including the WebSocket transport. They target Node.js even when Bun runs the build; the destination still needs a supported Node.js installation.
### Reuse a node runtime archive after Gateway restart
Linux and macOS deployment images can retain an already prepared node runtime archive as `node-runtime.tgz` in the running OpenClaw package root, beside `package.json`. During image preparation, copy the producer's archive there before closing the producer:

View file

@ -221,7 +221,11 @@ without copying the surrounding workspace. Compiled bundled runtime and setup
modules share the host's code identity; each inventory still owns its registered
callbacks and cleanup. Replacing that compiled code requires a build and Gateway
restart. Conditional package aliases retain their package metadata, and native
Node conditions select the target from that captured metadata. Legacy packages
Node conditions, including `module-sync`, select the target from that captured metadata.
Source inspection uses the same synchronous-module condition without evaluating plugin code.
Captured source retains the difference between authored imports and require calls, so Bun's
compiler resolution previews do not acquire a deferred dependency before its first call.
Missing selected targets remain absent for that captured generation. Legacy packages
without an exports map also admit their existing main or index entry without
executing unselected code. Native entries reuse the recorded admission below.
The selected package's remaining body is captured before execution.

View file

@ -135,6 +135,11 @@ Use `requireNodeTool("node")` and `stripNodeTypeScriptTypes` from
`test/helpers/node-toolchain.ts`, which share that Node-selection owner, while
keeping the Vitest worker on the selected test runtime.
Isolated native worker and subprocess fixtures use `mockNativeModuleExports` from
`test/helpers/native-module-mock.ts` for controlled module exports on either runtime.
The mocks live until that child exits. Capture original call-through functions before
registering replacements because Bun updates existing module namespace bindings.
The test toolchain pins stable Vitest `5.0.1`, including its browser and coverage
packages. Use `describe(name, { concurrent: false }, callback)` for ordered
suites. Await asynchronous assertions, keep `vi.mock`/`vi.hoisted` at module

View file

@ -1,4 +1,3 @@
import { isBuiltin } from "node:module";
import { fileURLToPath } from "node:url";
import type { UserConfig } from "tsdown";
import { packageActivationRuntimeEntrypoint } from "../../src/infra/package-update-activation-runtime-assets.ts";
@ -47,7 +46,7 @@ function createSealedRecoveryBuildConfig(entry: typeof managedHandoffRuntimeEntr
},
},
],
deps: { alwaysBundle: (id) => !isBuiltin(id), onlyBundle: false },
deps: { alwaysBundle: () => true, onlyBundle: false },
outExtensions: () => ({ js: ".mjs" }),
outputOptions: { codeSplitting: false },
shims: true,

View file

@ -1,5 +1,5 @@
import { mock } from "node:test";
import { parentPort } from "node:worker_threads";
import { mockNativeModuleExports } from "../../../test/helpers/native-module-mock.js";
import type {
CatalogInspection,
CatalogInspectionTask,
@ -10,40 +10,38 @@ let task: CatalogInspectionTask;
let sqliteCopies = 0;
let plans: CatalogInspection["plans"] = [];
const sqlite = await import("../../infra/sqlite-snapshot-source.js");
mock.module(new URL("../../infra/sqlite-snapshot-source.ts", import.meta.url).href, {
namedExports: {
...sqlite,
prepareSqliteReadOnlyLocationSync: (
...args: Parameters<typeof sqlite.prepareSqliteReadOnlyLocationSync>
) => {
sqliteCopies += 1;
return sqlite.prepareSqliteReadOnlyLocationSync(...args);
},
// Bun updates existing namespace bindings when a module is mocked.
const prepareSqliteReadOnlyLocationSync = sqlite.prepareSqliteReadOnlyLocationSync;
mockNativeModuleExports(new URL("../../infra/sqlite-snapshot-source.ts", import.meta.url), {
...sqlite,
prepareSqliteReadOnlyLocationSync: (
...args: Parameters<typeof prepareSqliteReadOnlyLocationSync>
) => {
sqliteCopies += 1;
return prepareSqliteReadOnlyLocationSync(...args);
},
});
const models = await import("../models-config.js");
mock.module(new URL("../models-config.ts", import.meta.url).href, {
namedExports: {
...models,
planOpenClawModelsJsonSource: async (
...args: Parameters<typeof models.planOpenClawModelsJsonSource>
) => {
const plan = await models.planOpenClawModelsJsonSource(...args);
plans.push(plan);
return plan;
},
const planOpenClawModelsJsonSource = models.planOpenClawModelsJsonSource;
mockNativeModuleExports(new URL("../models-config.ts", import.meta.url), {
...models,
planOpenClawModelsJsonSource: async (
...args: Parameters<typeof planOpenClawModelsJsonSource>
) => {
const plan = await planOpenClawModelsJsonSource(...args);
plans.push(plan);
return plan;
},
});
const catalog = await import("../prepared-model-runtime.full-catalog.js");
mock.module(new URL("../prepared-model-runtime.full-catalog.ts", import.meta.url).href, {
namedExports: {
...catalog,
prepareFullCatalogFacts: (...args: Parameters<typeof catalog.prepareFullCatalogFacts>) => {
if (task.inspection?.failCatalog) {
throw new Error("synthetic catalog construction failure");
}
return catalog.prepareFullCatalogFacts(...args);
},
const prepareFullCatalogFacts = catalog.prepareFullCatalogFacts;
mockNativeModuleExports(new URL("../prepared-model-runtime.full-catalog.ts", import.meta.url), {
...catalog,
prepareFullCatalogFacts: (...args: Parameters<typeof prepareFullCatalogFacts>) => {
if (task.inspection?.failCatalog) {
throw new Error("synthetic catalog construction failure");
}
return prepareFullCatalogFacts(...args);
},
});
const { getAuthoredConfigSecretRef, getConfigResolutionFacts, getResolvedConfigEnvSecretRef } =
@ -54,8 +52,7 @@ const { resolveUsableCustomProviderApiKey } = await import("../model-auth-provid
const { inspectSharedAuthLegacyRowsReadOnly } =
await import("../auth-profiles/shared-store-bootstrap.js");
// Inspect the exact received clone and completed worker result, not a reconstructed parent copy.
port.on("message", (message: { input: CatalogInspectionTask }) => {
function inspectInput(message: { input: CatalogInspectionTask }) {
task = message.input;
sqliteCopies = 0;
plans = [];
@ -66,7 +63,19 @@ port.on("message", (message: { input: CatalogInspectionTask }) => {
env: task.value.input.env,
});
}
});
}
// Observe input with the real handler so initialization cannot consume queued tasks early.
const on = port.on.bind(port);
port.on = (event, listener) => {
if (event === "message") {
port.on = on;
return on(event, (message: { input: CatalogInspectionTask }) => {
inspectInput(message);
Reflect.apply(listener, port, [message]);
});
}
return on(event, listener);
};
const post = port.postMessage.bind(port);
port.postMessage = (message: { status: string; value?: object }, transferList) => {
let response = message;

View file

@ -178,6 +178,20 @@ describe("backup create CLI", () => {
const { syncBuiltinESMExports } = require("node:module");
const sqlite = process.getBuiltinModule("node:sqlite");
const originalBackup = sqlite.backup.bind(sqlite);
if (process.versions.bun) {
const workerThreads = require("node:worker_threads");
const OriginalWorker = workerThreads.Worker;
const preload = process.env.OPENCLAW_TEST_SQLITE_WORKER_PRELOAD;
// The CLI's nested Workers do not inherit the Vitest parent's preload spy.
workerThreads.Worker = class Worker extends OriginalWorker {
constructor(filename, options = {}) {
super(filename, {
...options,
execArgv: [...(options.execArgv ?? process.execArgv), "--preload", preload],
});
}
};
}
const markerPath = process.env.PROOF_SNAPSHOT_MARKER;
const realNow = Date.now.bind(Date);
// Acquisition runs in a worker; every isolate must observe the same elapsed time.

View file

@ -1,4 +1,4 @@
import { mock } from "node:test";
import { mockNativeModuleExports } from "../../test/helpers/native-module-mock.js";
const extension = import.meta.url.endsWith(".ts") ? "ts" : "js";
const count = 150_000;
@ -16,71 +16,71 @@ let serviceCalls = 0;
// Keep the actual command, grid, and label-summary owners. Only fixture the
// service and unrelated metadata boundaries; no large database is needed.
mock.module(new URL(`../config/config.${extension}`, import.meta.url), {
namedExports: { getRuntimeConfig: () => ({}) },
mockNativeModuleExports(new URL(`../config/config.${extension}`, import.meta.url), {
getRuntimeConfig: () => ({}),
});
mock.module(new URL(`./session-store-targets.${extension}`, import.meta.url), {
namedExports: { resolveCommandSessionStoreTargets: () => [{ agentId: "main", storePath }] },
mockNativeModuleExports(new URL(`./session-store-targets.${extension}`, import.meta.url), {
resolveCommandSessionStoreTargets: () => [{ agentId: "main", storePath }],
});
mock.module(new URL(`../config/sessions.${extension}`, import.meta.url), {
namedExports: {
resolveSessionCleanupAction: () => "keep",
isSessionsCleanupPartialResult: unexpected,
serializeSessionCleanupResult: unexpected,
runSessionsCleanup: async () => {
serviceCalls += 1;
return {
mode: "warn",
appliedSummaries: [],
previewResults: [
{
summary: {
agentId: "main",
storePath,
mode: "warn",
dryRun: true,
beforeCount: count,
afterCount: count,
missing: 0,
dmScopeRetired: 0,
modelRunPruned: 0,
pruned: 0,
capped: 0,
diskBudget: null,
wouldMutate: false,
},
beforeStore,
missingKeys: new Set(),
modelRunPrunedKeys: new Set(),
archivedKeys: new Set(),
staleKeys: new Set(),
cappedKeys: new Set(),
dmScopeRetiredKeys: new Set(),
mockNativeModuleExports(new URL(`../config/sessions.${extension}`, import.meta.url), {
resolveSessionCleanupAction: () => "keep",
isSessionsCleanupPartialResult: unexpected,
serializeSessionCleanupResult: unexpected,
runSessionsCleanup: async () => {
serviceCalls += 1;
return {
mode: "warn",
appliedSummaries: [],
previewResults: [
{
summary: {
agentId: "main",
storePath,
mode: "warn",
dryRun: true,
beforeCount: count,
afterCount: count,
missing: 0,
dmScopeRetired: 0,
modelRunPruned: 0,
pruned: 0,
capped: 0,
diskBudget: null,
wouldMutate: false,
},
],
};
},
beforeStore,
missingKeys: new Set(),
modelRunPrunedKeys: new Set(),
archivedKeys: new Set(),
staleKeys: new Set(),
cappedKeys: new Set(),
dmScopeRetiredKeys: new Set(),
},
],
};
},
});
mock.module(new URL(`../gateway/call.${extension}`, import.meta.url), {
namedExports: {
buildGatewayConnectionDetails: unexpected,
callGateway: unexpected,
isImplicitLocalGatewayTarget: unexpected,
mockNativeModuleExports(new URL(`../gateway/call.${extension}`, import.meta.url), {
buildGatewayConnectionDetails: unexpected,
callGateway: unexpected,
isImplicitLocalGatewayTarget: unexpected,
});
mockNativeModuleExports(
new URL(`../gateway/call-mutation-fallback.${extension}`, import.meta.url),
{
resolveGatewayMutationFallback: unexpected,
},
});
mock.module(new URL(`../gateway/call-mutation-fallback.${extension}`, import.meta.url), {
namedExports: { resolveGatewayMutationFallback: unexpected },
});
mock.module(new URL(`../config/sessions/session-sqlite-target.${extension}`, import.meta.url), {
namedExports: { resolveSqliteTargetFromSessionStorePath: () => ({ path: storePath }) },
});
mock.module(new URL(`./sessions-display-model.${extension}`, import.meta.url), {
namedExports: {
resolveSessionDisplayModelRef: (_cfg: unknown, row: { model: string }) => ({
model: row.model,
}),
);
mockNativeModuleExports(
new URL(`../config/sessions/session-sqlite-target.${extension}`, import.meta.url),
{
resolveSqliteTargetFromSessionStorePath: () => ({ path: storePath }),
},
);
mockNativeModuleExports(new URL(`./sessions-display-model.${extension}`, import.meta.url), {
resolveSessionDisplayModelRef: (_cfg: unknown, row: { model: string }) => ({
model: row.model,
}),
});
const { sessionsCleanupCommand } = await import("./sessions-cleanup.js");
@ -111,4 +111,3 @@ await sessionsCleanupCommand(
},
);
console.log(JSON.stringify({ serviceCalls, gridPrinted, summaryPrinted, labelRows, total }));
mock.restoreAll();

View file

@ -405,7 +405,7 @@ test("logs a native reclamation Worker throw with its cause, first frame and has
error: expect.stringContaining(
`synthetic reclamation crash for ${redactIdentifier(scope.sessionId)} | synthetic disk failure`,
),
errorFrame: expect.stringContaining("at MessagePort.failReclamation"),
errorFrame: expect.stringMatching(/^at (?:MessagePort\.)?failReclamation \(/u),
}),
}),
expect.objectContaining({

View file

@ -1,15 +1,13 @@
import fs from "node:fs";
import { DatabaseSync } from "node:sqlite";
import { expect, test, vi } from "vitest";
import { runCliProcessChild } from "../cli/cli-process-child.test-helpers.js";
import { loadSessionEntry, upsertSessionEntryCore } from "../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveRuntimeWorkerArgv } from "../infra/runtime-worker-url.js";
import * as sqliteIntegrity from "../infra/sqlite-integrity.js";
import * as sqliteWal from "../infra/sqlite-wal.js";
import * as agentDatabaseLeases from "../state/openclaw-agent-db-lease.js";
import {
closeOpenClawAgentDatabasesAsync,
openOpenClawAgentDatabase,
} from "../state/openclaw-agent-db.js";
import { closeOpenClawAgentDatabasesAsync } from "../state/openclaw-agent-db.js";
import { listOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.test-support.js";
import { setStateDirEnv, withStateDirEnv } from "../test-helpers/state-dir-env.js";
import { readSessionGroupMembershipInWorker } from "./session-group-catalog.js";
@ -70,17 +68,25 @@ test.each([false, true])(
["Shared work", [scopes[1]]],
]),
);
const database = openOpenClawAgentDatabase(scopes[0]);
const external = new DatabaseSync(database.path);
try {
external
.prepare(
"UPDATE session_nodes SET entry_json = json_set(entry_json, '$.category', ?) WHERE session_key = ?",
)
.run("External", scopes[0].sessionKey);
} finally {
external.close();
}
const entryUrl = new URL("../config/sessions/session-accessor.ts", import.meta.url);
const cleanupUrl = new URL("../test-utils/session-state-cleanup.ts", import.meta.url);
// A foreign canonical writer refreshes metadata without this process's publications.
const external = await runCliProcessChild({
nodeArgs: [
...resolveRuntimeWorkerArgv(entryUrl).slice(0, -1),
"--input-type=module",
"--eval",
`import { upsertSessionEntryCore } from ${JSON.stringify(entryUrl.href)};
import { cleanupSessionStateForTest } from ${JSON.stringify(cleanupUrl.href)};
try {
await upsertSessionEntryCore(${JSON.stringify(scopes[0])}, { category: "External" });
} finally {
await cleanupSessionStateForTest({ stateDir: process.env.OPENCLAW_STATE_DIR });
}`,
],
env: { ...process.env },
});
expect(external.code, external.stderr).toBe(0);
expect(await readTargets()).toEqual(
new Map([
["External", [scopes[0]]],

View file

@ -324,33 +324,41 @@ it("carries only its captured read scope and refuses callbacks after owner retir
}
});
it("counts admitted read-only session children in node spawn diagnostics", () => {
let now = 0;
const clock = vi.spyOn(performance, "now").mockImplementation(() => now);
const events: unknown[] = [];
const stop = onDiagnosticEvent((event) => {
if (event.type === "diagnostic.child_process.spawn") {
events.push(event);
it.each([
{ execPath: "/fixture/bin/node", family: "node" },
{ execPath: "/fixture/bin/bun", family: "bun" },
{ execPath: "/fixture/bin/custom-runtime", family: "other" },
])(
"counts admitted read-only session children as $family in spawn diagnostics",
({ execPath, family }) => {
const originalExecPath = process.execPath;
let now = 0;
const clock = vi.spyOn(performance, "now").mockImplementation(() => now);
const events: unknown[] = [];
const stop = onDiagnosticEvent((event) => {
if (event.type === "diagnostic.child_process.spawn") {
events.push(event);
}
});
try {
process.execPath = execPath;
setDiagnosticsEnabledForProcess(false);
emitChildProcessSpawnSample();
setDiagnosticsEnabledForProcess(true);
const { child } = createSession();
now = 60_000;
emitChildProcessSpawnSample();
expect(events).toEqual([]);
child.emit("spawn");
now = 120_000;
emitChildProcessSpawnSample();
expect(events).toEqual([expect.objectContaining({ family, count: 1 })]);
} finally {
process.execPath = originalExecPath;
stop();
setDiagnosticsEnabledForProcess(false);
emitChildProcessSpawnSample();
clock.mockRestore();
}
});
try {
setDiagnosticsEnabledForProcess(false);
emitChildProcessSpawnSample();
setDiagnosticsEnabledForProcess(true);
const { child } = createSession();
now = 60_000;
emitChildProcessSpawnSample();
expect(events).toEqual([]);
child.emit("spawn");
now = 120_000;
emitChildProcessSpawnSample();
expect(events).toEqual([
expect.objectContaining({ family: process.versions.bun ? "other" : "node", count: 1 }),
]);
} finally {
stop();
setDiagnosticsEnabledForProcess(false);
emitChildProcessSpawnSample();
clock.mockRestore();
}
});
},
);

View file

@ -2,7 +2,6 @@ import fs from "node:fs";
import { isBuiltin } from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { moduleResolve } from "import-meta-resolve";
import type { JitiOptions } from "jiti";
import { isPathInside } from "../infra/path-guards.js";
import { createJiti } from "./jiti-factory.js";
@ -36,6 +35,8 @@ import {
import { isPluginSourceEntry } from "./plugin-source-file.js";
import {
capturedPluginModuleUrl,
createPluginPackageMapReferences,
resolvePluginPackageMapTarget,
visitPluginSourceReferences,
} from "./plugin-source-references.js";
import { verifyPluginSourceInputs } from "./plugin-source-verification.js";
@ -103,6 +104,7 @@ export function capturePluginGenerationArtifact(
}
return existing.destination;
}
const packageMap = createPluginPackageMapReferences();
const packageId = `package-${packages.size}`;
const moduleRoot = path.join(directory, packageId, "node_modules");
const parentName = path.basename(path.dirname(boundary));
@ -127,6 +129,7 @@ export function capturePluginGenerationArtifact(
const source = path.join(boundary, path.relative(capturedBoundary, filename));
if (
!capturedPaths.has(source) &&
!packageMap.hasMissingTarget(source) &&
fs.statSync(source, { throwIfNoEntry: false })?.isFile() &&
(isPathInside(boundary, fs.realpathSync(source)) ||
nativeAdmission.isRetainedReference(source))
@ -292,9 +295,7 @@ export function capturePluginGenerationArtifact(
try: true,
conditions: conditions
? [...conditions]
: kind === "require"
? ["node", "require"]
: ["node", "import"],
: ["node", "module-sync", kind === "require" ? "require" : "import"],
});
if (!resolved?.startsWith("file:")) {
return resolved;
@ -320,7 +321,15 @@ export function capturePluginGenerationArtifact(
return undefined;
}
const name = packageName(value);
const resolved = resolve(value);
const self = scope?.manifest.exports != null && scope.manifest.name === name;
const resolved =
value.startsWith("#") || self
? packageMap.resolveReference(
value,
source,
conditions ?? ["node", "module-sync", kind],
)
: resolve(value);
const input = resolved?.startsWith("file:") ? fileURLToPath(resolved) : resolved;
if (
resolver.options.tsconfigPaths &&
@ -341,7 +350,6 @@ export function capturePluginGenerationArtifact(
return conditions && execute ? captureExecutableFile(input) : null;
}
}
const self = scope?.manifest.exports != null && scope.manifest.name === name;
if (!value.startsWith("#") && !self) {
if (conditions && !resolved) {
return undefined;
@ -354,12 +362,15 @@ export function capturePluginGenerationArtifact(
}
let external = false;
if (!self && scope) {
// Jiti selects the condition/target. String leaves identify lookup aliases only;
// Package-map resolution selects the target; string leaves identify lookup aliases only;
// preserve every matching alias when several names share one physical package.
for (const alias of scope.aliases) {
const dependency = resolveDependency(alias, scope.source);
if (dependency && isPathInside(dependency.root, input)) {
addDependency(alias, scope.source);
// Package aliases retain metadata now; execution captures the selected body.
if (conditions || !execute) {
addDependency(alias, scope.source);
}
external = true;
}
}
@ -480,30 +491,17 @@ export function capturePluginGenerationArtifact(
return dependencyPrepared ? { retryNative: true } : undefined;
}
if (dependencyPrepared === "package-map") {
let selected: URL;
try {
selected = moduleResolve(specifier, pathToFileURL(target), new Set(conditions));
} catch (error) {
if (
!(error instanceof Error) ||
!("code" in error) ||
error.code !== "ERR_MODULE_NOT_FOUND"
) {
throw error;
}
if (!("url" in error) || typeof error.url !== "string") {
return undefined;
}
// Node chose this target from immutable metadata; only its body is still uncaptured.
selected = new URL(error.url);
}
if (selected.protocol !== "file:") {
const filename = resolvePluginPackageMapTarget(specifier, target, conditions);
if (!filename) {
return undefined;
}
const filename = fileURLToPath(selected);
if (inPackage(capturedBoundary, filename)) {
const original = path.join(boundary, path.relative(capturedBoundary, filename));
if (packageMap.hasMissingTarget(original)) {
return undefined;
}
if (!capturedPaths.has(original) && !fs.existsSync(original)) {
packageMap.recordMissingTarget(original);
return undefined;
}
captureFile(original, resolver.options);
@ -528,13 +526,23 @@ export function capturePluginGenerationArtifact(
return { target: capturedPluginModuleUrl(captured, specifier, conditions) };
};
const nativeScope = getNativeScope(source, scope?.manifest);
moduleCaptures.set(target, { prepareDependency, nativeScope, capture: captureModule });
if (entry && !executableEntry) {
visitPluginSourceReferences(
const moduleCapture: PluginModuleCapture = {
isRequireReference: (specifier) =>
observed.has(`require\0${specifier}`) && !observed.has(`import\0${specifier}`),
prepareDependency,
nativeScope,
capture: captureModule,
};
moduleCaptures.set(target, moduleCapture);
// Only Bun previews need deferred-code facts without acquiring unresolved references.
if ((entry && !executableEntry) || process.versions.bun) {
moduleCapture.staticImports = visitPluginSourceReferences(
source,
fs.readFileSync(target, "utf8"),
resolver,
captureObservedReference,
entry && !executableEntry
? captureObservedReference
: (reference, kind) => observed.set(`${kind}\0${reference}`, null),
);
}
};
@ -619,6 +627,12 @@ export function capturePluginGenerationArtifact(
nativeAdmission.reconcileSourceInputs(inputs);
},
sourceForCaptured: (file: string) => originalSources.get(path.resolve(file)),
isRequireReference: (importer: string, specifier: string) =>
moduleCaptures.get(importer)?.isRequireReference(specifier) ?? false,
isRequirePreview: (importer: string, specifier: string) => {
const imports = moduleCaptures.get(importer)?.staticImports;
return imports !== undefined && !imports.has(specifier);
},
boundaryRoot: directory,
// The receipt attests the initial snapshot; first-demand inputs extend only its identity ledger.
sourceDigest: initialReceipt.sourceDigest,

View file

@ -1,7 +1,9 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";
import { runNodeScript } from "../../test/helpers/run-node-script.js";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { createPluginCache, withPluginCache } from "./plugin-cache.js";
import { bindPluginInstanceModuleLoader } from "./plugin-instance-module-loader.js";
@ -9,6 +11,9 @@ import { PluginInstance } from "./plugin-instance.js";
const temp = useAutoCleanupTempDirTracker(afterEach);
const nativeRequire = createRequire(import.meta.url);
// The CI pin predates openclaw/bun#57. Remove this gate when that pin advances.
const lacksNativeModuleSync =
Reflect.get(process, "revision") === "17c9ecf9eb4aa0b60ae1a2c9f28c1b6dc0f4c8ac";
const instances: PluginInstance[] = [];
afterEach(async () => {
vi.unstubAllEnvs();
@ -33,11 +38,13 @@ function load(rootDir: string, entry: string, standalone = false) {
}
describe("captured module conditions", () => {
it.each(
["#selected", "condition-owner/selected"].flatMap((specifier) =>
["import", "require"].map((mode) => ({ specifier, mode })),
),
)(
it
.skipIf(lacksNativeModuleSync)
.each(
["#selected", "condition-owner/selected"].flatMap((specifier) =>
["import", "require"].map((mode) => ({ specifier, mode })),
),
)(
"preserves native module-sync selection for selective $mode $specifier",
({ specifier, mode }) => {
const root = temp.make("plugin-native-conditions-");
@ -67,75 +74,87 @@ describe("captured module conditions", () => {
);
it.each(
["import", "require"].flatMap((mode) =>
["exports", "main"].map((entryField) => ({ mode, entryField })),
[
{ mode: "import", entry: "index.mjs" },
{ mode: "require", entry: "index.cjs" },
{ mode: "require", entry: "index.js" },
].flatMap(({ mode, entry }) =>
["exports", "main"].map((entryField) => ({ mode, entry, entryField })),
),
)("retains conditional external $entryField metadata for $mode", async ({ mode, entryField }) => {
const root = temp.make("plugin-conditional-metadata-");
const manifest = {
type: "module",
imports: {
"#selected": {
"module-sync": "sync-dependency",
import: "import-dependency",
require: "import-dependency",
)(
"retains conditional external $entryField metadata for $mode in $entry",
async ({ mode, entry, entryField }) => {
const root = temp.make("plugin-conditional-metadata-");
const manifest = {
type: entry === "index.js" ? "commonjs" : "module",
imports: {
"#selected": {
"module-sync": "sync-dependency",
import: "import-dependency",
require: "import-dependency",
},
"#unused": "invalid-dependency",
},
"#unused": "invalid-dependency",
},
};
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify(manifest));
const entry = mode === "import" ? "index.mjs" : "index.cjs";
fs.writeFileSync(
path.join(root, entry),
mode === "import"
? "export const read = async () => { const loaded = await import('#selected'); return [loaded.value, loaded.body]; };"
: "exports.read = () => { const loaded = require('#selected'); return [loaded.value, loaded.body]; };",
);
for (const name of ["sync-dependency", "import-dependency", "invalid-dependency"]) {
const directory = path.join(root, "node_modules", name);
fs.mkdirSync(directory, { recursive: true });
};
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify(manifest));
fs.writeFileSync(
path.join(directory, "package.json"),
name === "invalid-dependency"
? "invalid unselected manifest"
: JSON.stringify({ [entryField]: "./original.mjs" }),
path.join(root, entry),
mode === "import"
? "export const read = async () => { const loaded = await import('#selected'); return [loaded.value, loaded.body]; };"
: "exports.read = () => { const loaded = require('#selected'); return [loaded.value, loaded.body]; };",
);
for (const name of ["sync-dependency", "import-dependency", "invalid-dependency"]) {
const directory = path.join(root, "node_modules", name);
fs.mkdirSync(directory, { recursive: true });
fs.writeFileSync(
path.join(directory, "package.json"),
name === "invalid-dependency"
? "invalid unselected manifest"
: JSON.stringify({ [entryField]: "./original.mjs" }),
);
fs.writeFileSync(
path.join(directory, "original.mjs"),
`export const value = '${name}'; export { body } from './body.mjs';`,
);
fs.writeFileSync(
path.join(directory, "body.mjs"),
"export const body = 'before selection';",
);
fs.writeFileSync(
path.join(directory, "replacement.mjs"),
"export const value = 'wrong replacement';",
);
}
const plugin = load(root, entry, true).value as { read(): string[] | Promise<string[]> };
fs.writeFileSync(
path.join(root, "package.json"),
JSON.stringify({ ...manifest, imports: { "#selected": "import-dependency" } }),
);
const selected = path.join(root, "node_modules", "sync-dependency");
fs.writeFileSync(
path.join(selected, "package.json"),
JSON.stringify({
[entryField]: "./replacement.mjs",
dependencies: { "missing-later-dependency": "1.0.0" },
}),
);
fs.writeFileSync(
path.join(directory, "original.mjs"),
`export const value = '${name}'; export { body } from './body.mjs';`,
path.join(selected, "original.mjs"),
"export const value = 'first demand'; export { body } from './body.mjs';",
);
fs.writeFileSync(path.join(directory, "body.mjs"), "export const body = 'before selection';");
fs.writeFileSync(
path.join(directory, "replacement.mjs"),
"export const value = 'wrong replacement';",
);
}
const plugin = load(root, entry, true).value as { read(): string[] | Promise<string[]> };
fs.writeFileSync(
path.join(root, "package.json"),
JSON.stringify({ ...manifest, imports: { "#selected": "import-dependency" } }),
);
const selected = path.join(root, "node_modules", "sync-dependency");
fs.writeFileSync(
path.join(selected, "package.json"),
JSON.stringify({
[entryField]: "./replacement.mjs",
dependencies: { "missing-later-dependency": "1.0.0" },
}),
);
fs.writeFileSync(
path.join(selected, "original.mjs"),
"export const value = 'first demand'; export { body } from './body.mjs';",
);
fs.writeFileSync(path.join(selected, "body.mjs"), "export const body = 'selected body';");
const expected = [entryField === "main" ? "sync-dependency" : "first demand", "selected body"];
expect(await plugin.read()).toEqual(expected);
fs.writeFileSync(path.join(selected, "original.mjs"), "export const value = 'later edit';");
fs.writeFileSync(path.join(selected, "body.mjs"), "export const body = 'later body';");
expect(await plugin.read()).toEqual(expected);
});
fs.writeFileSync(path.join(selected, "body.mjs"), "export const body = 'selected body';");
const expected = [
entryField === "main" ? "sync-dependency" : "first demand",
"selected body",
];
expect(await plugin.read()).toEqual(expected);
fs.writeFileSync(path.join(selected, "original.mjs"), "export const value = 'later edit';");
fs.writeFileSync(path.join(selected, "body.mjs"), "export const body = 'later body';");
expect(await plugin.read()).toEqual(expected);
},
);
it.each(["import", "require"])(
it.skipIf(lacksNativeModuleSync).each(["import", "require"])(
"retains a missing selected conditional target for %s",
async (mode) => {
const root = temp.make("plugin-missing-conditional-target-");
@ -319,3 +338,220 @@ describe("captured module conditions", () => {
expect(plugin.read("#direct")).toBe(42);
});
});
it("retains the first observed absence of a computed package alias", () => {
const root = temp.make("plugin-computed-missing-target-");
fs.writeFileSync(path.join(root, "package.json"), '{"imports":{"#selected":"./missing.cjs"}}');
fs.writeFileSync(path.join(root, "index.cjs"), "exports.read = name => require(name);");
const plugin = load(root, "index.cjs", true).value as { read(name: string): unknown };
expect(() => plugin.read("#selected")).toThrow();
fs.writeFileSync(path.join(root, "missing.cjs"), "module.exports = 42;");
expect(() => plugin.read("#selected")).toThrow();
const fresh = load(root, "index.cjs", true).value as { read(name: string): unknown };
expect(fresh.read("#selected")).toBe(42);
});
it("does not acquire the unselected native runtime alias package", () => {
const root = temp.make("plugin-native-runtime-alias-");
fs.writeFileSync(
path.join(root, "package.json"),
JSON.stringify({
imports: { "#selected": { bun: "bun-dependency", default: "node-dependency" } },
}),
);
const selected = process.versions.bun ? "bun-dependency" : "node-dependency";
for (const name of ["bun-dependency", "node-dependency"]) {
const directory = path.join(root, "node_modules", name);
fs.mkdirSync(directory, { recursive: true });
fs.writeFileSync(
path.join(directory, "package.json"),
JSON.stringify({
main: "index.cjs",
...(name === selected
? {}
: { dependencies: { "unselected-missing-dependency": "1.0.0" } }),
}),
);
fs.writeFileSync(path.join(directory, "index.cjs"), "exports.value = 42;");
}
fs.writeFileSync(path.join(root, "index.cjs"), "exports.read = name => require(name).value;");
const plugin = load(root, "index.cjs", true).value as { read(name: string): number };
expect(plugin.read("#selected")).toBe(42);
});
it("scopes observed package-map absences to the target selected by custom conditions", async () => {
const home = temp.make("plugin-custom-condition-absence-");
const roots = ["import", "require"].map((mode) => {
const root = path.join(home, mode);
fs.mkdirSync(root, { mode: 0o700 });
fs.writeFileSync(
path.join(root, "package.json"),
JSON.stringify({
imports: {
"#selected": { "openclaw-custom": "./valid.cjs", default: "./missing.cjs" },
"#late": "./missing.cjs",
},
}),
);
fs.writeFileSync(path.join(root, "valid.cjs"), "exports.value = 42;");
const entry = path.join(root, mode === "import" ? "index.mjs" : "index.cjs");
fs.writeFileSync(
entry,
mode === "import"
? "export const read = async () => (await import('#selected')).default.value; export const readLate = async () => (await import('#late')).default.value;"
: "exports.read = () => require('#selected').value; exports.readLate = () => require('#late').value;",
);
return { root, entry };
});
const moduleUrl = (filename: string) => pathToFileURL(path.resolve("src/plugins", filename)).href;
const probe = path.join(home, "probe.mts");
fs.writeFileSync(
probe,
`import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { createPluginCache, withPluginCache } from ${JSON.stringify(moduleUrl("plugin-cache.ts"))};
import { bindPluginInstanceModuleLoader } from ${JSON.stringify(moduleUrl("plugin-instance-module-loader.ts"))};
import { PluginInstance } from ${JSON.stringify(moduleUrl("plugin-instance.ts"))};
const values = [];
for (const { root, entry } of ${JSON.stringify(roots)}) {
const instances = [];
const load = () => {
const instance = new PluginInstance('custom-condition-fixture');
instances.push(instance);
withPluginCache(createPluginCache(), () => bindPluginInstanceModuleLoader({
instance, origin: 'config', source: entry, rootDir: root, standalone: true,
}));
return instance.loadModule(entry);
};
try {
const plugin = load();
fs.writeFileSync(path.join(root, 'missing.cjs'), 'exports.value = 84;');
values.push(await plugin.read());
await assert.rejects(async () => await plugin.readLate());
assert.equal(await load().readLate(), 84);
} finally {
for (const instance of instances.toReversed()) await instance.dispose();
}
}
console.log(JSON.stringify(values));`,
);
const state = path.join(home, "state");
fs.mkdirSync(state, { mode: 0o700 });
const result = await runNodeScript(
[
"--conditions=openclaw-custom",
...(process.versions.bun
? ["--no-install"]
: ["--import", pathToFileURL(path.resolve("scripts/tsx.mjs")).href]),
probe,
],
{ ...process.env, HOME: home, USERPROFILE: home, TMPDIR: home, OPENCLAW_STATE_DIR: state },
undefined,
{ executable: process.execPath },
);
expect(result.error).toBeUndefined();
expect(result.status, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toEqual([42, 42]);
});
// Native erasure keeps specifier-only empty requests; the existing Node/Jiti adapter removes them.
it.each([
{
name: "import type",
declaration: "import type { Shape } from '#selected';",
expected: "after",
},
{
name: "type import specifier",
declaration: "import { type Shape } from '#selected';",
expected: process.versions.bun ? "before" : "after",
},
{
name: "implicit type import",
declaration: "import { Shape } from '#selected'; type Alias = Shape;",
expected: "after",
},
{
name: "export type",
declaration: "export type { Shape } from '#selected';",
expected: "after",
},
{
name: "type export specifier",
declaration: "export { type Shape } from '#selected';",
expected: process.versions.bun ? "before" : "after",
},
{ name: "export type all", declaration: "export type * from '#selected';", expected: "after" },
{ name: "side-effect import", declaration: "import '#selected';", expected: "before" },
{
name: "mixed runtime import",
declaration: "import { type Shape, value } from '#selected'; export const observed = value;",
expected: "before",
},
{
name: "mixed runtime export",
declaration: "export { type Shape, value } from '#selected';",
expected: "before",
},
])("acquires the dependency body at runtime after $name", ({ declaration, expected }) => {
const root = temp.make("plugin-type-only-reference-");
const dependency = path.join(root, "node_modules", "selected-dependency");
fs.mkdirSync(dependency, { recursive: true });
fs.writeFileSync(
path.join(root, "package.json"),
'{"imports":{"#selected":"selected-dependency"}}',
);
fs.writeFileSync(path.join(dependency, "package.json"), '{"exports":"./index.cjs"}');
fs.writeFileSync(
path.join(dependency, "index.cjs"),
"exports.value = require('./body.cjs').value;",
);
fs.writeFileSync(path.join(dependency, "body.cjs"), "exports.value = 'before';");
// A computed import exercises Bun's native TypeScript adapter.
fs.writeFileSync(
path.join(root, "index.ts"),
`${declaration}
export const read = () => require('#selected').value;
export const load = async (name: string) => import(name);`,
);
const plugin = load(root, "index.ts", true).value as { read(): string };
fs.writeFileSync(path.join(dependency, "body.cjs"), "exports.value = 'after';");
expect(plugin.read()).toBe(expected);
});
it.each(["declared", "alias", "undeclared"])(
"preserves %s nested dependency capture timing for a deferred entry",
(kind) => {
const root = temp.make("plugin-deferred-entry-facts-");
const outer = path.join(root, "node_modules", "outer-dependency");
const inner = path.join(outer, "node_modules", "inner-dependency");
fs.mkdirSync(inner, { recursive: true });
fs.writeFileSync(path.join(root, "package.json"), '{"imports":{"#outer":"outer-dependency"}}');
fs.writeFileSync(
path.join(outer, "package.json"),
JSON.stringify({
exports: "./index.cjs",
...(kind === "declared"
? { dependencies: { "inner-dependency": "1.0.0" } }
: kind === "alias"
? { imports: { "#inner": "inner-dependency" } }
: {}),
}),
);
fs.writeFileSync(
path.join(outer, "index.cjs"),
`exports.read = () => require(${JSON.stringify(kind === "alias" ? "#inner" : "inner-dependency")}).value;`,
);
fs.writeFileSync(path.join(inner, "package.json"), '{"exports":"./index.cjs"}');
fs.writeFileSync(path.join(inner, "index.cjs"), "exports.value = require('./body.cjs').value;");
const body = path.join(inner, "body.cjs");
fs.writeFileSync(body, "exports.value = 'before-load';");
fs.writeFileSync(path.join(root, "index.cjs"), "exports.select = () => require('#outer');");
const plugin = load(root, "index.cjs", true).value as { select(): { read(): string } };
fs.writeFileSync(body, "exports.value = 'before-selection';");
const selected = plugin.select();
fs.writeFileSync(body, "exports.value = 'after-selection';");
expect(selected.read()).toBe(kind === "declared" ? "before-selection" : "after-selection");
},
);

View file

@ -144,3 +144,47 @@ it("inspects a captured cyclic dependency graph without following its dependency
expect(fs.readdirSync(captures)).toEqual([]);
expect(followedLinks.size).toBe(0);
});
it.for(
["#selected", "inspection-conditions/selected"].flatMap((specifier) =>
["import", "require"].map((kind) => ({ specifier, kind })),
),
)(
"inspects the module-sync target for $kind $specifier without evaluation",
({ specifier, kind }, context) => {
// The CI pin predates openclaw/bun#57; keep import inspection active on that pin.
if (
kind === "require" &&
Reflect.get(process, "revision") === "17c9ecf9eb4aa0b60ae1a2c9f28c1b6dc0f4c8ac"
) {
context.skip();
}
const root = temp.make("plugin-inspection-conditions-");
const entryFile = path.join(root, kind === "import" ? "index.mjs" : "index.cjs");
const selected = path.join(root, "sync.mjs");
const selection = { "module-sync": "./sync.mjs", default: "./fallback.mjs" };
fs.writeFileSync(
path.join(root, "package.json"),
JSON.stringify({
name: "inspection-conditions",
type: "module",
imports: { "#selected": selection },
exports: { "./selected": selection },
}),
);
for (const file of [selected, path.join(root, "fallback.mjs")]) {
fs.writeFileSync(file, "throw new Error('inspection must not evaluate plugin code');");
}
fs.writeFileSync(
entryFile,
kind === "import"
? `import ${JSON.stringify(specifier)};`
: `require(${JSON.stringify(specifier)});`,
);
const inspection = inspectPluginSourceDependencies([{ rootDir: root, entryFile }]);
expect(inspection.unresolved).toEqual([]);
expect(inspection.references).toEqual([{ source: entryFile, specifier, target: selected }]);
expect(() => inspection.assertSourceCurrent()).not.toThrow();
},
);

View file

@ -59,7 +59,7 @@ export function inspectPluginSourceDependencies(
path.isAbsolute(specifier) ||
specifier.startsWith("file:");
try {
const conditions = ["node", kind];
const conditions = ["node", "module-sync", kind];
const result = artifact.captureModule(captured, specifier, conditions);
const target =
result && "target" in result

View file

@ -133,7 +133,7 @@ export function bindPluginInstanceModuleLoader(params: PluginInstanceModuleLoade
: undefined;
for (const { specifier } of bunSourceFacts?.staticImports ?? []) {
if (path.isAbsolute(specifier) || specifier.startsWith("file:")) {
artifact.captureModule(capturedSource, specifier, ["node", "import"]);
artifact.captureModule(capturedSource, specifier, ["node", "module-sync", "import"]);
}
}
const bunNeedsNativeSource =

View file

@ -15,7 +15,7 @@ import type { PluginOrigin } from "./plugin-origin.types.js";
import { isPluginSdkAliasSpecifier } from "./sdk-alias.js";
function getBunConditions(requireMode: boolean): Set<string> {
const conditions = new Set(["bun", "node", requireMode ? "require" : "import"]);
const conditions = new Set(["bun", "node", "module-sync", requireMode ? "require" : "import"]);
if (!process.execArgv.includes("--no-addons")) {
conditions.add("node-addons");
}
@ -97,6 +97,11 @@ export function bindNativePluginInstanceModuleLoader(
}
: {}),
prepare(request, parent, kind) {
// Bun previews literal require calls as imports while compiling CommonJS.
// The require-call hook owns acquisition when that operation executes.
if (kind === "import-statement" && artifact.isRequirePreview(parent, request)) {
return undefined;
}
// Resolved URLs and built relative imports retain the selected host SDK's identity.
const original = artifact.sourceForCaptured(parent);
const sdkTarget = hostSdkTarget(request, original);
@ -115,11 +120,17 @@ export function bindNativePluginInstanceModuleLoader(
artifact.prepareModule(source);
let target: string | undefined;
const requireMode = kind === "require-call" || kind === "require-resolve";
const conditions = ["node", requireMode ? "require" : "import"];
const conditions = [...getBunConditions(requireMode)];
if (source === parent && request.startsWith(".")) {
// Jiti implements computed imports through require.resolve; relative source capture
// still follows the authored import graph rather than that internal mechanism.
const captured = artifact.captureModule(parent, request, ["node", "import"]);
const captured = artifact.captureModule(
parent,
request,
kind === "require-resolve" && !artifact.isRequireReference(parent, request)
? ["node", "module-sync", "import"]
: conditions,
);
if (captured && "target" in captured) {
target =
captured.target.search || captured.target.hash
@ -130,7 +141,6 @@ export function bindNativePluginInstanceModuleLoader(
source === parent &&
!path.isAbsolute(request) &&
!request.startsWith("file:") &&
!request.startsWith("#") &&
!isBuiltin(request)
) {
const captured = artifact.captureModule(parent, request, conditions);
@ -231,7 +241,11 @@ export function bindNativePluginInstanceModuleLoader(
}
return captured;
}
const captured = artifact.captureModule(parent, request, ["node", "require"]);
const captured = artifact.captureModule(parent, request, [
"node",
"module-sync",
"require",
]);
return captured && "target" in captured ? fileURLToPath(captured.target) : undefined;
}),
);

View file

@ -90,6 +90,8 @@ function pluginDependencyNames(manifest: Record<string, unknown> | undefined): S
type PluginNativeDependencyScope = { prepareDependencies?: () => void };
export type PluginModuleCapture = {
staticImports?: ReadonlySet<string>;
isRequireReference: (specifier: string) => boolean;
prepareDependency: ReturnType<typeof createPluginDependencyLookup>;
nativeScope: PluginNativeDependencyScope;
capture: (

View file

@ -1,8 +1,16 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import type { NodePath } from "@babel/traverse";
import type { CallExpression, Node, Program as BabelProgram } from "@babel/types";
import type {
CallExpression,
ExportAllDeclaration,
ExportNamedDeclaration,
ImportDeclaration,
Node,
Program as BabelProgram,
} from "@babel/types";
import { parse, type AnyNode, type Program } from "acorn";
import { moduleResolve } from "import-meta-resolve";
import type { createJiti } from "jiti";
export function capturedPluginModuleUrl(
@ -23,6 +31,60 @@ export function capturedPluginModuleUrl(
return url;
}
/** Package metadata selects a target before its deferred body has been captured. */
export function resolvePluginPackageMapTarget(
specifier: string,
importer: string,
conditions: readonly string[],
): string | undefined {
let selected: URL;
try {
selected = moduleResolve(specifier, pathToFileURL(importer), new Set(conditions));
} catch (error) {
if (!(error instanceof Error) || !("code" in error) || error.code !== "ERR_MODULE_NOT_FOUND") {
throw error;
}
if (!("url" in error) || typeof error.url !== "string") {
return undefined;
}
// Node chose this target from immutable metadata; only its body is still uncaptured.
selected = new URL(error.url);
}
return selected.protocol === "file:" ? fileURLToPath(selected) : undefined;
}
/** Missing physical inputs stay absent without poisoning another condition's selected target. */
export function createPluginPackageMapReferences() {
const missingTargets = new Set<string>();
const recordMissingTarget = (filename: string) => {
missingTargets.add(path.resolve(filename));
};
return {
recordMissingTarget,
hasMissingTarget: (filename: string) => missingTargets.has(path.resolve(filename)),
resolveReference(specifier: string, importer: string, conditions: readonly string[]) {
try {
return moduleResolve(specifier, pathToFileURL(importer), new Set(conditions)).href;
} catch (error) {
if (
error instanceof Error &&
"code" in error &&
error.code === "ERR_MODULE_NOT_FOUND" &&
"url" in error &&
typeof error.url === "string"
) {
const target = new URL(error.url);
if (target.protocol === "file:") {
recordMissingTarget(fileURLToPath(target));
}
}
// Optional invalid metadata is reported only when its branch is executed.
return undefined;
}
},
};
}
type StaticStringNode = {
type: string;
value?: unknown;
@ -350,7 +412,8 @@ export function visitPluginSourceReferences(
sourceText: string,
resolver: ReturnType<typeof createJiti>,
visitReference: (reference: string, kind: "asset" | "import" | "require") => void,
): void {
): ReadonlySet<string> {
const authoredStaticImports = new Set<string>();
const visitDirectoryAsset = (name: string, parts: readonly (string | undefined)[]) => {
if (
(name === "join" || name === "resolve") &&
@ -376,6 +439,32 @@ export function visitPluginSourceReferences(
{
pre(file: { path: NodePath<BabelProgram> }) {
file.path.traverse({
// Native type erasure retains empty requests from specifier-only type syntax.
ImportDeclaration(declaration) {
if (
declaration.node.importKind !== "type" &&
declaration.node.specifiers.length > 0 &&
declaration.node.specifiers.every(
(specifier) =>
specifier.type === "ImportSpecifier" && specifier.importKind === "type",
)
) {
authoredStaticImports.add(declaration.node.source.value);
}
},
ExportNamedDeclaration(declaration) {
if (
declaration.node.source &&
declaration.node.exportKind !== "type" &&
declaration.node.specifiers.length > 0 &&
declaration.node.specifiers.every(
(specifier) =>
specifier.type === "ExportSpecifier" && specifier.exportKind === "type",
)
) {
authoredStaticImports.add(declaration.node.source.value);
}
},
MemberExpression(member) {
// Jiti inlines import.meta.url, dirname and filename as strings, also
// where valid code assigns to them. Inspection reads only references,
@ -418,6 +507,20 @@ export function visitPluginSourceReferences(
},
});
},
// Jiti runs these after TypeScript erasure and before lowering module declarations.
visitor: {
ImportDeclaration(declaration: NodePath<ImportDeclaration>) {
authoredStaticImports.add(declaration.node.source.value);
},
ExportNamedDeclaration(declaration: NodePath<ExportNamedDeclaration>) {
if (declaration.node.source) {
authoredStaticImports.add(declaration.node.source.value);
}
},
ExportAllDeclaration(declaration: NodePath<ExportAllDeclaration>) {
authoredStaticImports.add(declaration.node.source.value);
},
},
},
],
},
@ -434,6 +537,7 @@ export function visitPluginSourceReferences(
const reference = staticString(statement.source);
if (reference !== undefined) {
staticImports.add(reference);
authoredStaticImports.add(reference);
}
}
}
@ -486,4 +590,5 @@ export function visitPluginSourceReferences(
}
};
visit(tree);
return authoredStaticImports;
}

View file

@ -3,6 +3,7 @@ import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core/expect";
import { assert, expect, it, vi, type Mock } from "vitest";
import type { runCommandWithTimeout } from "../process/exec.js";
import { npmCommandArgs } from "../test-utils/npm-command.js";
import {
expectIntegrityDriftRejected,
mockNpmViewMetadataResult,
@ -86,9 +87,9 @@ export function registerNpmUpdateMetadataTests({
});
expect(
runCommandWithTimeoutMock.mock.calls
.map(([argv]) => argv)
.filter((argv) => argv[1] === "view")
.map((argv) => expectDefined(argv[2], "npm view package spec"))
.map(([argv]) => npmCommandArgs(argv))
.filter((args): args is string[] => args?.[0] === "view")
.map((args) => expectDefined(args[1], "npm view package spec"))
.toSorted((left, right) => left.localeCompare(right)),
).toEqual([`${packageName}@beta`, `${packageName}@latest`]);
});
@ -156,9 +157,9 @@ export function registerNpmUpdateMetadataTests({
JSON.parse(fs.readFileSync(path.join(record.installPath, "package.json"), "utf8")),
).toMatchObject({ name: packageName, version: "2.0.0" });
const metadataCommands = runCommandWithTimeoutMock.mock.calls
.map(([argv]) => argv)
.filter((argv) => argv[1] === "view")
.map((argv) => expectDefined(argv[2], "npm view package spec"));
.map(([argv]) => npmCommandArgs(argv))
.filter((args): args is string[] => args?.[0] === "view")
.map((args) => expectDefined(args[1], "npm view package spec"));
expect(metadataCommands.toSorted((left, right) => left.localeCompare(right))).toEqual(
channel === "beta" ? [`${packageName}@beta`, `${packageName}@latest`] : [packageName],
);

View file

@ -55,7 +55,10 @@ async function startSupervisedRelay(
options: { timeoutMs?: number; noOutputTimeoutMs?: number } = {},
) {
platformMock = mockProcessPlatform("linux");
vi.spyOn(process, "kill").mockImplementation(() => {
vi.spyOn(process, "kill").mockImplementation((pid, signal) => {
if (pid === 0 && signal === 0) {
return true;
}
throw Object.assign(new Error("fixture group absent"), { code: "ESRCH" });
});
const stub = createWritableRelayChild();
@ -106,7 +109,15 @@ async function startSupervisedRelay(
stub.disconnectMock();
stub.emitExit(0);
});
return { ...stub, supervisor, closeScope, starting, emit, finish };
return {
...stub,
supervisor,
closeScope,
starting,
emit,
finish,
treeOwnership: start.treeOwnership,
};
}
it.each(["stdout", "stderr"] as const)(
@ -193,9 +204,13 @@ it.each(["cancel", "overall-timeout"] as const)(
const run = await f.starting;
expect((await run.wait()).reason).toBe(mode === "cancel" ? "manual-cancel" : mode);
expect((await run.wait()).stdout).toBe("before cancellation");
// Disconnect asks the dedicated native owner to stop and reap descendants.
// Killing that owner during construction would abandon its wait custody.
expect(f.killMock).not.toHaveBeenCalled();
if (f.treeOwnership === "linux-subreaper") {
// Disconnect preserves the native owner's descendant wait custody.
expect(f.killMock).not.toHaveBeenCalled();
} else {
// Both construction abort and its failed-ready cleanup stop the ordinary relay.
expect(f.killMock.mock.calls).toEqual([["SIGKILL"], ["SIGKILL"]]);
}
expect(f.disconnectMock).toHaveBeenCalled();
expect(f.sendMock.mock.calls.length).toBe(sending);
await expect(f.closeScope()).rejects.toThrow("construction aborted");

View file

@ -0,0 +1,19 @@
import { createRequire } from "node:module";
import { mock } from "node:test";
const require = createRequire(import.meta.url);
/** Install fixture exports for the lifetime of an isolated native child or worker. */
export function mockNativeModuleExports(
specifier: string | URL,
namedExports: Record<string, unknown>,
): void {
if (process.versions.bun) {
const { mock: bunMock } = require("bun:test") as {
mock: { module: (id: string, factory: () => Record<string, unknown>) => void };
};
bunMock.module(String(specifier), () => namedExports);
} else {
mock.module(specifier, { namedExports });
}
}

View file

@ -48,12 +48,21 @@ type AncestryFixture = {
target: string;
};
function ancestryGitEnv(): NodeJS.ProcessEnv {
return {
...process.env,
// Detached Git maintenance can keep writing after the fixture starts cleanup.
GIT_CONFIG_PARAMETERS:
`${process.env.GIT_CONFIG_PARAMETERS ?? ""} 'maintenance.auto=false' 'gc.auto=0'`.trim(),
};
}
function fixtureGit(cwd: string, args: string[], input?: string) {
const result = spawnSync("git", args, {
cwd,
encoding: "utf8",
env: {
...process.env,
...ancestryGitEnv(),
GIT_AUTHOR_EMAIL: "fixture@example.invalid",
GIT_AUTHOR_NAME: "fixture",
GIT_COMMITTER_EMAIL: "fixture@example.invalid",
@ -215,7 +224,7 @@ function runReleaseAncestry(
cwd: checkout,
encoding: "utf8",
env: {
...process.env,
...ancestryGitEnv(),
RELEASE_ANCESTRY_MODE: mode,
RELEASE_ANCESTRY_TARGET_REF: "refs/heads/main",
...env,

View file

@ -178,9 +178,6 @@ if (process.argv[2] === "sentinel") {
throw error;
}
};
// Loaded macOS hosts can drop FSEvents directory notifications entirely.
const watch = fs.watch;
fs.watch = (target, ...args) => (target === root ? { close() {} } : watch(target, ...args));
}
syncFixtureBuiltinExports();
`
@ -1065,6 +1062,14 @@ cp.spawnSync = (command, args, options) => {
''' + "\nrequire(" + json.dumps(sys.argv[5]) + ").syncFixtureBuiltinExports();\n")
with subprocess.Popen([sys.executable, "-I", "-S", "-c", "import sys; sys.stdin.read()"],
stdin=subprocess.PIPE) as child, contextlib.ExitStack() as cleanup:
lease_owner = cleanup.enter_context(subprocess.Popen([
sys.argv[1], sys.argv[2], "lease-owner", str(root), "standalone"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True))
def close_lease_owner():
lease_owner.communicate(timeout=4)
assert lease_owner.returncode == 0, "lease owner failed during retirement"
cleanup.callback(close_lease_owner)
assert lease_owner.stdout.readline().strip() == "ready", "lease owner failed to initialize"
if os.name == "nt":
broker = cleanup.enter_context(subprocess.Popen([
sys.argv[1], "--input-type=module", "-e", """
@ -1159,11 +1164,19 @@ with subprocess.Popen([sys.executable, "-I", "-S", "-c", "pass"], start_new_sess
assert not group_alive(child.pid, deadline), "zombies are terminated, not checkout writers"
group_signal(child.pid, signal.SIGTERM, deadline)
group_signal(child.pid, signal.SIGKILL, deadline)
with tempfile.TemporaryDirectory(prefix="checkout-zombie-") as directory:
with tempfile.TemporaryDirectory(prefix="checkout-zombie-") as directory, contextlib.ExitStack() as cleanup:
root = pathlib.Path(directory).resolve()
(root / "workspace").mkdir()
(root / "pids").mkdir()
(root / "lease").write_text("owned")
lease_owner = cleanup.enter_context(subprocess.Popen([
sys.argv[1], sys.argv[2], "lease-owner", str(root), "standalone"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True))
def close_lease_owner():
lease_owner.communicate(timeout=4)
assert lease_owner.returncode == 0, "lease owner failed during retirement"
cleanup.callback(close_lease_owner)
assert lease_owner.stdout.readline().strip() == "ready", "lease owner failed to initialize"
for pid, role, attempt in [(child.pid, "grandchild", 1), (os.getpid(), "sentinel", 0)]:
(root / "pids" / f"{pid}.json").write_text(json.dumps(dict(pid=pid, role=role, attempt=attempt, instance=str(pid))))
subprocess.run([sys.argv[1], sys.argv[2], "git", str(root), "early-leader-exit",

View file

@ -365,17 +365,18 @@ describe("desktop proof identity and public evidence", () => {
it("keeps the tap off a port claimed before its listener binds", async () => {
const upstream = await acquireTestPortBlock({ offsets: [0] });
// oxlint-disable-next-line typescript/unbound-method -- Reflect.apply binds each listener.
const listen = net.Server.prototype.listen;
const createServer = net.createServer;
// Model the kernel choosing another fixture's claimed but unbound port.
const listenSpy = vi.spyOn(net.Server.prototype, "listen").mockImplementation(function (
this: net.Server,
...args
) {
if (args[0] === 0) {
args[0] = upstream.port;
}
return Reflect.apply(listen, this, args);
const createServerSpy = vi.spyOn(net, "createServer").mockImplementation((...args) => {
const server = createServer(...args);
const listen = server.listen.bind(server);
server.listen = (...listenArgs) => {
if (listenArgs[0] === 0) {
listenArgs[0] = upstream.port;
}
return Reflect.apply(listen, server, listenArgs);
};
return server;
});
let closeTap: (() => Promise<void>) | undefined;
await runQaGatewayFixture(
@ -387,7 +388,7 @@ describe("desktop proof identity and public evidence", () => {
closeTap = tap.close;
expect(tap.port).not.toBe(upstream.port);
},
() => listenSpy.mockRestore(),
() => createServerSpy.mockRestore(),
() => closeTap?.(),
() => upstream.release(),
);

View file

@ -1,6 +1,7 @@
import { spawn, spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
@ -27,6 +28,10 @@ const lease = path.join(root, "lease");
const recordsDir = path.join(root, "pids");
const eventsFile = path.join(root, "events.jsonl");
const commandsFile = path.join(root, "commands.jsonl");
const leaseChannelFile = path.join(root, "lease-channel.json");
const leaseConnections = new Set();
let leaseChannel;
let actorChannel;
const optionsFile = path.join(root, "fixture-options.json");
const options = fs.existsSync(optionsFile) ? JSON.parse(fs.readFileSync(optionsFile, "utf8")) : {};
const localGit = options.localGit ?? options.performance;
@ -123,7 +128,64 @@ function recordCommand(tool, cwd, commandArgs, configuration) {
);
}
async function startLeaseChannel(onError, token = instance) {
leaseChannel = net.createServer((socket) => {
leaseConnections.add(socket);
socket.once("close", () => leaseConnections.delete(socket));
socket.on("error", () => socket.destroy());
let authenticated = false;
let input = "";
socket.setEncoding("utf8");
socket.on("data", (chunk) => {
input += chunk;
if (!authenticated) {
const end = input.indexOf("\n");
if (end < 0 && input.length <= 128) {
return;
}
if (end < 0 || input.slice(0, end) !== token || !fs.existsSync(lease)) {
socket.destroy();
return;
}
authenticated = true;
input = input.slice(end + 1);
socket.write("ready\n");
}
if (input) {
if (!/^\.+$/u.test(input)) {
socket.destroy();
return;
}
input = "";
}
});
});
await new Promise((resolve, reject) => {
leaseChannel.once("error", reject);
leaseChannel.listen(0, "127.0.0.1", () => {
leaseChannel.off("error", reject);
leaseChannel.on("error", onError);
resolve();
});
});
publish("lease-channel.json", { port: leaseChannel.address().port });
}
function retireLeaseChannel() {
for (const socket of leaseConnections) {
socket.destroy();
}
return new Promise((resolve) => {
if (leaseChannel) {
leaseChannel.close(resolve);
} else {
resolve();
}
});
}
function notifyPublication() {
actorChannel?.write(".");
if (process.connected && process.send) {
// The owner can close IPC during cleanup. Its exit and existing watchdog
// still bound readiness; a closed channel must not crash an orphan actor.
@ -135,6 +197,7 @@ function publish(name, value) {
const target = path.join(root, name);
fs.writeFileSync(`${target}.${process.pid}.tmp`, JSON.stringify(value));
fs.renameSync(`${target}.${process.pid}.tmp`, target);
// Notify only after the authoritative record exists.
notifyPublication();
}
@ -376,7 +439,7 @@ function launch(role, attempt) {
return child;
}
function holdLease() {
async function holdLease() {
actorLease = fs.readFileSync(lease, "utf8");
const isLive = () => {
try {
@ -394,15 +457,32 @@ function holdLease() {
process.exit(0);
}
};
// Watch the lease itself before rereading: replacing or retiring it must wake
// actors immediately, including a change during registration. macOS serves
// directory watches through FSEvents, which can drop the unlink under load;
// a file watch is kernel-delivered. A lease already gone fails the reread.
try {
fs.watch(lease, checkLease);
} catch (error) {
if (error.code !== "ENOENT" && error.code !== "EPERM") throw error;
}
// The supervisor owns this connection across intermediate Git parents exiting.
// Its retirement or death closes every actor's channel without filesystem polling.
const { port } = JSON.parse(fs.readFileSync(leaseChannelFile, "utf8"));
actorChannel = net.createConnection({ host: "127.0.0.1", port });
actorChannel.once("close", () => {
checkLease();
process.exit(0);
});
await new Promise((resolve, reject) => {
let reply = "";
actorChannel.once("error", reject);
actorChannel.once("connect", () => actorChannel.write(`${actorLease}\n`));
actorChannel.setEncoding("utf8");
actorChannel.on("data", (chunk) => {
reply += chunk;
if (!reply.includes("\n") && reply.length <= 128) {
return;
}
if (reply !== "ready\n") {
reject(new Error("Fixture lease channel rejected its actor"));
return;
}
checkLease();
resolve();
});
});
setTimeout(checkLease, Math.max(0, deadline - Date.now()));
checkLease();
return deadline;
@ -434,7 +514,7 @@ function writeConsumer(target, tool) {
}
async function command() {
operationDeadline = holdLease();
operationDeadline = await holdLease();
const descendant = mode === "child" || mode === "grandchild";
// Descendants publish their actual attempt below. Replacing a provisional PID
// record can race a Windows reader and fail before readiness with EPERM.
@ -1168,6 +1248,7 @@ async function supervise() {
: undefined;
try {
fs.rmSync(lease, { force: true });
const channelClosed = retireLeaseChannel();
sentinel?.kill("SIGKILL");
if (shell && shell.exitCode === null && shell.signalCode === null) {
// Only this fixture's still-owned detached shell group may be signaled.
@ -1192,7 +1273,7 @@ async function supervise() {
let closeCutoff;
try {
await Promise.race([
Promise.all(pendingChildren.values()),
Promise.all([...pendingChildren.values(), channelClosed]),
new Promise((_, reject) => {
closeCutoff = setTimeout(
() => reject(new Error("Timed out waiting for direct child close")),
@ -1294,6 +1375,11 @@ async function supervise() {
});
operationDeadline = Date.now() + lifetimeCeilingMs;
try {
await startLeaseChannel((error) => void stop(error));
if (stopping) {
await stopping;
return;
}
if (process.platform === "win32") {
census = createWindowsProcessCensus({
root,
@ -1458,6 +1544,22 @@ source "$2"`,
if (mode === "supervise") {
await supervise();
} else if (mode === "lease-owner") {
try {
await startLeaseChannel(
(error) => {
throw error;
},
fs.readFileSync(lease, "utf8"),
);
process.stdout.write("ready\n");
await new Promise((resolve) => {
process.stdin.once("end", resolve);
process.stdin.resume();
});
} finally {
await retireLeaseChannel();
}
} else {
await command();
}

View file

@ -1327,7 +1327,7 @@ console.log("relocated Bash parser works without native grammar package");
throw new Error("worker deploy config must define dependency bundling");
}
expect(alwaysBundle("json5", undefined)).toBe(true);
expect(alwaysBundle("node:fs", undefined)).toBe(false);
expect(config?.platform).toBe("node");
expect(config?.outExtensions?.(context)).toEqual({ js: ".mjs", dts: ".d.ts" });
}
});

View file

@ -161,6 +161,13 @@ export { setRuntimeConfigSnapshot } from "../config/runtime-snapshot.js";`;
expect(bundles.flatMap((bundle) => bundle.chunks.map((chunk) => chunk.fileName))).toEqual([
"worker/worker.mjs",
]);
expect(
bundles.flatMap((bundle) =>
bundle.chunks.flatMap((chunk) =>
chunk.type === "chunk" ? [...chunk.imports, ...chunk.dynamicImports] : [],
),
),
).not.toContain("ws");
const { collectWorkerDeployArtifactErrors } =
await import("../../scripts/check-cli-bootstrap-imports.mts");
expect(
@ -393,6 +400,7 @@ console.log("relocated worker facade activation follows the shared config snapsh
const result = await promisify(execFile)(
process.execPath,
[
...(process.versions.bun ? ["--no-install"] : []),
"--input-type=module",
"--eval",
`

View file

@ -1,6 +1,6 @@
// tsdown config defines package build entrypoints and output options.
import fs from "node:fs";
import { createRequire, isBuiltin } from "node:module";
import { createRequire } from "node:module";
import path from "node:path";
import type { DtsOptions, TsdownPlugin, UserConfig } from "tsdown";
import {
@ -243,6 +243,7 @@ function workerDeployBuildConfig(entry: Record<string, string>): UserConfig {
name: TSDOWN_UNIFIED_CONFIG_GROUP,
entry,
outDir: "dist",
platform: "node",
dts: false,
env,
define: {
@ -260,7 +261,8 @@ function workerDeployBuildConfig(entry: Record<string, string>): UserConfig {
"utf-8-validate": WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
},
deps: {
alwaysBundle: (id) => !isBuiltin(id),
// Rolldown's Node target owns builtin resolution, independently of the build host.
alwaysBundle: () => true,
onlyBundle: false,
},
fixedExtension: false,
@ -282,11 +284,12 @@ function workerHelperBuildConfig(
name: TSDOWN_UNIFIED_CONFIG_GROUP,
entry,
outDir: "dist",
platform: "node",
dts: false,
env,
define,
deps: {
alwaysBundle: (id) => !isBuiltin(id),
alwaysBundle: () => true,
onlyBundle: false,
},
fixedExtension: false,