feat(gateway): rig gateway human add verb + registry hardening [M1 A3 pt2]

M1 A3 part 2: the verb-add-only CLI surface + the three r1 pt1-review items.

r1 MUST (no silent clobber — managed-config data-safety, mirror addHostEntry): addHumanFragment
now REFUSES an existing <entityId>.yaml unless { replace: true } is passed explicitly; a re-run
never silently replaces a human's data.
r1 note 1 (unknown-key): validateHumanFragment rejects unknown keys at the fragment, binding, AND
prefs levels (closed key sets) — a typo'd 'awya:' fails loud, never silently degrades.
r1 note 2 (address shape): address is pinned to <entityId>@external (the registered convention),
not a loose @external match — mike@externalx / other@external are refused.

VERB: `rig gateway human add <entityId> --display-name --binding kind:connectorRef:secretsRef:role
(repeatable) --delivery-class A-D [--away] [--replace]`. address is DERIVED (<entityId>@external);
--binding parse keeps a vault-pointer secretsRef intact (secretsRef may contain ':', e.g.
vault://slack/mike — kind/connectorRef first, role last, secretsRef the colon-bearing middle).
Mounted on createProgram beside crashCart.

Tests 25/25 (module + verb wiring + hermetic integration incl. the vault-pointer parse + no-clobber
exit-1); index createProgram wiring 4/4; tsc 0; build 0. Based on local main (A3 pt1 fold-34 87bcbfa6).

DAEMON READER (r1 noted for pt2): the gateway-side resolve of @external against the projection is
A4's consumption surface (the gateway process resolves admission) — rides A4, flagged. Never push.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
v-openrig-buildandClaude Opus 4.8 committed 2026-08-06 18:14:13 +00:00
1 parent 87bcbfa6a6
commit 18d0e618a9
4 files changed
+205 -6

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
import { Command } from "commander";
import { addHumanFragment } from "../gateway/human-registry.js";
// `rig gateway human add` — the verb-add-only surface for the human registry (M1 A3).
// Operators NEVER hand-create the fragment YAML; the verb owns it (validate -> atomic
// write -> re-project). address is DERIVED as <entityId>@external (the registered
// convention, pinned by the schema); no-clobber unless --replace is passed explicitly.
interface BindingSpec {
kind: string;
connectorRef: string;
secretsRef: string;
role: string;
}
/** --binding kind:connectorRef:secretsRef:role. secretsRef is a vault POINTER and may
* itself contain ':' (e.g. "vault://slack/mike"), so kind/connectorRef are the first
* two fields, role is the LAST, and secretsRef is everything between (colons intact). */
function parseBinding(spec: string): BindingSpec | { error: string } {
const parts = spec.split(":");
if (parts.length < 4) {
return { error: `--binding must be kind:connectorRef:secretsRef:role (got "${spec}")` };
}
const kind = parts[0]!;
const connectorRef = parts[1]!;
const role = parts[parts.length - 1]!;
const secretsRef = parts.slice(2, -1).join(":");
if (!kind || !connectorRef || !secretsRef || !role) {
return { error: `--binding fields must be non-empty: kind:connectorRef:secretsRef:role (got "${spec}")` };
}
return { kind, connectorRef, secretsRef, role };
}
export function gatewayCommand(): Command {
const cmd = new Command("gateway").description("Gateway: the human registry + connector surfaces");
const human = cmd.command("human").description("Manage human specs (file-per-human fragments under gateway/humans/)");
human
.command("add <entityId>")
.description("Add a human fragment (verb-add-only; the fragment is truth, the registry is a GENERATED projection)")
.requiredOption("--display-name <name>", "Human-readable display name")
.requiredOption(
"--binding <kind:connectorRef:secretsRef:role>",
"Connector binding (repeatable); role primary|secondary, EXACTLY ONE primary",
(v: string, acc: string[] = []) => { acc.push(v); return acc; },
)
.requiredOption("--delivery-class <A|B|C|D>", "Notification loudness class (the notifications register selection)")
.option("--away", "Set the AWAY preset")
.option("--replace", "Explicitly replace an existing human (no silent overwrite)")
.action((entityId: string, opts: { displayName: string; binding: string[]; deliveryClass: string; away?: boolean; replace?: boolean }) => {
const bindings: BindingSpec[] = [];
for (const spec of opts.binding) {
const b = parseBinding(spec);
if ("error" in b) { console.error(b.error); process.exitCode = 1; return; }
bindings.push(b);
}
const fragment: Record<string, unknown> = {
entityId,
class: "human",
displayName: opts.displayName,
address: `${entityId}@external`,
connectorBindings: bindings,
prefs: { deliveryClass: opts.deliveryClass, ...(opts.away ? { away: true } : {}) },
};
const res = addHumanFragment(fragment, undefined, { replace: !!opts.replace });
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
console.log(JSON.stringify({ ok: true, entityId: res.fragment.entityId, path: res.path }));
});
return cmd;
}
+36 -2
View File
@@ -27,6 +27,19 @@ export const HUMAN_DELIVERY_CLASSES = new Set(["A", "B", "C", "D"]);
// entityId is the fragment key + filename: a stable slug that survives platform
// renames. Lowercase alnum + separators, no path chars (it is spliced into a path).
export const ENTITY_ID_PATTERN = /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/;
// The registered @external ref shape (the section2 registered mode). A registry
// fragment's address is <entityId>@external by convention; literal-scheme addresses
// (slack:U...@external) are one-off ADDRESSES, never registry entries.
export const ADDRESS_DOMAIN = "external";
// Closed key sets — a typo'd field must fail LOUD, never silently degrade behavior.
const ALLOWED_FRAGMENT_KEYS = new Set(["entityId", "class", "displayName", "address", "connectorBindings", "prefs"]);
const ALLOWED_BINDING_KEYS = new Set(["kind", "connectorRef", "secretsRef", "role"]);
const ALLOWED_PREFS_KEYS = new Set(["deliveryClass", "away"]);
function unknownKey(obj: Record<string, unknown>, allowed: Set<string>): string | undefined {
for (const k of Object.keys(obj)) if (!allowed.has(k)) return k;
return undefined;
}
export interface HumanConnectorBinding {
kind: "slack";
@@ -68,6 +81,8 @@ function isObj(v: unknown): v is Record<string, unknown> {
* exactly-one-primary-per-entity). Does NOT touch the filesystem. */
export function validateHumanFragment(raw: unknown): ValidateResult {
if (!isObj(raw)) return { ok: false, error: "human fragment must be a mapping" };
const uk = unknownKey(raw, ALLOWED_FRAGMENT_KEYS);
if (uk) return { ok: false, error: `unknown fragment key "${uk}" — allowed: ${[...ALLOWED_FRAGMENT_KEYS].join(", ")} (a typo must not silently degrade)` };
const { entityId, class: cls, displayName, address, connectorBindings, prefs } = raw;
if (typeof entityId !== "string" || !ENTITY_ID_PATTERN.test(entityId)) {
@@ -82,6 +97,11 @@ export function validateHumanFragment(raw: unknown): ValidateResult {
if (typeof address !== "string" || address.length === 0) {
return { ok: false, error: "address must be a non-empty string (the registered @external ref)" };
}
// Registered-mode pin: the address is <entityId>@external (collision-free, ties the
// ref to the fragment key). Not a loose @external match — mike@externalx is NOT a ref.
if (address !== `${entityId}@${ADDRESS_DOMAIN}`) {
return { ok: false, error: `address "${address}" must be the registered ref "${entityId}@${ADDRESS_DOMAIN}" (the <entityId>@external convention)` };
}
if (!Array.isArray(connectorBindings) || connectorBindings.length < 1) {
return { ok: false, error: "connectorBindings must be a non-empty list (>=1)" };
}
@@ -91,6 +111,8 @@ export function validateHumanFragment(raw: unknown): ValidateResult {
for (let i = 0; i < connectorBindings.length; i++) {
const b = connectorBindings[i];
if (!isObj(b)) return { ok: false, error: `connectorBindings[${i}] must be a mapping` };
const ubk = unknownKey(b, ALLOWED_BINDING_KEYS);
if (ubk) return { ok: false, error: `connectorBindings[${i}] has unknown key "${ubk}" — allowed: ${[...ALLOWED_BINDING_KEYS].join(", ")}` };
if (!HUMAN_CONNECTOR_KINDS.has(String(b.kind))) {
return { ok: false, error: `connectorBindings[${i}].kind "${String(b.kind)}" is not a known connector kind (M1: ${[...HUMAN_CONNECTOR_KINDS].join(", ")})` };
}
@@ -112,6 +134,8 @@ export function validateHumanFragment(raw: unknown): ValidateResult {
}
if (!isObj(prefs)) return { ok: false, error: "prefs must be a mapping { deliveryClass, away? }" };
const upk = unknownKey(prefs, ALLOWED_PREFS_KEYS);
if (upk) return { ok: false, error: `prefs has unknown key "${upk}" — allowed: ${[...ALLOWED_PREFS_KEYS].join(", ")} (the notifications register selection)` };
if (!HUMAN_DELIVERY_CLASSES.has(String(prefs.deliveryClass))) {
return { ok: false, error: `prefs.deliveryClass "${String(prefs.deliveryClass)}" must be one of A|B|C|D (the notifications register)` };
}
@@ -203,11 +227,21 @@ export type AddHumanResult =
| { ok: false; error: string };
/** The verb-add writer: validate (add-time) -> atomic write the ONE fragment file ->
* re-project. Operators never hand-create the fragment YAML; the verb owns it. */
export function addHumanFragment(raw: unknown, home: string = getOpenRigHome()): AddHumanResult {
* re-project. Operators never hand-create the fragment YAML; the verb owns it.
* NO SILENT CLOBBER (mirrors hosts-registry addHostEntry): an existing entityId is
* REFUSED unless `replace` is passed explicitly — a re-run must not silently replace
* a human's data (managed-config data-safety class). */
export function addHumanFragment(
raw: unknown,
home: string = getOpenRigHome(),
opts: { replace?: boolean } = {},
): AddHumanResult {
const v = validateHumanFragment(raw);
if (!v.ok) return { ok: false, error: v.error };
const file = join(humansDir(home), `${v.fragment.entityId}.yaml`);
if (!opts.replace && existsSync(file)) {
return { ok: false, error: `human "${v.fragment.entityId}" already exists at ${file} — pass an explicit replace to update it (no silent overwrite)` };
}
const w = atomicWrite(file, stringifyYaml(v.fragment));
if (!w.ok) return { ok: false, error: w.error };
const proj = writeProjection(home);
+2
View File
@@ -27,6 +27,7 @@ import { archiveCommand } from "./commands/archive.js";
import { unarchiveCommand } from "./commands/unarchive.js";
import { psCommand } from "./commands/ps.js";
import { hostCommand } from "./commands/host.js";
import { gatewayCommand } from "./commands/gateway.js";
import { mcpCommand } from "./commands/mcp.js";
import { agentCommand, type AgentDeps } from "./commands/agent.js";
import { rigCommand, type RigDeps } from "./commands/rig.js";
@@ -164,6 +165,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
program.addCommand(snapshotCommand(depsOverride?.snapshotDeps));
program.addCommand(restoreCommand(depsOverride?.restoreDeps));
program.addCommand(crashCartCommand());
program.addCommand(gatewayCommand());
program.addCommand(exportCommand(depsOverride?.exportDeps));
program.addCommand(importCommand(depsOverride?.importDeps));
program.addCommand(uiCommand(depsOverride?.uiDeps));
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "no
import { join } from "node:path";
import { tmpdir } from "node:os";
import { stringify as stringifyYaml } from "yaml";
import { createProgram } from "../src/index.js";
import {
validateHumanFragment,
addHumanFragment,
@@ -20,11 +21,12 @@ import {
// a fragment edit re-projects; the projection hand-edit refuses.
function fragment(over: Partial<HumanFragment> = {}): Record<string, unknown> {
const entityId = (over.entityId as string) ?? "mike";
return {
entityId: "mike",
entityId,
class: "human",
displayName: "Mike",
address: "mike@external",
address: `${entityId}@external`, // the registered convention; override to test the pin
connectorBindings: [
{ kind: "slack", connectorRef: "slack-main", secretsRef: "vault://slack/mike", role: "primary" },
],
@@ -121,8 +123,8 @@ describe("A3 proof-5 — fragments -> generated projection; re-project; hand-edi
it("a fragment EDIT re-projects (projection tracks the fragment = truth)", () => {
addHumanFragment(fragment({ entityId: "mike", prefs: { deliveryClass: "B" } }), home);
const before = readFileSync(projectionPath(home), "utf8");
// edit the fragment via the verb (deliveryClass B -> D) + re-project
addHumanFragment(fragment({ entityId: "mike", prefs: { deliveryClass: "D" } }), home);
// edit the fragment via the EXPLICIT replace path (deliveryClass B -> D) + re-project
expect(addHumanFragment(fragment({ entityId: "mike", prefs: { deliveryClass: "D" } }), home, { replace: true }).ok).toBe(true);
const after = readFileSync(projectionPath(home), "utf8");
expect(after).not.toBe(before);
expect(after).toContain("deliveryClass: D");
@@ -155,4 +157,94 @@ describe("A3 proof-5 — fragments -> generated projection; re-project; hand-edi
const proj = projectHumans(home);
expect(proj.ok).toBe(false);
});
// pt2 r1 MUST — no silent clobber (managed-config data-safety; mirror addHostEntry).
it("addHumanFragment REFUSES an existing entityId (no silent overwrite)", () => {
expect(addHumanFragment(fragment({ entityId: "founder", displayName: "Founder" }), home).ok).toBe(true);
const dup = addHumanFragment(fragment({ entityId: "founder", displayName: "Impostor" }), home);
expect(dup.ok).toBe(false);
if (!dup.ok) expect(dup.error).toMatch(/exists|already/i);
// the on-disk fragment is UNCHANGED (no clobber)
const loaded = loadHumanRegistry(home);
expect(loaded.ok && loaded.entities[0]!.displayName).toBe("Founder");
});
it("addHumanFragment { replace: true } is the EXPLICIT update path", () => {
addHumanFragment(fragment({ entityId: "founder", displayName: "Founder" }), home);
const r = addHumanFragment(fragment({ entityId: "founder", displayName: "Founder Renamed" }), home, { replace: true });
expect(r.ok).toBe(true);
const loaded = loadHumanRegistry(home);
expect(loaded.ok && loaded.entities[0]!.displayName).toBe("Founder Renamed");
});
});
describe("A3 pt2 — hardening (r1 pooled notes)", () => {
it("rejects UNKNOWN top-level keys (a typo must not silently degrade)", () => {
expect(validateHumanFragment({ ...fragment(), bogus: 1 }).ok).toBe(false);
});
it("rejects an unknown key inside prefs (awya: typo)", () => {
expect(validateHumanFragment(fragment({ prefs: { deliveryClass: "B", awya: true } as unknown as HumanFragment["prefs"] })).ok).toBe(false);
});
it("rejects an unknown key inside a connectorBinding", () => {
expect(validateHumanFragment({ ...fragment(), connectorBindings: [{ kind: "slack", connectorRef: "x", secretsRef: "v", role: "primary", bogus: 1 }] }).ok).toBe(false);
});
it("pins address to <entityId>@external (mike@externalx is NOT a registered ref)", () => {
expect(validateHumanFragment(fragment({ entityId: "mike", address: "mike@externalx" })).ok).toBe(false);
expect(validateHumanFragment(fragment({ entityId: "mike", address: "mike@external" })).ok).toBe(true);
expect(validateHumanFragment(fragment({ entityId: "mike", address: "other@external" })).ok).toBe(false); // must match entityId
});
});
describe("A3 pt2 — rig gateway human add verb", () => {
let home: string;
let prevHome: string | undefined;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "a3-verb-"));
prevHome = process.env.OPENRIG_HOME;
process.env.OPENRIG_HOME = home;
process.exitCode = undefined;
});
afterEach(() => {
if (prevHome === undefined) delete process.env.OPENRIG_HOME; else process.env.OPENRIG_HOME = prevHome;
rmSync(home, { recursive: true, force: true });
});
it("is wired via createProgram (gateway human add)", () => {
const gw = createProgram().commands.find((c) => c.name() === "gateway");
expect(gw).toBeDefined();
const human = gw!.commands.find((c) => c.name() === "human");
expect(human!.commands.find((c) => c.name() === "add")).toBeDefined();
});
it("add writes a fragment + projection; address DERIVED; vault-pointer secretsRef (with ':') survives", async () => {
const program = createProgram();
program.exitOverride();
await program.parseAsync([
"node", "rig", "gateway", "human", "add", "mike",
"--display-name", "Mike",
"--binding", "slack:main:vault://slack/mike:primary",
"--delivery-class", "B",
]);
expect(existsSync(join(humansDir(home), "mike.yaml"))).toBe(true);
const loaded = loadHumanRegistry(home);
expect(loaded.ok).toBe(true);
if (loaded.ok) {
expect(loaded.entities[0]!.address).toBe("mike@external");
expect(loaded.entities[0]!.connectorBindings[0]!.secretsRef).toBe("vault://slack/mike");
}
});
it("add REFUSES an existing entityId (no silent clobber; exit 1)", async () => {
const args = [
"node", "rig", "gateway", "human", "add", "mike",
"--display-name", "Mike", "--binding", "slack:main:vault://x:primary", "--delivery-class", "B",
];
const p1 = createProgram(); p1.exitOverride();
await p1.parseAsync(args);
expect(process.exitCode).toBeUndefined();
process.exitCode = undefined;
const p2 = createProgram(); p2.exitOverride();
try { await p2.parseAsync(args); } catch { /* action sets exitCode, not throw */ }
expect(process.exitCode).toBe(1);
});
});