feat(gateway): run managed services from standalone artifacts (#183838)

This commit is contained in:
Peter Steinberger authored and GitHub committed 2026-10-25 16:29:28 -07:00
1 parent bb6a983a9d
commit cfece29ae8
31 files changed
+374 -113

No files matched your search

+22
View File
@@ -399,6 +399,28 @@ exercise native plugin loading. See [macOS signing](/platforms/mac/signing).
## Update the complete artifact
### Managed Gateway service
Install a complete standalone artifact as the managed Gateway runtime with:
```sh
./standalone-aot/openclaw gateway install --runtime standalone --runtime-path /absolute/path/to/standalone-aot/openclaw
```
The service launches `<executable> gateway ...` directly. It does not add a loose
JavaScript entrypoint or Bun flags. Installation probes the executable's embedded
runtime-check entry, including SQLite capabilities, and records the exact runtime
path. Renaming the executable is supported when its adjacent `manifest.json`
names it correctly. Keep that manifest and the complete `runtime/` directory
together; older artifacts without the embedded runtime-check entry must be rebuilt.
Reinstallation and Doctor retain an explicit standalone selection. To switch
artifacts, install the newly verified complete directory with the same two flags
and `--force`; retain the previous directory until the Gateway and CLI no longer
reference it. Desktop applications own this handoff for their bundled artifacts.
### Artifact replacement
The standalone executable is not a package-manager or git installation.
`openclaw update` refuses to modify it and displays a next step: update the owning
desktop application, or rebuild and replace the standalone output.
+1 -1
View File
@@ -50,7 +50,7 @@ var scripts = {
daemonRuntimeProbe: {
fileName: "daemon-runtime-probe.mjs",
source:
'var __getOwnPropNames=Object.getOwnPropertyNames;var __esm=(fn,res,err)=>function __init(){if(err)throw err[0];try{return fn&&(res=(0,fn[__getOwnPropNames(fn)[0]])(fn=0)),res}catch(e){throw err=[e],e}};var GATEWAY_SHUTDOWN_DRAIN_TIMEOUT_MS,GATEWAY_SHUTDOWN_RESERVE_MS,GATEWAY_SUPERVISOR_EXIT_MARGIN_MS,GATEWAY_SHUTDOWN_TIMEOUT_MS,GATEWAY_SERVICE_STOP_TIMEOUT_MS,LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS;var init_gateway_shutdown_budget=__esm({"gateway-shutdown-budget.mjs"(){"use strict";GATEWAY_SHUTDOWN_DRAIN_TIMEOUT_MS=315e3;GATEWAY_SHUTDOWN_RESERVE_MS=1e4;GATEWAY_SUPERVISOR_EXIT_MARGIN_MS=5e3;GATEWAY_SHUTDOWN_TIMEOUT_MS=GATEWAY_SHUTDOWN_DRAIN_TIMEOUT_MS+GATEWAY_SHUTDOWN_RESERVE_MS;GATEWAY_SERVICE_STOP_TIMEOUT_MS=GATEWAY_SHUTDOWN_TIMEOUT_MS+GATEWAY_SUPERVISOR_EXIT_MARGIN_MS;LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS=GATEWAY_SERVICE_STOP_TIMEOUT_MS/1e3}});var parentKey,CLI_RESPAWN_PARENT_ENV,inheritedParent,CLI_PARENT_BOUND_PRELOAD;var init_cli_parent_lifetime=__esm({"cli-parent-lifetime.mjs"(){"use strict";init_gateway_shutdown_budget();parentKey=Symbol.for("openclaw.cliRespawnParent");CLI_RESPAWN_PARENT_ENV="OPENCLAW_CLI_RESPAWN_PARENT";inheritedParent=process.env[CLI_RESPAWN_PARENT_ENV];delete process.env[CLI_RESPAWN_PARENT_ENV];if(inheritedParent===String(process.ppid)){globalThis[parentKey]={pid:process.ppid}}CLI_PARENT_BOUND_PRELOAD=`data:text/javascript,${encodeURIComponent(`\n globalThis[Symbol.for("openclaw.cliRespawnParent")] = { pid: process.ppid };\n process.execArgv = process.execArgv.filter((arg, index, args) =>\n arg !== import.meta.url && !(arg === "--import" && args[index + 1] === import.meta.url));\n`)}`}});var init_node_runtime_env=__esm({"node-runtime-env.mjs"(){"use strict"}});import{readFileSync}from"node:fs";import path from"node:path";import{fileURLToPath}from"node:url";function readStandaloneRuntimeManifest(executable=process.execPath){return readStandaloneManifest({executable})}function projectStandaloneBuildInfo(value){const nonBlank=text=>typeof text==="string"&&text.trim().length>0;const stringList=items=>Array.isArray(items)&&items.every(nonBlank);const result={mode:value.mode,openclaw:{version:value.openclaw.version,...nonBlank(value.openclaw.commit)?{commit:value.openclaw.commit}:{}},bun:{version:value.bun.version}};for(const key of["revision","commit","executableSha256"]){if(nonBlank(value.bun[key])){result.bun[key]=value.bun[key]}}if(value.build!==void 0){const{id,flavor,pluginSetSha256,moduleGraphSha256}=value.build;if(!nonBlank(id)||!["standard","support"].includes(flavor)||!/^[a-f0-9]{64}$/.test(pluginSetSha256)||!/^[a-f0-9]{64}$/.test(moduleGraphSha256)){throw new Error("Invalid standalone build provenance")}result.build={id,flavor,pluginSetSha256,moduleGraphSha256};if(value.build.debugEntry!==void 0){const entry=value.build.debugEntry;if(typeof entry!=="string"||!entry.startsWith("debug/")||entry.includes("\\\\")||entry.split("/").some(part=>!part||part==="."||part==="..")){throw new Error("Invalid standalone debugger entry")}result.build.debugEntry=entry}}if(value.target!==void 0){if(!nonBlank(value.target.platform)||!nonBlank(value.target.arch)){throw new Error("Invalid standalone build target")}result.target={platform:value.target.platform,arch:value.target.arch}}if(value.plugins!==void 0){if(!stringList(value.plugins.embedded)||!stringList(value.plugins.omitted)){throw new Error("Invalid standalone plugin inventory")}result.plugins={embedded:[...value.plugins.embedded],omitted:[...value.plugins.omitted]}}if(value.nativeModules!==void 0){if(!Array.isArray(value.nativeModules)||value.nativeModules.some(item=>!item||!nonBlank(item.name)||!nonBlank(item.version)||!stringList(item.artifacts)||item.qualification!=="unverified"||!nonBlank(item.backend))){throw new Error("Invalid standalone native inventory")}result.nativeModules=value.nativeModules.map(({name,version,artifacts,qualification,backend})=>({name,version,artifacts:[...artifacts],qualification,backend}))}return result}function readStandaloneManifest({executable,runtimeRoot,includeBuild=false}){const location=executable??runtimeRoot;if(!path.isAbsolute(location)){return void 0}const root=path.dirname(location);const manifestPath=path.join(root,"manifest.json");let raw;try{raw=readFileSync(manifestPath,"utf8")}catch(error){if(error?.code==="ENOENT"||error?.code==="ENOTDIR"){return void 0}throw error}let manifest;try{manifest=JSON.parse(raw)}catch(cause){throw new Error(`Invalid standalone runtime manifest: ${manifestPath}`,{cause})}const nonBlank=value=>typeof value==="string"&&value.trim().length>0;if(manifest?.schemaVersion!==1||!["jit","bytecode","aot"].includes(manifest.mode)||!nonBlank(manifest.executable?.path)||!nonBlank(manifest.runtime?.path)||!nonBlank(manifest.openclaw?.version)||!nonBlank(manifest.bun?.version)){throw new Error(`Invalid standalone runtime manifest: ${manifestPath}`)}const executableRelative=path.relative(root,path.resolve(root,manifest.executable.path));const sidecarRelative=path.relative(roLine truncated
'var __getOwnPropNames=Object.getOwnPropertyNames;var __esm=(fn,res,err)=>function __init(){if(err)throw err[0];try{return fn&&(res=(0,fn[__getOwnPropNames(fn)[0]])(fn=0)),res}catch(e){throw err=[e],e}};var GATEWAY_SHUTDOWN_DRAIN_TIMEOUT_MS,GATEWAY_SHUTDOWN_RESERVE_MS,GATEWAY_SUPERVISOR_EXIT_MARGIN_MS,GATEWAY_SHUTDOWN_TIMEOUT_MS,GATEWAY_SERVICE_STOP_TIMEOUT_MS,LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS;var init_gateway_shutdown_budget=__esm({"gateway-shutdown-budget.mjs"(){"use strict";GATEWAY_SHUTDOWN_DRAIN_TIMEOUT_MS=315e3;GATEWAY_SHUTDOWN_RESERVE_MS=1e4;GATEWAY_SUPERVISOR_EXIT_MARGIN_MS=5e3;GATEWAY_SHUTDOWN_TIMEOUT_MS=GATEWAY_SHUTDOWN_DRAIN_TIMEOUT_MS+GATEWAY_SHUTDOWN_RESERVE_MS;GATEWAY_SERVICE_STOP_TIMEOUT_MS=GATEWAY_SHUTDOWN_TIMEOUT_MS+GATEWAY_SUPERVISOR_EXIT_MARGIN_MS;LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS=GATEWAY_SERVICE_STOP_TIMEOUT_MS/1e3}});var parentKey,CLI_RESPAWN_PARENT_ENV,inheritedParent,CLI_PARENT_BOUND_PRELOAD;var init_cli_parent_lifetime=__esm({"cli-parent-lifetime.mjs"(){"use strict";init_gateway_shutdown_budget();parentKey=Symbol.for("openclaw.cliRespawnParent");CLI_RESPAWN_PARENT_ENV="OPENCLAW_CLI_RESPAWN_PARENT";inheritedParent=process.env[CLI_RESPAWN_PARENT_ENV];delete process.env[CLI_RESPAWN_PARENT_ENV];if(inheritedParent===String(process.ppid)){globalThis[parentKey]={pid:process.ppid}}CLI_PARENT_BOUND_PRELOAD=`data:text/javascript,${encodeURIComponent(`\n globalThis[Symbol.for("openclaw.cliRespawnParent")] = { pid: process.ppid };\n process.execArgv = process.execArgv.filter((arg, index, args) =>\n arg !== import.meta.url && !(arg === "--import" && args[index + 1] === import.meta.url));\n`)}`}});var init_node_runtime_env=__esm({"node-runtime-env.mjs"(){"use strict"}});import{readFileSync}from"node:fs";import path from"node:path";import{fileURLToPath}from"node:url";function readStandaloneRuntimeManifest(executable=process.execPath){return readStandaloneManifest({executable})}function projectStandaloneBuildInfo(value){const nonBlank=text=>typeof text==="string"&&text.trim().length>0;const stringList=items=>Array.isArray(items)&&items.every(nonBlank);const result={mode:value.mode,openclaw:{version:value.openclaw.version,...nonBlank(value.openclaw.commit)?{commit:value.openclaw.commit}:{}},bun:{version:value.bun.version}};for(const key of["revision","commit","executableSha256"]){if(nonBlank(value.bun[key])){result.bun[key]=value.bun[key]}}if(value.build!==void 0){const{id,flavor,pluginSetSha256,moduleGraphSha256}=value.build;if(!nonBlank(id)||!["standard","support"].includes(flavor)||!/^[a-f0-9]{64}$/.test(pluginSetSha256)||!/^[a-f0-9]{64}$/.test(moduleGraphSha256)){throw new Error("Invalid standalone build provenance")}result.build={id,flavor,pluginSetSha256,moduleGraphSha256};if(value.build.debugEntry!==void 0){const entry=value.build.debugEntry;if(typeof entry!=="string"||!entry.startsWith("debug/")||entry.includes("\\\\")||entry.split("/").some(part=>!part||part==="."||part==="..")){throw new Error("Invalid standalone debugger entry")}result.build.debugEntry=entry}}if(value.target!==void 0){if(!nonBlank(value.target.platform)||!nonBlank(value.target.arch)){throw new Error("Invalid standalone build target")}result.target={platform:value.target.platform,arch:value.target.arch}}if(value.plugins!==void 0){if(!stringList(value.plugins.embedded)||!stringList(value.plugins.omitted)){throw new Error("Invalid standalone plugin inventory")}result.plugins={embedded:[...value.plugins.embedded],omitted:[...value.plugins.omitted]}}if(value.nativeModules!==void 0){if(!Array.isArray(value.nativeModules)||value.nativeModules.some(item=>!item||!nonBlank(item.name)||!nonBlank(item.version)||!stringList(item.artifacts)||item.qualification!=="unverified"||!nonBlank(item.backend))){throw new Error("Invalid standalone native inventory")}result.nativeModules=value.nativeModules.map(({name,version,artifacts,qualification,backend})=>({name,version,artifacts:[...artifacts],qualification,backend}))}return result}function readStandaloneManifest({executable,runtimeRoot,includeBuild=false}){const location=executable??runtimeRoot;if(!path.isAbsolute(location)){return void 0}const root=path.dirname(location);const manifestPath=path.join(root,"manifest.json");let raw;try{raw=readFileSync(manifestPath,"utf8")}catch(error){if(error?.code==="ENOENT"||error?.code==="ENOTDIR"){return void 0}throw error}let manifest;try{manifest=JSON.parse(raw)}catch(cause){throw new Error(`Invalid standalone runtime manifest: ${manifestPath}`,{cause})}const nonBlank=value=>typeof value==="string"&&value.trim().length>0;if(manifest?.schemaVersion!==1||!["jit","bytecode","aot"].includes(manifest.mode)||!nonBlank(manifest.executable?.path)||!nonBlank(manifest.runtime?.path)||!nonBlank(manifest.openclaw?.version)||!nonBlank(manifest.bun?.version)){throw new Error(`Invalid standalone runtime manifest: ${manifestPath}`)}const executableRelative=path.relative(root,path.resolve(root,manifest.executable.path));const sidecarRelative=path.relative(roLine truncated
},
updateStateSizes: {
fileName: "update-state-sizes.mjs",
+16 -9
View File
@@ -40,13 +40,15 @@ const serviceFile = z
.refine((file) => path.isAbsolute(file) && !/[\0\r\n]/.test(file) && isRegularFile(file));
const restoreServiceCliSchema = z.strictObject({
executable: z.string(),
entrypoint: serviceFile.refine(
(entry) =>
path.basename(entry) === "openclaw.mjs" ||
buildGatewayInstallEntrypointCandidates(path.dirname(path.dirname(entry))).includes(
path.normalize(entry),
),
),
entrypoint: serviceFile
.refine(
(entry) =>
path.basename(entry) === "openclaw.mjs" ||
buildGatewayInstallEntrypointCandidates(path.dirname(path.dirname(entry))).includes(
path.normalize(entry),
),
)
.nullable(),
sqliteLibrary: serviceFile.nullable(),
});
@@ -66,8 +68,13 @@ export async function resolveRestoreServiceCli(
if (expectedRuntimePin === undefined) {
throw new Error("requires --expected-runtime-pin.");
}
if (runtime !== "node" && runtime !== "bun") {
throw new Error('requires an explicit --runtime ("node" or "bun").');
if (runtime !== "node" && runtime !== "bun" && runtime !== "standalone") {
throw new Error('requires an explicit --runtime ("node", "bun", or "standalone").');
}
if ((runtime === "standalone") !== (serviceCli.entrypoint === null)) {
throw new Error(
"standalone recovery requires a null entrypoint; Node and Bun require an entry script.",
);
}
if (wrapper !== undefined || installEnv[OPENCLAW_WRAPPER_ENV_KEY]?.trim()) {
throw new Error("cannot be combined with a wrapper.");
+30 -7
View File
@@ -118,7 +118,7 @@ describe("runDaemonInstall", () => {
revision: expected.revision,
definition: expected.definition,
});
function recoveryFixture(runtime: "node" | "bun" = "node") {
function recoveryFixture(runtime: "node" | "bun" | "standalone" = "node") {
const root = tempDirs.make("openclaw-restore-cli-");
const executable = path.join(
root,
@@ -129,8 +129,26 @@ describe("runDaemonInstall", () => {
for (const file of [executable, entrypoint, sqliteLibrary]) {
fs.writeFileSync(file, "", { mode: 0o755 });
}
if (runtime === "standalone") {
fs.mkdirSync(path.join(root, "runtime"));
fs.writeFileSync(
path.join(root, "manifest.json"),
JSON.stringify({
schemaVersion: 1,
mode: "aot",
executable: { path: "standalone" },
runtime: { path: "runtime" },
openclaw: { version: "2026.10.25" },
bun: { version: "1.4.4" },
}),
);
}
pinSnapshotMock.mockReturnValue(expected);
return { executable, entrypoint, sqliteLibrary };
return {
executable,
entrypoint,
sqliteLibrary,
};
}
const restore = (cli: object, options: Partial<DaemonInstallOptions> = {}) =>
runDaemonInstall({
@@ -171,13 +189,18 @@ describe("runDaemonInstall", () => {
it.each([
{ runtime: "node", library: false },
{ runtime: "bun", library: true },
{ runtime: "standalone", library: false },
] as const)(
"restores $runtime with retained library=$library and definition custody",
async ({ runtime, library }) => {
const files = recoveryFixture(runtime);
const cli = { ...files, sqliteLibrary: library ? files.sqliteLibrary : null };
const cli = {
...files,
entrypoint: runtime === "standalone" ? null : files.entrypoint,
sqliteLibrary: library ? files.sqliteLibrary : null,
};
process.env.OPENCLAW_SQLITE_LIBRARY = "/installer/libsqlite3.dylib";
if (runtime === "bun") {
if (runtime !== "node") {
const probe = nodeProbeOutput("26.12.1");
runExecMock.mockResolvedValue({
...probe,
@@ -187,19 +210,19 @@ describe("runDaemonInstall", () => {
performServiceInstall();
await restore(cli, {
runtime,
...(runtime === "bun" ? { runtimePath: cli.executable } : {}),
...(runtime !== "node" ? { runtimePath: cli.executable } : {}),
});
expect(actionState.failed).toEqual([]);
const plan = readFirstInstallPlanArg();
expect(plan).toMatchObject({
serviceCli: { executable: cli.executable, entrypoint: cli.entrypoint },
runtimePath: cli.executable,
pinnedRuntimePath: runtime === "bun" ? cli.executable : undefined,
pinnedRuntimePath: runtime !== "node" ? cli.executable : undefined,
});
expectPartialCall(service.install, {
runtimePinUpdate: {
expected,
pin: runtime === "bun" ? { runtime, path: cli.executable } : undefined,
pin: runtime !== "node" ? { runtime, path: cli.executable } : undefined,
requireDefinitionMatch: true,
},
env: plan.env,
+5 -2
View File
@@ -262,9 +262,12 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
fail(formatInvalidConfigPort("gateway.port"));
return;
}
const runtimeRaw = opts.runtime || resolveGatewayDaemonRuntime([pinnedRuntimePath ?? ""]);
const runtimeRaw =
opts.runtime ||
pinSnapshot.pin?.runtime ||
resolveGatewayDaemonRuntime([pinnedRuntimePath ?? ""]);
if (!isGatewayDaemonRuntime(runtimeRaw)) {
fail('Invalid --runtime (use "node" or "bun")');
fail('Invalid --runtime (use "node", "bun", or "standalone")');
return;
}
let runtime = runtimeRaw;
@@ -82,7 +82,7 @@ export function addGatewayServiceCommands(parent: Command, opts?: { statusDescri
.command("install")
.description("Install and start the Gateway service (launchd/systemd/schtasks)")
.option("--port <port>", "Gateway port")
.option("--runtime <runtime>", "Daemon runtime (node|bun). Default: node")
.option("--runtime <runtime>", "Daemon runtime (node|bun|standalone). Default: node")
.option("--runtime-path <path>", "Pin an absolute Node/Bun executable path")
.addOption(
new Option("--expected-runtime-pin <json>", "Require the observed runtime intent").hideHelp(),
+20 -13
View File
@@ -194,17 +194,23 @@ export async function repairLoadedGatewayServiceForStart(
readDaemonRuntimePin({ kind: "gateway", env: installEnv }, params.state.command),
);
const pinnedRuntime = wrapperPath ? undefined : pinSnapshot.pin?.path;
const installedRuntime = resolveGatewayDaemonRuntime(
pinnedRuntime ? [pinnedRuntime] : managedCommand?.programArguments,
);
const installedRuntime =
(!wrapperPath && pinSnapshot.pin?.runtime) ||
resolveGatewayDaemonRuntime(pinnedRuntime ? [pinnedRuntime] : managedCommand?.programArguments);
if (!wrapperPath) {
await resolvePinnedDaemonRuntimePath(pinnedRuntime, installedRuntime, installEnv);
}
const installedRuntimePath =
installedRuntime === "bun" ? (pinnedRuntime ?? managedCommand?.programArguments[0]) : undefined;
const runtimeInfo = installedRuntimePath
? await resolveBunRuntimeInfo(installedRuntimePath)
: undefined;
installedRuntime === "bun" || installedRuntime === "standalone"
? (pinnedRuntime ?? managedCommand?.programArguments[0])
: undefined;
if (installedRuntime === "standalone" && !pinnedRuntime) {
await resolvePinnedDaemonRuntimePath(installedRuntimePath, "standalone", installEnv);
}
const runtimeInfo =
installedRuntimePath && installedRuntime === "bun"
? await resolveBunRuntimeInfo(installedRuntimePath)
: undefined;
if (runtimeInfo?.status === "probe-failed") {
throw runtimeInfo.error;
}
@@ -212,11 +218,12 @@ export async function repairLoadedGatewayServiceForStart(
if (runtimeInfo?.status === "unsupported" && runtimeInfo.sqliteSelectionError) {
throw new Error(runtimeInfo.sqliteSelectionError);
}
const runtime = pinnedRuntime
? installedRuntime
: runtimeInfo?.status === "supported"
? "bun"
: "node";
const runtime =
pinnedRuntime || installedRuntime === "standalone"
? installedRuntime
: runtimeInfo?.status === "supported"
? "bun"
: "node";
const tokenResolution = await resolveGatewayInstallToken({
config: cfg,
@@ -243,7 +250,7 @@ export async function repairLoadedGatewayServiceForStart(
env: installEnv,
port,
runtime,
runtimePath: runtime === "bun" ? installedRuntimePath : undefined,
runtimePath: runtime === "bun" || runtime === "standalone" ? installedRuntimePath : undefined,
pinnedRuntimePath: pinSnapshot.pin?.path,
wrapperPath,
existingCommand: params.state.command,
+8 -3
View File
@@ -202,9 +202,12 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
return;
}
let pinnedRuntimePath = opts.runtimePath ?? (opts.runtime ? undefined : pinSnapshot.pin?.path);
const runtimeRaw = opts.runtime || resolveGatewayDaemonRuntime([pinnedRuntimePath ?? ""]);
const runtimeRaw =
opts.runtime ||
pinSnapshot.pin?.runtime ||
resolveGatewayDaemonRuntime([pinnedRuntimePath ?? ""]);
if (!isGatewayDaemonRuntime(runtimeRaw)) {
fail('Invalid --runtime (use "node" or "bun")');
fail('Invalid --runtime (use "node", "bun", or "standalone")');
return;
}
@@ -261,7 +264,9 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
});
const cliEntrypoint =
restoreServiceCli?.entrypoint ??
(!devMode && !wrapperPath ? await resolveDurableNodeEntrypoint(installEnv) : undefined);
(!devMode && !wrapperPath && runtime !== "standalone"
? await resolveDurableNodeEntrypoint(installEnv)
: undefined);
const { programArguments, workingDirectory } = await resolveNodeProgramArguments({
cliEntrypoint,
host,
+1 -1
View File
@@ -175,7 +175,7 @@ export function registerNodeCli(program: Command) {
)
.option("--share-installed-apps", "Share installed macOS applications with the Gateway")
.option("--no-share-installed-apps", "Disable installed application sharing")
.option("--runtime <runtime>", "Service runtime (node|bun). Default: node")
.option("--runtime <runtime>", "Service runtime (node|bun|standalone). Default: node")
.option("--runtime-path <path>", "Pin an absolute Node/Bun executable path")
.addOption(
new Option("--expected-runtime-pin <json>", "Require the observed runtime intent").hideHelp(),
+1 -1
View File
@@ -148,7 +148,7 @@ export function registerOnboardRuntimeOptions(
.option("--install-daemon", "Install gateway service")
.option("--no-install-daemon", "Skip gateway service install")
.option("--skip-daemon", "Skip gateway service install")
.option("--daemon-runtime <runtime>", "Daemon runtime: node|bun (default: node)")
.option("--daemon-runtime <runtime>", "Daemon runtime: node|bun|standalone (default: node)")
.option("--skip-channels", "Skip channel setup")
.option("--skip-skills", "Skip skills setup")
.option("--skip-bootstrap", "Skip creating default agent workspace files")
+6 -3
View File
@@ -582,7 +582,7 @@ export async function buildGatewayInstallPlan(params: {
runtimePath?: string;
pinnedRuntimePath?: string;
/** Retained CLI to plan for instead of this process's own entrypoint and executable. */
serviceCli?: { executable: string; entrypoint: string };
serviceCli?: { executable: string; entrypoint: string | null };
wrapperPath?: string;
platform?: NodeJS.Platform;
warn?: DaemonInstallWarnFn;
@@ -652,7 +652,7 @@ export async function buildGatewayInstallPlan(params: {
runtime,
runtimePath,
wrapperPath,
cliEntrypoint: params.serviceCli?.entrypoint,
cliEntrypoint: params.serviceCli?.entrypoint ?? undefined,
...(params.existingCommand ? { existingCommand: params.existingCommand } : {}),
});
await emitNodeRuntimeWarning({
@@ -676,7 +676,10 @@ export async function buildGatewayInstallPlan(params: {
platform,
extraPathDirs: resolveDaemonServicePathDirs({
runtimePath,
argv: params.serviceCli && [params.serviceCli.executable, params.serviceCli.entrypoint],
argv: params.serviceCli && [
params.serviceCli.executable,
...(params.serviceCli.entrypoint ? [params.serviceCli.entrypoint] : []),
],
env: serviceInputEnv,
platform,
}),
@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { isStandaloneRuntimeExecutable } from "../../cli-invocation.mjs";
import {
resolveBunRuntimeInfo,
resolvePinnedDaemonRuntimePath,
@@ -65,6 +66,19 @@ export async function resolveDaemonInstallRuntimeInputs(params: {
if (params.wrapperPath?.trim()) {
return { devMode, runtime: params.runtime, runtimePath: params.runtimePath };
}
if (params.runtime === "standalone") {
const executable =
params.pinnedRuntimePath ??
params.runtimePath ??
(isStandaloneRuntimeExecutable() ? process.execPath : undefined);
if (!executable) {
throw new Error(
"Standalone services require --runtime-path pointing to a complete standalone artifact.",
);
}
const runtimePath = await resolvePinnedDaemonRuntimePath(executable, "standalone", params.env);
return { devMode: false, runtime: "standalone", runtimePath };
}
const pinnedRuntimePath =
params.pinnedRuntimePath === undefined
? undefined
+1
View File
@@ -12,6 +12,7 @@ describe("gateway daemon runtime", () => {
expect(GATEWAY_DAEMON_RUNTIME_OPTIONS.map((option) => option.value)).toEqual(["node", "bun"]);
expect(isGatewayDaemonRuntime("node")).toBe(true);
expect(isGatewayDaemonRuntime("bun")).toBe(true);
expect(isGatewayDaemonRuntime("standalone")).toBe(true);
expect(isGatewayDaemonRuntime(undefined)).toBe(false);
});
+8 -3
View File
@@ -1,6 +1,7 @@
import { isStandaloneRuntimeExecutable } from "../../cli-invocation.mjs";
import { isBunRuntime } from "../daemon/runtime-binary.js";
export type GatewayDaemonRuntime = "bun" | "node";
export type GatewayDaemonRuntime = "bun" | "node" | "standalone";
export const DEFAULT_GATEWAY_DAEMON_RUNTIME: GatewayDaemonRuntime = "node";
@@ -22,12 +23,16 @@ export const GATEWAY_DAEMON_RUNTIME_OPTIONS: Array<{
];
export function isGatewayDaemonRuntime(value: string | undefined): value is GatewayDaemonRuntime {
return value === "bun" || value === "node";
return value === "bun" || value === "node" || value === "standalone";
}
/** Detects the runtime selected by an installed daemon command. */
export function resolveGatewayDaemonRuntime(
programArguments: string[] | undefined,
): GatewayDaemonRuntime {
return isBunRuntime(programArguments?.[0] ?? "") ? "bun" : DEFAULT_GATEWAY_DAEMON_RUNTIME;
const executable = programArguments?.[0] ?? "";
if (isStandaloneRuntimeExecutable(executable)) {
return "standalone";
}
return isBunRuntime(executable) ? "bun" : DEFAULT_GATEWAY_DAEMON_RUNTIME;
}
+4 -7
View File
@@ -317,15 +317,12 @@ export async function maybeRepairGatewayServiceConfig(
const expectedGatewayToken = tokenRefConfigured ? undefined : gatewayTokenResolution.token;
const port = resolveGatewayPort(cfg, process.env);
const hasInstallWrapper = Boolean(serviceInstallEnv[OPENCLAW_WRAPPER_ENV_KEY]?.trim());
const activeRuntimePin = hasInstallWrapper ? undefined : pinSnapshot.pin?.path;
const runtimeChoice = hasInstallWrapper
? "node"
: resolveGatewayDaemonRuntime(
activeRuntimePin ? [activeRuntimePin] : managedDefinition.programArguments,
);
: (pinSnapshot.pin?.runtime ?? resolveGatewayDaemonRuntime(managedDefinition.programArguments));
const installedRuntimePath =
runtimeChoice === "bun"
? (activeRuntimePin ?? managedDefinition.programArguments[0])
runtimeChoice === "bun" || runtimeChoice === "standalone"
? (pinSnapshot.pin?.path ?? managedDefinition.programArguments[0])
: undefined;
const expectedPlan = await buildExpectedGatewayServicePlan({
cfg,
@@ -375,7 +372,7 @@ export async function maybeRepairGatewayServiceConfig(
});
}
const needsNodeRuntime =
!hasInstallWrapper && !activeRuntimePin && needsNodeRuntimeMigration(audit.issues);
!hasInstallWrapper && !pinSnapshot.pin && needsNodeRuntimeMigration(audit.issues);
// Unusable runtimes and version-managed Node services migrate through a concrete system Node.
const systemNodeInfo = needsNodeRuntime
? await resolveSystemNodeInfo({ env: process.env, platform })
+2 -1
View File
@@ -3,6 +3,7 @@ import { runtimeFixture } from "../cli/update-cli/update-command-fixtures.test-s
import * as runtimePaths from "../daemon/runtime-paths.js";
import type { DaemonRuntimePinSnapshot } from "../daemon/runtime-pin-types.js";
import { resolveDaemonInstallRuntimeInputs } from "./daemon-install-plan.shared.js";
import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
import { resolveGatewaySetupRuntime } from "./gateway-setup-runtime.js";
const readPin = vi.hoisted(() => vi.fn<() => DaemonRuntimePinSnapshot>());
@@ -61,7 +62,7 @@ it.each([
vi.spyOn(runtimePaths, "resolveBunRuntimeInfo").mockResolvedValue(
runtimeFixture("1.4.3", "3.53.4"),
);
const select = vi.fn(async (runtime: "node" | "bun") => runtime);
const select = vi.fn(async (runtime: GatewayDaemonRuntime) => runtime);
const selection = await resolveGatewaySetupRuntime({
env: {},
existingCommand: null,
+1 -1
View File
@@ -52,7 +52,7 @@ export function validateOnboardingChoiceOptions(
return reject('Invalid --flow. Use "quickstart", "advanced", "manual", or "import".');
}
if (opts.daemonRuntime !== undefined && !isGatewayDaemonRuntime(opts.daemonRuntime)) {
return reject('Invalid --daemon-runtime. Use "node" or "bun".');
return reject('Invalid --daemon-runtime. Use "node", "bun", or "standalone".');
}
if (opts.nodeManager !== undefined && !isNodeManagerChoice(opts.nodeManager)) {
return reject('Invalid --node-manager. Use "npm", "pnpm", or "bun".');
+12
View File
@@ -72,6 +72,18 @@ function nodeArgs(options: Partial<Parameters<typeof resolveNodeProgramArguments
const gatewayTail = ["gateway", "--port", "18789"];
const nodeTail = ["node", "run", "--host", "gateway.example", "--port", "18789"];
describe("resolveGatewayProgramArguments", () => {
it("runs a standalone service directly without a loose entrypoint or runtime flags", async () => {
process.argv = [originalExecPath, "/unavailable/source/entry.ts"];
const runtimePath = "/opt/immutable-runtime/openclaw";
await expect(gatewayArgs({ runtime: "standalone", runtimePath, dev: true })).resolves.toEqual({
programArguments: [runtimePath, ...gatewayTail],
});
await expect(nodeArgs({ runtime: "standalone", runtimePath })).resolves.toEqual({
programArguments: [runtimePath, ...nodeTail],
});
expect(fsMocks.access).not.toHaveBeenCalled();
});
it("installs the requested start-mode override once", async () => {
const entryPath = path.resolve("/opt/openclaw/dist/index.js");
mockEntrypoint(entryPath);
+11
View File
@@ -1,6 +1,7 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { resolveOpenClawCliInvocation } from "../../cli-invocation.mjs";
import { SUPPORTED_NODE_VERSIONS } from "../../node-version.mjs";
import type { GatewayDaemonRuntime } from "../commands/daemon-runtime.js";
import { resolveBrewOpenClawPath } from "../infra/brew.js";
@@ -209,6 +210,16 @@ async function resolveCliProgramArguments(params: {
}
const runtimePath = params.runtimePath;
if (params.runtime === "standalone") {
const invocation = resolveOpenClawCliInvocation({
execPath: runtimePath,
entryPath: null,
args: params.args,
execArgv: [],
});
return { programArguments: [invocation.command, ...invocation.args] };
}
if (params.dev) {
const repoRoot = resolveRepoRootForDev();
const devCliPath = path.join(repoRoot, "src", "entry.ts");
+55 -17
View File
@@ -1,19 +1,23 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { getEmbeddedStandaloneRuntimeRoot } from "../../cli-invocation.mjs";
import {
getEmbeddedStandaloneRuntimeRoot,
isStandaloneRuntimeExecutable,
resolveStandaloneSidecarRoot,
} from "../../cli-invocation.mjs";
import { nodeRuntimeFailure, type SqliteCapabilities } from "../../node-sqlite.mjs";
import { SUPPORTED_NODE_VERSIONS } from "../../node-version.mjs";
import { createRuntimeProcessScriptFile } from "../../runtime-process-scripts.generated.mjs";
import type { GatewayDaemonRuntime } from "../commands/daemon-runtime.js";
import { isMissingPathError } from "../infra/errno.js";
import {
isSupportedBunVersion,
isSupportedNodeVersion,
parseSemver,
} from "../infra/runtime-guard.js";
import { prepareRuntimeProcessScriptInvocation } from "../infra/runtime-process-url.js";
import { isSqliteWalResetSafeVersion } from "../infra/sqlite-runtime-version.js";
import { resolveStableNodePath } from "../infra/stable-node-path.js";
import { getWindowsProgramFilesRoots } from "../infra/windows-install-roots.js";
@@ -154,20 +158,25 @@ type SystemNodeInfo = RuntimeInfo & { path: string };
async function resolveRuntimeInfo(
runtimePath: string,
runtime: "node" | "bun",
runtime: GatewayDaemonRuntime,
env: Record<string, string | undefined>,
timeoutMs = RUNTIME_PROBE_TIMEOUT_MS,
): Promise<RuntimeInfo> {
const label = runtime === "node" ? "Node" : "Bun";
const label = runtime === "node" ? "Node" : runtime === "bun" ? "Bun" : "Standalone OpenClaw";
const probeRuntime = runtime === "standalone" ? "bun" : runtime;
let cwd: string | undefined;
try {
cwd = process.cwd();
const script = createRuntimeProcessScriptFile("daemonRuntimeProbe");
const script = prepareRuntimeProcessScriptInvocation("daemonRuntimeProbe", runtimePath);
let stdout: string;
try {
({ stdout } = await runExec(
runtimePath,
[fileURLToPath(script.url), runtime, getEmbeddedStandaloneRuntimeRoot() ?? ""],
[
...script.argv.slice(1),
probeRuntime,
resolveStandaloneSidecarRoot(runtimePath) ?? getEmbeddedStandaloneRuntimeRoot() ?? "",
],
{
baseEnv: buildRuntimeProbeEnv(env),
logOutput: false,
@@ -181,13 +190,13 @@ async function resolveRuntimeInfo(
if (!isRecord(parsed)) {
throw new Error("Runtime check returned invalid output");
}
const version = parsed[`${runtime}Version`];
const version = parsed[`${probeRuntime}Version`];
const sqliteVersion = parsed.sqliteVersion;
const sqliteSelectionError = parsed.sqliteSelectionError;
const sqliteLibraryPath = parsed.sqliteLibraryPath;
const probe = parsed.sqliteProbe;
if (
!(typeof version === "string" || (runtime === "bun" && version === null)) ||
!(typeof version === "string" || (probeRuntime === "bun" && version === null)) ||
(runtime === "node" && typeof version === "string" && !parseSemver(version)) ||
!(typeof sqliteVersion === "string" || sqliteVersion === null) ||
!(typeof sqliteSelectionError === "string" || sqliteSelectionError == null) ||
@@ -253,6 +262,14 @@ export function resolveBunRuntimeInfo(
return resolveRuntimeInfo(bunPath, "bun", env);
}
export function resolveStandaloneRuntimeInfo(
executable: string,
env: Record<string, string | undefined> = process.env,
timeoutMs = RUNTIME_PROBE_TIMEOUT_MS,
) {
return resolveRuntimeInfo(executable, "standalone", env, timeoutMs);
}
/** Probes a recorded Node executable without inheriting service preloads or secrets. */
export function resolveNodeRuntimeInfo(
nodePath: string,
@@ -266,15 +283,17 @@ export function resolveNodeRuntimeInfo(
export async function resolveRecordedDaemonRuntime(
runtimePath: string | undefined,
env: Record<string, string | undefined>,
): Promise<(RuntimeInfo & { runtime: "node" | "bun"; path: string }) | undefined> {
): Promise<(RuntimeInfo & { runtime: GatewayDaemonRuntime; path: string }) | undefined> {
if (!runtimePath) {
return undefined;
}
const runtime = isBunRuntime(runtimePath)
? "bun"
: isNodeRuntime(runtimePath)
? "node"
: undefined;
const runtime = isStandaloneRuntimeExecutable(runtimePath)
? "standalone"
: isBunRuntime(runtimePath)
? "bun"
: isNodeRuntime(runtimePath)
? "node"
: undefined;
if (!runtime) {
return undefined;
}
@@ -474,7 +493,7 @@ export async function resolvePreferredBunPath(
/** Validate an operator pin without falling back to another executable. */
export async function resolvePinnedDaemonRuntimePath(
input: string | undefined,
runtime: "node" | "bun",
runtime: GatewayDaemonRuntime,
env: Record<string, string | undefined>,
): Promise<string | undefined> {
if (input === undefined) {
@@ -492,11 +511,30 @@ export async function resolvePinnedDaemonRuntimePath(
throw new Error("--runtime-path must be an absolute executable path.");
}
const runtimePath = paths.normalize(value);
const standaloneRoot = resolveStandaloneSidecarRoot(runtimePath);
const matchesRuntime =
runtime === "node" ? isNodeRuntime(runtimePath) : isBunRuntime(runtimePath);
runtime === "standalone"
? standaloneRoot !== undefined
: standaloneRoot !== undefined
? false
: runtime === "node"
? isNodeRuntime(runtimePath)
: isBunRuntime(runtimePath);
if (!matchesRuntime) {
throw new Error(`--runtime-path must name a ${runtime} executable: ${runtimePath}`);
}
if (runtime === "standalone") {
try {
if (!standaloneRoot || !(await fs.stat(standaloneRoot)).isDirectory()) {
throw new Error("not a directory");
}
} catch (cause) {
throw new Error(
`Pinned standalone runtime directory is unavailable: ${standaloneRoot ?? runtimePath}. Restore the complete standalone artifact.`,
{ cause },
);
}
}
try {
if (!(await fs.stat(runtimePath)).isFile()) {
throw new Error("not a regular file");
+30 -23
View File
@@ -21,29 +21,36 @@ beforeEach(() => {
afterEach(() => vi.unstubAllEnvs());
describe("transactional runtime pin state", () => {
it("keeps intent local with PostgreSQL selected and the shared database unavailable", async () => {
await withOpenClawTestState({ label: "runtime-pin-local" }, async (state) => {
fs.writeFileSync(
state.statePath("openclaw.json"),
JSON.stringify({
database: {
engine: "postgres",
postgres: { connection: "postgres://unreachable.invalid/openclaw" },
},
}),
);
const scope = { kind: "gateway" as const, env: state.env };
await withDaemonRuntimePinMigration(state.env, () => undefined);
const expected = readDaemonRuntimePin(scope, command);
commitDaemonRuntimePin(scope, { expected, pin }, command);
expect(readDaemonRuntimePin(scope, command).pin).toEqual(pin);
expect(fs.existsSync(state.statePath("state", "openclaw.sqlite"))).toBe(false);
if (process.platform !== "win32") {
expect(fs.statSync(resolveDaemonRuntimePinStorePath(state.env)).mode & 0o777).toBe(0o600);
expect(fs.statSync(state.statePath("control")).mode & 0o777).toBe(0o700);
}
});
});
it.each(["node", "standalone"] as const)(
"keeps %s intent local with PostgreSQL selected and the shared database unavailable",
async (runtime) => {
await withOpenClawTestState({ label: "runtime-pin-local" }, async (state) => {
fs.writeFileSync(
state.statePath("openclaw.json"),
JSON.stringify({
database: {
engine: "postgres",
postgres: { connection: "postgres://unreachable.invalid/openclaw" },
},
}),
);
const scope = { kind: "gateway" as const, env: state.env };
await withDaemonRuntimePinMigration(state.env, () => undefined);
const selectedPath = runtime === "standalone" ? "/runtime tools/openclaw" : runtimePath;
const selectedCommand =
runtime === "standalone" ? { programArguments: [selectedPath, "gateway"] } : command;
const expected = readDaemonRuntimePin(scope, selectedCommand);
const selectedPin = { runtime, path: selectedPath };
commitDaemonRuntimePin(scope, { expected, pin: selectedPin }, selectedCommand);
expect(readDaemonRuntimePin(scope, selectedCommand).pin).toEqual(selectedPin);
expect(fs.existsSync(state.statePath("state", "openclaw.sqlite"))).toBe(false);
if (process.platform !== "win32") {
expect(fs.statSync(resolveDaemonRuntimePinStorePath(state.env)).mode & 0o777).toBe(0o600);
expect(fs.statSync(state.statePath("control")).mode & 0o777).toBe(0o700);
}
});
},
);
it("observes another writer's committed changes and rejects stale writes", async () => {
await withOpenClawTestState({ label: "runtime-pin-current" }, async (state) => {
+4 -1
View File
@@ -21,7 +21,10 @@ import {
} from "./service-types.js";
import { resolveSystemdServiceName } from "./systemd-service-files.js";
const pinSchema = z.object({ runtime: z.enum(["node", "bun"]), path: z.string().min(1) });
const pinSchema = z.object({
runtime: z.enum(["node", "bun", "standalone"]),
path: z.string().min(1),
});
const recordSchema = z.object({ version: z.literal(1), pin: pinSchema, definition: z.string() });
type PinScope = { kind: "gateway" | "node"; env: NodeJS.ProcessEnv };
+1 -1
View File
@@ -1,5 +1,5 @@
/** Dependency-free runtime intent contracts shared by planners and native service writers. */
type DaemonRuntimePin = { runtime: "node" | "bun"; path: string };
type DaemonRuntimePin = { runtime: "node" | "bun" | "standalone"; path: string };
export type DaemonRuntimePinSnapshot = {
revision: string;
+57
View File
@@ -2,6 +2,8 @@ import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { resolveDaemonInstallRuntimeInputs } from "../commands/daemon-install-plan.shared.js";
import { resolveGatewayDaemonRuntime } from "../commands/daemon-runtime.js";
import { resolvePinnedDaemonRuntimePath } from "./runtime-paths.js";
import { buildNodeServiceEnvironment, buildServiceEnvironment } from "./service-env.js";
@@ -25,6 +27,61 @@ const probe = (file: string, env: NodeJS.ProcessEnv = {}) =>
resolvePinnedDaemonRuntimePath(file, "node", env);
describe.skipIf(process.platform === "win32")("runtime pin executable boundary", () => {
it("uses standalone identity even when the executable is named bun", async () => {
const file = runtimeFixture(
`
const path = require("node:path");
const root = path.join(path.dirname(process.argv[1]), "runtime");
if (process.argv[2] !== path.join(root, "dist/infra/daemon-runtime-probe.entry.js") ||
process.argv[3] !== "bun" || process.argv[4] !== root ||
process.env.BUN_BE_BUN || process.env.NODE_OPTIONS || process.env.FIXTURE_SECRET) process.exit(9);
process.stdout.write(JSON.stringify({
bunVersion: "1.4.4", sqliteVersion: "3.53.4",
sqliteProbe: { available: true, version: "3.53.4", text: true, blob: true, json: true }
}));
`,
"bun",
);
const root = path.dirname(file);
fs.mkdirSync(path.join(root, "runtime"));
fs.writeFileSync(
path.join(root, "manifest.json"),
JSON.stringify({
schemaVersion: 1,
mode: "aot",
executable: { path: "bun" },
runtime: { path: "runtime" },
openclaw: { version: "2026.10.25" },
bun: { version: "1.4.4" },
}),
);
const env = {
NODE_OPTIONS: "--require=/nonexistent/fixture.js",
BUN_BE_BUN: "1",
FIXTURE_SECRET: "synthetic",
};
expect(resolveGatewayDaemonRuntime([file, "gateway"])).toBe("standalone");
await expect(resolvePinnedDaemonRuntimePath(file, "bun", env)).rejects.toThrow(
/must name a bun/,
);
await expect(
resolveDaemonInstallRuntimeInputs({
runtime: "standalone",
pinnedRuntimePath: file,
devMode: true,
env,
}),
).resolves.toEqual({ devMode: false, runtime: "standalone", runtimePath: file });
fs.rmdirSync(path.join(root, "runtime"));
await expect(resolvePinnedDaemonRuntimePath(file, "standalone", env)).rejects.toThrow(
/Restore the complete standalone artifact/,
);
fs.unlinkSync(path.join(root, "manifest.json"));
await expect(resolvePinnedDaemonRuntimePath(file, "standalone", env)).rejects.toThrow(
/must name a standalone/,
);
});
it("uses a bounded sanitized probe without inheriting secrets or Node preloads", async () => {
const file = runtimeFixture(
"if (process.env.FIXTURE_SECRET || process.env.NODE_OPTIONS) process.exit(9);\n" +
+23 -13
View File
@@ -1,3 +1,4 @@
import { isStandaloneRuntimeExecutable } from "../../cli-invocation.mjs";
import { SUPPORTED_NODE_VERSIONS } from "../../node-version.mjs";
import { isBunRuntime, isNodeRuntime } from "./runtime-binary.js";
import {
@@ -5,6 +6,7 @@ import {
isVersionManagedNodePath,
resolveBunRuntimeInfo,
resolveNodeRuntimeInfo,
resolveStandaloneRuntimeInfo,
resolveSystemNodePath,
} from "./runtime-paths.js";
import * as runtimePins from "./runtime-pin-state.js";
@@ -14,6 +16,7 @@ import { normalizeServicePathEntry } from "./service-path-policy.js";
export const SERVICE_RUNTIME_AUDIT_CODES = {
gatewayRuntimeBun: "gateway-runtime-bun",
gatewayRuntimeNode: "gateway-runtime-node",
gatewayRuntimeStandalone: "gateway-runtime-standalone",
gatewayRuntimeProbeFailed: "gateway-runtime-probe-failed",
gatewayRuntimeNodeVersionManager: "gateway-runtime-node-version-manager",
gatewayRuntimeNodeSystemMissing: "gateway-runtime-node-system-missing",
@@ -32,36 +35,43 @@ export async function auditGatewayRuntime(
}
const bun = isBunRuntime(execPath);
if (!bun && !isNodeRuntime(execPath)) {
const standalone = isStandaloneRuntimeExecutable(execPath);
if (!standalone && !bun && !isNodeRuntime(execPath)) {
return undefined;
}
const runtime = bun
? await resolveBunRuntimeInfo(execPath)
: await resolveNodeRuntimeInfo(execPath, env, timeoutMs);
const runtime = standalone
? await resolveStandaloneRuntimeInfo(execPath, env, timeoutMs)
: bun
? await resolveBunRuntimeInfo(execPath)
: await resolveNodeRuntimeInfo(execPath, env, timeoutMs);
if (runtime.status !== "supported") {
issues.push({
code:
runtime.status === "probe-failed"
? SERVICE_RUNTIME_AUDIT_CODES.gatewayRuntimeProbeFailed
: bun
? SERVICE_RUNTIME_AUDIT_CODES.gatewayRuntimeBun
: SERVICE_RUNTIME_AUDIT_CODES.gatewayRuntimeNode,
: standalone
? SERVICE_RUNTIME_AUDIT_CODES.gatewayRuntimeStandalone
: bun
? SERVICE_RUNTIME_AUDIT_CODES.gatewayRuntimeBun
: SERVICE_RUNTIME_AUDIT_CODES.gatewayRuntimeNode,
message:
runtime.status === "probe-failed"
? `Gateway service ${bun ? "Bun" : "Node"} runtime check failed.`
: bun
? "Gateway service uses an unsupported Bun runtime; Bun 1.4.3+ with WAL-reset-safe node:sqlite is required."
: (runtime.capabilityError ?? "Gateway service Node failed its capability check."),
? `Gateway service ${standalone ? "standalone OpenClaw" : bun ? "Bun" : "Node"} runtime check failed.`
: standalone
? "Gateway standalone runtime failed its capability check; replace the complete artifact."
: bun
? "Gateway service uses an unsupported Bun runtime; Bun 1.4.3+ with WAL-reset-safe node:sqlite is required."
: (runtime.capabilityError ?? "Gateway service Node failed its capability check."),
detail:
runtime.status === "probe-failed"
? runtime.error.message
: bun && runtime.sqliteSelectionError
: (bun || standalone) && runtime.sqliteSelectionError
? `${execPath}: ${runtime.sqliteSelectionError}`
: execPath,
level: "recommended",
});
}
if (bun) {
if (bun || standalone) {
return undefined;
}
+1 -1
View File
@@ -341,7 +341,7 @@ function buildCommonServiceEnvironment(
// Match the installing shell's probed SQLite library; wrappers hide the runtime.
if (
platform === "darwin" &&
(runtime === "bun" || normalizeOptionalString(env.OPENCLAW_WRAPPER))
(runtime === "bun" || runtime === "standalone" || normalizeOptionalString(env.OPENCLAW_WRAPPER))
) {
const library = normalizeOptionalString(env.OPENCLAW_SQLITE_LIBRARY);
const prefix = normalizeOptionalString(env.HOMEBREW_PREFIX);
+22
View File
@@ -9,6 +9,28 @@ const dirs = useAutoCleanupTempDirTracker(afterEach);
afterEach(() => vi.restoreAllMocks());
describe("summarizeGatewayServiceLayout", () => {
it("identifies the standalone executable as the entrypoint even when it is named bun", async () => {
const root = dirs.make("standalone-service-layout-");
const executable = path.join(root, "bun");
await fs.writeFile(executable, "");
await fs.writeFile(
path.join(root, "manifest.json"),
JSON.stringify({
schemaVersion: 1,
mode: "aot",
executable: { path: "bun" },
runtime: { path: "runtime" },
openclaw: { version: "2026.10.25" },
bun: { version: "1.4.4" },
}),
);
await expect(
summarizeGatewayServiceLayout({
programArguments: [executable, "gateway", "--port", "19876"],
}),
).resolves.toHaveProperty("entrypoint", executable);
});
it("preserves optional physical fields and lexical source paths on probe failure", async () => {
vi.spyOn(fs, "realpath").mockRejectedValue(
Object.assign(new Error("denied"), { code: "EACCES" }),
+5 -1
View File
@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { isStandaloneRuntimeExecutable } from "../../cli-invocation.mjs";
import { quoteCliArg } from "../cli/quote-cli-arg.js";
import { consumeRootCommandOptionToken, FLAG_TERMINATOR } from "../infra/cli-root-options.js";
import { pathExists } from "../infra/fs-safe.js";
@@ -48,6 +49,7 @@ export async function resolveGatewayServiceInstallationRefreshRoot(params: {
!root ||
!command ||
isBunRuntime(command.programArguments[0] ?? "") ||
isStandaloneRuntimeExecutable(command.programArguments[0] ?? "") ||
state.loadState.status === "unknown" ||
(state.runtime?.status !== "running" && state.runtime?.status !== "stopped") ||
(process.platform === "linux" && !isSystemdManagerUid(managerUid)) ||
@@ -118,7 +120,9 @@ function resolveSystemdScopeFromServicePath(
}
export function resolveServiceEntrypointIndex(args: readonly string[]): number | undefined {
const { position: script } = resolveRuntimeScriptPosition(args);
const { position: script } = isStandaloneRuntimeExecutable(args[0] ?? "")
? { position: { kind: "not-runtime" } }
: resolveRuntimeScriptPosition(args);
if (typeof script !== "number" && script.kind === "other") {
return undefined;
}
+1 -1
View File
@@ -1,3 +1,3 @@
// Generated by scripts/generate-runtime-script-assets.mjs; do not edit.
export const DAEMON_RUNTIME_PROBE_SOURCE =
'var __getOwnPropNames=Object.getOwnPropertyNames;var __esm=(fn,res,err)=>function __init(){if(err)throw err[0];try{return fn&&(res=(0,fn[__getOwnPropNames(fn)[0]])(fn=0)),res}catch(e){throw err=[e],e}};var GATEWAY_SHUTDOWN_DRAIN_TIMEOUT_MS,GATEWAY_SHUTDOWN_RESERVE_MS,GATEWAY_SUPERVISOR_EXIT_MARGIN_MS,GATEWAY_SHUTDOWN_TIMEOUT_MS,GATEWAY_SERVICE_STOP_TIMEOUT_MS,LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS;var init_gateway_shutdown_budget=__esm({"gateway-shutdown-budget.mjs"(){"use strict";GATEWAY_SHUTDOWN_DRAIN_TIMEOUT_MS=315e3;GATEWAY_SHUTDOWN_RESERVE_MS=1e4;GATEWAY_SUPERVISOR_EXIT_MARGIN_MS=5e3;GATEWAY_SHUTDOWN_TIMEOUT_MS=GATEWAY_SHUTDOWN_DRAIN_TIMEOUT_MS+GATEWAY_SHUTDOWN_RESERVE_MS;GATEWAY_SERVICE_STOP_TIMEOUT_MS=GATEWAY_SHUTDOWN_TIMEOUT_MS+GATEWAY_SUPERVISOR_EXIT_MARGIN_MS;LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS=GATEWAY_SERVICE_STOP_TIMEOUT_MS/1e3}});var parentKey,CLI_RESPAWN_PARENT_ENV,inheritedParent,CLI_PARENT_BOUND_PRELOAD;var init_cli_parent_lifetime=__esm({"cli-parent-lifetime.mjs"(){"use strict";init_gateway_shutdown_budget();parentKey=Symbol.for("openclaw.cliRespawnParent");CLI_RESPAWN_PARENT_ENV="OPENCLAW_CLI_RESPAWN_PARENT";inheritedParent=process.env[CLI_RESPAWN_PARENT_ENV];delete process.env[CLI_RESPAWN_PARENT_ENV];if(inheritedParent===String(process.ppid)){globalThis[parentKey]={pid:process.ppid}}CLI_PARENT_BOUND_PRELOAD=`data:text/javascript,${encodeURIComponent(`\n globalThis[Symbol.for("openclaw.cliRespawnParent")] = { pid: process.ppid };\n process.execArgv = process.execArgv.filter((arg, index, args) =>\n arg !== import.meta.url && !(arg === "--import" && args[index + 1] === import.meta.url));\n`)}`}});var init_node_runtime_env=__esm({"node-runtime-env.mjs"(){"use strict"}});import{readFileSync}from"node:fs";import path from"node:path";import{fileURLToPath}from"node:url";function readStandaloneRuntimeManifest(executable=process.execPath){return readStandaloneManifest({executable})}function projectStandaloneBuildInfo(value){const nonBlank=text=>typeof text==="string"&&text.trim().length>0;const stringList=items=>Array.isArray(items)&&items.every(nonBlank);const result={mode:value.mode,openclaw:{version:value.openclaw.version,...nonBlank(value.openclaw.commit)?{commit:value.openclaw.commit}:{}},bun:{version:value.bun.version}};for(const key of["revision","commit","executableSha256"]){if(nonBlank(value.bun[key])){result.bun[key]=value.bun[key]}}if(value.build!==void 0){const{id,flavor,pluginSetSha256,moduleGraphSha256}=value.build;if(!nonBlank(id)||!["standard","support"].includes(flavor)||!/^[a-f0-9]{64}$/.test(pluginSetSha256)||!/^[a-f0-9]{64}$/.test(moduleGraphSha256)){throw new Error("Invalid standalone build provenance")}result.build={id,flavor,pluginSetSha256,moduleGraphSha256};if(value.build.debugEntry!==void 0){const entry=value.build.debugEntry;if(typeof entry!=="string"||!entry.startsWith("debug/")||entry.includes("\\\\")||entry.split("/").some(part=>!part||part==="."||part==="..")){throw new Error("Invalid standalone debugger entry")}result.build.debugEntry=entry}}if(value.target!==void 0){if(!nonBlank(value.target.platform)||!nonBlank(value.target.arch)){throw new Error("Invalid standalone build target")}result.target={platform:value.target.platform,arch:value.target.arch}}if(value.plugins!==void 0){if(!stringList(value.plugins.embedded)||!stringList(value.plugins.omitted)){throw new Error("Invalid standalone plugin inventory")}result.plugins={embedded:[...value.plugins.embedded],omitted:[...value.plugins.omitted]}}if(value.nativeModules!==void 0){if(!Array.isArray(value.nativeModules)||value.nativeModules.some(item=>!item||!nonBlank(item.name)||!nonBlank(item.version)||!stringList(item.artifacts)||item.qualification!=="unverified"||!nonBlank(item.backend))){throw new Error("Invalid standalone native inventory")}result.nativeModules=value.nativeModules.map(({name,version,artifacts,qualification,backend})=>({name,version,artifacts:[...artifacts],qualification,backend}))}return result}function readStandaloneManifest({executable,runtimeRoot,includeBuild=false}){const location=executable??runtimeRoot;if(!path.isAbsolute(location)){return void 0}const root=path.dirname(location);const manifestPath=path.join(root,"manifest.json");let raw;try{raw=readFileSync(manifestPath,"utf8")}catch(error){if(error?.code==="ENOENT"||error?.code==="ENOTDIR"){return void 0}throw error}let manifest;try{manifest=JSON.parse(raw)}catch(cause){throw new Error(`Invalid standalone runtime manifest: ${manifestPath}`,{cause})}const nonBlank=value=>typeof value==="string"&&value.trim().length>0;if(manifest?.schemaVersion!==1||!["jit","bytecode","aot"].includes(manifest.mode)||!nonBlank(manifest.executable?.path)||!nonBlank(manifest.runtime?.path)||!nonBlank(manifest.openclaw?.version)||!nonBlank(manifest.bun?.version)){throw new Error(`Invalid standalone runtime manifest: ${manifestPath}`)}const executableRelative=path.relative(root,path.resolve(root,manifest.executable.path));const sidecarRelative=path.relative(root,pLine truncated
'var __getOwnPropNames=Object.getOwnPropertyNames;var __esm=(fn,res,err)=>function __init(){if(err)throw err[0];try{return fn&&(res=(0,fn[__getOwnPropNames(fn)[0]])(fn=0)),res}catch(e){throw err=[e],e}};var GATEWAY_SHUTDOWN_DRAIN_TIMEOUT_MS,GATEWAY_SHUTDOWN_RESERVE_MS,GATEWAY_SUPERVISOR_EXIT_MARGIN_MS,GATEWAY_SHUTDOWN_TIMEOUT_MS,GATEWAY_SERVICE_STOP_TIMEOUT_MS,LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS;var init_gateway_shutdown_budget=__esm({"gateway-shutdown-budget.mjs"(){"use strict";GATEWAY_SHUTDOWN_DRAIN_TIMEOUT_MS=315e3;GATEWAY_SHUTDOWN_RESERVE_MS=1e4;GATEWAY_SUPERVISOR_EXIT_MARGIN_MS=5e3;GATEWAY_SHUTDOWN_TIMEOUT_MS=GATEWAY_SHUTDOWN_DRAIN_TIMEOUT_MS+GATEWAY_SHUTDOWN_RESERVE_MS;GATEWAY_SERVICE_STOP_TIMEOUT_MS=GATEWAY_SHUTDOWN_TIMEOUT_MS+GATEWAY_SUPERVISOR_EXIT_MARGIN_MS;LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS=GATEWAY_SERVICE_STOP_TIMEOUT_MS/1e3}});var parentKey,CLI_RESPAWN_PARENT_ENV,inheritedParent,CLI_PARENT_BOUND_PRELOAD;var init_cli_parent_lifetime=__esm({"cli-parent-lifetime.mjs"(){"use strict";init_gateway_shutdown_budget();parentKey=Symbol.for("openclaw.cliRespawnParent");CLI_RESPAWN_PARENT_ENV="OPENCLAW_CLI_RESPAWN_PARENT";inheritedParent=process.env[CLI_RESPAWN_PARENT_ENV];delete process.env[CLI_RESPAWN_PARENT_ENV];if(inheritedParent===String(process.ppid)){globalThis[parentKey]={pid:process.ppid}}CLI_PARENT_BOUND_PRELOAD=`data:text/javascript,${encodeURIComponent(`\n globalThis[Symbol.for("openclaw.cliRespawnParent")] = { pid: process.ppid };\n process.execArgv = process.execArgv.filter((arg, index, args) =>\n arg !== import.meta.url && !(arg === "--import" && args[index + 1] === import.meta.url));\n`)}`}});var init_node_runtime_env=__esm({"node-runtime-env.mjs"(){"use strict"}});import{readFileSync}from"node:fs";import path from"node:path";import{fileURLToPath}from"node:url";function readStandaloneRuntimeManifest(executable=process.execPath){return readStandaloneManifest({executable})}function projectStandaloneBuildInfo(value){const nonBlank=text=>typeof text==="string"&&text.trim().length>0;const stringList=items=>Array.isArray(items)&&items.every(nonBlank);const result={mode:value.mode,openclaw:{version:value.openclaw.version,...nonBlank(value.openclaw.commit)?{commit:value.openclaw.commit}:{}},bun:{version:value.bun.version}};for(const key of["revision","commit","executableSha256"]){if(nonBlank(value.bun[key])){result.bun[key]=value.bun[key]}}if(value.build!==void 0){const{id,flavor,pluginSetSha256,moduleGraphSha256}=value.build;if(!nonBlank(id)||!["standard","support"].includes(flavor)||!/^[a-f0-9]{64}$/.test(pluginSetSha256)||!/^[a-f0-9]{64}$/.test(moduleGraphSha256)){throw new Error("Invalid standalone build provenance")}result.build={id,flavor,pluginSetSha256,moduleGraphSha256};if(value.build.debugEntry!==void 0){const entry=value.build.debugEntry;if(typeof entry!=="string"||!entry.startsWith("debug/")||entry.includes("\\\\")||entry.split("/").some(part=>!part||part==="."||part==="..")){throw new Error("Invalid standalone debugger entry")}result.build.debugEntry=entry}}if(value.target!==void 0){if(!nonBlank(value.target.platform)||!nonBlank(value.target.arch)){throw new Error("Invalid standalone build target")}result.target={platform:value.target.platform,arch:value.target.arch}}if(value.plugins!==void 0){if(!stringList(value.plugins.embedded)||!stringList(value.plugins.omitted)){throw new Error("Invalid standalone plugin inventory")}result.plugins={embedded:[...value.plugins.embedded],omitted:[...value.plugins.omitted]}}if(value.nativeModules!==void 0){if(!Array.isArray(value.nativeModules)||value.nativeModules.some(item=>!item||!nonBlank(item.name)||!nonBlank(item.version)||!stringList(item.artifacts)||item.qualification!=="unverified"||!nonBlank(item.backend))){throw new Error("Invalid standalone native inventory")}result.nativeModules=value.nativeModules.map(({name,version,artifacts,qualification,backend})=>({name,version,artifacts:[...artifacts],qualification,backend}))}return result}function readStandaloneManifest({executable,runtimeRoot,includeBuild=false}){const location=executable??runtimeRoot;if(!path.isAbsolute(location)){return void 0}const root=path.dirname(location);const manifestPath=path.join(root,"manifest.json");let raw;try{raw=readFileSync(manifestPath,"utf8")}catch(error){if(error?.code==="ENOENT"||error?.code==="ENOTDIR"){return void 0}throw error}let manifest;try{manifest=JSON.parse(raw)}catch(cause){throw new Error(`Invalid standalone runtime manifest: ${manifestPath}`,{cause})}const nonBlank=value=>typeof value==="string"&&value.trim().length>0;if(manifest?.schemaVersion!==1||!["jit","bytecode","aot"].includes(manifest.mode)||!nonBlank(manifest.executable?.path)||!nonBlank(manifest.runtime?.path)||!nonBlank(manifest.openclaw?.version)||!nonBlank(manifest.bun?.version)){throw new Error(`Invalid standalone runtime manifest: ${manifestPath}`)}const executableRelative=path.relative(root,path.resolve(root,manifest.executable.path));const sidecarRelative=path.relative(root,pLine truncated
+2
View File
@@ -18,6 +18,7 @@ export function createRuntimeProcessEntrypoints(currentModuleUrl: string) {
}
return {
daemonRuntimeProbe: runtimeProcessEntrypoint("infra/daemon-runtime-probe.entry"),
pluginToolsServe: runtimeProcessEntrypoint("mcp/plugin-tools-serve"),
openclawToolsServe: runtimeProcessEntrypoint("mcp/openclaw-tools-serve"),
repositoryPublicationRestore: runtimeProcessEntrypoint(
@@ -702,6 +703,7 @@ export const runtimeProcessScriptEntrypoints = {
kind: "file",
fileName: "daemon-runtime-probe.mjs",
materialize: true,
runtimeEntrypoint: "daemonRuntimeProbe",
},
updateStateSizes: {
sourceModule: "src/infra/update-state-sizes.generated.mjs",
+9 -2
View File
@@ -1,6 +1,6 @@
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { isEmbeddedStandaloneModule } from "../../cli-invocation.mjs";
import { isEmbeddedStandaloneModule, resolveStandaloneSidecarRoot } from "../../cli-invocation.mjs";
import { createRuntimeProcessScriptFile } from "../../runtime-process-scripts.generated.mjs";
import { runtimeProcessEntrypoints } from "./runtime-process-entrypoints.js";
import {
@@ -58,7 +58,7 @@ export function resolveRuntimeScriptEntrypointUrl(
return pathToFileURL(path.join(directory, runtimeProcessScriptEntrypoints[name].fileName));
}
/** Embedded self children use the image dispatcher; foreign runtimes need a real file. */
/** Standalone images use declared child entries; ordinary runtimes need a physical script. */
export function prepareRuntimeProcessScriptInvocation(
name: Parameters<typeof createRuntimeProcessScriptFile>[0],
execPath: string,
@@ -71,6 +71,13 @@ export function prepareRuntimeProcessScriptInvocation(
return { argv: [execPath, ...resolveRuntimeWorkerArgv(url, execPath)], dispose() {} };
}
}
if ("runtimeEntrypoint" in entry) {
const root = resolveStandaloneSidecarRoot(execPath);
if (root !== undefined) {
const child = runtimeProcessEntrypoints[entry.runtimeEntrypoint];
return { argv: [execPath, path.join(root, "dist", child.distWorkerPath)], dispose() {} };
}
}
const file = createRuntimeProcessScriptFile(name, options);
return { argv: [execPath, fileURLToPath(file.url)], dispose: () => file.dispose() };
}