mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix: update managed macOS Gateways from runtime snapshots (#104946)
* fix(updater): migrate validated gateway snapshots * test(updater): declare snapshot maintenance helpers
This commit is contained in:
@@ -30,8 +30,8 @@ Keep `/Users/steipete/openclaw` a read-only-to-the-agent deployment mirror: clea
|
||||
- Every successful update sets `actions.gatewayBuild` and rebuilds exact new `main` before any restart.
|
||||
- Missing, invalid, or stale build output also forces a build, even when Git did not move.
|
||||
- A dependency-input change, absent `node_modules`, or missing/invalid build provenance requires `pnpm install --frozen-lockfile`. When a build is required, do not install before acquiring the maintenance suspension and stopping the managed Gateway.
|
||||
- Before any dependency or build mutation, invoke the Gateway's existing built CLI to acquire `gateway.suspend.prepare`, binding both prepare and resume to this checkout's managed LaunchAgent port and local auth even when normal CLI configuration points at a remote Gateway. This atomically pauses cron scheduling, closes new work admission, and refuses while active work remains. A busy result defers the build to the next heartbeat without installing dependencies or stopping the Gateway; never replace this fence with `cron list` polling. Once ready, stop through the same existing `dist/index.js` directly, install frozen dependencies when required, then build; source launchers can auto-build stale output before dispatching the stop. Resume the suspension if stop fails. If suspension RPC is unavailable on macOS, proceed only when native inspection proves this checkout's managed LaunchAgent is booted out and its configured port has no listener; never accept a loaded KeepAlive job's transient stopped state. On other platforms, require the existing CLI to prove the managed service is stopped with no PID, listener, or RPC. This preserves retry after a post-stop failure without weakening the live-work fence. Preserve `dist/OpenClaw.app` outside `dist` for the build and restore it even when the build fails, because the JS build cleans `dist` regardless of Mac impact classification. Never mutate the live `dist` tree while an old Gateway can dynamically import from it. `pnpm build` must leave both canonical stamp heads and `dist/build-info.json.commit` equal to post-update `afterSha`; any missing/mismatched stamp or required artifact blocks restart.
|
||||
- Only after exact-SHA build proof may it restart the managed Gateway and require `gateway status --deep --require-rpc --json` plus `health --verbose --json`.
|
||||
- Before any dependency or build mutation, invoke the Gateway's existing built CLI to acquire `gateway.suspend.prepare`, binding both prepare and resume to this checkout's managed LaunchAgent loopback port and service auth even when normal CLI configuration points at a remote Gateway. The LaunchAgent may execute either this checkout's `dist/index.js` or a clean detached canonical snapshot under `~/.openclaw/runtime/gateway-<sha>` whose commit is an ancestor of the checkout; reject every other entrypoint. Never execute snapshot code: capture an exact source control build before the Git fast-forward for its prepare and failure-resume calls, preserve any validated generated service-environment wrapper, and stop the managed LaunchAgent with native launchd bootout. If that control build is missing, proceed only when native proof shows the snapshot job is already booted out with its port free, allowing a failed-build retry without executing unverified generated code. This atomically pauses cron scheduling, closes new work admission, and refuses while active work remains. A busy result defers the build to the next heartbeat without installing dependencies or stopping the Gateway; never replace this fence with `cron list` polling. Once ready, stop directly without a source launcher, install frozen dependencies when required, then build; source launchers can auto-build stale output before dispatching the stop. Resume the suspension if stop fails. If suspension RPC is unavailable on macOS, proceed only when native inspection proves this checkout's managed LaunchAgent is booted out and its configured port has no listener; never accept a loaded KeepAlive job's transient stopped state. On other platforms, require the existing CLI to prove the managed service is stopped with no PID, listener, or RPC. This preserves retry after a post-stop failure without weakening the live-work fence. Preserve `dist/OpenClaw.app` outside `dist` for the build and restore it even when the build fails, because the JS build cleans `dist` regardless of Mac impact classification. Never mutate the live `dist` tree while an old Gateway can dynamically import from it. `pnpm build` must leave both canonical stamp heads and `dist/build-info.json.commit` equal to post-update `afterSha`; any missing/mismatched stamp or required artifact blocks restart.
|
||||
- Only after exact-SHA build proof may it restart the managed Gateway and require `gateway status --deep --require-rpc --json` plus `health --verbose --json`. A validated ancestor snapshot is suspension-only: prove the old launchd job is booted out with its port free, then atomically retarget only the owned LaunchAgent entrypoint to this checkout's exact `dist/index.js`, including on a retry where the build is already current. Preserve all other service arguments and environment unchanged. After restart, prove the loaded launchd PID owns the configured listener.
|
||||
- After every managed restart, query Gateway logs through RPC, restrict the audit to entries emitted since that restart began, report warning summaries, and fail the pass on any error/fatal entry. If RPC verification or log retrieval fails, still inspect the local structured log for that restart window. Never accept supervisor or RPC health without this restart-window log audit.
|
||||
|
||||
Treat supervisor state alone as insufficient. If build or proof fails, leave the new mirror head intact and retry the stale/missing build on the next heartbeat; never run the old `dist` against new source.
|
||||
|
||||
@@ -38,6 +38,15 @@ const FULL_SHA_RE = /^[0-9a-f]{40}$/u;
|
||||
const GATEWAY_READINESS_ATTEMPTS = 3;
|
||||
const GATEWAY_READINESS_RETRY_DELAY_MS = 5_000;
|
||||
const GATEWAY_SUSPEND_TIMEOUT_MS = 10_000;
|
||||
const GENERATED_LAUNCH_AGENT_ENV_WRAPPER = `#!/bin/sh
|
||||
set -eu
|
||||
env_file="$1"
|
||||
shift
|
||||
if [ -f "$env_file" ]; then
|
||||
. "$env_file"
|
||||
fi
|
||||
exec "$@"
|
||||
`;
|
||||
const DEPENDENCY_INPUT_RE =
|
||||
/^(?:\.npmrc$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|patches\/)|(?:^|\/)package\.json$/u;
|
||||
|
||||
@@ -521,6 +530,164 @@ function defaultRunCommand(command, args, checkout) {
|
||||
});
|
||||
}
|
||||
|
||||
export function isOwnedGatewayEntrypoint(checkout, home, entrypoint) {
|
||||
const sourceEntrypoint = path.join(checkout, "dist/index.js");
|
||||
if (entrypoint === sourceEntrypoint) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const runtimeRoot = path.join(home, ".openclaw/runtime");
|
||||
const snapshotRoot = path.dirname(path.dirname(entrypoint));
|
||||
const snapshotName = path.basename(snapshotRoot);
|
||||
if (
|
||||
path.dirname(snapshotRoot) !== runtimeRoot ||
|
||||
!/^gateway-[0-9a-f]{7}$/u.test(snapshotName) ||
|
||||
path.basename(path.dirname(entrypoint)) !== "dist" ||
|
||||
path.basename(entrypoint) !== "index.js"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
realpathSync(snapshotRoot) !== snapshotRoot ||
|
||||
realpathSync(entrypoint) !== entrypoint ||
|
||||
!originMatches(gitText(snapshotRoot, ["remote", "get-url", "origin"])) ||
|
||||
gitText(snapshotRoot, ["status", "--porcelain"]) !== ""
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
git(snapshotRoot, ["symbolic-ref", "-q", "HEAD"]);
|
||||
return false;
|
||||
} catch {
|
||||
// Immutable runtime snapshots are detached from every mutable branch.
|
||||
}
|
||||
const snapshotHead = gitText(snapshotRoot, ["rev-parse", "HEAD"]);
|
||||
if (
|
||||
!FULL_SHA_RE.test(snapshotHead) ||
|
||||
snapshotName !== `gateway-${snapshotHead.slice(0, 7)}` ||
|
||||
!commitExists(checkout, snapshotHead) ||
|
||||
!inspectBuildState(snapshotRoot, snapshotHead).current
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
git(checkout, ["merge-base", "--is-ancestor", snapshotHead, "HEAD"]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveManagedGatewayCommand(
|
||||
programArguments,
|
||||
home,
|
||||
stateDir,
|
||||
label = "ai.openclaw.gateway",
|
||||
) {
|
||||
if (!Array.isArray(programArguments)) {
|
||||
return null;
|
||||
}
|
||||
const defaultEnvDir = path.join(stateDir ?? path.join(home, ".openclaw"), "service-env");
|
||||
let envDir = defaultEnvDir;
|
||||
let envFilePath = null;
|
||||
let commandStartIndex = 0;
|
||||
let executable = programArguments[0];
|
||||
let prefix = [];
|
||||
let wrapperPath = null;
|
||||
const acceptsGeneratedWrapper = (candidateWrapper, candidateEnvFile) => {
|
||||
const candidateDir = path.dirname(candidateWrapper);
|
||||
return (
|
||||
path.basename(candidateDir) === "service-env" &&
|
||||
path.dirname(candidateEnvFile) === candidateDir &&
|
||||
path.basename(candidateWrapper) === `${label}-env-wrapper.sh` &&
|
||||
path.basename(candidateEnvFile) === `${label}.env`
|
||||
);
|
||||
};
|
||||
if (
|
||||
programArguments[0] === "/bin/sh" &&
|
||||
typeof programArguments[1] === "string" &&
|
||||
typeof programArguments[2] === "string" &&
|
||||
acceptsGeneratedWrapper(programArguments[1], programArguments[2])
|
||||
) {
|
||||
wrapperPath = programArguments[1];
|
||||
envFilePath = programArguments[2];
|
||||
envDir = path.dirname(wrapperPath);
|
||||
commandStartIndex = 3;
|
||||
prefix = [wrapperPath, envFilePath];
|
||||
} else if (
|
||||
typeof programArguments[0] === "string" &&
|
||||
typeof programArguments[1] === "string" &&
|
||||
acceptsGeneratedWrapper(programArguments[0], programArguments[1])
|
||||
) {
|
||||
wrapperPath = programArguments[0];
|
||||
envFilePath = programArguments[1];
|
||||
envDir = path.dirname(wrapperPath);
|
||||
commandStartIndex = 2;
|
||||
executable = wrapperPath;
|
||||
prefix = [envFilePath];
|
||||
}
|
||||
const runtime = programArguments[commandStartIndex];
|
||||
const entrypoint = programArguments[commandStartIndex + 1];
|
||||
const command = programArguments[commandStartIndex + 2];
|
||||
// Arbitrary --wrapper executables contain no checkout entrypoint, so their
|
||||
// ownership cannot be proven and conversion must remain a manual operation.
|
||||
return typeof runtime === "string" &&
|
||||
["bun", "node"].includes(path.basename(runtime)) &&
|
||||
typeof entrypoint === "string" &&
|
||||
command === "gateway"
|
||||
? {
|
||||
entrypoint,
|
||||
entrypointIndex: commandStartIndex + 1,
|
||||
envFilePath,
|
||||
executable,
|
||||
invocationPrefix: wrapperPath ? [...prefix, runtime, entrypoint] : [entrypoint],
|
||||
runtime,
|
||||
stateDir: path.dirname(envDir),
|
||||
wrapperPath,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
export function resolveManagedGatewayEntrypoint(programArguments, home, stateDir) {
|
||||
return resolveManagedGatewayCommand(programArguments, home, stateDir)?.entrypoint ?? null;
|
||||
}
|
||||
|
||||
function isTrustedGeneratedEnvironmentWrapper(command) {
|
||||
if (!command.wrapperPath || !command.envFilePath) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const wrapperStat = lstatSync(command.wrapperPath);
|
||||
const envFileStat = lstatSync(command.envFilePath);
|
||||
const owner = process.getuid();
|
||||
return (
|
||||
wrapperStat.isFile() &&
|
||||
!wrapperStat.isSymbolicLink() &&
|
||||
envFileStat.isFile() &&
|
||||
!envFileStat.isSymbolicLink() &&
|
||||
wrapperStat.uid === owner &&
|
||||
envFileStat.uid === owner &&
|
||||
(wrapperStat.mode & 0o022) === 0 &&
|
||||
(envFileStat.mode & 0o077) === 0 &&
|
||||
realpathSync(command.wrapperPath) === command.wrapperPath &&
|
||||
realpathSync(command.envFilePath) === command.envFilePath &&
|
||||
readFileSync(command.wrapperPath, "utf8") === GENERATED_LAUNCH_AGENT_ENV_WRAPPER
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isTrustedOwnedRegularFile(fileStat) {
|
||||
return (
|
||||
fileStat.isFile() &&
|
||||
!fileStat.isSymbolicLink() &&
|
||||
fileStat.uid === process.getuid() &&
|
||||
(fileStat.mode & 0o022) === 0
|
||||
);
|
||||
}
|
||||
|
||||
function readManagedGatewayLaunchAgent(checkout) {
|
||||
if (process.platform !== "darwin" || typeof process.getuid !== "function") {
|
||||
throw new UpdateInvariantError(
|
||||
@@ -533,6 +700,21 @@ function readManagedGatewayLaunchAgent(checkout) {
|
||||
throw new UpdateInvariantError("gateway_launchagent_failed", "HOME is unavailable");
|
||||
}
|
||||
const plistPath = path.join(home, "Library/LaunchAgents/ai.openclaw.gateway.plist");
|
||||
let plistStat;
|
||||
try {
|
||||
plistStat = lstatSync(plistPath);
|
||||
} catch (error) {
|
||||
throw new UpdateInvariantError(
|
||||
"gateway_launchagent_failed",
|
||||
`could not inspect the managed Gateway LaunchAgent: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
if (!isTrustedOwnedRegularFile(plistStat)) {
|
||||
throw new UpdateInvariantError(
|
||||
"gateway_launchagent_failed",
|
||||
"managed Gateway LaunchAgent is not a regular owned plist file",
|
||||
);
|
||||
}
|
||||
const plistResult = spawnSync("/usr/bin/plutil", ["-convert", "json", "-o", "-", plistPath], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
@@ -545,13 +727,25 @@ function readManagedGatewayLaunchAgent(checkout) {
|
||||
const plist = JSON.parse(plistResult.stdout);
|
||||
const label = plist?.Label;
|
||||
const programArguments = plist?.ProgramArguments;
|
||||
const environmentVariables = plist?.EnvironmentVariables;
|
||||
const serviceEnvironment = Object.fromEntries(
|
||||
Object.entries(environmentVariables ?? {}).filter((entry) => typeof entry[1] === "string"),
|
||||
);
|
||||
const stateDir =
|
||||
typeof environmentVariables?.OPENCLAW_STATE_DIR === "string"
|
||||
? environmentVariables.OPENCLAW_STATE_DIR
|
||||
: path.join(home, ".openclaw");
|
||||
const gatewayCommand = resolveManagedGatewayCommand(programArguments, home, stateDir, label);
|
||||
const gatewayEntrypoint = gatewayCommand?.entrypoint ?? null;
|
||||
const ownsGatewayEntrypoint =
|
||||
gatewayEntrypoint !== null && isOwnedGatewayEntrypoint(checkout, home, gatewayEntrypoint);
|
||||
const portFlag = Array.isArray(programArguments) ? programArguments.indexOf("--port") : -1;
|
||||
const port = Number(portFlag >= 0 ? programArguments[portFlag + 1] : Number.NaN);
|
||||
if (
|
||||
typeof label !== "string" ||
|
||||
!Array.isArray(programArguments) ||
|
||||
!programArguments.includes(path.join(checkout, "dist/index.js")) ||
|
||||
!programArguments.includes("gateway") ||
|
||||
!ownsGatewayEntrypoint ||
|
||||
!isTrustedGeneratedEnvironmentWrapper(gatewayCommand) ||
|
||||
!Number.isInteger(port) ||
|
||||
port < 1 ||
|
||||
port > 65_535
|
||||
@@ -564,63 +758,283 @@ function readManagedGatewayLaunchAgent(checkout) {
|
||||
const configPath =
|
||||
typeof plist?.EnvironmentVariables?.OPENCLAW_CONFIG_PATH === "string"
|
||||
? plist.EnvironmentVariables.OPENCLAW_CONFIG_PATH
|
||||
: path.join(home, ".openclaw/openclaw.json");
|
||||
return { configPath, label, port };
|
||||
: path.join(gatewayCommand.stateDir, "openclaw.json");
|
||||
return {
|
||||
configPath,
|
||||
entrypoint: gatewayEntrypoint,
|
||||
entrypointIndex: gatewayCommand.entrypointIndex,
|
||||
envFilePath: gatewayCommand.envFilePath,
|
||||
executable: gatewayCommand.executable,
|
||||
invocationPrefix: gatewayCommand.invocationPrefix,
|
||||
label,
|
||||
plistPath,
|
||||
port,
|
||||
runtime: gatewayCommand.runtime,
|
||||
serviceEnvironment,
|
||||
stateDir: gatewayCommand.stateDir,
|
||||
wrapperPath: gatewayCommand.wrapperPath,
|
||||
};
|
||||
}
|
||||
|
||||
function runBuiltGatewayCall(checkout, method, params) {
|
||||
const { configPath, port } = readManagedGatewayLaunchAgent(checkout);
|
||||
function inspectManagedGatewayDeployment(checkout) {
|
||||
if (process.platform !== "darwin") {
|
||||
return null;
|
||||
}
|
||||
const home = process.env.HOME;
|
||||
if (!home || !existsSync(path.join(home, "Library/LaunchAgents/ai.openclaw.gateway.plist"))) {
|
||||
return null;
|
||||
}
|
||||
return readManagedGatewayLaunchAgent(checkout);
|
||||
}
|
||||
|
||||
export function repointManagedGatewayDeployment(
|
||||
checkout,
|
||||
deployment,
|
||||
replaceEntrypoint,
|
||||
inspectDeployment = inspectManagedGatewayDeployment,
|
||||
) {
|
||||
const sourceEntrypoint = path.join(checkout, "dist/index.js");
|
||||
if (deployment.entrypoint === sourceEntrypoint) {
|
||||
return { changed: false, ...deployment };
|
||||
}
|
||||
replaceEntrypoint(deployment, sourceEntrypoint);
|
||||
const installed = inspectDeployment(checkout);
|
||||
if (
|
||||
!installed ||
|
||||
installed.configPath !== deployment.configPath ||
|
||||
installed.entrypoint !== sourceEntrypoint ||
|
||||
installed.label !== deployment.label ||
|
||||
installed.port !== deployment.port
|
||||
) {
|
||||
throw new UpdateInvariantError(
|
||||
"gateway_repoint_failed",
|
||||
"managed Gateway LaunchAgent was not retargeted to the exact source build",
|
||||
);
|
||||
}
|
||||
return {
|
||||
changed: true,
|
||||
...installed,
|
||||
previousEntrypoint: deployment.entrypoint,
|
||||
};
|
||||
}
|
||||
|
||||
function replaceLaunchAgentEntrypoint(deployment, entrypoint) {
|
||||
const original = readFileSync(deployment.plistPath);
|
||||
const temporaryPath = `${deployment.plistPath}.openclaw-live-updater-${randomUUID()}`;
|
||||
writeFileSync(temporaryPath, original, {
|
||||
flag: "wx",
|
||||
mode: statSync(deployment.plistPath).mode,
|
||||
});
|
||||
try {
|
||||
execFileSync(
|
||||
"/usr/bin/plutil",
|
||||
[
|
||||
"-replace",
|
||||
`ProgramArguments.${deployment.entrypointIndex}`,
|
||||
"-string",
|
||||
entrypoint,
|
||||
temporaryPath,
|
||||
],
|
||||
{ stdio: ["ignore", "ignore", "pipe"] },
|
||||
);
|
||||
execFileSync("/usr/bin/plutil", ["-lint", temporaryPath], {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
renameSync(temporaryPath, deployment.plistPath);
|
||||
} finally {
|
||||
rmSync(temporaryPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function verifyManagedGatewayRuntime(checkout, expectedSha) {
|
||||
if (process.platform !== "darwin") {
|
||||
return null;
|
||||
}
|
||||
assertExactBuild(checkout, expectedSha);
|
||||
const deployment = inspectManagedGatewayDeployment(checkout);
|
||||
if (!deployment) {
|
||||
return null;
|
||||
}
|
||||
const sourceEntrypoint = path.join(checkout, "dist/index.js");
|
||||
if (deployment.entrypoint !== sourceEntrypoint) {
|
||||
throw new UpdateInvariantError(
|
||||
"gateway_runtime_mismatch",
|
||||
"managed Gateway still targets an immutable ancestor snapshot after maintenance",
|
||||
);
|
||||
}
|
||||
const launchctl = spawnSync(
|
||||
"/bin/launchctl",
|
||||
["print", `gui/${process.getuid()}/${deployment.label}`],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
const pidMatch =
|
||||
launchctl.status === 0 ? String(launchctl.stdout).match(/\bpid = (\d+)\b/u) : null;
|
||||
const pid = Number(pidMatch?.[1] ?? Number.NaN);
|
||||
const loadedArguments = parseLaunchctlArguments(String(launchctl.stdout));
|
||||
const loadedCommand = resolveManagedGatewayCommand(
|
||||
loadedArguments,
|
||||
process.env.HOME,
|
||||
deployment.stateDir,
|
||||
deployment.label,
|
||||
);
|
||||
const loadedPortFlag = loadedArguments.indexOf("--port");
|
||||
const loadedPort = Number(loadedPortFlag >= 0 ? loadedArguments[loadedPortFlag + 1] : Number.NaN);
|
||||
if (
|
||||
!loadedCommand ||
|
||||
loadedCommand.entrypoint !== sourceEntrypoint ||
|
||||
loadedCommand.executable !== deployment.executable ||
|
||||
loadedCommand.runtime !== deployment.runtime ||
|
||||
loadedCommand.wrapperPath !== deployment.wrapperPath ||
|
||||
loadedPort !== deployment.port
|
||||
) {
|
||||
throw new UpdateInvariantError(
|
||||
"gateway_runtime_mismatch",
|
||||
"loaded Gateway LaunchAgent arguments do not use the exact source entrypoint",
|
||||
);
|
||||
}
|
||||
if (!Number.isInteger(pid) || pid < 1) {
|
||||
throw new UpdateInvariantError(
|
||||
"gateway_runtime_mismatch",
|
||||
"managed Gateway LaunchAgent has no running process after maintenance",
|
||||
);
|
||||
}
|
||||
const listeners = spawnSync(
|
||||
"/usr/sbin/lsof",
|
||||
["-nP", `-iTCP:${deployment.port}`, "-sTCP:LISTEN", "-t"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
const listenerPids = String(listeners.stdout).trim().split(/\s+/u).filter(Boolean).map(Number);
|
||||
// The Gateway overwrites process.title, so ps cannot prove argv. The owned
|
||||
// LaunchAgent arguments plus its exact listener PID remain stable evidence.
|
||||
if (listeners.status !== 0 || !listenerPids.includes(pid)) {
|
||||
throw new UpdateInvariantError(
|
||||
"gateway_runtime_mismatch",
|
||||
"managed Gateway LaunchAgent PID does not own its configured listener",
|
||||
);
|
||||
}
|
||||
return { commit: expectedSha, entrypoint: sourceEntrypoint, pid, port: deployment.port };
|
||||
}
|
||||
|
||||
export function parseLaunchctlArguments(output) {
|
||||
const block = output.match(/\n\s*arguments = \{\n(?<body>[\s\S]*?)\n\s*\}/u)?.groups?.body;
|
||||
return block
|
||||
? block
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
function runBuiltGatewayCli(checkout, args, deployment) {
|
||||
const managedDeployment = deployment ?? readManagedGatewayLaunchAgent(checkout);
|
||||
const {
|
||||
configPath,
|
||||
entrypoint,
|
||||
envFilePath,
|
||||
executable,
|
||||
invocationPrefix,
|
||||
port,
|
||||
runtime,
|
||||
serviceEnvironment = {},
|
||||
wrapperPath,
|
||||
} = managedDeployment;
|
||||
const baseEnv = { ...process.env };
|
||||
delete baseEnv.OPENCLAW_GATEWAY_URL;
|
||||
delete baseEnv.OPENCLAW_GATEWAY_TOKEN;
|
||||
delete baseEnv.OPENCLAW_GATEWAY_PASSWORD;
|
||||
delete baseEnv.OPENCLAW_CONFIG_PATH;
|
||||
delete baseEnv.OPENCLAW_GATEWAY_PORT;
|
||||
Object.assign(baseEnv, serviceEnvironment);
|
||||
delete baseEnv.OPENCLAW_GATEWAY_URL;
|
||||
let effectiveConfigPath = configPath;
|
||||
if (wrapperPath && envFilePath) {
|
||||
delete baseEnv.OPENCLAW_CONFIG_PATH;
|
||||
delete baseEnv.OPENCLAW_GATEWAY_PORT;
|
||||
const wrapperPrefix = executable === "/bin/sh" ? [wrapperPath, envFilePath] : [envFilePath];
|
||||
try {
|
||||
const configuredPath = execFileSync(
|
||||
executable,
|
||||
[...wrapperPrefix, "/usr/bin/printenv", "OPENCLAW_CONFIG_PATH"],
|
||||
{ encoding: "utf8", env: baseEnv, stdio: ["ignore", "pipe", "ignore"] },
|
||||
).trim();
|
||||
if (configuredPath) {
|
||||
effectiveConfigPath = configuredPath;
|
||||
}
|
||||
} catch {
|
||||
// The service environment may rely on the state-directory default.
|
||||
}
|
||||
}
|
||||
const overlayPath = path.join(
|
||||
path.dirname(configPath),
|
||||
path.dirname(effectiveConfigPath),
|
||||
`.openclaw-live-updater-config-${randomUUID()}.json`,
|
||||
);
|
||||
writeFileSync(
|
||||
overlayPath,
|
||||
`${JSON.stringify({
|
||||
$include: `./${path.basename(configPath)}`,
|
||||
$include: `./${path.basename(effectiveConfigPath)}`,
|
||||
gateway: { mode: "local", port },
|
||||
})}\n`,
|
||||
{ flag: "wx", mode: 0o600 },
|
||||
);
|
||||
const env = {
|
||||
...process.env,
|
||||
const envOverrides = [`OPENCLAW_CONFIG_PATH=${overlayPath}`, `OPENCLAW_GATEWAY_PORT=${port}`];
|
||||
const env = Object.assign(baseEnv, {
|
||||
OPENCLAW_CONFIG_PATH: overlayPath,
|
||||
OPENCLAW_GATEWAY_PORT: String(port),
|
||||
};
|
||||
delete env.OPENCLAW_GATEWAY_URL;
|
||||
delete env.OPENCLAW_GATEWAY_TOKEN;
|
||||
delete env.OPENCLAW_GATEWAY_PASSWORD;
|
||||
});
|
||||
try {
|
||||
return execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
"dist/index.js",
|
||||
"gateway",
|
||||
"call",
|
||||
method,
|
||||
"--params",
|
||||
JSON.stringify(params),
|
||||
"--json",
|
||||
"--timeout",
|
||||
String(GATEWAY_SUSPEND_TIMEOUT_MS),
|
||||
],
|
||||
{
|
||||
cwd: checkout,
|
||||
encoding: "utf8",
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
},
|
||||
);
|
||||
const callArgs =
|
||||
wrapperPath && envFilePath
|
||||
? [
|
||||
...(executable === "/bin/sh" ? [wrapperPath, envFilePath] : [envFilePath]),
|
||||
"/usr/bin/env",
|
||||
"-u",
|
||||
"OPENCLAW_GATEWAY_URL",
|
||||
...envOverrides,
|
||||
runtime,
|
||||
entrypoint,
|
||||
...args,
|
||||
]
|
||||
: [...invocationPrefix, ...args];
|
||||
return execFileSync(executable, callArgs, {
|
||||
cwd: path.dirname(path.dirname(entrypoint)),
|
||||
encoding: "utf8",
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
});
|
||||
} finally {
|
||||
rmSync(overlayPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareGatewaySuspension(checkout, callGateway = runBuiltGatewayCall) {
|
||||
export function runBuiltGatewayCall(checkout, method, params, deployment) {
|
||||
const managedDeployment = deployment ?? readManagedGatewayLaunchAgent(checkout);
|
||||
return runBuiltGatewayCli(
|
||||
checkout,
|
||||
[
|
||||
"gateway",
|
||||
"call",
|
||||
method,
|
||||
"--params",
|
||||
JSON.stringify(params),
|
||||
"--json",
|
||||
"--timeout",
|
||||
String(GATEWAY_SUSPEND_TIMEOUT_MS),
|
||||
],
|
||||
managedDeployment,
|
||||
);
|
||||
}
|
||||
|
||||
export function prepareGatewaySuspension(
|
||||
checkout,
|
||||
callGateway = runBuiltGatewayCall,
|
||||
deployment = null,
|
||||
) {
|
||||
const requestId = `openclaw-live-updater-${randomUUID()}`;
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(callGateway(checkout, "gateway.suspend.prepare", { requestId }));
|
||||
result = JSON.parse(
|
||||
callGateway(checkout, "gateway.suspend.prepare", { requestId }, deployment),
|
||||
);
|
||||
} catch (error) {
|
||||
throw new UpdateInvariantError(
|
||||
"gateway_suspend_prepare_failed",
|
||||
@@ -639,8 +1053,63 @@ export function prepareGatewaySuspension(checkout, callGateway = runBuiltGateway
|
||||
);
|
||||
}
|
||||
|
||||
function defaultResumeGatewaySuspension(checkout, suspensionId) {
|
||||
runBuiltGatewayCall(checkout, "gateway.suspend.resume", { suspensionId });
|
||||
function defaultResumeGatewaySuspension(checkout, suspensionId, deployment) {
|
||||
runBuiltGatewayCall(checkout, "gateway.suspend.resume", { suspensionId }, deployment);
|
||||
}
|
||||
|
||||
function stopManagedGateway(runCommand, checkout, deployment) {
|
||||
if (!deployment) {
|
||||
runCommand(process.execPath, ["dist/index.js", "gateway", "stop"], checkout);
|
||||
return;
|
||||
}
|
||||
runCommand(
|
||||
"/bin/launchctl",
|
||||
["bootout", `gui/${process.getuid()}/${deployment.label}`],
|
||||
checkout,
|
||||
);
|
||||
}
|
||||
|
||||
function isTrustedSourceControlBuild(checkout, buildState, currentHead) {
|
||||
if (buildState.current) {
|
||||
return true;
|
||||
}
|
||||
const commit = buildState.commit;
|
||||
if (
|
||||
buildState.state !== "stale" ||
|
||||
!commit ||
|
||||
buildState.buildStampHead !== commit ||
|
||||
buildState.runtimePostBuildStampHead !== commit ||
|
||||
buildState.missingUiAssets.length > 0 ||
|
||||
!commitExists(checkout, commit)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
git(checkout, ["merge-base", "--is-ancestor", commit, currentHead]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGatewayControlDeployment(checkout, deployment, buildBefore, currentHead) {
|
||||
if (!deployment) {
|
||||
return null;
|
||||
}
|
||||
const sourceEntrypoint = path.join(checkout, "dist/index.js");
|
||||
if (deployment.entrypoint === sourceEntrypoint) {
|
||||
return deployment;
|
||||
}
|
||||
if (!isTrustedSourceControlBuild(checkout, buildBefore, currentHead)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...deployment,
|
||||
entrypoint: sourceEntrypoint,
|
||||
invocationPrefix: deployment.invocationPrefix.map((argument) =>
|
||||
argument === deployment.entrypoint ? sourceEntrypoint : argument,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function proveMacLaunchdGatewayStopped(checkout) {
|
||||
@@ -823,15 +1292,55 @@ function runBuildWithPreservedMacApp(runCommand, checkout, sleep = defaultSleep)
|
||||
}
|
||||
}
|
||||
|
||||
function restartGateway(runCommand, checkout, expectedSha) {
|
||||
function restartGateway(
|
||||
runCommand,
|
||||
checkout,
|
||||
expectedSha,
|
||||
startedAtMs = Date.now(),
|
||||
deployment = null,
|
||||
bootstrap = false,
|
||||
) {
|
||||
assertExactBuild(checkout, expectedSha);
|
||||
const startedAtMs = Date.now();
|
||||
runCommand("pnpm", ["openclaw", "gateway", "restart"], checkout);
|
||||
if (!deployment) {
|
||||
runCommand("pnpm", ["openclaw", "gateway", "restart"], checkout);
|
||||
return startedAtMs;
|
||||
}
|
||||
if (bootstrap) {
|
||||
const plistStat = lstatSync(deployment.plistPath);
|
||||
if (!isTrustedOwnedRegularFile(plistStat)) {
|
||||
throw new UpdateInvariantError(
|
||||
"gateway_launchagent_failed",
|
||||
"managed Gateway LaunchAgent ownership or permissions changed before bootstrap",
|
||||
);
|
||||
}
|
||||
const domain = `gui/${process.getuid()}`;
|
||||
runCommand("/bin/launchctl", ["enable", `${domain}/${deployment.label}`], checkout);
|
||||
runCommand("/bin/launchctl", ["bootstrap", domain, deployment.plistPath], checkout);
|
||||
return startedAtMs;
|
||||
}
|
||||
runCommand(
|
||||
deployment.executable,
|
||||
[...deployment.invocationPrefix, "gateway", "restart"],
|
||||
path.dirname(path.dirname(deployment.entrypoint)),
|
||||
);
|
||||
return startedAtMs;
|
||||
}
|
||||
|
||||
function verifyGateway(runCommand, checkout, expectedSha) {
|
||||
function verifyGateway(runCommand, checkout, expectedSha, deployment = null) {
|
||||
assertExactBuild(checkout, expectedSha);
|
||||
if (deployment) {
|
||||
runBuiltGatewayCli(
|
||||
checkout,
|
||||
["gateway", "status", "--deep", "--require-rpc", "--json"],
|
||||
deployment,
|
||||
);
|
||||
runBuiltGatewayCli(
|
||||
checkout,
|
||||
["health", "--port", String(deployment.port), "--verbose", "--json"],
|
||||
deployment,
|
||||
);
|
||||
return;
|
||||
}
|
||||
runCommand(
|
||||
"pnpm",
|
||||
["openclaw", "gateway", "status", "--deep", "--require-rpc", "--json"],
|
||||
@@ -844,11 +1353,17 @@ function defaultSleep(ms) {
|
||||
execFileSync("sleep", [String(ms / 1_000)]);
|
||||
}
|
||||
|
||||
export function verifyGatewayReadiness(runCommand, checkout, expectedSha, sleep = defaultSleep) {
|
||||
export function verifyGatewayReadiness(
|
||||
runCommand,
|
||||
checkout,
|
||||
expectedSha,
|
||||
sleep = defaultSleep,
|
||||
deployment = null,
|
||||
) {
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= GATEWAY_READINESS_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
verifyGateway(runCommand, checkout, expectedSha);
|
||||
verifyGateway(runCommand, checkout, expectedSha, deployment);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
@@ -977,12 +1492,13 @@ function verifyAndAuditGateway({
|
||||
auditGatewayLogs,
|
||||
checkout,
|
||||
expectedSha,
|
||||
deployment,
|
||||
sinceMs,
|
||||
sleep,
|
||||
}) {
|
||||
let verificationError;
|
||||
try {
|
||||
verifyGatewayReadiness(runCommand, checkout, expectedSha, sleep);
|
||||
verifyGatewayReadiness(runCommand, checkout, expectedSha, sleep, deployment);
|
||||
} catch (error) {
|
||||
verificationError = error;
|
||||
}
|
||||
@@ -1030,6 +1546,35 @@ export function maintainMain(options, dependencies = {}) {
|
||||
}
|
||||
|
||||
try {
|
||||
const verifiedBefore = verifyCheckout(options.checkout, { remote: options.remote });
|
||||
const runCommand = dependencies.runCommand ?? defaultRunCommand;
|
||||
const inspectGatewayDeployment =
|
||||
dependencies.inspectGatewayDeployment ?? inspectManagedGatewayDeployment;
|
||||
const repointGatewayDeployment =
|
||||
dependencies.repointGatewayDeployment ?? repointManagedGatewayDeployment;
|
||||
const replaceGatewayEntrypoint =
|
||||
dependencies.replaceGatewayEntrypoint ?? replaceLaunchAgentEntrypoint;
|
||||
const verifyGatewayRuntime = dependencies.verifyGatewayRuntime ?? verifyManagedGatewayRuntime;
|
||||
const prepareSuspension =
|
||||
dependencies.prepareGatewaySuspension ??
|
||||
((checkout, deployment) =>
|
||||
prepareGatewaySuspension(checkout, runBuiltGatewayCall, deployment));
|
||||
const resumeSuspension = dependencies.resumeGatewaySuspension ?? defaultResumeGatewaySuspension;
|
||||
const proveGatewayStopped = dependencies.proveGatewayStopped ?? defaultProveGatewayStopped;
|
||||
const verifyMacTarget = dependencies.verifyMacTarget ?? defaultVerifyMacTarget;
|
||||
const auditGatewayLogs = dependencies.auditGatewayLogs ?? defaultAuditGatewayLogs;
|
||||
const sleep = dependencies.sleep ?? defaultSleep;
|
||||
const gatewayDeploymentBefore = inspectGatewayDeployment(verifiedBefore.checkout);
|
||||
const sourceBuildBeforeUpdate = inspectBuildState(
|
||||
verifiedBefore.checkout,
|
||||
verifiedBefore.headSha,
|
||||
);
|
||||
const gatewayControlDeployment = resolveGatewayControlDeployment(
|
||||
verifiedBefore.checkout,
|
||||
gatewayDeploymentBefore,
|
||||
sourceBuildBeforeUpdate,
|
||||
verifiedBefore.headSha,
|
||||
);
|
||||
const update = updateMain(options, dependencies);
|
||||
const statePath = options.statePath ?? defaultStatePath(update.checkout);
|
||||
const maintenanceState = readMaintenanceState(statePath);
|
||||
@@ -1055,14 +1600,12 @@ export function maintainMain(options, dependencies = {}) {
|
||||
actions.macAppRebuild = true;
|
||||
actions.macUiVerification ||= maintenanceState.macUiVerification === true;
|
||||
}
|
||||
const runCommand = dependencies.runCommand ?? defaultRunCommand;
|
||||
const prepareSuspension = dependencies.prepareGatewaySuspension ?? prepareGatewaySuspension;
|
||||
const resumeSuspension = dependencies.resumeGatewaySuspension ?? defaultResumeGatewaySuspension;
|
||||
const proveGatewayStopped = dependencies.proveGatewayStopped ?? defaultProveGatewayStopped;
|
||||
const verifyMacTarget = dependencies.verifyMacTarget ?? defaultVerifyMacTarget;
|
||||
const auditGatewayLogs = dependencies.auditGatewayLogs ?? defaultAuditGatewayLogs;
|
||||
const sleep = dependencies.sleep ?? defaultSleep;
|
||||
const gatewayRuntimeRepointRequired =
|
||||
gatewayDeploymentBefore !== null &&
|
||||
gatewayDeploymentBefore.entrypoint !== path.join(update.checkout, "dist/index.js");
|
||||
let gatewayLogAudit = null;
|
||||
let gatewayDeployment = null;
|
||||
let gatewayRuntime = null;
|
||||
let queuedMacState = null;
|
||||
if (actions.macAppRebuild) {
|
||||
queuedMacState = {
|
||||
@@ -1075,11 +1618,12 @@ export function maintainMain(options, dependencies = {}) {
|
||||
writeMaintenanceState(statePath, queuedMacState);
|
||||
}
|
||||
|
||||
if (actions.gatewayBuild || actions.dependencyInstall) {
|
||||
if (actions.gatewayBuild || actions.dependencyInstall || gatewayRuntimeRepointRequired) {
|
||||
actions.gatewayRestart = true;
|
||||
let gatewaySuspension;
|
||||
try {
|
||||
gatewaySuspension = prepareSuspension(update.checkout);
|
||||
} catch (prepareError) {
|
||||
const controlUnavailable =
|
||||
gatewayDeploymentBefore !== null && gatewayControlDeployment === null;
|
||||
if (controlUnavailable) {
|
||||
try {
|
||||
gatewaySuspension = {
|
||||
status: "offline",
|
||||
@@ -1087,10 +1631,32 @@ export function maintainMain(options, dependencies = {}) {
|
||||
};
|
||||
} catch (proofError) {
|
||||
throw new AggregateError(
|
||||
[prepareError, proofError],
|
||||
"Gateway suspension failed and the managed Gateway could not be proven stopped",
|
||||
[
|
||||
new UpdateInvariantError(
|
||||
"gateway_snapshot_control_unavailable",
|
||||
"managed Gateway uses a snapshot but the source checkout has no exact trusted control build",
|
||||
),
|
||||
proofError,
|
||||
],
|
||||
"Gateway control is unavailable and the managed Gateway could not be proven stopped",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
gatewaySuspension = prepareSuspension(update.checkout, gatewayControlDeployment);
|
||||
} catch (prepareError) {
|
||||
try {
|
||||
gatewaySuspension = {
|
||||
status: "offline",
|
||||
proof: proveGatewayStopped(update.checkout),
|
||||
};
|
||||
} catch (proofError) {
|
||||
throw new AggregateError(
|
||||
[prepareError, proofError],
|
||||
"Gateway suspension failed and the managed Gateway could not be proven stopped",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (gatewaySuspension.status === "busy") {
|
||||
return {
|
||||
@@ -1106,13 +1672,20 @@ export function maintainMain(options, dependencies = {}) {
|
||||
};
|
||||
}
|
||||
if (gatewaySuspension.status === "ready") {
|
||||
// Use the existing built CLI directly. Source launchers may auto-build a
|
||||
// stale dist before dispatching `gateway stop`, recreating the live-import race.
|
||||
// Native bootout prevents launchd from retaining old ProgramArguments
|
||||
// and avoids source launchers that can rebuild stale dist before stopping.
|
||||
try {
|
||||
runCommand(process.execPath, ["dist/index.js", "gateway", "stop"], update.checkout);
|
||||
stopManagedGateway(runCommand, update.checkout, gatewayDeploymentBefore);
|
||||
// Retarget only after launchd has discarded the old ProgramArguments.
|
||||
// Otherwise a later kickstart can revive its cached snapshot command.
|
||||
proveGatewayStopped(update.checkout);
|
||||
} catch (error) {
|
||||
try {
|
||||
resumeSuspension(update.checkout, gatewaySuspension.suspensionId);
|
||||
resumeSuspension(
|
||||
update.checkout,
|
||||
gatewaySuspension.suspensionId,
|
||||
gatewayControlDeployment,
|
||||
);
|
||||
} catch (resumeError) {
|
||||
throw new AggregateError(
|
||||
[error, resumeError],
|
||||
@@ -1129,30 +1702,57 @@ export function maintainMain(options, dependencies = {}) {
|
||||
runBuildWithPreservedMacApp(runCommand, update.checkout, sleep);
|
||||
}
|
||||
assertExactBuild(update.checkout, update.afterSha);
|
||||
const restartStartedAt = restartGateway(runCommand, update.checkout, update.afterSha);
|
||||
const restartStartedAt = Date.now();
|
||||
gatewayDeployment = gatewayDeploymentBefore
|
||||
? repointGatewayDeployment(
|
||||
update.checkout,
|
||||
gatewayDeploymentBefore,
|
||||
replaceGatewayEntrypoint,
|
||||
inspectGatewayDeployment,
|
||||
)
|
||||
: null;
|
||||
restartGateway(
|
||||
runCommand,
|
||||
update.checkout,
|
||||
update.afterSha,
|
||||
restartStartedAt,
|
||||
gatewayDeployment,
|
||||
gatewayDeployment !== null,
|
||||
);
|
||||
gatewayLogAudit = verifyAndAuditGateway({
|
||||
runCommand,
|
||||
auditGatewayLogs,
|
||||
checkout: update.checkout,
|
||||
expectedSha: update.afterSha,
|
||||
deployment: gatewayDeployment,
|
||||
sinceMs: restartStartedAt,
|
||||
sleep,
|
||||
});
|
||||
gatewayRuntime = verifyGatewayRuntime(update.checkout, update.afterSha);
|
||||
} else {
|
||||
try {
|
||||
verifyGateway(runCommand, update.checkout, update.afterSha);
|
||||
verifyGateway(runCommand, update.checkout, update.afterSha, gatewayControlDeployment);
|
||||
gatewayRuntime = verifyGatewayRuntime(update.checkout, update.afterSha);
|
||||
} catch {
|
||||
actions.gatewayRestart = true;
|
||||
actions.gatewaySelfHeal = true;
|
||||
const restartStartedAt = restartGateway(runCommand, update.checkout, update.afterSha);
|
||||
const restartStartedAt = restartGateway(
|
||||
runCommand,
|
||||
update.checkout,
|
||||
update.afterSha,
|
||||
Date.now(),
|
||||
gatewayControlDeployment,
|
||||
);
|
||||
gatewayLogAudit = verifyAndAuditGateway({
|
||||
runCommand,
|
||||
auditGatewayLogs,
|
||||
checkout: update.checkout,
|
||||
expectedSha: update.afterSha,
|
||||
deployment: gatewayControlDeployment,
|
||||
sinceMs: restartStartedAt,
|
||||
sleep,
|
||||
});
|
||||
gatewayRuntime = verifyGatewayRuntime(update.checkout, update.afterSha);
|
||||
}
|
||||
}
|
||||
if (actions.macAppRebuild) {
|
||||
@@ -1180,7 +1780,12 @@ export function maintainMain(options, dependencies = {}) {
|
||||
update.checkout,
|
||||
);
|
||||
const macTarget = verifyMacTarget(update.checkout);
|
||||
verifyGateway(runCommand, update.checkout, update.afterSha);
|
||||
verifyGateway(
|
||||
runCommand,
|
||||
update.checkout,
|
||||
update.afterSha,
|
||||
gatewayDeployment ?? gatewayControlDeployment,
|
||||
);
|
||||
rmSync(statePath, { force: true });
|
||||
maintenanceState.macTarget = macTarget;
|
||||
} catch (error) {
|
||||
@@ -1199,7 +1804,21 @@ export function maintainMain(options, dependencies = {}) {
|
||||
buildBefore,
|
||||
buildChangedPaths,
|
||||
actions,
|
||||
...(gatewayDeployment
|
||||
? {
|
||||
gatewayDeployment: {
|
||||
changed: gatewayDeployment.changed,
|
||||
entrypoint: gatewayDeployment.entrypoint,
|
||||
label: gatewayDeployment.label,
|
||||
port: gatewayDeployment.port,
|
||||
...(gatewayDeployment.previousEntrypoint
|
||||
? { previousEntrypoint: gatewayDeployment.previousEntrypoint }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(gatewayLogAudit ? { gatewayLogAudit } : {}),
|
||||
...(gatewayRuntime ? { gatewayRuntime } : {}),
|
||||
...(maintenanceState.macTarget ? { macTarget: maintenanceState.macTarget } : {}),
|
||||
};
|
||||
} finally {
|
||||
|
||||
Vendored
+33
-1
@@ -119,6 +119,9 @@ declare module "*openclaw-changelog-update/scripts/verify-release-notes.mjs" {
|
||||
}
|
||||
|
||||
declare module "*openclaw-live-updater/scripts/update-main.mjs" {
|
||||
type GatewayDeployment = Record<string, unknown> & {
|
||||
entrypoint: string;
|
||||
};
|
||||
type UpdateResult = Record<string, unknown> & {
|
||||
actions: Record<string, unknown>;
|
||||
buildBefore: Record<string, unknown>;
|
||||
@@ -127,6 +130,29 @@ declare module "*openclaw-live-updater/scripts/update-main.mjs" {
|
||||
release?: () => void;
|
||||
};
|
||||
export function originMatches(remoteUrl: string): boolean;
|
||||
export function isOwnedGatewayEntrypoint(
|
||||
checkout: string,
|
||||
home: string,
|
||||
entrypoint: string,
|
||||
): boolean;
|
||||
export function parseLaunchctlArguments(output: string): string[];
|
||||
export function resolveManagedGatewayEntrypoint(
|
||||
programArguments: string[],
|
||||
home: string,
|
||||
stateDir?: string,
|
||||
): string | null;
|
||||
export function repointManagedGatewayDeployment(
|
||||
checkout: string,
|
||||
deployment: GatewayDeployment,
|
||||
replaceEntrypoint: (deployment: GatewayDeployment, replacement: string) => void,
|
||||
inspectDeployment?: (checkout: string) => GatewayDeployment | null,
|
||||
): GatewayDeployment & { changed: boolean; previousEntrypoint?: string };
|
||||
export function runBuiltGatewayCall(
|
||||
checkout: string,
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
deployment?: GatewayDeployment | null,
|
||||
): string;
|
||||
export function classifyActions(
|
||||
changedPaths: string[],
|
||||
options: Record<string, unknown>,
|
||||
@@ -143,7 +169,13 @@ declare module "*openclaw-live-updater/scripts/update-main.mjs" {
|
||||
export function parseGatewayLogAudit(output: string, sinceMs: number): Record<string, unknown>;
|
||||
export function prepareGatewaySuspension(
|
||||
checkout: string,
|
||||
callGateway?: (checkout: string, method: string, params: { requestId: string }) => string,
|
||||
callGateway?: (
|
||||
checkout: string,
|
||||
method: string,
|
||||
params: { requestId: string },
|
||||
deployment: GatewayDeployment | null,
|
||||
) => string,
|
||||
deployment?: GatewayDeployment | null,
|
||||
):
|
||||
| { status: "ready"; suspensionId: string }
|
||||
| {
|
||||
|
||||
@@ -21,10 +21,15 @@ import {
|
||||
classifyActions,
|
||||
findExactMacTarget,
|
||||
inspectBuildState,
|
||||
isOwnedGatewayEntrypoint,
|
||||
maintainMain,
|
||||
originMatches,
|
||||
parseGatewayLogAudit,
|
||||
parseLaunchctlArguments,
|
||||
prepareGatewaySuspension,
|
||||
repointManagedGatewayDeployment,
|
||||
resolveManagedGatewayEntrypoint,
|
||||
runBuiltGatewayCall,
|
||||
verifyGatewayReadiness,
|
||||
} from "../../.agents/skills/openclaw-live-updater/scripts/update-main.mjs";
|
||||
import {
|
||||
@@ -55,6 +60,8 @@ function maintainFixture(
|
||||
) {
|
||||
return maintainMain(options, {
|
||||
fetchMain: fetchFixtureMain,
|
||||
inspectGatewayDeployment: () => null,
|
||||
verifyGatewayRuntime: () => null,
|
||||
auditGatewayLogs: () => ({
|
||||
entries: 0,
|
||||
errorCount: 0,
|
||||
@@ -66,12 +73,18 @@ function maintainFixture(
|
||||
status: "ready",
|
||||
suspensionId: "fixture-suspension",
|
||||
}),
|
||||
proveGatewayStopped: () => ({
|
||||
runtimeStatus: "stopped",
|
||||
port: 18789,
|
||||
portStatus: "free",
|
||||
proofSource: "fixture",
|
||||
}),
|
||||
...dependencies,
|
||||
});
|
||||
}
|
||||
|
||||
function makeFixture() {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "openclaw-live-updater-"));
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "openclaw-live-updater-")));
|
||||
const origin = path.join(root, "origin.git");
|
||||
const seed = path.join(root, "seed");
|
||||
const mirror = path.join(root, "mirror");
|
||||
@@ -183,6 +196,34 @@ describe("openclaw live updater", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("parses the loaded launchd ProgramArguments block", () => {
|
||||
expect(
|
||||
parseLaunchctlArguments(`gui/501/ai.openclaw.gateway = {
|
||||
\tprogram = /bin/sh
|
||||
\targuments = {
|
||||
\t\t/bin/sh
|
||||
\t\t/Users/test/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh
|
||||
\t\t/Users/test/.openclaw/service-env/ai.openclaw.gateway.env
|
||||
\t\t/opt/homebrew/bin/node
|
||||
\t\t/Users/test/openclaw/dist/index.js
|
||||
\t\tgateway
|
||||
\t\t--port
|
||||
\t\t18789
|
||||
\t}
|
||||
\tpid = 123
|
||||
}`),
|
||||
).toEqual([
|
||||
"/bin/sh",
|
||||
"/Users/test/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh",
|
||||
"/Users/test/.openclaw/service-env/ai.openclaw.gateway.env",
|
||||
"/opt/homebrew/bin/node",
|
||||
"/Users/test/openclaw/dist/index.js",
|
||||
"gateway",
|
||||
"--port",
|
||||
"18789",
|
||||
]);
|
||||
});
|
||||
|
||||
test("audits raw file logs when RPC log retrieval is unavailable", () => {
|
||||
const output = [
|
||||
{
|
||||
@@ -240,14 +281,22 @@ describe("openclaw live updater", () => {
|
||||
});
|
||||
|
||||
test("parses ready and busy atomic Gateway suspension responses", () => {
|
||||
const deployment = { entrypoint: "/snapshot/dist/index.js" };
|
||||
expect(
|
||||
prepareGatewaySuspension(
|
||||
"/checkout",
|
||||
(_checkout: string, method: string, params: { requestId: string }) => {
|
||||
(
|
||||
_checkout: string,
|
||||
method: string,
|
||||
params: { requestId: string },
|
||||
selectedDeployment: unknown,
|
||||
) => {
|
||||
expect(method).toBe("gateway.suspend.prepare");
|
||||
expect(params.requestId).toMatch(/^openclaw-live-updater-/u);
|
||||
expect(selectedDeployment).toBe(deployment);
|
||||
return JSON.stringify({ status: "ready", suspensionId: "suspension-1" });
|
||||
},
|
||||
deployment,
|
||||
),
|
||||
).toEqual({ status: "ready", suspensionId: "suspension-1" });
|
||||
|
||||
@@ -264,12 +313,166 @@ describe("openclaw live updater", () => {
|
||||
).toMatchObject({ status: "busy", activeCount: 1 });
|
||||
});
|
||||
|
||||
test("pins managed Gateway calls with a backward-compatible local overlay", () => {
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "openclaw-gateway-call-")));
|
||||
const checkout = path.join(root, "checkout");
|
||||
const entrypoint = path.join(checkout, "dist/index.js");
|
||||
const capture = path.join(root, "capture.mjs");
|
||||
const configPath = path.join(root, "openclaw.json");
|
||||
mkdirSync(path.dirname(entrypoint), { recursive: true });
|
||||
writeFileSync(configPath, "{}\n");
|
||||
writeFileSync(
|
||||
capture,
|
||||
'import fs from "node:fs"; console.log(JSON.stringify({ argv: process.argv.slice(2), config: JSON.parse(fs.readFileSync(process.env.OPENCLAW_CONFIG_PATH, "utf8")), hasToken: Boolean(process.env.OPENCLAW_GATEWAY_TOKEN), url: process.env.OPENCLAW_GATEWAY_URL ?? null }));\n',
|
||||
);
|
||||
|
||||
const result = JSON.parse(
|
||||
runBuiltGatewayCall(
|
||||
checkout,
|
||||
"gateway.suspend.prepare",
|
||||
{ requestId: "request-1" },
|
||||
{
|
||||
configPath,
|
||||
entrypoint,
|
||||
executable: process.execPath,
|
||||
invocationPrefix: [capture],
|
||||
port: 19001,
|
||||
serviceEnvironment: { OPENCLAW_GATEWAY_TOKEN: ["fixture", "value"].join("-") },
|
||||
wrapperPath: null,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.config).toMatchObject({ gateway: { mode: "local", port: 19001 } });
|
||||
expect(result.argv).not.toContain("--port");
|
||||
expect(result.argv).not.toContain("--url");
|
||||
expect(result.hasToken).toBe(true);
|
||||
expect(result.url).toBeNull();
|
||||
});
|
||||
|
||||
test("accepts supported OpenClaw GitHub origins", () => {
|
||||
expect(originMatches("https://github.com/openclaw/openclaw.git")).toBe(true);
|
||||
expect(originMatches("git@github.com:openclaw/openclaw.git")).toBe(true);
|
||||
expect(originMatches("https://github.com/example/openclaw.git")).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts only immutable canonical runtime snapshots owned by the checkout", () => {
|
||||
const { root, mirror, origin } = makeFixture();
|
||||
const head = git(mirror, "rev-parse", "HEAD");
|
||||
const home = path.join(root, "home");
|
||||
const runtimeRoot = path.join(home, ".openclaw/runtime");
|
||||
const snapshot = path.join(runtimeRoot, `gateway-${head.slice(0, 7)}`);
|
||||
mkdirSync(runtimeRoot, { recursive: true });
|
||||
git(runtimeRoot, "clone", origin, snapshot);
|
||||
git(snapshot, "remote", "set-url", "origin", "https://github.com/openclaw/openclaw.git");
|
||||
git(snapshot, "checkout", "--detach", head);
|
||||
writeBuild(snapshot);
|
||||
const entrypoint = path.join(snapshot, "dist/index.js");
|
||||
|
||||
expect(isOwnedGatewayEntrypoint(mirror, home, entrypoint)).toBe(true);
|
||||
expect(isOwnedGatewayEntrypoint(mirror, home, path.join(mirror, "dist/index.js"))).toBe(true);
|
||||
|
||||
git(snapshot, "switch", "-c", "mutable");
|
||||
expect(isOwnedGatewayEntrypoint(mirror, home, entrypoint)).toBe(false);
|
||||
git(snapshot, "checkout", "--detach", head);
|
||||
writeFileSync(path.join(snapshot, "README.md"), "dirty\n");
|
||||
expect(isOwnedGatewayEntrypoint(mirror, home, entrypoint)).toBe(false);
|
||||
git(snapshot, "checkout", "--", "README.md");
|
||||
writeFileSync(
|
||||
path.join(snapshot, "dist", BUILD_STAMP_FILE),
|
||||
`${JSON.stringify({ head: "0".repeat(40) })}\n`,
|
||||
);
|
||||
expect(isOwnedGatewayEntrypoint(mirror, home, entrypoint)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts owned entrypoints only in supported LaunchAgent command layouts", () => {
|
||||
const home = "/Users/test";
|
||||
const entrypoint = "/Users/test/.openclaw/runtime/gateway-1234567/dist/index.js";
|
||||
const wrapper = `${home}/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh`;
|
||||
const envFile = `${home}/.openclaw/service-env/ai.openclaw.gateway.env`;
|
||||
const nodeCommand = ["/opt/homebrew/bin/node", entrypoint, "gateway", "--port", "18789"];
|
||||
|
||||
expect(resolveManagedGatewayEntrypoint(nodeCommand, home)).toBe(entrypoint);
|
||||
expect(
|
||||
resolveManagedGatewayEntrypoint(["/opt/homebrew/bin/bun", entrypoint, "gateway"], home),
|
||||
).toBe(entrypoint);
|
||||
expect(
|
||||
resolveManagedGatewayEntrypoint(["/bin/sh", wrapper, envFile, ...nodeCommand], home),
|
||||
).toBe(entrypoint);
|
||||
expect(resolveManagedGatewayEntrypoint([wrapper, envFile, ...nodeCommand], home)).toBe(
|
||||
entrypoint,
|
||||
);
|
||||
const customState = "/Users/test/state";
|
||||
const customWrapper = `${customState}/service-env/ai.openclaw.gateway-env-wrapper.sh`;
|
||||
const customEnvFile = `${customState}/service-env/ai.openclaw.gateway.env`;
|
||||
expect(
|
||||
resolveManagedGatewayEntrypoint(
|
||||
["/bin/sh", customWrapper, customEnvFile, ...nodeCommand],
|
||||
home,
|
||||
),
|
||||
).toBe(entrypoint);
|
||||
expect(
|
||||
resolveManagedGatewayEntrypoint(["/usr/bin/python3", "/tmp/foreign.py", entrypoint], home),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveManagedGatewayEntrypoint(["/bin/sh", "/tmp/wrapper.sh", entrypoint, "gateway"], home),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("retargets an accepted snapshot to the exact source build", () => {
|
||||
const checkout = "/Users/test/openclaw";
|
||||
const snapshot = "/Users/test/.openclaw/runtime/gateway-1234567/dist/index.js";
|
||||
const source = path.join(checkout, "dist/index.js");
|
||||
const replacements: string[] = [];
|
||||
const deployment = {
|
||||
configPath: "/Users/test/config/openclaw.json",
|
||||
entrypoint: snapshot,
|
||||
entrypointIndex: 1,
|
||||
label: "ai.openclaw.gateway",
|
||||
plistPath: "/Users/test/Library/LaunchAgents/ai.openclaw.gateway.plist",
|
||||
port: 18789,
|
||||
};
|
||||
const result = repointManagedGatewayDeployment(
|
||||
checkout,
|
||||
deployment,
|
||||
(_current, replacement: string) => replacements.push(replacement),
|
||||
() => ({ ...deployment, entrypoint: source }),
|
||||
);
|
||||
|
||||
expect(replacements).toEqual([source]);
|
||||
expect(result).toMatchObject({
|
||||
changed: true,
|
||||
configPath: deployment.configPath,
|
||||
entrypoint: source,
|
||||
label: "ai.openclaw.gateway",
|
||||
port: 18789,
|
||||
previousEntrypoint: snapshot,
|
||||
});
|
||||
});
|
||||
|
||||
test("fails closed when Gateway service retargeting does not stick", () => {
|
||||
const checkout = "/Users/test/openclaw";
|
||||
const snapshot = "/Users/test/.openclaw/runtime/gateway-1234567/dist/index.js";
|
||||
expect(() =>
|
||||
repointManagedGatewayDeployment(
|
||||
checkout,
|
||||
{
|
||||
configPath: "/Users/test/.openclaw/openclaw.json",
|
||||
entrypoint: snapshot,
|
||||
label: "ai.openclaw.gateway",
|
||||
port: 18789,
|
||||
},
|
||||
() => {},
|
||||
() => ({
|
||||
configPath: "/Users/test/.openclaw/openclaw.json",
|
||||
entrypoint: snapshot,
|
||||
label: "ai.openclaw.gateway",
|
||||
port: 18789,
|
||||
}),
|
||||
),
|
||||
).toThrow(/not retargeted/u);
|
||||
});
|
||||
|
||||
test("production fetch refreshes the remote-tracking main ref", () => {
|
||||
const source = readFileSync(script, "utf8");
|
||||
expect(source).toContain("refs/heads/main:refs/remotes/${remoteName}/main");
|
||||
@@ -737,6 +940,191 @@ describe("openclaw live updater", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("repoints an ancestor snapshot across the next source update", () => {
|
||||
const { root, mirror, seed } = makeFixture();
|
||||
mkdirSync(path.join(mirror, "node_modules"));
|
||||
writeBuild(mirror);
|
||||
writeFileSync(path.join(seed, "README.md"), "snapshot update\n");
|
||||
git(seed, "add", "README.md");
|
||||
git(seed, "commit", "-m", "snapshot update");
|
||||
git(seed, "push");
|
||||
const commands = fakeCommands(mirror);
|
||||
const snapshot = path.join(root, "gateway-ancestor/dist/index.js");
|
||||
const source = path.join(mirror, "dist/index.js");
|
||||
const configPath = path.join(root, "openclaw.json");
|
||||
const plistPath = path.join(root, "ai.openclaw.gateway.plist");
|
||||
writeFileSync(configPath, "{}\n");
|
||||
writeFileSync(plistPath, "plist\n", { mode: 0o600 });
|
||||
let deployedEntrypoint = snapshot;
|
||||
let controlEntrypoint: string | undefined;
|
||||
const inspectGatewayDeployment = () => ({
|
||||
configPath,
|
||||
entrypoint: deployedEntrypoint,
|
||||
entrypointIndex: 1,
|
||||
executable: process.execPath,
|
||||
invocationPrefix: [deployedEntrypoint],
|
||||
label: "ai.openclaw.gateway",
|
||||
plistPath,
|
||||
port: 18789,
|
||||
runtime: process.execPath,
|
||||
serviceEnvironment: { PRIVATE_MARKER: "not-serialized" },
|
||||
});
|
||||
|
||||
const deferred = maintainFixture(
|
||||
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
||||
{
|
||||
runCommand: commands.runCommand,
|
||||
inspectGatewayDeployment,
|
||||
prepareGatewaySuspension: () => ({
|
||||
status: "busy",
|
||||
reason: "active-work",
|
||||
retryAfterMs: 20_000,
|
||||
activeCount: 1,
|
||||
blockers: [{ kind: "agent-run", count: 1, message: "busy" }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
expect(deferred).toMatchObject({ deferred: true, reason: "gateway_active_work" });
|
||||
expect(commands.calls).toEqual([]);
|
||||
|
||||
const output = maintainFixture(
|
||||
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
||||
{
|
||||
runCommand: commands.runCommand,
|
||||
prepareGatewaySuspension: (_checkout: string, deployment: { entrypoint: string }) => {
|
||||
controlEntrypoint = deployment.entrypoint;
|
||||
return { status: "ready", suspensionId: "fixture-suspension" };
|
||||
},
|
||||
inspectGatewayDeployment,
|
||||
repointGatewayDeployment: (
|
||||
_checkout: string,
|
||||
deployment: { entrypoint: string; label: string; port: number },
|
||||
) => {
|
||||
deployedEntrypoint = source;
|
||||
return {
|
||||
changed: true,
|
||||
...deployment,
|
||||
entrypoint: source,
|
||||
invocationPrefix: [source],
|
||||
previousEntrypoint: deployment.entrypoint,
|
||||
};
|
||||
},
|
||||
verifyGatewayRuntime: () => ({
|
||||
commit: git(mirror, "rev-parse", "HEAD"),
|
||||
entrypoint: source,
|
||||
pid: 123,
|
||||
port: 18789,
|
||||
}),
|
||||
proveGatewayStopped: () => {
|
||||
commands.calls.push("prove gateway stopped");
|
||||
return {
|
||||
runtimeStatus: "stopped",
|
||||
port: 18789,
|
||||
portStatus: "free",
|
||||
proofSource: "fixture",
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(output.actions).toMatchObject({
|
||||
gatewayBuild: true,
|
||||
gatewayRestart: true,
|
||||
gatewaySelfHeal: false,
|
||||
});
|
||||
expect(output.gatewayDeployment).toMatchObject({
|
||||
changed: true,
|
||||
entrypoint: source,
|
||||
previousEntrypoint: snapshot,
|
||||
});
|
||||
expect(output.gatewayRuntime).toMatchObject({ entrypoint: source, pid: 123 });
|
||||
expect(JSON.stringify(output)).not.toContain("not-serialized");
|
||||
expect(controlEntrypoint).toBe(source);
|
||||
const uid = process.getuid?.() ?? 501;
|
||||
expect(commands.calls).toEqual([
|
||||
`/bin/launchctl bootout gui/${uid}/ai.openclaw.gateway`,
|
||||
"prove gateway stopped",
|
||||
"pnpm build",
|
||||
`/bin/launchctl enable gui/${uid}/ai.openclaw.gateway`,
|
||||
`/bin/launchctl bootstrap gui/${uid} ${plistPath}`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("recovers a stopped snapshot when the source control build is missing", () => {
|
||||
const { root, mirror } = makeFixture();
|
||||
mkdirSync(path.join(mirror, "node_modules"));
|
||||
const commands = fakeCommands(mirror);
|
||||
const snapshot = path.join(root, "gateway-ancestor/dist/index.js");
|
||||
const source = path.join(mirror, "dist/index.js");
|
||||
const configPath = path.join(root, "openclaw.json");
|
||||
const plistPath = path.join(root, "ai.openclaw.gateway.plist");
|
||||
writeFileSync(configPath, "{}\n");
|
||||
writeFileSync(plistPath, "plist\n", { mode: 0o600 });
|
||||
let deployedEntrypoint = snapshot;
|
||||
let prepareCalled = false;
|
||||
|
||||
const output = maintainFixture(
|
||||
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
||||
{
|
||||
runCommand: commands.runCommand,
|
||||
prepareGatewaySuspension: () => {
|
||||
prepareCalled = true;
|
||||
throw new Error("must not execute an unavailable control build");
|
||||
},
|
||||
inspectGatewayDeployment: () => ({
|
||||
configPath,
|
||||
entrypoint: deployedEntrypoint,
|
||||
entrypointIndex: 1,
|
||||
executable: process.execPath,
|
||||
invocationPrefix: [deployedEntrypoint],
|
||||
label: "ai.openclaw.gateway",
|
||||
plistPath,
|
||||
port: 18789,
|
||||
runtime: process.execPath,
|
||||
}),
|
||||
proveGatewayStopped: () => ({
|
||||
runtimeStatus: "stopped",
|
||||
port: 18789,
|
||||
portStatus: "free",
|
||||
proofSource: "fixture",
|
||||
}),
|
||||
repointGatewayDeployment: (
|
||||
_checkout: string,
|
||||
deployment: { entrypoint: string; invocationPrefix: string[] },
|
||||
) => {
|
||||
deployedEntrypoint = source;
|
||||
return {
|
||||
changed: true,
|
||||
...deployment,
|
||||
entrypoint: source,
|
||||
invocationPrefix: [source],
|
||||
previousEntrypoint: deployment.entrypoint,
|
||||
};
|
||||
},
|
||||
verifyGatewayRuntime: () => ({
|
||||
commit: git(mirror, "rev-parse", "HEAD"),
|
||||
entrypoint: source,
|
||||
pid: 123,
|
||||
port: 18789,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(prepareCalled).toBe(false);
|
||||
expect(output.gatewayDeployment).toMatchObject({
|
||||
changed: true,
|
||||
entrypoint: source,
|
||||
previousEntrypoint: snapshot,
|
||||
});
|
||||
const uid = process.getuid?.() ?? 501;
|
||||
expect(commands.calls).toEqual([
|
||||
"pnpm install --frozen-lockfile",
|
||||
"pnpm build",
|
||||
`/bin/launchctl enable gui/${uid}/ai.openclaw.gateway`,
|
||||
`/bin/launchctl bootstrap gui/${uid} ${plistPath}`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("restores missing dependencies before probing a current build", () => {
|
||||
const { root, mirror } = makeFixture();
|
||||
writeBuild(mirror);
|
||||
@@ -866,7 +1254,9 @@ describe("openclaw live updater", () => {
|
||||
auditCalls += 1;
|
||||
return { entries: 1, errorCount: 0, warningCount: 0, errors: [], warnings: [] };
|
||||
},
|
||||
inspectGatewayDeployment: () => null,
|
||||
sleep() {},
|
||||
verifyGatewayRuntime: () => null,
|
||||
},
|
||||
),
|
||||
).toThrow("RPC unavailable");
|
||||
|
||||
Reference in New Issue
Block a user