mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-26 01:11:37 +00:00
Merge remote-tracking branch 'origin/main' into ui/dashboard-v2.1
This commit is contained in:
@@ -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" });
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user