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

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