refactor: dedupe cli config cron and install flows

This commit is contained in:
Peter Steinberger 2026-03-02 19:48:38 +00:00
parent 9d30159fcd
commit b1c30f0ba9
80 changed files with 1379 additions and 2027 deletions

View File

@ -2,7 +2,12 @@ import type { Command } from "commander";
import { danger } from "../../globals.js"; import { danger } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import type { BrowserParentOpts } from "../browser-cli-shared.js"; import type { BrowserParentOpts } from "../browser-cli-shared.js";
import { callBrowserAct, requireRef, resolveBrowserActionContext } from "./shared.js"; import {
callBrowserAct,
logBrowserActionResult,
requireRef,
resolveBrowserActionContext,
} from "./shared.js";
export function registerBrowserElementCommands( export function registerBrowserElementCommands(
browser: Command, browser: Command,
@ -41,12 +46,8 @@ export function registerBrowserElementCommands(
modifiers, modifiers,
}, },
}); });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const suffix = result.url ? ` on ${result.url}` : ""; const suffix = result.url ? ` on ${result.url}` : "";
defaultRuntime.log(`clicked ref ${refValue}${suffix}`); logBrowserActionResult(parent, result, `clicked ref ${refValue}${suffix}`);
} catch (err) { } catch (err) {
defaultRuntime.error(danger(String(err))); defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1); defaultRuntime.exit(1);
@ -80,11 +81,7 @@ export function registerBrowserElementCommands(
targetId: opts.targetId?.trim() || undefined, targetId: opts.targetId?.trim() || undefined,
}, },
}); });
if (parent?.json) { logBrowserActionResult(parent, result, `typed into ref ${refValue}`);
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`typed into ref ${refValue}`);
} catch (err) { } catch (err) {
defaultRuntime.error(danger(String(err))); defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1); defaultRuntime.exit(1);
@ -104,11 +101,7 @@ export function registerBrowserElementCommands(
profile, profile,
body: { kind: "press", key, targetId: opts.targetId?.trim() || undefined }, body: { kind: "press", key, targetId: opts.targetId?.trim() || undefined },
}); });
if (parent?.json) { logBrowserActionResult(parent, result, `pressed ${key}`);
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`pressed ${key}`);
} catch (err) { } catch (err) {
defaultRuntime.error(danger(String(err))); defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1); defaultRuntime.exit(1);
@ -128,11 +121,7 @@ export function registerBrowserElementCommands(
profile, profile,
body: { kind: "hover", ref, targetId: opts.targetId?.trim() || undefined }, body: { kind: "hover", ref, targetId: opts.targetId?.trim() || undefined },
}); });
if (parent?.json) { logBrowserActionResult(parent, result, `hovered ref ${ref}`);
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`hovered ref ${ref}`);
} catch (err) { } catch (err) {
defaultRuntime.error(danger(String(err))); defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1); defaultRuntime.exit(1);
@ -165,11 +154,7 @@ export function registerBrowserElementCommands(
}, },
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined, timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
}); });
if (parent?.json) { logBrowserActionResult(parent, result, `scrolled into view: ${refValue}`);
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`scrolled into view: ${refValue}`);
} catch (err) { } catch (err) {
defaultRuntime.error(danger(String(err))); defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1); defaultRuntime.exit(1);
@ -195,11 +180,7 @@ export function registerBrowserElementCommands(
targetId: opts.targetId?.trim() || undefined, targetId: opts.targetId?.trim() || undefined,
}, },
}); });
if (parent?.json) { logBrowserActionResult(parent, result, `dragged ${startRef}${endRef}`);
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`dragged ${startRef}${endRef}`);
} catch (err) { } catch (err) {
defaultRuntime.error(danger(String(err))); defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1); defaultRuntime.exit(1);
@ -225,11 +206,7 @@ export function registerBrowserElementCommands(
targetId: opts.targetId?.trim() || undefined, targetId: opts.targetId?.trim() || undefined,
}, },
}); });
if (parent?.json) { logBrowserActionResult(parent, result, `selected ${values.join(", ")}`);
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`selected ${values.join(", ")}`);
} catch (err) { } catch (err) {
defaultRuntime.error(danger(String(err))); defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1); defaultRuntime.exit(1);

View File

@ -2,7 +2,12 @@ import type { Command } from "commander";
import { danger } from "../../globals.js"; import { danger } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import type { BrowserParentOpts } from "../browser-cli-shared.js"; import type { BrowserParentOpts } from "../browser-cli-shared.js";
import { callBrowserAct, readFields, resolveBrowserActionContext } from "./shared.js"; import {
callBrowserAct,
logBrowserActionResult,
readFields,
resolveBrowserActionContext,
} from "./shared.js";
export function registerBrowserFormWaitEvalCommands( export function registerBrowserFormWaitEvalCommands(
browser: Command, browser: Command,
@ -30,11 +35,7 @@ export function registerBrowserFormWaitEvalCommands(
targetId: opts.targetId?.trim() || undefined, targetId: opts.targetId?.trim() || undefined,
}, },
}); });
if (parent?.json) { logBrowserActionResult(parent, result, `filled ${fields.length} field(s)`);
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`filled ${fields.length} field(s)`);
} catch (err) { } catch (err) {
defaultRuntime.error(danger(String(err))); defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1); defaultRuntime.exit(1);
@ -83,11 +84,7 @@ export function registerBrowserFormWaitEvalCommands(
}, },
timeoutMs, timeoutMs,
}); });
if (parent?.json) { logBrowserActionResult(parent, result, "wait complete");
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log("wait complete");
} catch (err) { } catch (err) {
defaultRuntime.error(danger(String(err))); defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1); defaultRuntime.exit(1);

View File

