From 91ead0da47f5a943bf71a7a59dde69df9127fa4b Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Thu, 24 Sep 2026 15:36:02 -0700 Subject: [PATCH] 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 (cherry picked from commit b026948c8faa206ba3e9348c71a6bbf153a6771e) * test(infra): split gateway lock inspection coverage --------- Co-authored-by: Peter Steinberger Co-authored-by: Claude Fable 5.1 --- docs/cli/update.md | 9 + scripts/docker/install-sh-smoke/run.sh | 75 ++++++- .../update-command-service-maintenance.ts | 22 +- ...update-command-service.integration.test.ts | 4 + src/daemon/service.test.ts | 96 +++++++-- src/daemon/service.ts | 15 ++ src/daemon/systemd-scope.ts | 67 +++++++ src/infra/gateway-lock-inspection.test.ts | 112 +++++++++++ src/infra/gateway-lock.test.ts | 16 -- src/infra/gateway-lock.ts | 49 +++-- test/scripts/test-install-sh-docker.test.ts | 188 ++++++++++++++++++ 11 files changed, 599 insertions(+), 54 deletions(-) create mode 100644 src/infra/gateway-lock-inspection.test.ts diff --git a/docs/cli/update.md b/docs/cli/update.md index bc1b53c68f5d..fd92c8f5082a 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -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, diff --git a/scripts/docker/install-sh-smoke/run.sh b/scripts/docker/install-sh-smoke/run.sh index 36c27eefe38d..22c7989a795e 100755 --- a/scripts/docker/install-sh-smoke/run.sh +++ b/scripts/docker/install-sh-smoke/run.sh @@ -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() { diff --git a/src/cli/update-cli/update-command-service-maintenance.ts b/src/cli/update-cli/update-command-service-maintenance.ts index 606cb0de1c7a..fe275b340364 100644 --- a/src/cli/update-cli/update-command-service-maintenance.ts +++ b/src/cli/update-cli/update-command-service-maintenance.ts @@ -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, diff --git a/src/cli/update-cli/update-command-service.integration.test.ts b/src/cli/update-cli/update-command-service.integration.test.ts index ffdba737a68d..400b18651071 100644 --- a/src/cli/update-cli/update-command-service.integration.test.ts +++ b/src/cli/update-cli/update-command-service.integration.test.ts @@ -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")); diff --git a/src/daemon/service.test.ts b/src/daemon/service.test.ts index 3df360603f5c..9c923a29ed01 100644 --- a/src/daemon/service.test.ts +++ b/src/daemon/service.test.ts @@ -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((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((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((resolve) => { + listener.close(() => resolve()); + }); + } snapshot.restore(); await fs.rm(home, { recursive: true, force: true }); } diff --git a/src/daemon/service.ts b/src/daemon/service.ts index 0a6297ec2807..a25e988e37b6 100644 --- a/src/daemon/service.ts +++ b/src/daemon/service.ts @@ -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; isEnabled?: (args: GatewayServiceEnvArgs) => Promise; hasInstalledDefinition?: (args: GatewayServiceEnvArgs) => Promise; + isAbsent?: (args: GatewayServiceEnvArgs) => Promise; readDefinitionMutationCapability?: ( args: GatewayServiceEnvArgs & { environment?: GatewayServiceEnv }, ) => ReturnType; @@ -204,6 +206,18 @@ export async function readGatewayServiceState( ): Promise { 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 isSystemdServiceAbsent(env ?? process.env), hasInstalledDefinition: async ({ env }) => (await findInstalledSystemdGatewayScope(env ?? process.env)) !== null, readDefinitionMutationCapability: ({ env, environment, timeoutMs }) => diff --git a/src/daemon/systemd-scope.ts b/src/daemon/systemd-scope.ts index 0387c99f6f6d..c2f2b9820215 100644 --- a/src/daemon/systemd-scope.ts +++ b/src/daemon/systemd-scope.ts @@ -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 { + 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 { const serviceFile = `${resolveSystemdServiceName(env)}.service`; for (const dir of SYSTEM_SYSTEMD_UNIT_DIRS) { diff --git a/src/infra/gateway-lock-inspection.test.ts b/src/infra/gateway-lock-inspection.test.ts new file mode 100644 index 000000000000..ef8bf33ac686 --- /dev/null +++ b/src/infra/gateway-lock-inspection.test.ts @@ -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(); + } + }, + ); +}); diff --git a/src/infra/gateway-lock.test.ts b/src/infra/gateway-lock.test.ts index f19f9b6b93b0..e68315f4937c 100644 --- a/src/infra/gateway-lock.test.ts +++ b/src/infra/gateway-lock.test.ts @@ -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); diff --git a/src/infra/gateway-lock.ts b/src/infra/gateway-lock.ts index 5597d957a764..a7331eb3b2fd 100644 --- a/src/infra/gateway-lock.ts +++ b/src/infra/gateway-lock.ts @@ -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 { +async function readLockPayload( + lockPath: string, + requireInspection = false, +): Promise { 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 { return (await readActiveGatewayLockIdentity(opts))?.port; } export async function readActiveGatewayLockIdentity( - opts: Pick< - GatewayLockOptions, - "env" | "lockDir" | "platform" | "readProcessCmdline" | "readProcessStartTime" - > = {}, + opts: GatewayLockObservationOptions = {}, ): Promise { 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, + opts: GatewayLockObservationOptions, ): Promise { - 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 { diff --git a/test/scripts/test-install-sh-docker.test.ts b/test/scripts/test-install-sh-docker.test.ts index d56221a36fd2..aa8996cfc117 100644 --- a/test/scripts/test-install-sh-docker.test.ts +++ b/test/scripts/test-install-sh-docker.test.ts @@ -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",