mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 18:01:41 +00:00
* fix(updater): scope restart log audits * test(updater): declare log source argument * fix(updater): preserve configured plugin logs * fix(updater): normalize plugin log sources * fix(updater): reuse managed plugin config * fix(updater): resolve relative plugin roots * fix(updater): handle unset gateway workdir * fix(updater): audit effective plugin roots * fix(updater): bind logs to managed runtime * fix(updater): canonicalize log source roots
1905 lines
66 KiB
TypeScript
1905 lines
66 KiB
TypeScript
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
realpathSync,
|
|
renameSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { describe, expect, test } from "vitest";
|
|
import {
|
|
acquireMaintenanceLock,
|
|
classifyActions,
|
|
findExactMacTarget,
|
|
inspectBuildState,
|
|
isOwnedGatewayEntrypoint,
|
|
maintainMain,
|
|
originMatches,
|
|
parseGatewayLogAudit,
|
|
parseLaunchctlArguments,
|
|
prepareGatewaySuspension,
|
|
replaceLaunchAgentProgramArgument,
|
|
repointManagedGatewayDeployment,
|
|
resolveManagedGatewaySourceRoot,
|
|
resolveManagedPluginSourceRoots,
|
|
resolveManagedGatewayEntrypoint,
|
|
runBuiltGatewayCall,
|
|
verifyGatewayReadiness,
|
|
} from "../../.agents/skills/openclaw-live-updater/scripts/update-main.mjs";
|
|
import {
|
|
BUILD_STAMP_FILE,
|
|
RUNTIME_POSTBUILD_STAMP_FILE,
|
|
} from "../../scripts/lib/local-build-metadata.mjs";
|
|
import { listCoreRuntimePostBuildOutputs } from "../../scripts/runtime-postbuild.mjs";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const script = path.join(repoRoot, ".agents/skills/openclaw-live-updater/scripts/update-main.mjs");
|
|
const fixtureOrigins = new Map<string, string>();
|
|
|
|
function git(cwd: string, ...args: string[]) {
|
|
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
|
}
|
|
|
|
function fetchFixtureMain(checkout: string, remote: string) {
|
|
const origin = fixtureOrigins.get(checkout);
|
|
if (!origin) {
|
|
throw new Error(`missing fixture origin for ${checkout}`);
|
|
}
|
|
git(checkout, "fetch", origin, `main:refs/remotes/${remote}/main`);
|
|
}
|
|
|
|
function maintainFixture(
|
|
options: Record<string, unknown>,
|
|
dependencies: Record<string, unknown> = {},
|
|
) {
|
|
return maintainMain(options, {
|
|
fetchMain: fetchFixtureMain,
|
|
inspectGatewayDeployment: () => null,
|
|
verifyGatewayRuntime: () => null,
|
|
auditGatewayLogs: () => ({
|
|
entries: 0,
|
|
errorCount: 0,
|
|
warningCount: 0,
|
|
errors: [],
|
|
warnings: [],
|
|
}),
|
|
prepareGatewaySuspension: () => ({
|
|
status: "ready",
|
|
suspensionId: "fixture-suspension",
|
|
}),
|
|
proveGatewayStopped: () => ({
|
|
runtimeStatus: "stopped",
|
|
port: 18789,
|
|
portStatus: "free",
|
|
proofSource: "fixture",
|
|
}),
|
|
...dependencies,
|
|
});
|
|
}
|
|
|
|
function makeFixture() {
|
|
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "openclaw-live-updater-")));
|
|
const origin = path.join(root, "origin.git");
|
|
const seed = path.join(root, "seed");
|
|
const mirror = path.join(root, "mirror");
|
|
mkdirSync(seed);
|
|
git(root, "init", "--bare", origin);
|
|
git(seed, "init", "-b", "main");
|
|
git(seed, "config", "user.name", "Test");
|
|
git(seed, "config", "user.email", "test@example.com");
|
|
writeFileSync(path.join(seed, "README.md"), "one\n");
|
|
writeFileSync(path.join(seed, ".gitignore"), "dist/\nnode_modules/\n");
|
|
git(seed, "add", "README.md", ".gitignore");
|
|
git(seed, "commit", "-m", "initial");
|
|
git(seed, "remote", "add", "origin", origin);
|
|
git(seed, "push", "-u", "origin", "main");
|
|
git(root, "--git-dir", origin, "symbolic-ref", "HEAD", "refs/heads/main");
|
|
git(root, "clone", origin, mirror);
|
|
const canonicalOrigin = "https://github.com/openclaw/openclaw.git";
|
|
git(mirror, "remote", "set-url", "origin", canonicalOrigin);
|
|
fixtureOrigins.set(mirror, origin);
|
|
fixtureOrigins.set(realpathSync(mirror), origin);
|
|
return { root, mirror, origin, seed };
|
|
}
|
|
|
|
function writeBuild(mirror: string) {
|
|
mkdirSync(path.join(mirror, "dist/control-ui"), { recursive: true });
|
|
const head = git(mirror, "rev-parse", "HEAD");
|
|
writeFileSync(path.join(mirror, "dist/build-info.json"), `${JSON.stringify({ commit: head })}\n`);
|
|
writeFileSync(path.join(mirror, "dist/index.js"), "// built\n");
|
|
writeFileSync(path.join(mirror, "dist/entry.js"), "// built\n");
|
|
mkdirSync(path.join(mirror, "dist/control-ui/assets"), { recursive: true });
|
|
writeFileSync(
|
|
path.join(mirror, "dist/control-ui/index.html"),
|
|
'<script type="module" src="./assets/app.js"></script>\n',
|
|
);
|
|
writeFileSync(path.join(mirror, "dist/control-ui/assets/app.js"), "// ui\n");
|
|
writeFileSync(path.join(mirror, "dist", BUILD_STAMP_FILE), `${JSON.stringify({ head })}\n`);
|
|
writeFileSync(
|
|
path.join(mirror, "dist", RUNTIME_POSTBUILD_STAMP_FILE),
|
|
`${JSON.stringify({ head })}\n`,
|
|
);
|
|
for (const relativePath of listCoreRuntimePostBuildOutputs({ rootDir: mirror })) {
|
|
const outputPath = path.join(mirror, relativePath);
|
|
mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
writeFileSync(outputPath, "// runtime postbuild\n");
|
|
}
|
|
}
|
|
|
|
function fakeCommands(mirror: string) {
|
|
const calls: string[] = [];
|
|
return {
|
|
calls,
|
|
runCommand(command: string, args: string[]) {
|
|
calls.push([command, ...args].join(" "));
|
|
if (command === "pnpm" && args[0] === "install") {
|
|
mkdirSync(path.join(mirror, "node_modules"), { recursive: true });
|
|
}
|
|
if (command === "pnpm" && args[0] === "build") {
|
|
writeBuild(mirror);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("openclaw live updater", () => {
|
|
test("audits only error and warning logs emitted after Gateway restart", () => {
|
|
const output = [
|
|
{ type: "meta", file: "/tmp/openclaw.log" },
|
|
{ type: "log", time: "2026-07-11T08:00:00.000Z", level: "error", message: "old" },
|
|
{ type: "log", time: "2026-07-11T08:00:02.000Z", level: "info", message: "ready" },
|
|
{
|
|
type: "log",
|
|
time: "2026-07-11T08:00:03.000Z",
|
|
level: "warn",
|
|
subsystem: "gateway",
|
|
message: "degraded",
|
|
},
|
|
{
|
|
type: "log",
|
|
time: "2026-07-11T08:00:04.000Z",
|
|
level: "fatal",
|
|
subsystem: "gateway",
|
|
message: "failed",
|
|
},
|
|
{ type: "notice", message: "done" },
|
|
]
|
|
.map((entry) => JSON.stringify(entry))
|
|
.join("\n");
|
|
|
|
expect(parseGatewayLogAudit(output, Date.parse("2026-07-11T08:00:02.000Z"))).toEqual({
|
|
entries: 3,
|
|
errorCount: 1,
|
|
warningCount: 1,
|
|
errors: [
|
|
{
|
|
time: "2026-07-11T08:00:04.000Z",
|
|
level: "fatal",
|
|
subsystem: "gateway",
|
|
message: "failed",
|
|
},
|
|
],
|
|
warnings: [
|
|
{
|
|
time: "2026-07-11T08:00:03.000Z",
|
|
level: "warn",
|
|
subsystem: "gateway",
|
|
message: "degraded",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("parses the loaded launchd ProgramArguments block", () => {
|
|
expect(
|
|
parseLaunchctlArguments(`gui/501/ai.openclaw.gateway = {
|
|
\tprogram = /bin/sh
|
|
\targuments = {
|
|
\t\t/bin/sh
|
|
\t\t/Users/test/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh
|
|
\t\t/Users/test/.openclaw/service-env/ai.openclaw.gateway.env
|
|
\t\t/opt/homebrew/bin/node
|
|
\t\t/Users/test/openclaw/dist/index.js
|
|
\t\tgateway
|
|
\t\t--port
|
|
\t\t18789
|
|
\t}
|
|
\tpid = 123
|
|
}`),
|
|
).toEqual([
|
|
"/bin/sh",
|
|
"/Users/test/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh",
|
|
"/Users/test/.openclaw/service-env/ai.openclaw.gateway.env",
|
|
"/opt/homebrew/bin/node",
|
|
"/Users/test/openclaw/dist/index.js",
|
|
"gateway",
|
|
"--port",
|
|
"18789",
|
|
]);
|
|
});
|
|
|
|
test("audits raw file logs when RPC log retrieval is unavailable", () => {
|
|
const output = [
|
|
{
|
|
"0": '{"subsystem":"gateway"}',
|
|
"1": "startup warning",
|
|
time: "2026-07-11T08:00:03.000Z",
|
|
_meta: { date: "2026-07-11T08:00:03.000Z", logLevelName: "WARN" },
|
|
},
|
|
{
|
|
"0": '{"subsystem":"gateway"}',
|
|
"1": "startup failed",
|
|
time: "2026-07-11T08:00:04.000Z",
|
|
_meta: { date: "2026-07-11T08:00:04.000Z", logLevelName: "ERROR" },
|
|
},
|
|
]
|
|
.map((entry) => JSON.stringify(entry))
|
|
.join("\n");
|
|
|
|
expect(parseGatewayLogAudit(output, Date.parse("2026-07-11T08:00:02.000Z"))).toMatchObject({
|
|
entries: 2,
|
|
errorCount: 1,
|
|
warningCount: 1,
|
|
errors: [{ subsystem: "gateway", message: "startup failed" }],
|
|
warnings: [{ subsystem: "gateway", message: "startup warning" }],
|
|
});
|
|
});
|
|
|
|
test("ignores restart-window logs emitted by a foreign OpenClaw checkout", () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-log-attribution-"));
|
|
const sourceRoot = path.join(root, "managed/openclaw/dist");
|
|
const foreignRoot = path.join(root, "worktree/openclaw");
|
|
mkdirSync(path.join(foreignRoot, ".git"), { recursive: true });
|
|
writeFileSync(path.join(foreignRoot, "package.json"), '{"name":"openclaw"}\n');
|
|
const output = [
|
|
{
|
|
"0": '{"subsystem":"gateway"}',
|
|
"1": "managed warning",
|
|
time: "2026-07-11T08:00:03.000Z",
|
|
_meta: {
|
|
date: "2026-07-11T08:00:03.000Z",
|
|
logLevelName: "WARN",
|
|
path: { fullFilePath: `${sourceRoot}/subsystem-current.js` },
|
|
},
|
|
},
|
|
{
|
|
"0": "[tools] browser failed",
|
|
time: "2026-07-11T08:00:04.000Z",
|
|
_meta: {
|
|
date: "2026-07-11T08:00:04.000Z",
|
|
logLevelName: "ERROR",
|
|
path: {
|
|
fullFilePath: pathToFileURL(path.join(foreignRoot, "dist/console-foreign.js")).href,
|
|
},
|
|
},
|
|
},
|
|
]
|
|
.map((entry) => JSON.stringify(entry))
|
|
.join("\n");
|
|
|
|
expect(
|
|
parseGatewayLogAudit(output, Date.parse("2026-07-11T08:00:02.000Z"), sourceRoot),
|
|
).toEqual({
|
|
entries: 1,
|
|
errorCount: 0,
|
|
warningCount: 1,
|
|
errors: [],
|
|
warnings: [
|
|
{
|
|
time: "2026-07-11T08:00:03.000Z",
|
|
level: "warn",
|
|
subsystem: "gateway",
|
|
message: "managed warning",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("keeps managed restart logs when the deployment root is symlinked", () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-log-symlink-attribution-"));
|
|
const releaseRoot = path.join(root, "releases/abc");
|
|
const releaseDist = path.join(releaseRoot, "dist");
|
|
const linkedRoot = path.join(root, "current");
|
|
mkdirSync(releaseDist, { recursive: true });
|
|
mkdirSync(path.join(releaseRoot, ".git"));
|
|
writeFileSync(path.join(releaseRoot, "package.json"), '{"name":"openclaw"}\n');
|
|
const sourceFile = path.join(releaseDist, "console-managed.js");
|
|
writeFileSync(sourceFile, "export {};\n");
|
|
symlinkSync(releaseRoot, linkedRoot);
|
|
const output = JSON.stringify({
|
|
"0": "managed failure",
|
|
time: "2026-07-11T08:00:03.000Z",
|
|
_meta: {
|
|
date: "2026-07-11T08:00:03.000Z",
|
|
logLevelName: "ERROR",
|
|
path: { fullFilePath: sourceFile },
|
|
},
|
|
});
|
|
|
|
expect(
|
|
parseGatewayLogAudit(
|
|
output,
|
|
Date.parse("2026-07-11T08:00:02.000Z"),
|
|
path.join(linkedRoot, "dist"),
|
|
),
|
|
).toMatchObject({ entries: 1, errorCount: 1 });
|
|
});
|
|
|
|
test("scopes embedded RPC records without dropping unattributed errors", () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-rpc-log-attribution-"));
|
|
const sourceRoot = path.join(root, "managed/openclaw/dist");
|
|
const foreignRoot = path.join(root, "worktree/openclaw");
|
|
mkdirSync(path.join(foreignRoot, ".git"), { recursive: true });
|
|
writeFileSync(path.join(foreignRoot, "package.json"), '{"name":"openclaw"}\n');
|
|
const configuredPluginFile = path.join(foreignRoot, "configured-plugin.ts");
|
|
writeFileSync(configuredPluginFile, "export default {};\n");
|
|
const output = [
|
|
{
|
|
type: "log",
|
|
time: "2026-07-11T08:00:03.000Z",
|
|
level: "error",
|
|
message: "managed failure",
|
|
},
|
|
{
|
|
type: "log",
|
|
time: "2026-07-11T08:00:04.000Z",
|
|
level: "error",
|
|
message: "foreign failure",
|
|
raw: JSON.stringify({
|
|
"0": "foreign failure",
|
|
time: "2026-07-11T08:00:04.000Z",
|
|
_meta: {
|
|
date: "2026-07-11T08:00:04.000Z",
|
|
logLevelName: "ERROR",
|
|
path: {
|
|
fullFilePath: pathToFileURL(path.join(foreignRoot, "dist/console-foreign.js")).href,
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
type: "log",
|
|
time: "2026-07-11T08:00:05.000Z",
|
|
level: "error",
|
|
message: "installed plugin failure",
|
|
raw: JSON.stringify({
|
|
"0": "installed plugin failure",
|
|
time: "2026-07-11T08:00:05.000Z",
|
|
_meta: {
|
|
date: "2026-07-11T08:00:05.000Z",
|
|
logLevelName: "ERROR",
|
|
path: {
|
|
fullFilePath: path.join(root, "extensions/example/dist/logger.js"),
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
type: "log",
|
|
time: "2026-07-11T08:00:06.000Z",
|
|
level: "error",
|
|
message: "configured foreign-checkout plugin failure",
|
|
raw: JSON.stringify({
|
|
"0": "configured foreign-checkout plugin failure",
|
|
time: "2026-07-11T08:00:06.000Z",
|
|
_meta: {
|
|
date: "2026-07-11T08:00:06.000Z",
|
|
logLevelName: "ERROR",
|
|
path: {
|
|
fullFilePath: path.join(foreignRoot, "extensions/configured/dist/logger.js"),
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
type: "log",
|
|
time: "2026-07-11T08:00:07.000Z",
|
|
level: "error",
|
|
message: "configured standalone plugin failure",
|
|
raw: JSON.stringify({
|
|
"0": "configured standalone plugin failure",
|
|
time: "2026-07-11T08:00:07.000Z",
|
|
_meta: {
|
|
date: "2026-07-11T08:00:07.000Z",
|
|
logLevelName: "ERROR",
|
|
path: { fullFilePath: `${configuredPluginFile}:12:3` },
|
|
},
|
|
}),
|
|
},
|
|
]
|
|
.map((entry) => JSON.stringify(entry))
|
|
.join("\n");
|
|
|
|
expect(
|
|
parseGatewayLogAudit(output, Date.parse("2026-07-11T08:00:02.000Z"), sourceRoot, [
|
|
path.join(foreignRoot, "extensions/configured"),
|
|
configuredPluginFile,
|
|
]),
|
|
).toMatchObject({
|
|
entries: 4,
|
|
errorCount: 4,
|
|
errors: [
|
|
{ message: "managed failure" },
|
|
{ message: "installed plugin failure" },
|
|
{ message: "configured foreign-checkout plugin failure" },
|
|
{ message: "configured standalone plugin failure" },
|
|
],
|
|
});
|
|
|
|
expect(
|
|
parseGatewayLogAudit(output, Date.parse("2026-07-11T08:00:02.000Z"), sourceRoot, null),
|
|
).toMatchObject({ entries: 5, errorCount: 5 });
|
|
});
|
|
|
|
test("uses every enabled plugin root reported by managed discovery", () => {
|
|
expect(
|
|
resolveManagedPluginSourceRoots({
|
|
plugins: [
|
|
{ id: "configured", rootDir: "/opt/configured-plugin" },
|
|
{ id: "workspace", rootDir: "/srv/workspace/.openclaw/extensions/workspace" },
|
|
{ id: "global", rootDir: "/Users/test/.openclaw/extensions/global" },
|
|
],
|
|
}),
|
|
).toEqual([
|
|
"/opt/configured-plugin",
|
|
"/srv/workspace/.openclaw/extensions/workspace",
|
|
"/Users/test/.openclaw/extensions/global",
|
|
]);
|
|
expect(resolveManagedPluginSourceRoots({ plugins: [{ id: "unknown" }] })).toBeNull();
|
|
expect(resolveManagedPluginSourceRoots({})).toBeNull();
|
|
});
|
|
|
|
test("scopes restart logs to the effective managed runtime", () => {
|
|
expect(
|
|
resolveManagedGatewaySourceRoot("/srv/openclaw", {
|
|
entrypoint: "/srv/runtime/gateway-abc/dist/index.js",
|
|
}),
|
|
).toBe("/srv/runtime/gateway-abc/dist");
|
|
});
|
|
|
|
test("retries bounded Gateway readiness after restart", () => {
|
|
const { mirror } = makeFixture();
|
|
writeBuild(mirror);
|
|
const calls: string[] = [];
|
|
const delays: number[] = [];
|
|
let statusAttempts = 0;
|
|
|
|
verifyGatewayReadiness(
|
|
(command: string, args: string[]) => {
|
|
const call = [command, ...args].join(" ");
|
|
calls.push(call);
|
|
if (call.includes("gateway status") && ++statusAttempts < 3) {
|
|
throw new Error("RPC warming up");
|
|
}
|
|
},
|
|
mirror,
|
|
git(mirror, "rev-parse", "HEAD"),
|
|
(ms: number) => delays.push(ms),
|
|
);
|
|
|
|
expect(delays).toEqual([5_000, 5_000]);
|
|
expect(calls).toEqual([
|
|
"pnpm openclaw gateway status --deep --require-rpc --json",
|
|
"pnpm openclaw gateway status --deep --require-rpc --json",
|
|
"pnpm openclaw gateway status --deep --require-rpc --json",
|
|
"pnpm openclaw health --verbose --json",
|
|
]);
|
|
});
|
|
|
|
test("parses ready and busy atomic Gateway suspension responses", () => {
|
|
const deployment = { entrypoint: "/snapshot/dist/index.js" };
|
|
expect(
|
|
prepareGatewaySuspension(
|
|
"/checkout",
|
|
(
|
|
_checkout: string,
|
|
method: string,
|
|
params: { requestId: string },
|
|
selectedDeployment: unknown,
|
|
) => {
|
|
expect(method).toBe("gateway.suspend.prepare");
|
|
expect(params.requestId).toMatch(/^openclaw-live-updater-/u);
|
|
expect(selectedDeployment).toBe(deployment);
|
|
return JSON.stringify({ status: "ready", suspensionId: "suspension-1" });
|
|
},
|
|
deployment,
|
|
),
|
|
).toEqual({ status: "ready", suspensionId: "suspension-1" });
|
|
|
|
expect(
|
|
prepareGatewaySuspension("/checkout", () =>
|
|
JSON.stringify({
|
|
status: "busy",
|
|
reason: "active-work",
|
|
retryAfterMs: 20_000,
|
|
activeCount: 1,
|
|
blockers: [{ kind: "cron-run", count: 1, message: "busy" }],
|
|
}),
|
|
),
|
|
).toMatchObject({ status: "busy", activeCount: 1 });
|
|
});
|
|
|
|
test("pins managed Gateway calls with a backward-compatible local overlay", () => {
|
|
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "openclaw-gateway-call-")));
|
|
const checkout = path.join(root, "checkout");
|
|
const entrypoint = path.join(checkout, "dist/index.js");
|
|
const capture = path.join(root, "capture.mjs");
|
|
const configPath = path.join(root, "openclaw.json");
|
|
mkdirSync(path.dirname(entrypoint), { recursive: true });
|
|
writeFileSync(configPath, "{}\n");
|
|
writeFileSync(
|
|
capture,
|
|
'import fs from "node:fs"; console.log(JSON.stringify({ argv: process.argv.slice(2), config: JSON.parse(fs.readFileSync(process.env.OPENCLAW_CONFIG_PATH, "utf8")), hasToken: Boolean(process.env.OPENCLAW_GATEWAY_TOKEN), url: process.env.OPENCLAW_GATEWAY_URL ?? null }));\n',
|
|
);
|
|
|
|
const result = JSON.parse(
|
|
runBuiltGatewayCall(
|
|
checkout,
|
|
"gateway.suspend.prepare",
|
|
{ requestId: "request-1" },
|
|
{
|
|
configPath,
|
|
entrypoint,
|
|
executable: process.execPath,
|
|
invocationPrefix: [capture],
|
|
port: 19001,
|
|
serviceEnvironment: { OPENCLAW_GATEWAY_TOKEN: ["fixture", "value"].join("-") },
|
|
wrapperPath: null,
|
|
},
|
|
),
|
|
);
|
|
|
|
expect(result.config).toMatchObject({ gateway: { mode: "local", port: 19001 } });
|
|
expect(result.argv).not.toContain("--port");
|
|
expect(result.argv).not.toContain("--url");
|
|
expect(result.hasToken).toBe(true);
|
|
expect(result.url).toBeNull();
|
|
});
|
|
|
|
test("accepts supported OpenClaw GitHub origins", () => {
|
|
expect(originMatches("https://github.com/openclaw/openclaw.git")).toBe(true);
|
|
expect(originMatches("git@github.com:openclaw/openclaw.git")).toBe(true);
|
|
expect(originMatches("https://github.com/example/openclaw.git")).toBe(false);
|
|
});
|
|
|
|
test("accepts only immutable canonical runtime snapshots owned by the checkout", () => {
|
|
const { root, mirror, origin } = makeFixture();
|
|
const head = git(mirror, "rev-parse", "HEAD");
|
|
const home = path.join(root, "home");
|
|
const runtimeRoot = path.join(home, ".openclaw/runtime");
|
|
const snapshot = path.join(runtimeRoot, `gateway-${head.slice(0, 7)}`);
|
|
mkdirSync(runtimeRoot, { recursive: true });
|
|
git(runtimeRoot, "clone", origin, snapshot);
|
|
git(snapshot, "remote", "set-url", "origin", "https://github.com/openclaw/openclaw.git");
|
|
git(snapshot, "checkout", "--detach", head);
|
|
chmodSync(path.join(snapshot, ".git/HEAD"), 0o600);
|
|
chmodSync(path.join(snapshot, ".git/config"), 0o600);
|
|
writeBuild(snapshot);
|
|
const entrypoint = path.join(snapshot, "dist/index.js");
|
|
|
|
expect(isOwnedGatewayEntrypoint(mirror, home, entrypoint)).toBe(true);
|
|
expect(isOwnedGatewayEntrypoint(mirror, home, path.join(mirror, "dist/index.js"))).toBe(true);
|
|
|
|
const fsmonitorMarker = path.join(root, "fsmonitor-ran");
|
|
const fsmonitorHook = path.join(root, "fsmonitor.sh");
|
|
writeFileSync(fsmonitorHook, `#!/bin/sh\ntouch ${fsmonitorMarker}\n`);
|
|
chmodSync(fsmonitorHook, 0o755);
|
|
git(snapshot, "config", "core.fsmonitor", fsmonitorHook);
|
|
rmSync(fsmonitorMarker, { force: true });
|
|
expect(isOwnedGatewayEntrypoint(mirror, home, entrypoint)).toBe(true);
|
|
expect(existsSync(fsmonitorMarker)).toBe(false);
|
|
|
|
git(snapshot, "switch", "-c", "mutable");
|
|
expect(isOwnedGatewayEntrypoint(mirror, home, entrypoint)).toBe(false);
|
|
git(snapshot, "checkout", "--detach", head);
|
|
|
|
const filterMarker = path.join(root, "filter-ran");
|
|
const filterHook = path.join(root, "filter.sh");
|
|
writeFileSync(filterHook, `#!/bin/sh\ntouch ${filterMarker}\ncat\n`);
|
|
chmodSync(filterHook, 0o755);
|
|
git(snapshot, "config", "filter.untrusted.clean", filterHook);
|
|
mkdirSync(path.join(snapshot, ".git/info"), { recursive: true });
|
|
writeFileSync(path.join(snapshot, ".git/info/attributes"), "README.md filter=untrusted\n");
|
|
writeFileSync(path.join(snapshot, "README.md"), "dirty\n");
|
|
rmSync(filterMarker, { force: true });
|
|
expect(isOwnedGatewayEntrypoint(mirror, home, entrypoint)).toBe(true);
|
|
expect(existsSync(filterMarker)).toBe(false);
|
|
git(snapshot, "checkout", "--", "README.md");
|
|
writeFileSync(
|
|
path.join(snapshot, "dist", BUILD_STAMP_FILE),
|
|
`${JSON.stringify({ head: "0".repeat(40) })}\n`,
|
|
);
|
|
const snapshotExecutionMarker = path.join(root, "snapshot-executed");
|
|
writeFileSync(
|
|
entrypoint,
|
|
`import fs from "node:fs"; fs.writeFileSync(${JSON.stringify(snapshotExecutionMarker)}, "ran");\n`,
|
|
);
|
|
expect(isOwnedGatewayEntrypoint(mirror, home, entrypoint)).toBe(true);
|
|
|
|
writeBuild(mirror);
|
|
const sourceExecutionMarker = path.join(root, "source-executed");
|
|
const sourceEntrypoint = path.join(mirror, "dist/index.js");
|
|
writeFileSync(
|
|
sourceEntrypoint,
|
|
`import fs from "node:fs"; fs.writeFileSync(${JSON.stringify(sourceExecutionMarker)}, "ran"); console.log("{}");\n`,
|
|
);
|
|
const configPath = path.join(root, "openclaw.json");
|
|
writeFileSync(configPath, "{}\n");
|
|
expect(
|
|
runBuiltGatewayCall(
|
|
mirror,
|
|
"gateway.suspend.prepare",
|
|
{ requestId: "request-1" },
|
|
{
|
|
configPath,
|
|
entrypoint,
|
|
executable: process.execPath,
|
|
invocationPrefix: [entrypoint],
|
|
port: 19001,
|
|
serviceEnvironment: {},
|
|
wrapperPath: null,
|
|
},
|
|
),
|
|
).toContain("{}");
|
|
expect(existsSync(sourceExecutionMarker)).toBe(true);
|
|
expect(existsSync(snapshotExecutionMarker)).toBe(false);
|
|
});
|
|
|
|
test("accepts owned entrypoints only in supported LaunchAgent command layouts", () => {
|
|
const home = "/Users/test";
|
|
const entrypoint = "/Users/test/.openclaw/runtime/gateway-1234567/dist/index.js";
|
|
const wrapper = `${home}/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh`;
|
|
const envFile = `${home}/.openclaw/service-env/ai.openclaw.gateway.env`;
|
|
const nodeCommand = ["/opt/homebrew/bin/node", entrypoint, "gateway", "--port", "18789"];
|
|
|
|
expect(resolveManagedGatewayEntrypoint(nodeCommand, home)).toBe(entrypoint);
|
|
expect(
|
|
resolveManagedGatewayEntrypoint(["/opt/homebrew/bin/bun", entrypoint, "gateway"], home),
|
|
).toBe(entrypoint);
|
|
expect(
|
|
resolveManagedGatewayEntrypoint(["/bin/sh", wrapper, envFile, ...nodeCommand], home),
|
|
).toBe(entrypoint);
|
|
expect(resolveManagedGatewayEntrypoint([wrapper, envFile, ...nodeCommand], home)).toBe(
|
|
entrypoint,
|
|
);
|
|
const customState = "/Users/test/state";
|
|
const customWrapper = `${customState}/service-env/ai.openclaw.gateway-env-wrapper.sh`;
|
|
const customEnvFile = `${customState}/service-env/ai.openclaw.gateway.env`;
|
|
expect(
|
|
resolveManagedGatewayEntrypoint(
|
|
["/bin/sh", customWrapper, customEnvFile, ...nodeCommand],
|
|
home,
|
|
),
|
|
).toBe(entrypoint);
|
|
expect(
|
|
resolveManagedGatewayEntrypoint(["/usr/bin/python3", "/tmp/foreign.py", entrypoint], home),
|
|
).toBeNull();
|
|
expect(
|
|
resolveManagedGatewayEntrypoint(["/bin/sh", "/tmp/wrapper.sh", entrypoint, "gateway"], home),
|
|
).toBeNull();
|
|
});
|
|
|
|
test("retargets an accepted snapshot to the exact source build", () => {
|
|
const checkout = "/Users/test/openclaw";
|
|
const snapshot = "/Users/test/.openclaw/runtime/gateway-1234567/dist/index.js";
|
|
const source = path.join(checkout, "dist/index.js");
|
|
const replacements: string[] = [];
|
|
const deployment = {
|
|
configPath: "/Users/test/config/openclaw.json",
|
|
entrypoint: snapshot,
|
|
entrypointIndex: 1,
|
|
label: "ai.openclaw.gateway",
|
|
plistPath: "/Users/test/Library/LaunchAgents/ai.openclaw.gateway.plist",
|
|
port: 18789,
|
|
};
|
|
const result = repointManagedGatewayDeployment(
|
|
checkout,
|
|
deployment,
|
|
(_current, replacement: string) => replacements.push(replacement),
|
|
() => ({ ...deployment, entrypoint: source }),
|
|
);
|
|
|
|
expect(replacements).toEqual([source]);
|
|
expect(result).toMatchObject({
|
|
changed: true,
|
|
configPath: deployment.configPath,
|
|
entrypoint: source,
|
|
label: "ai.openclaw.gateway",
|
|
port: 18789,
|
|
previousEntrypoint: snapshot,
|
|
});
|
|
});
|
|
|
|
test("replaces a wrapped LaunchAgent entrypoint without inserting another argument", () => {
|
|
const snapshot = "/Users/test/.openclaw/runtime/gateway-1234567/dist/index.js";
|
|
const source = "/Users/test/openclaw/dist/index.js";
|
|
const original = [
|
|
"/bin/sh",
|
|
"/Users/test/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh",
|
|
"/Users/test/.openclaw/service-env/ai.openclaw.gateway.env",
|
|
"/opt/homebrew/bin/node",
|
|
snapshot,
|
|
"gateway",
|
|
"--port",
|
|
"18789",
|
|
];
|
|
|
|
expect(replaceLaunchAgentProgramArgument(original, 4, snapshot, source)).toEqual([
|
|
...original.slice(0, 4),
|
|
source,
|
|
...original.slice(5),
|
|
]);
|
|
expect(original).toHaveLength(8);
|
|
expect(original[4]).toBe(snapshot);
|
|
});
|
|
|
|
test("fails closed when Gateway service retargeting does not stick", () => {
|
|
const checkout = "/Users/test/openclaw";
|
|
const snapshot = "/Users/test/.openclaw/runtime/gateway-1234567/dist/index.js";
|
|
expect(() =>
|
|
repointManagedGatewayDeployment(
|
|
checkout,
|
|
{
|
|
configPath: "/Users/test/.openclaw/openclaw.json",
|
|
entrypoint: snapshot,
|
|
label: "ai.openclaw.gateway",
|
|
port: 18789,
|
|
},
|
|
() => {},
|
|
() => ({
|
|
configPath: "/Users/test/.openclaw/openclaw.json",
|
|
entrypoint: snapshot,
|
|
label: "ai.openclaw.gateway",
|
|
port: 18789,
|
|
}),
|
|
),
|
|
).toThrow(/not retargeted/u);
|
|
});
|
|
|
|
test("production fetch refreshes the remote-tracking main ref", () => {
|
|
const source = readFileSync(script, "utf8");
|
|
expect(source).toContain("refs/heads/main:refs/remotes/${remoteName}/main");
|
|
expect(source).not.toContain('["fetch", "--prune", remoteName, "main"]');
|
|
});
|
|
|
|
test("rejects Git URL rewrites that change the effective fetch source", () => {
|
|
const { mirror, origin } = makeFixture();
|
|
git(mirror, "config", `url.${origin}.insteadOf`, "https://github.com/openclaw/openclaw.git");
|
|
|
|
const result = spawnSync(process.execPath, [script, "--checkout", mirror], {
|
|
encoding: "utf8",
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
ok: false,
|
|
error: { code: "rewritten_origin" },
|
|
});
|
|
});
|
|
|
|
test("rejects a symlinked Git directory", () => {
|
|
const { root, mirror } = makeFixture();
|
|
const externalGitDir = path.join(root, "external-git-dir");
|
|
renameSync(path.join(mirror, ".git"), externalGitDir);
|
|
symlinkSync(externalGitDir, path.join(mirror, ".git"), "dir");
|
|
|
|
const result = spawnSync(process.execPath, [script, "--checkout", mirror], {
|
|
encoding: "utf8",
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
ok: false,
|
|
error: { code: "not_standalone_clone" },
|
|
});
|
|
});
|
|
|
|
test("classifies exact-head build, install, macOS rebuild, and native UI proof", () => {
|
|
expect(
|
|
classifyActions(["docs/index.md"], {
|
|
buildProvenanceKnown: true,
|
|
buildRequired: true,
|
|
nodeModulesPresent: true,
|
|
}),
|
|
).toEqual({
|
|
dependencyInstall: false,
|
|
gatewayBuild: true,
|
|
gatewayProbe: true,
|
|
gatewayRestart: true,
|
|
gatewaySelfHeal: false,
|
|
macAppRebuild: false,
|
|
macUiVerification: false,
|
|
});
|
|
expect(
|
|
classifyActions(["package.json", "apps/macos/Sources/OpenClaw/AppDelegate.swift"], {
|
|
buildProvenanceKnown: true,
|
|
buildRequired: true,
|
|
nodeModulesPresent: true,
|
|
}),
|
|
).toEqual({
|
|
dependencyInstall: true,
|
|
gatewayBuild: true,
|
|
gatewayProbe: true,
|
|
gatewayRestart: true,
|
|
gatewaySelfHeal: false,
|
|
macAppRebuild: true,
|
|
macUiVerification: true,
|
|
});
|
|
expect(
|
|
classifyActions(["apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift"], {
|
|
buildProvenanceKnown: true,
|
|
buildRequired: true,
|
|
nodeModulesPresent: true,
|
|
}),
|
|
).toEqual({
|
|
dependencyInstall: false,
|
|
gatewayBuild: true,
|
|
gatewayProbe: true,
|
|
gatewayRestart: true,
|
|
gatewaySelfHeal: false,
|
|
macAppRebuild: true,
|
|
macUiVerification: true,
|
|
});
|
|
});
|
|
|
|
test("accepts only the delayed exact target bundle process", () => {
|
|
const executable = "/Users/steipete/openclaw/dist/OpenClaw.app/Contents/MacOS/OpenClaw";
|
|
const foreign = "41 /tmp/agent/OpenClaw.app/Contents/MacOS/OpenClaw";
|
|
expect(findExactMacTarget(foreign, executable)).toBeNull();
|
|
expect(findExactMacTarget(`${foreign}\n42 ${executable} --attach-only`, executable)).toEqual({
|
|
executable,
|
|
pid: 42,
|
|
});
|
|
});
|
|
|
|
test("rejects missing or mismatched canonical build stamps", () => {
|
|
const { mirror } = makeFixture();
|
|
writeBuild(mirror);
|
|
const head = git(mirror, "rev-parse", "HEAD");
|
|
const buildStamp = path.join(mirror, "dist", BUILD_STAMP_FILE);
|
|
const runtimeStamp = path.join(mirror, "dist", RUNTIME_POSTBUILD_STAMP_FILE);
|
|
|
|
writeFileSync(buildStamp, `${JSON.stringify({ head: "0".repeat(40) })}\n`);
|
|
expect(inspectBuildState(mirror, head)).toMatchObject({
|
|
current: false,
|
|
buildStampHead: "0".repeat(40),
|
|
requirements: { build: { shouldBuild: true, reason: "git_head_changed" } },
|
|
});
|
|
|
|
writeFileSync(buildStamp, `${JSON.stringify({ head })}\n`);
|
|
rmSync(runtimeStamp);
|
|
expect(inspectBuildState(mirror, head)).toMatchObject({
|
|
current: false,
|
|
runtimePostBuildStampHead: null,
|
|
requirements: {
|
|
runtimePostBuild: { shouldSync: true, reason: "missing_runtime_postbuild_stamp" },
|
|
},
|
|
});
|
|
});
|
|
|
|
test("rejects a missing Control UI asset referenced by index", () => {
|
|
const { mirror } = makeFixture();
|
|
writeBuild(mirror);
|
|
const head = git(mirror, "rev-parse", "HEAD");
|
|
rmSync(path.join(mirror, "dist/control-ui/assets/app.js"));
|
|
|
|
expect(inspectBuildState(mirror, head)).toMatchObject({
|
|
current: false,
|
|
missingUiAssets: ["assets/*", "assets/app.js"],
|
|
});
|
|
});
|
|
|
|
test("fast-forwards, builds exact SHA, restarts Gateway, then proves exact Mac target", () => {
|
|
const { root, mirror, seed } = makeFixture();
|
|
mkdirSync(path.join(seed, "apps/macos/Sources/OpenClaw"), { recursive: true });
|
|
writeFileSync(path.join(seed, "apps/macos/Sources/OpenClaw/App.swift"), "// changed\n");
|
|
git(seed, "add", ".");
|
|
git(seed, "commit", "-m", "mac change");
|
|
git(seed, "push");
|
|
const commands = fakeCommands(mirror);
|
|
|
|
const output = maintainFixture(
|
|
{
|
|
checkout: mirror,
|
|
remote: "origin",
|
|
lockPath: path.join(root, "maintenance.lock"),
|
|
statePath: path.join(root, "maintenance-state.json"),
|
|
},
|
|
{
|
|
runCommand: commands.runCommand,
|
|
verifyMacTarget: () => ({
|
|
executable: path.join(mirror, "dist/OpenClaw.app/Contents/MacOS/OpenClaw"),
|
|
pid: 123,
|
|
}),
|
|
},
|
|
);
|
|
|
|
expect(output.updated).toBe(true);
|
|
expect(output.afterSha).toBe(git(seed, "rev-parse", "HEAD"));
|
|
expect(output.buildChangedPaths).toEqual(["apps/macos/Sources/OpenClaw/App.swift"]);
|
|
expect(output.actions).toEqual({
|
|
dependencyInstall: true,
|
|
gatewayBuild: true,
|
|
gatewayProbe: true,
|
|
gatewayRestart: true,
|
|
gatewaySelfHeal: false,
|
|
macAppRebuild: true,
|
|
macUiVerification: true,
|
|
});
|
|
expect(output.gatewayLogAudit).toEqual({
|
|
entries: 0,
|
|
errorCount: 0,
|
|
warningCount: 0,
|
|
errors: [],
|
|
warnings: [],
|
|
});
|
|
expect(commands.calls).toEqual([
|
|
`${process.execPath} dist/index.js gateway stop`,
|
|
"pnpm install --frozen-lockfile",
|
|
"pnpm build",
|
|
"pnpm openclaw gateway restart",
|
|
"pnpm openclaw gateway status --deep --require-rpc --json",
|
|
"pnpm openclaw health --verbose --json",
|
|
"env SKIP_TSC=1 SKIP_UI_BUILD=1 bash scripts/restart-mac.sh --sign --wait --target-only",
|
|
"pnpm openclaw gateway status --deep --require-rpc --json",
|
|
"pnpm openclaw health --verbose --json",
|
|
]);
|
|
expect(output.macTarget?.executable).toBe(
|
|
path.join(mirror, "dist/OpenClaw.app/Contents/MacOS/OpenClaw"),
|
|
);
|
|
});
|
|
|
|
test("rejects a local main that is ahead of origin main", () => {
|
|
const { root, mirror } = makeFixture();
|
|
git(mirror, "config", "user.name", "Test");
|
|
git(mirror, "config", "user.email", "test@example.com");
|
|
writeFileSync(path.join(mirror, "local-commit.txt"), "local\n");
|
|
git(mirror, "add", "local-commit.txt");
|
|
git(mirror, "commit", "-m", "local commit");
|
|
|
|
expect(() =>
|
|
maintainFixture({
|
|
checkout: mirror,
|
|
remote: "origin",
|
|
lockPath: path.join(root, "maintenance.lock"),
|
|
}),
|
|
).toThrow(/does not equal origin\/main/u);
|
|
});
|
|
|
|
test("builds and restarts when build output is missing without a new commit", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
const commands = fakeCommands(mirror);
|
|
|
|
const output = maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{ runCommand: commands.runCommand },
|
|
);
|
|
|
|
expect(output.updated).toBe(false);
|
|
expect(output.buildBefore.state).toBe("missing");
|
|
expect(output.actions.gatewayBuild).toBe(true);
|
|
expect(output.actions.dependencyInstall).toBe(true);
|
|
expect(commands.calls).toEqual([
|
|
`${process.execPath} dist/index.js gateway stop`,
|
|
"pnpm install --frozen-lockfile",
|
|
"pnpm build",
|
|
"pnpm openclaw gateway restart",
|
|
"pnpm openclaw gateway status --deep --require-rpc --json",
|
|
"pnpm openclaw health --verbose --json",
|
|
]);
|
|
});
|
|
|
|
test("defers a stale build without stopping Gateway when atomic suspension reports active work", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
const commands = fakeCommands(mirror);
|
|
|
|
const output = maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{
|
|
runCommand: commands.runCommand,
|
|
prepareGatewaySuspension: () => ({
|
|
status: "busy",
|
|
reason: "active-work",
|
|
retryAfterMs: 20_000,
|
|
activeCount: 1,
|
|
blockers: [{ kind: "cron-run", count: 1, message: "1 active cron run(s)" }],
|
|
}),
|
|
},
|
|
);
|
|
|
|
expect(output).toMatchObject({
|
|
ok: true,
|
|
deferred: true,
|
|
reason: "gateway_active_work",
|
|
gatewaySuspension: {
|
|
status: "busy",
|
|
activeCount: 1,
|
|
blockers: [{ kind: "cron-run", count: 1 }],
|
|
},
|
|
});
|
|
expect(commands.calls).toEqual([]);
|
|
expect(inspectBuildState(mirror, git(mirror, "rev-parse", "HEAD")).current).toBe(false);
|
|
});
|
|
|
|
test("accepts native stopped proof when the stop command reports an error", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
const commands = fakeCommands(mirror);
|
|
const resumed: string[] = [];
|
|
|
|
const output = maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{
|
|
runCommand(command: string, args: string[]) {
|
|
if (command === process.execPath && args.includes("stop")) {
|
|
throw new Error("stop failed after stopping");
|
|
}
|
|
commands.runCommand(command, args);
|
|
},
|
|
resumeGatewaySuspension: (_checkout: string, suspensionId: string) => {
|
|
resumed.push(suspensionId);
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(output.ok).toBe(true);
|
|
expect(resumed).toEqual([]);
|
|
});
|
|
|
|
test("resumes a prepared suspension when stopped proof never converges", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
const resumed: string[] = [];
|
|
let proofAttempts = 0;
|
|
let sleepAttempts = 0;
|
|
|
|
expect(() =>
|
|
maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{
|
|
proveGatewayStopped: () => {
|
|
proofAttempts += 1;
|
|
throw new Error("listener still present");
|
|
},
|
|
sleep: () => {
|
|
sleepAttempts += 1;
|
|
},
|
|
resumeGatewaySuspension: (_checkout: string, suspensionId: string) => {
|
|
resumed.push(suspensionId);
|
|
},
|
|
},
|
|
),
|
|
).toThrow("native stopped proof did not converge");
|
|
expect(proofAttempts).toBe(100);
|
|
expect(sleepAttempts).toBe(99);
|
|
expect(resumed).toEqual(["fixture-suspension"]);
|
|
});
|
|
|
|
test("recovers a stale build only after proving an unavailable Gateway is stopped", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
const commands = fakeCommands(mirror);
|
|
|
|
const output = maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{
|
|
runCommand: commands.runCommand,
|
|
prepareGatewaySuspension: () => {
|
|
throw new Error("Gateway unavailable");
|
|
},
|
|
proveGatewayStopped: () => ({
|
|
runtimeStatus: "stopped",
|
|
port: 18_789,
|
|
portStatus: "free",
|
|
}),
|
|
},
|
|
);
|
|
|
|
expect(output.gatewayLogAudit).toMatchObject({ errorCount: 0 });
|
|
expect(commands.calls).toEqual([
|
|
"pnpm install --frozen-lockfile",
|
|
"pnpm build",
|
|
"pnpm openclaw gateway restart",
|
|
"pnpm openclaw gateway status --deep --require-rpc --json",
|
|
"pnpm openclaw health --verbose --json",
|
|
]);
|
|
});
|
|
|
|
test("preserves the signed Mac bundle while a Gateway build replaces dist", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
const appBundle = path.join(mirror, "dist/OpenClaw.app");
|
|
const appMarker = path.join(appBundle, "Contents/signature-marker");
|
|
mkdirSync(path.dirname(appMarker), { recursive: true });
|
|
writeFileSync(appMarker, "signed\n");
|
|
const commands = fakeCommands(mirror);
|
|
|
|
maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{
|
|
runCommand(command: string, args: string[]) {
|
|
if (command === "pnpm" && args[0] === "build") {
|
|
expect(existsSync(appBundle)).toBe(false);
|
|
}
|
|
commands.runCommand(command, args);
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(readFileSync(appMarker, "utf8")).toBe("signed\n");
|
|
expect(
|
|
readdirSync(path.join(mirror, ".git")).filter((entry) =>
|
|
entry.startsWith(".openclaw-live-mac-"),
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("restores the Mac bundle when the Gateway build fails", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
const appMarker = path.join(mirror, "dist/OpenClaw.app/Contents/signature-marker");
|
|
mkdirSync(path.dirname(appMarker), { recursive: true });
|
|
writeFileSync(appMarker, "signed\n");
|
|
|
|
expect(() =>
|
|
maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{
|
|
runCommand(command: string, args: string[]) {
|
|
if (command === "pnpm" && args[0] === "build") {
|
|
throw new Error("build failed");
|
|
}
|
|
},
|
|
},
|
|
),
|
|
).toThrow("build failed");
|
|
expect(readFileSync(appMarker, "utf8")).toBe("signed\n");
|
|
});
|
|
|
|
test("accepts a delayed external restore of the exact preserved Mac bundle", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
const appBundle = path.join(mirror, "dist/OpenClaw.app");
|
|
const appMarker = path.join(appBundle, "Contents/signature-marker");
|
|
mkdirSync(path.dirname(appMarker), { recursive: true });
|
|
writeFileSync(appMarker, "signed\n");
|
|
const commands = fakeCommands(mirror);
|
|
const delayedBundle = path.join(root, "delayed-openclaw.app");
|
|
let restored = false;
|
|
|
|
maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{
|
|
runCommand(command: string, args: string[]) {
|
|
if (command === "pnpm" && args[0] === "build") {
|
|
expect(existsSync(appBundle)).toBe(false);
|
|
}
|
|
commands.runCommand(command, args);
|
|
if (command === "pnpm" && args[0] === "build") {
|
|
const preserved = readdirSync(path.join(mirror, ".git")).find((entry) =>
|
|
entry.startsWith(".openclaw-live-mac-"),
|
|
);
|
|
expect(preserved).toBeDefined();
|
|
renameSync(path.join(mirror, ".git", preserved!), delayedBundle);
|
|
}
|
|
},
|
|
sleep() {
|
|
if (restored) {
|
|
return;
|
|
}
|
|
renameSync(delayedBundle, appBundle);
|
|
restored = true;
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(restored).toBe(true);
|
|
expect(readFileSync(appMarker, "utf8")).toBe("signed\n");
|
|
expect(
|
|
readdirSync(path.join(mirror, ".git")).filter((entry) =>
|
|
entry.startsWith(".openclaw-live-mac-"),
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("preserves a build failure after an external Mac bundle restore", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
const appBundle = path.join(mirror, "dist/OpenClaw.app");
|
|
const appMarker = path.join(appBundle, "Contents/signature-marker");
|
|
mkdirSync(path.dirname(appMarker), { recursive: true });
|
|
writeFileSync(appMarker, "signed\n");
|
|
|
|
expect(() =>
|
|
maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{
|
|
runCommand(command: string, args: string[]) {
|
|
if (command === "pnpm" && args[0] === "build") {
|
|
const preserved = readdirSync(path.join(mirror, ".git")).find((entry) =>
|
|
entry.startsWith(".openclaw-live-mac-"),
|
|
);
|
|
expect(preserved).toBeDefined();
|
|
renameSync(path.join(mirror, ".git", preserved!), appBundle);
|
|
throw new Error("build failed after external restore");
|
|
}
|
|
},
|
|
},
|
|
),
|
|
).toThrow("build failed after external restore");
|
|
expect(readFileSync(appMarker, "utf8")).toBe("signed\n");
|
|
});
|
|
|
|
test("proves a current exact-SHA Gateway on a no-op heartbeat", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
writeBuild(mirror);
|
|
const commands = fakeCommands(mirror);
|
|
|
|
const output = maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{ runCommand: commands.runCommand },
|
|
);
|
|
|
|
expect(output.updated).toBe(false);
|
|
expect(output.actions).toMatchObject({
|
|
gatewayBuild: false,
|
|
gatewayProbe: true,
|
|
gatewayRestart: false,
|
|
gatewaySelfHeal: false,
|
|
});
|
|
expect(commands.calls).toEqual([
|
|
"pnpm openclaw gateway status --deep --require-rpc --json",
|
|
"pnpm openclaw health --verbose --json",
|
|
]);
|
|
});
|
|
|
|
test("repoints an ancestor snapshot across the next source update", () => {
|
|
const { root, mirror, seed } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
writeBuild(mirror);
|
|
writeFileSync(path.join(seed, "README.md"), "snapshot update\n");
|
|
git(seed, "add", "README.md");
|
|
git(seed, "commit", "-m", "snapshot update");
|
|
git(seed, "push");
|
|
const commands = fakeCommands(mirror);
|
|
const snapshot = path.join(root, "gateway-ancestor/dist/index.js");
|
|
const source = path.join(mirror, "dist/index.js");
|
|
const configPath = path.join(root, "openclaw.json");
|
|
const plistPath = path.join(root, "ai.openclaw.gateway.plist");
|
|
writeFileSync(configPath, "{}\n");
|
|
writeFileSync(plistPath, "plist\n", { mode: 0o600 });
|
|
let deployedEntrypoint = snapshot;
|
|
let controlEntrypoint: string | undefined;
|
|
const inspectGatewayDeployment = () => ({
|
|
configPath,
|
|
entrypoint: deployedEntrypoint,
|
|
entrypointIndex: 1,
|
|
executable: process.execPath,
|
|
invocationPrefix: [deployedEntrypoint],
|
|
label: "ai.openclaw.gateway",
|
|
plistPath,
|
|
port: 18789,
|
|
runtime: process.execPath,
|
|
serviceEnvironment: { PRIVATE_MARKER: "not-serialized" },
|
|
});
|
|
|
|
const deferred = maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{
|
|
runCommand: commands.runCommand,
|
|
inspectGatewayDeployment,
|
|
prepareGatewaySuspension: () => ({
|
|
status: "busy",
|
|
reason: "active-work",
|
|
retryAfterMs: 20_000,
|
|
activeCount: 1,
|
|
blockers: [{ kind: "agent-run", count: 1, message: "busy" }],
|
|
}),
|
|
},
|
|
);
|
|
expect(deferred).toMatchObject({ deferred: true, reason: "gateway_active_work" });
|
|
expect(commands.calls).toEqual([]);
|
|
|
|
const output = maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{
|
|
runCommand: commands.runCommand,
|
|
prepareGatewaySuspension: (_checkout: string, deployment: { entrypoint: string }) => {
|
|
controlEntrypoint = deployment.entrypoint;
|
|
return { status: "ready", suspensionId: "fixture-suspension" };
|
|
},
|
|
inspectGatewayDeployment,
|
|
repointGatewayDeployment: (
|
|
_checkout: string,
|
|
deployment: { entrypoint: string; label: string; port: number },
|
|
) => {
|
|
deployedEntrypoint = source;
|
|
return {
|
|
changed: true,
|
|
...deployment,
|
|
entrypoint: source,
|
|
invocationPrefix: [source],
|
|
previousEntrypoint: deployment.entrypoint,
|
|
};
|
|
},
|
|
verifyGatewayRuntime: () => ({
|
|
commit: git(mirror, "rev-parse", "HEAD"),
|
|
entrypoint: source,
|
|
pid: 123,
|
|
port: 18789,
|
|
}),
|
|
proveGatewayStopped: () => {
|
|
commands.calls.push("prove gateway stopped");
|
|
return {
|
|
runtimeStatus: "stopped",
|
|
port: 18789,
|
|
portStatus: "free",
|
|
proofSource: "fixture",
|
|
};
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(output.actions).toMatchObject({
|
|
gatewayBuild: true,
|
|
gatewayRestart: true,
|
|
gatewaySelfHeal: false,
|
|
});
|
|
expect(output.gatewayDeployment).toMatchObject({
|
|
changed: true,
|
|
entrypoint: source,
|
|
previousEntrypoint: snapshot,
|
|
});
|
|
expect(output.gatewayRuntime).toMatchObject({ entrypoint: source, pid: 123 });
|
|
expect(JSON.stringify(output)).not.toContain("not-serialized");
|
|
expect(controlEntrypoint).toBe(source);
|
|
const uid = process.getuid?.() ?? 501;
|
|
expect(commands.calls).toEqual([
|
|
`/bin/launchctl bootout gui/${uid}/ai.openclaw.gateway`,
|
|
"prove gateway stopped",
|
|
"pnpm build",
|
|
`/bin/launchctl enable gui/${uid}/ai.openclaw.gateway`,
|
|
`/bin/launchctl bootstrap gui/${uid} ${plistPath}`,
|
|
]);
|
|
});
|
|
|
|
test("builds a trusted source control client while a snapshot is still running", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
const commands = fakeCommands(mirror);
|
|
const snapshot = path.join(root, "gateway-ancestor/dist/index.js");
|
|
const source = path.join(mirror, "dist/index.js");
|
|
const configPath = path.join(root, "openclaw.json");
|
|
const plistPath = path.join(root, "ai.openclaw.gateway.plist");
|
|
writeFileSync(configPath, "{}\n");
|
|
writeFileSync(plistPath, "plist\n", { mode: 0o600 });
|
|
let deployedEntrypoint = snapshot;
|
|
let controlEntrypoint = "";
|
|
let stoppedProofAttempts = 0;
|
|
|
|
const output = maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{
|
|
runCommand: commands.runCommand,
|
|
prepareGatewaySuspension: (_checkout: string, deployment: { entrypoint: string }) => {
|
|
controlEntrypoint = deployment.entrypoint;
|
|
return { status: "ready", suspensionId: "fixture-suspension" };
|
|
},
|
|
inspectGatewayDeployment: () => ({
|
|
configPath,
|
|
entrypoint: deployedEntrypoint,
|
|
entrypointIndex: 1,
|
|
executable: process.execPath,
|
|
invocationPrefix: [deployedEntrypoint],
|
|
label: "ai.openclaw.gateway",
|
|
plistPath,
|
|
port: 18789,
|
|
runtime: process.execPath,
|
|
}),
|
|
proveGatewayStopped: () => {
|
|
stoppedProofAttempts += 1;
|
|
commands.calls.push("prove gateway stopped");
|
|
if (stoppedProofAttempts <= 2) {
|
|
throw new Error("snapshot still owns its listener");
|
|
}
|
|
return {
|
|
runtimeStatus: "stopped",
|
|
port: 18789,
|
|
portStatus: "free",
|
|
proofSource: "fixture",
|
|
};
|
|
},
|
|
sleep: (ms: number) => {
|
|
commands.calls.push(`sleep ${ms}`);
|
|
},
|
|
repointGatewayDeployment: (
|
|
_checkout: string,
|
|
deployment: { entrypoint: string; invocationPrefix: string[] },
|
|
) => {
|
|
deployedEntrypoint = source;
|
|
return {
|
|
changed: true,
|
|
...deployment,
|
|
entrypoint: source,
|
|
invocationPrefix: [source],
|
|
previousEntrypoint: deployment.entrypoint,
|
|
};
|
|
},
|
|
verifyGatewayRuntime: () => ({
|
|
commit: git(mirror, "rev-parse", "HEAD"),
|
|
entrypoint: source,
|
|
pid: 123,
|
|
port: 18789,
|
|
}),
|
|
},
|
|
);
|
|
|
|
expect(controlEntrypoint).toBe(source);
|
|
expect(stoppedProofAttempts).toBe(3);
|
|
expect(output.gatewayDeployment).toMatchObject({
|
|
changed: true,
|
|
entrypoint: source,
|
|
previousEntrypoint: snapshot,
|
|
});
|
|
const uid = process.getuid?.() ?? 501;
|
|
expect(commands.calls).toEqual([
|
|
"prove gateway stopped",
|
|
"pnpm install --frozen-lockfile",
|
|
"pnpm build",
|
|
`/bin/launchctl bootout gui/${uid}/ai.openclaw.gateway`,
|
|
"prove gateway stopped",
|
|
"sleep 100",
|
|
"prove gateway stopped",
|
|
`/bin/launchctl enable gui/${uid}/ai.openclaw.gateway`,
|
|
`/bin/launchctl bootstrap gui/${uid} ${plistPath}`,
|
|
]);
|
|
});
|
|
|
|
test("recovers a stopped snapshot when the source control build is missing", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
const commands = fakeCommands(mirror);
|
|
const snapshot = path.join(root, "gateway-ancestor/dist/index.js");
|
|
const source = path.join(mirror, "dist/index.js");
|
|
const configPath = path.join(root, "openclaw.json");
|
|
const plistPath = path.join(root, "ai.openclaw.gateway.plist");
|
|
writeFileSync(configPath, "{}\n");
|
|
writeFileSync(plistPath, "plist\n", { mode: 0o600 });
|
|
let deployedEntrypoint = snapshot;
|
|
let prepareCalled = false;
|
|
|
|
const output = maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{
|
|
runCommand: commands.runCommand,
|
|
prepareGatewaySuspension: () => {
|
|
prepareCalled = true;
|
|
throw new Error("must not execute an unavailable control build");
|
|
},
|
|
inspectGatewayDeployment: () => ({
|
|
configPath,
|
|
entrypoint: deployedEntrypoint,
|
|
entrypointIndex: 1,
|
|
executable: process.execPath,
|
|
invocationPrefix: [deployedEntrypoint],
|
|
label: "ai.openclaw.gateway",
|
|
plistPath,
|
|
port: 18789,
|
|
runtime: process.execPath,
|
|
}),
|
|
proveGatewayStopped: () => ({
|
|
runtimeStatus: "stopped",
|
|
port: 18789,
|
|
portStatus: "free",
|
|
proofSource: "fixture",
|
|
}),
|
|
repointGatewayDeployment: (
|
|
_checkout: string,
|
|
deployment: { entrypoint: string; invocationPrefix: string[] },
|
|
) => {
|
|
deployedEntrypoint = source;
|
|
return {
|
|
changed: true,
|
|
...deployment,
|
|
entrypoint: source,
|
|
invocationPrefix: [source],
|
|
previousEntrypoint: deployment.entrypoint,
|
|
};
|
|
},
|
|
verifyGatewayRuntime: () => ({
|
|
commit: git(mirror, "rev-parse", "HEAD"),
|
|
entrypoint: source,
|
|
pid: 123,
|
|
port: 18789,
|
|
}),
|
|
},
|
|
);
|
|
|
|
expect(prepareCalled).toBe(false);
|
|
expect(output.gatewayDeployment).toMatchObject({
|
|
changed: true,
|
|
entrypoint: source,
|
|
previousEntrypoint: snapshot,
|
|
});
|
|
const uid = process.getuid?.() ?? 501;
|
|
expect(commands.calls).toEqual([
|
|
"pnpm install --frozen-lockfile",
|
|
"pnpm build",
|
|
`/bin/launchctl enable gui/${uid}/ai.openclaw.gateway`,
|
|
`/bin/launchctl bootstrap gui/${uid} ${plistPath}`,
|
|
]);
|
|
});
|
|
|
|
test("restores missing dependencies before probing a current build", () => {
|
|
const { root, mirror } = makeFixture();
|
|
writeBuild(mirror);
|
|
const commands = fakeCommands(mirror);
|
|
|
|
const output = maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{ runCommand: commands.runCommand },
|
|
);
|
|
|
|
expect(output.actions).toMatchObject({
|
|
dependencyInstall: true,
|
|
gatewayBuild: false,
|
|
gatewayProbe: true,
|
|
gatewayRestart: true,
|
|
});
|
|
expect(commands.calls).toEqual([
|
|
`${process.execPath} dist/index.js gateway stop`,
|
|
"pnpm install --frozen-lockfile",
|
|
"pnpm openclaw gateway restart",
|
|
"pnpm openclaw gateway status --deep --require-rpc --json",
|
|
"pnpm openclaw health --verbose --json",
|
|
]);
|
|
});
|
|
|
|
test("restarts once when a current exact-SHA Gateway probe fails", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
writeBuild(mirror);
|
|
const calls: string[] = [];
|
|
let failed = false;
|
|
|
|
const output = maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{
|
|
runCommand(command: string, args: string[]) {
|
|
const call = [command, ...args].join(" ");
|
|
calls.push(call);
|
|
if (!failed && call.includes("gateway status")) {
|
|
failed = true;
|
|
throw new Error("RPC unavailable");
|
|
}
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(output.actions).toMatchObject({
|
|
gatewayBuild: false,
|
|
gatewayRestart: true,
|
|
gatewaySelfHeal: true,
|
|
});
|
|
expect(calls).toEqual([
|
|
"pnpm openclaw gateway status --deep --require-rpc --json",
|
|
"pnpm openclaw gateway restart",
|
|
"pnpm openclaw gateway status --deep --require-rpc --json",
|
|
"pnpm openclaw health --verbose --json",
|
|
]);
|
|
});
|
|
|
|
test("bootstraps an owned LaunchAgent left unloaded by a failed restart", () => {
|
|
const uid = process.getuid?.() ?? 501;
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
writeBuild(mirror);
|
|
const commands = fakeCommands(mirror);
|
|
const source = path.join(mirror, "dist/index.js");
|
|
const plistPath = path.join(root, "ai.openclaw.gateway.plist");
|
|
writeFileSync(plistPath, "plist\n", { mode: 0o600 });
|
|
const deployment = {
|
|
configPath: path.join(root, "openclaw.json"),
|
|
entrypoint: source,
|
|
entrypointIndex: 1,
|
|
executable: process.execPath,
|
|
invocationPrefix: [source],
|
|
label: "ai.openclaw.gateway",
|
|
plistPath,
|
|
port: 18789,
|
|
runtime: process.execPath,
|
|
};
|
|
|
|
const output = maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{
|
|
runCommand: commands.runCommand,
|
|
inspectGatewayDeployment: () => deployment,
|
|
isGatewayLoaded: () => false,
|
|
verifyGateway: () => {
|
|
throw new Error("managed job is unloaded");
|
|
},
|
|
verifyAndAuditGateway: () => ({
|
|
entries: 0,
|
|
errorCount: 0,
|
|
warningCount: 0,
|
|
errors: [],
|
|
warnings: [],
|
|
}),
|
|
verifyGatewayRuntime: () => ({ entrypoint: source, pid: 123, port: 18789 }),
|
|
},
|
|
);
|
|
|
|
expect(output.actions).toMatchObject({
|
|
gatewayBuild: false,
|
|
gatewayRestart: true,
|
|
gatewaySelfHeal: true,
|
|
});
|
|
expect(commands.calls).toEqual([
|
|
`/bin/launchctl enable gui/${uid}/ai.openclaw.gateway`,
|
|
`/bin/launchctl bootstrap gui/${uid} ${plistPath}`,
|
|
]);
|
|
});
|
|
|
|
test("keeps successful CLI stdout as one machine-readable JSON object", () => {
|
|
const { root, mirror, origin } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
writeBuild(mirror);
|
|
const binDir = path.join(root, "bin");
|
|
const pnpm = path.join(binDir, "pnpm");
|
|
const gitShim = path.join(binDir, "git");
|
|
const realGit = execFileSync("which", ["git"], { encoding: "utf8" }).trim();
|
|
mkdirSync(binDir);
|
|
writeFileSync(pnpm, "#!/bin/sh\necho child-output\n");
|
|
writeFileSync(
|
|
gitShim,
|
|
`#!/bin/sh\nif [ "$3" = "fetch" ]; then\n exec "${realGit}" -C "$2" fetch "${origin}" "main:refs/remotes/origin/main"\nfi\nexec "${realGit}" "$@"\n`,
|
|
);
|
|
chmodSync(pnpm, 0o755);
|
|
chmodSync(gitShim, 0o755);
|
|
|
|
const result = spawnSync(process.execPath, [script, "--checkout", mirror], {
|
|
encoding: "utf8",
|
|
env: { ...process.env, PATH: `${binDir}:${process.env.PATH}` },
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout.trim().split("\n")).toHaveLength(1);
|
|
expect(JSON.parse(result.stdout)).toMatchObject({ ok: true, updated: false });
|
|
expect(result.stderr).toContain("child-output");
|
|
});
|
|
|
|
test("does not restart Gateway when build provenance misses the exact SHA", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
const calls: string[] = [];
|
|
|
|
expect(() =>
|
|
maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{
|
|
runCommand: (command: string, args: string[]) => calls.push([command, ...args].join(" ")),
|
|
},
|
|
),
|
|
).toThrow(/build output does not match/u);
|
|
expect(calls).toEqual([
|
|
`${process.execPath} dist/index.js gateway stop`,
|
|
"pnpm install --frozen-lockfile",
|
|
"pnpm build",
|
|
]);
|
|
});
|
|
|
|
test("audits restart-window logs even when deep Gateway verification fails", () => {
|
|
const { root, mirror } = makeFixture();
|
|
mkdirSync(path.join(mirror, "node_modules"));
|
|
writeBuild(mirror);
|
|
let auditCalls = 0;
|
|
let statusCalls = 0;
|
|
|
|
expect(() =>
|
|
maintainMain(
|
|
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
|
|
{
|
|
fetchMain: fetchFixtureMain,
|
|
runCommand(command: string, args: string[]) {
|
|
if (command === "pnpm" && args.slice(0, 3).join(" ") === "openclaw gateway status") {
|
|
statusCalls += 1;
|
|
throw new Error("RPC unavailable");
|
|
}
|
|
},
|
|
auditGatewayLogs() {
|
|
auditCalls += 1;
|
|
return { entries: 1, errorCount: 0, warningCount: 0, errors: [], warnings: [] };
|
|
},
|
|
inspectGatewayDeployment: () => null,
|
|
sleep() {},
|
|
verifyGatewayRuntime: () => null,
|
|
},
|
|
),
|
|
).toThrow("RPC unavailable");
|
|
expect(statusCalls).toBe(4);
|
|
expect(auditCalls).toBe(1);
|
|
});
|
|
|
|
test("retains failed exact-bundle Mac proof for the next heartbeat", () => {
|
|
const { root, mirror, seed } = makeFixture();
|
|
mkdirSync(path.join(seed, "apps/macos/Sources/OpenClaw"), { recursive: true });
|
|
writeFileSync(path.join(seed, "apps/macos/Sources/OpenClaw/App.swift"), "// changed\n");
|
|
git(seed, "add", ".");
|
|
git(seed, "commit", "-m", "mac change");
|
|
git(seed, "push");
|
|
const lockPath = path.join(root, "maintenance.lock");
|
|
const statePath = path.join(root, "maintenance-state.json");
|
|
const firstCommands = fakeCommands(mirror);
|
|
|
|
expect(() =>
|
|
maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath, statePath },
|
|
{
|
|
runCommand: firstCommands.runCommand,
|
|
verifyMacTarget: () => {
|
|
throw new Error("exact target exited");
|
|
},
|
|
},
|
|
),
|
|
).toThrow("exact target exited");
|
|
expect(JSON.parse(readFileSync(statePath, "utf8"))).toMatchObject({
|
|
macPending: true,
|
|
attempts: 1,
|
|
lastFailure: "exact target exited",
|
|
});
|
|
|
|
const retryCommands = fakeCommands(mirror);
|
|
const retry = maintainFixture(
|
|
{ checkout: mirror, remote: "origin", lockPath, statePath },
|
|
{
|
|
runCommand: retryCommands.runCommand,
|
|
verifyMacTarget: () => ({ executable: "exact", pid: 456 }),
|
|
},
|
|
);
|
|
expect(retry.updated).toBe(false);
|
|
expect(retry.actions.gatewayBuild).toBe(false);
|
|
expect(retry.actions.macAppRebuild).toBe(true);
|
|
expect(retryCommands.calls.slice(0, 3)).toEqual([
|
|
"pnpm openclaw gateway status --deep --require-rpc --json",
|
|
"pnpm openclaw health --verbose --json",
|
|
"env SKIP_TSC=1 SKIP_UI_BUILD=1 bash scripts/restart-mac.sh --sign --wait --target-only",
|
|
]);
|
|
expect(existsSync(statePath)).toBe(false);
|
|
});
|
|
|
|
test("records pending Mac work before Gateway maintenance can fail", () => {
|
|
const { root, mirror, seed } = makeFixture();
|
|
mkdirSync(path.join(seed, "apps/macos/Sources/OpenClaw"), { recursive: true });
|
|
writeFileSync(path.join(seed, "apps/macos/Sources/OpenClaw/App.swift"), "// changed\n");
|
|
git(seed, "add", ".");
|
|
git(seed, "commit", "-m", "mac change");
|
|
git(seed, "push");
|
|
const statePath = path.join(root, "maintenance-state.json");
|
|
const commands = fakeCommands(mirror);
|
|
|
|
expect(() =>
|
|
maintainFixture(
|
|
{
|
|
checkout: mirror,
|
|
remote: "origin",
|
|
lockPath: path.join(root, "maintenance.lock"),
|
|
statePath,
|
|
},
|
|
{
|
|
runCommand(command: string, args: string[]) {
|
|
commands.runCommand(command, args);
|
|
if (command === "pnpm" && args.includes("status")) {
|
|
throw new Error("Gateway failed");
|
|
}
|
|
},
|
|
},
|
|
),
|
|
).toThrow("Gateway failed");
|
|
expect(JSON.parse(readFileSync(statePath, "utf8"))).toMatchObject({
|
|
macPending: true,
|
|
attempts: 0,
|
|
});
|
|
});
|
|
|
|
test("refuses a symlinked maintenance state file without touching its target", () => {
|
|
const { root, mirror } = makeFixture();
|
|
const statePath = path.join(root, "maintenance-state.json");
|
|
const victimPath = path.join(root, "victim.txt");
|
|
writeFileSync(victimPath, "untouched\n");
|
|
symlinkSync(victimPath, statePath);
|
|
|
|
expect(() =>
|
|
maintainFixture({
|
|
checkout: mirror,
|
|
remote: "origin",
|
|
lockPath: path.join(root, "maintenance.lock"),
|
|
statePath,
|
|
}),
|
|
).toThrow(/maintenance state is unreadable/u);
|
|
expect(readFileSync(victimPath, "utf8")).toBe("untouched\n");
|
|
});
|
|
|
|
test("skips an overlapping heartbeat while the owner process is alive", () => {
|
|
const { root, mirror } = makeFixture();
|
|
const lockPath = path.join(root, "maintenance.lock");
|
|
const held = acquireMaintenanceLock(mirror, lockPath);
|
|
try {
|
|
const output = maintainFixture({ checkout: mirror, remote: "origin", lockPath });
|
|
expect(output).toMatchObject({ ok: true, skipped: true, reason: "overlap" });
|
|
} finally {
|
|
held.release?.();
|
|
}
|
|
});
|
|
|
|
test("atomically recovers a dead maintenance lock", () => {
|
|
const { root, mirror } = makeFixture();
|
|
const lockPath = path.join(root, "maintenance.lock");
|
|
mkdirSync(lockPath);
|
|
writeFileSync(
|
|
path.join(lockPath, "owner.json"),
|
|
`${JSON.stringify({ pid: 999_999_999, checkout: mirror, startedAt: "stale" })}\n`,
|
|
);
|
|
|
|
const held = acquireMaintenanceLock(mirror, lockPath);
|
|
try {
|
|
expect(held.acquired).toBe(true);
|
|
expect(held.owner.pid).toBe(process.pid);
|
|
expect(existsSync(`${lockPath}.stale-${process.pid}`)).toBe(false);
|
|
} finally {
|
|
held.release?.();
|
|
}
|
|
});
|
|
|
|
test("treats an owner file creation race as a normal overlap", () => {
|
|
const { root, mirror } = makeFixture();
|
|
const lockPath = path.join(root, "maintenance.lock");
|
|
mkdirSync(lockPath);
|
|
const owner = { pid: process.pid, checkout: mirror, startedAt: "racing" };
|
|
const writer = spawn(
|
|
"sh",
|
|
["-c", 'sleep 0.03; printf "%s\\n" "$OWNER_JSON" > "$LOCK_PATH/owner.json"'],
|
|
{
|
|
env: { ...process.env, LOCK_PATH: lockPath, OWNER_JSON: JSON.stringify(owner) },
|
|
stdio: "ignore",
|
|
},
|
|
);
|
|
|
|
try {
|
|
expect(acquireMaintenanceLock(mirror, lockPath)).toMatchObject({
|
|
acquired: false,
|
|
owner,
|
|
});
|
|
} finally {
|
|
writer.kill();
|
|
}
|
|
});
|
|
|
|
test("refuses dirty work without moving HEAD", () => {
|
|
const { mirror } = makeFixture();
|
|
const before = git(mirror, "rev-parse", "HEAD");
|
|
writeFileSync(path.join(mirror, "local.txt"), "do not destroy\n");
|
|
|
|
const result = spawnSync(process.execPath, [script, "--checkout", mirror], {
|
|
encoding: "utf8",
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(JSON.parse(result.stdout.trim())).toMatchObject({
|
|
ok: false,
|
|
error: { code: "dirty_checkout" },
|
|
});
|
|
expect(git(mirror, "rev-parse", "HEAD")).toBe(before);
|
|
expect(git(mirror, "status", "--porcelain")).toContain("?? local.txt");
|
|
});
|
|
});
|