diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index d1713ee0e4c..34ca4efaa87 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -624,12 +624,50 @@ describe("update-cli", () => { expect(runCommandWithTimeout).toHaveBeenCalledWith( [expect.stringMatching(/node/), entryPath, "gateway", "install", "--force"], - expect.objectContaining({ timeoutMs: 60_000 }), + expect.objectContaining({ cwd: root, timeoutMs: 60_000 }), ); expect(runDaemonInstall).not.toHaveBeenCalled(); expect(runRestartScript).toHaveBeenCalled(); }); + it("updateCommand preserves invocation-relative service env overrides during refresh", async () => { + const root = createCaseDir("openclaw-updated-root"); + const entryPath = path.join(root, "dist", "entry.js"); + pathExists.mockImplementation(async (candidate: string) => candidate === entryPath); + + vi.mocked(runGatewayUpdate).mockResolvedValue({ + status: "ok", + mode: "npm", + root, + steps: [], + durationMs: 100, + }); + serviceLoaded.mockResolvedValue(true); + + await withEnvAsync( + { + OPENCLAW_STATE_DIR: "./state", + OPENCLAW_CONFIG_PATH: "./config/openclaw.json", + }, + async () => { + await updateCommand({}); + }, + ); + + expect(runCommandWithTimeout).toHaveBeenCalledWith( + [expect.stringMatching(/node/), entryPath, "gateway", "install", "--force"], + expect.objectContaining({ + cwd: root, + env: expect.objectContaining({ + OPENCLAW_STATE_DIR: path.resolve("./state"), + OPENCLAW_CONFIG_PATH: path.resolve("./config/openclaw.json"), + }), + timeoutMs: 60_000, + }), + ); + expect(runDaemonInstall).not.toHaveBeenCalled(); + }); + it("updateCommand falls back to restart when env refresh install fails", async () => { await runRestartFallbackScenario({ daemonInstall: "fail" }); }); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 6063eb5f163..d0d39e0215a 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -69,6 +69,13 @@ import { suppressDeprecations } from "./suppress-deprecations.js"; const CLI_NAME = resolveCliName(); const SERVICE_REFRESH_TIMEOUT_MS = 60_000; +const SERVICE_REFRESH_PATH_ENV_KEYS = [ + "OPENCLAW_HOME", + "OPENCLAW_STATE_DIR", + "CLAWDBOT_STATE_DIR", + "OPENCLAW_CONFIG_PATH", + "CLAWDBOT_CONFIG_PATH", +] as const; const UPDATE_QUIPS = [ "Leveled up! New skills unlocked. You're welcome.", @@ -117,6 +124,25 @@ function formatCommandFailure(stdout: string, stderr: string): string { return detail.split("\n").slice(-3).join("\n"); } +function resolveServiceRefreshEnv( + env: NodeJS.ProcessEnv, + invocationCwd: string = process.cwd(), +): NodeJS.ProcessEnv { + const resolvedEnv: NodeJS.ProcessEnv = { ...env }; + for (const key of SERVICE_REFRESH_PATH_ENV_KEYS) { + const rawValue = resolvedEnv[key]?.trim(); + if (!rawValue) { + continue; + } + if (rawValue.startsWith("~") || path.isAbsolute(rawValue) || path.win32.isAbsolute(rawValue)) { + resolvedEnv[key] = rawValue; + continue; + } + resolvedEnv[key] = path.resolve(invocationCwd, rawValue); + } + return resolvedEnv; +} + type UpdateDryRunPreview = { dryRun: true; root: string; @@ -190,6 +216,8 @@ async function refreshGatewayServiceEnv(params: { continue; } const res = await runCommandWithTimeout([resolveNodeRunner(), candidate, ...args], { + cwd: params.result.root, + env: resolveServiceRefreshEnv(process.env), timeoutMs: SERVICE_REFRESH_TIMEOUT_MS, }); if (res.code === 0) { diff --git a/src/shared/config-eval.test.ts b/src/shared/config-eval.test.ts index 2ef18d1bef6..48ddb9e3298 100644 --- a/src/shared/config-eval.test.ts +++ b/src/shared/config-eval.test.ts @@ -1,5 +1,98 @@ import { describe, expect, it } from "vitest"; -import { evaluateRuntimeEligibility } from "./config-eval.js"; +import { + evaluateRuntimeEligibility, + evaluateRuntimeRequires, + isConfigPathTruthyWithDefaults, + isTruthy, + resolveConfigPath, +} from "./config-eval.js"; + +describe("config-eval helpers", () => { + it("normalizes truthy values across primitive types", () => { + expect(isTruthy(undefined)).toBe(false); + expect(isTruthy(null)).toBe(false); + expect(isTruthy(false)).toBe(false); + expect(isTruthy(true)).toBe(true); + expect(isTruthy(0)).toBe(false); + expect(isTruthy(1)).toBe(true); + expect(isTruthy(" ")).toBe(false); + expect(isTruthy(" ok ")).toBe(true); + expect(isTruthy({})).toBe(true); + }); + + it("resolves nested config paths and missing branches safely", () => { + const config = { + browser: { + enabled: true, + nested: { + count: 1, + }, + }, + }; + + expect(resolveConfigPath(config, "browser.enabled")).toBe(true); + expect(resolveConfigPath(config, ".browser..nested.count.")).toBe(1); + expect(resolveConfigPath(config, "browser.missing.value")).toBeUndefined(); + expect(resolveConfigPath("not-an-object", "browser.enabled")).toBeUndefined(); + }); + + it("uses defaults only when config paths are unresolved", () => { + const config = { + browser: { + enabled: false, + }, + }; + + expect( + isConfigPathTruthyWithDefaults(config, "browser.enabled", { "browser.enabled": true }), + ).toBe(false); + expect( + isConfigPathTruthyWithDefaults(config, "browser.missing", { "browser.missing": true }), + ).toBe(true); + expect(isConfigPathTruthyWithDefaults(config, "browser.other", {})).toBe(false); + }); +}); + +describe("evaluateRuntimeRequires", () => { + it("accepts remote bins and remote any-bin matches", () => { + const result = evaluateRuntimeRequires({ + requires: { + bins: ["node"], + anyBins: ["bun", "deno"], + env: ["OPENAI_API_KEY"], + config: ["browser.enabled"], + }, + hasBin: () => false, + hasRemoteBin: (bin) => bin === "node", + hasAnyRemoteBin: (bins) => bins.includes("deno"), + hasEnv: (name) => name === "OPENAI_API_KEY", + isConfigPathTruthy: (path) => path === "browser.enabled", + }); + + expect(result).toBe(true); + }); + + it("rejects when any required runtime check is still unsatisfied", () => { + expect( + evaluateRuntimeRequires({ + requires: { bins: ["node"] }, + hasBin: () => false, + hasEnv: () => true, + isConfigPathTruthy: () => true, + }), + ).toBe(false); + + expect( + evaluateRuntimeRequires({ + requires: { anyBins: ["bun", "node"] }, + hasBin: () => false, + hasAnyRemoteBin: () => false, + hasEnv: () => true, + isConfigPathTruthy: () => true, + }), + ).toBe(false); + }); +}); describe("evaluateRuntimeEligibility", () => { it("rejects entries when required OS does not match local or remote", () => { diff --git a/src/shared/net/ip.test.ts b/src/shared/net/ip.test.ts index f89fb03f7ef..2ed2558214a 100644 --- a/src/shared/net/ip.test.ts +++ b/src/shared/net/ip.test.ts @@ -2,11 +2,16 @@ import { describe, expect, it } from "vitest"; import { blockedIpv6MulticastLiterals } from "./ip-test-fixtures.js"; import { extractEmbeddedIpv4FromIpv6, + isBlockedSpecialUseIpv4Address, isCanonicalDottedDecimalIPv4, + isCarrierGradeNatIpv4Address, isIpInCidr, isIpv6Address, isLegacyIpv4Literal, + isLoopbackIpAddress, isPrivateOrLoopbackIpAddress, + isRfc1918Ipv4Address, + normalizeIpAddress, parseCanonicalIpAddress, } from "./ip.js"; @@ -53,4 +58,35 @@ describe("shared ip helpers", () => { } expect(isPrivateOrLoopbackIpAddress("2001:4860:4860::8888")).toBe(false); }); + + it("normalizes canonical IP strings and loopback detection", () => { + expect(normalizeIpAddress("[::FFFF:127.0.0.1]")).toBe("127.0.0.1"); + expect(normalizeIpAddress(" [2001:DB8::1] ")).toBe("2001:db8::1"); + expect(isLoopbackIpAddress("::ffff:127.0.0.1")).toBe(true); + expect(isLoopbackIpAddress("198.18.0.1")).toBe(false); + }); + + it("classifies RFC1918 and carrier-grade-nat IPv4 ranges", () => { + expect(isRfc1918Ipv4Address("10.42.0.59")).toBe(true); + expect(isRfc1918Ipv4Address("100.64.0.1")).toBe(false); + expect(isCarrierGradeNatIpv4Address("100.64.0.1")).toBe(true); + expect(isCarrierGradeNatIpv4Address("10.42.0.59")).toBe(false); + }); + + it("blocks special-use IPv4 ranges while allowing optional RFC2544 benchmark addresses", () => { + const loopback = parseCanonicalIpAddress("127.0.0.1"); + const benchmark = parseCanonicalIpAddress("198.18.0.1"); + + expect(loopback?.kind()).toBe("ipv4"); + expect(benchmark?.kind()).toBe("ipv4"); + if (!loopback || loopback.kind() !== "ipv4" || !benchmark || benchmark.kind() !== "ipv4") { + throw new Error("expected ipv4 fixtures"); + } + + expect(isBlockedSpecialUseIpv4Address(loopback)).toBe(true); + expect(isBlockedSpecialUseIpv4Address(benchmark)).toBe(true); + expect(isBlockedSpecialUseIpv4Address(benchmark, { allowRfc2544BenchmarkRange: true })).toBe( + false, + ); + }); }); diff --git a/src/shared/node-list-parse.test.ts b/src/shared/node-list-parse.test.ts index 379f4395054..9437e31118a 100644 --- a/src/shared/node-list-parse.test.ts +++ b/src/shared/node-list-parse.test.ts @@ -6,6 +6,7 @@ describe("shared/node-list-parse", () => { expect(parseNodeList({ nodes: [{ nodeId: "node-1" }] })).toEqual([{ nodeId: "node-1" }]); expect(parseNodeList({ nodes: "nope" })).toEqual([]); expect(parseNodeList(null)).toEqual([]); + expect(parseNodeList(["not-an-object"])).toEqual([]); }); it("parses node.pair.list payloads", () => { @@ -20,5 +21,6 @@ describe("shared/node-list-parse", () => { }); expect(parsePairingList({ pending: 1, paired: "x" })).toEqual({ pending: [], paired: [] }); expect(parsePairingList(undefined)).toEqual({ pending: [], paired: [] }); + expect(parsePairingList(["not-an-object"])).toEqual({ pending: [], paired: [] }); }); }); diff --git a/src/shared/pid-alive.test.ts b/src/shared/pid-alive.test.ts index c0d714fb21a..88066f1a794 100644 --- a/src/shared/pid-alive.test.ts +++ b/src/shared/pid-alive.test.ts @@ -59,6 +59,21 @@ describe("isPidAlive", () => { expect(freshIsPidAlive(zombiePid)).toBe(false); }); }); + + it("treats unreadable linux proc status as non-zombie when kill succeeds", async () => { + const readFileSyncSpy = vi.spyOn(fsSync, "readFileSync").mockImplementation(() => { + throw new Error("no proc status"); + }); + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); + + await withLinuxProcessPlatform(async () => { + const { isPidAlive: freshIsPidAlive } = await import("./pid-alive.js"); + expect(freshIsPidAlive(42)).toBe(true); + }); + + expect(readFileSyncSpy).toHaveBeenCalledWith("/proc/42/status", "utf8"); + expect(killSpy).toHaveBeenCalledWith(42, 0); + }); }); describe("getProcessStartTime", () => { @@ -114,4 +129,19 @@ describe("getProcessStartTime", () => { expect(fresh(42)).toBe(55555); }); }); + + it("returns null for negative or non-integer start times", async () => { + const fakeStatPrefix = "42 (node) S 1 42 42 0 -1 4194304 12345 0 0 0 100 50 0 0 20 0 8 0 "; + const fakeStatSuffix = + " 123456789 5000 18446744073709551615 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0"; + mockProcReads({ + "/proc/42/stat": `${fakeStatPrefix}-1${fakeStatSuffix}`, + "/proc/43/stat": `${fakeStatPrefix}1.5${fakeStatSuffix}`, + }); + await withLinuxProcessPlatform(async () => { + const { getProcessStartTime: fresh } = await import("./pid-alive.js"); + expect(fresh(42)).toBeNull(); + expect(fresh(43)).toBeNull(); + }); + }); }); diff --git a/src/shared/requirements.test.ts b/src/shared/requirements.test.ts index 06d48ec2e58..0a05a0eb85c 100644 --- a/src/shared/requirements.test.ts +++ b/src/shared/requirements.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { buildConfigChecks, + evaluateRequirements, evaluateRequirementsFromMetadata, + evaluateRequirementsFromMetadataWithRemote, resolveMissingAnyBins, resolveMissingBins, resolveMissingEnv, @@ -79,4 +81,87 @@ describe("requirements helpers", () => { expect(res.missing.os).toEqual(["darwin"]); expect(res.eligible).toBe(false); }); + + it("evaluateRequirements reports config checks and all missing categories directly", () => { + const res = evaluateRequirements({ + always: false, + required: { + bins: ["node"], + anyBins: ["bun", "deno"], + env: ["OPENAI_API_KEY"], + config: ["browser.enabled", "gateway.enabled"], + os: ["darwin"], + }, + hasLocalBin: () => false, + hasRemoteBin: (bin) => bin === "node", + hasRemoteAnyBin: () => false, + localPlatform: "linux", + remotePlatforms: ["windows"], + isEnvSatisfied: () => false, + isConfigSatisfied: (path) => path === "gateway.enabled", + }); + + expect(res.missing).toEqual({ + bins: [], + anyBins: ["bun", "deno"], + env: ["OPENAI_API_KEY"], + config: ["browser.enabled"], + os: ["darwin"], + }); + expect(res.configChecks).toEqual([ + { path: "browser.enabled", satisfied: false }, + { path: "gateway.enabled", satisfied: true }, + ]); + expect(res.eligible).toBe(false); + }); + + it("clears missing requirements when always is true but preserves config checks", () => { + const res = evaluateRequirements({ + always: true, + required: { + bins: ["node"], + anyBins: ["bun"], + env: ["OPENAI_API_KEY"], + config: ["browser.enabled"], + os: ["darwin"], + }, + hasLocalBin: () => false, + localPlatform: "linux", + isEnvSatisfied: () => false, + isConfigSatisfied: () => false, + }); + + expect(res.missing).toEqual({ bins: [], anyBins: [], env: [], config: [], os: [] }); + expect(res.configChecks).toEqual([{ path: "browser.enabled", satisfied: false }]); + expect(res.eligible).toBe(true); + }); + + it("evaluateRequirementsFromMetadataWithRemote wires remote predicates and platforms through", () => { + const res = evaluateRequirementsFromMetadataWithRemote({ + always: false, + metadata: { + requires: { bins: ["node"], anyBins: ["bun"], env: ["OPENAI_API_KEY"] }, + os: ["darwin"], + }, + remote: { + hasBin: (bin) => bin === "node", + hasAnyBin: (bins) => bins.includes("bun"), + platforms: ["darwin"], + }, + hasLocalBin: () => false, + localPlatform: "linux", + isEnvSatisfied: (name) => name === "OPENAI_API_KEY", + isConfigSatisfied: () => true, + }); + + expect(res.required).toEqual({ + bins: ["node"], + anyBins: ["bun"], + env: ["OPENAI_API_KEY"], + config: [], + os: ["darwin"], + }); + expect(res.missing).toEqual({ bins: [], anyBins: [], env: [], config: [], os: [] }); + expect(res.eligible).toBe(true); + }); });