Files
openclaw/.agents/skills/openclaw-live-updater/scripts/update-main.mjs
T
Peter Steinberger c70aee247e refactor(scripts): migrate JavaScript tools to TypeScript (#121005)
* refactor(scripts): migrate JavaScript tools to TypeScript

* fix(ci): keep changed-scope preflight zero-install

* fix(ci): preserve zero-install script owners

* fix(ci): complete script migration follow-through

* fix(release): keep stable closeout zero-install

* fix(scripts): preserve standalone execution boundaries

* fix(scripts): repair standalone loader boundaries

* fix(scripts): normalize gateway observation ids

* fix(scripts): keep Docker packager standalone

* test(scripts): preserve rebase cleanup helpers

* test(sessions): use tracked temp directory
2026-08-09 07:21:35 -07:00

3760 lines
120 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync, spawn, spawnSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import {
existsSync,
lstatSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import process from "node:process";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { detectChangedScope } from "../../../../scripts/ci-changed-scope.mjs";
import { isDirectRunUrl } from "../../../../scripts/lib/direct-run.mjs";
import {
BUILD_STAMP_FILE,
RUNTIME_POSTBUILD_STAMP_FILE,
} from "../../../../scripts/lib/local-build-metadata.mts";
import { runManagedCommand } from "../../../../scripts/lib/managed-child-process.mts";
import {
runNodeConfigFiles,
runNodeSourceRoots,
} from "../../../../scripts/run-node-watch-paths.mts";
import {
resolveBuildRequirement,
resolveRuntimePostBuildRequirement,
} from "../../../../scripts/run-node.mts";
const DEFAULT_CHECKOUT = "/Users/steipete/openclaw";
const DEFAULT_EXPECTED_ORIGIN = "openclaw/openclaw";
const FULL_SHA_RE = /^[0-9a-f]{40}$/u;
const GATEWAY_READINESS_ATTEMPTS = 7;
const GATEWAY_READINESS_RETRY_DELAY_MS = 5_000;
const GATEWAY_CLI_TIMEOUT_MS = 30_000;
const DEFAULT_LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS = 20;
const MAX_LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS = 300;
const LAUNCHD_TEARDOWN_MARGIN_MS = 15_000;
const GATEWAY_STOP_PROOF_RETRY_DELAY_MS = 250;
const GATEWAY_PROCESS_START_TIMEOUT_MS = 20_000;
const GATEWAY_PROCESS_START_RETRY_DELAY_MS = 250;
const GATEWAY_SUSPEND_TIMEOUT_MS = 10_000;
const LEAF_COMMAND_TIMEOUT_MS = 30_000;
const COMMAND_TIMEOUT_MS = Object.freeze({
dependencyInstall: 15 * 60_000,
gatewayBuild: 20 * 60_000,
gatewayProbe: GATEWAY_CLI_TIMEOUT_MS,
gatewayService: 60_000,
gitFetch: 5 * 60_000,
gitMerge: 2 * 60_000,
macAppRebuild: 30 * 60_000,
});
const GATEWAY_STARTUP_TRACE_ENV = "OPENCLAW_GATEWAY_STARTUP_TRACE";
const SYSTEM_LAUNCH_DAEMON_DIR = "/Library/LaunchDaemons";
const MAX_FAILURE_DIAGNOSTIC_DEPTH = 4;
const MAX_FAILURE_DIAGNOSTIC_MEMBERS = 8;
const SAFE_INVARIANT_DETAIL_KEYS = [
"elapsedMs",
"exitTimeoutSeconds",
"listenerClosed",
"lockRetained",
"manualRecoveryRequired",
"phase",
"processExited",
"processTreeState",
"serviceBootedOut",
"serviceState",
"timeoutMs",
];
// CLI diagnostics stay typed and bounded because child-process errors can
// retain argv, environment, and output that must never enter the JSON result.
const aggregateDiagnosticMembers = new WeakMap();
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;
/**
* @typedef {object} GatewayDeploymentRef
* @property {string} entrypoint
*/
/**
* The fields required when the updater invokes the managed Gateway CLI.
* LaunchAgent inspection returns a richer object, while focused probes may
* provide only this execution view.
*
* @typedef {object} GatewayCliDeploymentBase
* @property {string} configPath
* @property {string} entrypoint
* @property {string} executable
* @property {string[]} invocationPrefix
* @property {number} port
* @property {Record<string, string>} [serviceEnvironment]
* @property {string | null} [workingDirectory]
*/
/**
* @typedef {GatewayCliDeploymentBase & {
* envFilePath?: null,
* runtime?: string,
* wrapperPath?: null,
* }} DirectGatewayCliDeployment
*/
/**
* @typedef {GatewayCliDeploymentBase & {
* envFilePath: string,
* runtime: string,
* wrapperPath: string,
* }} WrappedGatewayCliDeployment
*/
/** @typedef {DirectGatewayCliDeployment | WrappedGatewayCliDeployment} GatewayCliDeployment */
/**
* The stable identity fields used to verify a LaunchAgent retarget.
* Repointing deliberately does not require the execution-only fields above.
*
* @typedef {object} GatewayRepointDeployment
* @property {string} configPath
* @property {string} entrypoint
* @property {string} label
* @property {number} port
*/
/**
* The updater's established test/API result keeps owner details extensible,
* while naming the fields every completed maintenance run exposes.
*
* @typedef {Record<string, unknown> & {
* actions: Record<string, unknown>,
* buildBefore: Record<string, unknown>,
* changedPaths?: string[],
* macTarget?: Record<string, unknown>,
* release?: () => void,
* }} UpdateResult
*/
class UpdateInvariantError extends Error {
constructor(code, message, details, options) {
super(message, options);
this.name = "UpdateInvariantError";
this.code = code;
this.details = details;
}
}
class UpdateCommandError extends Error {
constructor(operation, error) {
super(retainedErrorMessage(error), { cause: error });
this.name = "UpdateCommandError";
this.operation = operation;
const status = ownDataProperty(error, "status");
const signal = ownDataProperty(error, "signal");
if (Number.isInteger(status)) {
this.status = status;
}
if (typeof signal === "string" && /^SIG[A-Z0-9]+$/u.test(signal)) {
this.signal = signal;
}
}
}
/**
* Re-throw the original runtime value while exposing the Error contract to type-aware lint.
*
* @returns {never}
*/
function throwPreservingValue(value) {
throw /** @type {Error} */ (value);
}
function ownDataProperty(value, key) {
if ((typeof value !== "object" && typeof value !== "function") || value === null) {
return undefined;
}
try {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
return descriptor && "value" in descriptor ? descriptor.value : undefined;
} catch {
return undefined;
}
}
function failureMessage(error) {
if (error instanceof Error) {
if (error instanceof UpdateCommandError) {
return `${error.operation} failed`;
}
const status = ownDataProperty(error, "status");
const signal = ownDataProperty(error, "signal");
if (
Number.isInteger(status) ||
(typeof signal === "string" && /^SIG[A-Z0-9]+$/u.test(signal))
) {
return "external command failed";
}
const message = ownDataProperty(error, "message");
return typeof message === "string" ? message : error.name;
}
try {
return String(error);
} catch {
return "unknown updater failure";
}
}
function retainedErrorMessage(error) {
if (error instanceof Error) {
const message = ownDataProperty(error, "message");
return typeof message === "string" ? message : error.name;
}
try {
return String(error);
} catch {
return "unknown updater failure";
}
}
function aggregateErrorWithCause(members, message, cause) {
const error = new AggregateError(
members.map((member) => member.error),
message,
{ cause },
);
aggregateDiagnosticMembers.set(error, members);
return error;
}
function gatewayCliOperation(args) {
if (args[0] === "gateway" && args[1] === "call") {
if (args[2] === "gateway.suspend.prepare") {
return "gateway.suspend.prepare";
}
if (args[2] === "gateway.suspend.resume") {
return "gateway.suspend.resume";
}
return "gateway.call";
}
if (args[0] === "gateway" && args[1] === "status") {
return "gateway.status";
}
if (args[0] === "health") {
return "gateway.health";
}
return "gateway.cli";
}
async function runUpdateCommand(runCommand, operation, command, args, checkout, options) {
try {
return await runCommand(command, args, checkout, options);
} catch (error) {
if (
error instanceof UpdateInvariantError ||
error instanceof UpdateCommandError ||
error instanceof AggregateError
) {
throw error;
}
throw new UpdateCommandError(operation, error);
}
}
function formatInvariantDetails(details) {
const formatted = {};
for (const key of SAFE_INVARIANT_DETAIL_KEYS) {
const value = ownDataProperty(details, key);
if (typeof value === "boolean") {
formatted[key] = value;
} else if (
["elapsedMs", "exitTimeoutSeconds", "timeoutMs"].includes(key) &&
Number.isInteger(value)
) {
formatted[key] = value;
} else if (key === "phase" && typeof value === "string") {
formatted[key] = value;
} else if (
key === "processTreeState" &&
["indeterminate", "live", "terminated"].includes(value)
) {
formatted[key] = value;
} else if (key === "serviceState" && ["running", "stopped", "stopping"].includes(value)) {
formatted[key] = value;
}
}
return Object.keys(formatted).length > 0 ? formatted : undefined;
}
function formatCommandDiagnostic(error, operation) {
const diagnostic = { kind: "command", operation };
const status = ownDataProperty(error, "status");
const signal = ownDataProperty(error, "signal");
if (Number.isInteger(status)) {
diagnostic.status = status;
}
if (typeof signal === "string" && /^SIG[A-Z0-9]+$/u.test(signal)) {
diagnostic.signal = signal;
}
return diagnostic;
}
function formatFailureDiagnostic(error, state, depth = 0) {
if (depth >= MAX_FAILURE_DIAGNOSTIC_DEPTH) {
return { kind: "truncated", reason: "depth_limit" };
}
if ((typeof error === "object" || typeof error === "function") && error !== null) {
if (state.seen.has(error)) {
return { kind: "truncated", reason: "cycle" };
}
state.seen.add(error);
}
if (error instanceof UpdateInvariantError) {
const details = formatInvariantDetails(error.details);
const cause = ownDataProperty(error, "cause");
return {
kind: "invariant",
code: error.code,
...(details ? { details } : {}),
...(cause === undefined ? {} : { cause: formatFailureDiagnostic(cause, state, depth + 1) }),
};
}
if (error instanceof UpdateCommandError) {
return formatCommandDiagnostic(error, error.operation);
}
if (error instanceof AggregateError) {
const rawErrors = ownDataProperty(error, "errors");
const errors = Array.isArray(rawErrors) ? rawErrors : [];
const members =
aggregateDiagnosticMembers.get(error) ??
errors.map((memberError, index) => ({
role: index === 0 ? "primary" : "secondary",
error: memberError,
}));
const limitedMembers = members.slice(0, MAX_FAILURE_DIAGNOSTIC_MEMBERS);
const diagnostic = {
kind: "aggregate",
members: limitedMembers.map((member) => ({
role: member.role,
error: formatFailureDiagnostic(member.error, state, depth + 1),
})),
};
const cause = ownDataProperty(error, "cause");
const causeMember = members.findIndex((member) => member.error === cause);
if (causeMember >= 0 && causeMember < limitedMembers.length) {
diagnostic.causeMember = causeMember;
}
if (members.length > limitedMembers.length) {
diagnostic.omittedMembers = members.length - limitedMembers.length;
}
return diagnostic;
}
const status = ownDataProperty(error, "status");
const signal = ownDataProperty(error, "signal");
if (Number.isInteger(status) || (typeof signal === "string" && /^SIG[A-Z0-9]+$/u.test(signal))) {
return formatCommandDiagnostic(error, "external_command");
}
return { kind: error instanceof Error ? "error" : "thrown_value" };
}
export function formatUpdateFailure(error) {
const code = error instanceof UpdateInvariantError ? error.code : "update_failed";
const message = failureMessage(error);
return {
schemaVersion: 1,
ok: false,
error: {
code,
message,
diagnostics: formatFailureDiagnostic(error, { seen: new Set() }),
},
};
}
function findUpdateInvariantError(value, code) {
const pending = [value];
const seen = new Set();
while (pending.length > 0) {
const current = pending.shift();
if (!current || (typeof current !== "object" && typeof current !== "function")) {
continue;
}
if (seen.has(current)) {
continue;
}
seen.add(current);
if (current instanceof UpdateInvariantError && current.code === code) {
return current;
}
if (current instanceof AggregateError) {
pending.push(...current.errors);
}
if ("cause" in current) {
pending.push(current.cause);
}
}
return null;
}
function findUnsafeCommandCleanupFailure(value) {
const failure = findUpdateInvariantError(value, "command_cleanup_failed");
const unsafeTreeState = ["indeterminate", "live"].includes(failure?.details?.processTreeState);
const retainedByProcessGroup = Number.isSafeInteger(failure?.details?.processGroupId);
const retainedForManualRecovery = failure?.details?.manualRecoveryRequired === true;
return failure && unsafeTreeState && (retainedByProcessGroup || retainedForManualRecovery)
? failure
: null;
}
function boundedSyncOptions(options = {}, timeoutMs = LEAF_COMMAND_TIMEOUT_MS) {
return {
...options,
killSignal: "SIGKILL",
timeout: timeoutMs,
};
}
function git(checkout, args, options = {}) {
return execFileSync("git", ["-C", checkout, ...args], {
...boundedSyncOptions({}, options.timeoutMs),
encoding: options.encoding ?? "utf8",
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
});
}
function gitText(checkout, args) {
return git(checkout, args).trim();
}
function configValue(checkout, key, bool = false) {
try {
return gitText(checkout, ["config", ...(bool ? ["--bool"] : ["--get"]), key]);
} catch {
return "";
}
}
function githubSlug(remoteUrl) {
const match = remoteUrl.match(
/^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/]+\/[^/]+?)(?:\.git)?\/?$/iu,
);
return match?.[1]?.toLowerCase() ?? null;
}
function applicableUrlRewrite(checkout, remoteUrl) {
let output;
try {
output = gitText(checkout, ["config", "--get-regexp", "^url\\..*\\.insteadOf$"]);
} catch {
return null;
}
return (
output
.split("\n")
.map((line) => line.match(/^\S+\s+(.+)$/u)?.[1]?.trim())
.filter(Boolean)
.filter((prefix) => remoteUrl.startsWith(prefix))
.toSorted((left, right) => right.length - left.length)[0] ?? null
);
}
export function originMatches(remoteUrl) {
return githubSlug(remoteUrl) === DEFAULT_EXPECTED_ORIGIN;
}
function changedPathsBetween(checkout, beforeSha, afterSha) {
return git(checkout, ["diff", "--name-only", "-z", beforeSha, afterSha], {
encoding: "buffer",
})
.toString("utf8")
.split("\0")
.filter(Boolean);
}
function commitExists(checkout, sha) {
try {
git(checkout, ["cat-file", "-e", `${sha}^{commit}`]);
return true;
} catch {
return false;
}
}
function isAncestorCommit(checkout, ancestor, descendant = "HEAD") {
try {
git(checkout, ["merge-base", "--is-ancestor", ancestor, descendant]);
return true;
} catch {
return false;
}
}
export function classifyActions(
changedPaths,
{ buildProvenanceKnown, buildRequired, nodeModulesPresent },
) {
// CI skips generated protocol-only macOS jobs, but the live app embeds these Swift sources.
const generatedMacProtocolChanged = changedPaths.some((changedPath) =>
changedPath.startsWith("apps/shared/OpenClawKit/Sources/OpenClawProtocol/"),
);
const runMacos =
changedPaths.length > 0 &&
(detectChangedScope(changedPaths).runMacos || generatedMacProtocolChanged);
const macUiVerification =
runMacos &&
changedPaths.some((changedPath) =>
/^(?:apps\/macos\/Sources\/|apps\/shared\/OpenClawKit\/Sources\/|apps\/swabble\/Sources\/)/u.test(
changedPath,
),
);
const dependencyInputsChanged = changedPaths.some((changedPath) =>
DEPENDENCY_INPUT_RE.test(changedPath),
);
const dependencyInstall =
!nodeModulesPresent || (buildRequired && (dependencyInputsChanged || !buildProvenanceKnown));
return {
dependencyInstall,
gatewayBuild: buildRequired,
gatewayProbe: true,
gatewayRestart: buildRequired || dependencyInstall,
gatewaySelfHeal: false,
macAppRebuild: runMacos,
macUiVerification,
};
}
function readStampHead(checkout, stampFile) {
try {
const parsed = JSON.parse(readFileSync(path.join(checkout, "dist", stampFile), "utf8"));
return typeof parsed.head === "string" && FULL_SHA_RE.test(parsed.head.toLowerCase())
? parsed.head.toLowerCase()
: null;
} catch {
return null;
}
}
function canonicalBuildRequirements(checkout) {
const distRoot = path.join(checkout, "dist");
const fsImpl = { existsSync, readFileSync, readdirSync, statSync };
const deps = {
cwd: checkout,
env: process.env,
fs: fsImpl,
spawnSync: (command, args, options) => spawnSync(command, args, boundedSyncOptions(options)),
distRoot,
distEntry: path.join(distRoot, "entry.js"),
buildStampPath: path.join(distRoot, BUILD_STAMP_FILE),
runtimePostBuildStampPath: path.join(distRoot, RUNTIME_POSTBUILD_STAMP_FILE),
sourceRoots: runNodeSourceRoots.map((sourceRoot) => ({
name: sourceRoot,
path: path.join(checkout, sourceRoot),
})),
configFiles: runNodeConfigFiles.map((filePath) => path.join(checkout, filePath)),
};
return {
build: resolveBuildRequirement(deps),
runtimePostBuild: resolveRuntimePostBuildRequirement(deps),
};
}
function missingControlUiAssets(checkout) {
const root = path.join(checkout, "dist/control-ui");
const indexPath = path.join(root, "index.html");
let html;
try {
html = readFileSync(indexPath, "utf8");
} catch {
return ["index.html"];
}
const references = [...html.matchAll(/\b(?:href|src)=["']([^"']+)["']/giu)]
.map((match) => match[1].split(/[?#]/u, 1)[0])
.filter(
(reference) => reference && reference !== "/" && !/^(?:[a-z]+:|\/\/|#)/iu.test(reference),
)
.map((reference) => reference.replace(/^\.\//u, ""));
const missing = references.filter((reference) => {
const candidate = path.resolve(root, reference.replace(/^\//u, ""));
return (
!candidate.startsWith(`${root}${path.sep}`) ||
!statSync(candidate, { throwIfNoEntry: false })?.isFile()
);
});
const assetsDir = path.join(root, "assets");
let hasAssetPayload = false;
try {
hasAssetPayload = readdirSync(assetsDir, { withFileTypes: true }).some((entry) =>
entry.isFile(),
);
} catch {
// Report the missing payload below.
}
if (!hasAssetPayload) {
missing.push("assets/*");
}
return [...new Set(missing)].toSorted((left, right) =>
left < right ? -1 : left > right ? 1 : 0,
);
}
export function inspectBuildState(checkout, expectedSha) {
const buildInfoPath = path.join(checkout, "dist/build-info.json");
const uiPath = path.join(checkout, "dist/control-ui/index.html");
let commit = null;
try {
const parsed = JSON.parse(readFileSync(buildInfoPath, "utf8"));
commit = typeof parsed.commit === "string" ? parsed.commit.toLowerCase() : null;
if (!commit || !FULL_SHA_RE.test(commit)) {
commit = null;
}
} catch {
// Missing or invalid provenance is handled below.
}
const buildStampHead = readStampHead(checkout, BUILD_STAMP_FILE);
const runtimePostBuildStampHead = readStampHead(checkout, RUNTIME_POSTBUILD_STAMP_FILE);
const requirements = canonicalBuildRequirements(checkout);
const missingUiAssets = missingControlUiAssets(checkout);
const requiredFilesPresent =
existsSync(buildInfoPath) && existsSync(uiPath) && missingUiAssets.length === 0;
const current =
requiredFilesPresent &&
commit === expectedSha &&
buildStampHead === expectedSha &&
runtimePostBuildStampHead === expectedSha &&
!requirements.build.shouldBuild &&
!requirements.runtimePostBuild.shouldSync;
const missingCanonicalOutput =
!requiredFilesPresent ||
requirements.build.reason.startsWith("missing_") ||
requirements.runtimePostBuild.reason.startsWith("missing_");
return {
current,
state: current ? "current" : missingCanonicalOutput ? "missing" : commit ? "stale" : "invalid",
commit,
buildStampHead,
runtimePostBuildStampHead,
missingUiAssets,
requirements,
};
}
function verifyCheckout(checkout, { remote }) {
let resolvedCheckout;
try {
resolvedCheckout = realpathSync(checkout);
} catch {
throw new UpdateInvariantError("checkout_missing", `checkout does not exist: ${checkout}`);
}
const gitDir = path.join(resolvedCheckout, ".git");
const gitDirStat = lstatSync(gitDir, { throwIfNoEntry: false });
if (!gitDirStat?.isDirectory() || gitDirStat.isSymbolicLink()) {
throw new UpdateInvariantError(
"not_standalone_clone",
`checkout must contain its own .git directory: ${resolvedCheckout}`,
);
}
if (
realpathSync(gitText(resolvedCheckout, ["rev-parse", "--show-toplevel"])) !== resolvedCheckout
) {
throw new UpdateInvariantError(
"checkout_not_root",
"checkout path must be the repository root",
);
}
const commonDir = realpathSync(
path.resolve(resolvedCheckout, gitText(resolvedCheckout, ["rev-parse", "--git-common-dir"])),
);
if (commonDir !== realpathSync(gitDir)) {
throw new UpdateInvariantError("linked_worktree", "checkout uses a shared Git directory");
}
if (gitText(resolvedCheckout, ["rev-parse", "--is-shallow-repository"]) !== "false") {
throw new UpdateInvariantError("shallow_clone", "checkout must be a full clone");
}
if (configValue(resolvedCheckout, "core.sparseCheckout", true) === "true") {
throw new UpdateInvariantError("sparse_checkout", "checkout must not use sparse checkout");
}
if (
configValue(resolvedCheckout, `remote.${remote}.promisor`, true) === "true" ||
configValue(resolvedCheckout, "extensions.partialClone")
) {
throw new UpdateInvariantError("partial_clone", "checkout must not use partial clone filters");
}
if (existsSync(path.join(gitDir, "objects/info/alternates"))) {
throw new UpdateInvariantError("borrowed_objects", "checkout must own its Git objects");
}
const worktreeCount = gitText(resolvedCheckout, ["worktree", "list", "--porcelain"])
.split("\n")
.filter((line) => line.startsWith("worktree ")).length;
if (worktreeCount !== 1) {
throw new UpdateInvariantError(
"multiple_worktrees",
`checkout must own exactly one worktree; found ${worktreeCount}`,
);
}
const branch = gitText(resolvedCheckout, ["symbolic-ref", "--short", "HEAD"]);
if (branch !== "main") {
throw new UpdateInvariantError("wrong_branch", `checkout must be on main; found ${branch}`);
}
if (gitText(resolvedCheckout, ["status", "--porcelain=v1", "--untracked-files=all"])) {
throw new UpdateInvariantError("dirty_checkout", "checkout has tracked or untracked changes");
}
const remoteUrl = configValue(resolvedCheckout, `remote.${remote}.url`);
if (!originMatches(remoteUrl)) {
throw new UpdateInvariantError(
"unexpected_origin",
`${remote} points to ${remoteUrl}; expected ${DEFAULT_EXPECTED_ORIGIN}`,
);
}
const rewrite = applicableUrlRewrite(resolvedCheckout, remoteUrl);
if (rewrite) {
throw new UpdateInvariantError(
"rewritten_origin",
`${remote} URL is affected by a Git insteadOf rewrite for ${rewrite}`,
);
}
return {
checkout: resolvedCheckout,
branch,
headSha: gitText(resolvedCheckout, ["rev-parse", "HEAD"]),
remoteUrl,
};
}
async function updateMain({ checkout, remote }, runCommand, dependencies = {}) {
const before = verifyCheckout(checkout, { remote });
const fetchMain =
dependencies.fetchMain ??
((target, remoteName) =>
runUpdateCommand(
runCommand,
"git.fetch",
"git",
[
"-C",
target,
"fetch",
"--prune",
remoteName,
`refs/heads/main:refs/remotes/${remoteName}/main`,
],
target,
{
phase: "Git fetch",
serviceState: "running",
timeoutMs: COMMAND_TIMEOUT_MS.gitFetch,
},
));
await fetchMain(before.checkout, remote);
const afterFetch = verifyCheckout(before.checkout, { remote });
if (afterFetch.headSha !== before.headSha) {
throw new UpdateInvariantError(
"concurrent_head_change",
`HEAD changed during fetch: ${before.headSha} -> ${afterFetch.headSha}`,
);
}
const remoteSha = gitText(before.checkout, ["rev-parse", `${remote}/main`]);
await runUpdateCommand(
runCommand,
"git.merge",
"git",
["-C", before.checkout, "merge", "--ff-only", `${remote}/main`],
before.checkout,
{
phase: "Git fast-forward merge",
serviceState: "running",
timeoutMs: COMMAND_TIMEOUT_MS.gitMerge,
},
);
const after = verifyCheckout(before.checkout, { remote });
if (after.headSha !== remoteSha) {
throw new UpdateInvariantError(
"local_main_diverged",
`local main ${after.headSha} does not equal ${remote}/main ${remoteSha}`,
);
}
const updated = before.headSha !== after.headSha;
return {
checkout: before.checkout,
remote,
branch: after.branch,
beforeSha: before.headSha,
afterSha: after.headSha,
remoteSha,
updated,
changedPaths: updated
? changedPathsBetween(before.checkout, before.headSha, after.headSha)
: [],
};
}
function defaultLockPath(checkout) {
const key = createHash("sha256").update(path.resolve(checkout)).digest("hex").slice(0, 12);
return path.join(tmpdir(), `openclaw-live-updater-${key}.lock`);
}
function defaultStatePath(checkout) {
return path.join(realpathSync(checkout), ".git", "openclaw-live-updater-state.json");
}
function readMaintenanceState(statePath) {
if (!existsSync(statePath)) {
return {};
}
try {
const stat = lstatSync(statePath);
if (!stat.isFile() || stat.isSymbolicLink()) {
throw new Error("unsafe state file");
}
const state = JSON.parse(readFileSync(statePath, "utf8"));
return state && typeof state === "object" ? state : {};
} catch {
throw new UpdateInvariantError(
"invalid_state",
`maintenance state is unreadable: ${statePath}`,
);
}
}
function writeMaintenanceState(statePath, state) {
const directory = path.dirname(statePath);
const temporary = path.join(directory, `.openclaw-live-updater-${process.pid}-${randomUUID()}`);
writeFileSync(temporary, `${JSON.stringify(state)}\n`, { flag: "wx", mode: 0o600 });
try {
renameSync(temporary, statePath);
} finally {
rmSync(temporary, { force: true });
}
}
function processAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function processGroupAlive(processGroupId) {
if (
process.platform === "win32" ||
!Number.isSafeInteger(processGroupId) ||
processGroupId <= 1
) {
return false;
}
try {
process.kill(-processGroupId, 0);
return true;
} catch (error) {
return error?.code !== "ESRCH";
}
}
export function acquireMaintenanceLock(checkout, requestedPath) {
const lockPath = requestedPath ?? defaultLockPath(checkout);
let incompleteLockRetries = 0;
while (true) {
try {
mkdirSync(lockPath);
break;
} catch (error) {
if (error?.code !== "EEXIST") {
throw error;
}
let stat;
try {
stat = lstatSync(lockPath);
} catch (statError) {
if (statError?.code === "ENOENT") {
continue;
}
throw statError;
}
if (!stat.isDirectory() || stat.isSymbolicLink()) {
throw new UpdateInvariantError("unsafe_lock", `refusing unsafe lock path: ${lockPath}`);
}
let owner;
try {
owner = JSON.parse(readFileSync(path.join(lockPath, "owner.json"), "utf8"));
} catch {
// The mkdir winner publishes owner.json right after creating the lock
// dir, so readers can see ENOENT before the write and an empty/partial
// file during writeFileSync's open-truncate window. Both are
// creation-in-progress, not corruption; re-read within the bounded
// budget and only then declare the lock invalid.
if (incompleteLockRetries < 20) {
incompleteLockRetries += 1;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
continue;
}
throw new UpdateInvariantError("invalid_lock", `lock owner is unreadable: ${lockPath}`);
}
incompleteLockRetries = 0;
const ownerProcessAlive = Number.isInteger(owner.pid) && processAlive(owner.pid);
const blockedProcessGroupAlive =
Number.isInteger(owner.processGroupId) && processGroupAlive(owner.processGroupId);
const manualRecoveryRequired = owner.manualRecoveryRequired === true;
if (ownerProcessAlive || blockedProcessGroupAlive || manualRecoveryRequired) {
return { acquired: false, lockPath, owner };
}
const staleClaim = `${lockPath}.stale-${process.pid}-${randomUUID()}`;
try {
renameSync(lockPath, staleClaim);
} catch (renameError) {
if (renameError?.code === "ENOENT") {
continue;
}
throw renameError;
}
rmSync(staleClaim, { recursive: true });
}
}
const owner = {
pid: process.pid,
checkout: path.resolve(checkout),
startedAt: new Date().toISOString(),
};
writeFileSync(path.join(lockPath, "owner.json"), `${JSON.stringify(owner)}\n`, { flag: "wx" });
return {
acquired: true,
lockPath,
owner,
retainForCleanupFailure(details = {}) {
const processGroupId = details.processGroupId;
const manualRecoveryRequired = details.manualRecoveryRequired === true;
if (
(!Number.isSafeInteger(processGroupId) || processGroupId <= 1) &&
!manualRecoveryRequired
) {
throw new UpdateInvariantError(
"invalid_cleanup_lock",
"refusing to retain maintenance lock without a process group or manual recovery state",
);
}
const ownerPath = path.join(lockPath, "owner.json");
const current = JSON.parse(readFileSync(ownerPath, "utf8"));
if (current.pid !== process.pid) {
throw new UpdateInvariantError("lock_owner_changed", "maintenance lock ownership changed");
}
const retainedOwner = {
...current,
blockedAt: new Date().toISOString(),
reason: "command_cleanup_failed",
...(Number.isSafeInteger(processGroupId) ? { processGroupId } : {}),
...(manualRecoveryRequired ? { manualRecoveryRequired: true } : {}),
...(typeof details.phase === "string" ? { phase: details.phase } : {}),
...(typeof details.processTreeState === "string"
? { processTreeState: details.processTreeState }
: {}),
...(typeof details.serviceState === "string" ? { serviceState: details.serviceState } : {}),
};
writeFileSync(ownerPath, `${JSON.stringify(retainedOwner)}\n`, { mode: 0o600 });
return retainedOwner;
},
release() {
const current = JSON.parse(readFileSync(path.join(lockPath, "owner.json"), "utf8"));
if (current.pid !== process.pid) {
throw new UpdateInvariantError("lock_owner_changed", "maintenance lock ownership changed");
}
rmSync(lockPath, { recursive: true });
},
};
}
async function defaultRunCommand(command, args, checkout, options = {}) {
const phase = options.phase ?? "command";
const timeoutMs = options.timeoutMs ?? LEAF_COMMAND_TIMEOUT_MS;
const startedAt = Date.now();
try {
const managedRunner = options.runManagedCommand ?? runManagedCommand;
const status = await managedRunner({
bin: command,
args,
cwd: checkout,
requireProcessTreeExit: true,
stdio: ["ignore", 2, 2],
timeoutMs,
});
if (status !== 0) {
throw new Error(`${phase} command exited with status ${status}`);
}
} catch (error) {
if (error?.code === "EPROCESS_TREE_VERIFICATION_UNSUPPORTED") {
throw new UpdateInvariantError(
"unsupported_process_tree_verification",
`${phase} requires process-tree verification unavailable on this platform`,
{
phase,
serviceState: options.serviceState ?? "running",
},
);
}
if (error?.code === "EPROCESSGROUP_CLEANUP_FAILED") {
throw new UpdateInvariantError(
"command_cleanup_failed",
`${phase} process tree could not be cleaned up`,
{
elapsedMs: Date.now() - startedAt,
phase,
...(Number.isSafeInteger(error.processGroupId)
? { processGroupId: error.processGroupId }
: {}),
...(typeof error.processTreeState === "string"
? { processTreeState: error.processTreeState }
: {}),
...(error.manualRecoveryRequired === true ? { manualRecoveryRequired: true } : {}),
serviceState: options.serviceState ?? "running",
timeoutMs,
},
);
}
if (error?.code !== "ETIMEDOUT") {
throw error;
}
throw new UpdateInvariantError("command_timeout", `${phase} timed out after ${timeoutMs}ms`, {
elapsedMs: Date.now() - startedAt,
phase,
serviceState: options.serviceState ?? "running",
timeoutMs,
});
}
}
function readSnapshotMetadata(snapshotRoot) {
const gitDir = path.join(snapshotRoot, ".git");
const headPath = path.join(gitDir, "HEAD");
const configPath = path.join(gitDir, "config");
const gitDirStat = lstatSync(gitDir);
const headStat = lstatSync(headPath);
const configStat = lstatSync(configPath);
const owner = typeof process.getuid === "function" ? process.getuid() : null;
const isTrustedMetadataFile = (filePath, fileStat) =>
fileStat.isFile() &&
!fileStat.isSymbolicLink() &&
(owner === null || fileStat.uid === owner) &&
(fileStat.mode & 0o022) === 0 &&
realpathSync(filePath) === filePath;
if (
!gitDirStat.isDirectory() ||
gitDirStat.isSymbolicLink() ||
realpathSync(gitDir) !== gitDir ||
!isTrustedMetadataFile(headPath, headStat) ||
!isTrustedMetadataFile(configPath, configStat)
) {
return null;
}
const head = readFileSync(headPath, "utf8").trim().toLowerCase();
if (!FULL_SHA_RE.test(head)) {
return null;
}
let inOrigin = false;
let originUrl = null;
for (const rawLine of readFileSync(configPath, "utf8").split("\n")) {
const line = rawLine.trim();
if (line.startsWith("[") && line.endsWith("]")) {
inOrigin = /^\[remote\s+"origin"\]$/u.test(line);
continue;
}
if (!inOrigin) {
continue;
}
const urlMatch = line.match(/^url\s*=\s*(.+)$/u);
if (urlMatch) {
originUrl = urlMatch[1].trim().replace(/^"(.*)"$/u, "$1");
break;
}
}
return originUrl ? { head, originUrl } : null;
}
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 {
const metadata = readSnapshotMetadata(snapshotRoot);
const entrypointStat = lstatSync(entrypoint);
const owner = typeof process.getuid === "function" ? process.getuid() : null;
if (
realpathSync(snapshotRoot) !== snapshotRoot ||
realpathSync(entrypoint) !== entrypoint ||
!entrypointStat.isFile() ||
entrypointStat.isSymbolicLink() ||
(owner !== null && entrypointStat.uid !== owner) ||
(entrypointStat.mode & 0o022) !== 0 ||
!metadata ||
!originMatches(metadata.originUrl)
) {
return false;
}
const snapshotHead = metadata.head;
if (
!FULL_SHA_RE.test(snapshotHead) ||
snapshotName !== `gateway-${snapshotHead.slice(0, 7)}` ||
!commitExists(checkout, snapshotHead) ||
!isAncestorCommit(checkout, snapshotHead)
) {
return false;
}
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
);
}
export function resolveLaunchAgentExitTimeoutSeconds(value) {
if (value === 0 || (Number.isInteger(value) && value > MAX_LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS)) {
throw new UpdateInvariantError(
"gateway_launchagent_failed",
`managed Gateway LaunchAgent ExitTimeOut=${value} prevents bounded stopped proof`,
{ exitTimeoutSeconds: value },
);
}
return Number.isInteger(value) && value > 0 ? value : DEFAULT_LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS;
}
function isLaunchctlServiceMissing(result) {
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
return result.status !== 0 && /could not find service|no such process|not found/iu.test(output);
}
export function assertNoSystemLaunchDaemonOwnership(label, dependencies = {}) {
const run = dependencies.spawnSync ?? spawnSync;
const readDirectory = dependencies.readdirSync ?? readdirSync;
const serviceTarget = `system/${label}`;
const inspectLoadedService = () => {
const result = run(
"/bin/launchctl",
["print", serviceTarget],
boundedSyncOptions({ encoding: "utf8" }),
);
if (result.status === 0) {
throw new UpdateInvariantError(
"gateway_system_launchdaemon_conflict",
`System LaunchDaemon ${serviceTarget} already owns the managed Gateway label`,
);
}
if (!isLaunchctlServiceMissing(result)) {
throw new UpdateInvariantError(
"gateway_system_launchdaemon_unverifiable",
`could not verify system LaunchDaemon ownership for ${serviceTarget}`,
);
}
};
inspectLoadedService();
let entries;
try {
entries = readDirectory(SYSTEM_LAUNCH_DAEMON_DIR);
} catch (error) {
if (error?.code === "ENOENT") {
entries = [];
} else {
throw new UpdateInvariantError(
"gateway_system_launchdaemon_unverifiable",
`could not inspect ${SYSTEM_LAUNCH_DAEMON_DIR}: ${String(error)}`,
);
}
}
for (const entry of entries.filter((candidate) => candidate.endsWith(".plist")).toSorted()) {
const plistPath = path.join(SYSTEM_LAUNCH_DAEMON_DIR, entry);
const result = run(
"/usr/bin/plutil",
["-convert", "json", "-o", "-", "--", plistPath],
boundedSyncOptions({ encoding: "utf8" }),
);
if (result.status !== 0) {
throw new UpdateInvariantError(
"gateway_system_launchdaemon_unverifiable",
`could not inspect system LaunchDaemon plist ${plistPath}`,
);
}
let plist;
try {
plist = JSON.parse(String(result.stdout));
} catch {
throw new UpdateInvariantError(
"gateway_system_launchdaemon_unverifiable",
`could not inspect system LaunchDaemon plist ${plistPath}`,
);
}
if (plist?.Label === label) {
throw new UpdateInvariantError(
"gateway_system_launchdaemon_conflict",
`System LaunchDaemon plist ${plistPath} already owns the managed Gateway label`,
);
}
}
// Close the query-to-directory-snapshot race at the activation boundary.
inspectLoadedService();
}
function readManagedGatewayLaunchAgent(checkout) {
if (process.platform !== "darwin" || typeof process.getuid !== "function") {
throw new UpdateInvariantError(
"gateway_launchagent_unavailable",
"managed Gateway LaunchAgent inspection is only available on macOS",
);
}
const home = process.env.HOME;
if (!home) {
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],
boundedSyncOptions({ encoding: "utf8" }),
);
if (plistResult.status !== 0) {
throw new UpdateInvariantError(
"gateway_launchagent_failed",
`could not read the managed Gateway LaunchAgent: ${plistResult.stderr.trim()}`,
);
}
const plist = JSON.parse(plistResult.stdout);
const label = plist?.Label;
const programArguments = plist?.ProgramArguments;
const environmentVariables = plist?.EnvironmentVariables;
const workingDirectory =
typeof plist?.WorkingDirectory === "string" ? plist.WorkingDirectory : null;
const exitTimeoutSeconds = resolveLaunchAgentExitTimeoutSeconds(plist?.ExitTimeOut);
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) ||
!ownsGatewayEntrypoint ||
!isTrustedGeneratedEnvironmentWrapper(gatewayCommand) ||
!Number.isInteger(port) ||
port < 1 ||
port > 65_535
) {
throw new UpdateInvariantError(
"gateway_launchagent_failed",
"LaunchAgent does not describe this checkout's managed Gateway and port",
);
}
const configPath =
typeof plist?.EnvironmentVariables?.OPENCLAW_CONFIG_PATH === "string"
? plist.EnvironmentVariables.OPENCLAW_CONFIG_PATH
: path.join(gatewayCommand.stateDir, "openclaw.json");
return {
configPath,
entrypoint: gatewayEntrypoint,
entrypointIndex: gatewayCommand.entrypointIndex,
envFilePath: gatewayCommand.envFilePath,
executable: gatewayCommand.executable,
exitTimeoutSeconds,
invocationPrefix: gatewayCommand.invocationPrefix,
label,
plistPath,
port,
runtime: gatewayCommand.runtime,
serviceEnvironment,
stateDir: gatewayCommand.stateDir,
workingDirectory,
wrapperPath: gatewayCommand.wrapperPath,
};
}
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);
}
/**
* @param {string} checkout
* @param {GatewayRepointDeployment} deployment
* @param {(deployment: GatewayRepointDeployment, replacement: string) => void} replaceEntrypoint
* @param {(checkout: string) => GatewayRepointDeployment | null} [inspectDeployment]
* @returns {GatewayRepointDeployment & { changed: boolean, previousEntrypoint?: string }}
*/
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,
};
}
export function replaceLaunchAgentProgramArgument(programArguments, index, expected, replacement) {
if (!Array.isArray(programArguments) || programArguments[index] !== expected) {
throw new UpdateInvariantError(
"gateway_repoint_failed",
"managed Gateway LaunchAgent changed before its entrypoint could be replaced",
);
}
return programArguments.with(index, replacement);
}
function prepareLaunchAgentEntrypointReplacement(deployment, entrypoint, options = {}) {
const temporaryPath = `${deployment.plistPath}.openclaw-live-updater-${randomUUID()}`;
const originalContents = readFileSync(deployment.plistPath);
const originalDigest = createHash("sha256").update(originalContents).digest("hex");
const originalMode = statSync(deployment.plistPath).mode;
writeFileSync(temporaryPath, originalContents, {
flag: "wx",
mode: originalMode,
});
let installed = false;
let replacementDigest = null;
try {
const plistResult = spawnSync(
"/usr/bin/plutil",
["-convert", "json", "-o", "-", temporaryPath],
boundedSyncOptions({ encoding: "utf8" }),
);
if (plistResult.status !== 0) {
throw new UpdateInvariantError(
"gateway_repoint_failed",
`could not read the managed Gateway LaunchAgent: ${plistResult.stderr.trim()}`,
);
}
const programArguments = replaceLaunchAgentProgramArgument(
JSON.parse(plistResult.stdout)?.ProgramArguments,
deployment.entrypointIndex,
deployment.entrypoint,
entrypoint,
);
execFileSync(
"/usr/bin/plutil",
["-replace", "ProgramArguments", "-json", JSON.stringify(programArguments), temporaryPath],
boundedSyncOptions({ stdio: ["ignore", "ignore", "pipe"] }),
);
execFileSync("/usr/bin/plutil", ["-lint", temporaryPath], {
...boundedSyncOptions({ stdio: ["ignore", "ignore", "pipe"] }),
});
const validatedResult = spawnSync(
"/usr/bin/plutil",
["-convert", "json", "-o", "-", temporaryPath],
boundedSyncOptions({ encoding: "utf8" }),
);
if (
validatedResult.status !== 0 ||
JSON.parse(validatedResult.stdout)?.ProgramArguments?.[deployment.entrypointIndex] !==
entrypoint
) {
throw new UpdateInvariantError(
"gateway_repoint_failed",
"replacement LaunchAgent did not preserve the validated entrypoint",
);
}
replacementDigest = createHash("sha256").update(readFileSync(temporaryPath)).digest("hex");
const restore = () => {
if (!installed) {
return false;
}
const currentDigest = createHash("sha256")
.update(readFileSync(deployment.plistPath))
.digest("hex");
if (currentDigest !== replacementDigest) {
throw new UpdateInvariantError(
"gateway_repoint_restore_failed",
"managed Gateway LaunchAgent changed after replacement installation",
);
}
const rollbackPath = `${deployment.plistPath}.openclaw-live-updater-rollback-${randomUUID()}`;
try {
writeFileSync(rollbackPath, originalContents, {
flag: "wx",
mode: originalMode,
});
renameSync(rollbackPath, deployment.plistPath);
installed = false;
return true;
} finally {
rmSync(rollbackPath, { force: true });
}
};
return {
install() {
const assertOwnership =
options.assertNoSystemLaunchDaemonOwnership ?? assertNoSystemLaunchDaemonOwnership;
assertOwnership(deployment.label);
const currentDigest = createHash("sha256")
.update(readFileSync(deployment.plistPath))
.digest("hex");
if (currentDigest !== originalDigest) {
throw new UpdateInvariantError(
"gateway_repoint_failed",
"managed Gateway LaunchAgent changed after its replacement was prepared",
);
}
renameSync(temporaryPath, deployment.plistPath);
installed = true;
try {
assertOwnership(deployment.label);
} catch (ownershipError) {
try {
restore();
} catch (restoreError) {
throw aggregateErrorWithCause(
[
{ role: "primary", error: ownershipError },
{ role: "rollback", error: restoreError },
],
"System LaunchDaemon ownership changed during plist publication and the previous LaunchAgent could not be restored",
restoreError,
);
}
throw ownershipError;
}
},
restore,
discard() {
if (!installed) {
rmSync(temporaryPath, { force: true });
}
},
};
} catch (error) {
rmSync(temporaryPath, { force: true });
throw error;
}
}
function replaceLaunchAgentEntrypoint(deployment, entrypoint) {
const replacement = prepareLaunchAgentEntrypointReplacement(deployment, entrypoint);
try {
replacement.install();
} finally {
replacement.discard();
}
}
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}`],
boundedSyncOptions({ encoding: "utf8" }),
);
const pidMatch = launchctl.status === 0 ? launchctl.stdout.match(/\bpid = (\d+)\b/u) : null;
const pid = Number(pidMatch?.[1] ?? Number.NaN);
const loadedArguments = parseLaunchctlArguments(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"],
boundedSyncOptions({ encoding: "utf8" }),
);
const listenerPids = 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)
: [];
}
/**
* @param {string} checkout
* @param {string[]} args
* @param {GatewayCliDeployment | null | undefined} deployment
* @param {{ stderr?: "inherit" | "pipe", timeoutMs?: number }} [options]
* @returns {string}
*/
export function runBuiltGatewayCli(checkout, args, deployment, options = {}) {
const observedDeployment = deployment ?? readManagedGatewayLaunchAgent(checkout);
const sourceEntrypoint = path.join(checkout, "dist/index.js");
let managedDeployment = observedDeployment;
if (observedDeployment.entrypoint !== sourceEntrypoint) {
const currentHead = gitText(checkout, ["rev-parse", "HEAD"]);
managedDeployment = resolveGatewayControlDeployment(
checkout,
observedDeployment,
inspectBuildState(checkout, currentHead),
currentHead,
);
if (!managedDeployment) {
throw new UpdateInvariantError(
"gateway_snapshot_control_unavailable",
"refusing to execute a managed Gateway runtime snapshot without a trusted source control build",
);
}
}
const {
configPath,
entrypoint,
envFilePath,
executable,
invocationPrefix,
port,
runtime,
serviceEnvironment = {},
workingDirectory,
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"],
boundedSyncOptions({
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(effectiveConfigPath),
`.openclaw-live-updater-config-${randomUUID()}.json`,
);
writeFileSync(
overlayPath,
`${JSON.stringify({
$include: `./${path.basename(effectiveConfigPath)}`,
gateway: { mode: "local", port },
})}\n`,
{ flag: "wx", mode: 0o600 },
);
const envOverrides = [`OPENCLAW_CONFIG_PATH=${overlayPath}`, `OPENCLAW_GATEWAY_PORT=${port}`];
const env = Object.assign(baseEnv, {
OPENCLAW_CONFIG_PATH: overlayPath,
OPENCLAW_GATEWAY_PORT: String(port),
});
try {
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: workingDirectory ?? path.dirname(path.dirname(entrypoint)),
encoding: "utf8",
env,
killSignal: "SIGKILL",
stdio: ["ignore", "pipe", options.stderr ?? "inherit"],
timeout: options.timeoutMs ?? GATEWAY_CLI_TIMEOUT_MS,
});
} catch (error) {
throw new UpdateCommandError(gatewayCliOperation(args), error);
} finally {
rmSync(overlayPath, { force: true });
}
}
/**
* @param {string} checkout
* @param {string} method
* @param {Record<string, unknown>} params
* @param {GatewayCliDeployment | null | undefined} deployment
* @returns {string}
*/
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,
);
}
/**
* @param {string} checkout
* @param {(checkout: string, method: string, params: { requestId: string }, deployment: GatewayDeploymentRef | null) => string} [callGateway]
* @param {GatewayDeploymentRef | null} [deployment]
*/
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 }, deployment),
);
} catch (error) {
throw new UpdateInvariantError(
"gateway_suspend_prepare_failed",
"could not atomically prepare Gateway maintenance",
undefined,
{ cause: error },
);
}
if (result?.status === "ready" && typeof result.suspensionId === "string") {
return result;
}
if (result?.status === "busy" && Array.isArray(result.blockers)) {
return result;
}
throw new UpdateInvariantError(
"gateway_suspend_prepare_invalid",
`Gateway returned an invalid suspension result: ${JSON.stringify(result)}`,
);
}
function defaultResumeGatewaySuspension(checkout, suspensionId, deployment) {
runBuiltGatewayCall(checkout, "gateway.suspend.resume", { suspensionId }, deployment);
}
async function stopManagedGateway(runCommand, checkout, deployment) {
if (!deployment) {
await runUpdateCommand(
runCommand,
"gateway.stop",
process.execPath,
["dist/index.js", "gateway", "stop"],
checkout,
{
phase: "gateway stop",
serviceState: "stopping",
timeoutMs: COMMAND_TIMEOUT_MS.gatewayService,
},
);
return;
}
await runUpdateCommand(
runCommand,
"launchd.bootout",
"/bin/launchctl",
["bootout", `gui/${process.getuid()}/${deployment.label}`],
checkout,
{
phase: "Gateway LaunchAgent bootout",
serviceState: "stopping",
timeoutMs: COMMAND_TIMEOUT_MS.gatewayService,
},
);
}
function timestampAt(readTimeMs) {
const timeMs = readTimeMs();
return new Date(timeMs).toISOString();
}
function recordStoppedMilestones(timing, observation, now) {
const details = observation?.details ?? observation;
if (details?.processExited === true) {
recordGatewayTimestamp(timing, "processExitedAt", timestampAt(now));
}
if (details?.listenerClosed === true) {
recordGatewayTimestamp(timing, "listenerClosedAt", timestampAt(now));
}
}
async function stopManagedGatewayAndProve(
runCommand,
checkout,
deployment,
proveGatewayStopped,
sleep,
now = Date.now,
) {
const timing = {
bootoutStartedAt: timestampAt(now),
bootoutCompletedAt: null,
processExitedAt: null,
listenerClosedAt: null,
timestampSemantics: { bootoutStartedAt: "observed" },
};
let stopError;
try {
await stopManagedGateway(runCommand, checkout, deployment);
} catch (error) {
stopError = error;
} finally {
recordGatewayTimestamp(timing, "bootoutCompletedAt", timestampAt(now));
}
const exitTimeoutSeconds =
Number.isInteger(deployment?.exitTimeoutSeconds) && deployment.exitTimeoutSeconds > 0
? deployment.exitTimeoutSeconds
: DEFAULT_LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS;
// launchd may retain the job until ExitTimeOut elapses. Match the native
// restart owner by allowing that ceiling plus a bounded teardown margin.
const proofTimeoutMs = exitTimeoutSeconds * 1_000 + LAUNCHD_TEARDOWN_MARGIN_MS;
const proofAttempts = Math.ceil(proofTimeoutMs / GATEWAY_STOP_PROOF_RETRY_DELAY_MS) + 1;
let proofError;
for (let attempt = 0; attempt < proofAttempts; attempt += 1) {
try {
const proof = await proveGatewayStopped(checkout);
recordStoppedMilestones(timing, proof, now);
recordGatewayTimestamp(timing, "processExitedAt", timestampAt(now));
recordGatewayTimestamp(timing, "listenerClosedAt", timestampAt(now));
return { proof, timing };
} catch (error) {
proofError = error;
recordStoppedMilestones(timing, error, now);
if (attempt + 1 < proofAttempts) {
await sleep(GATEWAY_STOP_PROOF_RETRY_DELAY_MS);
}
}
}
if (!stopError) {
throw proofError;
}
throw aggregateErrorWithCause(
[
{ role: "primary", error: stopError },
{ role: "proof", error: proofError },
],
"Gateway stop command failed and native stopped proof did not converge",
proofError,
);
}
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) {
const { label, port } = readManagedGatewayLaunchAgent(checkout);
const launchctl = spawnSync(
"/bin/launchctl",
["print", `gui/${process.getuid()}/${label}`],
boundedSyncOptions({ encoding: "utf8" }),
);
const launchctlOutput = `${launchctl.stdout ?? ""}\n${launchctl.stderr ?? ""}`;
const serviceBootedOut =
launchctl.status !== 0 && /could not find service|service not found/iu.test(launchctlOutput);
const processExited =
serviceBootedOut || (launchctl.status === 0 && !/\bpid\s*=\s*\d+\b/iu.test(launchctlOutput));
const listeners = spawnSync(
"/usr/sbin/lsof",
["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"],
boundedSyncOptions({ encoding: "utf8" }),
);
const listenerClosed =
listeners.status === 1 && !listeners.stdout.trim() && !listeners.stderr.trim();
const details = { listenerClosed, processExited, serviceBootedOut };
if (!serviceBootedOut) {
throw new UpdateInvariantError(
"gateway_not_proven_stopped",
"managed Gateway LaunchAgent is still loaded or its bootout state is ambiguous",
details,
);
}
if (!listenerClosed) {
throw new UpdateInvariantError(
"gateway_not_proven_stopped",
`Gateway port ${port} is listening or could not be inspected conclusively`,
details,
);
}
return {
runtimeStatus: "stopped",
port,
portStatus: "free",
proofSource: "launchd",
...details,
};
}
function defaultProveGatewayStopped(checkout) {
if (process.platform === "darwin") {
return proveMacLaunchdGatewayStopped(checkout);
}
let result;
try {
result = JSON.parse(
execFileSync(process.execPath, ["dist/index.js", "gateway", "status", "--json"], {
cwd: checkout,
encoding: "utf8",
killSignal: "SIGKILL",
stdio: ["ignore", "pipe", "inherit"],
timeout: COMMAND_TIMEOUT_MS.gatewayProbe,
}),
);
} catch (error) {
const commandError =
error instanceof UpdateCommandError ? error : new UpdateCommandError("gateway.status", error);
throw new UpdateInvariantError(
"gateway_stopped_proof_failed",
"could not inspect the managed Gateway after suspension failed",
undefined,
{ cause: commandError },
);
}
const runtime = result?.service?.runtime;
const port = result?.port;
if (
runtime?.status !== "stopped" ||
runtime.pid != null ||
port?.status !== "free" ||
!Array.isArray(port.listeners) ||
port.listeners.length > 0 ||
result?.rpc?.ok === true
) {
throw new UpdateInvariantError(
"gateway_not_proven_stopped",
`managed Gateway is not conclusively stopped: ${JSON.stringify({
runtimeStatus: runtime?.status ?? null,
runtimePid: runtime?.pid ?? null,
portStatus: port?.status ?? null,
listenerCount: Array.isArray(port?.listeners) ? port.listeners.length : null,
rpcOk: result?.rpc?.ok ?? null,
})}`,
);
}
return {
runtimeStatus: runtime.status,
port: port.port ?? null,
portStatus: port.status,
};
}
function assertExactBuild(checkout, expectedSha) {
const state = inspectBuildState(checkout, expectedSha);
if (!state.current) {
throw new UpdateInvariantError(
"build_sha_mismatch",
`build output does not match ${expectedSha}; state=${state.state}`,
);
}
return state;
}
function isOriginalMacBundle(bundlePath, originalStat) {
try {
const currentStat = lstatSync(bundlePath);
return (
currentStat.isDirectory() &&
!currentStat.isSymbolicLink() &&
currentStat.dev === originalStat.dev &&
currentStat.ino === originalStat.ino
);
} catch {
return false;
}
}
async function runBuildWithPreservedMacApp(
runCommand,
checkout,
sleep = defaultSleep,
serviceState = "stopped",
) {
const appBundle = path.join(checkout, "dist/OpenClaw.app");
if (!existsSync(appBundle)) {
await runUpdateCommand(runCommand, "build", "pnpm", ["build"], checkout, {
phase: "Gateway build",
serviceState,
timeoutMs: COMMAND_TIMEOUT_MS.gatewayBuild,
});
return;
}
const appStat = lstatSync(appBundle);
if (!appStat.isDirectory() || appStat.isSymbolicLink()) {
throw new UpdateInvariantError(
"unsafe_mac_bundle",
`refusing to preserve unsafe Mac app bundle: ${appBundle}`,
);
}
const preservedBundle = path.join(
checkout,
".git",
`.openclaw-live-mac-${process.pid}-${randomUUID()}.app`,
);
renameSync(appBundle, preservedBundle);
let buildFailed = false;
let buildError;
try {
await runUpdateCommand(runCommand, "build", "pnpm", ["build"], checkout, {
phase: "Gateway build",
serviceState,
timeoutMs: COMMAND_TIMEOUT_MS.gatewayBuild,
});
} catch (error) {
buildFailed = true;
buildError = error;
}
// Restore outside `finally` so restoration failures retain precedence over build failures.
// Accept an external restore only when the original inode returns; replacements still fail closed.
for (let attempt = 0; attempt < 20; attempt += 1) {
if (existsSync(preservedBundle) || existsSync(appBundle)) {
break;
}
await sleep(100);
}
const alreadyRestored = isOriginalMacBundle(appBundle, appStat);
if (!alreadyRestored && existsSync(appBundle)) {
throw new UpdateInvariantError(
"mac_bundle_restore_conflict",
`build unexpectedly created ${appBundle}; preserved bundle remains at ${preservedBundle}`,
);
}
if (!alreadyRestored) {
mkdirSync(path.dirname(appBundle), { recursive: true });
try {
renameSync(preservedBundle, appBundle);
} catch (error) {
if (!isOriginalMacBundle(appBundle, appStat)) {
if (existsSync(appBundle)) {
throw new UpdateInvariantError(
"mac_bundle_restore_conflict",
`build unexpectedly created ${appBundle}; preserved bundle remains at ${preservedBundle}`,
);
}
if (existsSync(preservedBundle)) {
throw new UpdateInvariantError(
"mac_bundle_restore_failed",
`failed to restore Mac app bundle: ${String(error)}`,
);
}
throw new UpdateInvariantError(
"missing_preserved_mac_bundle",
`preserved Mac app bundle disappeared: ${preservedBundle}`,
);
}
}
}
if (!isOriginalMacBundle(appBundle, appStat)) {
throw new UpdateInvariantError(
"missing_preserved_mac_bundle",
`original Mac app bundle was not restored to ${appBundle}`,
);
}
if (existsSync(preservedBundle)) {
throw new UpdateInvariantError(
"mac_bundle_restore_conflict",
`original Mac app bundle exists at both ${appBundle} and ${preservedBundle}`,
);
}
if (buildFailed) {
throwPreservingValue(buildError);
}
}
async function restartGateway(
runCommand,
checkout,
expectedSha,
startedAtMs = Date.now(),
deployment = null,
bootstrap = false,
options = {},
) {
assertExactBuild(checkout, expectedSha);
if (!deployment) {
await runUpdateCommand(
runCommand,
"gateway.restart",
"pnpm",
["openclaw", "gateway", "restart"],
checkout,
{
phase: "Gateway restart",
serviceState: "stopped",
timeoutMs: COMMAND_TIMEOUT_MS.gatewayService,
},
);
return { processStartedAt: null, restartStartedAtMs: startedAtMs };
}
if (bootstrap) {
return {
...(await bootstrapManagedGateway(runCommand, checkout, deployment, {
...options,
startupTrace: true,
})),
restartStartedAtMs: startedAtMs,
};
}
const assertOwnership =
options.assertNoSystemLaunchDaemonOwnership ?? assertNoSystemLaunchDaemonOwnership;
await assertOwnership(deployment.label);
await runUpdateCommand(
runCommand,
"gateway.restart",
deployment.executable,
[...deployment.invocationPrefix, "gateway", "restart"],
path.dirname(path.dirname(deployment.entrypoint)),
{
phase: "managed Gateway restart",
serviceState: "stopped",
timeoutMs: COMMAND_TIMEOUT_MS.gatewayService,
},
);
return { processStartedAt: null, restartStartedAtMs: startedAtMs };
}
async function bootstrapManagedGateway(runCommand, checkout, deployment, options = {}) {
const plistStat = lstatSync(deployment.plistPath);
if (!isTrustedOwnedRegularFile(plistStat)) {
throw new UpdateInvariantError(
"gateway_launchagent_failed",
"managed Gateway LaunchAgent ownership or permissions changed before bootstrap",
);
}
const assertOwnership =
options.assertNoSystemLaunchDaemonOwnership ?? assertNoSystemLaunchDaemonOwnership;
await assertOwnership(deployment.label);
const domain = `gui/${process.getuid()}`;
const serviceTarget = `${domain}/${deployment.label}`;
const waitForProcess = options.waitForProcess ?? waitForManagedGatewayProcess;
const now = options.now ?? Date.now;
if (!options.startupTrace) {
await runUpdateCommand(
runCommand,
"launchd.enable",
"/bin/launchctl",
["enable", serviceTarget],
checkout,
{
phase: "Gateway LaunchAgent enable",
serviceState: "stopped",
timeoutMs: COMMAND_TIMEOUT_MS.gatewayService,
},
);
await runUpdateCommand(
runCommand,
"launchd.bootstrap",
"/bin/launchctl",
["bootstrap", domain, deployment.plistPath],
checkout,
{
phase: "Gateway LaunchAgent bootstrap",
serviceState: "stopped",
timeoutMs: COMMAND_TIMEOUT_MS.gatewayService,
},
);
await waitForProcess(deployment, options.sleep ?? defaultSleep);
return { processStartedAt: timestampAt(now) };
}
const readLaunchdEnvironment = options.readLaunchdEnvironment ?? readLaunchdEnvironmentVariable;
const armEnvironmentRestore = options.armEnvironmentRestore ?? armLaunchdEnvironmentRestore;
const previousTraceValue = await readLaunchdEnvironment(GATEWAY_STARTUP_TRACE_ENV);
const environmentRestore = armEnvironmentRestore(GATEWAY_STARTUP_TRACE_ENV, previousTraceValue);
let restartError;
let processStartedAt = null;
await runUpdateCommand(
runCommand,
"launchd.setenv",
"/bin/launchctl",
["setenv", GATEWAY_STARTUP_TRACE_ENV, "1"],
checkout,
{
phase: "Gateway startup trace enable",
serviceState: "stopped",
timeoutMs: COMMAND_TIMEOUT_MS.gatewayService,
},
);
try {
await runUpdateCommand(
runCommand,
"launchd.enable",
"/bin/launchctl",
["enable", serviceTarget],
checkout,
{
phase: "Gateway LaunchAgent enable",
serviceState: "stopped",
timeoutMs: COMMAND_TIMEOUT_MS.gatewayService,
},
);
await runUpdateCommand(
runCommand,
"launchd.bootstrap",
"/bin/launchctl",
["bootstrap", domain, deployment.plistPath],
checkout,
{
phase: "Gateway LaunchAgent bootstrap",
serviceState: "stopped",
timeoutMs: COMMAND_TIMEOUT_MS.gatewayService,
},
);
await waitForProcess(deployment, options.sleep ?? defaultSleep);
processStartedAt = timestampAt(now);
} catch (error) {
restartError = error;
}
try {
// The booted process already inherited the trace flag. Restore launchd's
// previous value immediately so later starts keep the host's normal config.
await runUpdateCommand(
runCommand,
previousTraceValue === null ? "launchd.unsetenv" : "launchd.setenv",
"/bin/launchctl",
previousTraceValue === null
? ["unsetenv", GATEWAY_STARTUP_TRACE_ENV]
: ["setenv", GATEWAY_STARTUP_TRACE_ENV, previousTraceValue],
checkout,
{
phase: "Gateway startup trace restore",
serviceState: "running",
timeoutMs: COMMAND_TIMEOUT_MS.gatewayService,
},
);
} catch (cleanupError) {
if (restartError) {
throw aggregateErrorWithCause(
[
{ role: "primary", error: restartError },
{ role: "cleanup", error: cleanupError },
],
"Gateway restart failed and the one-shot startup trace environment could not be cleared",
cleanupError,
);
}
throw cleanupError;
}
environmentRestore.disarm();
if (restartError) {
throwPreservingValue(restartError);
}
return { processStartedAt };
}
function armLaunchdEnvironmentRestore(name, previousValue) {
const markerPath = path.join(
tmpdir(),
`.openclaw-launchd-env-restore-${process.pid}-${randomUUID()}`,
);
writeFileSync(markerPath, "armed\n", { flag: "wx", mode: 0o600 });
const restoreScript = `
marker="$1"
parent_pid="$2"
name="$3"
mode="$4"
value="$5"
while [ -e "$marker" ] && kill -0 "$parent_pid" >/dev/null 2>&1; do
sleep 0.1
done
if [ ! -e "$marker" ]; then
exit 0
fi
if [ "$mode" = "set" ]; then
/bin/launchctl setenv "$name" "$value"
else
/bin/launchctl unsetenv "$name"
fi
/bin/rm -f "$marker"
`;
const child = spawn(
"/bin/sh",
[
"-c",
restoreScript,
"openclaw-launchd-env-restore",
markerPath,
String(process.pid),
name,
previousValue === null ? "unset" : "set",
previousValue ?? "",
],
{ detached: true, stdio: "ignore" },
);
child.unref();
return {
disarm() {
rmSync(markerPath, { force: true });
},
};
}
function readLaunchdEnvironmentVariable(name) {
const result = spawnSync(
"/bin/launchctl",
["getenv", name],
boundedSyncOptions({ encoding: "utf8" }),
);
if (result.error || result.status !== 0) {
throw new UpdateInvariantError(
"gateway_restart_failed",
`could not read launchd environment ${name}`,
);
}
// launchd normalizes `setenv NAME ""` to the same absent manager state as
// `unsetenv NAME`; both `getenv` and `print gui/$UID` omit the value.
const value = result.stdout.replace(/\r?\n$/u, "");
return value || null;
}
async function waitForManagedGatewayProcess(deployment, sleep = defaultSleep) {
const target = `gui/${process.getuid()}/${deployment.label}`;
const attempts =
Math.ceil(GATEWAY_PROCESS_START_TIMEOUT_MS / GATEWAY_PROCESS_START_RETRY_DELAY_MS) + 1;
for (let attempt = 0; attempt < attempts; attempt += 1) {
const result = spawnSync(
"/bin/launchctl",
["print", target],
boundedSyncOptions({ encoding: "utf8" }),
);
if (result.status === 0 && /\bpid\s*=\s*\d+\b/iu.test(result.stdout)) {
return;
}
if (attempt + 1 < attempts) {
await sleep(GATEWAY_PROCESS_START_RETRY_DELAY_MS);
}
}
throw new UpdateInvariantError(
"gateway_restart_failed",
"launchd registered the replacement Gateway but did not report a process",
);
}
function isManagedGatewayLoaded(deployment) {
const result = spawnSync(
"/bin/launchctl",
["print", `gui/${process.getuid()}/${deployment.label}`],
boundedSyncOptions({ encoding: "utf8" }),
);
return result.status === 0;
}
async function waitForManagedGatewayReadiness(
deployment,
probeMilestones = probeGatewayMilestones,
sleep = defaultSleep,
) {
for (let attempt = 1; attempt <= GATEWAY_READINESS_ATTEMPTS; attempt += 1) {
if ((await Promise.resolve(probeMilestones(deployment)))?.readyzReady) {
return;
}
if (attempt < GATEWAY_READINESS_ATTEMPTS) {
await sleep(GATEWAY_READINESS_RETRY_DELAY_MS);
}
}
throw new UpdateInvariantError(
"gateway_recovery_failed",
"the previous managed Gateway did not become ready after rollback",
);
}
export function isGatewayProbeResponse(route, payload) {
return route === "/readyz"
? payload?.ready === true
: payload?.ok === true && payload.status === "live";
}
function probeGatewayHttp(port, route) {
for (const scheme of ["http", "https"]) {
const result = spawnSync(
"/usr/bin/curl",
[
"--silent",
"--show-error",
"--fail",
"--insecure",
"--max-time",
"1",
`${scheme}://127.0.0.1:${port}${route}`,
],
boundedSyncOptions({ encoding: "utf8" }, 5_000),
);
if (result.status !== 0) {
continue;
}
try {
const payload = JSON.parse(result.stdout);
if (isGatewayProbeResponse(route, payload)) {
return true;
}
} catch {
// Try the alternate loopback protocol.
}
}
return false;
}
function probeGatewayMilestones(deployment) {
const listeners = spawnSync(
"/usr/sbin/lsof",
["-nP", `-iTCP:${deployment.port}`, "-sTCP:LISTEN", "-t"],
boundedSyncOptions({ encoding: "utf8" }),
);
const listenerReady = listeners.status === 0 && Boolean(listeners.stdout.trim());
if (!listenerReady) {
return { listenerReady: false, healthzReady: false, readyzReady: false };
}
const healthzReady = probeGatewayHttp(deployment.port, "/healthz");
return {
listenerReady,
healthzReady,
readyzReady: healthzReady && probeGatewayHttp(deployment.port, "/readyz"),
};
}
function channelConnected(summary, channelId) {
const channel = summary?.channels?.[channelId];
if (!channel || typeof channel !== "object") {
return false;
}
if (channel.connected === true) {
return true;
}
return Object.values(channel.accounts ?? {}).some((account) => account?.connected === true);
}
function recordGatewayTimestamp(timing, key, at, semantics = "observed") {
if (timing[key]) {
return;
}
timing[key] = at;
timing.timestampSemantics ??= {};
timing.timestampSemantics[key] = semantics;
}
function markGatewayMilestones(timing, observation, observedAt, deepRpcUpperBoundAt = null) {
if (!observation) {
return;
}
if (observation.listenerReady) {
recordGatewayTimestamp(
timing,
"listenerReadyAt",
deepRpcUpperBoundAt ?? observedAt,
deepRpcUpperBoundAt ? "no-later-than" : "observed",
);
}
if (observation.healthzReady) {
recordGatewayTimestamp(
timing,
"healthzReadyAt",
deepRpcUpperBoundAt ?? observedAt,
deepRpcUpperBoundAt ? "no-later-than" : "observed",
);
}
if (observation.readyzReady) {
recordGatewayTimestamp(timing, "readyzReadyAt", observedAt);
}
}
async function verifyGatewayDeepRpc(runCommand, checkout, expectedSha, deployment, now) {
assertExactBuild(checkout, expectedSha);
if (deployment) {
runBuiltGatewayCli(
checkout,
["gateway", "status", "--deep", "--require-rpc", "--json"],
deployment,
);
} else {
await runUpdateCommand(
runCommand,
"gateway.status",
"pnpm",
["openclaw", "gateway", "status", "--deep", "--require-rpc", "--json"],
checkout,
{
phase: "Gateway deep status probe",
serviceState: "running",
timeoutMs: COMMAND_TIMEOUT_MS.gatewayProbe,
},
);
}
return timestampAt(now);
}
async function readGatewayHealth(runCommand, checkout, deployment) {
if (deployment) {
const healthOutput = runBuiltGatewayCli(
checkout,
["health", "--verbose", "--json"],
deployment,
);
let healthSummary;
try {
healthSummary = JSON.parse(healthOutput);
} catch (error) {
throw new UpdateInvariantError(
"gateway_health_invalid",
`Gateway health probe did not return JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}
return healthSummary;
}
await runUpdateCommand(
runCommand,
"gateway.health",
"pnpm",
["openclaw", "health", "--verbose", "--json"],
checkout,
{
phase: "Gateway health probe",
serviceState: "running",
timeoutMs: COMMAND_TIMEOUT_MS.gatewayProbe,
},
);
return null;
}
async function verifyGateway(runCommand, checkout, expectedSha, deployment = null, now = Date.now) {
const deepRpcReadyAt = await verifyGatewayDeepRpc(
runCommand,
checkout,
expectedSha,
deployment,
now,
);
return {
deepRpcReadyAt,
healthSummary: await readGatewayHealth(runCommand, checkout, deployment),
};
}
function defaultSleep(ms) {
return delay(ms);
}
/**
* @param {(command: string, args: string[], checkout: string, options?: Record<string, unknown>) => unknown | Promise<unknown>} runCommand
* @param {string} checkout
* @param {string} expectedSha
* @param {(ms: number) => void | Promise<void>} [sleep]
* @param {GatewayCliDeployment | null} [deployment]
* @param {{
* now?: () => number,
* probeMilestones?: (deployment: GatewayCliDeployment) => {
* listenerReady: boolean,
* healthzReady: boolean,
* readyzReady: boolean,
* },
* timing?: Record<string, unknown>,
* }} [options]
* @returns {Promise<Record<string, unknown>>}
*/
export async function verifyGatewayReadiness(
runCommand,
checkout,
expectedSha,
sleep = defaultSleep,
deployment = null,
options = {},
) {
const now = options.now ?? Date.now;
const probeMilestones = options.probeMilestones ?? probeGatewayMilestones;
const timing = options.timing ?? {
listenerReadyAt: null,
healthzReadyAt: null,
readyzReadyAt: null,
deepRpcReadyAt: null,
discordConnectedAt: null,
telegramConnectedAt: null,
timestampSemantics: {},
};
let lastError;
for (let attempt = 1; attempt <= GATEWAY_READINESS_ATTEMPTS; attempt += 1) {
try {
if (deployment) {
markGatewayMilestones(timing, await probeMilestones(deployment), timestampAt(now));
}
const deepRpcReadyAt = await verifyGatewayDeepRpc(
runCommand,
checkout,
expectedSha,
deployment,
now,
);
recordGatewayTimestamp(timing, "deepRpcReadyAt", deepRpcReadyAt);
if (deployment) {
markGatewayMilestones(
timing,
await probeMilestones(deployment),
timestampAt(now),
deepRpcReadyAt,
);
}
const healthSummary = await readGatewayHealth(runCommand, checkout, deployment);
if (channelConnected(healthSummary, "discord")) {
recordGatewayTimestamp(timing, "discordConnectedAt", timestampAt(now));
}
if (channelConnected(healthSummary, "telegram")) {
recordGatewayTimestamp(timing, "telegramConnectedAt", timestampAt(now));
}
return timing;
} catch (error) {
lastError = error;
if (attempt < GATEWAY_READINESS_ATTEMPTS) {
await sleep(GATEWAY_READINESS_RETRY_DELAY_MS);
}
}
}
throw lastError;
}
function summarizeGatewayLogEntry(entry) {
return {
time: entry.time,
level: entry.level,
subsystem: entry.subsystem ?? null,
message: String(entry.message ?? "").slice(0, 500),
};
}
function canonicalizeExistingPath(filePath) {
try {
return realpathSync(filePath);
} catch {
return path.resolve(filePath);
}
}
function isPathWithinRoot(sourcePath, rootPath) {
const normalizedRoot = canonicalizeExistingPath(rootPath);
const normalizedSource = canonicalizeExistingPath(sourcePath);
return (
normalizedSource === normalizedRoot ||
normalizedSource.startsWith(`${normalizedRoot}${path.sep}`)
);
}
function isCurrentGatewayLogSource(source, sourceRoot, managedSourceRoots) {
if (managedSourceRoots === null) {
return true;
}
if (!sourceRoot) {
return true;
}
if (typeof source !== "string" || source.length === 0) {
return true;
}
let sourcePath;
try {
sourcePath = source.startsWith("file:") ? fileURLToPath(source) : source;
} catch {
return true;
}
const sourceFilePath = sourcePath.replace(/:\d+(?::\d+)?$/u, "");
if (sourceFilePath !== sourcePath && !existsSync(sourcePath) && existsSync(sourceFilePath)) {
sourcePath = sourceFilePath;
}
if (
isPathWithinRoot(sourcePath, sourceRoot) ||
managedSourceRoots.some((rootPath) => isPathWithinRoot(sourcePath, rootPath))
) {
return true;
}
const normalizedRoot = canonicalizeExistingPath(sourceRoot);
const normalizedSource = canonicalizeExistingPath(sourcePath);
const checkoutRoot = path.dirname(normalizedRoot);
let candidate = path.dirname(normalizedSource);
while (candidate !== path.dirname(candidate)) {
const packagePath = path.join(candidate, "package.json");
const gitPath = path.join(candidate, ".git");
if (existsSync(packagePath) && existsSync(gitPath)) {
try {
if (JSON.parse(readFileSync(packagePath, "utf8")).name === "openclaw") {
return candidate === checkoutRoot;
}
} catch {
return true;
}
}
candidate = path.dirname(candidate);
}
return true;
}
function parseGatewayLogEntries(output, sinceMs) {
return output
.split("\n")
.filter(Boolean)
.flatMap((line) => {
try {
const raw = JSON.parse(line);
let sourceRecord = raw;
if (raw.type === "log" && typeof raw.raw === "string") {
try {
sourceRecord = JSON.parse(raw.raw);
} catch {
sourceRecord = raw;
}
}
const rawLevel = raw.type === "log" ? raw.level : raw._meta?.logLevelName;
const level = String(rawLevel ?? "").toLowerCase();
const time = raw.time ?? raw._meta?.date;
const timestamp = Date.parse(time ?? "");
if (!level || !Number.isFinite(timestamp) || timestamp < sinceMs) {
return [];
}
let subsystem = raw.subsystem ?? null;
if (!subsystem && typeof raw["0"] === "string") {
try {
subsystem = JSON.parse(raw["0"]).subsystem ?? null;
} catch {
subsystem = null;
}
}
return [
{
time,
level,
subsystem,
message: raw.message ?? raw["1"] ?? raw["0"] ?? "",
source: sourceRecord._meta?.path?.fullFilePath ?? null,
},
];
} catch {
return [];
}
});
}
function summarizeGatewayLogAudit(entries) {
const errors = entries
.filter((entry) => entry.level === "error" || entry.level === "fatal")
.map(summarizeGatewayLogEntry);
const warnings = entries.filter((entry) => entry.level === "warn").map(summarizeGatewayLogEntry);
const startupTrace = entries
.filter((entry) => String(entry.message ?? "").includes("startup trace:"))
.map(summarizeGatewayLogEntry)
.slice(0, 100);
return {
entries: entries.length,
errorCount: errors.length,
warningCount: warnings.length,
errors: errors.slice(0, 20),
warnings: warnings.slice(0, 20),
...(startupTrace.length > 0 ? { startupTrace } : {}),
};
}
/**
* @param {string} output
* @param {number} sinceMs
* @param {string | null} [sourceRoot]
* @param {string[] | null} [managedSourceRoots]
*/
export function parseGatewayLogAudit(output, sinceMs, sourceRoot = null, managedSourceRoots = []) {
const entries = parseGatewayLogEntries(output, sinceMs).filter((entry) =>
isCurrentGatewayLogSource(entry.source, sourceRoot, managedSourceRoots),
);
return summarizeGatewayLogAudit(entries);
}
function localDateKey(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function readFallbackGatewayLogs(sinceMs) {
const dates = new Set([localDateKey(new Date(sinceMs)), localDateKey(new Date())]);
const directories = new Set(["/tmp/openclaw", path.join(tmpdir(), "openclaw")]);
const contents = [];
for (const directory of directories) {
for (const date of dates) {
const logPath = path.join(directory, `openclaw-${date}.log`);
if (existsSync(logPath)) {
contents.push(readFileSync(logPath, "utf8"));
}
}
}
return contents.join("\n");
}
function readManagedPluginSourceRoots(checkout, deployment) {
let managedDeployment = deployment;
try {
managedDeployment ??= readManagedGatewayLaunchAgent(checkout);
} catch {
return null;
}
try {
const output = runBuiltGatewayCli(
checkout,
["plugins", "list", "--enabled", "--json"],
managedDeployment,
{ stderr: "pipe" },
);
return resolveManagedPluginSourceRoots(JSON.parse(output));
} catch {
return null;
}
}
export function resolveManagedPluginSourceRoots(report) {
if (!Array.isArray(report?.plugins)) {
return null;
}
const roots = [];
for (const plugin of report.plugins) {
if (typeof plugin?.rootDir !== "string" || plugin.rootDir.length === 0) {
return null;
}
roots.push(plugin.rootDir);
}
return roots;
}
/**
* @param {string} checkout
* @param {GatewayDeploymentRef | null | undefined} deployment
*/
export function resolveManagedGatewaySourceRoot(checkout, deployment) {
return typeof deployment?.entrypoint === "string" && deployment.entrypoint.length > 0
? path.dirname(path.resolve(deployment.entrypoint))
: path.join(realpathSync(checkout), "dist");
}
function defaultAuditGatewayLogs(checkout, sinceMs, deployment = null) {
let output;
try {
output = execFileSync(
process.execPath,
[
"openclaw.mjs",
"logs",
"--json",
"--limit",
"1000",
"--max-bytes",
"1000000",
"--timeout",
"10000",
],
boundedSyncOptions({
cwd: checkout,
encoding: "utf8",
maxBuffer: 4 * 1024 * 1024,
}),
);
} catch (error) {
output = readFallbackGatewayLogs(sinceMs);
if (!output) {
throw error;
}
}
const audit = parseGatewayLogAudit(
output,
sinceMs,
resolveManagedGatewaySourceRoot(checkout, deployment),
readManagedPluginSourceRoots(checkout, deployment),
);
if (audit.errorCount > 0) {
throw new UpdateInvariantError(
"gateway_restart_log_errors",
`Gateway emitted ${audit.errorCount} error/fatal log entries after restart: ${JSON.stringify(audit.errors.slice(0, 5))}`,
);
}
return audit;
}
async function verifyAndAuditGateway({
runCommand,
auditGatewayLogs,
checkout,
expectedSha,
deployment,
sinceMs,
sleep,
timing,
now,
probeMilestones,
}) {
let verificationError;
let gatewayTiming = timing;
try {
gatewayTiming = await verifyGatewayReadiness(
runCommand,
checkout,
expectedSha,
sleep,
deployment,
{
timing,
now,
probeMilestones,
},
);
} catch (error) {
verificationError = error;
}
const audit = await auditGatewayLogs(checkout, sinceMs, deployment);
if (verificationError) {
throwPreservingValue(verificationError);
}
return { audit, timing: gatewayTiming };
}
function finalizeGatewayTiming(timing) {
if (!timing) {
return null;
}
const deepRpcReadyMs = Date.parse(timing.deepRpcReadyAt ?? "");
const listenerClosedMs = Date.parse(timing.listenerClosedAt ?? "");
const processStartedMs = Date.parse(timing.processStartedAt ?? "");
// Both endpoints are observed after their underlying events. Their
// independent observation delays make these useful estimates, not bounds.
return {
...timing,
totalOutageMs:
Number.isFinite(deepRpcReadyMs) && Number.isFinite(listenerClosedMs)
? Math.max(0, deepRpcReadyMs - listenerClosedMs)
: null,
coldStartMs:
Number.isFinite(deepRpcReadyMs) && Number.isFinite(processStartedMs)
? Math.max(0, deepRpcReadyMs - processStartedMs)
: null,
durationSemantics: {
totalOutageMs: "observed-estimate",
coldStartMs: "observed-estimate",
},
};
}
export function findExactMacTarget(processes, executable) {
const target = processes
.split("\n")
.map((line) => line.trim().match(/^(\d+)\s+(.+)$/u))
.find((match) => match && (match[2] === executable || match[2].startsWith(`${executable} `)));
return target ? { executable, pid: Number(target[1]) } : null;
}
async function defaultVerifyMacTarget(checkout) {
await delay(10_000);
const executable = path.join(checkout, "dist/OpenClaw.app/Contents/MacOS/OpenClaw");
const processes = execFileSync(
"ps",
["axww", "-o", "pid=,command="],
boundedSyncOptions({ encoding: "utf8" }),
);
const target = findExactMacTarget(processes, executable);
if (!target) {
throw new UpdateInvariantError(
"mac_target_not_alive",
`exact target bundle exited after delayed verification: ${executable}`,
);
}
return target;
}
/**
* @overload
* @param {Record<string, unknown>} options
* @param {Record<string, unknown>} [dependencies]
* @returns {Promise<UpdateResult>}
*/
export async function maintainMain(options, dependencies = {}) {
const lock = acquireMaintenanceLock(options.checkout, options.lockPath);
if (!lock.acquired) {
return {
schemaVersion: 1,
ok: true,
skipped: true,
reason: "overlap",
lock: {
path: lock.lockPath,
ownerPid: lock.owner.pid,
startedAt: lock.owner.startedAt,
...(Number.isInteger(lock.owner.processGroupId)
? { processGroupId: lock.owner.processGroupId }
: {}),
...(lock.owner.manualRecoveryRequired === true ? { manualRecoveryRequired: true } : {}),
...(typeof lock.owner.processTreeState === "string"
? { processTreeState: lock.owner.processTreeState }
: {}),
...(typeof lock.owner.reason === "string" ? { reason: lock.owner.reason } : {}),
},
};
}
let preparedGatewayReplacement = null;
let retainMaintenanceLock = false;
try {
const verifiedBefore = verifyCheckout(options.checkout, { remote: options.remote });
const managedCommand = (command, args, checkout, commandOptions = {}) =>
defaultRunCommand(command, args, checkout, {
...commandOptions,
runManagedCommand: dependencies.runManagedCommand,
});
const runCommand = dependencies.runCommand ?? managedCommand;
const inspectGatewayDeployment =
dependencies.inspectGatewayDeployment ?? inspectManagedGatewayDeployment;
const repointGatewayDeployment =
dependencies.repointGatewayDeployment ?? repointManagedGatewayDeployment;
const replaceGatewayEntrypoint =
dependencies.replaceGatewayEntrypoint ?? replaceLaunchAgentEntrypoint;
const assertSystemOwnership =
dependencies.assertNoSystemLaunchDaemonOwnership ?? assertNoSystemLaunchDaemonOwnership;
const prepareGatewayEntrypointReplacement =
dependencies.prepareGatewayEntrypointReplacement ??
((deployment, entrypoint) =>
dependencies.replaceGatewayEntrypoint
? {
install: () => replaceGatewayEntrypoint(deployment, entrypoint),
discard() {},
}
: prepareLaunchAgentEntrypointReplacement(deployment, entrypoint, {
assertNoSystemLaunchDaemonOwnership: assertSystemOwnership,
}));
const verifyGatewayRuntime = dependencies.verifyGatewayRuntime ?? verifyManagedGatewayRuntime;
const verifyGatewayProbe = dependencies.verifyGateway ?? verifyGateway;
const verifyGatewayAfterRestart = dependencies.verifyAndAuditGateway ?? verifyAndAuditGateway;
const restartManagedGateway = dependencies.restartGateway ?? restartGateway;
const isGatewayLoaded = dependencies.isGatewayLoaded ?? isManagedGatewayLoaded;
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 now = dependencies.now ?? Date.now;
const probeMilestones = dependencies.probeGatewayMilestones ?? probeGatewayMilestones;
const waitForGatewayProcess =
dependencies.waitForGatewayProcess ?? waitForManagedGatewayProcess;
const readLaunchdEnvironment =
dependencies.readLaunchdEnvironment ?? readLaunchdEnvironmentVariable;
const armEnvironmentRestore =
dependencies.armEnvironmentRestore ?? armLaunchdEnvironmentRestore;
const gatewayDeploymentBefore = await inspectGatewayDeployment(verifiedBefore.checkout);
const sourceBuildBeforeUpdate = inspectBuildState(
verifiedBefore.checkout,
verifiedBefore.headSha,
);
let gatewayControlDeployment = resolveGatewayControlDeployment(
verifiedBefore.checkout,
gatewayDeploymentBefore,
sourceBuildBeforeUpdate,
verifiedBefore.headSha,
);
const update = await updateMain(options, managedCommand, dependencies);
const statePath = options.statePath ?? defaultStatePath(update.checkout);
const maintenanceState = readMaintenanceState(statePath);
const buildBefore = inspectBuildState(update.checkout, update.afterSha);
const buildRequired = update.updated || !buildBefore.current;
let buildChangedPaths = update.changedPaths;
const buildBaseExists =
Boolean(buildBefore.commit) && commitExists(update.checkout, buildBefore.commit);
if (
buildRequired &&
buildBefore.commit &&
buildBefore.commit !== update.afterSha &&
buildBaseExists
) {
buildChangedPaths = changedPathsBetween(update.checkout, buildBefore.commit, update.afterSha);
}
const actions = classifyActions(buildChangedPaths, {
buildProvenanceKnown: buildBefore.current || buildBaseExists,
buildRequired,
nodeModulesPresent: existsSync(path.join(update.checkout, "node_modules")),
});
if (maintenanceState.macPending) {
actions.macAppRebuild = true;
actions.macUiVerification ||= maintenanceState.macUiVerification === true;
}
const gatewayRuntimeRepointRequired =
gatewayDeploymentBefore !== null &&
gatewayDeploymentBefore.entrypoint !== path.join(update.checkout, "dist/index.js");
let gatewayLogAudit = null;
let gatewayDeployment = null;
let gatewayRuntime = null;
let gatewayTiming = null;
let queuedMacState = null;
if (actions.macAppRebuild) {
queuedMacState = {
macPending: true,
macUiVerification: actions.macUiVerification,
sinceSha: maintenanceState.sinceSha ?? update.afterSha,
attempts: Number(maintenanceState.attempts ?? 0),
queuedAt: maintenanceState.queuedAt ?? new Date().toISOString(),
};
writeMaintenanceState(statePath, queuedMacState);
}
if (actions.gatewayBuild || actions.dependencyInstall || gatewayRuntimeRepointRequired) {
actions.gatewayRestart = true;
let controlBuildPrepared = false;
let controlDependenciesInstalled = false;
let gatewayStoppedForMaintenance = false;
let gatewaySuspension;
const controlUnavailable =
gatewayDeploymentBefore !== null && gatewayControlDeployment === null;
if (controlUnavailable) {
try {
gatewaySuspension = {
status: "offline",
proof: await proveGatewayStopped(update.checkout),
};
} catch (proofError) {
try {
if (!gatewayRuntimeRepointRequired) {
throw new UpdateInvariantError(
"gateway_live_source_build_forbidden",
"refusing to rebuild the source entrypoint while its managed Gateway is still running",
);
}
// The running Gateway is isolated in its immutable snapshot, so a
// clean source build cannot mutate its code. Build only to obtain
// an exact trusted client for the suspension RPC.
if (actions.dependencyInstall) {
await runUpdateCommand(
runCommand,
"dependencies.install",
"pnpm",
["install", "--frozen-lockfile"],
update.checkout,
{
phase: "dependency install",
serviceState: "running",
timeoutMs: COMMAND_TIMEOUT_MS.dependencyInstall,
},
);
controlDependenciesInstalled = true;
}
if (!actions.gatewayBuild) {
throw new UpdateInvariantError(
"gateway_snapshot_control_unavailable",
"managed Gateway snapshot has no exact trusted source control build",
);
}
await runBuildWithPreservedMacApp(runCommand, update.checkout, sleep, "running");
assertExactBuild(update.checkout, update.afterSha);
controlBuildPrepared = true;
gatewayControlDeployment = resolveGatewayControlDeployment(
update.checkout,
gatewayDeploymentBefore,
inspectBuildState(update.checkout, update.afterSha),
update.afterSha,
);
if (!gatewayControlDeployment) {
throw new UpdateInvariantError(
"gateway_snapshot_control_unavailable",
"source build did not produce an exact trusted Gateway control client",
);
}
gatewaySuspension = await prepareSuspension(update.checkout, gatewayControlDeployment);
} catch (controlError) {
throw aggregateErrorWithCause(
[
{
role: "context",
error: new UpdateInvariantError(
"gateway_snapshot_control_unavailable",
"managed Gateway uses a snapshot but the source checkout has no exact trusted control build",
),
},
{ role: "proof", error: proofError },
{ role: "primary", error: controlError },
],
"Gateway control is unavailable and the managed Gateway could not be proven stopped",
controlError,
);
}
}
} else {
try {
gatewaySuspension = await prepareSuspension(update.checkout, gatewayControlDeployment);
} catch (prepareError) {
try {
gatewaySuspension = {
status: "offline",
proof: await proveGatewayStopped(update.checkout),
};
} catch (proofError) {
throw aggregateErrorWithCause(
[
{ role: "primary", error: prepareError },
{ role: "proof", error: proofError },
],
"Gateway suspension failed and the managed Gateway could not be proven stopped",
proofError,
);
}
}
}
if (gatewaySuspension.status === "busy") {
return {
schemaVersion: 1,
ok: true,
deferred: true,
reason: "gateway_active_work",
...update,
buildBefore,
buildChangedPaths,
actions,
gatewaySuspension,
};
}
gatewayStoppedForMaintenance = gatewaySuspension.status === "offline";
if (gatewaySuspension.status === "ready") {
// Native bootout prevents launchd from retaining old ProgramArguments
// and avoids source launchers that can rebuild stale dist before stopping.
try {
if (gatewayDeploymentBefore) {
await assertSystemOwnership(gatewayDeploymentBefore.label);
}
if (gatewayRuntimeRepointRequired) {
// Complete every fallible plist rewrite and validation while the
// current service is available; publication is one atomic rename.
preparedGatewayReplacement = prepareGatewayEntrypointReplacement(
gatewayDeploymentBefore,
path.join(update.checkout, "dist/index.js"),
);
}
// launchctl can return before the job and listener have disappeared.
// Retarget only after bounded native proof prevents cached snapshot revival.
const stopped = await stopManagedGatewayAndProve(
runCommand,
update.checkout,
gatewayDeploymentBefore,
proveGatewayStopped,
sleep,
now,
);
gatewayTiming = stopped.timing;
gatewayStoppedForMaintenance = true;
} catch (error) {
try {
await resumeSuspension(
update.checkout,
gatewaySuspension.suspensionId,
gatewayControlDeployment,
);
} catch (resumeError) {
throw aggregateErrorWithCause(
[
{ role: "primary", error },
{ role: "rollback", error: resumeError },
],
"Gateway stop failed and the prepared maintenance suspension could not be resumed",
resumeError,
);
}
throw error;
}
}
try {
if (actions.dependencyInstall && !controlDependenciesInstalled) {
await runUpdateCommand(
runCommand,
"dependencies.install",
"pnpm",
["install", "--frozen-lockfile"],
update.checkout,
{
phase: "dependency install",
serviceState: "stopped",
timeoutMs: COMMAND_TIMEOUT_MS.dependencyInstall,
},
);
}
if (actions.gatewayBuild && !controlBuildPrepared) {
await runBuildWithPreservedMacApp(runCommand, update.checkout, sleep);
}
assertExactBuild(update.checkout, update.afterSha);
const restartStartedAt = now();
if (gatewayDeploymentBefore) {
await assertSystemOwnership(gatewayDeploymentBefore.label);
}
gatewayDeployment = gatewayDeploymentBefore
? repointGatewayDeployment(
update.checkout,
gatewayDeploymentBefore,
(deployment, entrypoint) => {
if (preparedGatewayReplacement) {
preparedGatewayReplacement.install();
return;
}
replaceGatewayEntrypoint(deployment, entrypoint);
},
inspectGatewayDeployment,
)
: null;
gatewayTiming = {
bootoutStartedAt: null,
bootoutCompletedAt: null,
processExitedAt: null,
listenerClosedAt: null,
listenerReadyAt: null,
healthzReadyAt: null,
readyzReadyAt: null,
deepRpcReadyAt: null,
discordConnectedAt: null,
telegramConnectedAt: null,
...gatewayTiming,
};
const restart = await restartManagedGateway(
runCommand,
update.checkout,
update.afterSha,
restartStartedAt,
gatewayDeployment,
gatewayDeployment !== null,
{
now,
sleep,
waitForProcess: waitForGatewayProcess,
readLaunchdEnvironment,
armEnvironmentRestore,
assertNoSystemLaunchDaemonOwnership: assertSystemOwnership,
},
);
if (typeof restart?.processStartedAt === "string") {
recordGatewayTimestamp(gatewayTiming, "processStartedAt", restart.processStartedAt);
}
const verification = await verifyGatewayAfterRestart({
runCommand,
auditGatewayLogs,
checkout: update.checkout,
expectedSha: update.afterSha,
deployment: gatewayDeployment,
sinceMs: restartStartedAt,
sleep,
timing: gatewayTiming,
now,
probeMilestones,
});
gatewayLogAudit = verification?.audit ?? verification;
gatewayTiming = finalizeGatewayTiming(verification?.timing ?? gatewayTiming);
gatewayRuntime = await verifyGatewayRuntime(update.checkout, update.afterSha);
} catch (error) {
if (findUnsafeCommandCleanupFailure(error)) {
throwPreservingValue(error);
}
if (!gatewayStoppedForMaintenance || !gatewayDeploymentBefore) {
throw error;
}
try {
// A failed bootstrap may still have registered or started the
// replacement. Bootout is allowed to fail only when native proof
// independently confirms that no job or listener remains.
await stopManagedGatewayAndProve(
runCommand,
update.checkout,
gatewayDeploymentBefore,
proveGatewayStopped,
sleep,
now,
);
preparedGatewayReplacement?.restore?.();
await bootstrapManagedGateway(runCommand, update.checkout, gatewayDeploymentBefore, {
now,
sleep,
waitForProcess: waitForGatewayProcess,
assertNoSystemLaunchDaemonOwnership: assertSystemOwnership,
});
await waitForManagedGatewayReadiness(gatewayDeploymentBefore, probeMilestones, sleep);
} catch (recoveryError) {
throw aggregateErrorWithCause(
[
{ role: "primary", error },
{ role: "rollback", error: recoveryError },
],
"Gateway replacement failed and the previous managed service could not be restored",
recoveryError,
);
}
throw error;
}
} else {
try {
await verifyGatewayProbe(
runCommand,
update.checkout,
update.afterSha,
gatewayControlDeployment,
);
gatewayRuntime = await verifyGatewayRuntime(update.checkout, update.afterSha);
} catch {
actions.gatewayRestart = true;
actions.gatewaySelfHeal = true;
const bootstrap =
gatewayControlDeployment !== null && !(await isGatewayLoaded(gatewayControlDeployment));
const restartStartedAt = now();
const restart = await restartManagedGateway(
runCommand,
update.checkout,
update.afterSha,
restartStartedAt,
gatewayControlDeployment,
bootstrap,
{
now,
sleep,
waitForProcess: waitForGatewayProcess,
readLaunchdEnvironment,
armEnvironmentRestore,
assertNoSystemLaunchDaemonOwnership: assertSystemOwnership,
},
);
gatewayTiming = {
bootoutStartedAt: null,
bootoutCompletedAt: null,
processExitedAt: null,
listenerClosedAt: null,
processStartedAt: null,
listenerReadyAt: null,
healthzReadyAt: null,
readyzReadyAt: null,
deepRpcReadyAt: null,
discordConnectedAt: null,
telegramConnectedAt: null,
};
if (typeof restart?.processStartedAt === "string") {
recordGatewayTimestamp(gatewayTiming, "processStartedAt", restart.processStartedAt);
}
const verification = await verifyGatewayAfterRestart({
runCommand,
auditGatewayLogs,
checkout: update.checkout,
expectedSha: update.afterSha,
deployment: gatewayControlDeployment,
sinceMs: restartStartedAt,
sleep,
timing: gatewayTiming,
now,
probeMilestones,
});
gatewayLogAudit = verification?.audit ?? verification;
gatewayTiming = finalizeGatewayTiming(verification?.timing ?? gatewayTiming);
gatewayRuntime = await verifyGatewayRuntime(update.checkout, update.afterSha);
}
}
if (actions.macAppRebuild) {
const pendingState = {
...queuedMacState,
attempts: (queuedMacState?.attempts ?? 0) + 1,
lastAttemptAt: new Date().toISOString(),
};
writeMaintenanceState(statePath, pendingState);
try {
// The exact-SHA JS build above already produced dist/control-ui. Letting
// Mac packaging rebuild it can empty dist while the live app bundle is
// there, defeating the staged-swap guarantee.
await runUpdateCommand(
runCommand,
"mac.restart",
"env",
[
"SKIP_TSC=1",
"SKIP_UI_BUILD=1",
"bash",
"scripts/restart-mac.sh",
"--sign",
"--wait",
"--target-only",
],
update.checkout,
{
phase: "Mac app rebuild",
serviceState: "running",
timeoutMs: COMMAND_TIMEOUT_MS.macAppRebuild,
},
);
const macTarget = await verifyMacTarget(update.checkout);
await verifyGatewayProbe(
runCommand,
update.checkout,
update.afterSha,
gatewayDeployment ?? gatewayControlDeployment,
);
rmSync(statePath, { force: true });
maintenanceState.macTarget = macTarget;
} catch (error) {
writeMaintenanceState(statePath, {
...pendingState,
lastFailure: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
return {
schemaVersion: 1,
ok: true,
...update,
buildBefore,
buildChangedPaths,
actions,
...(gatewayDeployment
? {
gatewayDeployment: {
changed: gatewayDeployment.changed,
entrypoint: gatewayDeployment.entrypoint,
label: gatewayDeployment.label,
port: gatewayDeployment.port,
...(gatewayDeployment.previousEntrypoint
? { previousEntrypoint: gatewayDeployment.previousEntrypoint }
: {}),
},
}
: {}),
...(gatewayLogAudit ? { gatewayLogAudit } : {}),
...(gatewayTiming ? { gatewayTiming } : {}),
...(gatewayRuntime ? { gatewayRuntime } : {}),
...(maintenanceState.macTarget ? { macTarget: maintenanceState.macTarget } : {}),
};
} catch (error) {
const cleanupFailure = findUnsafeCommandCleanupFailure(error);
if (cleanupFailure) {
retainMaintenanceLock = true;
lock.retainForCleanupFailure(cleanupFailure.details);
cleanupFailure.details = {
...cleanupFailure.details,
lockPath: lock.lockPath,
lockRetained: true,
};
}
return throwPreservingValue(error);
} finally {
preparedGatewayReplacement?.discard();
if (!retainMaintenanceLock) {
lock.release();
}
}
}
function parseArgs(argv) {
const options = { checkout: DEFAULT_CHECKOUT, remote: "origin" };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--checkout") {
options.checkout = argv[++index];
} else if (arg === "--remote") {
options.remote = argv[++index];
} else if (arg === "--help" || arg === "-h") {
console.log("Usage: update-main.mjs [--checkout PATH] [--remote NAME]");
process.exit(0);
} else {
throw new UpdateInvariantError("invalid_argument", `unknown argument: ${arg}`);
}
}
if (!options.checkout || !options.remote) {
throw new UpdateInvariantError("invalid_argument", "option values must be non-empty");
}
return options;
}
export async function runLiveUpdaterMain(argv = process.argv.slice(2), dependencies = {}) {
try {
console.log(JSON.stringify(await maintainMain(parseArgs(argv), dependencies)));
} catch (error) {
console.log(JSON.stringify(formatUpdateFailure(error)));
process.exitCode = 1;
}
}
if (isDirectRunUrl(process.argv[1], import.meta.url)) {
void runLiveUpdaterMain();
}