mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
fix(0.3.2 slice 01 BC): HG-6 CLI-side validation — --max-files + --exit (no silent coercion)
Addresses guard BLOCKING-CONCERN verdict on a28f9b71 (guard qitem
qitem-20260516095128-843fbff5; 2 findings).
BLOCK 1 — rig workspace validate --max-files silently coerced garbage.
`--max-files 12abc` was passed through Number.parseInt which returns
12, and `--max-files abc` returned NaN that JSON-serialized to null.
Either way the daemon was called with bogus input.
Fix: exported parseMaxFilesStrict() in workspace.ts. Pattern
/^[1-9][0-9]*$/ rejects `12abc` / `abc` / `0` / `-1` / `""` and
throws an Error carrying fact/consequence/action fields. The
validate action checks BEFORE withClient runs; on failure emits a
3-part error (JSON-shape `{ ok: false, error: { fact, consequence,
action } }` in --json mode; 3-line stderr in human mode), sets
exitCode = 1, and returns WITHOUT calling the daemon.
Discriminators (8 new tests in workspace-max-files-validation.test.ts):
- parseMaxFilesStrict: accepts 1/12/10000; rejects 12abc/abc/""/0/-1;
the 3-part error carries fact + consequence + action fields
- end-to-end: --max-files 12abc --json → exit 1, parsed JSON has
ok=false + 3-part error.fact/consequence/action, client.post()
never called
- parameterized check across abc/0/-1
- human-mode emits 3-part to stderr (Error: ... did not run ... Pass
a positive integer)
BLOCK 2 — rig workflow project --exit silently forwarded any string.
TypeScript's union type "handoff" | "waiting" | "done" | "failed"
is erased at runtime; commander didn't validate. `--exit banana`
forwarded to /api/workflow/project with body.exit = "banana".
Fix: exported isProjectExitKind() + PROJECT_EXIT_KINDS in
workflow.ts. The project action validates opts.exit BEFORE
withClient runs; on failure emits a 3-part error and returns
without calling the daemon. Same JSON / human shape as BLOCK 1
via a shared emit3PartError helper inside workflow.ts (parallel
to the one in workspace.ts — the two surfaces have separate
command modules and don't share a deps tree, so duplicating the
10-line emitter keeps the change minimal).
Discriminators (6 new tests in workflow-exit-validation.test.ts):
- isProjectExitKind: accepts every kind in PROJECT_EXIT_KINDS;
rejects banana/""/HANDOFF (case-sensitive); rejects non-strings
- end-to-end: --exit banana --json → exit 1, parsed JSON has
3-part error.fact matching the full enum + got value,
client.post() never called
- human-mode emits 3-part to stderr
- parameterized across ""/HANDOFF/shipped (the latter is a likely
operator-confusion case from queue closures)
Verification:
- packages/cli npx tsc --noEmit: clean
- 28/28 PASS across workspace-max-files-validation (8) +
workflow-exit-validation (6) + workflow-ux (14)
Both fixes are minimal additive guards; no existing behavior changed
for valid input. Positive paths (--max-files 12, --exit handoff)
still call the daemon with the parsed value unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
a28f9b710e
commit
2b4772c785
4 files changed
+347
-3
No files matched your search
@@ -52,6 +52,27 @@ export interface OutcomeSummary {
|
||||
next: string;
|
||||
}
|
||||
|
||||
// release-0.3.2 slice 01 BC repair — runtime validator for the
|
||||
// project --exit enum. TypeScript types don't enforce at runtime;
|
||||
// without this guard `--exit banana` would forward to the daemon
|
||||
// transactional-scribe surface. Match the same 3-part error shape
|
||||
// used elsewhere in the slice.
|
||||
export const PROJECT_EXIT_KINDS = ["handoff", "waiting", "done", "failed"] as const;
|
||||
export type ProjectExitKind = (typeof PROJECT_EXIT_KINDS)[number];
|
||||
|
||||
export function isProjectExitKind(value: unknown): value is ProjectExitKind {
|
||||
return typeof value === "string" && (PROJECT_EXIT_KINDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function emit3PartError(json: boolean, fact: string, consequence: string, action: string): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ ok: false, error: { fact, consequence, action } }, null, 2));
|
||||
} else {
|
||||
process.stderr.write(`Error: ${fact}\n${consequence}\n${action}\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
export function printOutcomeSummary(json: boolean, status: number, summary: OutcomeSummary | null): void {
|
||||
if (json || !summary || status >= 400) return;
|
||||
console.log("");
|
||||
@@ -176,13 +197,27 @@ Examples:
|
||||
.action(async (opts: {
|
||||
instance: string;
|
||||
currentPacket: string;
|
||||
exit: "handoff" | "waiting" | "done" | "failed";
|
||||
exit: string;
|
||||
actorSession: string;
|
||||
resultNote?: string;
|
||||
blockedOn?: string;
|
||||
nextOwner?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
// HG-6 — runtime-validate the --exit enum BEFORE the daemon
|
||||
// call. TypeScript types are erased at runtime; without this
|
||||
// guard `--exit banana` would forward to the transactional
|
||||
// workflow surface.
|
||||
if (!isProjectExitKind(opts.exit)) {
|
||||
emit3PartError(
|
||||
Boolean(opts.json),
|
||||
`--exit must be one of ${PROJECT_EXIT_KINDS.join(" | ")} (got "${opts.exit}").`,
|
||||
"rig workflow project did not run; daemon was not contacted.",
|
||||
`Pass a valid exit kind. Example: --exit handoff.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const exitKind: ProjectExitKind = opts.exit;
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<{
|
||||
@@ -193,7 +228,7 @@ Examples:
|
||||
}>("/api/workflow/project", {
|
||||
instanceId: opts.instance,
|
||||
currentPacketId: opts.currentPacket,
|
||||
exit: opts.exit,
|
||||
exit: exitKind,
|
||||
actorSession: opts.actorSession,
|
||||
resultNote: opts.resultNote,
|
||||
blockedOn: opts.blockedOn,
|
||||
|
||||
@@ -46,6 +46,32 @@ async function withClient<T>(
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
// release-0.3.2 slice 01 BC repair — strict-int validator for
|
||||
// --max-files. Rejects `12abc`, `abc`, `0`, `-1`, etc. with a 3-part
|
||||
// fact/consequence/action error; does NOT call the daemon on invalid
|
||||
// input. Keeps positive cases (`10000`, `12`, etc.) flowing through.
|
||||
export function parseMaxFilesStrict(raw: string): number {
|
||||
if (!/^[1-9][0-9]*$/.test(raw)) {
|
||||
const err = new Error(
|
||||
`--max-files must be a positive integer (got "${raw}").`,
|
||||
) as Error & { fact: string; consequence: string; action: string };
|
||||
err.fact = `--max-files must be a positive integer (got "${raw}").`;
|
||||
err.consequence = "rig workspace validate did not run; daemon was not contacted.";
|
||||
err.action = "Pass a positive integer like --max-files 10000.";
|
||||
throw err;
|
||||
}
|
||||
return Number.parseInt(raw, 10);
|
||||
}
|
||||
|
||||
function emit3PartError(json: boolean, fact: string, consequence: string, action: string): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ ok: false, error: { fact, consequence, action } }, null, 2));
|
||||
} else {
|
||||
process.stderr.write(`Error: ${fact}\n${consequence}\n${action}\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
|
||||
const cmd = new Command("workspace").description(
|
||||
"PL-007 Workspace Primitive — typed-kind tooling. v0: `validate` walks a root and reports frontmatter gaps.",
|
||||
@@ -78,6 +104,17 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
|
||||
json?: boolean;
|
||||
},
|
||||
) => {
|
||||
// HG-6 — CLI-side validation BEFORE the daemon call. Reject
|
||||
// malformed --max-files with a 3-part error; never silently
|
||||
// coerce `12abc` → 12.
|
||||
let maxFiles: number;
|
||||
try {
|
||||
maxFiles = parseMaxFilesStrict(opts.maxFiles);
|
||||
} catch (err) {
|
||||
const e = err as Error & { fact?: string; consequence?: string; action?: string };
|
||||
emit3PartError(Boolean(opts.json), e.fact ?? e.message, e.consequence ?? "", e.action ?? "");
|
||||
return;
|
||||
}
|
||||
const root = path.resolve(rootArg ?? process.cwd());
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
@@ -86,7 +123,7 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
|
||||
workspaceKind: opts.kind,
|
||||
recursive: opts.recursive !== false,
|
||||
requireFrontmatter: opts.requireFrontmatter ?? false,
|
||||
maxFiles: Number.parseInt(opts.maxFiles, 10),
|
||||
maxFiles,
|
||||
});
|
||||
if (res.status >= 400) {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// release-0.3.2 slice 01 BC repair — HG-6 discriminators for the
|
||||
// workflow project --exit enum. Without runtime validation, the
|
||||
// TypeScript-only enum allowed `--exit banana` to flow through to
|
||||
// the daemon transactional-scribe surface.
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
workflowCommand,
|
||||
isProjectExitKind,
|
||||
PROJECT_EXIT_KINDS,
|
||||
type WorkflowDeps,
|
||||
} from "../src/commands/workflow.js";
|
||||
import type { LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
|
||||
function captureStdout(): { logs: string[]; restore: () => void } {
|
||||
const logs: string[] = [];
|
||||
const original = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
logs.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
|
||||
};
|
||||
return { logs, restore: () => { console.log = original; } };
|
||||
}
|
||||
|
||||
function captureStderr(): { logs: string[]; restore: () => void } {
|
||||
const logs: string[] = [];
|
||||
const original = process.stderr.write;
|
||||
process.stderr.write = ((chunk: unknown) => { logs.push(String(chunk)); return true; }) as typeof process.stderr.write;
|
||||
return { logs, restore: () => { process.stderr.write = original; } };
|
||||
}
|
||||
|
||||
function fakeRunningDeps(post: ReturnType<typeof vi.fn>): WorkflowDeps {
|
||||
return {
|
||||
lifecycleDeps: {} as LifecycleDeps,
|
||||
clientFactory: () => ({
|
||||
post,
|
||||
get: vi.fn(),
|
||||
postText: vi.fn(),
|
||||
}) as unknown as ReturnType<WorkflowDeps["clientFactory"]>,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isProjectExitKind — pure helper", () => {
|
||||
it("accepts every documented exit kind", () => {
|
||||
for (const kind of PROJECT_EXIT_KINDS) {
|
||||
expect(isProjectExitKind(kind)).toBe(true);
|
||||
}
|
||||
});
|
||||
it("rejects garbage strings (BC-2 root cause)", () => {
|
||||
expect(isProjectExitKind("banana")).toBe(false);
|
||||
expect(isProjectExitKind("")).toBe(false);
|
||||
expect(isProjectExitKind("HANDOFF")).toBe(false); // case-sensitive
|
||||
});
|
||||
it("rejects non-strings", () => {
|
||||
expect(isProjectExitKind(undefined)).toBe(false);
|
||||
expect(isProjectExitKind(null)).toBe(false);
|
||||
expect(isProjectExitKind(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig workflow project --exit — CLI-side discriminator (BLOCK 2)", () => {
|
||||
it("rejects '--exit banana' with --json: exit=1, body.error 3-part, client.post NOT called", async () => {
|
||||
const post = vi.fn();
|
||||
const deps = fakeRunningDeps(post);
|
||||
const out = captureStdout();
|
||||
const originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
const root = workflowCommand(deps);
|
||||
await root.parseAsync([
|
||||
"node", "rig", "project",
|
||||
"--instance", "WF1",
|
||||
"--current-packet", "q1",
|
||||
"--exit", "banana",
|
||||
"--actor-session", "x@y",
|
||||
"--json",
|
||||
]);
|
||||
out.restore();
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = originalExitCode;
|
||||
expect(exitCode).toBe(1);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
const joined = out.logs.join("\n");
|
||||
const parsed = JSON.parse(joined);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.error.fact).toMatch(/--exit must be one of handoff \| waiting \| done \| failed.*banana/);
|
||||
expect(parsed.error.consequence).toMatch(/did not run/);
|
||||
expect(parsed.error.action).toMatch(/--exit handoff/);
|
||||
});
|
||||
|
||||
it("emits 3-part error to stderr in human mode", async () => {
|
||||
const post = vi.fn();
|
||||
const deps = fakeRunningDeps(post);
|
||||
const err = captureStderr();
|
||||
const originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
const root = workflowCommand(deps);
|
||||
await root.parseAsync([
|
||||
"node", "rig", "project",
|
||||
"--instance", "WF1",
|
||||
"--current-packet", "q1",
|
||||
"--exit", "banana",
|
||||
"--actor-session", "x@y",
|
||||
]);
|
||||
err.restore();
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = originalExitCode;
|
||||
expect(exitCode).toBe(1);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
const joined = err.logs.join("");
|
||||
expect(joined).toMatch(/Error:.*--exit must be one of/);
|
||||
expect(joined).toMatch(/banana/);
|
||||
expect(joined).toMatch(/did not run/);
|
||||
});
|
||||
|
||||
it("rejects each garbage variant ('', 'HANDOFF', 'shipped') without calling the daemon", async () => {
|
||||
for (const bad of ["", "HANDOFF", "shipped"]) {
|
||||
const post = vi.fn();
|
||||
const deps = fakeRunningDeps(post);
|
||||
const out = captureStdout();
|
||||
const originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
const root = workflowCommand(deps);
|
||||
await root.parseAsync([
|
||||
"node", "rig", "project",
|
||||
"--instance", "WF1",
|
||||
"--current-packet", "q1",
|
||||
"--exit", bad,
|
||||
"--actor-session", "x@y",
|
||||
"--json",
|
||||
]);
|
||||
out.restore();
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = originalExitCode;
|
||||
expect(exitCode, `--exit "${bad}" should fail`).toBe(1);
|
||||
expect(post, `--exit "${bad}" must not call the daemon`).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
// Positive-path validation (e.g. --exit handoff forwards correctly)
|
||||
// is covered by isProjectExitKind pure-helper above; the live action
|
||||
// path is gated by getDaemonStatus and tested at the integration
|
||||
// tier, mirroring the slice-01 workflow-ux test pattern.
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
// release-0.3.2 slice 01 BC repair — HG-6 discriminators.
|
||||
// Verifies that `rig workspace validate --max-files <garbage>` rejects
|
||||
// CLI-side with a 3-part error AND does NOT call the daemon. Matches
|
||||
// the guard BC-1 fix recipe.
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { workspaceCommand, parseMaxFilesStrict, type WorkspaceDeps } from "../src/commands/workspace.js";
|
||||
import type { LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
|
||||
function captureStdout(): { logs: string[]; restore: () => void } {
|
||||
const logs: string[] = [];
|
||||
const original = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
logs.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
|
||||
};
|
||||
return { logs, restore: () => { console.log = original; } };
|
||||
}
|
||||
|
||||
function captureStderr(): { logs: string[]; restore: () => void } {
|
||||
const logs: string[] = [];
|
||||
const original = process.stderr.write;
|
||||
process.stderr.write = ((chunk: unknown) => { logs.push(String(chunk)); return true; }) as typeof process.stderr.write;
|
||||
return { logs, restore: () => { process.stderr.write = original; } };
|
||||
}
|
||||
|
||||
function fakeRunningDeps(post: ReturnType<typeof vi.fn>): WorkspaceDeps {
|
||||
return {
|
||||
lifecycleDeps: {} as LifecycleDeps,
|
||||
clientFactory: () => ({
|
||||
post,
|
||||
get: vi.fn(),
|
||||
postText: vi.fn(),
|
||||
}) as unknown as ReturnType<WorkspaceDeps["clientFactory"]>,
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseMaxFilesStrict — pure helper", () => {
|
||||
it("accepts positive integers", () => {
|
||||
expect(parseMaxFilesStrict("1")).toBe(1);
|
||||
expect(parseMaxFilesStrict("12")).toBe(12);
|
||||
expect(parseMaxFilesStrict("10000")).toBe(10000);
|
||||
});
|
||||
it("rejects garbage tails (BC-1 root cause)", () => {
|
||||
expect(() => parseMaxFilesStrict("12abc")).toThrow(/positive integer/);
|
||||
});
|
||||
it("rejects non-numeric input", () => {
|
||||
expect(() => parseMaxFilesStrict("abc")).toThrow(/positive integer/);
|
||||
expect(() => parseMaxFilesStrict("")).toThrow(/positive integer/);
|
||||
});
|
||||
it("rejects zero and negatives", () => {
|
||||
expect(() => parseMaxFilesStrict("0")).toThrow(/positive integer/);
|
||||
expect(() => parseMaxFilesStrict("-1")).toThrow(/positive integer/);
|
||||
});
|
||||
it("3-part error carries fact + consequence + action fields", () => {
|
||||
try {
|
||||
parseMaxFilesStrict("12abc");
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as Error & { fact?: string; consequence?: string; action?: string };
|
||||
expect(e.fact).toMatch(/positive integer/);
|
||||
expect(e.consequence).toMatch(/did not run/);
|
||||
expect(e.action).toMatch(/Pass a positive integer/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig workspace validate --max-files — CLI-side discriminator (BLOCK 1)", () => {
|
||||
it("rejects '12abc' with --json: exit=1, body.error has fact/consequence/action, client.post NOT called", async () => {
|
||||
const post = vi.fn();
|
||||
const deps = fakeRunningDeps(post);
|
||||
const out = captureStdout();
|
||||
const originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
const root = workspaceCommand(deps);
|
||||
await root.parseAsync(["node", "rig", "validate", "/tmp/ws", "--max-files", "12abc", "--json"]);
|
||||
out.restore();
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = originalExitCode;
|
||||
expect(exitCode).toBe(1);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
const joined = out.logs.join("\n");
|
||||
const parsed = JSON.parse(joined);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.error.fact).toMatch(/positive integer/);
|
||||
expect(parsed.error.consequence).toMatch(/did not run/);
|
||||
expect(parsed.error.action).toMatch(/--max-files/);
|
||||
});
|
||||
|
||||
it("rejects 'abc', '0', '-1' equivalently", async () => {
|
||||
for (const bad of ["abc", "0", "-1"]) {
|
||||
const post = vi.fn();
|
||||
const deps = fakeRunningDeps(post);
|
||||
const out = captureStdout();
|
||||
const root = workspaceCommand(deps);
|
||||
const originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
await root.parseAsync(["node", "rig", "validate", "/tmp/ws", "--max-files", bad, "--json"]);
|
||||
out.restore();
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = originalExitCode;
|
||||
expect(exitCode, `--max-files ${bad} should fail`).toBe(1);
|
||||
expect(post, `--max-files ${bad} must not call the daemon`).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("emits 3-part error to stderr in human mode (no --json)", async () => {
|
||||
const post = vi.fn();
|
||||
const deps = fakeRunningDeps(post);
|
||||
const err = captureStderr();
|
||||
const originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
const root = workspaceCommand(deps);
|
||||
await root.parseAsync(["node", "rig", "validate", "/tmp/ws", "--max-files", "12abc"]);
|
||||
err.restore();
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = originalExitCode;
|
||||
expect(exitCode).toBe(1);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
const joined = err.logs.join("");
|
||||
expect(joined).toMatch(/Error:.*positive integer/);
|
||||
expect(joined).toMatch(/did not run/);
|
||||
expect(joined).toMatch(/Pass a positive integer/);
|
||||
});
|
||||
|
||||
// Positive case is covered by parseMaxFilesStrict pure-helper test
|
||||
// above ("accepts positive integers"); driving end-to-end here
|
||||
// requires stubbing the daemon-status check too, which is covered
|
||||
// by the daemon-lifecycle adopt/expand test pattern (out of scope
|
||||
// for this BC discriminator).
|
||||
});
|
||||
Reference in new issue
Block a user