fix(update): allow already-current installs without a Gateway service (#157633)

* fix(update): allow updates without a Gateway service (#136798)

The installed 2026.8.2 updater refuses the release smoke before package
mutation or candidate handoff. Current main has the same defect: strict
systemd command inspection cannot distinguish a missing manager from an
uninspectable existing service.

Let the native service-state owner record affirmative manager/unit absence,
then require strict Gateway lock inspection and a free configured port at
update preflight. Preserve unknown-state refusal and report skipped restart
when nothing is running. Lock discovery retains its existing contract.

Use the shipped baseline's documented --no-restart path only after proving
the dedicated smoke container idle, then repeat the candidate update with
default restart policy. Join the heartbeat timer exposed by that idle check.
Document the one-time 2026.8.2 upgrade workaround.

Proof: original baseline and packed-main Docker runs refused with exit 1;
managerless and strict-lock regressions fail before the fix. Final focused
suites pass 532 tests; the full update CLI suite passes 370 with 1 skipped.
Final Docker baseline/manual plus candidate/default updates and doctor
steps pass; pnpm check:changed passes. Independent review is clean through P2.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit b026948c8f)

* test(infra): split gateway lock inspection coverage

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dallin Romney 2026-09-24 15:36:02 -07:00 • committed by GitHub
parent cc8b2a0769
commit 91ead0da47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 599 additions and 54 deletions

View file

@ -308,12 +308,21 @@ Shell installers do not establish the same service ownership proof. If their
service refresh is denied, they report code installation success, leave the
service untouched, and print guidance to inspect ownership and restart manually.
On Linux without a service manager, updates proceed when native inspection proves
the service is absent and the selected Gateway has no active lock or listener.
The command reports that there is no Gateway to restart. Existing service files,
manager runtime state, or failed filesystem inspection still require service access.
If service inspection is unavailable, a restart-enabled code update refuses to
mutate the checkout or package tree; it does not assume that no service exists.
Run `openclaw gateway status --deep` and retry when access is restored. Use
`--no-restart` only after manually stopping the Gateway, then restart it
manually after the update. Services owned by another install remain untouched.
The published 2026.8.2 CLI also refuses updates on service-less Linux installs.
Use `openclaw update --no-restart` for that upgrade after confirming that no Gateway
is running; the new CLI cannot fix the old CLI's pre-update inspection.
Package-manager updates normally keep using the Node binary recorded in the
managed service. If that Node cannot run the target release, but the current
CLI Node can and the service is proven to belong to the package being updated,

View file

@ -122,8 +122,12 @@ run_with_heartbeat() {
"$@" &
command_pid=$!
(
# Join the timer when the command finishes; killing only this shell orphans sleep.
trap 'for timer_pid in $(jobs -pr); do kill "$timer_pid" >/dev/null 2>&1 || true; done; wait' EXIT
trap 'exit 0' TERM INT
while true; do
sleep "$interval"
sleep "$interval" &
wait "$!"
kill -0 "$command_pid" >/dev/null 2>&1 || exit 0
local now
local elapsed
@ -331,6 +335,54 @@ run_install_smoke() {
echo "OK"
}
assert_update_smoke_offline() {
node - <<'NODE'
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const fail = (detail) => { throw new Error(`update smoke requires an idle, service-free container: ${detail}`); };
if (process.platform !== "linux") fail("Linux process inspection is required");
for (const name of ["OPENCLAW_PROFILE", "OPENCLAW_SYSTEMD_UNIT", "OPENCLAW_HOME", "OPENCLAW_CONFIG_PATH", "OPENCLAW_STATE_DIR", "DBUS_SESSION_BUS_ADDRESS", "DBUS_SYSTEM_BUS_ADDRESS", "XDG_RUNTIME_DIR", "SYSTEMD_UNIT_PATH"]) {
if (process.env[name]) fail(`unexpected ${name}`);
}
for (const marker of ["/run/systemd/system", "/run/systemd/private", `/run/user/${process.getuid()}/systemd/private`]) {
try {
fs.lstatSync(marker);
fail(`service manager runtime exists at ${marker}`);
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
}
const initArgs = fs.readFileSync("/proc/1/cmdline", "utf8").split("\0");
if (process.ppid !== 1 || !initArgs.includes("/usr/local/bin/openclaw-install-smoke")) {
fail("the smoke runner must own the PID namespace");
}
for (const pid of fs.readdirSync("/proc").filter((entry) => /^\d+$/.test(entry))) {
if (Number(pid) === 1 || Number(pid) === process.pid) continue;
try {
if (fs.readFileSync(`/proc/${pid}/cmdline`, "utf8")) fail(`unexpected live process ${pid}`);
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
}
for (const root of [path.join(os.homedir(), ".config/systemd"), path.join(os.homedir(), ".local/share/systemd"), "/etc/systemd", "/run/systemd", "/usr/local/lib/systemd", "/usr/lib/systemd", "/lib/systemd"]) {
let entries;
try {
entries = fs.readdirSync(root, { recursive: true, withFileTypes: true });
} catch (error) {
if (error.code === "ENOENT") continue;
throw error;
}
for (const entry of entries) {
if (!/\.(service|socket|timer)$/.test(entry.name)) continue;
const file = path.join(entry.parentPath, entry.name);
if (/openclaw/i.test(entry.name) || /openclaw/i.test(fs.readFileSync(file, "utf8"))) fail(`service definition exists at ${file}`);
}
}
console.log("==> Verified idle container: no Gateway process or service definition");
NODE
}
run_update_smoke() {
if [[ -z "$UPDATE_EXPECT_VERSION" ]]; then
echo "ERROR: OPENCLAW_INSTALL_UPDATE_EXPECT_VERSION is required for update mode" >&2
@ -353,7 +405,21 @@ run_update_smoke() {
print_install_audit "baseline install"
verify_installed_cli "$PACKAGE_NAME" "$UPDATE_BASELINE_VERSION"
echo "==> Run openclaw update from host-served tgz"
# The shipped baseline cannot distinguish absent service managers from failed inspection.
# Its documented manual path is safe only in this verified idle container; the candidate
# then repeats the update with default restart policy so regressions remain visible.
assert_update_smoke_offline
run_update_candidate "$UPDATE_BASELINE_VERSION" --no-restart
echo "==> Verify candidate default update without a Gateway service"
run_update_candidate "$UPDATE_EXPECT_VERSION"
verify_candidate_ai_runtime
echo "OK"
}
run_update_candidate() {
local UPDATE_BASELINE_VERSION="$1"
shift
echo "==> Run openclaw update from host-served tgz (from $UPDATE_BASELINE_VERSION)"
local update_status
local update_stderr_file
local update_stderr
@ -371,7 +437,7 @@ run_update_smoke() {
UPDATE_JSON="$(
run_with_heartbeat "openclaw update" \
"${update_env[@]}" \
openclaw update --tag "$UPDATE_TAG_URL" --yes --json 2>"$update_stderr_file"
openclaw update --tag "$UPDATE_TAG_URL" --yes --json "$@" 2>"$update_stderr_file"
)"
update_status=$?
set -e
@ -485,9 +551,6 @@ NODE
echo "==> Verify updated version"
print_install_audit "updated install"
verify_installed_cli "$PACKAGE_NAME" "$UPDATE_EXPECT_VERSION"
verify_candidate_ai_runtime
echo "OK"
}
run_npm_global_smoke() {

View file

@ -29,6 +29,8 @@ import {
import { readGatewayServiceState, resolveGatewayService } from "../../daemon/service.js";
import { resolveSystemdServiceName } from "../../daemon/systemd-service-files.js";
import { sha256Hex } from "../../infra/crypto-digest.js";
import { readActiveGatewayLockIdentity } from "../../infra/gateway-lock.js";
import { probePortUsage } from "../../infra/ports-probe.js";
import { getSelfAndAncestorPidsSync } from "../../infra/restart-stale-pids.js";
import { parseTcpPortFromArgs } from "../../infra/tcp-port.js";
import { defaultRuntime } from "../../runtime.js";
@ -109,7 +111,13 @@ async function inspectManagedGatewayServiceBeforeUpdate(params: {
return !state.installed &&
state.loadState.status === "not-loaded" &&
!state.running &&
state.runtime?.missingUnit
state.runtime?.missingUnit &&
(await readActiveGatewayLockIdentity({ env: state.env, requireInspection: true }).then(
(identity) => !identity,
() => false,
)) &&
(await probePortUsage(await resolveUpdatedGatewayRestartPort({ serviceEnv: state.env }))) ===
"free"
? { kind: "absent" }
: unavailable();
}
@ -480,7 +488,15 @@ export async function maybeStopManagedServiceBeforeMutableUpdate(params: {
"Gateway service management skipped: the service belongs to a different OpenClaw installation and was left untouched.",
};
}
if (serviceUpdateVerdict.kind === "absent" || params.phase === "inspect") {
if (serviceUpdateVerdict.kind === "absent") {
return {
...inspected,
serviceMutationAllowed: false,
serviceMutationSkipMessage:
"Gateway restart skipped: no Gateway service or listener is running.",
};
}
if (params.phase === "inspect") {
return inspected;
}
const suspendTask = () =>
@ -683,6 +699,8 @@ export async function resolveUpdatedGatewayRestartPort(params: {
if (port !== null) {
return port;
}
}
if (params.serviceCommand || !config) {
config = await createConfigIO({
env,
observe: false,

View file

@ -177,6 +177,10 @@ beforeEach(async () => {
delete process.env[key];
}
process.env.HOME = root;
// This fixture models an installed service even though its manager calls are simulated.
const unitPath = path.join(root, ".config/systemd/user/openclaw-gateway.service");
await fs.mkdir(path.dirname(unitPath), { recursive: true });
await fs.writeFile(unitPath, "[Service]\nExecStart=/fixture/openclaw gateway\n");
configPath = path.join(root, ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath));
await fs.mkdir(path.join(root, "dist"));

View file

@ -1,5 +1,6 @@
// Daemon service tests cover service install, start, stop, and status flows.
import fs from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@ -139,13 +140,25 @@ describe("resolveGatewayService", () => {
describe("readGatewayServiceState", () => {
it.each([
{ updateInstallKind: "git" as const, shouldRestart: false },
{ updateInstallKind: "git" as const, shouldRestart: true },
{ updateInstallKind: "package" as const, shouldRestart: false },
{ updateInstallKind: "package" as const, shouldRestart: true },
{ updateInstallKind: "git" as const, shouldRestart: false, condition: "absent" },
{ updateInstallKind: "git" as const, shouldRestart: true, condition: "absent" },
{ updateInstallKind: "package" as const, shouldRestart: false, condition: "absent" },
{ updateInstallKind: "package" as const, shouldRestart: true, condition: "absent" },
...[
"installed",
"global definition",
"unreadable",
"manager",
"listener",
"configured listener",
].map((condition) => ({
updateInstallKind: "package" as const,
shouldRestart: true,
condition,
})),
])(
"handles managerless Linux preflight for $updateInstallKind restart=$shouldRestart",
async ({ updateInstallKind, shouldRestart }) => {
"handles managerless Linux preflight for $updateInstallKind restart=$shouldRestart ($condition)",
async ({ updateInstallKind, shouldRestart, condition }) => {
const { maybeStopManagedServiceBeforeMutableUpdate } =
await import("../cli/update-cli/update-command-service.js");
const home = await makeTempWorkspace("openclaw-managerless-preflight-");
@ -156,6 +169,7 @@ describe("readGatewayServiceState", () => {
"OPENCLAW_STATE_DIR",
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_PROFILE",
"OPENCLAW_GATEWAY_PORT",
"OPENCLAW_SUPERVISOR_MODE",
"OPENCLAW_SERVICE_MARKER",
"OPENCLAW_SERVICE_KIND",
@ -163,9 +177,15 @@ describe("readGatewayServiceState", () => {
"DBUS_SESSION_BUS_ADDRESS",
"DBUS_SYSTEM_BUS_ADDRESS",
"XDG_RUNTIME_DIR",
"XDG_CONFIG_HOME",
"XDG_CONFIG_DIRS",
"XDG_DATA_HOME",
"XDG_DATA_DIRS",
"SYSTEMD_UNIT_PATH",
"SUDO_USER",
];
const snapshot = captureEnv(keys);
const listener = net.createServer();
try {
setPlatform("linux");
for (const key of keys) {
@ -173,6 +193,51 @@ describe("readGatewayServiceState", () => {
}
process.env.HOME = home;
process.env.PATH = home;
await new Promise<void>((resolve) => {
listener.listen(0, "127.0.0.1", resolve);
});
const address = listener.address();
if (!address || typeof address === "string") {
throw new Error("missing listener port");
}
process.env.OPENCLAW_GATEWAY_PORT = String(address.port);
if (condition !== "listener" && condition !== "configured listener") {
await new Promise<void>((resolve) => {
listener.close(() => resolve());
});
}
if (condition === "configured listener") {
delete process.env.OPENCLAW_GATEWAY_PORT;
await fs.mkdir(path.join(home, ".openclaw"), { recursive: true });
await fs.writeFile(
path.join(home, ".openclaw/openclaw.json"),
JSON.stringify({ gateway: { port: address.port } }),
);
}
const unit = path.join(home, ".config/systemd/user/openclaw-gateway.service");
if (condition === "installed") {
await fs.mkdir(path.dirname(unit), { recursive: true });
await fs.writeFile(unit, "[Service]\nExecStart=/missing/openclaw gateway\n");
}
const lstat = fs.lstat;
vi.spyOn(fs, "lstat").mockImplementation(async (target, options) => {
const name = String(target);
if (
(condition === "manager" && name === "/run/systemd") ||
(condition === "global definition" &&
name === "/etc/systemd/user/openclaw-gateway.service")
) {
return lstat(home, options);
}
if (condition === "unreadable" && name === unit) {
throw Object.assign(new Error("permission denied"), { code: "EACCES" });
}
// The host may run systemd; this fixture models a separate managerless namespace.
if (name === "/run/systemd" || /^\/run\/user\/\d+\/systemd$/.test(name)) {
throw Object.assign(new Error("missing"), { code: "ENOENT" });
}
return lstat(target, options);
});
const result = await maybeStopManagedServiceBeforeMutableUpdate({
root: home,
updateInstallKind,
@ -181,19 +246,22 @@ describe("readGatewayServiceState", () => {
phase: "inspect",
timeoutMs: 2_000,
});
if (shouldRestart) {
expect(result.blockMessage).toContain("Refusing to mutate code");
expect(result.blockMessage).toContain("stop the Gateway manually before the update");
expect(result.serviceMutationSkipMessage).toBeUndefined();
} else {
if (condition === "absent") {
expect(result.blockMessage).toBeUndefined();
expect(result.serviceMutationSkipMessage).toContain("inspection is unavailable");
expect(result.serviceMutationSkipMessage).toContain("gateway status --deep");
expect(result.serviceMutationSkipMessage).toContain("no Gateway service or listener");
expect(result.serviceUpdateVerdict?.kind).toBe("absent");
} else {
expect(result.blockMessage).toContain("Refusing to mutate code");
expect(result.serviceUpdateVerdict?.kind).not.toBe("absent");
}
expect(result.serviceMutationAllowed).toBe(false);
expect(result.serviceUpdateVerdict?.kind).not.toBe("absent");
expect(result.stopped).toBe(false);
} finally {
if (listener.listening) {
await new Promise<void>((resolve) => {
listener.close(() => resolve());
});
}
snapshot.restore();
await fs.rm(home, { recursive: true, force: true });
}

View file

@ -51,6 +51,7 @@ import type {
GatewayServiceState,
} from "./service-types.js";
import { readSystemdDefinitionMutationCapability } from "./systemd-definition-mutation.js";
import { isSystemdServiceAbsent } from "./systemd-scope.js";
import {
findInstalledSystemdGatewayScope,
installSystemdService,
@ -92,6 +93,7 @@ export type GatewayService = {
isLoaded: (args: GatewayServiceEnvArgs) => Promise<boolean>;
isEnabled?: (args: GatewayServiceEnvArgs) => Promise<boolean>;
hasInstalledDefinition?: (args: GatewayServiceEnvArgs) => Promise<boolean>;
isAbsent?: (args: GatewayServiceEnvArgs) => Promise<boolean>;
readDefinitionMutationCapability?: (
args: GatewayServiceEnvArgs & { environment?: GatewayServiceEnv },
) => ReturnType<typeof readSystemdDefinitionMutationCapability>;
@ -204,6 +206,18 @@ export async function readGatewayServiceState(
): Promise<GatewayServiceState> {
const baseEnv = args.env ?? (process.env as GatewayServiceEnv);
const { timeoutMs } = args;
// Native absence is affirmative evidence; failed effective-command inspection is not.
if (await service.isAbsent?.({ env: baseEnv, timeoutMs }).catch(() => false)) {
args.validateEnvBeforeStatusRead?.(baseEnv);
return {
installed: false,
loadState: { status: "not-loaded" },
running: false,
env: baseEnv,
command: null,
runtime: { status: "stopped", missingUnit: true },
};
}
const command = args.requireEffective
? await service.readCommand(baseEnv, { timeoutMs, requireEffective: true })
: await service.readCommand(baseEnv, { timeoutMs }).catch(() => null);
@ -390,6 +404,7 @@ const GATEWAY_SERVICE_REGISTRY: Record<SupportedGatewayServicePlatform, GatewayS
stop: stopSystemdService,
restart: restartSystemdService,
isLoaded: isSystemdServiceEnabled,
isAbsent: ({ env }) => isSystemdServiceAbsent(env ?? process.env),
hasInstalledDefinition: async ({ env }) =>
(await findInstalledSystemdGatewayScope(env ?? process.env)) !== null,
readDefinitionMutationCapability: ({ env, environment, timeoutMs }) =>

View file

@ -2,6 +2,9 @@
import fs from "node:fs/promises";
import path from "node:path";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { hasErrnoCode } from "../infra/errno.js";
import { isGatewayServiceEnv } from "./constants.js";
import { resolveDaemonHomeDir } from "./paths.js";
import type { GatewayServiceEnv } from "./service-types.js";
import { execSystemctl, isSystemdUnitActive, type SystemdUnitScope } from "./systemd-exec.js";
import { resolveSystemdServiceName, resolveSystemdUnitPath } from "./systemd-service-files.js";
@ -13,6 +16,70 @@ const SYSTEM_SYSTEMD_UNIT_DIRS = [
"/lib/systemd/system",
] as const;
/** Proves service absence without interpreting failed manager commands as absence. */
export async function isSystemdServiceAbsent(env: GatewayServiceEnv): Promise<boolean> {
if (
env.DBUS_SESSION_BUS_ADDRESS ||
env.DBUS_SYSTEM_BUS_ADDRESS ||
env.SYSTEMD_UNIT_PATH ||
env.SUDO_USER ||
isGatewayServiceEnv(env) ||
typeof process.geteuid !== "function"
) {
return false;
}
const home = resolveDaemonHomeDir(env);
const runtimeDirs = new Set(
[`/run/user/${process.geteuid()}`, env.XDG_RUNTIME_DIR].filter((value): value is string =>
Boolean(value),
),
);
const configHome = env.XDG_CONFIG_HOME || path.posix.join(home, ".config");
const dataHome = env.XDG_DATA_HOME || path.posix.join(home, ".local/share");
const userRoots = [
path.posix.join(home, ".config"),
configHome,
dataHome,
...(env.XDG_CONFIG_DIRS || "/etc/xdg").split(":"),
...(env.XDG_DATA_DIRS || "/usr/local/share:/usr/share").split(":"),
"/etc",
"/usr/local/lib",
"/usr/lib",
"/lib",
];
const unitName = `${resolveSystemdServiceName(env)}.service`;
if (![...runtimeDirs, ...userRoots].every((dir) => path.posix.isAbsolute(dir))) {
return false;
}
// sd_booted() uses /run/systemd/system; user managers own runtime/systemd/private.
// Require the complete runtime directory absent so transient/generated units cannot hide.
const absentPaths = [
"/run/systemd",
...[...runtimeDirs].map((dir) => path.posix.join(dir, "systemd")),
...userRoots.flatMap((dir) =>
["user", "user.control", "user.attached"].map((scope) =>
path.posix.join(dir, "systemd", scope, unitName),
),
),
...["/etc", "/usr/local/lib", "/usr/lib", "/lib"].flatMap((dir) =>
["system", "system.control", "system.attached"].map((scope) =>
path.posix.join(dir, "systemd", scope, unitName),
),
),
];
for (const candidate of absentPaths) {
try {
await fs.lstat(candidate);
return false;
} catch (error) {
if (!hasErrnoCode(error, "ENOENT")) {
return false;
}
}
}
return (await findInstalledSystemdGatewayScope(env)) === null;
}
async function findSystemSystemdUnitPath(env: GatewayServiceEnv): Promise<string | null> {
const serviceFile = `${resolveSystemdServiceName(env)}.service`;
for (const dir of SYSTEM_SYSTEMD_UNIT_DIRS) {

View file

@ -0,0 +1,112 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import {
GatewayLockError,
readActiveGatewayLockIdentity,
readActiveGatewayLockPort,
} from "./gateway-lock.js";
const fixtureRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-gateway-lock-inspect-" });
async function makeEnv() {
const dir = await fixtureRootTracker.make("case");
const configPath = path.join(dir, "openclaw.json");
await fs.writeFile(configPath, "{}", "utf8");
return {
...process.env,
OPENCLAW_STATE_DIR: dir,
OPENCLAW_CONFIG_PATH: configPath,
};
}
function resolveLockPaths(env: NodeJS.ProcessEnv) {
const stateDir = resolveStateDir(env);
const configPath = resolveConfigPath(env, stateDir);
const configHash = createHash("sha256").update(configPath).digest("hex").slice(0, 8);
const lockDir = path.join(stateDir, "__locks");
return {
lockDir,
lockPath: path.join(lockDir, `gateway.${configHash}.lock`),
stateLockPath: path.join(lockDir, "gateway.state.lock"),
configPath,
};
}
describe("gateway lock inspection", () => {
beforeAll(async () => {
await fixtureRootTracker.setup();
});
afterAll(async () => {
await fixtureRootTracker.cleanup();
});
afterEach(() => {
vi.restoreAllMocks();
});
it.each([
{ state: "missing", file: "config", expected: "absent" },
{ state: "dead", file: "config", expected: "absent" },
{ state: "other role", file: "state", expected: "absent" },
{ state: "active", file: "state", expected: "active" },
{ state: "missing port", file: "config", expected: "unavailable" },
...["config", "state"].flatMap((file) =>
["corrupt", "unreadable", "unknown owner"].map((state) => ({
state,
file,
expected: "unavailable",
})),
),
])(
"preserves strict lock inspection for $state $file locks without changing discovery",
async ({ state, file, expected }) => {
const env = await makeEnv();
const { lockDir, lockPath, stateLockPath, configPath } = resolveLockPaths(env);
await fs.mkdir(lockDir, { recursive: true });
const target = file === "state" ? stateLockPath : lockPath;
if (state !== "missing") {
const payload = {
pid: process.pid,
createdAt: new Date().toISOString(),
configPath,
startTime: 111,
...(state !== "missing port" ? { port: 48789 } : {}),
...(state === "other role" ? { role: "sqlite-maintenance" as const } : {}),
};
await fs.writeFile(target, state === "corrupt" ? "{" : JSON.stringify(payload));
}
if (state === "unreadable") {
const readFile = fs.readFile;
vi.spyOn(fs, "readFile").mockImplementation(async (filePath, options) => {
if (filePath === target) {
throw Object.assign(new Error("permission denied"), { code: "EACCES" });
}
return readFile(filePath, options);
});
}
const options = {
env,
lockDir,
platform: "linux" as const,
readProcessStartTime: () => (state === "dead" ? 222 : null),
readProcessCmdline: () => (state === "unknown owner" ? null : ["openclaw-gateway"]),
};
await expect(readActiveGatewayLockPort(options)).resolves.toBe(
expected === "active" ? 48789 : undefined,
);
const strict = readActiveGatewayLockIdentity({ ...options, requireInspection: true });
if (expected === "unavailable") {
await expect(strict).rejects.toBeInstanceOf(GatewayLockError);
} else if (expected === "active") {
await expect(strict).resolves.toMatchObject({ pid: process.pid, port: 48789 });
} else {
await expect(strict).resolves.toBeUndefined();
}
},
);
});

View file

@ -469,22 +469,6 @@ describe("gateway lock", () => {
}
});
it("ignores active-port metadata when the lock owner cannot be verified", async () => {
const env = await makeEnv();
const { lockPath, configPath } = resolveLockPath(env);
const payload = createLockPayload({ configPath, startTime: 111, port: 48789 });
await fs.writeFile(lockPath, JSON.stringify(payload), "utf8");
await expect(
readActiveGatewayLockPort({
env,
lockDir: resolveTestLockDir(env),
platform: "darwin",
readProcessCmdline: () => null,
}),
).resolves.toBeUndefined();
});
it("treats recycled linux pid as stale when start time mismatches", async () => {
const env = await makeEnv();
const { lockPath, configPath } = resolveLockPath(env);

View file

@ -15,6 +15,7 @@ import { getFileLockProcessStartTime, isPidAlive } from "../shared/pid-alive.js"
import { safeParseJsonWithSchema } from "../utils/zod-parse.js";
import { resolveIdentityPathViaExistingAncestorSync } from "./boundary-path.js";
import { sha256HexPrefixCore } from "./crypto-digest.js";
import { hasErrnoCode } from "./errno.js";
import { createFileLockManager } from "./file-lock-manager.js";
import {
isGatewayArgv,
@ -243,11 +244,20 @@ async function resolveGatewayOwnerStatus(
return isGatewayArgv(args, { allowGatewayBinary: true }) ? "alive" : "dead";
}
async function readLockPayload(lockPath: string): Promise<LockPayload | null> {
async function readLockPayload(
lockPath: string,
requireInspection = false,
): Promise<LockPayload | null> {
try {
const raw = await fs.readFile(lockPath, "utf8");
return parseGatewayLockPayload(raw);
} catch {
const payload = parseGatewayLockPayload(await fs.readFile(lockPath, "utf8"));
if (requireInspection && !payload) {
throw new GatewayLockError("Gateway lock payload could not be verified");
}
return payload;
} catch (error) {
if (requireInspection && !hasErrnoCode(error, "ENOENT")) {
throw new GatewayLockError("Gateway lock inspection is unavailable", error);
}
return null;
}
}
@ -307,20 +317,19 @@ function resolveGatewayLockPaths(env: NodeJS.ProcessEnv, suppliedLockDir?: strin
};
}
type GatewayLockObservationOptions = Pick<
GatewayLockOptions,
"env" | "lockDir" | "platform" | "readProcessCmdline" | "readProcessStartTime"
> & { requireInspection?: boolean };
export async function readActiveGatewayLockPort(
opts: Pick<
GatewayLockOptions,
"env" | "lockDir" | "platform" | "readProcessCmdline" | "readProcessStartTime"
> = {},
opts: GatewayLockObservationOptions = {},
): Promise<number | undefined> {
return (await readActiveGatewayLockIdentity(opts))?.port;
}
export async function readActiveGatewayLockIdentity(
opts: Pick<
GatewayLockOptions,
"env" | "lockDir" | "platform" | "readProcessCmdline" | "readProcessStartTime"
> = {},
opts: GatewayLockObservationOptions = {},
): Promise<GatewayLockIdentity | undefined> {
const env = opts.env ?? process.env;
const { configLockPath, stateLockPath } = resolveGatewayLockPaths(env, opts.lockDir);
@ -330,10 +339,10 @@ export async function readActiveGatewayLockIdentity(
async function readVerifiedGatewayLockIdentity(
lockPath: string,
opts: Pick<GatewayLockOptions, "platform" | "readProcessCmdline" | "readProcessStartTime">,
opts: GatewayLockObservationOptions,
): Promise<GatewayLockIdentity | undefined> {
const payload = await readLockPayload(lockPath);
if (!payload?.port || (payload.role && payload.role !== "gateway")) {
const payload = await readLockPayload(lockPath, opts.requireInspection);
if (!payload || (payload.role && payload.role !== "gateway")) {
return undefined;
}
const ownerStatus = await resolveGatewayOwnerStatus(
@ -344,7 +353,15 @@ async function readVerifiedGatewayLockIdentity(
opts.readProcessStartTime,
{ trustUnknownCmdlineOwner: false },
);
if (ownerStatus !== "alive") {
// Discovery may omit an unverifiable owner; mutation preflight must preserve unknown.
if (
opts.requireInspection &&
ownerStatus !== "dead" &&
(ownerStatus === "unknown" || !payload.port)
) {
throw new GatewayLockError("Gateway lock owner identity could not be verified");
}
if (ownerStatus !== "alive" || !payload.port) {
return undefined;
}
return {

View file

@ -1653,6 +1653,194 @@ describe("install-sh E2E runner", () => {
});
describe("install-sh smoke runner", () => {
it.runIf(process.platform !== "win32").each([0, 23])(
"reaps the heartbeat timer and preserves command exit %i",
(exitCode) => {
const root = tempDirs.make("openclaw-smoke-heartbeat-");
const bin = join(root, "bin");
const pidFile = join(root, "timer.pid");
mkdirSync(bin);
writeFileSync(
join(bin, "sleep"),
'#!/bin/bash\nexec >/dev/null 2>&1\nprintf "%s" "$$" >"$SLEEP_PID_FILE"\nexec /bin/sleep "$@"\n',
{ mode: 0o755 },
);
const runner = readFileSync(SMOKE_RUNNER_PATH, "utf8");
const heartbeat = runner.slice(
runner.indexOf("run_with_heartbeat() {"),
runner.indexOf("\nis_self_swapped_package_process_exit()"),
);
const command = `
const fs = require("node:fs");
const timer = setInterval(() => {
if (fs.existsSync(process.env.SLEEP_PID_FILE) && /^\\d+$/.test(fs.readFileSync(process.env.SLEEP_PID_FILE, "utf8"))) {
clearInterval(timer);
process.exit(${exitCode});
}
}, 5);
setTimeout(() => process.exit(99), 2000).unref();
`;
let timerPid = 0;
try {
const result = spawnSync(
"bash",
[
"-c",
`set -euo pipefail
HEARTBEAT_INTERVAL=60
${heartbeat}
command_result=0
run_with_heartbeat fixture "$HOST_NODE" -e "$COMMAND_SOURCE" || command_result=$?
printf 'command-status=%s\\n' "$command_result"
`,
],
{
encoding: "utf8",
timeout: 5_000,
env: {
HOME: root,
PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`,
SLEEP_PID_FILE: pidFile,
HOST_NODE: process.execPath,
COMMAND_SOURCE: command,
},
},
);
timerPid = Number(readFileSync(pidFile, "utf8"));
expect(result.status, result.stderr).toBe(0);
expect(result.stdout.trim()).toBe(`command-status=${exitCode}`);
expect(timerPid).toBeGreaterThan(0);
expect(isProcessAlive(timerPid)).toBe(false);
} finally {
if (timerPid && isProcessAlive(timerPid)) {
process.kill(timerPid, "SIGKILL");
}
}
},
);
it.runIf(process.platform !== "win32").each([
{ scenario: "idle", exitCode: 0, updateCount: 2 },
{ scenario: "candidate refusal", exitCode: 21, updateCount: 2 },
{ scenario: "live process", exitCode: 1, updateCount: 0 },
{ scenario: "service definition", exitCode: 1, updateCount: 0 },
{ scenario: "service manager", exitCode: 1, updateCount: 0 },
{ scenario: "inspection failure", exitCode: 1, updateCount: 0 },
])(
"uses the baseline manual path only after offline proof and checks candidate defaults: $scenario",
({ scenario, exitCode, updateCount }) => {
const root = tempDirs.make("openclaw-update-smoke-");
const bin = join(root, "bin");
const globalRoot = join(root, "node_modules");
const versionFile = join(root, "version");
const callsFile = join(root, "updates.jsonl");
const preload = join(root, "native-inspection.cjs");
mkdirSync(bin);
mkdirSync(join(globalRoot, "openclaw"), { recursive: true });
writeFileSync(join(globalRoot, "openclaw", "package.json"), '{"version":"2026.8.2"}');
writeFileSync(versionFile, "2026.8.2");
writeFileSync(callsFile, "");
symlinkSync(process.execPath, join(bin, "node"));
writeFileSync(
join(bin, "npm"),
'#!/bin/bash\nif [[ " $* " == *" root -g "* ]]; then printf "%s\\n" "$FAKE_GLOBAL_ROOT"; fi\n',
{ mode: 0o755 },
);
writeFileSync(join(bin, "timeout"), '#!/bin/bash\nshift 2\nexec "$@"\n', { mode: 0o755 });
writeFileSync(
join(bin, "openclaw"),
`#!${process.execPath}
const fs = require("node:fs");
const args = process.argv.slice(2);
if (args[0] === "--version") {
console.log("OpenClaw " + fs.readFileSync(process.env.FAKE_VERSION_FILE, "utf8"));
} else if (args[0] === "update") {
const before = fs.readFileSync(process.env.FAKE_VERSION_FILE, "utf8");
fs.appendFileSync(process.env.FAKE_CALLS_FILE, JSON.stringify(args) + "\\n");
if (before === "2026.8.2" && !args.includes("--no-restart")) process.exit(20);
if (before === "2026.9.1" && (args.includes("--no-restart") || process.env.FAKE_SCENARIO === "candidate refusal")) process.exit(21);
fs.writeFileSync(process.env.FAKE_VERSION_FILE, "2026.9.1");
console.log(JSON.stringify({
status: "ok", before: { version: before }, after: { version: "2026.9.1" },
steps: [
{ name: "global update", exitCode: 0, command: "npm install " + args[args.indexOf("--tag") + 1] },
{ name: "openclaw doctor", exitCode: 0 },
],
}));
}
`,
{ mode: 0o755 },
);
// Simulate native /proc and service files; execute the complete shell runner and CLI boundary.
writeFileSync(
preload,
`const fs = require("node:fs");
const realRead = fs.readFileSync;
const realList = fs.readdirSync;
const realStat = fs.lstatSync;
const absent = () => { throw Object.assign(new Error("absent"), { code: "ENOENT" }); };
Object.defineProperty(process, "platform", { value: "linux" });
Object.defineProperty(process, "ppid", { value: 1 });
fs.lstatSync = (file, ...args) => {
if (String(file).startsWith("/run/")) {
if (process.env.FAKE_SCENARIO === "service manager") return {};
return absent();
}
return realStat(file, ...args);
};
fs.readdirSync = (file, ...args) => {
if (file === "/proc") return ["1", String(process.pid), ...(process.env.FAKE_SCENARIO === "live process" ? ["42"] : [])];
if (String(file).endsWith("/systemd")) {
if (process.env.FAKE_SCENARIO === "inspection failure") throw Object.assign(new Error("inspection denied"), { code: "EACCES" });
return process.env.FAKE_SCENARIO === "service definition" ? [{ name: "openclaw-gateway.service", parentPath: file }] : [];
}
return realList(file, ...args);
};
fs.readFileSync = (file, ...args) => {
if (file === "/proc/1/cmdline") return "bash\\0/usr/local/bin/openclaw-install-smoke\\0";
if (file === "/proc/42/cmdline") return "openclaw-gateway\\0";
return realRead(file, ...args);
};
`,
);
const result = spawnSync("bash", [SMOKE_RUNNER_PATH], {
encoding: "utf8",
env: {
HOME: root,
PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`,
NODE_OPTIONS: `--require=${preload}`,
FAKE_SCENARIO: scenario,
FAKE_GLOBAL_ROOT: globalRoot,
FAKE_VERSION_FILE: versionFile,
FAKE_CALLS_FILE: callsFile,
OPENCLAW_INSTALL_SMOKE_MODE: "update",
OPENCLAW_INSTALL_UPDATE_BASELINE: "2026.8.2",
OPENCLAW_INSTALL_UPDATE_BASELINE_TAG_URL: "http://baseline.invalid/openclaw.tgz",
OPENCLAW_INSTALL_UPDATE_EXPECT_VERSION: "2026.9.1",
OPENCLAW_INSTALL_UPDATE_TAG_URL: "http://candidate.invalid/openclaw.tgz",
OPENCLAW_INSTALL_SMOKE_HEARTBEAT_INTERVAL: "0",
},
});
const calls = readFileSync(callsFile, "utf8")
.trim()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as string[]);
expect(result.status, result.stderr).toBe(exitCode);
expect(calls).toHaveLength(updateCount);
if (updateCount > 0) {
expect(calls[0]).toContain("--no-restart");
expect(calls[1]).not.toContain("--no-restart");
}
if (exitCode === 0) {
expect(result.stdout).toContain("Verified idle container");
expect(result.stdout.trim().endsWith("OK")).toBe(true);
} else {
expect(result.stdout.trim().endsWith("OK")).toBe(false);
}
},
);
it("passes the URL and installer arguments through the timed pipeline unchanged", () => {
const installerArgs = [
"--install-method",