Merge remote-tracking branch 'origin/main' into ui/dashboard-v2.1

This commit is contained in:
Val Alexander
2026-03-13 16:36:06 -05:00
7 changed files with 314 additions and 2 deletions

View File

@@ -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" });
});

View File

@@ -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) {

View File

@@ -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", () => {

View File

@@ -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,
);
});
});

View File

@@ -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: [] });
});
});

View File

@@ -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();
});
});
});

View File

@@ -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);
});
});