feat(workers): run device sessions from Gateway bundles (#124037)

* feat(workers): run device sessions from Gateway bundles

Install the current Gateway bundle before a device environment becomes ready, verify it at attach and tunnel boundaries, launch only from the immutable namespaced bundle directory, and retire stale environments for idempotent reprovisioning. Remove the local execution mode and preserve the node-local build claim only as temporary inventory metadata for the final projection/cleanup slice.

* docs(runners): record Gateway bundle cutover

* test(ci): repair runner validation fixtures

# Conflicts:
#	src/scripts/test-projects.test.ts

* fix(workers): surface outdated node recovery

Keep legacy runner inventory diagnostic-only while exposing the update-and-reconnect action through node, environment, provider, placement, and Control UI surfaces.

* fix(workers): reject legacy inventory with recovery

* fix(workers): bundle worker deploy closure

* test(workers): close bundle cutover gates

* fix(workers): compose browser runtime at build

* fix(workers): satisfy bundle cutover gates

* fix(workers): route temp runtime through infra

* docs(workers): align bundle host guidance

* fix(ui): fence outdated session destinations
This commit is contained in:
Peter Steinberger
2026-08-15 17:46:44 -07:00
committed by GitHub
parent eb13f5719f
commit 78502eda6d
105 changed files with 1950 additions and 1533 deletions
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from "vitest";
import {
collectCliBootstrapExternalImportErrors,
collectGatewayRunChunkBudgetErrors,
collectWorkerDeployArtifactErrors,
listStaticImportSpecifiers,
} from "../../scripts/check-cli-bootstrap-imports.mts";
@@ -127,4 +128,46 @@ describe("check-cli-bootstrap-imports", () => {
`Gateway run chunk dist/run-gateway.js is ${gatewayRunChunkBytes} bytes, above budget 50 bytes.`,
]);
});
it("accepts one self-contained worker executable with builtin imports", () => {
const root = makeTempRoot();
writeFixture(
root,
"dist/worker/worker.mjs",
'import fs from "node:fs";\nexport const worker = Boolean(fs);\n',
);
expect(collectWorkerDeployArtifactErrors({ rootDir: root })).toEqual([]);
});
it("rejects worker package imports and dependency manifests", () => {
const root = makeTempRoot();
writeFixture(
root,
"dist/worker/worker.mjs",
[
'import "left-pad";',
'await import("./lazy.mjs");',
'__require("json5");',
'createRequire(import.meta.url)("../../package.json");',
'moduleNamespace.createRequire(import.meta.url)("@openclaw/fs-safe/temp");',
].join("\n"),
);
writeFixture(root, "dist/worker/lazy.mjs", "export {};\n");
writeFixture(
root,
"dist/worker/package.json",
`${JSON.stringify({ scripts: { postinstall: "node prepare.js" } })}\n`,
);
expect(collectWorkerDeployArtifactErrors({ rootDir: root })).toEqual([
'Worker deploy artifact dist/worker/worker.mjs retains runtime import "../../package.json" instead of bundling it.',
'Worker deploy artifact dist/worker/worker.mjs retains runtime import "./lazy.mjs" instead of bundling it.',
'Worker deploy artifact dist/worker/worker.mjs retains runtime import "@openclaw/fs-safe/temp" instead of bundling it.',
'Worker deploy artifact dist/worker/worker.mjs retains runtime import "json5" instead of bundling it.',
'Worker deploy artifact dist/worker/worker.mjs retains runtime import "left-pad" instead of bundling it.',
"Worker deploy artifact emits unstaged runtime asset dist/worker/lazy.mjs.",
"Worker deploy artifact must not contain a dependency manifest or lifecycle scripts.",
]);
});
});
+64 -1
View File
@@ -8,6 +8,7 @@ import {
TSDOWN_UNIFIED_CONFIG_GROUP,
TSDOWN_UNIFIED_DTS_CONFIG_GROUPS,
} from "../../scripts/lib/tsdown-config-groups.mts";
import { WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID } from "../../scripts/lib/worker-deploy-build-plugin.mts";
import config from "../../tsdown.config.ts";
const configs = Array.isArray(config) ? config : [config];
@@ -15,6 +16,16 @@ const configs = Array.isArray(config) ? config : [config];
type TsdownConfig = (typeof configs)[number];
type OutExtensions = NonNullable<TsdownConfig["outExtensions"]>;
function isWorkerDeployConfig(config: TsdownConfig): boolean {
const entry = config.entry;
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
return false;
}
return (
(entry as Record<string, unknown>)["worker/worker"] === "src/worker/worker-deploy-entry.ts"
);
}
describe("tsdown config", () => {
it.each(["tsdown.config.ts", "tsdown.ai.config.ts"])(
"keeps %s free of runtime imports from tsdown",
@@ -85,8 +96,60 @@ describe("tsdown config", () => {
expect(privateDeclarationSources).toContain("src/plugin-sdk/tts-runtime.ts");
});
it("builds one worker-only executable with every package dependency bundled", () => {
const workerConfig = configs.find(isWorkerDeployConfig);
expect(workerConfig?.entry).toEqual({
"worker/worker": "src/worker/worker-deploy-entry.ts",
});
expect(workerConfig?.dts).toBe(false);
const packageVersion = (
JSON.parse(fs.readFileSync("package.json", "utf8")) as {
version: string;
}
).version;
expect(workerConfig?.define).toEqual({
WORKER_DEPLOY_BUILD: "true",
WORKER_DEPLOY_VERSION: JSON.stringify(packageVersion),
});
expect(workerConfig?.alias).toMatchObject({
bufferutil: WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
"chromium-bidi/lib/cjs/bidiMapper/BidiMapper": WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
"chromium-bidi/lib/cjs/cdp/CdpConnection": WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
"electron/index.js": WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
fsevents: WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
kerberos: WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
"utf-8-validate": WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
});
expect(workerConfig?.outDir).toBe("dist");
expect(workerConfig?.shims).toBe(true);
expect(workerConfig?.plugins).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "openclaw:worker-deploy" })]),
);
expect(workerConfig?.outputOptions).toMatchObject({
codeSplitting: false,
assetFileNames: "worker/[name][extname]",
});
expect(workerConfig?.deps?.onlyBundle).toBe(false);
expect(workerConfig?.deps?.alwaysBundle).toBeTypeOf("function");
const alwaysBundle = workerConfig?.deps?.alwaysBundle;
if (typeof alwaysBundle !== "function") {
throw new Error("worker deploy config must define dependency bundling");
}
expect(alwaysBundle("json5", undefined)).toBe(true);
expect(alwaysBundle("node:fs", undefined)).toBe(false);
const context = {
format: "es",
options: {},
pkgType: "module",
} as Parameters<OutExtensions>[0];
expect(workerConfig?.outExtensions?.(context)).toEqual({ js: ".mjs", dts: ".d.ts" });
});
it("keeps node package artifacts on the declared js and dts extensions", () => {
const nodePackageConfigs = configs.filter((entry) => entry.fixedExtension === false);
const nodePackageConfigs = configs.filter(
(entry) => entry.fixedExtension === false && !isWorkerDeployConfig(entry),
);
expect(nodePackageConfigs).not.toHaveLength(0);
const context = {
@@ -0,0 +1,80 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
createWorkerDeployBuildPlugin,
WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID,
} from "../../scripts/lib/worker-deploy-build-plugin.mts";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const fail = (message: string): never => {
throw new Error(message);
};
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
describe("worker deploy build plugin", () => {
it("replaces optional host-native modules with a failing virtual module", () => {
const plugin = createWorkerDeployBuildPlugin();
expect(plugin.load(WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID)).toContain(
"optional host-native dependency unavailable",
);
});
it("composes the bundled Browser runtime only in the deploy build", () => {
const bridgePath = path.resolve("src/worker/worker-deploy-browser-runtime.ts");
const source = fs.readFileSync(bridgePath, "utf8");
const plugin = createWorkerDeployBuildPlugin();
const transformed = plugin.transform.call({ error: fail }, source, bridgePath);
expect(transformed).toContain(
'import { createAttachedBrowserToolRuntime } from "../../extensions/browser/runtime-api.js";',
);
expect(transformed).toContain("export default { createAttachedBrowserToolRuntime };");
expect(transformed).not.toContain("was not composed by the build");
});
it("inlines Playwright package identity without a runtime manifest read", () => {
const coreBundlePath = path.resolve("node_modules/playwright-core/lib/coreBundle.js");
const source = fs.readFileSync(coreBundlePath, "utf8");
const plugin = createWorkerDeployBuildPlugin();
const transformed = plugin.transform.call({ error: fail }, source, coreBundlePath);
expect(transformed).toContain('packageJSON = {"name":"playwright-core","version":"1.62.1"};');
expect(transformed).not.toContain(
'packageJSON = require(import_path9.default.join(packageRoot, "package.json"));',
);
expect(transformed).toContain(
'registry = new Registry({"comment":"Do not edit this file, use utils/roll_browser.js"',
);
expect(transformed).not.toContain(
'registry = new Registry(require(import_path20.default.join(packageRoot, "browsers.json")));',
);
});
it("matches the canonical dependency path behind a pnpm-style symlink", () => {
const sourceRoot = path.resolve("node_modules/playwright-core");
const source = fs.readFileSync(path.join(sourceRoot, "lib/coreBundle.js"), "utf8");
const tempRoot = tempDirs.make("openclaw-worker-build-plugin-");
const linkedRoot = path.join(tempRoot, "node_modules", "playwright-core");
fs.mkdirSync(path.dirname(linkedRoot), { recursive: true });
fs.symlinkSync(sourceRoot, linkedRoot, process.platform === "win32" ? "junction" : "dir");
const plugin = createWorkerDeployBuildPlugin(tempRoot);
const resolvedId = fs.realpathSync(path.join(linkedRoot, "lib/coreBundle.js"));
const transformed = plugin.transform.call({ error: fail }, source, resolvedId);
expect(transformed).toContain('packageJSON = {"name":"playwright-core","version":"1.62.1"};');
});
it("fails closed when the dependency-owned bootstrap shape changes", () => {
const coreBundlePath = path.resolve("node_modules/playwright-core/lib/coreBundle.js");
const plugin = createWorkerDeployBuildPlugin();
expect(() =>
plugin.transform.call({ error: fail }, "changed upstream source", coreBundlePath),
).toThrow("playwright-core package bootstrap changed");
});
});