mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
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]>
143 lines
5.0 KiB
TypeScript
143 lines
5.0 KiB
TypeScript
// 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.
|
|
});
|