fix: enforce strict config validation

This commit is contained in:
Peter Steinberger
2026-01-19 03:38:51 +00:00
parent a9fc2ca0ef
commit d1e9490f95
53 changed files with 1025 additions and 821 deletions

View File

@@ -43,6 +43,11 @@ vi.mock("../tui/tui.js", () => ({ runTui }));
vi.mock("../gateway/call.js", () => ({
callGateway,
randomIdempotencyKey: () => "idem-test",
buildGatewayConnectionDetails: () => ({
url: "ws://127.0.0.1:1234",
urlSource: "test",
message: "Gateway target: ws://127.0.0.1:1234",
}),
}));
vi.mock("./deps.js", () => ({ createDefaultDeps: () => ({}) }));
@@ -127,26 +132,32 @@ describe("cli program (nodes basics)", () => {
});
it("runs nodes describe and calls node.describe", async () => {
callGateway
.mockResolvedValueOnce({
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
})
.mockResolvedValueOnce({
ts: Date.now(),
nodeId: "ios-node",
displayName: "iOS Node",
caps: ["canvas", "camera"],
commands: ["canvas.eval", "canvas.snapshot", "camera.snap"],
connected: true,
});
callGateway.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "node.list") {
return {
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
};
}
if (opts.method === "node.describe") {
return {
ts: Date.now(),
nodeId: "ios-node",
displayName: "iOS Node",
caps: ["canvas", "camera"],
commands: ["canvas.eval", "canvas.snapshot", "camera.snap"],
connected: true,
};
}
return { ok: true };
});
const program = buildProgram();
runtime.log.mockClear();
@@ -154,12 +165,10 @@ describe("cli program (nodes basics)", () => {
from: "user",
});
expect(callGateway).toHaveBeenNthCalledWith(
1,
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({ method: "node.list", params: {} }),
);
expect(callGateway).toHaveBeenNthCalledWith(
2,
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "node.describe",
params: { nodeId: "ios-node" },
@@ -189,24 +198,30 @@ describe("cli program (nodes basics)", () => {
});
it("runs nodes invoke and calls node.invoke", async () => {
callGateway
.mockResolvedValueOnce({
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
})
.mockResolvedValueOnce({
ok: true,
nodeId: "ios-node",
command: "canvas.eval",
payload: { result: "ok" },
});
callGateway.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "node.list") {
return {
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
};
}
if (opts.method === "node.invoke") {
return {
ok: true,
nodeId: "ios-node",
command: "canvas.eval",
payload: { result: "ok" },
};
}
return { ok: true };
});
const program = buildProgram();
runtime.log.mockClear();
@@ -224,12 +239,10 @@ describe("cli program (nodes basics)", () => {
{ from: "user" },
);
expect(callGateway).toHaveBeenNthCalledWith(
1,
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({ method: "node.list", params: {} }),
);
expect(callGateway).toHaveBeenNthCalledWith(
2,
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "node.invoke",
params: {

View File

@@ -44,6 +44,11 @@ vi.mock("../tui/tui.js", () => ({ runTui }));
vi.mock("../gateway/call.js", () => ({
callGateway,
randomIdempotencyKey: () => "idem-test",
buildGatewayConnectionDetails: () => ({
url: "ws://127.0.0.1:1234",
urlSource: "test",
message: "Gateway target: ws://127.0.0.1:1234",
}),
}));
vi.mock("./deps.js", () => ({ createDefaultDeps: () => ({}) }));
@@ -56,61 +61,43 @@ describe("cli program (nodes media)", () => {
});
it("runs nodes camera snap and prints two MEDIA paths", async () => {
callGateway
.mockResolvedValueOnce({
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
})
.mockResolvedValueOnce({
ok: true,
nodeId: "ios-node",
command: "camera.snap",
payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 },
})
.mockResolvedValueOnce({
ok: true,
nodeId: "ios-node",
command: "camera.snap",
payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 },
});
callGateway.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "node.list") {
return {
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
};
}
if (opts.method === "node.invoke") {
return {
ok: true,
nodeId: "ios-node",
command: "camera.snap",
payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 },
};
}
return { ok: true };
});
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(["nodes", "camera", "snap", "--node", "ios-node"], { from: "user" });
expect(callGateway).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
method: "node.invoke",
params: expect.objectContaining({
nodeId: "ios-node",
command: "camera.snap",
timeoutMs: 20000,
idempotencyKey: "idem-test",
params: expect.objectContaining({ facing: "front", format: "jpg" }),
}),
}),
);
expect(callGateway).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
method: "node.invoke",
params: expect.objectContaining({
nodeId: "ios-node",
command: "camera.snap",
timeoutMs: 20000,
idempotencyKey: "idem-test",
params: expect.objectContaining({ facing: "back", format: "jpg" }),
}),
}),
);
const invokeCalls = callGateway.mock.calls
.map((call) => call[0] as { method?: string; params?: Record<string, unknown> })
.filter((call) => call.method === "node.invoke");
const facings = invokeCalls
.map((call) => (call.params?.params as { facing?: string } | undefined)?.facing)
.filter(Boolean)
.sort((a, b) => a.localeCompare(b));
expect(facings).toEqual(["back", "front"]);
const out = String(runtime.log.mock.calls[0]?.[0] ?? "");
const mediaPaths = out
@@ -130,29 +117,35 @@ describe("cli program (nodes media)", () => {
});
it("runs nodes camera clip and prints one MEDIA path", async () => {
callGateway
.mockResolvedValueOnce({
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
callGateway.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "node.list") {
return {
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
};
}
if (opts.method === "node.invoke") {
return {
ok: true,
nodeId: "ios-node",
command: "camera.clip",
payload: {
format: "mp4",
base64: "aGk=",
durationMs: 3000,
hasAudio: true,
},
],
})
.mockResolvedValueOnce({
ok: true,
nodeId: "ios-node",
command: "camera.clip",
payload: {
format: "mp4",
base64: "aGk=",
durationMs: 3000,
hasAudio: true,
},
});
};
}
return { ok: true };
});
const program = buildProgram();
runtime.log.mockClear();
@@ -161,8 +154,7 @@ describe("cli program (nodes media)", () => {
{ from: "user" },
);
expect(callGateway).toHaveBeenNthCalledWith(
2,
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "node.invoke",
params: expect.objectContaining({
@@ -192,24 +184,30 @@ describe("cli program (nodes media)", () => {
});
it("runs nodes camera snap with facing front and passes params", async () => {
callGateway
.mockResolvedValueOnce({
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
})
.mockResolvedValueOnce({
ok: true,
nodeId: "ios-node",
command: "camera.snap",
payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 },
});
callGateway.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "node.list") {
return {
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
};
}
if (opts.method === "node.invoke") {
return {
ok: true,
nodeId: "ios-node",
command: "camera.snap",
payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 },
};
}
return { ok: true };
});
const program = buildProgram();
runtime.log.mockClear();
@@ -234,8 +232,7 @@ describe("cli program (nodes media)", () => {
{ from: "user" },
);
expect(callGateway).toHaveBeenNthCalledWith(
2,
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "node.invoke",
params: expect.objectContaining({
@@ -265,29 +262,35 @@ describe("cli program (nodes media)", () => {
});
it("runs nodes camera clip with --no-audio", async () => {
callGateway
.mockResolvedValueOnce({
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
callGateway.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "node.list") {
return {
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
};
}
if (opts.method === "node.invoke") {
return {
ok: true,
nodeId: "ios-node",
command: "camera.clip",
payload: {
format: "mp4",
base64: "aGk=",
durationMs: 3000,
hasAudio: false,
},
],
})
.mockResolvedValueOnce({
ok: true,
nodeId: "ios-node",
command: "camera.clip",
payload: {
format: "mp4",
base64: "aGk=",
durationMs: 3000,
hasAudio: false,
},
});
};
}
return { ok: true };
});
const program = buildProgram();
runtime.log.mockClear();
@@ -307,8 +310,7 @@ describe("cli program (nodes media)", () => {
{ from: "user" },
);
expect(callGateway).toHaveBeenNthCalledWith(
2,
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "node.invoke",
params: expect.objectContaining({
@@ -335,29 +337,35 @@ describe("cli program (nodes media)", () => {
});
it("runs nodes camera clip with human duration (10s)", async () => {
callGateway
.mockResolvedValueOnce({
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
callGateway.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "node.list") {
return {
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
};
}
if (opts.method === "node.invoke") {
return {
ok: true,
nodeId: "ios-node",
command: "camera.clip",
payload: {
format: "mp4",
base64: "aGk=",
durationMs: 10_000,
hasAudio: true,
},
],
})
.mockResolvedValueOnce({
ok: true,
nodeId: "ios-node",
command: "camera.clip",
payload: {
format: "mp4",
base64: "aGk=",
durationMs: 10_000,
hasAudio: true,
},
});
};
}
return { ok: true };
});
const program = buildProgram();
runtime.log.mockClear();
@@ -366,8 +374,7 @@ describe("cli program (nodes media)", () => {
{ from: "user" },
);
expect(callGateway).toHaveBeenNthCalledWith(
2,
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "node.invoke",
params: expect.objectContaining({
@@ -380,24 +387,30 @@ describe("cli program (nodes media)", () => {
});
it("runs nodes canvas snapshot and prints MEDIA path", async () => {
callGateway
.mockResolvedValueOnce({
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
})
.mockResolvedValueOnce({
ok: true,
nodeId: "ios-node",
command: "canvas.snapshot",
payload: { format: "png", base64: "aGk=" },
});
callGateway.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "node.list") {
return {
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
};
}
if (opts.method === "node.invoke") {
return {
ok: true,
nodeId: "ios-node",
command: "canvas.snapshot",
payload: { format: "png", base64: "aGk=" },
};
}
return { ok: true };
});
const program = buildProgram();
runtime.log.mockClear();
@@ -418,16 +431,21 @@ describe("cli program (nodes media)", () => {
});
it("fails nodes camera snap on invalid facing", async () => {
callGateway.mockResolvedValueOnce({
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
callGateway.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "node.list") {
return {
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
};
}
return { ok: true };
});
const program = buildProgram();
@@ -439,6 +457,8 @@ describe("cli program (nodes media)", () => {
}),
).rejects.toThrow(/exit/i);
expect(runtime.error).toHaveBeenCalledWith(expect.stringMatching(/invalid facing/i));
expect(runtime.error.mock.calls.some(([msg]) => /invalid facing/i.test(String(msg)))).toBe(
true,
);
});
});

View File

@@ -43,6 +43,11 @@ vi.mock("../tui/tui.js", () => ({ runTui }));
vi.mock("../gateway/call.js", () => ({
callGateway,
randomIdempotencyKey: () => "idem-test",
buildGatewayConnectionDetails: () => ({
url: "ws://127.0.0.1:1234",
urlSource: "test",
message: "Gateway target: ws://127.0.0.1:1234",
}),
}));
vi.mock("./deps.js", () => ({ createDefaultDeps: () => ({}) }));

View File

@@ -1,65 +1,67 @@
import {
isNixMode,
loadConfig,
migrateLegacyConfig,
readConfigFileSnapshot,
writeConfigFile,
} from "../../config/config.js";
import { danger } from "../../globals.js";
import { autoMigrateLegacyState } from "../../infra/state-migrations.js";
import { readConfigFileSnapshot } from "../../config/config.js";
import { loadAndMaybeMigrateDoctorConfig } from "../../commands/doctor-config-flow.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { loadClawdbotPlugins } from "../../plugins/loader.js";
import type { RuntimeEnv } from "../../runtime.js";
const ALLOWED_INVALID_COMMANDS = new Set(["doctor", "logs", "health", "help", "status", "service"]);
function formatConfigIssues(issues: Array<{ path: string; message: string }>): string[] {
return issues.map((issue) => `- ${issue.path || "<root>"}: ${issue.message}`);
}
export async function ensureConfigReady(params: {
runtime: RuntimeEnv;
migrateState?: boolean;
commandPath?: string[];
}): Promise<void> {
await loadAndMaybeMigrateDoctorConfig({
options: { nonInteractive: true },
confirm: async () => false,
});
const snapshot = await readConfigFileSnapshot();
if (snapshot.legacyIssues.length > 0) {
if (isNixMode) {
params.runtime.error(
danger(
"Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and retry.",
),
);
params.runtime.exit(1);
return;
}
const migrated = migrateLegacyConfig(snapshot.parsed);
if (migrated.config) {
await writeConfigFile(migrated.config);
if (migrated.changes.length > 0) {
params.runtime.log(
`Migrated legacy config entries:\n${migrated.changes
.map((entry) => `- ${entry}`)
.join("\n")}`,
);
}
} else {
const issues = snapshot.legacyIssues
.map((issue) => `- ${issue.path}: ${issue.message}`)
.join("\n");
params.runtime.error(
danger(
`Legacy config entries detected. Run "clawdbot doctor" (or ask your agent) to migrate.\n${issues}`,
),
);
params.runtime.exit(1);
return;
const command = params.commandPath?.[0];
const allowInvalid = command ? ALLOWED_INVALID_COMMANDS.has(command) : false;
const issues = snapshot.exists && !snapshot.valid ? formatConfigIssues(snapshot.issues) : [];
const legacyIssues =
snapshot.legacyIssues.length > 0
? snapshot.legacyIssues.map((issue) => `- ${issue.path}: ${issue.message}`)
: [];
const pluginIssues: string[] = [];
if (snapshot.valid) {
const workspaceDir = resolveAgentWorkspaceDir(
snapshot.config,
resolveDefaultAgentId(snapshot.config),
);
const registry = loadClawdbotPlugins({
config: snapshot.config,
workspaceDir: workspaceDir ?? undefined,
cache: false,
mode: "validate",
});
for (const diag of registry.diagnostics) {
if (diag.level !== "error") continue;
const id = diag.pluginId ? ` ${diag.pluginId}` : "";
pluginIssues.push(`- plugin${id}: ${diag.message}`);
}
}
if (snapshot.exists && !snapshot.valid) {
params.runtime.error(`Config invalid at ${snapshot.path}.`);
for (const issue of snapshot.issues) {
params.runtime.error(`- ${issue.path || "<root>"}: ${issue.message}`);
}
params.runtime.error("Run `clawdbot doctor` to repair, then retry.");
const invalid = snapshot.exists && (!snapshot.valid || pluginIssues.length > 0);
if (!invalid) return;
params.runtime.error(`Config invalid at ${snapshot.path}.`);
if (issues.length > 0) {
params.runtime.error(issues.join("\n"));
}
if (legacyIssues.length > 0) {
params.runtime.error(`Legacy config keys detected:\n${legacyIssues.join("\n")}`);
}
if (pluginIssues.length > 0) {
params.runtime.error(`Plugin config errors:\n${pluginIssues.join("\n")}`);
}
params.runtime.error("Run `clawdbot doctor --fix` to repair, then retry.");
if (!allowInvalid) {
params.runtime.exit(1);
return;
}
if (params.migrateState !== false) {
const cfg = loadConfig();
await autoMigrateLegacyState({ cfg });
}
}

View File

@@ -1,7 +1,7 @@
import type { Command } from "commander";
import { defaultRuntime } from "../../runtime.js";
import { emitCliBanner } from "../banner.js";
import { getCommandPath, hasHelpOrVersion, shouldMigrateState } from "../argv.js";
import { getCommandPath, hasHelpOrVersion } from "../argv.js";
import { ensureConfigReady } from "./config-guard.js";
function setProcessTitleForCommand(actionCommand: Command) {
@@ -20,9 +20,8 @@ export function registerPreActionHooks(program: Command, programVersion: string)
emitCliBanner(programVersion);
const argv = process.argv;
if (hasHelpOrVersion(argv)) return;
const [primary] = getCommandPath(argv, 1);
if (primary === "doctor") return;
const migrateState = shouldMigrateState(argv);
await ensureConfigReady({ runtime: defaultRuntime, migrateState });
const commandPath = getCommandPath(argv, 2);
if (commandPath[0] === "doctor") return;
await ensureConfigReady({ runtime: defaultRuntime, commandPath });
});
}

View File

@@ -20,6 +20,7 @@ export function registerMaintenanceCommands(program: Command) {
.option("--no-workspace-suggestions", "Disable workspace memory system suggestions", false)
.option("--yes", "Accept defaults without prompting", false)
.option("--repair", "Apply recommended repairs without prompting", false)
.option("--fix", "Apply recommended repairs (alias for --repair)", false)
.option("--force", "Apply aggressive repairs (overwrites custom service config)", false)
.option("--non-interactive", "Run without prompts (safe migrations only)", false)
.option("--generate-gateway-token", "Generate and configure a gateway token", false)
@@ -29,7 +30,7 @@ export function registerMaintenanceCommands(program: Command) {
await doctorCommand(defaultRuntime, {
workspaceSuggestions: opts.workspaceSuggestions,
yes: Boolean(opts.yes),
repair: Boolean(opts.repair),
repair: Boolean(opts.repair) || Boolean(opts.fix),
force: Boolean(opts.force),
nonInteractive: Boolean(opts.nonInteractive),
generateGatewayToken: Boolean(opts.generateGatewayToken),

View File

@@ -15,18 +15,17 @@ import {
getVerboseFlag,
hasFlag,
hasHelpOrVersion,
shouldMigrateStateFromPath,
} from "./argv.js";
import { ensureConfigReady } from "./program/config-guard.js";
import { runMemoryStatus } from "./memory-cli.js";
async function prepareRoutedCommand(params: {
argv: string[];
migrateState: boolean;
commandPath: string[];
loadPlugins?: boolean;
}) {
emitCliBanner(VERSION, { argv: params.argv });
await ensureConfigReady({ runtime: defaultRuntime, migrateState: params.migrateState });
await ensureConfigReady({ runtime: defaultRuntime, commandPath: params.commandPath });
if (params.loadPlugins) {
ensurePluginRegistryLoaded();
}
@@ -39,10 +38,8 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
const path = getCommandPath(argv, 2);
const [primary, secondary] = path;
if (!primary) return false;
const migrateState = shouldMigrateStateFromPath(path);
if (primary === "health") {
await prepareRoutedCommand({ argv, migrateState, loadPlugins: true });
await prepareRoutedCommand({ argv, commandPath: path, loadPlugins: true });
const json = hasFlag(argv, "--json");
const verbose = getVerboseFlag(argv, { includeDebug: true });
const timeoutMs = getPositiveIntFlagValue(argv, "--timeout");
@@ -53,7 +50,7 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
}
if (primary === "status") {
await prepareRoutedCommand({ argv, migrateState, loadPlugins: true });
await prepareRoutedCommand({ argv, commandPath: path, loadPlugins: true });
const json = hasFlag(argv, "--json");
const deep = hasFlag(argv, "--deep");
const all = hasFlag(argv, "--all");
@@ -67,7 +64,7 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
}
if (primary === "sessions") {
await prepareRoutedCommand({ argv, migrateState });
await prepareRoutedCommand({ argv, commandPath: path });
const json = hasFlag(argv, "--json");
const verbose = getVerboseFlag(argv);
const store = getFlagValue(argv, "--store");
@@ -80,7 +77,7 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
}
if (primary === "agents" && secondary === "list") {
await prepareRoutedCommand({ argv, migrateState });
await prepareRoutedCommand({ argv, commandPath: path });
const json = hasFlag(argv, "--json");
const bindings = hasFlag(argv, "--bindings");
await agentsListCommand({ json, bindings }, defaultRuntime);
@@ -88,7 +85,7 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
}
if (primary === "memory" && secondary === "status") {
await prepareRoutedCommand({ argv, migrateState });
await prepareRoutedCommand({ argv, commandPath: path });
const agent = getFlagValue(argv, "--agent");
if (agent === null) return false;
const json = hasFlag(argv, "--json");