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

View File

@@ -2,7 +2,12 @@ import type { Command } from "commander";
import { danger } from "../../globals.js";
import { defaultRuntime } from "../../runtime.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(
browser: Command,
@@ -30,11 +35,7 @@ export function registerBrowserFormWaitEvalCommands(
targetId: opts.targetId?.trim() || undefined,
},
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`filled ${fields.length} field(s)`);
logBrowserActionResult(parent, result, `filled ${fields.length} field(s)`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
@@ -83,11 +84,7 @@ export function registerBrowserFormWaitEvalCommands(
},
timeoutMs,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log("wait complete");
logBrowserActionResult(parent, result, "wait complete");
} catch (err) {
defaultRuntime.error(danger(String(err)));
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) {
const refValue = typeof ref === "string" ? ref.trim() : "";
if (!refValue) {

View File

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

View File

@@ -9,6 +9,7 @@ import { parsePositiveIntOrUndefined } from "../program/helpers.js";
import {
getCronChannelOptions,
parseAt,
parseCronStaggerMs,
parseDurationMs,
printCronList,
warnIfCronSchedulerDisabled,
@@ -129,19 +130,7 @@ export function registerCronAddCommand(cron: Command) {
}
return { kind: "every" as const, everyMs };
}
const staggerMs = (() => {
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 staggerMs = parseCronStaggerMs({ staggerRaw, useExact });
return {
kind: "cron" as const,
expr: cronExpr,

View File

@@ -7,6 +7,7 @@ import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import {
getCronChannelOptions,
parseAt,
parseCronStaggerMs,
parseDurationMs,
warnIfCronSchedulerDisabled,
} from "./shared.js";
@@ -98,19 +99,7 @@ export function registerCronEditCommand(cron: Command) {
if (staggerRaw && useExact) {
throw new Error("Choose either --stagger or --exact, not both");
}
const requestedStaggerMs = (() => {
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 requestedStaggerMs = parseCronStaggerMs({ staggerRaw, useExact });
const patch: Record<string, unknown> = {};
if (typeof opts.name === "string") {

View File

@@ -62,6 +62,23 @@ export function parseDurationMs(input: string): number | null {
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 {
const raw = input.trim();
if (!raw) {

View File

@@ -15,6 +15,32 @@ vi.mock("../../infra/ports.js", () => ({
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", () => {
beforeEach(() => {
inspectPortUsage.mockReset();
@@ -71,24 +97,8 @@ describe("inspectGatewayRestart", () => {
});
it("treats unknown listeners as stale on Windows when enabled", async () => {
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
classifyPortListener.mockReturnValue("unknown");
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,
const snapshot = await inspectUnknownListenerFallback({
runtime: { status: "stopped" },
includeUnknownListenersAsStale: true,
});
@@ -96,24 +106,8 @@ describe("inspectGatewayRestart", () => {
});
it("does not treat unknown listeners as stale when fallback is disabled", async () => {
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
classifyPortListener.mockReturnValue("unknown");
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,
const snapshot = await inspectUnknownListenerFallback({
runtime: { status: "stopped" },
includeUnknownListenersAsStale: false,
});
@@ -121,24 +115,8 @@ describe("inspectGatewayRestart", () => {
});
it("does not apply unknown-listener fallback while runtime is running", async () => {
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
classifyPortListener.mockReturnValue("unknown");
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,
const snapshot = await inspectUnknownListenerFallback({
runtime: { status: "running", pid: 10920 },
includeUnknownListenersAsStale: true,
});

View File

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

View File

@@ -26,6 +26,7 @@ import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { resolveUserPath, shortenHomePath } from "../utils.js";
import { formatCliCommand } from "./command-format.js";
import { looksLikeLocalInstallSpec } from "./install-spec.js";
import {
buildNpmInstallRecordFields,
resolvePinnedNpmInstallRecordForCli,
@@ -660,15 +661,7 @@ export function registerHooksCli(program: Command): void {
process.exit(1);
}
const looksLikePath =
raw.startsWith(".") ||
raw.startsWith("~") ||
path.isAbsolute(raw) ||
raw.endsWith(".zip") ||
raw.endsWith(".tgz") ||
raw.endsWith(".tar.gz") ||
raw.endsWith(".tar");
if (looksLikePath) {
if (looksLikeLocalInstallSpec(raw, [".zip", ".tgz", ".tar.gz", ".tar"])) {
defaultRuntime.error(`Path not found: ${resolved}`);
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 = {
name?: string;
version?: string;
resolvedSpec?: string;
integrity?: string;
shasum?: string;
resolvedAt?: string;
};
import {
buildNpmResolutionFields,
type NpmSpecResolution as NpmResolutionMetadata,
} from "../infra/install-source-utils.js";
export function resolvePinnedNpmSpec(params: {
rawSpec: string;
@@ -36,14 +32,7 @@ export function mapNpmResolutionMetadata(resolution?: NpmResolutionMetadata): {
shasum?: string;
resolvedAt?: string;
} {
return {
resolvedName: resolution?.name,
resolvedVersion: resolution?.version,
resolvedSpec: resolution?.resolvedSpec,
integrity: resolution?.integrity,
shasum: resolution?.shasum,
resolvedAt: resolution?.resolvedAt,
};
return buildNpmResolutionFields(resolution);
}
export function buildNpmInstallRecordFields(params: {
@@ -68,7 +57,7 @@ export function buildNpmInstallRecordFields(params: {
spec: params.spec,
installPath: params.installPath,
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 { theme } from "../terminal/theme.js";
import { resolveUserPath, shortenHomeInString, shortenHomePath } from "../utils.js";
import { looksLikeLocalInstallSpec } from "./install-spec.js";
import { resolvePinnedNpmInstallRecordForCli } from "./npm-resolution.js";
import { setPluginEnabledInConfig } from "./plugins-config.js";
import { promptYesNo } from "./prompt.js";
@@ -603,19 +604,18 @@ export function registerPluginsCli(program: Command) {
process.exit(1);
}
const looksLikePath =
raw.startsWith(".") ||
raw.startsWith("~") ||
path.isAbsolute(raw) ||
raw.endsWith(".ts") ||
raw.endsWith(".js") ||
raw.endsWith(".mjs") ||
raw.endsWith(".cjs") ||
raw.endsWith(".tgz") ||
raw.endsWith(".tar.gz") ||
raw.endsWith(".tar") ||
raw.endsWith(".zip");
if (looksLikePath) {
if (
looksLikeLocalInstallSpec(raw, [
".ts",
".js",
".mjs",
".cjs",
".tgz",
".tar.gz",
".tar",
".zip",
])
) {
defaultRuntime.error(`Path not found: ${resolved}`);
process.exit(1);
}

View File

@@ -65,6 +65,18 @@ describe("cli program (nodes media)", () => {
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: {
command: "camera.snap" | "camera.clip";
payload: Record<string, unknown>;
@@ -266,54 +278,27 @@ describe("cli program (nodes media)", () => {
});
it("fails nodes camera snap on invalid facing", async () => {
mockNodeGateway();
const program = new Command();
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,
await expectCameraSnapParseFailure(
["nodes", "camera", "snap", "--node", "ios-node", "--facing", "nope"],
/invalid facing/i,
);
});
it("fails nodes camera snap when --facing both and --device-id are combined", async () => {
mockNodeGateway();
const program = new Command();
program.exitOverride();
registerNodesCli(program);
runtime.error.mockClear();
await expect(
program.parseAsync(
[
"nodes",
"camera",
"snap",
"--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);
await expectCameraSnapParseFailure(
[
"nodes",
"camera",
"snap",
"--node",
"ios-node",
"--facing",
"both",
"--device-id",
"cam-123",
],
/facing=both is not allowed when --device-id is set/i,
);
});
describe("URL-based payloads", () => {

View File

@@ -5,6 +5,7 @@ import path from "node:path";
import { resolveStateDir } from "../../config/paths.js";
import { resolveOpenClawPackageRoot } from "../../infra/openclaw-root.js";
import { readPackageName, readPackageVersion } from "../../infra/package-json.js";
import { normalizePackageTagInput } from "../../infra/package-tag.js";
import { trimLogTail } from "../../infra/restart-sentinel.js";
import { parseSemver } from "../../infra/runtime-guard.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]);
export function normalizeTag(value?: string | null): string | null {
if (!value) {
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;
return normalizePackageTagInput(value, ["openclaw", DEFAULT_PACKAGE_NAME]);
}
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-" });
}
function mockConfig(home: string, storePath: string) {
loadConfigSpy.mockReturnValue({
function createAcpEnabledConfig(home: string, storePath: string): OpenClawConfig {
return {
acp: {
enabled: true,
backend: "acpx",
@@ -42,7 +42,11 @@ function mockConfig(home: string, storePath: string) {
},
},
session: { store: storePath, mainKey: "main" },
} satisfies OpenClawConfig);
};
}
function mockConfig(home: string, storePath: string) {
loadConfigSpy.mockReturnValue(createAcpEnabledConfig(home, storePath));
}
function mockConfigWithAcpOverrides(
@@ -50,23 +54,12 @@ function mockConfigWithAcpOverrides(
storePath: string,
acpOverrides: Partial<NonNullable<OpenClawConfig["acp"]>>,
) {
loadConfigSpy.mockReturnValue({
acp: {
enabled: true,
backend: "acpx",
allowedAgents: ["codex"],
dispatch: { enabled: true },
...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);
const cfg = createAcpEnabledConfig(home, storePath);
cfg.acp = {
...cfg.acp,
...acpOverrides,
};
loadConfigSpy.mockReturnValue(cfg);
}
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(
tokenProvider: string | null | undefined,
): string | undefined {

View File

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

View File

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

View File

@@ -22,6 +22,8 @@ vi.mock("../terminal/note.js", () => ({
note,
}));
const { maybeRepairSandboxImages } = await import("./doctor-sandbox.js");
describe("maybeRepairSandboxImages", () => {
const mockRuntime: RuntimeEnv = {
log: vi.fn(),
@@ -37,22 +39,32 @@ describe("maybeRepairSandboxImages", () => {
vi.clearAllMocks();
});
it("warns when sandbox mode is enabled but Docker is not available", async () => {
// Simulate Docker not available (command fails)
runExec.mockRejectedValue(new Error("Docker not installed"));
const config: OpenClawConfig = {
function createSandboxConfig(mode: "off" | "all" | "non-main"): OpenClawConfig {
return {
agents: {
defaults: {
sandbox: {
mode: "non-main",
mode,
},
},
},
};
}
const { maybeRepairSandboxImages } = await import("./doctor-sandbox.js");
await maybeRepairSandboxImages(config, mockRuntime, mockPrompter);
async function runSandboxRepair(params: {
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
expect(note).toHaveBeenCalled();
@@ -66,20 +78,7 @@ describe("maybeRepairSandboxImages", () => {
});
it("warns when sandbox mode is 'all' but Docker is not available", async () => {
runExec.mockRejectedValue(new Error("Docker not installed"));
const config: OpenClawConfig = {
agents: {
defaults: {
sandbox: {
mode: "all",
},
},
},
};
const { maybeRepairSandboxImages } = await import("./doctor-sandbox.js");
await maybeRepairSandboxImages(config, mockRuntime, mockPrompter);
await runSandboxRepair({ mode: "all", dockerAvailable: false });
expect(note).toHaveBeenCalled();
const noteCall = note.mock.calls[0];
@@ -90,41 +89,14 @@ describe("maybeRepairSandboxImages", () => {
});
it("does not warn when sandbox mode is off", async () => {
runExec.mockRejectedValue(new Error("Docker not installed"));
const config: OpenClawConfig = {
agents: {
defaults: {
sandbox: {
mode: "off",
},
},
},
};
const { maybeRepairSandboxImages } = await import("./doctor-sandbox.js");
await maybeRepairSandboxImages(config, mockRuntime, mockPrompter);
await runSandboxRepair({ mode: "off", dockerAvailable: false });
// No warning needed when sandbox is off
expect(note).not.toHaveBeenCalled();
});
it("does not warn when Docker is available", async () => {
// Simulate Docker available
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);
await runSandboxRepair({ mode: "non-main", dockerAvailable: true });
// May have other notes about images, but not the Docker unavailable warning
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", () => ({
default: {
access: vi.fn(async () => {
@@ -269,39 +336,20 @@ describe("setupChannels", () => {
});
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 restore = patchTelegramAdapter({
const { cfg, selection, onAccountId } = await runQuickstartTelegramSetupWithInteractive({
configureInteractive,
});
const { prompter } = createUnexpectedQuickstartPrompter(
select as unknown as WizardPrompter["select"],
expect(configureInteractive).toHaveBeenCalledWith(
expect.objectContaining({ configured: false, label: expect.any(String) }),
);
try {
const cfg = await runSetupChannels({} as OpenClawConfig, prompter, {
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();
}
expect(selection).toHaveBeenCalledWith([]);
expect(onAccountId).not.toHaveBeenCalled();
expect(cfg.channels?.telegram?.botToken).toBeUndefined();
});
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 }) => ({
cfg: {
...cfg,
@@ -312,38 +360,22 @@ describe("setupChannels", () => {
} as OpenClawConfig,
accountId: "acct-1",
}));
const configure = vi.fn(async () => {
throw new Error("configure should not be called when configureInteractive is present");
});
const restore = patchTelegramAdapter({
const configure = createUnexpectedConfigureCall(
"configure should not be called when configureInteractive is present",
);
const { cfg, selection, onAccountId } = await runQuickstartTelegramSetupWithInteractive({
configureInteractive,
configure,
});
const { prompter } = createUnexpectedQuickstartPrompter(
select as unknown as WizardPrompter["select"],
);
try {
const cfg = await runSetupChannels({} as OpenClawConfig, prompter, {
quickstartDefaults: true,
onSelection: selection,
onAccountId,
});
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();
}
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");
});
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 }) => ({
cfg: {
...cfg,
@@ -354,74 +386,37 @@ describe("setupChannels", () => {
} as OpenClawConfig,
accountId: "acct-2",
}));
const configure = vi.fn(async () => {
throw new Error(
"configure should not be called when configureWhenConfigured handles updates",
);
});
const restore = patchTelegramAdapter({
configureInteractive: undefined,
const { cfg, selection, onAccountId, configure } = await runConfiguredTelegramSetup({
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) }),
);
try {
const cfg = await runSetupChannels(createTelegramCfg("old-token"), prompter, {
quickstartDefaults: true,
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();
}
expect(configure).not.toHaveBeenCalled();
expect(selection).toHaveBeenCalledWith(["telegram"]);
expect(onAccountId).toHaveBeenCalledWith("telegram", "acct-2");
expect(cfg.channels?.telegram?.botToken).toBe("updated-token");
});
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 configure = vi.fn(async () => {
throw new Error("configure should not run when configureWhenConfigured handles skip");
});
const restore = patchTelegramAdapter({
configureInteractive: undefined,
const { cfg, selection, onAccountId, configure } = await runConfiguredTelegramSetup({
strictUnexpected: true,
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) }),
);
try {
const cfg = await runSetupChannels(createTelegramCfg("old-token"), prompter, {
quickstartDefaults: true,
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();
}
expect(configure).not.toHaveBeenCalled();
expect(selection).toHaveBeenCalledWith([]);
expect(onAccountId).not.toHaveBeenCalled();
expect(cfg.channels?.telegram?.botToken).toBe("old-token");
});
it("prefers configureInteractive over configureWhenConfigured when both hooks exist", async () => {

View File

@@ -42,6 +42,21 @@ function createSelectPrompter(
describe("promptRemoteGatewayConfig", () => {
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(() => {
vi.clearAllMocks();
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) => {
if (params.message === "Gateway WebSocket URL") {
expect(params.initialValue).toBe("wss://gateway.tailnet.ts.net:18789");
@@ -79,15 +88,16 @@ describe("promptRemoteGatewayConfig", () => {
return "";
}) as WizardPrompter["text"];
const cfg = {} as OpenClawConfig;
const prompter = createPrompter({
confirm: vi.fn(async () => true),
select,
const { next, prompter } = await runRemotePrompt({
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?.remote?.url).toBe("wss://gateway.tailnet.ts.net:18789");
expect(next.gateway?.remote?.token).toBe("token-123");
@@ -111,17 +121,12 @@ describe("promptRemoteGatewayConfig", () => {
return "";
}) as WizardPrompter["text"];
const select = createSelectPrompter({ "Gateway auth": "off" });
const cfg = {} as OpenClawConfig;
const prompter = createPrompter({
confirm: vi.fn(async () => false),
select,
const { next } = await runRemotePrompt({
text,
confirm: false,
selectResponses: { "Gateway auth": "off" },
});
const next = await promptRemoteGatewayConfig(cfg, prompter);
expect(next.gateway?.mode).toBe("remote");
expect(next.gateway?.remote?.url).toBe("wss://remote.example.com:18789");
expect(next.gateway?.remote?.token).toBeUndefined();
@@ -138,17 +143,12 @@ describe("promptRemoteGatewayConfig", () => {
return "";
}) as WizardPrompter["text"];
const select = createSelectPrompter({ "Gateway auth": "off" });
const cfg = {} as OpenClawConfig;
const prompter = createPrompter({
confirm: vi.fn(async () => false),
select,
const { next } = await runRemotePrompt({
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");
});
});

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 {
buildChannelAccountSnapshot,
formatChannelAllowFrom,
resolveChannelAccountConfigured,
resolveChannelAccountEnabled,
} from "../../channels/account-summary.js";
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
@@ -85,30 +87,6 @@ const formatAccountLabel = (params: { accountId: string; name?: string }) => {
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: {
plugin: ChannelPlugin;
cfg: OpenClawConfig;
@@ -343,8 +321,13 @@ export async function buildChannelsTable(
const accounts: ChannelAccountRow[] = [];
for (const accountId of resolvedAccountIds) {
const account = plugin.config.resolveAccount(cfg, accountId);
const enabled = resolveAccountEnabled(plugin, account, cfg);
const configured = await resolveAccountConfigured(plugin, account, cfg);
const enabled = resolveChannelAccountEnabled({ plugin, account, cfg });
const configured = await resolveChannelAccountConfigured({
plugin,
account,
cfg,
readAccountConfiguredField: true,
});
const snapshot = buildChannelAccountSnapshot({
plugin,
cfg,

View File

@@ -1,6 +1,7 @@
import type { ProgressReporter } from "../../cli/progress.js";
import { renderTable } from "../../terminal/table.js";
import { isRich, theme } from "../../terminal/theme.js";
import { groupChannelIssuesByChannel } from "./channel-issues.js";
import { appendStatusAllDiagnosis } from "./diagnosis.js";
import { formatTimeAgo } from "./format.js";
@@ -81,19 +82,7 @@ export async function buildStatusAllReportLines(params: {
: theme.accentDim("SETUP"),
Detail: row.detail,
}));
const channelIssuesByChannel = (() => {
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 channelIssuesByChannel = groupChannelIssuesByChannel(params.channelIssues);
const channelRowsWithIssues = channelRows.map((row) => {
const issues = channelIssuesByChannel.get(row.channelId) ?? [];
if (issues.length === 0) {

View File

@@ -21,6 +21,7 @@ import { theme } from "../terminal/theme.js";
import { formatHealthChannelLines, type HealthSummary } from "./health.js";
import { resolveControlUiLinks } from "./onboard-helpers.js";
import { statusAllCommand } from "./status-all.js";
import { groupChannelIssuesByChannel } from "./status-all/channel-issues.js";
import { formatGatewayAuthUsed } from "./status-all/format.js";
import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js";
import {
@@ -500,19 +501,7 @@ export async function statusCommand(
runtime.log("");
runtime.log(theme.heading("Channels"));
const channelIssuesByChannel = (() => {
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;
})();
const channelIssuesByChannel = groupChannelIssuesByChannel(channelIssues);
runtime.log(
renderTable({
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 {
getConfigValueAtPath,
@@ -8,7 +6,7 @@ import {
unsetConfigValueAtPath,
} from "./config-paths.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";
describe("$schema key in config (#14998)", () => {
@@ -304,16 +302,10 @@ describe("config strict validation", () => {
it("flags legacy config entries without auto-migrating", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify({
agents: { list: [{ id: "pi" }] },
routing: { allowFrom: ["+15555550123"] },
}),
"utf-8",
);
await writeOpenClawConfig(home, {
agents: { list: [{ id: "pi" }] },
routing: { allowFrom: ["+15555550123"] },
});
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 () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify({
gateway: { bind: "${OPENCLAW_BIND}" },
}),
"utf-8",
);
await writeOpenClawConfig(home, {
gateway: { bind: "${OPENCLAW_BIND}" },
});
const prev = process.env.OPENCLAW_BIND;
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 () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify({
gateway: { bind: "0.0.0.0" },
}),
"utf-8",
);
await writeOpenClawConfig(home, {
gateway: { bind: "0.0.0.0" },
});
const snap = await readConfigFileSnapshot();
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 {
DEFAULT_AGENT_MAX_CONCURRENT,
@@ -8,7 +6,7 @@ import {
resolveSubagentMaxConcurrent,
} from "./agent-limits.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";
describe("agent concurrency defaults", () => {
@@ -48,13 +46,7 @@ describe("agent concurrency defaults", () => {
it("injects defaults on load", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify({}, null, 2),
"utf-8",
);
await writeOpenClawConfig(home, {});
const cfg = loadConfig();

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, it, expect } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import {
createConfigIO,
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> {
await withEnvOverrides(
await withEnvAsync(
{
OPENCLAW_CONFIG_PATH: configPath,
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 { migrateLegacyConfig } from "./legacy-migrate.js";
import { WHISPER_BASE_AUDIO_MODEL } from "./legacy-migrate.test-helpers.js";
describe("legacy migrate audio transcription", () => {
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.config?.tools?.media?.audio).toEqual({
enabled: true,
models: [
{
command: "whisper",
type: "cli",
args: ["--model", "base"],
timeoutSeconds: 2,
},
],
});
expect(res.config?.tools?.media?.audio).toEqual(WHISPER_BASE_AUDIO_MODEL);
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> {
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
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 () => {
mockLoadConfig.mockReturnValue({
session: {
maintenance: {
mode: "enforce",
pruneAfter: "365d",
maxEntries: 1,
rotateBytes: 10_485_760,
},
},
});
applyCappedMaintenanceConfig(mockLoadConfig);
const now = Date.now();
const oldestSessionId = "oldest-session";
@@ -251,16 +255,7 @@ describe("Integration: saveSessionStore with pruning", () => {
});
it("does not archive external transcript paths when capping entries", async () => {
mockLoadConfig.mockReturnValue({
session: {
maintenance: {
mode: "enforce",
pruneAfter: "365d",
maxEntries: 1,
rotateBytes: 10_485_760,
},
},
});
applyCappedMaintenanceConfig(mockLoadConfig);
const now = Date.now();
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 path from "node:path";
import { describe, expect, it } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import { createConfigIO } from "./io.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", () => {
it("maps legacy ElevenLabs fields into provider/providers", () => {
const normalized = normalizeTalkSection({
@@ -104,7 +78,7 @@ describe("talk normalization", () => {
});
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(
{
talk: {
@@ -124,7 +98,7 @@ describe("talk normalization", () => {
});
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(
{
talk: {

View File

@@ -3,8 +3,14 @@ import fs from "node:fs/promises";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { createCliDeps } from "./isolated-agent.delivery.test-helpers.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";
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 () => {
await withTempCronHome(async (home) => {
// 1. Write session store
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",
);
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
// 2. Write auth-profiles.json in the agent directory
// resolveAgentDir returns <stateDir>/agents/main/agent
@@ -79,14 +66,7 @@ describe("runCronIsolatedAgentTurn auth profile propagation (#20624)", () => {
const res = await runCronIsolatedAgentTurn({
cfg,
deps: {
sendMessageSlack: vi.fn(),
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
},
deps: createCliDeps(),
job: makeJob({ kind: "agentTurn", message: "check status", deliver: false }),
message: "check status",
sessionKey: "cron:job-1",

View File

@@ -1,8 +1,6 @@
import "./isolated-agent.mocks.js";
import fs from "node:fs/promises";
import os from "node:os";
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 { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.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 { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js";
let tempRoot = "";
let tempHomeId = 0;
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
if (!tempRoot) {
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");
}
return withTempHomeBase(fn, { prefix: "openclaw-cron-heartbeat-suite-" });
}
async function createTelegramDeliveryFixture(home: string): Promise<{
@@ -120,17 +70,6 @@ async function runTelegramAnnounceTurn(params: {
}
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(() => {
setupIsolatedAgentTurnMocks({ fast: true });
});

View File

@@ -1,4 +1,8 @@
import { vi } from "vitest";
import {
makeIsolatedAgentJobFixture,
makeIsolatedAgentParamsFixture,
} from "./isolated-agent/job-fixtures.js";
vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
@@ -22,28 +26,5 @@ vi.mock("../agents/subagent-announce.js", () => ({
runSubagentAnnounceFlow: vi.fn(),
}));
type LooseRecord = Record<string, unknown>;
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,
};
}
export const makeIsolatedAgentJob = makeIsolatedAgentJobFixture;
export const makeIsolatedAgentParams = makeIsolatedAgentParamsFixture;

View File

@@ -1,8 +1,7 @@
import "./isolated-agent.mocks.js";
import fs from "node:fs/promises";
import os from "node:os";
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 { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
import type { CliDeps } from "../cli/deps.js";
import {
@@ -14,56 +13,8 @@ import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
import { makeCfg, makeJob, writeSessionStore } from "./isolated-agent.test-harness.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> {
if (!tempRoot) {
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");
}
return withTempHomeBase(fn, { prefix: "openclaw-cron-delivery-suite-" });
}
async function runExplicitTelegramAnnounceTurn(params: {
@@ -216,17 +167,6 @@ async function assertExplicitTelegramTargetAnnounce(params: {
}
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(() => {
setupIsolatedAgentTurnMocks();
});

View File

@@ -1,8 +1,8 @@
import "./isolated-agent.mocks.js";
import fs from "node:fs/promises";
import os from "node:os";
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 { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import type { CliDeps } from "../cli/deps.js";
@@ -15,56 +15,8 @@ import {
} from "./isolated-agent.test-harness.js";
import type { CronJob } from "./types.js";
let tempRoot = "";
let tempHomeId = 0;
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
if (!tempRoot) {
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");
}
return withTempHomeBase(fn, { prefix: "openclaw-cron-turn-suite-" });
}
function makeDeps(): CliDeps {
@@ -201,17 +153,6 @@ async function runTurnWithStoredModelOverride(
}
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(() => {
vi.mocked(runEmbeddedPiAgent).mockClear();
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 {
clearFastTestEnv,
loadRunCronIsolatedAgentTurn,
makeCronSession,
resolveAgentModelFallbacksOverrideMock,
resolveCronSessionMock,
resetRunCronIsolatedAgentTurnHarness,
restoreFastTestEnv,
runWithModelFallbackMock,
} from "./run.test-harness.js";
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 ----------
describe("runCronIsolatedAgentTurn — payload.fallbacks", () => {
let previousFastTestEnv: string | undefined;
beforeEach(() => {
previousFastTestEnv = clearFastTestEnv();
resetRunCronIsolatedAgentTurnHarness();
resolveCronSessionMock.mockReturnValue(makeCronSession());
});
afterEach(() => {
restoreFastTestEnv(previousFastTestEnv);
});
setupRunCronIsolatedAgentTurnSuite();
it.each([
{
@@ -77,8 +45,8 @@ describe("runCronIsolatedAgentTurn — payload.fallbacks", () => {
}
const result = await runCronIsolatedAgentTurn(
makePayloadParams({
job: makePayloadJob({ payload }),
makeIsolatedAgentTurnParams({
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 {
buildWorkspaceSkillSnapshotMock,
clearFastTestEnv,
getCliSessionIdMock,
isCliProviderMock,
loadRunCronIsolatedAgentTurn,
logWarnMock,
makeCronSession,
resolveAgentConfigMock,
resolveAgentSkillsFilterMock,
resolveAllowedModelRefMock,
resolveCronSessionMock,
resetRunCronIsolatedAgentTurnHarness,
restoreFastTestEnv,
runCliAgentMock,
runWithModelFallbackMock,
} from "./run.test-harness.js";
const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
function makeSkillJob(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 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,
};
}
const makeSkillJob = makeIsolatedAgentTurnJob;
const makeSkillParams = makeIsolatedAgentTurnParams;
// ---------- tests ----------
describe("runCronIsolatedAgentTurn — skill filter", () => {
let previousFastTestEnv: string | undefined;
beforeEach(() => {
previousFastTestEnv = clearFastTestEnv();
resetRunCronIsolatedAgentTurnHarness();
resolveCronSessionMock.mockReturnValue(makeCronSession());
});
afterEach(() => {
restoreFastTestEnv(previousFastTestEnv);
});
setupRunCronIsolatedAgentTurnSuite();
async function runSkillFilterCase(overrides?: Record<string, unknown>) {
const result = await runCronIsolatedAgentTurn(makeSkillParams(overrides));
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams(overrides));
expect(result.status).toBe("ok");
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", () => {
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(() => {
noopLogger.debug.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 pastDueMs = 17 * 60 * 1000; // 17 minutes past due
const state = createCronServiceState({
const state = createTimerState({
storePath: "/tmp/test-cron/jobs.json",
cronEnabled: true,
log: noopLogger,
nowMs: () => now,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok" }),
now,
});
state.store = {
version: 1,
@@ -72,9 +91,7 @@ describe("CronService - armTimer tight loop prevention", () => {
armTimer(state);
expect(state.timer).not.toBeNull();
const delays = timeoutSpy.mock.calls
.map(([, delay]) => delay)
.filter((d): d is number => typeof d === "number");
const delays = extractTimeoutDelays(timeoutSpy);
// Before the fix, delay would be 0 (tight loop).
// 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 now = Date.parse("2026-02-28T12:32:00.000Z");
const state = createCronServiceState({
const state = createTimerState({
storePath: "/tmp/test-cron/jobs.json",
cronEnabled: true,
log: noopLogger,
nowMs: () => now,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok" }),
now,
});
state.store = {
version: 1,
@@ -121,9 +133,7 @@ describe("CronService - armTimer tight loop prevention", () => {
armTimer(state);
const delays = timeoutSpy.mock.calls
.map(([, delay]) => delay)
.filter((d): d is number => typeof d === "number");
const delays = extractTimeoutDelays(timeoutSpy);
// The natural delay (10 s) should be used, not the floor.
expect(delays).toContain(10_000);
@@ -151,14 +161,9 @@ describe("CronService - armTimer tight loop prevention", () => {
"utf-8",
);
const state = createCronServiceState({
const state = createTimerState({
storePath: store.storePath,
cronEnabled: true,
log: noopLogger,
nowMs: () => now,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok" }),
now,
});
// 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
// MIN_REFIRE_GAP_MS to prevent the hot-loop.
const allDelays = timeoutSpy.mock.calls
.map(([, delay]) => delay)
.filter((d): d is number => typeof d === "number");
const allDelays = extractTimeoutDelays(timeoutSpy);
// The last setTimeout call is from the finally→armTimer path.
const lastDelay = allDelays[allDelays.length - 1];

View File

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

View File

@@ -1,32 +1,11 @@
import fs from "node:fs/promises";
import os from "node:os";
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 type { CronStoreFile } from "./types.js";
let fixtureRoot = "";
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"),
};
}
const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-store-" });
function makeStore(jobId: string, enabled: boolean): CronStoreFile {
const now = Date.now();
@@ -72,6 +51,7 @@ describe("cron store", () => {
it("throws when store contains invalid JSON", async () => {
const store = await makeStorePath();
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(store.storePath, "{ not json", "utf-8");
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 { CronJobBase } from "./types-shared.js";
export type CronSchedule =
| { kind: "at"; at: string }
@@ -138,23 +139,14 @@ export type CronJobState = {
lastDelivered?: boolean;
};
export type CronJob = {
id: string;
agentId?: string;
/** Origin session namespace for reminder delivery and wake routing. */
sessionKey?: string;
name: string;
description?: string;
enabled: boolean;
deleteAfterRun?: boolean;
createdAtMs: number;
updatedAtMs: number;
schedule: CronSchedule;
sessionTarget: CronSessionTarget;
wakeMode: CronWakeMode;
payload: CronPayload;
delivery?: CronDelivery;
failureAlert?: CronFailureAlert | false;
export type CronJob = CronJobBase<
CronSchedule,
CronSessionTarget,
CronWakeMode,
CronPayload,
CronDelivery,
CronFailureAlert | false
> & {
state: CronJobState;
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
import {
buildChannelAccountSnapshot,
formatChannelAllowFrom,
resolveChannelAccountConfigured,
resolveChannelAccountEnabled,
} from "../channels/account-summary.js";
import { listChannelPlugins } from "../channels/plugins/index.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[]) =>
` - ${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: {
entry: ChannelAccountEntry;
plugin: ChannelPlugin;
@@ -133,8 +109,12 @@ export async function buildChannelSummary(
for (const accountId of resolvedAccountIds) {
const account = plugin.config.resolveAccount(effective, accountId);
const enabled = resolveAccountEnabled(plugin, account, effective);
const configured = await resolveAccountConfigured(plugin, account, effective);
const enabled = resolveChannelAccountEnabled({ plugin, account, cfg: effective });
const configured = await resolveChannelAccountConfigured({
plugin,
account,
cfg: effective,
});
const snapshot = buildChannelAccountSnapshot({
plugin,
account,

View File

@@ -14,6 +14,43 @@ export function extractErrorCode(err: unknown): string | 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).
*/

View File

@@ -18,6 +18,49 @@ describe("resolveAllowAlwaysPatterns", () => {
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", () => {
const exe = path.join("/tmp", "openclaw-tool");
const patterns = resolveAllowAlwaysPatterns({
@@ -233,42 +276,14 @@ describe("resolveAllowAlwaysPatterns", () => {
const busybox = makeExecutable(dir, "busybox");
const echo = makeExecutable(dir, "echo");
makeExecutable(dir, "id");
const safeBins = resolveSafeBins(undefined);
const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` };
const first = evaluateShellAllowlist({
command: `${busybox} sh -c 'echo warmup-ok'`,
allowlist: [],
safeBins,
cwd: dir,
expectAllowAlwaysBypassBlocked({
dir,
firstCommand: `${busybox} sh -c 'echo warmup-ok'`,
secondCommand: `${busybox} sh -c 'id > marker'`,
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", () => {
@@ -278,41 +293,13 @@ describe("resolveAllowAlwaysPatterns", () => {
const dir = makeTempDir();
const echo = makeExecutable(dir, "echo");
makeExecutable(dir, "id");
const safeBins = resolveSafeBins(undefined);
const env = makePathEnv(dir);
const first = evaluateShellAllowlist({
command: "/usr/bin/nice /bin/zsh -lc 'echo warmup-ok'",
allowlist: [],
safeBins,
cwd: dir,
expectAllowAlwaysBypassBlocked({
dir,
firstCommand: "/usr/bin/nice /bin/zsh -lc 'echo warmup-ok'",
secondCommand: "/usr/bin/nice /bin/zsh -lc 'id > marker'",
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(" ") };
},
});
if (!rebuilt.ok) {
return { ok: false, reason: rebuilt.reason };
}
return { ok: true, command: rebuilt.command };
return finalizeRebuiltShellCommand(rebuilt);
}
function renderQuotedArgv(argv: string[]): string {
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 {
if (segment.resolution?.policyBlocked === true) {
return null;
@@ -688,13 +698,7 @@ export function buildSafeBinsShellCommand(params: {
return { ok: true, rendered };
},
});
if (!rebuilt.ok) {
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 };
return finalizeRebuiltShellCommand(rebuilt, params.segments.length);
}
export function buildEnforcedShellCommand(params: {
@@ -717,13 +721,7 @@ export function buildEnforcedShellCommand(params: {
return { ok: true, rendered: renderQuotedArgv(argv) };
},
});
if (!rebuilt.ok) {
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 };
return finalizeRebuiltShellCommand(rebuilt, params.segments.length);
}
/**

View File

@@ -625,6 +625,36 @@ describe("exec approvals shell allowlist (chained commands)", () => {
});
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", () => {
const analysis = {
ok: true,
@@ -696,13 +726,9 @@ describe("exec approvals allowlist evaluation", () => {
},
],
};
const result = evaluateExecAllowlist({
const result = evaluateAutoAllowSkills({
analysis,
allowlist: [],
safeBins: new Set(),
skillBins: [{ name: "skill-bin", resolvedPath: "/opt/skills/skill-bin" }],
autoAllowSkills: true,
cwd: "/tmp",
resolvedPath: "/opt/skills/skill-bin",
});
expect(result.allowlistSatisfied).toBe(true);
});
@@ -722,16 +748,11 @@ describe("exec approvals allowlist evaluation", () => {
},
],
};
const result = evaluateExecAllowlist({
const result = evaluateAutoAllowSkills({
analysis,
allowlist: [],
safeBins: new Set(),
skillBins: [{ name: "skill-bin", resolvedPath: "/tmp/skill-bin" }],
autoAllowSkills: true,
cwd: "/tmp",
resolvedPath: "/tmp/skill-bin",
});
expect(result.allowlistSatisfied).toBe(false);
expect(result.segmentSatisfiedBy).toEqual([null]);
expectAutoAllowSkillsMiss(result);
});
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,
allowlist: [],
safeBins: new Set(),
skillBins: [{ name: "skill-bin", resolvedPath: "/opt/skills/skill-bin" }],
autoAllowSkills: true,
cwd: "/tmp",
resolvedPath: "/opt/skills/skill-bin",
});
expect(result.allowlistSatisfied).toBe(false);
expect(result.segmentSatisfiedBy).toEqual([null]);
expectAutoAllowSkillsMiss(result);
});
it("returns empty segment details for chain misses", () => {

View File

@@ -1,4 +1,9 @@
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;
@@ -51,9 +56,6 @@ const SHELL_WRAPPER_CANONICAL = new Set<string>([
...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([
"-u",
"--unset",
@@ -586,30 +588,7 @@ function extractInlineCommandByFlags(
flags: ReadonlySet<string>,
options: { allowCombinedC?: boolean } = {},
): string | 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 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;
return resolveInlineCommandMatch(argv, flags, options).command;
}
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 };
}
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(() => {
runCommandWithTimeoutMock.mockClear();
});
@@ -195,53 +220,11 @@ describe("packNpmSpecToArchive", () => {
});
it("falls back to archive detected in cwd when npm pack stdout is empty", async () => {
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: " \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: {},
});
await expectPackFallsBackToDetectedArchive({ stdout: " \n\n" });
});
it("falls back to archive detected in cwd when stdout does not contain a tgz", async () => {
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: "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: {},
});
await expectPackFallsBackToDetectedArchive({ stdout: "npm pack completed successfully\n" });
});
it("returns friendly error for 404 (package not on npm)", async () => {

View File

@@ -14,6 +14,26 @@ export type NpmSpecResolution = {
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 = {
expectedIntegrity: 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", () => {
it("drops gateway url overrides in backend mode (SSRF hardening)", async () => {
setRegistry(
createTestRegistry([
{
pluginId: "mattermost",
source: "test",
plugin: {
...createMattermostLikePlugin({ onSendText: () => {} }),
outbound: { deliveryMode: "gateway" },
},
},
]),
);
setMattermostGatewayRegistry();
callGatewayMock.mockResolvedValueOnce({ messageId: "m1" });
await sendMessage({
@@ -196,18 +200,7 @@ describe("gateway url override hardening", () => {
});
it("forwards explicit agentId in gateway send params", async () => {
setRegistry(
createTestRegistry([
{
pluginId: "mattermost",
source: "test",
plugin: {
...createMattermostLikePlugin({ onSendText: () => {} }),
outbound: { deliveryMode: "gateway" },
},
},
]),
);
setMattermostGatewayRegistry();
callGatewayMock.mockResolvedValueOnce({ messageId: "m-agent" });
await sendMessage({

View File

@@ -301,43 +301,44 @@ describe("resolveSessionDeliveryTarget", () => {
expect(resolved.to).toBe("63448508");
});
it("allows heartbeat delivery to Slack DMs and avoids inherited threadId by default", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
cfg,
entry: {
sessionId: "sess-heartbeat-outbound",
updatedAt: 1,
lastChannel: "slack",
lastTo: "user:U123",
lastThreadId: "1739142736.000100",
},
const resolveHeartbeatTarget = (
entry: Parameters<typeof resolveHeartbeatDeliveryTarget>[0]["entry"],
directPolicy?: "allow" | "block",
) =>
resolveHeartbeatDeliveryTarget({
cfg: {},
entry,
heartbeat: {
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.to).toBe("user:U123");
expect(resolved.threadId).toBeUndefined();
});
it("blocks heartbeat delivery to Slack DMs when directPolicy is block", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
cfg,
entry: {
const resolved = resolveHeartbeatTarget(
{
sessionId: "sess-heartbeat-outbound",
updatedAt: 1,
lastChannel: "slack",
lastTo: "user:U123",
lastThreadId: "1739142736.000100",
},
heartbeat: {
target: "last",
directPolicy: "block",
},
});
"block",
);
expect(resolved.channel).toBe("none");
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", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
cfg,
entry: {
sessionId: "sess-heartbeat-imessage-direct",
updatedAt: 1,
lastChannel: "imessage",
lastTo: "chat-guid-unknown-shape",
chatType: "direct",
},
heartbeat: {
target: "last",
},
const resolved = resolveHeartbeatTarget({
sessionId: "sess-heartbeat-imessage-direct",
updatedAt: 1,
lastChannel: "imessage",
lastTo: "chat-guid-unknown-shape",
chatType: "direct",
});
expect(resolved.channel).toBe("imessage");
@@ -480,21 +474,16 @@ describe("resolveSessionDeliveryTarget", () => {
});
it("blocks session chatType direct hints when directPolicy is block", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
cfg,
entry: {
const resolved = resolveHeartbeatTarget(
{
sessionId: "sess-heartbeat-imessage-direct",
updatedAt: 1,
lastChannel: "imessage",
lastTo: "chat-guid-unknown-shape",
chatType: "direct",
},
heartbeat: {
target: "last",
directPolicy: "block",
},
});
"block",
);
expect(resolved.channel).toBe("none");
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,
unwrapKnownShellMultiplexerInvocation,
} from "./exec-wrapper-resolution.js";
import {
POSIX_INLINE_COMMAND_FLAGS,
POWERSHELL_INLINE_COMMAND_FLAGS,
resolveInlineCommandMatch,
} from "./shell-inline-command.js";
export type SystemRunCommandValidation =
| {
@@ -63,41 +68,12 @@ const POSIX_OR_POWERSHELL_INLINE_WRAPPER_NAMES = new Set([
"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[] {
const dispatchUnwrapped = unwrapDispatchWrappersForResolution(argv);
const shellMultiplexer = unwrapKnownShellMultiplexerInvocation(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 {
const wrapperArgv = unwrapShellWrapperArgv(argv);
const token0 = wrapperArgv[0]?.trim();
@@ -112,10 +88,10 @@ function hasTrailingPositionalArgvAfterInlineCommand(argv: string[]): boolean {
const inlineCommandIndex =
wrapper === "powershell" || wrapper === "pwsh"
? resolveInlineCommandTokenIndex(wrapperArgv, POWERSHELL_INLINE_COMMAND_FLAGS)
: resolveInlineCommandTokenIndex(wrapperArgv, POSIX_INLINE_COMMAND_FLAGS, {
? resolveInlineCommandMatch(wrapperArgv, POWERSHELL_INLINE_COMMAND_FLAGS).valueTokenIndex
: resolveInlineCommandMatch(wrapperArgv, POSIX_INLINE_COMMAND_FLAGS, {
allowCombinedC: true,
});
}).valueTokenIndex;
if (inlineCommandIndex === null) {
return false;
}

View File

@@ -1,5 +1,10 @@
import process from "node:process";
import { extractErrorCode, formatUncaughtError } from "./errors.js";
import {
collectErrorGraphCandidates,
extractErrorCode,
formatUncaughtError,
readErrorName,
} from "./errors.js";
type UnhandledRejectionHandler = (reason: unknown) => boolean;
@@ -62,14 +67,6 @@ function getErrorCause(err: unknown): unknown {
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 {
const code = extractErrorCode(err);
if (code) {
@@ -96,44 +93,6 @@ function extractErrorCodeWithCause(err: unknown): string | undefined {
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.
* 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) {
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);
if (code && TRANSIENT_NETWORK_CODES.has(code)) {
return true;
}
const name = getErrorName(candidate);
const name = readErrorName(candidate);
if (name && TRANSIENT_NETWORK_ERROR_NAMES.has(name)) {
return true;
}

View File

@@ -8,6 +8,7 @@ import {
} from "./control-ui-assets.js";
import { detectPackageManager as detectPackageManagerImpl } from "./detect-package-manager.js";
import { readPackageName, readPackageVersion } from "./package-json.js";
import { normalizePackageTagInput } from "./package-tag.js";
import { trimLogTail } from "./restart-sentinel.js";
import {
channelToNpmTag,
@@ -312,17 +313,7 @@ function managerInstallArgs(manager: "pnpm" | "bun" | "npm") {
}
function normalizeTag(tag?: string) {
const trimmed = tag?.trim();
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;
return normalizePackageTagInput(tag, ["openclaw", DEFAULT_PACKAGE_NAME]) ?? "latest";
}
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([
{
name: "stable channel",
@@ -310,19 +336,8 @@ describe("update-startup", () => {
mockPackageUpdateStatus("beta", "2.0.0-beta.1");
const runAutoUpdate = createAutoUpdateSuccessMock();
await runGatewayUpdateCheck({
cfg: {
update: {
channel: "beta",
auto: {
enabled: true,
betaCheckIntervalHours: 1,
},
},
},
log: { info: vi.fn() },
isNixMode: false,
allowInTests: true,
await runAutoUpdateCheckWithDefaults({
cfg: createBetaAutoUpdateConfig(),
runAutoUpdate,
});
@@ -338,20 +353,8 @@ describe("update-startup", () => {
mockPackageUpdateStatus("beta", "2.0.0-beta.1");
const runAutoUpdate = createAutoUpdateSuccessMock();
await runGatewayUpdateCheck({
cfg: {
update: {
checkOnStart: false,
channel: "beta",
auto: {
enabled: true,
betaCheckIntervalHours: 1,
},
},
},
log: { info: vi.fn() },
isNixMode: false,
allowInTests: true,
await runAutoUpdateCheckWithDefaults({
cfg: createBetaAutoUpdateConfig({ checkOnStart: false }),
runAutoUpdate,
});
@@ -381,19 +384,8 @@ describe("update-startup", () => {
const originalArgv = process.argv.slice();
process.argv = [process.execPath, "/opt/openclaw/dist/entry.js"];
try {
await runGatewayUpdateCheck({
cfg: {
update: {
channel: "beta",
auto: {
enabled: true,
betaCheckIntervalHours: 1,
},
},
},
log: { info: vi.fn() },
isNixMode: false,
allowInTests: true,
await runAutoUpdateCheckWithDefaults({
cfg: createBetaAutoUpdateConfig(),
});
} finally {
process.argv = originalArgv;

View File

@@ -50,11 +50,9 @@ describe("normalizePluginsConfig", () => {
});
describe("resolveEffectiveEnableState", () => {
it("enables bundled channels when channels.<id>.enabled=true", () => {
const normalized = normalizePluginsConfig({
enabled: true,
});
const state = resolveEffectiveEnableState({
function resolveBundledTelegramState(config: Parameters<typeof normalizePluginsConfig>[0]) {
const normalized = normalizePluginsConfig(config);
return resolveEffectiveEnableState({
id: "telegram",
origin: "bundled",
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 });
});
it("keeps explicit plugin-level disable authoritative", () => {
const normalized = normalizePluginsConfig({
const state = resolveBundledTelegramState({
enabled: true,
entries: {
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" });
});
});

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import type { OpenClawConfig } from "../config/config.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 };
@@ -10,14 +10,7 @@ export function buildNpmResolutionInstallFields(
PluginInstallRecord,
"resolvedName" | "resolvedVersion" | "resolvedSpec" | "integrity" | "shasum" | "resolvedAt"
> {
return {
resolvedName: resolution?.name,
resolvedVersion: resolution?.version,
resolvedSpec: resolution?.resolvedSpec,
integrity: resolution?.integrity,
shasum: resolution?.shasum,
resolvedAt: resolution?.resolvedAt,
};
return buildNpmResolutionFields(resolution);
}
export function recordPluginInstall(

View File

@@ -570,8 +570,7 @@ export type PluginHookSubagentContext = {
export type PluginHookSubagentTargetKind = "subagent" | "acp";
// subagent_spawning hook
export type PluginHookSubagentSpawningEvent = {
type PluginHookSubagentSpawnBase = {
childSessionKey: string;
agentId: string;
label?: string;
@@ -585,6 +584,9 @@ export type PluginHookSubagentSpawningEvent = {
threadRequested: boolean;
};
// subagent_spawning hook
export type PluginHookSubagentSpawningEvent = PluginHookSubagentSpawnBase;
export type PluginHookSubagentSpawningResult =
| {
status: "ok";
@@ -620,19 +622,8 @@ export type PluginHookSubagentDeliveryTargetResult = {
};
// subagent_spawned hook
export type PluginHookSubagentSpawnedEvent = {
export type PluginHookSubagentSpawnedEvent = PluginHookSubagentSpawnBase & {
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

View File

@@ -41,6 +41,28 @@ function createMockChild(params?: { code?: number; signal?: NodeJS.Signals | nul
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", () => {
afterEach(() => {
spawnMock.mockReset();
@@ -59,16 +81,8 @@ describe("windows command wrapper behavior", () => {
try {
const result = await runCommandWithTimeout(["pnpm", "--version"], { timeoutMs: 1000 });
expect(result.code).toBe(0);
const captured = spawnMock.mock.calls[0] as
| [string, string[], Record<string, unknown>]
| 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);
const captured = spawnMock.mock.calls[0] as SpawnCall | undefined;
expectCmdWrappedInvocation({ captured, expectedComSpec });
} finally {
platformSpy.mockRestore();
}
@@ -91,21 +105,8 @@ describe("windows command wrapper behavior", () => {
try {
await runExec("pnpm", ["--version"], 1000);
const captured = execFileMock.mock.calls[0] as
| [
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);
const captured = execFileMock.mock.calls[0] as ExecCall | undefined;
expectCmdWrappedInvocation({ captured, expectedComSpec });
} finally {
platformSpy.mockRestore();
}

View File

@@ -210,6 +210,31 @@ function assertNoCancel<T>(value: T | symbol, message: string): T {
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: {
message: string;
initialValue?: number;
@@ -275,23 +300,10 @@ async function promptProviderSource(initial?: SecretRefSource): Promise<SecretRe
async function promptEnvProvider(
base?: Extract<SecretProviderConfig, { source: "env" }>,
): Promise<Extract<SecretProviderConfig, { source: "env" }>> {
const allowlistRaw = assertNoCancel(
await text({
message: "Env allowlist (comma-separated, blank for unrestricted)",
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 ?? ""));
const allowlist = await promptEnvNameCsv({
message: "Env allowlist (comma-separated, blank for unrestricted)",
initialValue: base?.allowlist?.join(",") ?? "",
});
return {
source: "env",
...(allowlist.length > 0 ? { allowlist } : {}),
@@ -436,22 +448,10 @@ async function promptExecProvider(
"Secrets configure cancelled.",
);
const passEnvRaw = assertNoCancel(
await text({
message: "Pass-through env vars (comma-separated, blank for none)",
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 passEnv = await promptEnvNameCsv({
message: "Pass-through env vars (comma-separated, blank for none)",
initialValue: base?.passEnv?.join(",") ?? "",
});
const trustedDirsRaw = assertNoCancel(
await text({
@@ -486,7 +486,6 @@ async function promptExecProvider(
);
const args = await parseArgsInput(String(argsRaw ?? ""));
const passEnv = parseCsv(String(passEnvRaw ?? ""));
const trustedDirs = parseCsv(String(trustedDirsRaw ?? ""));
return {

View File

@@ -27,6 +27,46 @@ describe("secret ref resolver", () => {
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 () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-"));
const sharedExecDir = path.join(fixtureRoot, "shared-exec");
@@ -134,22 +174,7 @@ describe("secret ref resolver", () => {
return;
}
const value = await resolveSecretRefString(
{ source: "exec", provider: "execmain", id: "openai/api-key" },
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: execProtocolV1ScriptPath,
passEnv: ["PATH"],
},
},
},
},
},
);
const value = await resolveExecSecret(execProtocolV1ScriptPath);
expect(value).toBe("value:openai/api-key");
});
@@ -158,23 +183,7 @@ describe("secret ref resolver", () => {
return;
}
const value = await resolveSecretRefString(
{ source: "exec", provider: "execmain", id: "openai/api-key" },
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: execPlainScriptPath,
passEnv: ["PATH"],
jsonOnly: false,
},
},
},
},
},
);
const value = await resolveExecSecret(execPlainScriptPath, { jsonOnly: false });
expect(value).toBe("plain-secret");
});
@@ -210,25 +219,9 @@ describe("secret ref resolver", () => {
const symlinkPath = path.join(root, "resolver-link.mjs");
await fs.symlink(execPlainScriptPath, symlinkPath);
await expect(
resolveSecretRefString(
{ 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");
await expect(resolveExecSecret(symlinkPath, { jsonOnly: false })).rejects.toThrow(
"must not be a symlink",
);
});
it("allows symlink command paths when allowSymlinkCommand is enabled", async () => {
@@ -240,25 +233,11 @@ describe("secret ref resolver", () => {
await fs.symlink(execPlainScriptPath, symlinkPath);
const trustedRoot = await fs.realpath(fixtureRoot);
const value = await resolveSecretRefString(
{ source: "exec", provider: "execmain", id: "openai/api-key" },
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: symlinkPath,
passEnv: ["PATH"],
jsonOnly: false,
allowSymlinkCommand: true,
trustedDirs: [trustedRoot],
},
},
},
},
},
);
const value = await resolveExecSecret(symlinkPath, {
jsonOnly: false,
allowSymlinkCommand: true,
trustedDirs: [trustedRoot],
});
expect(value).toBe("plain-secret");
});
@@ -287,44 +266,15 @@ describe("secret ref resolver", () => {
await fs.symlink(targetCommand, symlinkCommand);
const trustedRoot = await fs.realpath(root);
await expect(
resolveSecretRefString(
{ 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],
},
},
},
},
},
await expect(resolveExecSecret(symlinkCommand, { args: ["brew"] })).rejects.toThrow(
"must not be a symlink",
);
const value = await resolveExecSecret(symlinkCommand, {
args: ["brew"],
allowSymlinkCommand: true,
trustedDirs: [trustedRoot],
});
expect(value).toBe("brew:openai/api-key");
});
@@ -337,25 +287,11 @@ describe("secret ref resolver", () => {
await fs.symlink(execPlainScriptPath, symlinkPath);
await expect(
resolveSecretRefString(
{ source: "exec", provider: "execmain", id: "openai/api-key" },
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: symlinkPath,
passEnv: ["PATH"],
jsonOnly: false,
allowSymlinkCommand: true,
trustedDirs: [root],
},
},
},
},
},
),
resolveExecSecret(symlinkPath, {
jsonOnly: false,
allowSymlinkCommand: true,
trustedDirs: [root],
}),
).rejects.toThrow("outside trustedDirs");
});
@@ -363,73 +299,27 @@ describe("secret ref resolver", () => {
if (process.platform === "win32") {
return;
}
await expect(
resolveSecretRefString(
{ source: "exec", provider: "execmain", id: "openai/api-key" },
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: execProtocolV2ScriptPath,
passEnv: ["PATH"],
},
},
},
},
},
),
).rejects.toThrow("protocolVersion must be 1");
await expect(resolveExecSecret(execProtocolV2ScriptPath)).rejects.toThrow(
"protocolVersion must be 1",
);
});
it("rejects exec refs when response omits requested id", async () => {
if (process.platform === "win32") {
return;
}
await expect(
resolveSecretRefString(
{ 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"');
await expect(resolveExecSecret(execMissingIdScriptPath)).rejects.toThrow(
'response missing id "openai/api-key"',
);
});
it("rejects exec refs with invalid JSON when jsonOnly is true", async () => {
if (process.platform === "win32") {
return;
}
await expect(
resolveSecretRefString(
{ 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");
await expect(resolveExecSecret(execInvalidJsonScriptPath, { jsonOnly: true })).rejects.toThrow(
"returned invalid JSON",
);
});
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 { 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", () => {
it("returns true for the current running process", () => {
expect(isPidAlive(process.pid)).toBe(true);
@@ -22,68 +51,29 @@ describe("isPidAlive", () => {
it("returns false for zombie processes on Linux", async () => {
const zombiePid = process.pid;
// Mock readFileSync to return zombie state for /proc/<pid>/status
const originalReadFileSync = fsSync.readFileSync;
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;
mockProcReads({
[`/proc/${zombiePid}/status`]: `Name:\tnode\nUmask:\t0022\nState:\tZ (zombie)\nTgid:\t${zombiePid}\nPid:\t${zombiePid}\n`,
});
// 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();
await withLinuxProcessPlatform(async () => {
const { isPidAlive: freshIsPidAlive } = await import("./pid-alive.js");
expect(freshIsPidAlive(zombiePid)).toBe(false);
} finally {
Object.defineProperty(process, "platform", originalPlatformDescriptor);
vi.restoreAllMocks();
}
});
});
});
describe("getProcessStartTime", () => {
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
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) => {
if (filePath === `/proc/${process.pid}/stat`) {
return fakeStat;
}
return originalReadFileSync(filePath as never, encoding as never) as never;
mockProcReads({
[`/proc/${process.pid}/stat`]: fakeStat,
});
Object.defineProperty(process, "platform", {
...originalPlatformDescriptor,
value: "linux",
});
try {
vi.resetModules();
await withLinuxProcessPlatform(async () => {
const { getProcessStartTime: fresh } = await import("./pid-alive.js");
const starttime = fresh(process.pid);
expect(starttime).toBe(98765);
} finally {
Object.defineProperty(process, "platform", originalPlatformDescriptor);
vi.restoreAllMocks();
}
});
});
it("returns null on non-Linux platforms", () => {
@@ -104,62 +94,24 @@ describe("getProcessStartTime", () => {
});
it("returns null for malformed /proc stat content", async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
if (!originalPlatformDescriptor) {
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;
mockProcReads({
"/proc/42/stat": "42 node S malformed",
});
Object.defineProperty(process, "platform", {
...originalPlatformDescriptor,
value: "linux",
});
try {
vi.resetModules();
await withLinuxProcessPlatform(async () => {
const { getProcessStartTime: fresh } = await import("./pid-alive.js");
expect(fresh(42)).toBeNull();
} finally {
Object.defineProperty(process, "platform", originalPlatformDescriptor);
vi.restoreAllMocks();
}
});
});
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))"
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) => {
if (filePath === "/proc/42/stat") {
return fakeStat;
}
return originalReadFileSync(filePath as never, encoding as never) as never;
mockProcReads({
"/proc/42/stat": fakeStat,
});
Object.defineProperty(process, "platform", {
...originalPlatformDescriptor,
value: "linux",
});
try {
vi.resetModules();
await withLinuxProcessPlatform(async () => {
const { getProcessStartTime: fresh } = await import("./pid-alive.js");
expect(fresh(42)).toBe(55555);
} finally {
Object.defineProperty(process, "platform", originalPlatformDescriptor);
vi.restoreAllMocks();
}
});
});
});