mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
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:
1 parent
87bcbfa6a6
commit
18d0e618a9
4 files changed
+205
-6
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user