Files
openclaw/src/cli/config-set-input.ts
T
Vincent Koc fd1662f49c fix(cli): retire invalid secret flags and prove doctor recovery (#118926)
* test(qa): add doctor CLI recovery coverage

* test(qa): secure doctor exec SecretRef proof

* test(qa): gate doctor systemd recovery proof

* test(qa): normalize doctor terminal output

* test(qa): close doctor probe sockets

* test(qa): classify doctor probe as foreign

* test(qa): track doctor probe sockets

* test(qa): retain doctor instance narrowing

* test(qa): preserve observed doctor recovery proof

* test(qa): keep doctor recovery on stable dist

* test(qa): honor Windows exec ACL blocking

* test(qa): use canonical home for systemd recovery

* test(qa): follow bounded gateway recovery

* test(qa): accept lifecycle service label

* test(qa): align doctor recovery contract

Punchcard-Session: crisp-lantern-orchard-nv

* docs(secrets): remove retired provider bypasses

Punchcard-Session: crisp-lantern-orchard-nv

* test(qa): isolate doctor recovery target

Punchcard-Session: crisp-lantern-orchard-nv

* fix(cli): retire invalid secret provider flags

Punchcard-Session: crisp-lantern-orchard-nv

* test(qa): isolate doctor supervisor mode

Punchcard-Session: crisp-lantern-orchard-nv

* fix(plugins): remove dead secret path bypass

Punchcard-Session: crisp-lantern-orchard-nv

* chore: drop release-owned changelog entry

Punchcard-Session: crisp-lantern-orchard-nv

* test(qa): isolate doctor sudo scope

Punchcard-Session: crisp-lantern-orchard-nv

* fix(secrets): remove dead path bypass

Punchcard-Session: crisp-lantern-orchard-nv

* test(qa): isolate systemd user bus

Punchcard-Session: crisp-lantern-orchard-nv
2026-08-05 11:20:23 +08:00

177 lines
5.5 KiB
TypeScript

// Input-mode parsing helpers for `openclaw config set` values, refs, providers, and batches.
import fs from "node:fs";
import {
normalizeOptionalString,
normalizeStringifiedOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import JSON5 from "json5";
import { readFileDescriptorBoundedSync } from "../infra/boundary-file-read.js";
import { hasErrnoCode } from "../infra/errors.js";
export type ConfigSetOptions = {
strictJson?: boolean;
/** @deprecated Use strictJson. */
json?: boolean;
dryRun?: boolean;
allowExec?: boolean;
merge?: boolean;
replace?: boolean;
refProvider?: string;
refSource?: string;
refId?: string;
providerSource?: string;
providerAllowlist?: string[];
providerPath?: string;
providerMode?: string;
providerTimeoutMs?: string;
providerMaxBytes?: string;
providerCommand?: string;
providerArg?: string[];
providerNoOutputTimeoutMs?: string;
providerMaxOutputBytes?: string;
providerJsonOnly?: boolean;
providerEnv?: string[];
providerPassEnv?: string[];
providerTrustedDir?: string[];
batchJson?: string;
batchFile?: string;
};
export type ConfigSetBatchEntry = {
path: string;
value?: unknown;
ref?: unknown;
provider?: unknown;
};
const CONFIG_MUTATION_FILE_MAX_BYTES = 8 * 1024 * 1024;
export function readConfigMutationFileSync(
filePath: string,
sourceLabel: "--batch-file" | "--file",
): string {
// These explicit CLI file flags have historically followed user-provided
// symlinks. Pin the opened descriptor, then bound the read without changing that contract.
const fd = fs.openSync(filePath, "r");
try {
try {
return readFileDescriptorBoundedSync(fd, CONFIG_MUTATION_FILE_MAX_BYTES).toString("utf8");
} catch (error) {
if (error instanceof RangeError) {
throw new RangeError(
`${sourceLabel} exceeds the 8 MiB supported maximum (${CONFIG_MUTATION_FILE_MAX_BYTES} bytes): ${filePath}`,
{ cause: error },
);
}
throw error;
}
} finally {
fs.closeSync(fd);
}
}
export function hasBatchMode(opts: ConfigSetOptions): boolean {
return Boolean(
normalizeOptionalString(opts.batchJson) || normalizeOptionalString(opts.batchFile),
);
}
export function hasRefBuilderOptions(opts: ConfigSetOptions): boolean {
return Boolean(opts.refProvider || opts.refSource || opts.refId);
}
export function hasProviderBuilderOptions(opts: ConfigSetOptions): boolean {
return Boolean(
opts.providerSource ||
opts.providerAllowlist?.length ||
opts.providerPath ||
opts.providerMode ||
opts.providerTimeoutMs ||
opts.providerMaxBytes ||
opts.providerCommand ||
opts.providerArg?.length ||
opts.providerNoOutputTimeoutMs ||
opts.providerMaxOutputBytes ||
opts.providerJsonOnly ||
opts.providerEnv?.length ||
opts.providerPassEnv?.length ||
opts.providerTrustedDir?.length,
);
}
function parseJson5Raw(raw: string, label: string): unknown {
try {
return JSON5.parse(raw);
} catch (err) {
throw new Error(`Failed to parse ${label}: ${String(err)}`, { cause: err });
}
}
function parseBatchEntries(raw: string, sourceLabel: string): ConfigSetBatchEntry[] {
const parsed = parseJson5Raw(raw, sourceLabel);
if (!Array.isArray(parsed)) {
throw new Error(`${sourceLabel} must be a JSON array.`);
}
if (parsed.length === 0) {
throw new Error(`${sourceLabel} must contain at least one config update.`);
}
const out: ConfigSetBatchEntry[] = [];
for (const [index, entry] of parsed.entries()) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
throw new Error(`${sourceLabel}[${index}] must be an object.`);
}
const typed = entry as Record<string, unknown>;
const path = normalizeOptionalString(typed.path) ?? "";
if (!path) {
throw new Error(`${sourceLabel}[${index}].path is required.`);
}
const hasValue = Object.hasOwn(typed, "value");
const hasRef = Object.hasOwn(typed, "ref");
const hasProvider = Object.hasOwn(typed, "provider");
const modeCount = Number(hasValue) + Number(hasRef) + Number(hasProvider);
if (modeCount !== 1) {
throw new Error(
`${sourceLabel}[${index}] must include exactly one of: value, ref, provider.`,
);
}
out.push({
path,
...(hasValue ? { value: typed.value } : {}),
...(hasRef ? { ref: typed.ref } : {}),
...(hasProvider ? { provider: typed.provider } : {}),
});
}
return out;
}
export function parseBatchSource(opts: ConfigSetOptions): ConfigSetBatchEntry[] | null {
// Batch mode is exclusive because each entry carries its own value/ref/provider mode.
const batchJson = normalizeOptionalString(opts.batchJson);
const batchFile = normalizeOptionalString(opts.batchFile);
const hasInline = Boolean(batchJson);
const hasFile = Boolean(batchFile);
if (!hasInline && !hasFile) {
return null;
}
if (hasInline && hasFile) {
throw new Error("Use either --batch-json or --batch-file, not both.");
}
if (hasInline) {
return parseBatchEntries(batchJson as string, "--batch-json");
}
const pathname = normalizeStringifiedOptionalString(opts.batchFile) ?? "";
if (!pathname) {
throw new Error("--batch-file must not be empty.");
}
let raw: string;
try {
raw = readConfigMutationFileSync(pathname, "--batch-file");
} catch (err) {
if (hasErrnoCode(err, "ENOENT")) {
throw new Error(`--batch-file not found: ${pathname}`, { cause: err });
}
throw err;
}
return parseBatchEntries(raw, "--batch-file");
}