@ -40,6 +40,18 @@ export async function callBrowserAct<T = unknown>(params: {
); );
} }
export function logBrowserActionResult(
parent: BrowserParentOpts,
result: unknown,
successMessage: string,
) {
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(successMessage);
}
export function requireRef(ref: string | undefined) { export function requireRef(ref: string | undefined) {
const refValue = typeof ref === "string" ? ref.trim() : ""; const refValue = typeof ref === "string" ? ref.trim() : "";
if (!refValue) { if (!refValue) {

View File

@ -35,13 +35,7 @@ vi.mock("./cli-utils.js", () => ({
_runtime: unknown, _runtime: unknown,
action: () => Promise<void>, action: () => Promise<void>,
onError: (err: unknown) => void, onError: (err: unknown) => void,
) => { ) => await action().catch(onError),
try {
await action();
} catch (err) {
onError(err);
}
},
})); }));
vi.mock("../runtime.js", () => ({ vi.mock("../runtime.js", () => ({

View File

@ -9,6 +9,7 @@ import { parsePositiveIntOrUndefined } from "../program/helpers.js";
import { import {
getCronChannelOptions, getCronChannelOptions,
parseAt, parseAt,
parseCronStaggerMs,
parseDurationMs, parseDurationMs,
printCronList, printCronList,
warnIfCronSchedulerDisabled, warnIfCronSchedulerDisabled,
@ -129,19 +130,7 @@ export function registerCronAddCommand(cron: Command) {
} }
return { kind: "every" as const, everyMs }; return { kind: "every" as const, everyMs };
} }
const staggerMs = (() => { const staggerMs = parseCronStaggerMs({ staggerRaw, useExact });
if (useExact) {
return 0;
}
if (!staggerRaw) {
return undefined;
}
const parsed = parseDurationMs(staggerRaw);
if (!parsed) {
throw new Error("Invalid --stagger; use e.g. 30s, 1m, 5m");
}
return parsed;
})();
return { return {
kind: "cron" as const, kind: "cron" as const,
expr: cronExpr, expr: cronExpr,

View File

@ -7,6 +7,7 @@ import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import { import {
getCronChannelOptions, getCronChannelOptions,
parseAt, parseAt,
parseCronStaggerMs,
parseDurationMs, parseDurationMs,
warnIfCronSchedulerDisabled, warnIfCronSchedulerDisabled,
} from "./shared.js"; } from "./shared.js";
@ -98,19 +99,7 @@ export function registerCronEditCommand(cron: Command) {
if (staggerRaw && useExact) { if (staggerRaw && useExact) {
throw new Error("Choose either --stagger or --exact, not both"); throw new Error("Choose either --stagger or --exact, not both");
} }
const requestedStaggerMs = (() => { const requestedStaggerMs = parseCronStaggerMs({ staggerRaw, useExact });
if (useExact) {
return 0;
}
if (!staggerRaw) {
return undefined;
}
const parsed = parseDurationMs(staggerRaw);
if (!parsed) {
throw new Error("Invalid --stagger; use e.g. 30s, 1m, 5m");
}
return parsed;
})();
const patch: Record<string, unknown> = {}; const patch: Record<string, unknown> = {};
if (typeof opts.name === "string") { if (typeof opts.name === "string") {

View File

@ -62,6 +62,23 @@ export function parseDurationMs(input: string): number | null {
return Math.floor(n * factor); return Math.floor(n * factor);
} }
export function parseCronStaggerMs(params: {
staggerRaw: string;
useExact: boolean;
}): number | undefined {
if (params.useExact) {
return 0;
}
if (!params.staggerRaw) {
return undefined;
}
const parsed = parseDurationMs(params.staggerRaw);
if (!parsed) {
throw new Error("Invalid --stagger; use e.g. 30s, 1m, 5m");
}
return parsed;
}
export function parseAt(input: string): string | null { export function parseAt(input: string): string | null {
const raw = input.trim(); const raw = input.trim();
if (!raw) { if (!raw) {

View File

@ -15,6 +15,32 @@ vi.mock("../../infra/ports.js", () => ({
const originalPlatform = process.platform; const originalPlatform = process.platform;
async function inspectUnknownListenerFallback(params: {
runtime: { status: "running"; pid: number } | { status: "stopped" };
includeUnknownListenersAsStale: boolean;
}) {
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
classifyPortListener.mockReturnValue("unknown");
const service = {
readRuntime: vi.fn(async () => params.runtime),
} as unknown as GatewayService;
inspectPortUsage.mockResolvedValue({
port: 18789,
status: "busy",
listeners: [{ pid: 10920, command: "unknown" }],
hints: [],
});
const { inspectGatewayRestart } = await import("./restart-health.js");
return inspectGatewayRestart({
service,
port: 18789,
includeUnknownListenersAsStale: params.includeUnknownListenersAsStale,
});
}
describe("inspectGatewayRestart", () => { describe("inspectGatewayRestart", () => {
beforeEach(() => { beforeEach(() => {
inspectPortUsage.mockReset(); inspectPortUsage.mockReset();
@ -71,24 +97,8 @@ describe("inspectGatewayRestart", () => {
}); });
it("treats unknown listeners as stale on Windows when enabled", async () => { it("treats unknown listeners as stale on Windows when enabled", async () => {
Object.defineProperty(process, "platform", { value: "win32", configurable: true }); const snapshot = await inspectUnknownListenerFallback({
classifyPortListener.mockReturnValue("unknown"); runtime: { status: "stopped" },
const service = {
readRuntime: vi.fn(async () => ({ status: "stopped" })),
} as unknown as GatewayService;
inspectPortUsage.mockResolvedValue({
port: 18789,
status: "busy",
listeners: [{ pid: 10920, command: "unknown" }],
hints: [],
});
const { inspectGatewayRestart } = await import("./restart-health.js");
const snapshot = await inspectGatewayRestart({
service,
port: 18789,
includeUnknownListenersAsStale: true, includeUnknownListenersAsStale: true,
}); });
@ -96,24 +106,8 @@ describe("inspectGatewayRestart", () => {
}); });
it("does not treat unknown listeners as stale when fallback is disabled", async () => { it("does not treat unknown listeners as stale when fallback is disabled", async () => {
Object.defineProperty(process, "platform", { value: "win32", configurable: true }); const snapshot = await inspectUnknownListenerFallback({
classifyPortListener.mockReturnValue("unknown"); runtime: { status: "stopped" },
const service = {
readRuntime: vi.fn(async () => ({ status: "stopped" })),
} as unknown as GatewayService;
inspectPortUsage.mockResolvedValue({
port: 18789,
status: "busy",
listeners: [{ pid: 10920, command: "unknown" }],
hints: [],
});
const { inspectGatewayRestart } = await import("./restart-health.js");
const snapshot = await inspectGatewayRestart({
service,
port: 18789,
includeUnknownListenersAsStale: false, includeUnknownListenersAsStale: false,
}); });
@ -121,24 +115,8 @@ describe("inspectGatewayRestart", () => {
}); });
it("does not apply unknown-listener fallback while runtime is running", async () => { it("does not apply unknown-listener fallback while runtime is running", async () => {
Object.defineProperty(process, "platform", { value: "win32", configurable: true }); const snapshot = await inspectUnknownListenerFallback({
classifyPortListener.mockReturnValue("unknown"); runtime: { status: "running", pid: 10920 },
const service = {
readRuntime: vi.fn(async () => ({ status: "running", pid: 10920 })),
} as unknown as GatewayService;
inspectPortUsage.mockResolvedValue({
port: 18789,
status: "busy",
listeners: [{ pid: 10920, command: "unknown" }],
hints: [],
});
const { inspectGatewayRestart } = await import("./restart-health.js");
const snapshot = await inspectGatewayRestart({
service,
port: 18789,
includeUnknownListenersAsStale: true, includeUnknownListenersAsStale: true,
}); });

View File

@ -10,6 +10,7 @@ import type { FindExtraGatewayServicesOptions } from "../../daemon/inspect.js";
import { findExtraGatewayServices } from "../../daemon/inspect.js"; import { findExtraGatewayServices } from "../../daemon/inspect.js";
import type { ServiceConfigAudit } from "../../daemon/service-audit.js"; import type { ServiceConfigAudit } from "../../daemon/service-audit.js";
import { auditGatewayServiceConfig } from "../../daemon/service-audit.js"; import { auditGatewayServiceConfig } from "../../daemon/service-audit.js";
import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js";
import { resolveGatewayService } from "../../daemon/service.js"; import { resolveGatewayService } from "../../daemon/service.js";
import { resolveGatewayBindHost } from "../../gateway/net.js"; import { resolveGatewayBindHost } from "../../gateway/net.js";
import { import {
@ -54,19 +55,7 @@ export type DaemonStatus = {
environment?: Record<string, string>; environment?: Record<string, string>;
sourcePath?: string; sourcePath?: string;
} | null; } | null;
runtime?: { runtime?: GatewayServiceRuntime;
status?: string;
state?: string;
subState?: string;
pid?: number;
lastExitStatus?: number;
lastExitReason?: string;
lastRunResult?: string;
lastRunTime?: string;
detail?: string;
cachedLabel?: boolean;
missingUnit?: boolean;
};
configAudit?: ServiceConfigAudit; configAudit?: ServiceConfigAudit;
}; };
config?: { config?: {

View File

@ -26,6 +26,7 @@ import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { resolveUserPath, shortenHomePath } from "../utils.js"; import { resolveUserPath, shortenHomePath } from "../utils.js";
import { formatCliCommand } from "./command-format.js"; import { formatCliCommand } from "./command-format.js";
import { looksLikeLocalInstallSpec } from "./install-spec.js";
import { import {
buildNpmInstallRecordFields, buildNpmInstallRecordFields,
resolvePinnedNpmInstallRecordForCli, resolvePinnedNpmInstallRecordForCli,
@ -660,15 +661,7 @@ export function registerHooksCli(program: Command): void {
process.exit(1); process.exit(1);
} }
const looksLikePath = if (looksLikeLocalInstallSpec(raw, [".zip", ".tgz", ".tar.gz", ".tar"])) {
raw.startsWith(".") ||
raw.startsWith("~") ||
path.isAbsolute(raw) ||
raw.endsWith(".zip") ||
raw.endsWith(".tgz") ||
raw.endsWith(".tar.gz") ||
raw.endsWith(".tar");
if (looksLikePath) {
defaultRuntime.error(`Path not found: ${resolved}`); defaultRuntime.error(`Path not found: ${resolved}`);
process.exit(1); process.exit(1);
} }

10
src/cli/install-spec.ts Normal file
View File

@ -0,0 +1,10 @@
import path from "node:path";
export function looksLikeLocalInstallSpec(spec: string, knownSuffixes: readonly string[]): boolean {
return (
spec.startsWith(".") ||
spec.startsWith("~") ||
path.isAbsolute(spec) ||
knownSuffixes.some((suffix) => spec.endsWith(suffix))
);
}

View File

@ -1,11 +1,7 @@
export type NpmResolutionMetadata = { import {
name?: string; buildNpmResolutionFields,
version?: string; type NpmSpecResolution as NpmResolutionMetadata,
resolvedSpec?: string; } from "../infra/install-source-utils.js";
integrity?: string;
shasum?: string;
resolvedAt?: string;
};
export function resolvePinnedNpmSpec(params: { export function resolvePinnedNpmSpec(params: {
rawSpec: string; rawSpec: string;
@ -36,14 +32,7 @@ export function mapNpmResolutionMetadata(resolution?: NpmResolutionMetadata): {
shasum?: string; shasum?: string;
resolvedAt?: string; resolvedAt?: string;
} { } {
return { return buildNpmResolutionFields(resolution);
resolvedName: resolution?.name,
resolvedVersion: resolution?.version,
resolvedSpec: resolution?.resolvedSpec,
integrity: resolution?.integrity,
shasum: resolution?.shasum,
resolvedAt: resolution?.resolvedAt,
};
} }
export function buildNpmInstallRecordFields(params: { export function buildNpmInstallRecordFields(params: {
@ -68,7 +57,7 @@ export function buildNpmInstallRecordFields(params: {
spec: params.spec, spec: params.spec,
installPath: params.installPath, installPath: params.installPath,
version: params.version, version: params.version,
...mapNpmResolutionMetadata(params.resolution), ...buildNpmResolutionFields(params.resolution),
}; };
} }

View File

@ -22,6 +22,7 @@ import { formatDocsLink } from "../terminal/links.js";
import { renderTable } from "../terminal/table.js"; import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { resolveUserPath, shortenHomeInString, shortenHomePath } from "../utils.js"; import { resolveUserPath, shortenHomeInString, shortenHomePath } from "../utils.js";
import { looksLikeLocalInstallSpec } from "./install-spec.js";
import { resolvePinnedNpmInstallRecordForCli } from "./npm-resolution.js"; import { resolvePinnedNpmInstallRecordForCli } from "./npm-resolution.js";
import { setPluginEnabledInConfig } from "./plugins-config.js"; import { setPluginEnabledInConfig } from "./plugins-config.js";
import { promptYesNo } from "./prompt.js"; import { promptYesNo } from "./prompt.js";
@ -603,19 +604,18 @@ export function registerPluginsCli(program: Command) {
process.exit(1); process.exit(1);
} }
const looksLikePath = if (
raw.startsWith(".") || looksLikeLocalInstallSpec(raw, [
raw.startsWith("~") || ".ts",
path.isAbsolute(raw) || ".js",
raw.endsWith(".ts") || ".mjs",
raw.endsWith(".js") || ".cjs",
raw.endsWith(".mjs") || ".tgz",
raw.endsWith(".cjs") || ".tar.gz",
raw.endsWith(".tgz") || ".tar",
raw.endsWith(".tar.gz") || ".zip",
raw.endsWith(".tar") || ])
raw.endsWith(".zip"); ) {
if (looksLikePath) {
defaultRuntime.error(`Path not found: ${resolved}`); defaultRuntime.error(`Path not found: ${resolved}`);
process.exit(1); process.exit(1);
} }

View File

@ -65,6 +65,18 @@ describe("cli program (nodes media)", () => {
await program.parseAsync(argv, { from: "user" }); await program.parseAsync(argv, { from: "user" });
} }
async function expectCameraSnapParseFailure(args: string[], expectedError: RegExp) {
mockNodeGateway();
const parseProgram = new Command();
parseProgram.exitOverride();
registerNodesCli(parseProgram);
runtime.error.mockClear();
await expect(parseProgram.parseAsync(args, { from: "user" })).rejects.toThrow(/exit/i);
expect(runtime.error.mock.calls.some(([msg]) => expectedError.test(String(msg)))).toBe(true);
}
async function runAndExpectUrlPayloadMediaFile(params: { async function runAndExpectUrlPayloadMediaFile(params: {
command: "camera.snap" | "camera.clip"; command: "camera.snap" | "camera.clip";
payload: Record<string, unknown>; payload: Record<string, unknown>;
@ -266,54 +278,27 @@ describe("cli program (nodes media)", () => {
}); });
it("fails nodes camera snap on invalid facing", async () => { it("fails nodes camera snap on invalid facing", async () => {
mockNodeGateway(); await expectCameraSnapParseFailure(
["nodes", "camera", "snap", "--node", "ios-node", "--facing", "nope"],
const program = new Command(); /invalid facing/i,
program.exitOverride();
registerNodesCli(program);
runtime.error.mockClear();
await expect(
program.parseAsync(["nodes", "camera", "snap", "--node", "ios-node", "--facing", "nope"], {
from: "user",
}),
).rejects.toThrow(/exit/i);
expect(runtime.error.mock.calls.some(([msg]) => /invalid facing/i.test(String(msg)))).toBe(
true,
); );
}); });
it("fails nodes camera snap when --facing both and --device-id are combined", async () => { it("fails nodes camera snap when --facing both and --device-id are combined", async () => {
mockNodeGateway(); await expectCameraSnapParseFailure(
[
const program = new Command(); "nodes",
program.exitOverride(); "camera",
registerNodesCli(program); "snap",
runtime.error.mockClear(); "--node",
"ios-node",
await expect( "--facing",
program.parseAsync( "both",
[ "--device-id",
"nodes", "cam-123",
"camera", ],
"snap", /facing=both is not allowed when --device-id is set/i,
"--node", );
"ios-node",
"--facing",
"both",
"--device-id",
"cam-123",
],
{ from: "user" },
),
).rejects.toThrow(/exit/i);
expect(
runtime.error.mock.calls.some(([msg]) =>
/facing=both is not allowed when --device-id is set/i.test(String(msg)),
),
).toBe(true);
}); });
describe("URL-based payloads", () => { describe("URL-based payloads", () => {

View File

@ -5,6 +5,7 @@ import path from "node:path";
import { resolveStateDir } from "../../config/paths.js"; import { resolveStateDir } from "../../config/paths.js";
import { resolveOpenClawPackageRoot } from "../../infra/openclaw-root.js"; import { resolveOpenClawPackageRoot } from "../../infra/openclaw-root.js";
import { readPackageName, readPackageVersion } from "../../infra/package-json.js"; import { readPackageName, readPackageVersion } from "../../infra/package-json.js";
import { normalizePackageTagInput } from "../../infra/package-tag.js";
import { trimLogTail } from "../../infra/restart-sentinel.js"; import { trimLogTail } from "../../infra/restart-sentinel.js";
import { parseSemver } from "../../infra/runtime-guard.js"; import { parseSemver } from "../../infra/runtime-guard.js";
import { fetchNpmTagVersion } from "../../infra/update-check.js"; import { fetchNpmTagVersion } from "../../infra/update-check.js";
@ -58,20 +59,7 @@ export const DEFAULT_PACKAGE_NAME = "openclaw";
const CORE_PACKAGE_NAMES = new Set([DEFAULT_PACKAGE_NAME]); const CORE_PACKAGE_NAMES = new Set([DEFAULT_PACKAGE_NAME]);
export function normalizeTag(value?: string | null): string | null { export function normalizeTag(value?: string | null): string | null {
if (!value) { return normalizePackageTagInput(value, ["openclaw", DEFAULT_PACKAGE_NAME]);
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith("openclaw@")) {
return trimmed.slice("openclaw@".length);
}
if (trimmed.startsWith(`${DEFAULT_PACKAGE_NAME}@`)) {
return trimmed.slice(`${DEFAULT_PACKAGE_NAME}@`.length);
}
return trimmed;
} }
export function normalizeVersionTag(tag: string): string | null { export function normalizeVersionTag(tag: string): string | null {

View File

@ -26,8 +26,8 @@ async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(fn, { prefix: "openclaw-agent-acp-" }); return withTempHomeBase(fn, { prefix: "openclaw-agent-acp-" });
} }
function mockConfig(home: string, storePath: string) { function createAcpEnabledConfig(home: string, storePath: string): OpenClawConfig {
loadConfigSpy.mockReturnValue({ return {
acp: { acp: {
enabled: true, enabled: true,
backend: "acpx", backend: "acpx",
@ -42,7 +42,11 @@ function mockConfig(home: string, storePath: string) {
}, },
}, },
session: { store: storePath, mainKey: "main" }, session: { store: storePath, mainKey: "main" },
} satisfies OpenClawConfig); };
}
function mockConfig(home: string, storePath: string) {
loadConfigSpy.mockReturnValue(createAcpEnabledConfig(home, storePath));
} }
function mockConfigWithAcpOverrides( function mockConfigWithAcpOverrides(
@ -50,23 +54,12 @@ function mockConfigWithAcpOverrides(
storePath: string, storePath: string,
acpOverrides: Partial<NonNullable<OpenClawConfig["acp"]>>, acpOverrides: Partial<NonNullable<OpenClawConfig["acp"]>>,
) { ) {
loadConfigSpy.mockReturnValue({ const cfg = createAcpEnabledConfig(home, storePath);
acp: { cfg.acp = {
enabled: true, ...cfg.acp,
backend: "acpx", ...acpOverrides,
allowedAgents: ["codex"], };
dispatch: { enabled: true }, loadConfigSpy.mockReturnValue(cfg);
...acpOverrides,
},
agents: {
defaults: {
model: { primary: "openai/gpt-5.3-codex" },
models: { "openai/gpt-5.3-codex": {} },
workspace: path.join(home, "openclaw"),
},
},
session: { store: storePath, mainKey: "main" },
} satisfies OpenClawConfig);
} }
function writeAcpSessionStore(storePath: string) { function writeAcpSessionStore(storePath: string) {

View File

@ -304,6 +304,24 @@ export function createAuthChoiceDefaultModelApplier(
}; };
} }
export function createAuthChoiceDefaultModelApplierForMutableState(
params: ApplyAuthChoiceParams,
getConfig: () => ApplyAuthChoiceParams["config"],
setConfig: (config: ApplyAuthChoiceParams["config"]) => void,
getAgentModelOverride: () => string | undefined,
setAgentModelOverride: (model: string | undefined) => void,
): ReturnType<typeof createAuthChoiceDefaultModelApplier> {
return createAuthChoiceDefaultModelApplier(
params,
createAuthChoiceModelStateBridge({
getConfig,
setConfig,
getAgentModelOverride,
setAgentModelOverride,
}),
);
}
export function normalizeTokenProviderInput( export function normalizeTokenProviderInput(
tokenProvider: string | null | undefined, tokenProvider: string | null | undefined,
): string | undefined { ): string | undefined {

View File

@ -4,8 +4,7 @@ import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key
import { import {
normalizeSecretInputModeInput, normalizeSecretInputModeInput,
createAuthChoiceAgentModelNoter, createAuthChoiceAgentModelNoter,
createAuthChoiceDefaultModelApplier, createAuthChoiceDefaultModelApplierForMutableState,
createAuthChoiceModelStateBridge,
ensureApiKeyFromOptionEnvOrPrompt, ensureApiKeyFromOptionEnvOrPrompt,
normalizeTokenProviderInput, normalizeTokenProviderInput,
} from "./auth-choice.apply-helpers.js"; } from "./auth-choice.apply-helpers.js";
@ -317,14 +316,12 @@ export async function applyAuthChoiceApiProviders(
let nextConfig = params.config; let nextConfig = params.config;
let agentModelOverride: string | undefined; let agentModelOverride: string | undefined;
const noteAgentModel = createAuthChoiceAgentModelNoter(params); const noteAgentModel = createAuthChoiceAgentModelNoter(params);
const applyProviderDefaultModel = createAuthChoiceDefaultModelApplier( const applyProviderDefaultModel = createAuthChoiceDefaultModelApplierForMutableState(
params, params,
createAuthChoiceModelStateBridge({ () => nextConfig,
getConfig: () => nextConfig, (config) => (nextConfig = config),
setConfig: (config) => (nextConfig = config), () => agentModelOverride,
getAgentModelOverride: () => agentModelOverride, (model) => (agentModelOverride = model),
setAgentModelOverride: (model) => (agentModelOverride = model),
}),
); );
let authChoice = params.authChoice; let authChoice = params.authChoice;

View File

@ -1,7 +1,6 @@
import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js"; import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js";
import { import {
createAuthChoiceDefaultModelApplier, createAuthChoiceDefaultModelApplierForMutableState,
createAuthChoiceModelStateBridge,
ensureApiKeyFromOptionEnvOrPrompt, ensureApiKeyFromOptionEnvOrPrompt,
normalizeSecretInputModeInput, normalizeSecretInputModeInput,
} from "./auth-choice.apply-helpers.js"; } from "./auth-choice.apply-helpers.js";
@ -23,14 +22,12 @@ export async function applyAuthChoiceMiniMax(
): Promise<ApplyAuthChoiceResult | null> { ): Promise<ApplyAuthChoiceResult | null> {
let nextConfig = params.config; let nextConfig = params.config;
let agentModelOverride: string | undefined; let agentModelOverride: string | undefined;
const applyProviderDefaultModel = createAuthChoiceDefaultModelApplier( const applyProviderDefaultModel = createAuthChoiceDefaultModelApplierForMutableState(
params, params,
createAuthChoiceModelStateBridge({ () => nextConfig,
getConfig: () => nextConfig, (config) => (nextConfig = config),
setConfig: (config) => (nextConfig = config), () => agentModelOverride,
getAgentModelOverride: () => agentModelOverride, (model) => (agentModelOverride = model),
setAgentModelOverride: (model) => (agentModelOverride = model),
}),
); );
const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode); const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode);
const ensureMinimaxApiKey = async (opts: { const ensureMinimaxApiKey = async (opts: {

View File

@ -22,6 +22,8 @@ vi.mock("../terminal/note.js", () => ({
note, note,
})); }));
const { maybeRepairSandboxImages } = await import("./doctor-sandbox.js");
describe("maybeRepairSandboxImages", () => { describe("maybeRepairSandboxImages", () => {
const mockRuntime: RuntimeEnv = { const mockRuntime: RuntimeEnv = {
log: vi.fn(), log: vi.fn(),
@ -37,22 +39,32 @@ describe("maybeRepairSandboxImages", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("warns when sandbox mode is enabled but Docker is not available", async () => { function createSandboxConfig(mode: "off" | "all" | "non-main"): OpenClawConfig {
// Simulate Docker not available (command fails) return {
runExec.mockRejectedValue(new Error("Docker not installed"));
const config: OpenClawConfig = {
agents: { agents: {
defaults: { defaults: {
sandbox: { sandbox: {
mode: "non-main", mode,
}, },
}, },
}, },
}; };
}
const { maybeRepairSandboxImages } = await import("./doctor-sandbox.js"); async function runSandboxRepair(params: {
await maybeRepairSandboxImages(config, mockRuntime, mockPrompter); mode: "off" | "all" | "non-main";
dockerAvailable: boolean;
}) {
if (params.dockerAvailable) {
runExec.mockResolvedValue({ stdout: "24.0.0", stderr: "" });
} else {
runExec.mockRejectedValue(new Error("Docker not installed"));
}
await maybeRepairSandboxImages(createSandboxConfig(params.mode), mockRuntime, mockPrompter);
}
it("warns when sandbox mode is enabled but Docker is not available", async () => {
await runSandboxRepair({ mode: "non-main", dockerAvailable: false });
// The warning should clearly indicate sandbox is enabled but won't work // The warning should clearly indicate sandbox is enabled but won't work
expect(note).toHaveBeenCalled(); expect(note).toHaveBeenCalled();
@ -66,20 +78,7 @@ describe("maybeRepairSandboxImages", () => {
}); });
it("warns when sandbox mode is 'all' but Docker is not available", async () => { it("warns when sandbox mode is 'all' but Docker is not available", async () => {
runExec.mockRejectedValue(new Error("Docker not installed")); await runSandboxRepair({ mode: "all", dockerAvailable: false });
const config: OpenClawConfig = {
agents: {
defaults: {
sandbox: {
mode: "all",
},
},
},
};
const { maybeRepairSandboxImages } = await import("./doctor-sandbox.js");
await maybeRepairSandboxImages(config, mockRuntime, mockPrompter);
expect(note).toHaveBeenCalled(); expect(note).toHaveBeenCalled();
const noteCall = note.mock.calls[0]; const noteCall = note.mock.calls[0];
@ -90,41 +89,14 @@ describe("maybeRepairSandboxImages", () => {
}); });
it("does not warn when sandbox mode is off", async () => { it("does not warn when sandbox mode is off", async () => {
runExec.mockRejectedValue(new Error("Docker not installed")); await runSandboxRepair({ mode: "off", dockerAvailable: false });
const config: OpenClawConfig = {
agents: {
defaults: {
sandbox: {
mode: "off",
},
},
},
};
const { maybeRepairSandboxImages } = await import("./doctor-sandbox.js");
await maybeRepairSandboxImages(config, mockRuntime, mockPrompter);
// No warning needed when sandbox is off // No warning needed when sandbox is off
expect(note).not.toHaveBeenCalled(); expect(note).not.toHaveBeenCalled();
}); });
it("does not warn when Docker is available", async () => { it("does not warn when Docker is available", async () => {
// Simulate Docker available await runSandboxRepair({ mode: "non-main", dockerAvailable: true });
runExec.mockResolvedValue({ stdout: "24.0.0", stderr: "" });
const config: OpenClawConfig = {
agents: {
defaults: {
sandbox: {
mode: "non-main",
},
},
},
};
const { maybeRepairSandboxImages } = await import("./doctor-sandbox.js");
await maybeRepairSandboxImages(config, mockRuntime, mockPrompter);
// May have other notes about images, but not the Docker unavailable warning // May have other notes about images, but not the Docker unavailable warning
const dockerUnavailableWarning = note.mock.calls.find( const dockerUnavailableWarning = note.mock.calls.find(

View File

@ -95,6 +95,73 @@ function patchTelegramAdapter(overrides: Parameters<typeof patchChannelOnboardin
}); });
} }
function createUnexpectedConfigureCall(message: string) {
return vi.fn(async () => {
throw new Error(message);
});
}
async function runConfiguredTelegramSetup(params: {
strictUnexpected?: boolean;
configureWhenConfigured: NonNullable<
Parameters<typeof patchTelegramAdapter>[0]["configureWhenConfigured"]
>;
configureErrorMessage: string;
}) {
const select = createQuickstartTelegramSelect({ strictUnexpected: params.strictUnexpected });
const selection = vi.fn();
const onAccountId = vi.fn();
const configure = createUnexpectedConfigureCall(params.configureErrorMessage);
const restore = patchTelegramAdapter({
configureInteractive: undefined,
configureWhenConfigured: params.configureWhenConfigured,
configure,
});
const { prompter } = createUnexpectedQuickstartPrompter(
select as unknown as WizardPrompter["select"],
);
try {
const cfg = await runSetupChannels(createTelegramCfg("old-token"), prompter, {
quickstartDefaults: true,
onSelection: selection,
onAccountId,
});
return { cfg, selection, onAccountId, configure };
} finally {
restore();
}
}
async function runQuickstartTelegramSetupWithInteractive(params: {
configureInteractive: NonNullable<
Parameters<typeof patchTelegramAdapter>[0]["configureInteractive"]
>;
configure?: NonNullable<Parameters<typeof patchTelegramAdapter>[0]["configure"]>;
}) {
const select = createQuickstartTelegramSelect();
const selection = vi.fn();
const onAccountId = vi.fn();
const restore = patchTelegramAdapter({
configureInteractive: params.configureInteractive,
...(params.configure ? { configure: params.configure } : {}),
});
const { prompter } = createUnexpectedQuickstartPrompter(
select as unknown as WizardPrompter["select"],
);
try {
const cfg = await runSetupChannels({} as OpenClawConfig, prompter, {
quickstartDefaults: true,
onSelection: selection,
onAccountId,
});
return { cfg, selection, onAccountId };
} finally {
restore();
}
}
vi.mock("node:fs/promises", () => ({ vi.mock("node:fs/promises", () => ({
default: { default: {
access: vi.fn(async () => { access: vi.fn(async () => {
@ -269,39 +336,20 @@ describe("setupChannels", () => {
}); });
it("uses configureInteractive skip without mutating selection/account state", async () => { it("uses configureInteractive skip without mutating selection/account state", async () => {
const select = createQuickstartTelegramSelect();
const selection = vi.fn();
const onAccountId = vi.fn();
const configureInteractive = vi.fn(async () => "skip" as const); const configureInteractive = vi.fn(async () => "skip" as const);
const restore = patchTelegramAdapter({ const { cfg, selection, onAccountId } = await runQuickstartTelegramSetupWithInteractive({
configureInteractive, configureInteractive,
}); });
const { prompter } = createUnexpectedQuickstartPrompter(
select as unknown as WizardPrompter["select"], expect(configureInteractive).toHaveBeenCalledWith(
expect.objectContaining({ configured: false, label: expect.any(String) }),
); );
expect(selection).toHaveBeenCalledWith([]);
try { expect(onAccountId).not.toHaveBeenCalled();
const cfg = await runSetupChannels({} as OpenClawConfig, prompter, { expect(cfg.channels?.telegram?.botToken).toBeUndefined();
quickstartDefaults: true,
onSelection: selection,
onAccountId,
});
expect(configureInteractive).toHaveBeenCalledWith(
expect.objectContaining({ configured: false, label: expect.any(String) }),
);
expect(selection).toHaveBeenCalledWith([]);
expect(onAccountId).not.toHaveBeenCalled();
expect(cfg.channels?.telegram?.botToken).toBeUndefined();
} finally {
restore();
}
}); });
it("applies configureInteractive result cfg/account updates", async () => { it("applies configureInteractive result cfg/account updates", async () => {
const select = createQuickstartTelegramSelect();
const selection = vi.fn();
const onAccountId = vi.fn();
const configureInteractive = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ const configureInteractive = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
cfg: { cfg: {
...cfg, ...cfg,
@ -312,38 +360,22 @@ describe("setupChannels", () => {
} as OpenClawConfig, } as OpenClawConfig,
accountId: "acct-1", accountId: "acct-1",
})); }));
const configure = vi.fn(async () => { const configure = createUnexpectedConfigureCall(
throw new Error("configure should not be called when configureInteractive is present"); "configure should not be called when configureInteractive is present",
}); );
const restore = patchTelegramAdapter({ const { cfg, selection, onAccountId } = await runQuickstartTelegramSetupWithInteractive({
configureInteractive, configureInteractive,
configure, configure,
}); });
const { prompter } = createUnexpectedQuickstartPrompter(
select as unknown as WizardPrompter["select"],
);
try { expect(configureInteractive).toHaveBeenCalledTimes(1);
const cfg = await runSetupChannels({} as OpenClawConfig, prompter, { expect(configure).not.toHaveBeenCalled();
quickstartDefaults: true, expect(selection).toHaveBeenCalledWith(["telegram"]);
onSelection: selection, expect(onAccountId).toHaveBeenCalledWith("telegram", "acct-1");
onAccountId, expect(cfg.channels?.telegram?.botToken).toBe("new-token");
});
expect(configureInteractive).toHaveBeenCalledTimes(1);
expect(configure).not.toHaveBeenCalled();
expect(selection).toHaveBeenCalledWith(["telegram"]);
expect(onAccountId).toHaveBeenCalledWith("telegram", "acct-1");
expect(cfg.channels?.telegram?.botToken).toBe("new-token");
} finally {
restore();
}
}); });
it("uses configureWhenConfigured when channel is already configured", async () => { it("uses configureWhenConfigured when channel is already configured", async () => {
const select = createQuickstartTelegramSelect();
const selection = vi.fn();
const onAccountId = vi.fn();
const configureWhenConfigured = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ const configureWhenConfigured = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
cfg: { cfg: {
...cfg, ...cfg,
@ -354,74 +386,37 @@ describe("setupChannels", () => {
} as OpenClawConfig, } as OpenClawConfig,
accountId: "acct-2", accountId: "acct-2",
})); }));
const configure = vi.fn(async () => { const { cfg, selection, onAccountId, configure } = await runConfiguredTelegramSetup({
throw new Error(
"configure should not be called when configureWhenConfigured handles updates",
);
});
const restore = patchTelegramAdapter({
configureInteractive: undefined,
configureWhenConfigured, configureWhenConfigured,
configure, configureErrorMessage:
"configure should not be called when configureWhenConfigured handles updates",
}); });
const { prompter } = createUnexpectedQuickstartPrompter(
select as unknown as WizardPrompter["select"], expect(configureWhenConfigured).toHaveBeenCalledTimes(1);
expect(configureWhenConfigured).toHaveBeenCalledWith(
expect.objectContaining({ configured: true, label: expect.any(String) }),
); );
expect(configure).not.toHaveBeenCalled();
try { expect(selection).toHaveBeenCalledWith(["telegram"]);
const cfg = await runSetupChannels(createTelegramCfg("old-token"), prompter, { expect(onAccountId).toHaveBeenCalledWith("telegram", "acct-2");
quickstartDefaults: true, expect(cfg.channels?.telegram?.botToken).toBe("updated-token");
onSelection: selection,
onAccountId,
});
expect(configureWhenConfigured).toHaveBeenCalledTimes(1);
expect(configureWhenConfigured).toHaveBeenCalledWith(
expect.objectContaining({ configured: true, label: expect.any(String) }),
);
expect(configure).not.toHaveBeenCalled();
expect(selection).toHaveBeenCalledWith(["telegram"]);
expect(onAccountId).toHaveBeenCalledWith("telegram", "acct-2");
expect(cfg.channels?.telegram?.botToken).toBe("updated-token");
} finally {
restore();
}
}); });
it("respects configureWhenConfigured skip without mutating selection or account state", async () => { it("respects configureWhenConfigured skip without mutating selection or account state", async () => {
const select = createQuickstartTelegramSelect({ strictUnexpected: true });
const selection = vi.fn();
const onAccountId = vi.fn();
const configureWhenConfigured = vi.fn(async () => "skip" as const); const configureWhenConfigured = vi.fn(async () => "skip" as const);
const configure = vi.fn(async () => { const { cfg, selection, onAccountId, configure } = await runConfiguredTelegramSetup({
throw new Error("configure should not run when configureWhenConfigured handles skip"); strictUnexpected: true,
});
const restore = patchTelegramAdapter({
configureInteractive: undefined,
configureWhenConfigured, configureWhenConfigured,
configure, configureErrorMessage: "configure should not run when configureWhenConfigured handles skip",
}); });
const { prompter } = createUnexpectedQuickstartPrompter(
select as unknown as WizardPrompter["select"], expect(configureWhenConfigured).toHaveBeenCalledWith(
expect.objectContaining({ configured: true, label: expect.any(String) }),
); );
expect(configure).not.toHaveBeenCalled();
try { expect(selection).toHaveBeenCalledWith([]);
const cfg = await runSetupChannels(createTelegramCfg("old-token"), prompter, { expect(onAccountId).not.toHaveBeenCalled();
quickstartDefaults: true, expect(cfg.channels?.telegram?.botToken).toBe("old-token");
onSelection: selection,
onAccountId,
});
expect(configureWhenConfigured).toHaveBeenCalledWith(
expect.objectContaining({ configured: true, label: expect.any(String) }),
);
expect(configure).not.toHaveBeenCalled();
expect(selection).toHaveBeenCalledWith([]);
expect(onAccountId).not.toHaveBeenCalled();
expect(cfg.channels?.telegram?.botToken).toBe("old-token");
} finally {
restore();
}
}); });
it("prefers configureInteractive over configureWhenConfigured when both hooks exist", async () => { it("prefers configureInteractive over configureWhenConfigured when both hooks exist", async () => {

View File

@ -42,6 +42,21 @@ function createSelectPrompter(
describe("promptRemoteGatewayConfig", () => { describe("promptRemoteGatewayConfig", () => {
const envSnapshot = captureEnv(["OPENCLAW_ALLOW_INSECURE_PRIVATE_WS"]); const envSnapshot = captureEnv(["OPENCLAW_ALLOW_INSECURE_PRIVATE_WS"]);
async function runRemotePrompt(params: {
text: WizardPrompter["text"];
selectResponses: Partial<Record<string, string>>;
confirm: boolean;
}) {
const cfg = {} as OpenClawConfig;
const prompter = createPrompter({
confirm: vi.fn(async () => params.confirm),
select: createSelectPrompter(params.selectResponses),
text: params.text,
});
const next = await promptRemoteGatewayConfig(cfg, prompter);
return { next, prompter };
}
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
envSnapshot.restore(); envSnapshot.restore();
@ -61,12 +76,6 @@ describe("promptRemoteGatewayConfig", () => {
}, },
]); ]);
const select = createSelectPrompter({
"Select gateway": "0",
"Connection method": "direct",
"Gateway auth": "token",
});
const text: WizardPrompter["text"] = vi.fn(async (params) => { const text: WizardPrompter["text"] = vi.fn(async (params) => {
if (params.message === "Gateway WebSocket URL") { if (params.message === "Gateway WebSocket URL") {
expect(params.initialValue).toBe("wss://gateway.tailnet.ts.net:18789"); expect(params.initialValue).toBe("wss://gateway.tailnet.ts.net:18789");
@ -79,15 +88,16 @@ describe("promptRemoteGatewayConfig", () => {
return ""; return "";
}) as WizardPrompter["text"]; }) as WizardPrompter["text"];
const cfg = {} as OpenClawConfig; const { next, prompter } = await runRemotePrompt({
const prompter = createPrompter({
confirm: vi.fn(async () => true),
select,
text, text,
confirm: true,
selectResponses: {
"Select gateway": "0",
"Connection method": "direct",
"Gateway auth": "token",
},
}); });
const next = await promptRemoteGatewayConfig(cfg, prompter);
expect(next.gateway?.mode).toBe("remote"); expect(next.gateway?.mode).toBe("remote");
expect(next.gateway?.remote?.url).toBe("wss://gateway.tailnet.ts.net:18789"); expect(next.gateway?.remote?.url).toBe("wss://gateway.tailnet.ts.net:18789");
expect(next.gateway?.remote?.token).toBe("token-123"); expect(next.gateway?.remote?.token).toBe("token-123");
@ -111,17 +121,12 @@ describe("promptRemoteGatewayConfig", () => {
return ""; return "";
}) as WizardPrompter["text"]; }) as WizardPrompter["text"];
const select = createSelectPrompter({ "Gateway auth": "off" }); const { next } = await runRemotePrompt({
const cfg = {} as OpenClawConfig;
const prompter = createPrompter({
confirm: vi.fn(async () => false),
select,
text, text,
confirm: false,
selectResponses: { "Gateway auth": "off" },
}); });
const next = await promptRemoteGatewayConfig(cfg, prompter);
expect(next.gateway?.mode).toBe("remote"); expect(next.gateway?.mode).toBe("remote");
expect(next.gateway?.remote?.url).toBe("wss://remote.example.com:18789"); expect(next.gateway?.remote?.url).toBe("wss://remote.example.com:18789");
expect(next.gateway?.remote?.token).toBeUndefined(); expect(next.gateway?.remote?.token).toBeUndefined();
@ -138,17 +143,12 @@ describe("promptRemoteGatewayConfig", () => {
return ""; return "";
}) as WizardPrompter["text"]; }) as WizardPrompter["text"];
const select = createSelectPrompter({ "Gateway auth": "off" }); const { next } = await runRemotePrompt({
const cfg = {} as OpenClawConfig;
const prompter = createPrompter({
confirm: vi.fn(async () => false),
select,
text, text,
confirm: false,
selectResponses: { "Gateway auth": "off" },
}); });
const next = await promptRemoteGatewayConfig(cfg, prompter);
expect(next.gateway?.remote?.url).toBe("ws://10.0.0.8:18789"); expect(next.gateway?.remote?.url).toBe("ws://10.0.0.8:18789");
}); });
}); });

View File

@ -0,0 +1,15 @@
export function groupChannelIssuesByChannel<T extends { channel: string }>(
issues: readonly T[],
): Map<string, T[]> {
const byChannel = new Map<string, T[]>();
for (const issue of issues) {
const key = issue.channel;
const list = byChannel.get(key);
if (list) {
list.push(issue);
} else {
byChannel.set(key, [issue]);
}
}
return byChannel;
}

View File

@ -2,6 +2,8 @@ import fs from "node:fs";
import { import {
buildChannelAccountSnapshot, buildChannelAccountSnapshot,
formatChannelAllowFrom, formatChannelAllowFrom,
resolveChannelAccountConfigured,
resolveChannelAccountEnabled,
} from "../../channels/account-summary.js"; } from "../../channels/account-summary.js";
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js"; import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
import { listChannelPlugins } from "../../channels/plugins/index.js"; import { listChannelPlugins } from "../../channels/plugins/index.js";
@ -85,30 +87,6 @@ const formatAccountLabel = (params: { accountId: string; name?: string }) => {
return base; return base;
}; };
const resolveAccountEnabled = (
plugin: ChannelPlugin,
account: unknown,
cfg: OpenClawConfig,
): boolean => {
if (plugin.config.isEnabled) {
return plugin.config.isEnabled(account, cfg);
}
const enabled = asRecord(account).enabled;
return enabled !== false;
};
const resolveAccountConfigured = async (
plugin: ChannelPlugin,
account: unknown,
cfg: OpenClawConfig,
): Promise<boolean> => {
if (plugin.config.isConfigured) {
return await plugin.config.isConfigured(account, cfg);
}
const configured = asRecord(account).configured;
return configured !== false;
};
const buildAccountNotes = (params: { const buildAccountNotes = (params: {
plugin: ChannelPlugin; plugin: ChannelPlugin;
cfg: OpenClawConfig; cfg: OpenClawConfig;
@ -343,8 +321,13 @@ export async function buildChannelsTable(
const accounts: ChannelAccountRow[] = []; const accounts: ChannelAccountRow[] = [];
for (const accountId of resolvedAccountIds) { for (const accountId of resolvedAccountIds) {
const account = plugin.config.resolveAccount(cfg, accountId); const account = plugin.config.resolveAccount(cfg, accountId);
const enabled = resolveAccountEnabled(plugin, account, cfg); const enabled = resolveChannelAccountEnabled({ plugin, account, cfg });
const configured = await resolveAccountConfigured(plugin, account, cfg); const configured = await resolveChannelAccountConfigured({
plugin,
account,
cfg,
readAccountConfiguredField: true,
});
const snapshot = buildChannelAccountSnapshot({ const snapshot = buildChannelAccountSnapshot({
plugin, plugin,
cfg, cfg,

View File

@ -1,6 +1,7 @@
import type { ProgressReporter } from "../../cli/progress.js"; import type { ProgressReporter } from "../../cli/progress.js";
import { renderTable } from "../../terminal/table.js"; import { renderTable } from "../../terminal/table.js";
import { isRich, theme } from "../../terminal/theme.js"; import { isRich, theme } from "../../terminal/theme.js";
import { groupChannelIssuesByChannel } from "./channel-issues.js";
import { appendStatusAllDiagnosis } from "./diagnosis.js"; import { appendStatusAllDiagnosis } from "./diagnosis.js";
import { formatTimeAgo } from "./format.js"; import { formatTimeAgo } from "./format.js";
@ -81,19 +82,7 @@ export async function buildStatusAllReportLines(params: {
: theme.accentDim("SETUP"), : theme.accentDim("SETUP"),
Detail: row.detail, Detail: row.detail,
})); }));
const channelIssuesByChannel = (() => { const channelIssuesByChannel = groupChannelIssuesByChannel(params.channelIssues);
const map = new Map<string, ChannelIssueLike[]>();
for (const issue of params.channelIssues) {
const key = issue.channel;
const list = map.get(key);
if (list) {
list.push(issue);
} else {
map.set(key, [issue]);
}
}
return map;
})();
const channelRowsWithIssues = channelRows.map((row) => { const channelRowsWithIssues = channelRows.map((row) => {
const issues = channelIssuesByChannel.get(row.channelId) ?? []; const issues = channelIssuesByChannel.get(row.channelId) ?? [];
if (issues.length === 0) { if (issues.length === 0) {

View File

@ -21,6 +21,7 @@ import { theme } from "../terminal/theme.js";
import { formatHealthChannelLines, type HealthSummary } from "./health.js"; import { formatHealthChannelLines, type HealthSummary } from "./health.js";
import { resolveControlUiLinks } from "./onboard-helpers.js"; import { resolveControlUiLinks } from "./onboard-helpers.js";
import { statusAllCommand } from "./status-all.js"; import { statusAllCommand } from "./status-all.js";
import { groupChannelIssuesByChannel } from "./status-all/channel-issues.js";
import { formatGatewayAuthUsed } from "./status-all/format.js"; import { formatGatewayAuthUsed } from "./status-all/format.js";
import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js"; import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js";
import { import {
@ -500,19 +501,7 @@ export async function statusCommand(
runtime.log(""); runtime.log("");
runtime.log(theme.heading("Channels")); runtime.log(theme.heading("Channels"));
const channelIssuesByChannel = (() => { const channelIssuesByChannel = groupChannelIssuesByChannel(channelIssues);
const map = new Map<string, typeof channelIssues>();
for (const issue of channelIssues) {
const key = issue.channel;
const list = map.get(key);
if (list) {
list.push(issue);
} else {
map.set(key, [issue]);
}
}
return map;
})();
runtime.log( runtime.log(
renderTable({ renderTable({
width: tableWidth, width: tableWidth,

View File

@ -1,5 +1,3 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
getConfigValueAtPath, getConfigValueAtPath,
@ -8,7 +6,7 @@ import {
unsetConfigValueAtPath, unsetConfigValueAtPath,
} from "./config-paths.js"; } from "./config-paths.js";
import { readConfigFileSnapshot, validateConfigObject } from "./config.js"; import { readConfigFileSnapshot, validateConfigObject } from "./config.js";
import { buildWebSearchProviderConfig, withTempHome } from "./test-helpers.js"; import { buildWebSearchProviderConfig, withTempHome, writeOpenClawConfig } from "./test-helpers.js";
import { OpenClawSchema } from "./zod-schema.js"; import { OpenClawSchema } from "./zod-schema.js";
describe("$schema key in config (#14998)", () => { describe("$schema key in config (#14998)", () => {
@ -304,16 +302,10 @@ describe("config strict validation", () => {
it("flags legacy config entries without auto-migrating", async () => { it("flags legacy config entries without auto-migrating", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw"); await writeOpenClawConfig(home, {
await fs.mkdir(configDir, { recursive: true }); agents: { list: [{ id: "pi" }] },
await fs.writeFile( routing: { allowFrom: ["+15555550123"] },
path.join(configDir, "openclaw.json"), });
JSON.stringify({
agents: { list: [{ id: "pi" }] },
routing: { allowFrom: ["+15555550123"] },
}),
"utf-8",
);
const snap = await readConfigFileSnapshot(); const snap = await readConfigFileSnapshot();
@ -324,15 +316,9 @@ describe("config strict validation", () => {
it("does not mark resolved-only gateway.bind aliases as auto-migratable legacy", async () => { it("does not mark resolved-only gateway.bind aliases as auto-migratable legacy", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw"); await writeOpenClawConfig(home, {
await fs.mkdir(configDir, { recursive: true }); gateway: { bind: "${OPENCLAW_BIND}" },
await fs.writeFile( });
path.join(configDir, "openclaw.json"),
JSON.stringify({
gateway: { bind: "${OPENCLAW_BIND}" },
}),
"utf-8",
);
const prev = process.env.OPENCLAW_BIND; const prev = process.env.OPENCLAW_BIND;
process.env.OPENCLAW_BIND = "0.0.0.0"; process.env.OPENCLAW_BIND = "0.0.0.0";
@ -353,15 +339,9 @@ describe("config strict validation", () => {
it("still marks literal gateway.bind host aliases as legacy", async () => { it("still marks literal gateway.bind host aliases as legacy", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw"); await writeOpenClawConfig(home, {
await fs.mkdir(configDir, { recursive: true }); gateway: { bind: "0.0.0.0" },
await fs.writeFile( });
path.join(configDir, "openclaw.json"),
JSON.stringify({
gateway: { bind: "0.0.0.0" },
}),
"utf-8",
);
const snap = await readConfigFileSnapshot(); const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(false); expect(snap.valid).toBe(false);

View File

@ -1,5 +1,3 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_AGENT_MAX_CONCURRENT,
@ -8,7 +6,7 @@ import {
resolveSubagentMaxConcurrent, resolveSubagentMaxConcurrent,
} from "./agent-limits.js"; } from "./agent-limits.js";
import { loadConfig } from "./config.js"; import { loadConfig } from "./config.js";
import { withTempHome } from "./test-helpers.js"; import { withTempHome, writeOpenClawConfig } from "./test-helpers.js";
import { OpenClawSchema } from "./zod-schema.js"; import { OpenClawSchema } from "./zod-schema.js";
describe("agent concurrency defaults", () => { describe("agent concurrency defaults", () => {
@ -48,13 +46,7 @@ describe("agent concurrency defaults", () => {
it("injects defaults on load", async () => { it("injects defaults on load", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw"); await writeOpenClawConfig(home, {});
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify({}, null, 2),
"utf-8",
);
const cfg = loadConfig(); const cfg = loadConfig();

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "./config.js"; import type { OpenClawConfig } from "./config.js";
import { migrateLegacyConfig, validateConfigObject } from "./config.js"; import { migrateLegacyConfig, validateConfigObject } from "./config.js";
import { WHISPER_BASE_AUDIO_MODEL } from "./legacy-migrate.test-helpers.js";
function getLegacyRouting(config: unknown) { function getLegacyRouting(config: unknown) {
return (config as { routing?: Record<string, unknown> } | undefined)?.routing; return (config as { routing?: Record<string, unknown> } | undefined)?.routing;
@ -137,17 +138,7 @@ describe("legacy config detection", () => {
mode: "queue", mode: "queue",
cap: 3, cap: 3,
}); });
expect(res.config?.tools?.media?.audio).toEqual({ expect(res.config?.tools?.media?.audio).toEqual(WHISPER_BASE_AUDIO_MODEL);
enabled: true,
models: [
{
command: "whisper",
type: "cli",
args: ["--model", "base"],
timeoutSeconds: 2,
},
],
});
expect(getLegacyRouting(res.config)).toBeUndefined(); expect(getLegacyRouting(res.config)).toBeUndefined();
}); });
it("migrates audio.transcription with custom script names", async () => { it("migrates audio.transcription with custom script names", async () => {

View File

@ -1,6 +1,20 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { validateConfigObjectRaw } from "./validation.js"; import { validateConfigObjectRaw } from "./validation.js";
function validateOpenAiApiKeyRef(apiKey: unknown) {
return validateConfigObjectRaw({
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey,
models: [{ id: "gpt-5", name: "gpt-5" }],
},
},
},
});
}
describe("config secret refs schema", () => { describe("config secret refs schema", () => {
it("accepts top-level secrets sources and model apiKey refs", () => { it("accepts top-level secrets sources and model apiKey refs", () => {
const result = validateConfigObjectRaw({ const result = validateConfigObjectRaw({
@ -108,16 +122,10 @@ describe("config secret refs schema", () => {
}); });
it("rejects invalid secret ref id", () => { it("rejects invalid secret ref id", () => {
const result = validateConfigObjectRaw({ const result = validateOpenAiApiKeyRef({
models: { source: "env",
providers: { provider: "default",
openai: { id: "bad id with spaces",
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "env", provider: "default", id: "bad id with spaces" },
models: [{ id: "gpt-5", name: "gpt-5" }],
},
},
},
}); });
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
@ -129,16 +137,10 @@ describe("config secret refs schema", () => {
}); });
it("rejects env refs that are not env var names", () => { it("rejects env refs that are not env var names", () => {
const result = validateConfigObjectRaw({ const result = validateOpenAiApiKeyRef({
models: { source: "env",
providers: { provider: "default",
openai: { id: "/providers/openai/apiKey",
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "env", provider: "default", id: "/providers/openai/apiKey" },
models: [{ id: "gpt-5", name: "gpt-5" }],
},
},
},
}); });
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
@ -154,16 +156,10 @@ describe("config secret refs schema", () => {
}); });
it("rejects file refs that are not absolute JSON pointers", () => { it("rejects file refs that are not absolute JSON pointers", () => {
const result = validateConfigObjectRaw({ const result = validateOpenAiApiKeyRef({
models: { source: "file",
providers: { provider: "default",
openai: { id: "providers/openai/apiKey",
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "file", provider: "default", id: "providers/openai/apiKey" },
models: [{ id: "gpt-5", name: "gpt-5" }],
},
},
},
}); });
expect(result.ok).toBe(false); expect(result.ok).toBe(false);

View File

@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import { import {
createConfigIO, createConfigIO,
readConfigFileSnapshotForWrite, readConfigFileSnapshotForWrite,
@ -22,37 +23,8 @@ async function withTempConfig(
} }
} }
async function withEnvOverrides(
updates: Record<string, string | undefined>,
run: () => Promise<void>,
): Promise<void> {
const previous = new Map<string, string | undefined>();
for (const key of Object.keys(updates)) {
previous.set(key, process.env[key]);
}
try {
for (const [key, value] of Object.entries(updates)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
await run();
} finally {
for (const [key, value] of previous.entries()) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
async function withWrapperEnvContext(configPath: string, run: () => Promise<void>): Promise<void> { async function withWrapperEnvContext(configPath: string, run: () => Promise<void>): Promise<void> {
await withEnvOverrides( await withEnvAsync(
{ {
OPENCLAW_CONFIG_PATH: configPath, OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_DISABLE_CONFIG_CACHE: "1", OPENCLAW_DISABLE_CONFIG_CACHE: "1",

View File

@ -0,0 +1,11 @@
export const WHISPER_BASE_AUDIO_MODEL = {
enabled: true,
models: [
{
command: "whisper",
type: "cli",
args: ["--model", "base"],
timeoutSeconds: 2,
},
],
};

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { migrateLegacyConfig } from "./legacy-migrate.js"; import { migrateLegacyConfig } from "./legacy-migrate.js";
import { WHISPER_BASE_AUDIO_MODEL } from "./legacy-migrate.test-helpers.js";
describe("legacy migrate audio transcription", () => { describe("legacy migrate audio transcription", () => {
it("moves routing.transcribeAudio into tools.media.audio.models", () => { it("moves routing.transcribeAudio into tools.media.audio.models", () => {
@ -13,17 +14,7 @@ describe("legacy migrate audio transcription", () => {
}); });
expect(res.changes).toContain("Moved routing.transcribeAudio → tools.media.audio.models."); expect(res.changes).toContain("Moved routing.transcribeAudio → tools.media.audio.models.");
expect(res.config?.tools?.media?.audio).toEqual({ expect(res.config?.tools?.media?.audio).toEqual(WHISPER_BASE_AUDIO_MODEL);
enabled: true,
models: [
{
command: "whisper",
type: "cli",
args: ["--model", "base"],
timeoutSeconds: 2,
},
],
});
expect((res.config as { routing?: unknown } | null)?.routing).toBeUndefined(); expect((res.config as { routing?: unknown } | null)?.routing).toBeUndefined();
}); });

View File

@ -37,6 +37,19 @@ function applyEnforcedMaintenanceConfig(mockLoadConfig: ReturnType<typeof vi.fn>
}); });
} }
function applyCappedMaintenanceConfig(mockLoadConfig: ReturnType<typeof vi.fn>) {
mockLoadConfig.mockReturnValue({
session: {
maintenance: {
mode: "enforce",
pruneAfter: "365d",
maxEntries: 1,
rotateBytes: 10_485_760,
},
},
});
}
async function createCaseDir(prefix: string): Promise<string> { async function createCaseDir(prefix: string): Promise<string> {
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`); const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
await fs.mkdir(dir, { recursive: true }); await fs.mkdir(dir, { recursive: true });
@ -216,16 +229,7 @@ describe("Integration: saveSessionStore with pruning", () => {
}); });
it("archives transcript files for entries evicted by maxEntries capping", async () => { it("archives transcript files for entries evicted by maxEntries capping", async () => {
mockLoadConfig.mockReturnValue({ applyCappedMaintenanceConfig(mockLoadConfig);
session: {
maintenance: {
mode: "enforce",
pruneAfter: "365d",
maxEntries: 1,
rotateBytes: 10_485_760,
},
},
});
const now = Date.now(); const now = Date.now();
const oldestSessionId = "oldest-session"; const oldestSessionId = "oldest-session";
@ -251,16 +255,7 @@ describe("Integration: saveSessionStore with pruning", () => {
}); });
it("does not archive external transcript paths when capping entries", async () => { it("does not archive external transcript paths when capping entries", async () => {
mockLoadConfig.mockReturnValue({ applyCappedMaintenanceConfig(mockLoadConfig);
session: {
maintenance: {
mode: "enforce",
pruneAfter: "365d",
maxEntries: 1,
rotateBytes: 10_485_760,
},
},
});
const now = Date.now(); const now = Date.now();
const externalDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-external-cap-")); const externalDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-external-cap-"));

View File

@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import { createConfigIO } from "./io.js"; import { createConfigIO } from "./io.js";
import { normalizeTalkSection } from "./talk.js"; import { normalizeTalkSection } from "./talk.js";
@ -19,33 +20,6 @@ async function withTempConfig(
} }
} }
async function withEnv(
updates: Record<string, string | undefined>,
run: () => Promise<void>,
): Promise<void> {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(updates)) {
previous.set(key, process.env[key]);
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
try {
await run();
} finally {
for (const [key, value] of previous.entries()) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
describe("talk normalization", () => { describe("talk normalization", () => {
it("maps legacy ElevenLabs fields into provider/providers", () => { it("maps legacy ElevenLabs fields into provider/providers", () => {
const normalized = normalizeTalkSection({ const normalized = normalizeTalkSection({
@ -104,7 +78,7 @@ describe("talk normalization", () => {
}); });
it("merges ELEVENLABS_API_KEY into normalized defaults for legacy configs", async () => { it("merges ELEVENLABS_API_KEY into normalized defaults for legacy configs", async () => {
await withEnv({ ELEVENLABS_API_KEY: "env-eleven-key" }, async () => { await withEnvAsync({ ELEVENLABS_API_KEY: "env-eleven-key" }, async () => {
await withTempConfig( await withTempConfig(
{ {
talk: { talk: {
@ -124,7 +98,7 @@ describe("talk normalization", () => {
}); });
it("does not apply ELEVENLABS_API_KEY when active provider is not elevenlabs", async () => { it("does not apply ELEVENLABS_API_KEY when active provider is not elevenlabs", async () => {
await withEnv({ ELEVENLABS_API_KEY: "env-eleven-key" }, async () => { await withEnvAsync({ ELEVENLABS_API_KEY: "env-eleven-key" }, async () => {
await withTempConfig( await withTempConfig(
{ {
talk: { talk: {

View File

@ -3,8 +3,14 @@ import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { createCliDeps } from "./isolated-agent.delivery.test-helpers.js";
import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
import { makeCfg, makeJob, withTempCronHome } from "./isolated-agent.test-harness.js"; import {
makeCfg,
makeJob,
withTempCronHome,
writeSessionStore,
} from "./isolated-agent.test-harness.js";
import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js"; import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js";
describe("runCronIsolatedAgentTurn auth profile propagation (#20624)", () => { describe("runCronIsolatedAgentTurn auth profile propagation (#20624)", () => {
@ -14,26 +20,7 @@ describe("runCronIsolatedAgentTurn auth profile propagation (#20624)", () => {
it("passes authProfileId to runEmbeddedPiAgent when auth profiles exist", async () => { it("passes authProfileId to runEmbeddedPiAgent when auth profiles exist", async () => {
await withTempCronHome(async (home) => { await withTempCronHome(async (home) => {
// 1. Write session store const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
const sessionsDir = path.join(home, ".openclaw", "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const storePath = path.join(sessionsDir, "sessions.json");
await fs.writeFile(
storePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "main-session",
updatedAt: Date.now(),
lastProvider: "webchat",
lastTo: "",
},
},
null,
2,
),
"utf-8",
);
// 2. Write auth-profiles.json in the agent directory // 2. Write auth-profiles.json in the agent directory
// resolveAgentDir returns <stateDir>/agents/main/agent // resolveAgentDir returns <stateDir>/agents/main/agent
@ -79,14 +66,7 @@ describe("runCronIsolatedAgentTurn auth profile propagation (#20624)", () => {
const res = await runCronIsolatedAgentTurn({ const res = await runCronIsolatedAgentTurn({
cfg, cfg,
deps: { deps: createCliDeps(),
sendMessageSlack: vi.fn(),
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
},
job: makeJob({ kind: "agentTurn", message: "check status", deliver: false }), job: makeJob({ kind: "agentTurn", message: "check status", deliver: false }),
message: "check status", message: "check status",
sessionKey: "cron:job-1", sessionKey: "cron:job-1",

View File

@ -1,8 +1,6 @@
import "./isolated-agent.mocks.js"; import "./isolated-agent.mocks.js";
import fs from "node:fs/promises"; import { beforeEach, describe, expect, it, vi } from "vitest";
import os from "node:os"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
import type { CliDeps } from "../cli/deps.js"; import type { CliDeps } from "../cli/deps.js";
@ -10,56 +8,8 @@ import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
import { makeCfg, makeJob, writeSessionStore } from "./isolated-agent.test-harness.js"; import { makeCfg, makeJob, writeSessionStore } from "./isolated-agent.test-harness.js";
import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js"; import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js";
let tempRoot = "";
let tempHomeId = 0;
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> { async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
if (!tempRoot) { return withTempHomeBase(fn, { prefix: "openclaw-cron-heartbeat-suite-" });
throw new Error("temp root not initialized");
}
const home = path.join(tempRoot, `case-${tempHomeId++}`);
await fs.mkdir(path.join(home, ".openclaw", "agents", "main", "sessions"), {
recursive: true,
});
const snapshot = {
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
HOMEDRIVE: process.env.HOMEDRIVE,
HOMEPATH: process.env.HOMEPATH,
OPENCLAW_HOME: process.env.OPENCLAW_HOME,
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
};
process.env.HOME = home;
process.env.USERPROFILE = home;
delete process.env.OPENCLAW_HOME;
process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw");
if (process.platform === "win32") {
const driveMatch = home.match(/^([A-Za-z]:)(.*)$/);
if (driveMatch) {
process.env.HOMEDRIVE = driveMatch[1];
process.env.HOMEPATH = driveMatch[2] || "\\";
}
}
try {
return await fn(home);
} finally {
const restoreKey = (key: keyof typeof snapshot) => {
const value = snapshot[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
};
restoreKey("HOME");
restoreKey("USERPROFILE");
restoreKey("HOMEDRIVE");
restoreKey("HOMEPATH");
restoreKey("OPENCLAW_HOME");
restoreKey("OPENCLAW_STATE_DIR");
}
} }
async function createTelegramDeliveryFixture(home: string): Promise<{ async function createTelegramDeliveryFixture(home: string): Promise<{
@ -120,17 +70,6 @@ async function runTelegramAnnounceTurn(params: {
} }
describe("runCronIsolatedAgentTurn", () => { describe("runCronIsolatedAgentTurn", () => {
beforeAll(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-heartbeat-suite-"));
});
afterAll(async () => {
if (!tempRoot) {
return;
}
await fs.rm(tempRoot, { recursive: true, force: true });
});
beforeEach(() => { beforeEach(() => {
setupIsolatedAgentTurnMocks({ fast: true }); setupIsolatedAgentTurnMocks({ fast: true });
}); });

View File

@ -1,4 +1,8 @@
import { vi } from "vitest"; import { vi } from "vitest";
import {
makeIsolatedAgentJobFixture,
makeIsolatedAgentParamsFixture,
} from "./isolated-agent/job-fixtures.js";
vi.mock("../agents/pi-embedded.js", () => ({ vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false), abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
@ -22,28 +26,5 @@ vi.mock("../agents/subagent-announce.js", () => ({
runSubagentAnnounceFlow: vi.fn(), runSubagentAnnounceFlow: vi.fn(),
})); }));
type LooseRecord = Record<string, unknown>; export const makeIsolatedAgentJob = makeIsolatedAgentJobFixture;
export const makeIsolatedAgentParams = makeIsolatedAgentParamsFixture;
export function makeIsolatedAgentJob(overrides?: LooseRecord) {
return {
id: "test-job",
name: "Test Job",
schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" },
sessionTarget: "isolated",
payload: { kind: "agentTurn", message: "test" },
...overrides,
} as never;
}
export function makeIsolatedAgentParams(overrides?: LooseRecord) {
const jobOverrides =
overrides && "job" in overrides ? (overrides.job as LooseRecord | undefined) : undefined;
return {
cfg: {},
deps: {} as never,
job: makeIsolatedAgentJob(jobOverrides),
message: "test",
sessionKey: "cron:test",
...overrides,
};
}

View File

@ -1,8 +1,7 @@
import "./isolated-agent.mocks.js"; import "./isolated-agent.mocks.js";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import { beforeEach, describe, expect, it, vi } from "vitest";
import path from "node:path"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
import type { CliDeps } from "../cli/deps.js"; import type { CliDeps } from "../cli/deps.js";
import { import {
@ -14,56 +13,8 @@ import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
import { makeCfg, makeJob, writeSessionStore } from "./isolated-agent.test-harness.js"; import { makeCfg, makeJob, writeSessionStore } from "./isolated-agent.test-harness.js";
import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js"; import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js";
let tempRoot = "";
let tempHomeId = 0;
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> { async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
if (!tempRoot) { return withTempHomeBase(fn, { prefix: "openclaw-cron-delivery-suite-" });
throw new Error("temp root not initialized");
}
const home = path.join(tempRoot, `case-${tempHomeId++}`);
await fs.mkdir(path.join(home, ".openclaw", "agents", "main", "sessions"), {
recursive: true,
});
const snapshot = {
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
HOMEDRIVE: process.env.HOMEDRIVE,
HOMEPATH: process.env.HOMEPATH,
OPENCLAW_HOME: process.env.OPENCLAW_HOME,
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
};
process.env.HOME = home;
process.env.USERPROFILE = home;
delete process.env.OPENCLAW_HOME;
process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw");
if (process.platform === "win32") {
const driveMatch = home.match(/^([A-Za-z]:)(.*)$/);
if (driveMatch) {
process.env.HOMEDRIVE = driveMatch[1];
process.env.HOMEPATH = driveMatch[2] || "\\";
}
}
try {
return await fn(home);
} finally {
const restoreKey = (key: keyof typeof snapshot) => {
const value = snapshot[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
};
restoreKey("HOME");
restoreKey("USERPROFILE");
restoreKey("HOMEDRIVE");
restoreKey("HOMEPATH");
restoreKey("OPENCLAW_HOME");
restoreKey("OPENCLAW_STATE_DIR");
}
} }
async function runExplicitTelegramAnnounceTurn(params: { async function runExplicitTelegramAnnounceTurn(params: {
@ -216,17 +167,6 @@ async function assertExplicitTelegramTargetAnnounce(params: {
} }
describe("runCronIsolatedAgentTurn", () => { describe("runCronIsolatedAgentTurn", () => {
beforeAll(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-delivery-suite-"));
});
afterAll(async () => {
if (!tempRoot) {
return;
}
await fs.rm(tempRoot, { recursive: true, force: true });
});
beforeEach(() => { beforeEach(() => {
setupIsolatedAgentTurnMocks(); setupIsolatedAgentTurnMocks();
}); });

View File

@ -1,8 +1,8 @@
import "./isolated-agent.mocks.js"; import "./isolated-agent.mocks.js";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import { loadModelCatalog } from "../agents/model-catalog.js"; import { loadModelCatalog } from "../agents/model-catalog.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import type { CliDeps } from "../cli/deps.js"; import type { CliDeps } from "../cli/deps.js";
@ -15,56 +15,8 @@ import {
} from "./isolated-agent.test-harness.js"; } from "./isolated-agent.test-harness.js";
import type { CronJob } from "./types.js"; import type { CronJob } from "./types.js";
let tempRoot = "";
let tempHomeId = 0;
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> { async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
if (!tempRoot) { return withTempHomeBase(fn, { prefix: "openclaw-cron-turn-suite-" });
throw new Error("temp root not initialized");
}
const home = path.join(tempRoot, `case-${tempHomeId++}`);
await fs.mkdir(path.join(home, ".openclaw", "agents", "main", "sessions"), {
recursive: true,
});
const snapshot = {
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
HOMEDRIVE: process.env.HOMEDRIVE,
HOMEPATH: process.env.HOMEPATH,
OPENCLAW_HOME: process.env.OPENCLAW_HOME,
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
};
process.env.HOME = home;
process.env.USERPROFILE = home;
delete process.env.OPENCLAW_HOME;
process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw");
if (process.platform === "win32") {
const driveMatch = home.match(/^([A-Za-z]:)(.*)$/);
if (driveMatch) {
process.env.HOMEDRIVE = driveMatch[1];
process.env.HOMEPATH = driveMatch[2] || "\\";
}
}
try {
return await fn(home);
} finally {
const restoreKey = (key: keyof typeof snapshot) => {
const value = snapshot[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
};
restoreKey("HOME");
restoreKey("USERPROFILE");
restoreKey("HOMEDRIVE");
restoreKey("HOMEPATH");
restoreKey("OPENCLAW_HOME");
restoreKey("OPENCLAW_STATE_DIR");
}
} }
function makeDeps(): CliDeps { function makeDeps(): CliDeps {
@ -201,17 +153,6 @@ async function runTurnWithStoredModelOverride(
} }
describe("runCronIsolatedAgentTurn", () => { describe("runCronIsolatedAgentTurn", () => {
beforeAll(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-turn-suite-"));
});
afterAll(async () => {
if (!tempRoot) {
return;
}
await fs.rm(tempRoot, { recursive: true, force: true });
});
beforeEach(() => { beforeEach(() => {
vi.mocked(runEmbeddedPiAgent).mockClear(); vi.mocked(runEmbeddedPiAgent).mockClear();
vi.mocked(loadModelCatalog).mockResolvedValue([]); vi.mocked(loadModelCatalog).mockResolvedValue([]);

View File

@ -0,0 +1,25 @@
type LooseRecord = Record<string, unknown>;
export function makeIsolatedAgentJobFixture(overrides?: LooseRecord) {
return {
id: "test-job",
name: "Test Job",
schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" },
sessionTarget: "isolated",
payload: { kind: "agentTurn", message: "test" },
...overrides,
} as never;
}
export function makeIsolatedAgentParamsFixture(overrides?: LooseRecord) {
const jobOverrides =
overrides && "job" in overrides ? (overrides.job as LooseRecord | undefined) : undefined;
return {
cfg: {},
deps: {} as never,
job: makeIsolatedAgentJobFixture(jobOverrides),
message: "test",
sessionKey: "cron:test",
...overrides,
};
}

View File

@ -1,53 +1,21 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import {
makeIsolatedAgentTurnJob,
makeIsolatedAgentTurnParams,
setupRunCronIsolatedAgentTurnSuite,
} from "./run.suite-helpers.js";
import { import {
clearFastTestEnv,
loadRunCronIsolatedAgentTurn, loadRunCronIsolatedAgentTurn,
makeCronSession,
resolveAgentModelFallbacksOverrideMock, resolveAgentModelFallbacksOverrideMock,
resolveCronSessionMock,
resetRunCronIsolatedAgentTurnHarness,
restoreFastTestEnv,
runWithModelFallbackMock, runWithModelFallbackMock,
} from "./run.test-harness.js"; } from "./run.test-harness.js";
const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
function makePayloadJob(overrides?: Record<string, unknown>) {
return {
id: "test-job",
name: "Test Job",
schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" },
sessionTarget: "isolated",
payload: { kind: "agentTurn", message: "test" },
...overrides,
} as never;
}
function makePayloadParams(overrides?: Record<string, unknown>) {
return {
cfg: {},
deps: {} as never,
job: makePayloadJob(overrides?.job as Record<string, unknown> | undefined),
message: "test",
sessionKey: "cron:test",
...overrides,
};
}
// ---------- tests ---------- // ---------- tests ----------
describe("runCronIsolatedAgentTurn — payload.fallbacks", () => { describe("runCronIsolatedAgentTurn — payload.fallbacks", () => {
let previousFastTestEnv: string | undefined; setupRunCronIsolatedAgentTurnSuite();
beforeEach(() => {
previousFastTestEnv = clearFastTestEnv();
resetRunCronIsolatedAgentTurnHarness();
resolveCronSessionMock.mockReturnValue(makeCronSession());
});
afterEach(() => {
restoreFastTestEnv(previousFastTestEnv);
});
it.each([ it.each([
{ {
@ -77,8 +45,8 @@ describe("runCronIsolatedAgentTurn — payload.fallbacks", () => {
} }
const result = await runCronIsolatedAgentTurn( const result = await runCronIsolatedAgentTurn(
makePayloadParams({ makeIsolatedAgentTurnParams({
job: makePayloadJob({ payload }), job: makeIsolatedAgentTurnJob({ payload }),
}), }),
); );

View File

@ -1,62 +1,34 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import {
makeIsolatedAgentTurnJob,
makeIsolatedAgentTurnParams,
setupRunCronIsolatedAgentTurnSuite,
} from "./run.suite-helpers.js";
import { import {
buildWorkspaceSkillSnapshotMock, buildWorkspaceSkillSnapshotMock,
clearFastTestEnv,
getCliSessionIdMock, getCliSessionIdMock,
isCliProviderMock, isCliProviderMock,
loadRunCronIsolatedAgentTurn, loadRunCronIsolatedAgentTurn,
logWarnMock, logWarnMock,
makeCronSession,
resolveAgentConfigMock, resolveAgentConfigMock,
resolveAgentSkillsFilterMock, resolveAgentSkillsFilterMock,
resolveAllowedModelRefMock, resolveAllowedModelRefMock,
resolveCronSessionMock, resolveCronSessionMock,
resetRunCronIsolatedAgentTurnHarness,
restoreFastTestEnv,
runCliAgentMock, runCliAgentMock,
runWithModelFallbackMock, runWithModelFallbackMock,
} from "./run.test-harness.js"; } from "./run.test-harness.js";
const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
const makeSkillJob = makeIsolatedAgentTurnJob;
function makeSkillJob(overrides?: Record<string, unknown>) { const makeSkillParams = makeIsolatedAgentTurnParams;
return {
id: "test-job",
name: "Test Job",
schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" },
sessionTarget: "isolated",
payload: { kind: "agentTurn", message: "test" },
...overrides,
} as never;
}
function makeSkillParams(overrides?: Record<string, unknown>) {
return {
cfg: {},
deps: {} as never,
job: makeSkillJob(overrides?.job as Record<string, unknown> | undefined),
message: "test",
sessionKey: "cron:test",
...overrides,
};
}
// ---------- tests ---------- // ---------- tests ----------
describe("runCronIsolatedAgentTurn — skill filter", () => { describe("runCronIsolatedAgentTurn — skill filter", () => {
let previousFastTestEnv: string | undefined; setupRunCronIsolatedAgentTurnSuite();
beforeEach(() => {
previousFastTestEnv = clearFastTestEnv();
resetRunCronIsolatedAgentTurnHarness();
resolveCronSessionMock.mockReturnValue(makeCronSession());
});
afterEach(() => {
restoreFastTestEnv(previousFastTestEnv);
});
async function runSkillFilterCase(overrides?: Record<string, unknown>) { async function runSkillFilterCase(overrides?: Record<string, unknown>) {
const result = await runCronIsolatedAgentTurn(makeSkillParams(overrides)); const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams(overrides));
expect(result.status).toBe("ok"); expect(result.status).toBe("ok");
return result; return result;
} }

View File

@ -0,0 +1,24 @@
import { afterEach, beforeEach } from "vitest";
import { makeIsolatedAgentJobFixture, makeIsolatedAgentParamsFixture } from "./job-fixtures.js";
import {
clearFastTestEnv,
makeCronSession,
resolveCronSessionMock,
resetRunCronIsolatedAgentTurnHarness,
restoreFastTestEnv,
} from "./run.test-harness.js";
export function setupRunCronIsolatedAgentTurnSuite() {
let previousFastTestEnv: string | undefined;
beforeEach(() => {
previousFastTestEnv = clearFastTestEnv();
resetRunCronIsolatedAgentTurnHarness();
resolveCronSessionMock.mockReturnValue(makeCronSession());
});
afterEach(() => {
restoreFastTestEnv(previousFastTestEnv);
});
}
export const makeIsolatedAgentTurnJob = makeIsolatedAgentJobFixture;
export const makeIsolatedAgentTurnParams = makeIsolatedAgentParamsFixture;

View File

@ -39,6 +39,30 @@ function createStuckPastDueJob(params: { id: string; nowMs: number; pastDueMs: n
} }
describe("CronService - armTimer tight loop prevention", () => { describe("CronService - armTimer tight loop prevention", () => {
function extractTimeoutDelays(timeoutSpy: ReturnType<typeof vi.spyOn>) {
const calls = timeoutSpy.mock.calls as Array<[unknown, unknown, ...unknown[]]>;
return calls
.map(([, delay]: [unknown, unknown, ...unknown[]]) => delay)
.filter((d: unknown): d is number => typeof d === "number");
}
function createTimerState(params: {
storePath: string;
now: number;
runIsolatedAgentJob?: () => Promise<{ status: "ok" }>;
}) {
return createCronServiceState({
storePath: params.storePath,
cronEnabled: true,
log: noopLogger,
nowMs: () => params.now,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob:
params.runIsolatedAgentJob ?? vi.fn().mockResolvedValue({ status: "ok" }),
});
}
beforeEach(() => { beforeEach(() => {
noopLogger.debug.mockClear(); noopLogger.debug.mockClear();
noopLogger.info.mockClear(); noopLogger.info.mockClear();
@ -55,14 +79,9 @@ describe("CronService - armTimer tight loop prevention", () => {
const now = Date.parse("2026-02-28T12:32:00.000Z"); const now = Date.parse("2026-02-28T12:32:00.000Z");
const pastDueMs = 17 * 60 * 1000; // 17 minutes past due const pastDueMs = 17 * 60 * 1000; // 17 minutes past due
const state = createCronServiceState({ const state = createTimerState({
storePath: "/tmp/test-cron/jobs.json", storePath: "/tmp/test-cron/jobs.json",
cronEnabled: true, now,
log: noopLogger,
nowMs: () => now,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok" }),
}); });
state.store = { state.store = {
version: 1, version: 1,
@ -72,9 +91,7 @@ describe("CronService - armTimer tight loop prevention", () => {
armTimer(state); armTimer(state);
expect(state.timer).not.toBeNull(); expect(state.timer).not.toBeNull();
const delays = timeoutSpy.mock.calls const delays = extractTimeoutDelays(timeoutSpy);
.map(([, delay]) => delay)
.filter((d): d is number => typeof d === "number");
// Before the fix, delay would be 0 (tight loop). // Before the fix, delay would be 0 (tight loop).
// After the fix, delay must be >= MIN_REFIRE_GAP_MS (2000 ms). // After the fix, delay must be >= MIN_REFIRE_GAP_MS (2000 ms).
@ -90,14 +107,9 @@ describe("CronService - armTimer tight loop prevention", () => {
const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
const now = Date.parse("2026-02-28T12:32:00.000Z"); const now = Date.parse("2026-02-28T12:32:00.000Z");
const state = createCronServiceState({ const state = createTimerState({
storePath: "/tmp/test-cron/jobs.json", storePath: "/tmp/test-cron/jobs.json",
cronEnabled: true, now,
log: noopLogger,
nowMs: () => now,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok" }),
}); });
state.store = { state.store = {
version: 1, version: 1,
@ -121,9 +133,7 @@ describe("CronService - armTimer tight loop prevention", () => {
armTimer(state); armTimer(state);
const delays = timeoutSpy.mock.calls const delays = extractTimeoutDelays(timeoutSpy);
.map(([, delay]) => delay)
.filter((d): d is number => typeof d === "number");
// The natural delay (10 s) should be used, not the floor. // The natural delay (10 s) should be used, not the floor.
expect(delays).toContain(10_000); expect(delays).toContain(10_000);
@ -151,14 +161,9 @@ describe("CronService - armTimer tight loop prevention", () => {
"utf-8", "utf-8",
); );
const state = createCronServiceState({ const state = createTimerState({
storePath: store.storePath, storePath: store.storePath,
cronEnabled: true, now,
log: noopLogger,
nowMs: () => now,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok" }),
}); });
// Simulate the onTimer path: it will find no runnable jobs (blocked by // Simulate the onTimer path: it will find no runnable jobs (blocked by
@ -170,9 +175,7 @@ describe("CronService - armTimer tight loop prevention", () => {
// The re-armed timer must NOT use delay=0. It should use at least // The re-armed timer must NOT use delay=0. It should use at least
// MIN_REFIRE_GAP_MS to prevent the hot-loop. // MIN_REFIRE_GAP_MS to prevent the hot-loop.
const allDelays = timeoutSpy.mock.calls const allDelays = extractTimeoutDelays(timeoutSpy);
.map(([, delay]) => delay)
.filter((d): d is number => typeof d === "number");
// The last setTimeout call is from the finally→armTimer path. // The last setTimeout call is from the finally→armTimer path.
const lastDelay = allDelays[allDelays.length - 1]; const lastDelay = allDelays[allDelays.length - 1];

View File

@ -1,5 +1,4 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { HeartbeatRunResult } from "../infra/heartbeat-wake.js";
import { CronService } from "./service.js"; import { CronService } from "./service.js";
import { setupCronServiceSuite, writeCronStoreSnapshot } from "./service.test-harness.js"; import { setupCronServiceSuite, writeCronStoreSnapshot } from "./service.test-harness.js";
import type { CronJob } from "./types.js"; import type { CronJob } from "./types.js";
@ -8,59 +7,75 @@ const { logger, makeStorePath } = setupCronServiceSuite({
prefix: "cron-main-heartbeat-target", prefix: "cron-main-heartbeat-target",
}); });
describe("cron main job passes heartbeat target=last", () => { type RunHeartbeatOnce = NonNullable<
it("should pass heartbeat.target=last to runHeartbeatOnce for wakeMode=now main jobs", async () => { ConstructorParameters<typeof CronService>[0]["runHeartbeatOnce"]
const { storePath } = await makeStorePath(); >;
const now = Date.now();
const job: CronJob = { describe("cron main job passes heartbeat target=last", () => {
id: "test-main-delivery", function createMainCronJob(params: {
name: "test-main-delivery", now: number;
id: string;
wakeMode: CronJob["wakeMode"];
}): CronJob {
return {
id: params.id,
name: params.id,
enabled: true, enabled: true,
createdAtMs: now - 10_000, createdAtMs: params.now - 10_000,
updatedAtMs: now - 10_000, updatedAtMs: params.now - 10_000,
schedule: { kind: "every", everyMs: 60_000 }, schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main", sessionTarget: "main",
wakeMode: "now", wakeMode: params.wakeMode,
payload: { kind: "systemEvent", text: "Check in" }, payload: { kind: "systemEvent", text: "Check in" },
state: { nextRunAtMs: now - 1 }, state: { nextRunAtMs: params.now - 1 },
}; };
}
await writeCronStoreSnapshot({ storePath, jobs: [job] }); function createCronWithSpies(params: { storePath: string; runHeartbeatOnce: RunHeartbeatOnce }) {
const enqueueSystemEvent = vi.fn(); const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn(); const requestHeartbeatNow = vi.fn();
const runHeartbeatOnce = vi.fn<
(opts?: {
reason?: string;
agentId?: string;
sessionKey?: string;
heartbeat?: { target?: string };
}) => Promise<HeartbeatRunResult>
>(async () => ({
status: "ran" as const,
durationMs: 50,
}));
const cron = new CronService({ const cron = new CronService({
storePath, storePath: params.storePath,
cronEnabled: true, cronEnabled: true,
log: logger, log: logger,
enqueueSystemEvent, enqueueSystemEvent,
requestHeartbeatNow, requestHeartbeatNow,
runHeartbeatOnce, runHeartbeatOnce: params.runHeartbeatOnce,
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })), runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
}); });
return { cron, requestHeartbeatNow };
}
async function runSingleTick(cron: CronService) {
await cron.start(); await cron.start();
// Wait for the timer to fire
await vi.advanceTimersByTimeAsync(2_000); await vi.advanceTimersByTimeAsync(2_000);
// Give the async run a chance to complete
await vi.advanceTimersByTimeAsync(1_000); await vi.advanceTimersByTimeAsync(1_000);
cron.stop(); cron.stop();
}
it("should pass heartbeat.target=last to runHeartbeatOnce for wakeMode=now main jobs", async () => {
const { storePath } = await makeStorePath();
const now = Date.now();
const job = createMainCronJob({
now,
id: "test-main-delivery",
wakeMode: "now",
});
await writeCronStoreSnapshot({ storePath, jobs: [job] });
const runHeartbeatOnce = vi.fn<RunHeartbeatOnce>(async () => ({
status: "ran" as const,
durationMs: 50,
}));
const { cron } = createCronWithSpies({
storePath,
runHeartbeatOnce,
});
await runSingleTick(cron);
// runHeartbeatOnce should have been called // runHeartbeatOnce should have been called
expect(runHeartbeatOnce).toHaveBeenCalled(); expect(runHeartbeatOnce).toHaveBeenCalled();
@ -77,42 +92,25 @@ describe("cron main job passes heartbeat target=last", () => {
const { storePath } = await makeStorePath(); const { storePath } = await makeStorePath();
const now = Date.now(); const now = Date.now();
const job: CronJob = { const job = createMainCronJob({
now,
id: "test-next-heartbeat", id: "test-next-heartbeat",
name: "test-next-heartbeat",
enabled: true,
createdAtMs: now - 10_000,
updatedAtMs: now - 10_000,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat", wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "Check in" }, });
state: { nextRunAtMs: now - 1 },
};
await writeCronStoreSnapshot({ storePath, jobs: [job] }); await writeCronStoreSnapshot({ storePath, jobs: [job] });
const enqueueSystemEvent = vi.fn(); const runHeartbeatOnce = vi.fn<RunHeartbeatOnce>(async () => ({
const requestHeartbeatNow = vi.fn();
const runHeartbeatOnce = vi.fn(async () => ({
status: "ran" as const, status: "ran" as const,
durationMs: 50, durationMs: 50,
})); }));
const cron = new CronService({ const { cron, requestHeartbeatNow } = createCronWithSpies({
storePath, storePath,
cronEnabled: true,
log: logger,
enqueueSystemEvent,
requestHeartbeatNow,
runHeartbeatOnce, runHeartbeatOnce,
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
}); });
await cron.start(); await runSingleTick(cron);
await vi.advanceTimersByTimeAsync(2_000);
await vi.advanceTimersByTimeAsync(1_000);
cron.stop();
// wakeMode=next-heartbeat uses requestHeartbeatNow, not runHeartbeatOnce // wakeMode=next-heartbeat uses requestHeartbeatNow, not runHeartbeatOnce
expect(requestHeartbeatNow).toHaveBeenCalled(); expect(requestHeartbeatNow).toHaveBeenCalled();

View File

@ -1,32 +1,11 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createCronStoreHarness } from "./service.test-harness.js";
import { loadCronStore, resolveCronStorePath, saveCronStore } from "./store.js"; import { loadCronStore, resolveCronStorePath, saveCronStore } from "./store.js";
import type { CronStoreFile } from "./types.js"; import type { CronStoreFile } from "./types.js";
let fixtureRoot = ""; const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-store-" });
let fixtureCount = 0;
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-store-"));
});
afterAll(async () => {
if (!fixtureRoot) {
return;
}
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
async function makeStorePath() {
const dir = path.join(fixtureRoot, `case-${fixtureCount++}`);
await fs.mkdir(dir, { recursive: true });
return {
dir,
storePath: path.join(dir, "jobs.json"),
};
}
function makeStore(jobId: string, enabled: boolean): CronStoreFile { function makeStore(jobId: string, enabled: boolean): CronStoreFile {
const now = Date.now(); const now = Date.now();
@ -72,6 +51,7 @@ describe("cron store", () => {
it("throws when store contains invalid JSON", async () => { it("throws when store contains invalid JSON", async () => {
const store = await makeStorePath(); const store = await makeStorePath();
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(store.storePath, "{ not json", "utf-8"); await fs.writeFile(store.storePath, "{ not json", "utf-8");
await expect(loadCronStore(store.storePath)).rejects.toThrow(/Failed to parse cron store/i); await expect(loadCronStore(store.storePath)).rejects.toThrow(/Failed to parse cron store/i);
}); });

18
src/cron/types-shared.ts Normal file
View File

@ -0,0 +1,18 @@
export type CronJobBase<TSchedule, TSessionTarget, TWakeMode, TPayload, TDelivery, TFailureAlert> =
{
id: string;
agentId?: string;
sessionKey?: string;
name: string;
description?: string;
enabled: boolean;
deleteAfterRun?: boolean;
createdAtMs: number;
updatedAtMs: number;
schedule: TSchedule;
sessionTarget: TSessionTarget;
wakeMode: TWakeMode;
payload: TPayload;
delivery?: TDelivery;
failureAlert?: TFailureAlert;
};

View File

@ -1,4 +1,5 @@
import type { ChannelId } from "../channels/plugins/types.js"; import type { ChannelId } from "../channels/plugins/types.js";
import type { CronJobBase } from "./types-shared.js";
export type CronSchedule = export type CronSchedule =
| { kind: "at"; at: string } | { kind: "at"; at: string }
@ -138,23 +139,14 @@ export type CronJobState = {
lastDelivered?: boolean; lastDelivered?: boolean;
}; };
export type CronJob = { export type CronJob = CronJobBase<
id: string; CronSchedule,
agentId?: string; CronSessionTarget,
/** Origin session namespace for reminder delivery and wake routing. */ CronWakeMode,
sessionKey?: string; CronPayload,
name: string; CronDelivery,
description?: string; CronFailureAlert | false
enabled: boolean; > & {
deleteAfterRun?: boolean;
createdAtMs: number;
updatedAtMs: number;
schedule: CronSchedule;
sessionTarget: CronSessionTarget;
wakeMode: CronWakeMode;
payload: CronPayload;
delivery?: CronDelivery;
failureAlert?: CronFailureAlert | false;
state: CronJobState; state: CronJobState;
}; };

View File

@ -1,5 +1,5 @@
export type GatewayServiceRuntime = { export type GatewayServiceRuntime = {
status?: "running" | "stopped" | "unknown"; status?: string;
state?: string; state?: string;
subState?: string; subState?: string;
pid?: number; pid?: number;

View File

@ -65,6 +65,20 @@ describe("loader", () => {
}); });
describe("loadInternalHooks", () => { describe("loadInternalHooks", () => {
const createLegacyHandlerConfig = () =>
createEnabledHooksConfig([
{
event: "command:new",
module: "legacy-handler.js",
},
]);
const expectNoCommandHookRegistration = async (cfg: OpenClawConfig) => {
const count = await loadInternalHooks(cfg, tmpDir);
expect(count).toBe(0);
expect(getRegisteredEventKeys()).not.toContain("command:new");
};
it("should return 0 when hooks are not enabled", async () => { it("should return 0 when hooks are not enabled", async () => {
const cfg: OpenClawConfig = { const cfg: OpenClawConfig = {
hooks: { hooks: {
@ -252,11 +266,7 @@ describe("loader", () => {
return; return;
} }
const cfg = createEnabledHooksConfig(); await expectNoCommandHookRegistration(createEnabledHooksConfig());
const count = await loadInternalHooks(cfg, tmpDir);
expect(count).toBe(0);
expect(getRegisteredEventKeys()).not.toContain("command:new");
}); });
it("rejects legacy handler modules that escape workspace via symlink", async () => { it("rejects legacy handler modules that escape workspace via symlink", async () => {
@ -270,16 +280,7 @@ describe("loader", () => {
return; return;
} }
const cfg = createEnabledHooksConfig([ await expectNoCommandHookRegistration(createLegacyHandlerConfig());
{
event: "command:new",
module: "legacy-handler.js",
},
]);
const count = await loadInternalHooks(cfg, tmpDir);
expect(count).toBe(0);
expect(getRegisteredEventKeys()).not.toContain("command:new");
}); });
it("rejects directory hook handlers that escape hook dir via hardlink", async () => { it("rejects directory hook handlers that escape hook dir via hardlink", async () => {
@ -313,10 +314,7 @@ describe("loader", () => {
throw err; throw err;
} }
const cfg = createEnabledHooksConfig(); await expectNoCommandHookRegistration(createEnabledHooksConfig());
const count = await loadInternalHooks(cfg, tmpDir);
expect(count).toBe(0);
expect(getRegisteredEventKeys()).not.toContain("command:new");
}); });
it("rejects legacy handler modules that escape workspace via hardlink", async () => { it("rejects legacy handler modules that escape workspace via hardlink", async () => {
@ -336,16 +334,7 @@ describe("loader", () => {
throw err; throw err;
} }
const cfg = createEnabledHooksConfig([ await expectNoCommandHookRegistration(createLegacyHandlerConfig());
{
event: "command:new",
module: "legacy-handler.js",
},
]);
const count = await loadInternalHooks(cfg, tmpDir);
expect(count).toBe(0);
expect(getRegisteredEventKeys()).not.toContain("command:new");
}); });
}); });
}); });

View File

@ -339,6 +339,23 @@ function readBoundaryFileUtf8(params: {
rootPath: string; rootPath: string;
boundaryLabel: string; boundaryLabel: string;
}): string | null { }): string | null {
return withOpenedBoundaryFileSync(params, (opened) => {
try {
return fs.readFileSync(opened.fd, "utf-8");
} catch {
return null;
}
});
}
function withOpenedBoundaryFileSync<T>(
params: {
absolutePath: string;
rootPath: string;
boundaryLabel: string;
},
read: (opened: { fd: number; path: string }) => T,
): T | null {
const opened = openBoundaryFileSync({ const opened = openBoundaryFileSync({
absolutePath: params.absolutePath, absolutePath: params.absolutePath,
rootPath: params.rootPath, rootPath: params.rootPath,
@ -348,9 +365,7 @@ function readBoundaryFileUtf8(params: {
return null; return null;
} }
try { try {
return fs.readFileSync(opened.fd, "utf-8"); return read({ fd: opened.fd, path: opened.path });
} catch {
return null;
} finally { } finally {
fs.closeSync(opened.fd); fs.closeSync(opened.fd);
} }
@ -361,15 +376,5 @@ function resolveBoundaryFilePath(params: {
rootPath: string; rootPath: string;
boundaryLabel: string; boundaryLabel: string;
}): string | null { }): string | null {
const opened = openBoundaryFileSync({ return withOpenedBoundaryFileSync(params, (opened) => opened.path);
absolutePath: params.absolutePath,
rootPath: params.rootPath,
boundaryLabel: params.boundaryLabel,
});
if (!opened.ok) {
return null;
}
const safePath = opened.path;
fs.closeSync(opened.fd);
return safePath;
} }

View File

@ -80,13 +80,8 @@ export function openBoundaryFileSync(params: OpenBoundaryFileSyncParams): Bounda
if (resolved instanceof Promise) { if (resolved instanceof Promise) {
return toBoundaryValidationError(new Error("Unexpected async boundary resolution")); return toBoundaryValidationError(new Error("Unexpected async boundary resolution"));
} }
if ("ok" in resolved) { return finalizeBoundaryFileOpen({
return resolved; resolved,
}
return openBoundaryFileResolved({
absolutePath: resolved.absolutePath,
resolvedPath: resolved.resolvedPath,
rootRealPath: resolved.rootRealPath,
maxBytes: params.maxBytes, maxBytes: params.maxBytes,
rejectHardlinks: params.rejectHardlinks, rejectHardlinks: params.rejectHardlinks,
allowedType: params.allowedType, allowedType: params.allowedType,
@ -123,6 +118,27 @@ function openBoundaryFileResolved(params: {
}; };
} }
function finalizeBoundaryFileOpen(params: {
resolved: ResolvedBoundaryFilePath | BoundaryFileOpenResult;
maxBytes?: number;
rejectHardlinks?: boolean;
allowedType?: SafeOpenSyncAllowedType;
ioFs: BoundaryReadFs;
}): BoundaryFileOpenResult {
if ("ok" in params.resolved) {
return params.resolved;
}
return openBoundaryFileResolved({
absolutePath: params.resolved.absolutePath,
resolvedPath: params.resolved.resolvedPath,
rootRealPath: params.resolved.rootRealPath,
maxBytes: params.maxBytes,
rejectHardlinks: params.rejectHardlinks,
allowedType: params.allowedType,
ioFs: params.ioFs,
});
}
export async function openBoundaryFile( export async function openBoundaryFile(
params: OpenBoundaryFileParams, params: OpenBoundaryFileParams,
): Promise<BoundaryFileOpenResult> { ): Promise<BoundaryFileOpenResult> {
@ -140,13 +156,8 @@ export async function openBoundaryFile(
}), }),
}); });
const resolved = maybeResolved instanceof Promise ? await maybeResolved : maybeResolved; const resolved = maybeResolved instanceof Promise ? await maybeResolved : maybeResolved;
if ("ok" in resolved) { return finalizeBoundaryFileOpen({
return resolved; resolved,
}
return openBoundaryFileResolved({
absolutePath: resolved.absolutePath,
resolvedPath: resolved.resolvedPath,
rootRealPath: resolved.rootRealPath,
maxBytes: params.maxBytes, maxBytes: params.maxBytes,
rejectHardlinks: params.rejectHardlinks, rejectHardlinks: params.rejectHardlinks,
allowedType: params.allowedType, allowedType: params.allowedType,

View File

@ -1,6 +1,8 @@
import { import {
buildChannelAccountSnapshot, buildChannelAccountSnapshot,
formatChannelAllowFrom, formatChannelAllowFrom,
resolveChannelAccountConfigured,
resolveChannelAccountEnabled,
} from "../channels/account-summary.js"; } from "../channels/account-summary.js";
import { listChannelPlugins } from "../channels/plugins/index.js"; import { listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelAccountSnapshot, ChannelPlugin } from "../channels/plugins/types.js"; import type { ChannelAccountSnapshot, ChannelPlugin } from "../channels/plugins/types.js";
@ -38,32 +40,6 @@ const formatAccountLabel = (params: { accountId: string; name?: string }) => {
const accountLine = (label: string, details: string[]) => const accountLine = (label: string, details: string[]) =>
` - ${label}${details.length ? ` (${details.join(", ")})` : ""}`; ` - ${label}${details.length ? ` (${details.join(", ")})` : ""}`;
const resolveAccountEnabled = (
plugin: ChannelPlugin,
account: unknown,
cfg: OpenClawConfig,
): boolean => {
if (plugin.config.isEnabled) {
return plugin.config.isEnabled(account, cfg);
}
if (!account || typeof account !== "object") {
return true;
}
const enabled = (account as { enabled?: boolean }).enabled;
return enabled !== false;
};
const resolveAccountConfigured = async (
plugin: ChannelPlugin,
account: unknown,
cfg: OpenClawConfig,
): Promise<boolean> => {
if (plugin.config.isConfigured) {
return await plugin.config.isConfigured(account, cfg);
}
return true;
};
const buildAccountDetails = (params: { const buildAccountDetails = (params: {
entry: ChannelAccountEntry; entry: ChannelAccountEntry;
plugin: ChannelPlugin; plugin: ChannelPlugin;
@ -133,8 +109,12 @@ export async function buildChannelSummary(
for (const accountId of resolvedAccountIds) { for (const accountId of resolvedAccountIds) {
const account = plugin.config.resolveAccount(effective, accountId); const account = plugin.config.resolveAccount(effective, accountId);
const enabled = resolveAccountEnabled(plugin, account, effective); const enabled = resolveChannelAccountEnabled({ plugin, account, cfg: effective });
const configured = await resolveAccountConfigured(plugin, account, effective); const configured = await resolveChannelAccountConfigured({
plugin,
account,
cfg: effective,
});
const snapshot = buildChannelAccountSnapshot({ const snapshot = buildChannelAccountSnapshot({
plugin, plugin,
account, account,

View File

@ -14,6 +14,43 @@ export function extractErrorCode(err: unknown): string | undefined {
return undefined; return undefined;
} }
export function readErrorName(err: unknown): string {
if (!err || typeof err !== "object") {
return "";
}
const name = (err as { name?: unknown }).name;
return typeof name === "string" ? name : "";
}
export function collectErrorGraphCandidates(
err: unknown,
resolveNested?: (current: Record<string, unknown>) => Iterable<unknown>,
): unknown[] {
const queue: unknown[] = [err];
const seen = new Set<unknown>();
const candidates: unknown[] = [];
while (queue.length > 0) {
const current = queue.shift();
if (current == null || seen.has(current)) {
continue;
}
seen.add(current);
candidates.push(current);
if (!current || typeof current !== "object" || !resolveNested) {
continue;
}
for (const nested of resolveNested(current as Record<string, unknown>)) {
if (nested != null && !seen.has(nested)) {
queue.push(nested);
}
}
}
return candidates;
}
/** /**
* Type guard for NodeJS.ErrnoException (any error with a `code` property). * Type guard for NodeJS.ErrnoException (any error with a `code` property).
*/ */

View File

@ -18,6 +18,49 @@ describe("resolveAllowAlwaysPatterns", () => {
return exe; return exe;
} }
function expectAllowAlwaysBypassBlocked(params: {
dir: string;
firstCommand: string;
secondCommand: string;
env: Record<string, string | undefined>;
persistedPattern: string;
}) {
const safeBins = resolveSafeBins(undefined);
const first = evaluateShellAllowlist({
command: params.firstCommand,
allowlist: [],
safeBins,
cwd: params.dir,
env: params.env,
platform: process.platform,
});
const persisted = resolveAllowAlwaysPatterns({
segments: first.segments,
cwd: params.dir,
env: params.env,
platform: process.platform,
});
expect(persisted).toEqual([params.persistedPattern]);
const second = evaluateShellAllowlist({
command: params.secondCommand,
allowlist: [{ pattern: params.persistedPattern }],
safeBins,
cwd: params.dir,
env: params.env,
platform: process.platform,
});
expect(second.allowlistSatisfied).toBe(false);
expect(
requiresExecApproval({
ask: "on-miss",
security: "allowlist",
analysisOk: second.analysisOk,
allowlistSatisfied: second.allowlistSatisfied,
}),
).toBe(true);
}
it("returns direct executable paths for non-shell segments", () => { it("returns direct executable paths for non-shell segments", () => {
const exe = path.join("/tmp", "openclaw-tool"); const exe = path.join("/tmp", "openclaw-tool");
const patterns = resolveAllowAlwaysPatterns({ const patterns = resolveAllowAlwaysPatterns({
@ -233,42 +276,14 @@ describe("resolveAllowAlwaysPatterns", () => {
const busybox = makeExecutable(dir, "busybox"); const busybox = makeExecutable(dir, "busybox");
const echo = makeExecutable(dir, "echo"); const echo = makeExecutable(dir, "echo");
makeExecutable(dir, "id"); makeExecutable(dir, "id");
const safeBins = resolveSafeBins(undefined);
const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` }; const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` };
expectAllowAlwaysBypassBlocked({
const first = evaluateShellAllowlist({ dir,
command: `${busybox} sh -c 'echo warmup-ok'`, firstCommand: `${busybox} sh -c 'echo warmup-ok'`,
allowlist: [], secondCommand: `${busybox} sh -c 'id > marker'`,
safeBins,
cwd: dir,
env, env,
platform: process.platform, persistedPattern: echo,
}); });
const persisted = resolveAllowAlwaysPatterns({
segments: first.segments,
cwd: dir,
env,
platform: process.platform,
});
expect(persisted).toEqual([echo]);
const second = evaluateShellAllowlist({
command: `${busybox} sh -c 'id > marker'`,
allowlist: [{ pattern: echo }],
safeBins,
cwd: dir,
env,
platform: process.platform,
});
expect(second.allowlistSatisfied).toBe(false);
expect(
requiresExecApproval({
ask: "on-miss",
security: "allowlist",
analysisOk: second.analysisOk,
allowlistSatisfied: second.allowlistSatisfied,
}),
).toBe(true);
}); });
it("prevents allow-always bypass for dispatch-wrapper + shell-wrapper chains", () => { it("prevents allow-always bypass for dispatch-wrapper + shell-wrapper chains", () => {
@ -278,41 +293,13 @@ describe("resolveAllowAlwaysPatterns", () => {
const dir = makeTempDir(); const dir = makeTempDir();
const echo = makeExecutable(dir, "echo"); const echo = makeExecutable(dir, "echo");
makeExecutable(dir, "id"); makeExecutable(dir, "id");
const safeBins = resolveSafeBins(undefined);
const env = makePathEnv(dir); const env = makePathEnv(dir);
expectAllowAlwaysBypassBlocked({
const first = evaluateShellAllowlist({ dir,
command: "/usr/bin/nice /bin/zsh -lc 'echo warmup-ok'", firstCommand: "/usr/bin/nice /bin/zsh -lc 'echo warmup-ok'",
allowlist: [], secondCommand: "/usr/bin/nice /bin/zsh -lc 'id > marker'",
safeBins,
cwd: dir,
env, env,
platform: process.platform, persistedPattern: echo,
}); });
const persisted = resolveAllowAlwaysPatterns({
segments: first.segments,
cwd: dir,
env,
platform: process.platform,
});
expect(persisted).toEqual([echo]);
const second = evaluateShellAllowlist({
command: "/usr/bin/nice /bin/zsh -lc 'id > marker'",
allowlist: [{ pattern: echo }],
safeBins,
cwd: dir,
env,
platform: process.platform,
});
expect(second.allowlistSatisfied).toBe(false);
expect(
requiresExecApproval({
ask: "on-miss",
security: "allowlist",
analysisOk: second.analysisOk,
allowlistSatisfied: second.allowlistSatisfied,
}),
).toBe(true);
}); });
}); });

View File

@ -616,16 +616,26 @@ export function buildSafeShellCommand(params: { command: string; platform?: stri
return { ok: true, rendered: argv.map((token) => shellEscapeSingleArg(token)).join(" ") }; return { ok: true, rendered: argv.map((token) => shellEscapeSingleArg(token)).join(" ") };
}, },
}); });
if (!rebuilt.ok) { return finalizeRebuiltShellCommand(rebuilt);
return { ok: false, reason: rebuilt.reason };
}
return { ok: true, command: rebuilt.command };
} }
function renderQuotedArgv(argv: string[]): string { function renderQuotedArgv(argv: string[]): string {
return argv.map((token) => shellEscapeSingleArg(token)).join(" "); return argv.map((token) => shellEscapeSingleArg(token)).join(" ");
} }
function finalizeRebuiltShellCommand(
rebuilt: ReturnType<typeof rebuildShellCommandFromSource>,
expectedSegmentCount?: number,
): { ok: boolean; command?: string; reason?: string } {
if (!rebuilt.ok) {
return { ok: false, reason: rebuilt.reason };
}
if (typeof expectedSegmentCount === "number" && rebuilt.segmentCount !== expectedSegmentCount) {
return { ok: false, reason: "segment count mismatch" };
}
return { ok: true, command: rebuilt.command };
}
export function resolvePlannedSegmentArgv(segment: ExecCommandSegment): string[] | null { export function resolvePlannedSegmentArgv(segment: ExecCommandSegment): string[] | null {
if (segment.resolution?.policyBlocked === true) { if (segment.resolution?.policyBlocked === true) {
return null; return null;
@ -688,13 +698,7 @@ export function buildSafeBinsShellCommand(params: {
return { ok: true, rendered }; return { ok: true, rendered };
}, },
}); });
if (!rebuilt.ok) { return finalizeRebuiltShellCommand(rebuilt, params.segments.length);
return { ok: false, reason: rebuilt.reason };
}
if (rebuilt.segmentCount !== params.segments.length) {
return { ok: false, reason: "segment count mismatch" };
}
return { ok: true, command: rebuilt.command };
} }
export function buildEnforcedShellCommand(params: { export function buildEnforcedShellCommand(params: {
@ -717,13 +721,7 @@ export function buildEnforcedShellCommand(params: {
return { ok: true, rendered: renderQuotedArgv(argv) }; return { ok: true, rendered: renderQuotedArgv(argv) };
}, },
}); });
if (!rebuilt.ok) { return finalizeRebuiltShellCommand(rebuilt, params.segments.length);
return { ok: false, reason: rebuilt.reason };
}
if (rebuilt.segmentCount !== params.segments.length) {
return { ok: false, reason: "segment count mismatch" };
}
return { ok: true, command: rebuilt.command };
} }
/** /**

View File

@ -625,6 +625,36 @@ describe("exec approvals shell allowlist (chained commands)", () => {
}); });
describe("exec approvals allowlist evaluation", () => { describe("exec approvals allowlist evaluation", () => {
function evaluateAutoAllowSkills(params: {
analysis: {
ok: boolean;
segments: Array<{
raw: string;
argv: string[];
resolution: {
rawExecutable: string;
executableName: string;
resolvedPath?: string;
};
}>;
};
resolvedPath: string;
}) {
return evaluateExecAllowlist({
analysis: params.analysis,
allowlist: [],
safeBins: new Set(),
skillBins: [{ name: "skill-bin", resolvedPath: params.resolvedPath }],
autoAllowSkills: true,
cwd: "/tmp",
});
}
function expectAutoAllowSkillsMiss(result: ReturnType<typeof evaluateExecAllowlist>): void {
expect(result.allowlistSatisfied).toBe(false);
expect(result.segmentSatisfiedBy).toEqual([null]);
}
it("satisfies allowlist on exact match", () => { it("satisfies allowlist on exact match", () => {
const analysis = { const analysis = {
ok: true, ok: true,
@ -696,13 +726,9 @@ describe("exec approvals allowlist evaluation", () => {
}, },
], ],
}; };
const result = evaluateExecAllowlist({ const result = evaluateAutoAllowSkills({
analysis, analysis,
allowlist: [], resolvedPath: "/opt/skills/skill-bin",
safeBins: new Set(),
skillBins: [{ name: "skill-bin", resolvedPath: "/opt/skills/skill-bin" }],
autoAllowSkills: true,
cwd: "/tmp",
}); });
expect(result.allowlistSatisfied).toBe(true); expect(result.allowlistSatisfied).toBe(true);
}); });
@ -722,16 +748,11 @@ describe("exec approvals allowlist evaluation", () => {
}, },
], ],
}; };
const result = evaluateExecAllowlist({ const result = evaluateAutoAllowSkills({
analysis, analysis,
allowlist: [], resolvedPath: "/tmp/skill-bin",
safeBins: new Set(),
skillBins: [{ name: "skill-bin", resolvedPath: "/tmp/skill-bin" }],
autoAllowSkills: true,
cwd: "/tmp",
}); });
expect(result.allowlistSatisfied).toBe(false); expectAutoAllowSkillsMiss(result);
expect(result.segmentSatisfiedBy).toEqual([null]);
}); });
it("does not satisfy auto-allow skills when command resolution is missing", () => { it("does not satisfy auto-allow skills when command resolution is missing", () => {
@ -748,16 +769,11 @@ describe("exec approvals allowlist evaluation", () => {
}, },
], ],
}; };
const result = evaluateExecAllowlist({ const result = evaluateAutoAllowSkills({
analysis, analysis,
allowlist: [], resolvedPath: "/opt/skills/skill-bin",
safeBins: new Set(),
skillBins: [{ name: "skill-bin", resolvedPath: "/opt/skills/skill-bin" }],
autoAllowSkills: true,
cwd: "/tmp",
}); });
expect(result.allowlistSatisfied).toBe(false); expectAutoAllowSkillsMiss(result);
expect(result.segmentSatisfiedBy).toEqual([null]);
}); });
it("returns empty segment details for chain misses", () => { it("returns empty segment details for chain misses", () => {

View File

@ -1,4 +1,9 @@
import path from "node:path"; import path from "node:path";
import {
POSIX_INLINE_COMMAND_FLAGS,
POWERSHELL_INLINE_COMMAND_FLAGS,
resolveInlineCommandMatch,
} from "./shell-inline-command.js";
export const MAX_DISPATCH_WRAPPER_DEPTH = 4; export const MAX_DISPATCH_WRAPPER_DEPTH = 4;
@ -51,9 +56,6 @@ const SHELL_WRAPPER_CANONICAL = new Set<string>([
...POWERSHELL_WRAPPER_NAMES, ...POWERSHELL_WRAPPER_NAMES,
]); ]);
const POSIX_INLINE_COMMAND_FLAGS = new Set(["-lc", "-c", "--command"]);
const POWERSHELL_INLINE_COMMAND_FLAGS = new Set(["-c", "-command", "--command"]);
const ENV_OPTIONS_WITH_VALUE = new Set([ const ENV_OPTIONS_WITH_VALUE = new Set([
"-u", "-u",
"--unset", "--unset",
@ -586,30 +588,7 @@ function extractInlineCommandByFlags(
flags: ReadonlySet<string>, flags: ReadonlySet<string>,
options: { allowCombinedC?: boolean } = {}, options: { allowCombinedC?: boolean } = {},
): string | null { ): string | null {
for (let i = 1; i < argv.length; i += 1) { return resolveInlineCommandMatch(argv, flags, options).command;
const token = argv[i]?.trim();
if (!token) {
continue;
}
const lower = token.toLowerCase();
if (lower === "--") {
break;
}
if (flags.has(lower)) {
const cmd = argv[i + 1]?.trim();
return cmd ? cmd : null;
}
if (options.allowCombinedC && /^-[^-]*c[^-]*$/i.test(token)) {
const commandIndex = lower.indexOf("c");
const inline = token.slice(commandIndex + 1).trim();
if (inline) {
return inline;
}
const cmd = argv[i + 1]?.trim();
return cmd ? cmd : null;
}
}
return null;
} }
function extractShellWrapperPayload(argv: string[], spec: ShellWrapperSpec): string | null { function extractShellWrapperPayload(argv: string[], spec: ShellWrapperSpec): string | null {

View File

@ -0,0 +1,38 @@
import type { NpmIntegrityDriftPayload } from "./npm-integrity.js";
import {
finalizeNpmSpecArchiveInstall,
installFromNpmSpecArchiveWithInstaller,
type NpmSpecArchiveFinalInstallResult,
} from "./npm-pack-install.js";
import { validateRegistryNpmSpec } from "./npm-registry-spec.js";
export async function installFromValidatedNpmSpecArchive<
TResult extends { ok: boolean },
TArchiveInstallParams extends { archivePath: string },
>(params: {
spec: string;
timeoutMs: number;
tempDirPrefix: string;
expectedIntegrity?: string;
onIntegrityDrift?: (payload: NpmIntegrityDriftPayload) => boolean | Promise<boolean>;
warn?: (message: string) => void;
installFromArchive: (params: TArchiveInstallParams) => Promise<TResult>;
archiveInstallParams: Omit<TArchiveInstallParams, "archivePath">;
}): Promise<NpmSpecArchiveFinalInstallResult<TResult>> {
const spec = params.spec.trim();
const specError = validateRegistryNpmSpec(spec);
if (specError) {
return { ok: false, error: specError };
}
const flowResult = await installFromNpmSpecArchiveWithInstaller({
tempDirPrefix: params.tempDirPrefix,
spec,
timeoutMs: params.timeoutMs,
expectedIntegrity: params.expectedIntegrity,
onIntegrityDrift: params.onIntegrityDrift,
warn: params.warn,
installFromArchive: params.installFromArchive,
archiveInstallParams: params.archiveInstallParams,
});
return finalizeNpmSpecArchiveInstall(flowResult);
}

View File

@ -147,3 +147,20 @@ export async function installPackageDir(params: {
return { ok: true }; return { ok: true };
} }
export async function installPackageDirWithManifestDeps(params: {
sourceDir: string;
targetDir: string;
mode: "install" | "update";
timeoutMs: number;
logger?: { info?: (message: string) => void };
copyErrorPrefix: string;
depsLogMessage: string;
manifestDependencies?: Record<string, unknown>;
afterCopy?: () => void | Promise<void>;
}): Promise<{ ok: true } | { ok: false; error: string }> {
return installPackageDir({
...params,
hasDeps: Object.keys(params.manifestDependencies ?? {}).length > 0,
});
}

View File

@ -56,6 +56,31 @@ async function runPack(spec: string, cwd: string, timeoutMs = 1000) {
}); });
} }
async function expectPackFallsBackToDetectedArchive(params: { stdout: string }) {
const cwd = await createTempDir("openclaw-install-source-utils-");
const archivePath = path.join(cwd, "openclaw-plugin-1.2.3.tgz");
await fs.writeFile(archivePath, "", "utf-8");
runCommandWithTimeoutMock.mockResolvedValue({
stdout: params.stdout,
stderr: "",
code: 0,
signal: null,
killed: false,
});
const result = await packNpmSpecToArchive({
spec: "openclaw-plugin@1.2.3",
timeoutMs: 5000,
cwd,
});
expect(result).toEqual({
ok: true,
archivePath,
metadata: {},
});
}
beforeEach(() => { beforeEach(() => {
runCommandWithTimeoutMock.mockClear(); runCommandWithTimeoutMock.mockClear();
}); });
@ -195,53 +220,11 @@ describe("packNpmSpecToArchive", () => {
}); });
it("falls back to archive detected in cwd when npm pack stdout is empty", async () => { it("falls back to archive detected in cwd when npm pack stdout is empty", async () => {
const cwd = await createTempDir("openclaw-install-source-utils-"); await expectPackFallsBackToDetectedArchive({ stdout: " \n\n" });
const archivePath = path.join(cwd, "openclaw-plugin-1.2.3.tgz");
await fs.writeFile(archivePath, "", "utf-8");
runCommandWithTimeoutMock.mockResolvedValue({
stdout: " \n\n",
stderr: "",
code: 0,
signal: null,
killed: false,
});
const result = await packNpmSpecToArchive({
spec: "openclaw-plugin@1.2.3",
timeoutMs: 5000,
cwd,
});
expect(result).toEqual({
ok: true,
archivePath,
metadata: {},
});
}); });
it("falls back to archive detected in cwd when stdout does not contain a tgz", async () => { it("falls back to archive detected in cwd when stdout does not contain a tgz", async () => {
const cwd = await createTempDir("openclaw-install-source-utils-"); await expectPackFallsBackToDetectedArchive({ stdout: "npm pack completed successfully\n" });
const archivePath = path.join(cwd, "openclaw-plugin-1.2.3.tgz");
await fs.writeFile(archivePath, "", "utf-8");
runCommandWithTimeoutMock.mockResolvedValue({
stdout: "npm pack completed successfully\n",
stderr: "",
code: 0,
signal: null,
killed: false,
});
const result = await packNpmSpecToArchive({
spec: "openclaw-plugin@1.2.3",
timeoutMs: 5000,
cwd,
});
expect(result).toEqual({
ok: true,
archivePath,
metadata: {},
});
}); });
it("returns friendly error for 404 (package not on npm)", async () => { it("returns friendly error for 404 (package not on npm)", async () => {

View File

@ -14,6 +14,26 @@ export type NpmSpecResolution = {
resolvedAt?: string; resolvedAt?: string;
}; };
export type NpmResolutionFields = {
resolvedName?: string;
resolvedVersion?: string;
resolvedSpec?: string;
integrity?: string;
shasum?: string;
resolvedAt?: string;
};
export function buildNpmResolutionFields(resolution?: NpmSpecResolution): NpmResolutionFields {
return {
resolvedName: resolution?.name,
resolvedVersion: resolution?.version,
resolvedSpec: resolution?.resolvedSpec,
integrity: resolution?.integrity,
shasum: resolution?.shasum,
resolvedAt: resolution?.resolvedAt,
};
}
export type NpmIntegrityDrift = { export type NpmIntegrityDrift = {
expectedIntegrity: string; expectedIntegrity: string;
actualIntegrity: string; actualIntegrity: string;

View File

@ -155,20 +155,24 @@ describe("sendPoll channel normalization", () => {
}); });
}); });
const setMattermostGatewayRegistry = () => {
setRegistry(
createTestRegistry([
{
pluginId: "mattermost",
source: "test",
plugin: {
...createMattermostLikePlugin({ onSendText: () => {} }),
outbound: { deliveryMode: "gateway" },
},
},
]),
);
};
describe("gateway url override hardening", () => { describe("gateway url override hardening", () => {
it("drops gateway url overrides in backend mode (SSRF hardening)", async () => { it("drops gateway url overrides in backend mode (SSRF hardening)", async () => {
setRegistry( setMattermostGatewayRegistry();
createTestRegistry([
{
pluginId: "mattermost",
source: "test",
plugin: {
...createMattermostLikePlugin({ onSendText: () => {} }),
outbound: { deliveryMode: "gateway" },
},
},
]),
);
callGatewayMock.mockResolvedValueOnce({ messageId: "m1" }); callGatewayMock.mockResolvedValueOnce({ messageId: "m1" });
await sendMessage({ await sendMessage({
@ -196,18 +200,7 @@ describe("gateway url override hardening", () => {
}); });
it("forwards explicit agentId in gateway send params", async () => { it("forwards explicit agentId in gateway send params", async () => {
setRegistry( setMattermostGatewayRegistry();
createTestRegistry([
{
pluginId: "mattermost",
source: "test",
plugin: {
...createMattermostLikePlugin({ onSendText: () => {} }),
outbound: { deliveryMode: "gateway" },
},
},
]),
);
callGatewayMock.mockResolvedValueOnce({ messageId: "m-agent" }); callGatewayMock.mockResolvedValueOnce({ messageId: "m-agent" });
await sendMessage({ await sendMessage({

View File

@ -301,43 +301,44 @@ describe("resolveSessionDeliveryTarget", () => {
expect(resolved.to).toBe("63448508"); expect(resolved.to).toBe("63448508");
}); });
it("allows heartbeat delivery to Slack DMs and avoids inherited threadId by default", () => { const resolveHeartbeatTarget = (
const cfg: OpenClawConfig = {}; entry: Parameters<typeof resolveHeartbeatDeliveryTarget>[0]["entry"],
const resolved = resolveHeartbeatDeliveryTarget({ directPolicy?: "allow" | "block",
cfg, ) =>
entry: { resolveHeartbeatDeliveryTarget({
sessionId: "sess-heartbeat-outbound", cfg: {},
updatedAt: 1, entry,
lastChannel: "slack",
lastTo: "user:U123",
lastThreadId: "1739142736.000100",
},
heartbeat: { heartbeat: {
target: "last", target: "last",
...(directPolicy ? { directPolicy } : {}),
}, },
}); });
it("allows heartbeat delivery to Slack DMs and avoids inherited threadId by default", () => {
const resolved = resolveHeartbeatTarget({
sessionId: "sess-heartbeat-outbound",
updatedAt: 1,
lastChannel: "slack",
lastTo: "user:U123",
lastThreadId: "1739142736.000100",
});
expect(resolved.channel).toBe("slack"); expect(resolved.channel).toBe("slack");
expect(resolved.to).toBe("user:U123"); expect(resolved.to).toBe("user:U123");
expect(resolved.threadId).toBeUndefined(); expect(resolved.threadId).toBeUndefined();
}); });
it("blocks heartbeat delivery to Slack DMs when directPolicy is block", () => { it("blocks heartbeat delivery to Slack DMs when directPolicy is block", () => {
const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatTarget(
const resolved = resolveHeartbeatDeliveryTarget({ {
cfg,
entry: {
sessionId: "sess-heartbeat-outbound", sessionId: "sess-heartbeat-outbound",
updatedAt: 1, updatedAt: 1,
lastChannel: "slack", lastChannel: "slack",
lastTo: "user:U123", lastTo: "user:U123",
lastThreadId: "1739142736.000100", lastThreadId: "1739142736.000100",
}, },
heartbeat: { "block",
target: "last", );
directPolicy: "block",
},
});
expect(resolved.channel).toBe("none"); expect(resolved.channel).toBe("none");
expect(resolved.reason).toBe("dm-blocked"); expect(resolved.reason).toBe("dm-blocked");
@ -460,19 +461,12 @@ describe("resolveSessionDeliveryTarget", () => {
}); });
it("uses session chatType hint when target parser cannot classify and allows direct by default", () => { it("uses session chatType hint when target parser cannot classify and allows direct by default", () => {
const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatTarget({
const resolved = resolveHeartbeatDeliveryTarget({ sessionId: "sess-heartbeat-imessage-direct",
cfg, updatedAt: 1,
entry: { lastChannel: "imessage",
sessionId: "sess-heartbeat-imessage-direct", lastTo: "chat-guid-unknown-shape",
updatedAt: 1, chatType: "direct",
lastChannel: "imessage",
lastTo: "chat-guid-unknown-shape",
chatType: "direct",
},
heartbeat: {
target: "last",
},
}); });
expect(resolved.channel).toBe("imessage"); expect(resolved.channel).toBe("imessage");
@ -480,21 +474,16 @@ describe("resolveSessionDeliveryTarget", () => {
}); });
it("blocks session chatType direct hints when directPolicy is block", () => { it("blocks session chatType direct hints when directPolicy is block", () => {
const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatTarget(
const resolved = resolveHeartbeatDeliveryTarget({ {
cfg,
entry: {
sessionId: "sess-heartbeat-imessage-direct", sessionId: "sess-heartbeat-imessage-direct",
updatedAt: 1, updatedAt: 1,
lastChannel: "imessage", lastChannel: "imessage",
lastTo: "chat-guid-unknown-shape", lastTo: "chat-guid-unknown-shape",
chatType: "direct", chatType: "direct",
}, },
heartbeat: { "block",
target: "last", );
directPolicy: "block",
},
});
expect(resolved.channel).toBe("none"); expect(resolved.channel).toBe("none");
expect(resolved.reason).toBe("dm-blocked"); expect(resolved.reason).toBe("dm-blocked");

18
src/infra/package-tag.ts Normal file
View File

@ -0,0 +1,18 @@
export function normalizePackageTagInput(
value: string | undefined | null,
packageNames: readonly string[],
): string | null {
const trimmed = value?.trim();
if (!trimmed) {
return null;
}
for (const packageName of packageNames) {
const prefix = `${packageName}@`;
if (trimmed.startsWith(prefix)) {
return trimmed.slice(prefix.length);
}
}
return trimmed;
}

View File

@ -0,0 +1,35 @@
export const POSIX_INLINE_COMMAND_FLAGS = new Set(["-lc", "-c", "--command"]);
export const POWERSHELL_INLINE_COMMAND_FLAGS = new Set(["-c", "-command", "--command"]);
export function resolveInlineCommandMatch(
argv: string[],
flags: ReadonlySet<string>,
options: { allowCombinedC?: boolean } = {},
): { command: string | null; valueTokenIndex: number | null } {
for (let i = 1; i < argv.length; i += 1) {
const token = argv[i]?.trim();
if (!token) {
continue;
}
const lower = token.toLowerCase();
if (lower === "--") {
break;
}
if (flags.has(lower)) {
const valueTokenIndex = i + 1 < argv.length ? i + 1 : null;
const command = argv[i + 1]?.trim();
return { command: command ? command : null, valueTokenIndex };
}
if (options.allowCombinedC && /^-[^-]*c[^-]*$/i.test(token)) {
const commandIndex = lower.indexOf("c");
const inline = token.slice(commandIndex + 1).trim();
if (inline) {
return { command: inline, valueTokenIndex: i };
}
const valueTokenIndex = i + 1 < argv.length ? i + 1 : null;
const command = argv[i + 1]?.trim();
return { command: command ? command : null, valueTokenIndex };
}
}
return { command: null, valueTokenIndex: null };
}

View File

@ -5,6 +5,11 @@ import {
unwrapDispatchWrappersForResolution, unwrapDispatchWrappersForResolution,
unwrapKnownShellMultiplexerInvocation, unwrapKnownShellMultiplexerInvocation,
} from "./exec-wrapper-resolution.js"; } from "./exec-wrapper-resolution.js";
import {
POSIX_INLINE_COMMAND_FLAGS,
POWERSHELL_INLINE_COMMAND_FLAGS,
resolveInlineCommandMatch,
} from "./shell-inline-command.js";
export type SystemRunCommandValidation = export type SystemRunCommandValidation =
| { | {
@ -63,41 +68,12 @@ const POSIX_OR_POWERSHELL_INLINE_WRAPPER_NAMES = new Set([
"zsh", "zsh",
]); ]);
const POSIX_INLINE_COMMAND_FLAGS = new Set(["-lc", "-c", "--command"]);
const POWERSHELL_INLINE_COMMAND_FLAGS = new Set(["-c", "-command", "--command"]);
function unwrapShellWrapperArgv(argv: string[]): string[] { function unwrapShellWrapperArgv(argv: string[]): string[] {
const dispatchUnwrapped = unwrapDispatchWrappersForResolution(argv); const dispatchUnwrapped = unwrapDispatchWrappersForResolution(argv);
const shellMultiplexer = unwrapKnownShellMultiplexerInvocation(dispatchUnwrapped); const shellMultiplexer = unwrapKnownShellMultiplexerInvocation(dispatchUnwrapped);
return shellMultiplexer.kind === "unwrapped" ? shellMultiplexer.argv : dispatchUnwrapped; return shellMultiplexer.kind === "unwrapped" ? shellMultiplexer.argv : dispatchUnwrapped;
} }
function resolveInlineCommandTokenIndex(
argv: string[],
flags: ReadonlySet<string>,
options: { allowCombinedC?: boolean } = {},
): number | null {
for (let i = 1; i < argv.length; i += 1) {
const token = argv[i]?.trim();
if (!token) {
continue;
}
const lower = token.toLowerCase();
if (lower === "--") {
break;
}
if (flags.has(lower)) {
return i + 1 < argv.length ? i + 1 : null;
}
if (options.allowCombinedC && /^-[^-]*c[^-]*$/i.test(token)) {
const commandIndex = lower.indexOf("c");
const inline = token.slice(commandIndex + 1).trim();
return inline ? i : i + 1 < argv.length ? i + 1 : null;
}
}
return null;
}
function hasTrailingPositionalArgvAfterInlineCommand(argv: string[]): boolean { function hasTrailingPositionalArgvAfterInlineCommand(argv: string[]): boolean {
const wrapperArgv = unwrapShellWrapperArgv(argv); const wrapperArgv = unwrapShellWrapperArgv(argv);
const token0 = wrapperArgv[0]?.trim(); const token0 = wrapperArgv[0]?.trim();
@ -112,10 +88,10 @@ function hasTrailingPositionalArgvAfterInlineCommand(argv: string[]): boolean {
const inlineCommandIndex = const inlineCommandIndex =
wrapper === "powershell" || wrapper === "pwsh" wrapper === "powershell" || wrapper === "pwsh"
? resolveInlineCommandTokenIndex(wrapperArgv, POWERSHELL_INLINE_COMMAND_FLAGS) ? resolveInlineCommandMatch(wrapperArgv, POWERSHELL_INLINE_COMMAND_FLAGS).valueTokenIndex
: resolveInlineCommandTokenIndex(wrapperArgv, POSIX_INLINE_COMMAND_FLAGS, { : resolveInlineCommandMatch(wrapperArgv, POSIX_INLINE_COMMAND_FLAGS, {
allowCombinedC: true, allowCombinedC: true,
}); }).valueTokenIndex;
if (inlineCommandIndex === null) { if (inlineCommandIndex === null) {
return false; return false;
} }

View File

@ -1,5 +1,10 @@
import process from "node:process"; import process from "node:process";
import { extractErrorCode, formatUncaughtError } from "./errors.js"; import {
collectErrorGraphCandidates,
extractErrorCode,
formatUncaughtError,
readErrorName,
} from "./errors.js";
type UnhandledRejectionHandler = (reason: unknown) => boolean; type UnhandledRejectionHandler = (reason: unknown) => boolean;
@ -62,14 +67,6 @@ function getErrorCause(err: unknown): unknown {
return (err as { cause?: unknown }).cause; return (err as { cause?: unknown }).cause;
} }
function getErrorName(err: unknown): string {
if (!err || typeof err !== "object") {
return "";
}
const name = (err as { name?: unknown }).name;
return typeof name === "string" ? name : "";
}
function extractErrorCodeOrErrno(err: unknown): string | undefined { function extractErrorCodeOrErrno(err: unknown): string | undefined {
const code = extractErrorCode(err); const code = extractErrorCode(err);
if (code) { if (code) {
@ -96,44 +93,6 @@ function extractErrorCodeWithCause(err: unknown): string | undefined {
return extractErrorCode(getErrorCause(err)); return extractErrorCode(getErrorCause(err));
} }
function collectErrorCandidates(err: unknown): unknown[] {
const queue: unknown[] = [err];
const seen = new Set<unknown>();
const candidates: unknown[] = [];
while (queue.length > 0) {
const current = queue.shift();
if (current == null || seen.has(current)) {
continue;
}
seen.add(current);
candidates.push(current);
if (!current || typeof current !== "object") {
continue;
}
const maybeNested: Array<unknown> = [
(current as { cause?: unknown }).cause,
(current as { reason?: unknown }).reason,
(current as { original?: unknown }).original,
(current as { error?: unknown }).error,
(current as { data?: unknown }).data,
];
const errors = (current as { errors?: unknown }).errors;
if (Array.isArray(errors)) {
maybeNested.push(...errors);
}
for (const nested of maybeNested) {
if (nested != null && !seen.has(nested)) {
queue.push(nested);
}
}
}
return candidates;
}
/** /**
* Checks if an error is an AbortError. * Checks if an error is an AbortError.
* These are typically intentional cancellations (e.g., during shutdown) and shouldn't crash. * These are typically intentional cancellations (e.g., during shutdown) and shouldn't crash.
@ -172,13 +131,25 @@ export function isTransientNetworkError(err: unknown): boolean {
if (!err) { if (!err) {
return false; return false;
} }
for (const candidate of collectErrorCandidates(err)) { for (const candidate of collectErrorGraphCandidates(err, (current) => {
const nested: Array<unknown> = [
current.cause,
current.reason,
current.original,
current.error,
current.data,
];
if (Array.isArray(current.errors)) {
nested.push(...current.errors);
}
return nested;
})) {
const code = extractErrorCodeOrErrno(candidate); const code = extractErrorCodeOrErrno(candidate);
if (code && TRANSIENT_NETWORK_CODES.has(code)) { if (code && TRANSIENT_NETWORK_CODES.has(code)) {
return true; return true;
} }
const name = getErrorName(candidate); const name = readErrorName(candidate);
if (name && TRANSIENT_NETWORK_ERROR_NAMES.has(name)) { if (name && TRANSIENT_NETWORK_ERROR_NAMES.has(name)) {
return true; return true;
} }

View File

@ -8,6 +8,7 @@ import {
} from "./control-ui-assets.js"; } from "./control-ui-assets.js";
import { detectPackageManager as detectPackageManagerImpl } from "./detect-package-manager.js"; import { detectPackageManager as detectPackageManagerImpl } from "./detect-package-manager.js";
import { readPackageName, readPackageVersion } from "./package-json.js"; import { readPackageName, readPackageVersion } from "./package-json.js";
import { normalizePackageTagInput } from "./package-tag.js";
import { trimLogTail } from "./restart-sentinel.js"; import { trimLogTail } from "./restart-sentinel.js";
import { import {
channelToNpmTag, channelToNpmTag,
@ -312,17 +313,7 @@ function managerInstallArgs(manager: "pnpm" | "bun" | "npm") {
} }
function normalizeTag(tag?: string) { function normalizeTag(tag?: string) {
const trimmed = tag?.trim(); return normalizePackageTagInput(tag, ["openclaw", DEFAULT_PACKAGE_NAME]) ?? "latest";
if (!trimmed) {
return "latest";
}
if (trimmed.startsWith("openclaw@")) {
return trimmed.slice("openclaw@".length);
}
if (trimmed.startsWith(`${DEFAULT_PACKAGE_NAME}@`)) {
return trimmed.slice(`${DEFAULT_PACKAGE_NAME}@`.length);
}
return trimmed;
} }
export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<UpdateRunResult> { export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<UpdateRunResult> {

View File

@ -147,6 +147,32 @@ describe("update-startup", () => {
}); });
} }
function createBetaAutoUpdateConfig(params?: { checkOnStart?: boolean }) {
return {
update: {
...(params?.checkOnStart === false ? { checkOnStart: false } : {}),
channel: "beta" as const,
auto: {
enabled: true,
betaCheckIntervalHours: 1,
},
},
};
}
async function runAutoUpdateCheckWithDefaults(params: {
cfg: { update?: Record<string, unknown> };
runAutoUpdate?: ReturnType<typeof createAutoUpdateSuccessMock>;
}) {
await runGatewayUpdateCheck({
cfg: params.cfg,
log: { info: vi.fn() },
isNixMode: false,
allowInTests: true,
...(params.runAutoUpdate ? { runAutoUpdate: params.runAutoUpdate } : {}),
});
}
it.each([ it.each([
{ {
name: "stable channel", name: "stable channel",
@ -310,19 +336,8 @@ describe("update-startup", () => {
mockPackageUpdateStatus("beta", "2.0.0-beta.1"); mockPackageUpdateStatus("beta", "2.0.0-beta.1");
const runAutoUpdate = createAutoUpdateSuccessMock(); const runAutoUpdate = createAutoUpdateSuccessMock();
await runGatewayUpdateCheck({ await runAutoUpdateCheckWithDefaults({
cfg: { cfg: createBetaAutoUpdateConfig(),
update: {
channel: "beta",
auto: {
enabled: true,
betaCheckIntervalHours: 1,
},
},
},
log: { info: vi.fn() },
isNixMode: false,
allowInTests: true,
runAutoUpdate, runAutoUpdate,
}); });
@ -338,20 +353,8 @@ describe("update-startup", () => {
mockPackageUpdateStatus("beta", "2.0.0-beta.1"); mockPackageUpdateStatus("beta", "2.0.0-beta.1");
const runAutoUpdate = createAutoUpdateSuccessMock(); const runAutoUpdate = createAutoUpdateSuccessMock();
await runGatewayUpdateCheck({ await runAutoUpdateCheckWithDefaults({
cfg: { cfg: createBetaAutoUpdateConfig({ checkOnStart: false }),
update: {
checkOnStart: false,
channel: "beta",
auto: {
enabled: true,
betaCheckIntervalHours: 1,
},
},
},
log: { info: vi.fn() },
isNixMode: false,
allowInTests: true,
runAutoUpdate, runAutoUpdate,
}); });
@ -381,19 +384,8 @@ describe("update-startup", () => {
const originalArgv = process.argv.slice(); const originalArgv = process.argv.slice();
process.argv = [process.execPath, "/opt/openclaw/dist/entry.js"]; process.argv = [process.execPath, "/opt/openclaw/dist/entry.js"];
try { try {
await runGatewayUpdateCheck({ await runAutoUpdateCheckWithDefaults({
cfg: { cfg: createBetaAutoUpdateConfig(),
update: {
channel: "beta",
auto: {
enabled: true,
betaCheckIntervalHours: 1,
},
},
},
log: { info: vi.fn() },
isNixMode: false,
allowInTests: true,
}); });
} finally { } finally {
process.argv = originalArgv; process.argv = originalArgv;

View File

@ -50,11 +50,9 @@ describe("normalizePluginsConfig", () => {
}); });
describe("resolveEffectiveEnableState", () => { describe("resolveEffectiveEnableState", () => {
it("enables bundled channels when channels.<id>.enabled=true", () => { function resolveBundledTelegramState(config: Parameters<typeof normalizePluginsConfig>[0]) {
const normalized = normalizePluginsConfig({ const normalized = normalizePluginsConfig(config);
enabled: true, return resolveEffectiveEnableState({
});
const state = resolveEffectiveEnableState({
id: "telegram", id: "telegram",
origin: "bundled", origin: "bundled",
config: normalized, config: normalized,
@ -66,11 +64,17 @@ describe("resolveEffectiveEnableState", () => {
}, },
}, },
}); });
}
it("enables bundled channels when channels.<id>.enabled=true", () => {
const state = resolveBundledTelegramState({
enabled: true,
});
expect(state).toEqual({ enabled: true }); expect(state).toEqual({ enabled: true });
}); });
it("keeps explicit plugin-level disable authoritative", () => { it("keeps explicit plugin-level disable authoritative", () => {
const normalized = normalizePluginsConfig({ const state = resolveBundledTelegramState({
enabled: true, enabled: true,
entries: { entries: {
telegram: { telegram: {
@ -78,18 +82,6 @@ describe("resolveEffectiveEnableState", () => {
}, },
}, },
}); });
const state = resolveEffectiveEnableState({
id: "telegram",
origin: "bundled",
config: normalized,
rootConfig: {
channels: {
telegram: {
enabled: true,
},
},
},
});
expect(state).toEqual({ enabled: false, reason: "disabled in config" }); expect(state).toEqual({ enabled: false, reason: "disabled in config" });
}); });
}); });

View File

@ -7,6 +7,7 @@
*/ */
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { createHookRunner } from "./hooks.js"; import { createHookRunner } from "./hooks.js";
import { addTestHook, TEST_PLUGIN_AGENT_CTX } from "./hooks.test-helpers.js";
import { createEmptyPluginRegistry, type PluginRegistry } from "./registry.js"; import { createEmptyPluginRegistry, type PluginRegistry } from "./registry.js";
import type { PluginHookBeforeAgentStartResult, PluginHookRegistration } from "./types.js"; import type { PluginHookBeforeAgentStartResult, PluginHookRegistration } from "./types.js";
@ -16,21 +17,16 @@ function addBeforeAgentStartHook(
handler: () => PluginHookBeforeAgentStartResult | Promise<PluginHookBeforeAgentStartResult>, handler: () => PluginHookBeforeAgentStartResult | Promise<PluginHookBeforeAgentStartResult>,
priority?: number, priority?: number,
) { ) {
registry.typedHooks.push({ addTestHook({
registry,
pluginId, pluginId,
hookName: "before_agent_start", hookName: "before_agent_start",
handler, handler: handler as PluginHookRegistration["handler"],
priority, priority,
source: "test", });
} as PluginHookRegistration);
} }
const stubCtx = { const stubCtx = TEST_PLUGIN_AGENT_CTX;
agentId: "test-agent",
sessionKey: "sk",
sessionId: "sid",
workspaceDir: "/tmp",
};
describe("before_agent_start hook merger", () => { describe("before_agent_start hook merger", () => {
let registry: PluginRegistry; let registry: PluginRegistry;

View File

@ -8,10 +8,10 @@
*/ */
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { createHookRunner } from "./hooks.js"; import { createHookRunner } from "./hooks.js";
import { addTestHook, TEST_PLUGIN_AGENT_CTX } from "./hooks.test-helpers.js";
import { createEmptyPluginRegistry, type PluginRegistry } from "./registry.js"; import { createEmptyPluginRegistry, type PluginRegistry } from "./registry.js";
import type { import type {
PluginHookAgentContext, PluginHookAgentContext,
PluginHookBeforeAgentStartResult,
PluginHookBeforeModelResolveEvent, PluginHookBeforeModelResolveEvent,
PluginHookBeforeModelResolveResult, PluginHookBeforeModelResolveResult,
PluginHookBeforePromptBuildEvent, PluginHookBeforePromptBuildEvent,
@ -28,13 +28,13 @@ function addBeforeModelResolveHook(
) => PluginHookBeforeModelResolveResult | Promise<PluginHookBeforeModelResolveResult>, ) => PluginHookBeforeModelResolveResult | Promise<PluginHookBeforeModelResolveResult>,
priority?: number, priority?: number,
) { ) {
registry.typedHooks.push({ addTestHook({
registry,
pluginId, pluginId,
hookName: "before_model_resolve", hookName: "before_model_resolve",
handler, handler: handler as PluginHookRegistration["handler"],
priority, priority,
source: "test", });
} as PluginHookRegistration);
} }
function addBeforePromptBuildHook( function addBeforePromptBuildHook(
@ -46,36 +46,16 @@ function addBeforePromptBuildHook(
) => PluginHookBeforePromptBuildResult | Promise<PluginHookBeforePromptBuildResult>, ) => PluginHookBeforePromptBuildResult | Promise<PluginHookBeforePromptBuildResult>,
priority?: number, priority?: number,
) { ) {
registry.typedHooks.push({ addTestHook({
registry,
pluginId, pluginId,
hookName: "before_prompt_build", hookName: "before_prompt_build",
handler, handler: handler as PluginHookRegistration["handler"],
priority, priority,
source: "test", });
} as PluginHookRegistration);
} }
function addLegacyBeforeAgentStartHook( const stubCtx: PluginHookAgentContext = TEST_PLUGIN_AGENT_CTX;
registry: PluginRegistry,
pluginId: string,
handler: () => PluginHookBeforeAgentStartResult | Promise<PluginHookBeforeAgentStartResult>,
priority?: number,
) {
registry.typedHooks.push({
pluginId,
hookName: "before_agent_start",
handler,
priority,
source: "test",
} as PluginHookRegistration);
}
const stubCtx: PluginHookAgentContext = {
agentId: "test-agent",
sessionKey: "sk",
sessionId: "sid",
workspaceDir: "/tmp",
};
describe("model override pipeline wiring", () => { describe("model override pipeline wiring", () => {
let registry: PluginRegistry; let registry: PluginRegistry;
@ -109,10 +89,15 @@ describe("model override pipeline wiring", () => {
modelOverride: "llama3.3:8b", modelOverride: "llama3.3:8b",
providerOverride: "ollama", providerOverride: "ollama",
})); }));
addLegacyBeforeAgentStartHook(registry, "legacy-hook", () => ({ addTestHook({
modelOverride: "gpt-4o", registry,
providerOverride: "openai", pluginId: "legacy-hook",
})); hookName: "before_agent_start",
handler: (() => ({
modelOverride: "gpt-4o",
providerOverride: "openai",
})) as PluginHookRegistration["handler"],
});
const runner = createHookRunner(registry); const runner = createHookRunner(registry);
const explicit = await runner.runBeforeModelResolve({ prompt: "sensitive" }, stubCtx); const explicit = await runner.runBeforeModelResolve({ prompt: "sensitive" }, stubCtx);
@ -151,9 +136,14 @@ describe("model override pipeline wiring", () => {
addBeforePromptBuildHook(registry, "new-hook", () => ({ addBeforePromptBuildHook(registry, "new-hook", () => ({
prependContext: "new context", prependContext: "new context",
})); }));
addLegacyBeforeAgentStartHook(registry, "legacy-hook", () => ({ addTestHook({
prependContext: "legacy context", registry,
})); pluginId: "legacy-hook",
hookName: "before_agent_start",
handler: (() => ({
prependContext: "legacy context",
})) as PluginHookRegistration["handler"],
});
const runner = createHookRunner(registry); const runner = createHookRunner(registry);
const promptBuild = await runner.runBeforePromptBuild( const promptBuild = await runner.runBeforePromptBuild(
@ -207,7 +197,12 @@ describe("model override pipeline wiring", () => {
addBeforeModelResolveHook(registry, "plugin-a", () => ({})); addBeforeModelResolveHook(registry, "plugin-a", () => ({}));
addBeforePromptBuildHook(registry, "plugin-b", () => ({})); addBeforePromptBuildHook(registry, "plugin-b", () => ({}));
addLegacyBeforeAgentStartHook(registry, "plugin-c", () => ({})); addTestHook({
registry,
pluginId: "plugin-c",
hookName: "before_agent_start",
handler: (() => ({})) as PluginHookRegistration["handler"],
});
const runner2 = createHookRunner(registry); const runner2 = createHookRunner(registry);
expect(runner2.hasHooks("before_model_resolve")).toBe(true); expect(runner2.hasHooks("before_model_resolve")).toBe(true);

View File

@ -1,6 +1,6 @@
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import type { PluginInstallRecord } from "../config/types.plugins.js"; import type { PluginInstallRecord } from "../config/types.plugins.js";
import type { NpmSpecResolution } from "../infra/install-source-utils.js"; import { buildNpmResolutionFields, type NpmSpecResolution } from "../infra/install-source-utils.js";
export type PluginInstallUpdate = PluginInstallRecord & { pluginId: string }; export type PluginInstallUpdate = PluginInstallRecord & { pluginId: string };
@ -10,14 +10,7 @@ export function buildNpmResolutionInstallFields(
PluginInstallRecord, PluginInstallRecord,
"resolvedName" | "resolvedVersion" | "resolvedSpec" | "integrity" | "shasum" | "resolvedAt" "resolvedName" | "resolvedVersion" | "resolvedSpec" | "integrity" | "shasum" | "resolvedAt"
> { > {
return { return buildNpmResolutionFields(resolution);
resolvedName: resolution?.name,
resolvedVersion: resolution?.version,
resolvedSpec: resolution?.resolvedSpec,
integrity: resolution?.integrity,
shasum: resolution?.shasum,
resolvedAt: resolution?.resolvedAt,
};
} }
export function recordPluginInstall( export function recordPluginInstall(

View File

@ -570,8 +570,7 @@ export type PluginHookSubagentContext = {
export type PluginHookSubagentTargetKind = "subagent" | "acp"; export type PluginHookSubagentTargetKind = "subagent" | "acp";
// subagent_spawning hook type PluginHookSubagentSpawnBase = {
export type PluginHookSubagentSpawningEvent = {
childSessionKey: string; childSessionKey: string;
agentId: string; agentId: string;
label?: string; label?: string;
@ -585,6 +584,9 @@ export type PluginHookSubagentSpawningEvent = {
threadRequested: boolean; threadRequested: boolean;
}; };
// subagent_spawning hook
export type PluginHookSubagentSpawningEvent = PluginHookSubagentSpawnBase;
export type PluginHookSubagentSpawningResult = export type PluginHookSubagentSpawningResult =
| { | {
status: "ok"; status: "ok";
@ -620,19 +622,8 @@ export type PluginHookSubagentDeliveryTargetResult = {
}; };
// subagent_spawned hook // subagent_spawned hook
export type PluginHookSubagentSpawnedEvent = { export type PluginHookSubagentSpawnedEvent = PluginHookSubagentSpawnBase & {
runId: string; runId: string;
childSessionKey: string;
agentId: string;
label?: string;
mode: "run" | "session";
requester?: {
channel?: string;
accountId?: string;
to?: string;
threadId?: string | number;
};
threadRequested: boolean;
}; };
// subagent_ended hook // subagent_ended hook

View File

@ -41,6 +41,28 @@ function createMockChild(params?: { code?: number; signal?: NodeJS.Signals | nul
return child; return child;
} }
type SpawnCall = [string, string[], Record<string, unknown>];
type ExecCall = [
string,
string[],
Record<string, unknown>,
(err: Error | null, stdout: string, stderr: string) => void,
];
function expectCmdWrappedInvocation(params: {
captured: SpawnCall | ExecCall | undefined;
expectedComSpec: string;
}) {
if (!params.captured) {
throw new Error("expected command wrapper to be called");
}
expect(params.captured[0]).toBe(params.expectedComSpec);
expect(params.captured[1].slice(0, 3)).toEqual(["/d", "/s", "/c"]);
expect(params.captured[1][3]).toContain("pnpm.cmd --version");
expect(params.captured[2].windowsVerbatimArguments).toBe(true);
}
describe("windows command wrapper behavior", () => { describe("windows command wrapper behavior", () => {
afterEach(() => { afterEach(() => {
spawnMock.mockReset(); spawnMock.mockReset();
@ -59,16 +81,8 @@ describe("windows command wrapper behavior", () => {
try { try {
const result = await runCommandWithTimeout(["pnpm", "--version"], { timeoutMs: 1000 }); const result = await runCommandWithTimeout(["pnpm", "--version"], { timeoutMs: 1000 });
expect(result.code).toBe(0); expect(result.code).toBe(0);
const captured = spawnMock.mock.calls[0] as const captured = spawnMock.mock.calls[0] as SpawnCall | undefined;
| [string, string[], Record<string, unknown>] expectCmdWrappedInvocation({ captured, expectedComSpec });
| undefined;
if (!captured) {
throw new Error("spawn mock was not called");
}
expect(captured[0]).toBe(expectedComSpec);
expect(captured[1].slice(0, 3)).toEqual(["/d", "/s", "/c"]);
expect(captured[1][3]).toContain("pnpm.cmd --version");
expect(captured[2].windowsVerbatimArguments).toBe(true);
} finally { } finally {
platformSpy.mockRestore(); platformSpy.mockRestore();
} }
@ -91,21 +105,8 @@ describe("windows command wrapper behavior", () => {
try { try {
await runExec("pnpm", ["--version"], 1000); await runExec("pnpm", ["--version"], 1000);
const captured = execFileMock.mock.calls[0] as const captured = execFileMock.mock.calls[0] as ExecCall | undefined;
| [ expectCmdWrappedInvocation({ captured, expectedComSpec });
string,
string[],
Record<string, unknown>,
(err: Error | null, stdout: string, stderr: string) => void,
]
| undefined;
if (!captured) {
throw new Error("execFile mock was not called");
}
expect(captured[0]).toBe(expectedComSpec);
expect(captured[1].slice(0, 3)).toEqual(["/d", "/s", "/c"]);
expect(captured[1][3]).toContain("pnpm.cmd --version");
expect(captured[2].windowsVerbatimArguments).toBe(true);
} finally { } finally {
platformSpy.mockRestore(); platformSpy.mockRestore();
} }

View File

@ -210,6 +210,31 @@ function assertNoCancel<T>(value: T | symbol, message: string): T {
return value; return value;
} }
function validateEnvNameCsv(value: string): string | undefined {
const entries = parseCsv(value);
for (const entry of entries) {
if (!ENV_NAME_PATTERN.test(entry)) {
return `Invalid env name: ${entry}`;
}
}
return undefined;
}
async function promptEnvNameCsv(params: {
message: string;
initialValue: string;
}): Promise<string[]> {
const raw = assertNoCancel(
await text({
message: params.message,
initialValue: params.initialValue,
validate: (value) => validateEnvNameCsv(String(value ?? "")),
}),
"Secrets configure cancelled.",
);
return parseCsv(String(raw ?? ""));
}
async function promptOptionalPositiveInt(params: { async function promptOptionalPositiveInt(params: {
message: string; message: string;
initialValue?: number; initialValue?: number;
@ -275,23 +300,10 @@ async function promptProviderSource(initial?: SecretRefSource): Promise<SecretRe
async function promptEnvProvider( async function promptEnvProvider(
base?: Extract<SecretProviderConfig, { source: "env" }>, base?: Extract<SecretProviderConfig, { source: "env" }>,
): Promise<Extract<SecretProviderConfig, { source: "env" }>> { ): Promise<Extract<SecretProviderConfig, { source: "env" }>> {
const allowlistRaw = assertNoCancel( const allowlist = await promptEnvNameCsv({
await text({ message: "Env allowlist (comma-separated, blank for unrestricted)",
message: "Env allowlist (comma-separated, blank for unrestricted)", initialValue: base?.allowlist?.join(",") ?? "",
initialValue: base?.allowlist?.join(",") ?? "", });
validate: (value) => {
const entries = parseCsv(String(value ?? ""));
for (const entry of entries) {
if (!ENV_NAME_PATTERN.test(entry)) {
return `Invalid env name: ${entry}`;
}
}
return undefined;
},
}),
"Secrets configure cancelled.",
);
const allowlist = parseCsv(String(allowlistRaw ?? ""));
return { return {
source: "env", source: "env",
...(allowlist.length > 0 ? { allowlist } : {}), ...(allowlist.length > 0 ? { allowlist } : {}),
@ -436,22 +448,10 @@ async function promptExecProvider(
"Secrets configure cancelled.", "Secrets configure cancelled.",
); );
const passEnvRaw = assertNoCancel( const passEnv = await promptEnvNameCsv({
await text({ message: "Pass-through env vars (comma-separated, blank for none)",
message: "Pass-through env vars (comma-separated, blank for none)", initialValue: base?.passEnv?.join(",") ?? "",
initialValue: base?.passEnv?.join(",") ?? "", });
validate: (value) => {
const entries = parseCsv(String(value ?? ""));
for (const entry of entries) {
if (!ENV_NAME_PATTERN.test(entry)) {
return `Invalid env name: ${entry}`;
}
}
return undefined;
},
}),
"Secrets configure cancelled.",
);
const trustedDirsRaw = assertNoCancel( const trustedDirsRaw = assertNoCancel(
await text({ await text({
@ -486,7 +486,6 @@ async function promptExecProvider(
); );
const args = await parseArgsInput(String(argsRaw ?? "")); const args = await parseArgsInput(String(argsRaw ?? ""));
const passEnv = parseCsv(String(passEnvRaw ?? ""));
const trustedDirs = parseCsv(String(trustedDirsRaw ?? "")); const trustedDirs = parseCsv(String(trustedDirsRaw ?? ""));
return { return {

View File

@ -27,6 +27,46 @@ describe("secret ref resolver", () => {
return dir; return dir;
}; };
type ExecProviderConfig = {
source: "exec";
command: string;
passEnv?: string[];
jsonOnly?: boolean;
allowSymlinkCommand?: boolean;
trustedDirs?: string[];
args?: string[];
};
function createExecProviderConfig(
command: string,
overrides: Partial<ExecProviderConfig> = {},
): ExecProviderConfig {
return {
source: "exec",
command,
passEnv: ["PATH"],
...overrides,
};
}
async function resolveExecSecret(
command: string,
overrides: Partial<ExecProviderConfig> = {},
): Promise<string> {
return resolveSecretRefString(
{ source: "exec", provider: "execmain", id: "openai/api-key" },
{
config: {
secrets: {
providers: {
execmain: createExecProviderConfig(command, overrides),
},
},
},
},
);
}
beforeAll(async () => { beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-")); fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-"));
const sharedExecDir = path.join(fixtureRoot, "shared-exec"); const sharedExecDir = path.join(fixtureRoot, "shared-exec");
@ -134,22 +174,7 @@ describe("secret ref resolver", () => {
return; return;
} }
const value = await resolveSecretRefString( const value = await resolveExecSecret(execProtocolV1ScriptPath);
{ source: "exec", provider: "execmain", id: "openai/api-key" },
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: execProtocolV1ScriptPath,
passEnv: ["PATH"],
},
},
},
},
},
);
expect(value).toBe("value:openai/api-key"); expect(value).toBe("value:openai/api-key");
}); });
@ -158,23 +183,7 @@ describe("secret ref resolver", () => {
return; return;
} }
const value = await resolveSecretRefString( const value = await resolveExecSecret(execPlainScriptPath, { jsonOnly: false });
{ source: "exec", provider: "execmain", id: "openai/api-key" },
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: execPlainScriptPath,
passEnv: ["PATH"],
jsonOnly: false,
},
},
},
},
},
);
expect(value).toBe("plain-secret"); expect(value).toBe("plain-secret");
}); });
@ -210,25 +219,9 @@ describe("secret ref resolver", () => {
const symlinkPath = path.join(root, "resolver-link.mjs"); const symlinkPath = path.join(root, "resolver-link.mjs");
await fs.symlink(execPlainScriptPath, symlinkPath); await fs.symlink(execPlainScriptPath, symlinkPath);
await expect( await expect(resolveExecSecret(symlinkPath, { jsonOnly: false })).rejects.toThrow(
resolveSecretRefString( "must not be a symlink",
{ source: "exec", provider: "execmain", id: "openai/api-key" }, );
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: symlinkPath,
passEnv: ["PATH"],
jsonOnly: false,
},
},
},
},
},
),
).rejects.toThrow("must not be a symlink");
}); });
it("allows symlink command paths when allowSymlinkCommand is enabled", async () => { it("allows symlink command paths when allowSymlinkCommand is enabled", async () => {
@ -240,25 +233,11 @@ describe("secret ref resolver", () => {
await fs.symlink(execPlainScriptPath, symlinkPath); await fs.symlink(execPlainScriptPath, symlinkPath);
const trustedRoot = await fs.realpath(fixtureRoot); const trustedRoot = await fs.realpath(fixtureRoot);
const value = await resolveSecretRefString( const value = await resolveExecSecret(symlinkPath, {
{ source: "exec", provider: "execmain", id: "openai/api-key" }, jsonOnly: false,
{ allowSymlinkCommand: true,
config: { trustedDirs: [trustedRoot],
secrets: { });
providers: {
execmain: {
source: "exec",
command: symlinkPath,
passEnv: ["PATH"],
jsonOnly: false,
allowSymlinkCommand: true,
trustedDirs: [trustedRoot],
},
},
},
},
},
);
expect(value).toBe("plain-secret"); expect(value).toBe("plain-secret");
}); });
@ -287,44 +266,15 @@ describe("secret ref resolver", () => {
await fs.symlink(targetCommand, symlinkCommand); await fs.symlink(targetCommand, symlinkCommand);
const trustedRoot = await fs.realpath(root); const trustedRoot = await fs.realpath(root);
await expect( await expect(resolveExecSecret(symlinkCommand, { args: ["brew"] })).rejects.toThrow(
resolveSecretRefString( "must not be a symlink",
{ source: "exec", provider: "execmain", id: "openai/api-key" },
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: symlinkCommand,
args: ["brew"],
passEnv: ["PATH"],
},
},
},
},
},
),
).rejects.toThrow("must not be a symlink");
const value = await resolveSecretRefString(
{ source: "exec", provider: "execmain", id: "openai/api-key" },
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: symlinkCommand,
args: ["brew"],
allowSymlinkCommand: true,
trustedDirs: [trustedRoot],
},
},
},
},
},
); );
const value = await resolveExecSecret(symlinkCommand, {
args: ["brew"],
allowSymlinkCommand: true,
trustedDirs: [trustedRoot],
});
expect(value).toBe("brew:openai/api-key"); expect(value).toBe("brew:openai/api-key");
}); });
@ -337,25 +287,11 @@ describe("secret ref resolver", () => {
await fs.symlink(execPlainScriptPath, symlinkPath); await fs.symlink(execPlainScriptPath, symlinkPath);
await expect( await expect(
resolveSecretRefString( resolveExecSecret(symlinkPath, {
{ source: "exec", provider: "execmain", id: "openai/api-key" }, jsonOnly: false,
{ allowSymlinkCommand: true,
config: { trustedDirs: [root],
secrets: { }),
providers: {
execmain: {
source: "exec",
command: symlinkPath,
passEnv: ["PATH"],
jsonOnly: false,
allowSymlinkCommand: true,
trustedDirs: [root],
},
},
},
},
},
),
).rejects.toThrow("outside trustedDirs"); ).rejects.toThrow("outside trustedDirs");
}); });
@ -363,73 +299,27 @@ describe("secret ref resolver", () => {
if (process.platform === "win32") { if (process.platform === "win32") {
return; return;
} }
await expect( await expect(resolveExecSecret(execProtocolV2ScriptPath)).rejects.toThrow(
resolveSecretRefString( "protocolVersion must be 1",
{ source: "exec", provider: "execmain", id: "openai/api-key" }, );
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: execProtocolV2ScriptPath,
passEnv: ["PATH"],
},
},
},
},
},
),
).rejects.toThrow("protocolVersion must be 1");
}); });
it("rejects exec refs when response omits requested id", async () => { it("rejects exec refs when response omits requested id", async () => {
if (process.platform === "win32") { if (process.platform === "win32") {
return; return;
} }
await expect( await expect(resolveExecSecret(execMissingIdScriptPath)).rejects.toThrow(
resolveSecretRefString( 'response missing id "openai/api-key"',
{ source: "exec", provider: "execmain", id: "openai/api-key" }, );
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: execMissingIdScriptPath,
passEnv: ["PATH"],
},
},
},
},
},
),
).rejects.toThrow('response missing id "openai/api-key"');
}); });
it("rejects exec refs with invalid JSON when jsonOnly is true", async () => { it("rejects exec refs with invalid JSON when jsonOnly is true", async () => {
if (process.platform === "win32") { if (process.platform === "win32") {
return; return;
} }
await expect( await expect(resolveExecSecret(execInvalidJsonScriptPath, { jsonOnly: true })).rejects.toThrow(
resolveSecretRefString( "returned invalid JSON",
{ source: "exec", provider: "execmain", id: "openai/api-key" }, );
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: execInvalidJsonScriptPath,
passEnv: ["PATH"],
jsonOnly: true,
},
},
},
},
},
),
).rejects.toThrow("returned invalid JSON");
}); });
it("supports file singleValue mode with id=value", async () => { it("supports file singleValue mode with id=value", async () => {

View File

@ -2,6 +2,35 @@ import fsSync from "node:fs";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { getProcessStartTime, isPidAlive } from "./pid-alive.js"; import { getProcessStartTime, isPidAlive } from "./pid-alive.js";
function mockProcReads(entries: Record<string, string>) {
const originalReadFileSync = fsSync.readFileSync;
vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => {
const key = String(filePath);
if (Object.hasOwn(entries, key)) {
return entries[key] as never;
}
return originalReadFileSync(filePath as never, encoding as never) as never;
});
}
async function withLinuxProcessPlatform<T>(run: () => Promise<T>): Promise<T> {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
if (!originalPlatformDescriptor) {
throw new Error("missing process.platform descriptor");
}
Object.defineProperty(process, "platform", {
...originalPlatformDescriptor,
value: "linux",
});
try {
vi.resetModules();
return await run();
} finally {
Object.defineProperty(process, "platform", originalPlatformDescriptor);
vi.restoreAllMocks();
}
}
describe("isPidAlive", () => { describe("isPidAlive", () => {
it("returns true for the current running process", () => { it("returns true for the current running process", () => {
expect(isPidAlive(process.pid)).toBe(true); expect(isPidAlive(process.pid)).toBe(true);
@ -22,68 +51,29 @@ describe("isPidAlive", () => {
it("returns false for zombie processes on Linux", async () => { it("returns false for zombie processes on Linux", async () => {
const zombiePid = process.pid; const zombiePid = process.pid;
// Mock readFileSync to return zombie state for /proc/<pid>/status mockProcReads({
const originalReadFileSync = fsSync.readFileSync; [`/proc/${zombiePid}/status`]: `Name:\tnode\nUmask:\t0022\nState:\tZ (zombie)\nTgid:\t${zombiePid}\nPid:\t${zombiePid}\n`,
vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => {
if (filePath === `/proc/${zombiePid}/status`) {
return `Name:\tnode\nUmask:\t0022\nState:\tZ (zombie)\nTgid:\t${zombiePid}\nPid:\t${zombiePid}\n`;
}
return originalReadFileSync(filePath as never, encoding as never) as never;
}); });
await withLinuxProcessPlatform(async () => {
// Override platform to linux so the zombie check runs
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
if (!originalPlatformDescriptor) {
throw new Error("missing process.platform descriptor");
}
Object.defineProperty(process, "platform", {
...originalPlatformDescriptor,
value: "linux",
});
try {
// Re-import the module so it picks up the mocked platform and fs
vi.resetModules();
const { isPidAlive: freshIsPidAlive } = await import("./pid-alive.js"); const { isPidAlive: freshIsPidAlive } = await import("./pid-alive.js");
expect(freshIsPidAlive(zombiePid)).toBe(false); expect(freshIsPidAlive(zombiePid)).toBe(false);
} finally { });
Object.defineProperty(process, "platform", originalPlatformDescriptor);
vi.restoreAllMocks();
}
}); });
}); });
describe("getProcessStartTime", () => { describe("getProcessStartTime", () => {
it("returns a number on Linux for the current process", async () => { it("returns a number on Linux for the current process", async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
if (!originalPlatformDescriptor) {
throw new Error("missing process.platform descriptor");
}
const originalReadFileSync = fsSync.readFileSync;
// Simulate a realistic /proc/<pid>/stat line // Simulate a realistic /proc/<pid>/stat line
const fakeStat = `${process.pid} (node) S 1 ${process.pid} ${process.pid} 0 -1 4194304 12345 0 0 0 100 50 0 0 20 0 8 0 98765 123456789 5000 18446744073709551615 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0`; const fakeStat = `${process.pid} (node) S 1 ${process.pid} ${process.pid} 0 -1 4194304 12345 0 0 0 100 50 0 0 20 0 8 0 98765 123456789 5000 18446744073709551615 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0`;
vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => { mockProcReads({
if (filePath === `/proc/${process.pid}/stat`) { [`/proc/${process.pid}/stat`]: fakeStat,
return fakeStat;
}
return originalReadFileSync(filePath as never, encoding as never) as never;
}); });
Object.defineProperty(process, "platform", { await withLinuxProcessPlatform(async () => {
...originalPlatformDescriptor,
value: "linux",
});
try {
vi.resetModules();
const { getProcessStartTime: fresh } = await import("./pid-alive.js"); const { getProcessStartTime: fresh } = await import("./pid-alive.js");
const starttime = fresh(process.pid); const starttime = fresh(process.pid);
expect(starttime).toBe(98765); expect(starttime).toBe(98765);
} finally { });
Object.defineProperty(process, "platform", originalPlatformDescriptor);
vi.restoreAllMocks();
}
}); });
it("returns null on non-Linux platforms", () => { it("returns null on non-Linux platforms", () => {
@ -104,62 +94,24 @@ describe("getProcessStartTime", () => {
}); });
it("returns null for malformed /proc stat content", async () => { it("returns null for malformed /proc stat content", async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); mockProcReads({
if (!originalPlatformDescriptor) { "/proc/42/stat": "42 node S malformed",
throw new Error("missing process.platform descriptor");
}
const originalReadFileSync = fsSync.readFileSync;
vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => {
if (filePath === "/proc/42/stat") {
return "42 node S malformed";
}
return originalReadFileSync(filePath as never, encoding as never) as never;
}); });
await withLinuxProcessPlatform(async () => {
Object.defineProperty(process, "platform", {
...originalPlatformDescriptor,
value: "linux",
});
try {
vi.resetModules();
const { getProcessStartTime: fresh } = await import("./pid-alive.js"); const { getProcessStartTime: fresh } = await import("./pid-alive.js");
expect(fresh(42)).toBeNull(); expect(fresh(42)).toBeNull();
} finally { });
Object.defineProperty(process, "platform", originalPlatformDescriptor);
vi.restoreAllMocks();
}
}); });
it("handles comm fields containing spaces and parentheses", async () => { it("handles comm fields containing spaces and parentheses", async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
if (!originalPlatformDescriptor) {
throw new Error("missing process.platform descriptor");
}
const originalReadFileSync = fsSync.readFileSync;
// comm field with spaces and nested parens: "(My App (v2))" // comm field with spaces and nested parens: "(My App (v2))"
const fakeStat = `42 (My App (v2)) S 1 42 42 0 -1 4194304 0 0 0 0 0 0 0 0 20 0 1 0 55555 0 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0`; const fakeStat = `42 (My App (v2)) S 1 42 42 0 -1 4194304 0 0 0 0 0 0 0 0 20 0 1 0 55555 0 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0`;
vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => { mockProcReads({
if (filePath === "/proc/42/stat") { "/proc/42/stat": fakeStat,
return fakeStat;
}
return originalReadFileSync(filePath as never, encoding as never) as never;
}); });
await withLinuxProcessPlatform(async () => {
Object.defineProperty(process, "platform", {
...originalPlatformDescriptor,
value: "linux",
});
try {
vi.resetModules();
const { getProcessStartTime: fresh } = await import("./pid-alive.js"); const { getProcessStartTime: fresh } = await import("./pid-alive.js");
expect(fresh(42)).toBe(55555); expect(fresh(42)).toBe(55555);
} finally { });
Object.defineProperty(process, "platform", originalPlatformDescriptor);
vi.restoreAllMocks();
}
}); });
}); });