Compare commits

...
Author SHA1 Message Date
dev60-review b19e30fdad fix: a never-registered target says so from the requester's side; unknown isn't printed as nobody
A target where nobody was ever registered (the registry reports noHumans) now gives the same refusal as an emptied one, worded for the requesting machine: register a person on the target. rig host pair prints the target's own explanation of who reads answers, so a target that couldn't tell isn't shown as having no agent.
2026-11-11 02:26:40 +00:00
dev60-review 4298304a98 feat: a pairing request says who it went to and who reads the answer
The target's pair-request reply now carries who was asked (person) and who
reads their answer (owner), or why nobody does yet (noReader, the target's
own words), and `rig host pair` prints them; an older target's reply keeps
the general text. With nobody registered on the target, its refusal says to
register a person there, with the command.
2026-11-11 02:07:19 +00:00
5 changed files with 72 additions and 5 deletions

No files matched your search

+4 -1
View File
@@ -367,7 +367,10 @@ registered person can approve (none, or several with no default; name one with
`--human`) (`packages/daemon/src/routes/hosts.ts:379-398`).
The request stays open on the target for as long as `--timeout` waits (600 seconds by default), and the person's
approval says how long and until when, in UTC. A target older than this CLI ignores the timeout and keeps the request
approval says how long and until when, in UTC. A target that reports who was asked (`person`) and who reads their answer (`owner`), or why
nobody does yet (`noReader`), has `rig host pair` name them; an older target's reply keeps the general text. With
nobody registered on the target, its refusal (409 `pair_human_required`) says to register a person there:
`rig gateway human add <entityId> --display-name <name> --delivery-class B --default`. A target older than this CLI ignores the timeout and keeps the request
10 minutes.
On a 0.7.11 or later target, its person answers the request in the TUI (`rig tui`, then `attention`: open it), on
+10 -2
View File
@@ -862,7 +862,8 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
// retry loop forever. Values that work today keep their wait. The target
// keeps the request open as long as this side waits, and its approval says so.
const timeoutMs = resolvePairTimeoutMs(opts.timeout);
let issued: { pairId?: string; code?: string; approvalQitemId?: string; error?: string; message?: string };
let issued: { pairId?: string; code?: string; approvalQitemId?: string; error?: string; message?: string;
person?: string; owner?: string | null; ownerNote?: string; noReader?: string };
try {
const res = await httpPost(`${targetBase}/api/hosts/pair-request`, {
requester: `${userInfo().username}@${osHostname()}`,
@@ -884,7 +885,14 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
if (!opts.json) {
console.log(`Pairing code: ${issued.code}`);
console.log(`Waiting for approval on the target (its attention queue carries item ${issued.approvalQitemId ?? "?"}).`);
console.log("On the target, its person answers the pairing request in the TUI (rig tui, then attention), on Steering, or in Slack: with Approve or Deny, or in their own words. On a 0.7.11 or later target with a kernel operator, the operator reads the answer and decides (a stopped operator's answer waits for it); on a target with no kernel operator, Approve and Deny decide, and typed words are kept and the person is asked again. On an older target any typed reply approves.");
// A target that says who was asked and who reads the answer: name them. An older target keeps the general text.
if (issued.person) {
console.log(`The request went to ${issued.person} on the target.`);
console.log(issued.owner
? `${issued.owner} reads their answer and decides${issued.ownerNote ? ` (${issued.ownerNote}, so ${issued.owner} reads it instead)` : ""}.`
// The target's own words: none reads answers, or it couldn't tell. Never a categorical "nobody" over an unknown.
: `On the target: ${issued.noReader ?? "it didn't say whether an agent reads answers there"}. The request names how to decide directly on the target.`);
} else console.log("On the target, its person answers the pairing request in the TUI (rig tui, then attention), on Steering, or in Slack: with Approve or Deny, or in their own words. On a 0.7.11 or later target with a kernel operator, the operator reads the answer and decides (a stopped operator's answer waits for it); on a target with no kernel operator, Approve and Deny decide, and typed words are kept and the person is asked again. On an older target any typed reply approves.");
console.log(`On a 0.7.11 or later target, anyone at its terminal with its operator bearer token can also decide: rig host approve ${issued.code} (or rig host deny ${issued.code}), with OPENRIG_AUTH_BEARER_TOKEN set or --bearer <token>.`);
}
+20
View File
@@ -100,6 +100,26 @@ describe("rig host pair (OPR.0.4.6.MH1 FR-6)", () => {
expect(yaml).not.toContain("issued-bearer");
}, 15_000);
it("a target that says who was asked and who reads the answer is named; one where no agent reads says why", async () => {
const approved = [{ status: 200, body: { status: "approved", token: "issued-bearer" } }];
const owned = fakeDeps({ post: { status: 200, body: { pairId: "p-1", code: "424242", approvalQitemId: "qitem-1", person: "alex@external", owner: "lead@rig" } }, poll: approved });
const a = await capture(() => run(owned, ["pair", "vps-a:7433", "--id", "vps-a", "--timeout", "10"]));
expect(a.out.join("\n")).toContain("The request went to alex@external on the target.\nlead@rig reads their answer and decides.");
expect(a.out.join("\n")).not.toContain("On the target, its person answers the pairing request");
fs.rmSync(path.join(dir, "hosts.yaml"), { force: true }); fs.rmSync(path.join(dir, "secrets"), { recursive: true, force: true });
const free = fakeDeps({ post: { status: 200, body: { pairId: "p-2", code: "515151", approvalQitemId: "qitem-2", person: "alex@external", owner: null,
noReader: "no agent reads answers on this host (no receiving agent is set in agents.operator_session, and it has no operator-agent@kernel)" } }, poll: approved });
const b = await capture(() => run(free, ["pair", "vps-b:7433", "--id", "vps-b", "--timeout", "10"]));
expect(b.out.join("\n")).toContain("On the target: no agent reads answers on this host (no receiving agent is set in agents.operator_session, and it has no operator-agent@kernel). The request names how to decide directly on the target.");
// A target that couldn't tell is printed as unknown, never as "nobody reads".
fs.rmSync(path.join(dir, "hosts.yaml"), { force: true }); fs.rmSync(path.join(dir, "secrets"), { recursive: true, force: true });
const unknown = fakeDeps({ post: { status: 200, body: { pairId: "p-3", code: "616161", approvalQitemId: "qitem-3", person: "alex@external", owner: null,
noReader: "OpenRig couldn't tell whether an agent reads answers on this host" } }, poll: approved });
const c = await capture(() => run(unknown, ["pair", "vps-c:7433", "--id", "vps-c", "--timeout", "10"]));
expect(c.out.join("\n")).toContain("On the target: OpenRig couldn't tell whether an agent reads answers on this host. The request names how to decide directly on the target.");
expect(c.out.join("\n")).not.toContain("No agent reads answers there");
}, 15_000);
it("--timeout reaches the target, which keeps the request open that long", async () => {
const deps = fakeDeps({ poll: [{ status: 200, body: { status: "denied" } }] });
await capture(() => run(deps, ["pair", "http://vps-a:7433", "--timeout", "3600"]));
+8 -2
View File
@@ -400,7 +400,11 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
}
const body = (await c.req.json<{ requester?: string; human?: string; timeoutMs?: number }>().catch(() => ({}))) as { requester?: string; human?: string; timeoutMs?: number };
const registry = (opts?.humanRegistry ?? loadHumanRegistry)();
if (!registry.ok) return c.json({ error: "pair_human_registry_unavailable", message: registry.error }, 409);
// Nobody registered on this host (never, or no longer): the fix is here, and the requester's CLI prints this.
const noPerson = { error: "pair_human_required", addresses: [] as string[],
message: "No person is registered on the target host, so nobody there can be asked to approve pairing. On the target, register one: rig gateway human add <entityId> --display-name <name> --delivery-class B --default (a connector --binding is optional). No approval row was created." };
if (!registry.ok) return registry.noHumans ? c.json(noPerson, 409) : c.json({ error: "pair_human_registry_unavailable", message: registry.error }, 409);
if (registry.entities.length === 0) return c.json(noPerson, 409);
// Named, or else the default person (chosen, or the only one); never the first of several.
const fallback = body.human ? null : resolveDefaultPerson(registry.entities).person;
const humans = body.human ? registry.entities.filter((human) => human.address === body.human) : fallback ? [fallback] : [];
@@ -439,7 +443,9 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
return c.json({ error: "pair_approval_item_failed", message }, 500);
}
return c.json({ pairId, code, approvalQitemId: qitemId });
// Who was asked, and who reads their answer here (or why nobody does yet), for the requester to show.
return c.json({ pairId, code, approvalQitemId: qitemId, person: rec.person, owner: rec.owner,
...(rec.owner ? (rec.ownerNote ? { ownerNote: rec.ownerNote } : {}) : { noReader: noReader(rec) }) });
});
router.get("/pair-request/:pairId", async (c) => {
@@ -17,6 +17,7 @@ import net from "node:net";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync, statSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { loadHumanRegistry } from "../src/domain/gateway/human-registry.js";
import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
@@ -460,6 +461,35 @@ describe("pair-request — the target-side issuance handshake (FR-6)", () => {
expect(repo.list({ limit: 100 })[0]?.destinationSession).toBe("blair@external");
});
it("a target with nobody registered (never, or no longer) says so from the requester's side; several with no default keep today's text", async () => {
const neverHome = mkdtempSync(join(tmpdir(), "pair-never-registered-"));
try {
expect(loadHumanRegistry(neverHome)).toMatchObject({ ok: false, noHumans: true });
for (const registry of [() => loadHumanRegistry(neverHome), () => ({ ok: true as const, entities: [] })]) {
const res = await buildApp(repo, BEARER, registry).request("/api/hosts/pair-request", { method: "POST", body: "{}" });
expect(res.status).toBe(409);
expect(await res.json()).toEqual({ error: "pair_human_required", addresses: [],
message: "No person is registered on the target host, so nobody there can be asked to approve pairing. On the target, register one: rig gateway human add <entityId> --display-name <name> --delivery-class B --default (a connector --binding is optional). No approval row was created." });
}
expect(repo.list({ limit: 100 })).toHaveLength(0);
} finally { rmSync(neverHome, { recursive: true, force: true }); }
const several = await buildApp(repo, BEARER, () => ({ ok: true, entities: [human, { ...human, entityId: "blair", address: "blair@external" }] }))
.request("/api/hosts/pair-request", { method: "POST", body: "{}" });
expect(((await several.json()) as { message: string }).message).toContain("Pair approval needs one registered human, or a default when several exist.");
});
it("the reply names who was asked and who reads their answer, or why nobody does yet", async () => {
const res = await app.request("/api/hosts/pair-request", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requester: "tester@somewhere" }) });
expect(await res.json()).toMatchObject({ person: "alex@external", owner: null,
noReader: "no agent reads answers on this host (no receiving agent is set in agents.operator_session, and it has no operator-agent@kernel)" });
vi.stubEnv("OPENRIG_AGENTS_OPERATOR_SESSION", "lead@rig");
try {
const owned = await (await app.request("/api/hosts/pair-request", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requester: "tester@somewhere" }) })).json() as Record<string, unknown>;
expect(owned).toMatchObject({ person: "alex@external", owner: "lead@rig" });
expect(owned.noReader).toBeUndefined();
} finally { vi.unstubAllEnvs(); }
});
it("issuance mints the ONE human approval moment: a human-routed qitem with code + summary + evidence_ref", async () => {
const create = vi.spyOn(repo, "create");
const { code, approvalQitemId } = await issue();