Compare commits

..
Author SHA1 Message Date
dev60-review ea349ce66b fix(daemon): tell the receiving agent what a terminal answer means and how to ask the person back
A person's answer typed at this machine's terminal or TUI is recorded,
truthfully, as human@host: OpenRig can't tell who typed. The agent that
reads the answer was never told what that means for acting on it, so a
fresh receiver refused a clear Approve as "unattributed" and sent its
confirmation to human@host, where nothing is delivered. A clarification
also had no named route back to the person, and a runner's decider was
never told what closing its item does.

The receiver's text now says, in one place (receiver-context.ts):
- what an unattributed terminal answer is: the answer of whoever is at
  this machine, usually the person asked, to act on with the request's
  own decision command and without a confirmation;
- who's who on a pairing or runner request: the person asked, the
  requester, and who answered;
- how to ask them back: a new ask to the person the request went to,
  which reaches them in the Feed, Steering and Slack, and never
  human@host. When that person isn't registered, the agent says so once,
  keeps the request and the answer waiting, and names registration or
  the request's own decision command;
- for a runner's request, that closing the item without a decision
  permits nothing: the answer stays kept and the runner waits. The
  default permit still applies only to a request nobody answered.

Text only. The recorded answerer stays human@host, the registered default
person is never recorded as the actor, and no state, refusal or policy
changes.
2026-11-12 03:14:23 +00:00
19 changed files with 134 additions and 164 deletions

No files matched your search

@@ -43,6 +43,7 @@ import { createHash } from "node:crypto";
import type Database from "better-sqlite3";
import type { EventBus } from "../event-bus.js";
import { isTypedGateBlocker, mcOperationTag, UNATTRIBUTED_ACTOR, type QueueRepository, type QueueItem } from "../queue-repository.js";
import { askBackRoute, terminalAnswerMeaning } from "../receiver-context.js";
import type { PersistedEvent } from "../types.js";
import {
MissionControlActionLog,
@@ -695,7 +696,7 @@ export class MissionControlWriteContract {
try {
returned = await this.queueRepo.createHumanAnswer({
sourceSession: input.actorSession, destinationSession: ask.sourceSession,
body: `${reply}\n${answeredBy} in OpenRig.`,
body: `${reply}\n${answeredBy} in OpenRig.${unattributed ? `\n${terminalAnswerMeaning(UNNAMED_PRIMARY_USER)} ${askBackRoute(this.queueRepo.reachablePerson(forAsked), UNNAMED_PRIMARY_USER, input.qitemId)}` : ""}`,
summary: `${forAsked && unattributed ? `Unattributed (${UNNAMED_PRIMARY_USER})` : input.actorSession} answered${forAsked ? ` ${forAsked}'s ask` : ""}: ${ask.summary ?? input.qitemId}`,
tags: [
"human-answer",
+17 -1
View File
@@ -103,6 +103,7 @@ import { WatchdogJobsRepository } from "./watchdog-jobs-repository.js";
import { armQueueWait, backOffQueueWait, refreshQueueWaits, evaluateQueueWait, retargetQueueWait, isQueueWait } from "./queue-wait-backoff.js";
import { parseHumanQuestions, unansweredQuestions, describeTypedReplyPlacement, formatHumanAnswers, humanAnswerText, type HumanQuestion, type HumanAnswers, type TypedHumanReply, type RecordHumanAnswerResult } from "./human-questions.js";
import { slackMicros } from "./gateway/slack/state-store.js";
import { askBackRoute, RUNNER_CLOSE_WAITS, terminalAnswerMeaning } from "./receiver-context.js";
/** A direct ask's answer row ID: from the ask, or from the ask and the park episode its answer belongs to. */
function humanAnswerId(askId: string, episodeKey?: string): string {
@@ -2504,6 +2505,14 @@ export class QueueRepository {
return registry.ok && resolveRegisteredHumanAddress(destination, registry.entities) !== null;
}
/** The registered person an address names, so an ask back reaches them; null for anyone else, the unattributed
* terminal actor included (it's a person address, but nothing is delivered there). */
reachablePerson(address: string | null | undefined): string | null {
if (!address || address === UNATTRIBUTED_ACTOR) return null;
const registry = this.loadHumanRegistryFn();
return registry.ok ? resolveRegisteredHumanAddress(address, registry.entities) : null;
}
/** Who may answer an ask: the asked person, and any other registered person in this workspace, who's recorded as
* themselves with the asked person kept (one trusted workspace, equal authority). A shared channel never answers. */
answersAsk(item: QueueItem, actorSession: string): boolean {
@@ -4245,6 +4254,9 @@ export class QueueRepository {
: answer && typeof answer === "object" && "text" in answer ? `wrote "${String(answer.text).trim()}"`
: "answered without a choice or words";
const person = input.actorSession;
// Who to ask back: the person who answered when OpenRig can name them, else the person the request went to.
const unattributed = person === UNATTRIBUTED_ACTOR;
const askBack = (unattributed ? null : this.reachablePerson(person)) ?? this.reachablePerson(item.destinationSession);
// A handoff that fails after persisting its events rolls them back with its savepoint: discardOnThrow takes their
// tokens out of the caller's notify envelope too, so the kept answer below still commits.
try { this.eventBus.discardOnThrow(() => this.handoffNow({
@@ -4260,8 +4272,12 @@ export class QueueRepository {
"",
`Their whole answer is recorded on the request: rig queue show ${answered.qitemId} --full.`,
`Decide with: rig seat decide-permission ${item.qitemId} --option <id> --reason "<why>"`,
"Act on a clear answer at once: a clear yes or an allow click is the allow option, a clear no or a reject click the reject option. Don't ask them to confirm a clear answer. If their words are unclear or conditional, ask them back, naming this request, then decide.",
`- **Who's who:** ${root.sourceSession} is the agent asking to run the tool; ${item.destinationSession} is the person it asked; ${person} answered.`,
...(unattributed ? [terminalAnswerMeaning(UNATTRIBUTED_ACTOR)] : []),
"Act on a clear answer at once: a clear yes or an allow click is the allow option, a clear no or a reject click the reject option. Don't ask them to confirm a clear answer.",
`${askBackRoute(askBack, UNATTRIBUTED_ACTOR, `request ${item.qitemId}`)} Then decide on what they answer.`,
"Until it's decided (by you, or by anyone with that command), the runner keeps waiting, with no timeout that refuses it; if its turn ends first, nothing is sent.",
RUNNER_CLOSE_WAITS,
].join("\n"),
})); } catch (error) {
// Kept, and committed with the answer's own write (a throw here would roll it back): the request stays open, and
@@ -0,0 +1,26 @@
// What a receiving agent needs to act on a person's answer, in one place: what an answer typed at this machine means,
// how to ask the person back, and what closing a runner's request does. Text only: who is recorded as answering, and
// every state and policy, stay as they are.
/** An answer recorded as `unattributed` (typed at this machine's terminal or TUI): what it means for acting on it. */
export function terminalAnswerMeaning(unattributed: string): string {
return `${unattributed} is how OpenRig records any answer typed at this machine's terminal or TUI: it can't tell who typed, so it names nobody. `
+ "It's the answer of whoever is at this machine, usually the person the request went to. Act on a clear answer with the request's own decision command, and don't ask them to confirm it.";
}
/** How to ask the person back when their answer is unclear or conditional: a new ask to the person the request went to,
* which reaches them where they answer. `asked` is null when that person isn't registered (any more): what to do then is
* the one open case, so it's this one sentence. */
export function askBackRoute(asked: string | null, unattributed: string, request: string): string {
const nowhere = `Don't write to ${unattributed}: nothing is delivered there.`;
if (asked) {
return `If their answer is unclear or conditional, ask them back with an ask to ${asked} that names ${request}: `
+ `rig queue create --destination ${asked} --summary "<your question>" --body-file <file>. It reaches them where they answer (the Feed, Steering, and Slack when it's connected). ${nowhere}`;
}
return `If their answer is unclear or conditional, OpenRig can't ask them back: the person ${request} went to isn't registered. `
+ `Say so once, in this conversation, and keep the request and their answer waiting. The ways through are registering a person (rig gateway human add) or the request's own decision command. ${nowhere}`;
}
/** What closing a runner's permission request without a decision does: a kept answer waits (runner-permissions). */
export const RUNNER_CLOSE_WAITS = "Closing this item without deciding permits nothing: the person's answer stays kept and the runner keeps waiting until it's decided. "
+ "OpenRig's default permit applies only to a request nobody answered.";
+2 -1
View File
@@ -56,6 +56,7 @@ import { addHostEntry, updateHostEntry, removeHostEntry } from "../domain/hosts/
import { defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, type HostEntry } from "../domain/hosts/hosts-registry-reader.js";
import type { SettingsStore } from "../domain/user-settings/settings-store.js";
import { UNATTRIBUTED_ACTOR, type QueueItem, type QueueRepository } from "../domain/queue-repository.js";
import { askBackRoute } from "../domain/receiver-context.js";
import { KERNEL_OPERATOR_SESSION, resolveDefaultReceiver } from "../domain/default-receiver.js";
import type { HumanQuestion } from "../domain/human-questions.js";
import { PAIR_SOURCE_SESSION } from "../domain/hosts/pairing-ask.js";
@@ -268,7 +269,7 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
...(owner ? [
`Answer with Approve or Deny, or in your own words. ${owner} reads your answer and acts on it: a clear yes pairs, a clear no denies, and if your answer isn't clear it asks you back.${rec.ownerNote ? ` (${rec.ownerNote}, so ${owner} reads it instead.)` : ""}`,
...((running) => running === true ? [] : [`${running === false ? `${owner} isn't running right now, so your answer waits` : `OpenRig couldn't tell whether ${owner} is running, so your answer may wait`} until it starts; nothing is granted or denied meanwhile. To decide sooner on this host: ${directDecision(code)}.`])(ownerRunning(c, owner)),
`For ${owner}: when the person's answer to this request comes back, act on it. A clear yes or an Approve click: rig host approve ${code}. A clear no or a Deny click: rig host deny ${code}. Don't ask them to confirm a clear answer. If their words are unclear or conditional, ask them back, naming pairing code ${code}, and act on what they answer.`,
`For ${owner}: when the person's answer to this request comes back, act on it. ${rec.person} is the person asked, on this host; ${requester} is the other machine, asking to pair. A clear yes or an Approve click: rig host approve ${code}. A clear no or a Deny click: rig host deny ${code}. Don't ask them to confirm a clear answer. ${askBackRoute(rec.person, UNATTRIBUTED_ACTOR, `pairing code ${code}`)} Then act on what they answer.`,
] : [
`No agent reads answers here yet: ${noReader(rec)}. Your answer, a click or your words, is kept, and goes to the receiving agent once one is set; nothing is granted or denied meanwhile.`,
`To decide now on this host: ${directDecision(code)}.`,
@@ -259,7 +259,10 @@ describe("host pairing with the kernel operator running: the person's answer goe
const item = f.repo.getById(f.ask)!;
expect(item).toMatchObject({ sourceSession: OPERATOR, destinationSession: PERSON, tier: "human-gate" });
expect(item.body).toContain(`Answer with Approve or Deny, or in your own words. ${OPERATOR} reads your answer and acts on it`);
expect(item.body).toContain(`For ${OPERATOR}: when the person's answer to this request comes back, act on it. A clear yes or an Approve click: rig host approve ${f.issued.code}. A clear no or a Deny click: rig host deny ${f.issued.code}.`);
expect(item.body).toContain(`For ${OPERATOR}: when the person's answer to this request comes back, act on it. ${PERSON} is the person asked, on this host; requester@far-host is the other machine, asking to pair. A clear yes or an Approve click: rig host approve ${f.issued.code}. A clear no or a Deny click: rig host deny ${f.issued.code}.`);
// An unclear answer is asked back through the person asked, never human@host.
expect(item.body).toContain(`ask them back with an ask to ${PERSON} that names pairing code ${f.issued.code}: rig queue create --destination ${PERSON}`);
expect(item.body).toContain("Don't write to human@host: nothing is delivered there. Then act on what they answer.");
});
it.each(["yes", "no", "yes, but which machine?"])("a typed %j (Steering) reaches the operator as a reply with the person's words, and decides nothing by itself", async (words) => {
@@ -16,6 +16,7 @@ import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js";
import { humanAnswerText } from "../src/domain/human-questions.js";
import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js";
import { missionControlRoutes } from "../src/routes/mission-control.js";
import { addHumanFragment } from "../src/domain/gateway/human-registry.js";
@@ -77,6 +78,12 @@ describe("the primary user acts on anyone's ask, unattributed, on that person's
const reply = repo.list({ limit: 50 }).find((item) => item.replyTo === sarahs.qitemId)!;
expect(reply).toMatchObject({ sourceSession: "human@host", destinationSession: ASKER, summary: "Unattributed (human@host) answered sarah@external's ask: Ship the draft?" });
expect(reply.body).toContain("Answered at the terminal by someone OpenRig can't name (human@host), unattributed, on sarah@external's ask in OpenRig.");
// The receiver reads what that means for acting, and the route to ask the person back: never human@host.
expect(reply.body).toContain("human@host is how OpenRig records any answer typed at this machine's terminal or TUI");
expect(reply.body).toContain("don't ask them to confirm it");
expect(reply.body).toContain(`ask them back with an ask to sarah@external that names ${sarahs.qitemId}: rig queue create --destination sarah@external`);
expect(reply.body).toContain("Don't write to human@host: nothing is delivered there.");
expect(humanAnswerText(reply.body)).toBe("Ship it");
});
it("records a choice on a question ask the same way", async () => {
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { askBackRoute, RUNNER_CLOSE_WAITS, terminalAnswerMeaning } from "../src/domain/receiver-context.js";
describe("what a receiving agent reads to act on a person's answer", () => {
it("names the person to ask back, and never the unattributed terminal actor", () => {
const route = askBackRoute("sarah@external", "human@host", "request q-1");
expect(route).toContain("ask them back with an ask to sarah@external that names request q-1: rig queue create --destination sarah@external");
expect(route).toContain("Don't write to human@host: nothing is delivered there.");
});
it("with no registered person to ask back: says so once, keeps the request and the answer waiting, and names the ways through", () => {
const route = askBackRoute(null, "human@host", "request q-1");
expect(route).toContain("OpenRig can't ask them back: the person request q-1 went to isn't registered.");
expect(route).toContain("Say so once, in this conversation, and keep the request and their answer waiting.");
expect(route).toContain("registering a person (rig gateway human add) or the request's own decision command");
expect(route).not.toContain("rig queue create");
});
it("explains an unattributed terminal answer as the answer of whoever is at this machine, to act on without a confirmation", () => {
expect(terminalAnswerMeaning("human@host")).toContain("human@host is how OpenRig records any answer typed at this machine's terminal or TUI");
expect(terminalAnswerMeaning("human@host")).toContain("don't ask them to confirm it");
});
it("says a close without a decision permits nothing, and keeps the default permit to a request nobody answered", () => {
expect(RUNNER_CLOSE_WAITS).toContain("Closing this item without deciding permits nothing");
expect(RUNNER_CLOSE_WAITS).toContain("OpenRig's default permit applies only to a request nobody answered.");
});
});
@@ -642,6 +642,10 @@ describe("an answered request goes to the receiving agent, which decides while t
expect(handed).toMatchObject({ destinationSession: DECIDER, state: "pending" });
expect(handed.body).toContain(`${PERSON} answered ${SEAT}'s permission request ${askId}: they chose "Allow" (option allow).`);
expect(handed.body).toContain(`Decide with: rig seat decide-permission ${askId} --option <id>`);
// The receiver reads who to ask back (the person who answered), and that closing without a decision permits nothing.
expect(handed.body).toContain(`ask them back with an ask to ${PERSON} that names request ${askId}`);
expect(handed.body).toContain("Closing this item without deciding permits nothing: the person's answer stays kept and the runner keeps waiting");
expect(handed.body).not.toContain("is how OpenRig records any answer typed at this machine's terminal");
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
expect(await f.decide(askId, "allow")).toMatchObject({ status: 200, body: { ok: true, state: "answered", optionId: "allow" } });
expect(await f.read(askId)).toMatchObject({ body: { state: "answered", optionId: "allow" } });
@@ -652,6 +656,25 @@ describe("an answered request goes to the receiving agent, which decides while t
expect(f.permits()).toHaveLength(0);
});
it("an answer typed at the terminal reaches the agent with what it means, who's who, and the asked person to ask back", async () => {
const f = fixture({ decider: DECIDER });
const askId = String((await f.submit()).body.askId);
const asked = f.queueRepo.getById(askId)!.destinationSession;
await f.contract.act({ verb: "answer", qitemId: askId, actorSession: "human@host", authority: "primary-user",
questionId: "permission", optionId: "allow" } as never);
const handed = f.queueRepo.list({ limit: 100 }).find((row) => row.handedOffFrom === askId)!;
expect(handed).toMatchObject({ destinationSession: DECIDER, state: "pending" });
// Provenance stays truthful: the answerer is human@host, never a registered person.
expect(handed.body).toContain(`human@host answered ${SEAT}'s permission request ${askId}: they chose "Allow" (option allow).`);
expect(handed.body).toContain(`- **Who's who:** ${SEAT} is the agent asking to run the tool; ${asked} is the person it asked; human@host answered.`);
expect(handed.body).toContain("human@host is how OpenRig records any answer typed at this machine's terminal or TUI");
expect(handed.body).toContain(`ask them back with an ask to ${asked} that names request ${askId}`);
expect(handed.body).toContain("Don't write to human@host: nothing is delivered there.");
expect(handed.body).toContain("Closing this item without deciding permits nothing");
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
expect(f.permits()).toHaveLength(0);
});
it("a person's words, even a plain yes, go to the agent with the question and options, never permitted by default first", async () => {
for (const words of ["yes", "only if it's the test file"]) {
const f = fixture({ decider: DECIDER });
+2 -4
View File
@@ -287,10 +287,8 @@ function toAgentRow(node: NodeInventoryRead): AgentRow {
: node.activityState?.display === "idle"
? "idle"
: (node.terminalActive === true || (node.terminalActive == null && node.agentActivity?.state === "running") ? "active"
// The daemon's activity word decides when it's served (as in `rig ps`): it said unknown, so a quiet
// screen stays unknown. A community harness gives no idle signal either. Only an older daemon that
// serves no activity word keeps the screen fallback.
: node.communityHarness || node.activityState ? "unknown" : "idle"))
// A community harness gives no idle signal: a quiet screen is unknown, not idle.
: node.communityHarness ? "unknown" : "idle"))
: (node.sessionStatus ?? "unknown"),
live: node.lifecycleState === "running",
canRun: node.lifecycleState !== "running"
+3 -23
View File
@@ -710,7 +710,7 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
expect(output.lines.find((line) => /\bmismatch\s/.test(line))).not.toContain("run ▸");
expect(output.lines.find((line) => /\bmissing\s/.test(line))).not.toContain("run ▸");
});
it("shows a quiet seat the daemon reads as unknown as unknown, never idle, for a community harness and a built-in runtime alike", async () => {
it("shows a quiet community harness as unknown, never idle, and leaves built-in rows as they were", async () => {
const base = (FIXTURES["/api/rigs/01JRIG/nodes"] as Array<Record<string, unknown>>)[0]!;
const quiet = { startupStatus: "ready", sessionStatus: "running", terminalActive: false, agentActivity: { state: "unknown" },
activityState: { activity: "unknown", display: "unknown", needsInput: { count: 0, reason: null }, decidedBy: null } };
@@ -727,28 +727,8 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
"dev.app-quiet": "unknown",
"dev.app-moving": "active",
"dev.app-prompt": "needs_input",
"dev.pi-quiet": "unknown",
"dev.claude-quiet": "unknown",
});
});
it("keeps the daemon's idle and working words, and the screen fallback only for a daemon that serves no activity word", async () => {
const base = (FIXTURES["/api/rigs/01JRIG/nodes"] as Array<Record<string, unknown>>)[0]!;
const running = { startupStatus: "ready", sessionStatus: "running", runtime: "claude-code", agentActivity: { state: "unknown" } };
const served = (display: string) => ({ activity: display === "idle" ? "idle-at-prompt" : display, display, needsInput: { count: 0, reason: null }, decidedBy: null });
const nodes = [
{ ...base, ...running, logicalId: "dev.served-idle", terminalActive: false, activityState: served("idle") },
{ ...base, ...running, logicalId: "dev.served-working", terminalActive: false, activityState: served("working") },
{ ...base, ...running, logicalId: "dev.unknown-moving", terminalActive: true, activityState: served("unknown") },
{ ...base, ...running, logicalId: "dev.legacy-quiet", terminalActive: false, activityState: undefined },
];
const snap = await hydrateSnapshot(fixtureClient({}, { "/api/rigs/01JRIG/nodes": nodes }));
const statuses = Object.fromEntries(myrig(snap).pods[0]!.agents.map((agent) => [agent.name, agent.status]));
expect(statuses).toEqual({
"dev.served-idle": "idle",
"dev.served-working": "active",
"dev.unknown-moving": "active",
"dev.legacy-quiet": "idle",
"dev.pi-quiet": "idle",
"dev.claude-quiet": "idle",
});
});
+1 -2
View File
@@ -4,7 +4,6 @@ import fs from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { packEntry } from "./npm-pack-json.mjs";
const script = fs.readFileSync(new URL("./build-package.sh", import.meta.url), "utf8");
const assetsBranch = script.match(/^if \[ -d "\$DAEMON_DIR\/assets" \]; then\n[\s\S]*?^fi$/m)?.[0];
@@ -130,7 +129,7 @@ test("offline npm pack carries staged asset bytes and executability, with npm's
assertStaged(f);
const staged = snapshot(f.staged);
fs.writeFileSync(join(f.cli, "package.json"), JSON.stringify({ name: "openrig-assets-fixture", version: "1.0.0", private: true, files: ["daemon"] }));
const packed = packEntry(execFileSync("bash", ["-c", 'umask 0022; exec npm pack --offline --ignore-scripts --json --pack-destination "$1"', "asset-pack", f.root], { cwd: f.cli, encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] }));
const packed = JSON.parse(execFileSync("bash", ["-c", 'umask 0022; exec npm pack --offline --ignore-scripts --json --pack-destination "$1"', "asset-pack", f.root], { cwd: f.cli, encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] }))[0];
const files = new Map(packed.files.map(file => [file.path, file]));
for (const [name, bytes] of Object.entries(contents)) {
const entry = `daemon/assets/${name}`;
+2 -2
View File
@@ -2,14 +2,14 @@ import { execSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { test } from "node:test";
import assert from "node:assert/strict";
import { packEntry } from "./npm-pack-json.mjs";
test("npm pack of @openrig/cli includes scripts/check-abi.mjs in tarball", () => {
const output = execSync("npm pack --dry-run --json 2>/dev/null", {
cwd: "packages/cli",
encoding: "utf-8",
});
const files = packEntry(output).files?.map((f) => f.path) ?? [];
const entries = JSON.parse(output);
const files = entries[0]?.files?.map((f) => f.path) ?? [];
assert.ok(
files.some((f) => f.includes("scripts/check-abi.mjs")),
+4 -14
View File
@@ -19,7 +19,6 @@ import {
scanInternalLeaks,
} from "./internal-leak-scanner.mjs";
import { LEAK_GUARD_RULES_ENV, leakGuardRulesPath } from "./leak-guard-rules.mjs";
import { packEntries } from "./npm-pack-json.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const FIX = "Genericize the content, cite its public home, or re-home it to the internal pack root.";
@@ -156,22 +155,13 @@ function derivePackageArtifacts(packageRoot) {
if (packed.status !== 0) {
throw new Error(`npm artifact enumeration failed:\n${packed.stderr || packed.stdout || `exit ${packed.status}`}`);
}
let entries = [];
let parsed;
try {
// One parse, in the shared reader: a JSON string (even one holding a serialized list) is not a shape npm prints.
entries = packEntries(packed.stdout);
parsed = JSON.parse(packed.stdout);
} catch (error) {
if (error instanceof SyntaxError) throw new Error(`npm artifact enumeration did not return JSON: ${error.message}`);
// not a shape npm prints: reported below as no files array
throw new Error(`npm artifact enumeration did not return JSON: ${error.message}`);
}
// One package is packed here; scanning only the first of several would issue a receipt for part of the artifact.
if (entries.length > 1) {
throw new Error(
`npm artifact enumeration listed ${entries.length} packages where one was packed; `
+ "run the gate for one package (check --package-root and any npm workspaces setting)",
);
}
const rawFiles = entries[0]?.files;
const rawFiles = parsed?.[0]?.files;
if (!Array.isArray(rawFiles)) throw new Error("npm artifact enumeration returned no files array");
const files = rawFiles.map((entry) => entry?.path).filter((path) => typeof path === "string");
if (files.length !== rawFiles.length || files.length === 0) {
+3 -42
View File
@@ -5,7 +5,6 @@ import { execFileSync, spawnSync } from "node:child_process";
import {
existsSync,
mkdirSync,
chmodSync,
mkdtempSync,
readFileSync,
rmSync,
@@ -406,44 +405,6 @@ test("exact-line allowances reach content and derived scans and are recorded in
});
});
test("npm output that is a JSON string is refused, even when the string holds a serialized list", () => {
withFixture(({ root, repo, rules, review, receipt }) => {
write(join(repo, "public/guide.md"), "Generic product guidance.\n");
commitAll(repo, "string output fixture");
writeFileSync(review, JSON.stringify({ surfaces: [] }));
const bin = join(root, "bin");
const serialized = JSON.stringify([{ files: [{ path: "package.json" }, { path: "public/guide.md" }] }]);
write(join(bin, "npm"), `#!${process.execPath}\nprocess.stdout.write(${JSON.stringify(JSON.stringify(serialized))});\n`);
chmodSync(join(bin, "npm"), 0o755);
const result = runGate({ repo, rules, review, receipt, cutSha: gitHead(repo),
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } });
assert.notEqual(result.status, 0);
assert.match(result.stderr, /npm artifact enumeration returned no files array/);
});
});
test("npm output that lists more than one package is refused before any scan or receipt", () => {
const first = { name: "first", files: [{ path: "package.json" }, { path: "public/guide.md" }] };
const second = { name: "second", files: [{ path: "package.json" }, { path: "public/other.md" }] };
for (const printed of [[first, second], { first, second }]) {
withFixture(({ root, repo, rules, review, receipt }) => {
write(join(repo, "public/guide.md"), "Generic product guidance.\n");
commitAll(repo, "two package output fixture");
writeFileSync(review, JSON.stringify({ surfaces: [] }));
const bin = join(root, "bin");
write(join(bin, "npm"), `#!${process.execPath}\nprocess.stdout.write(${JSON.stringify(JSON.stringify(printed))});\n`);
chmodSync(join(bin, "npm"), 0o755);
const result = runGate({ repo, rules, review, receipt, cutSha: gitHead(repo),
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } });
assert.notEqual(result.status, 0);
assert.match(result.stderr, /npm artifact enumeration listed 2 packages where one was packed; run the gate for one package \(check --package-root and any npm workspaces setting\)/);
assert.equal(existsSync(receipt), false);
});
}
});
test("the release ceremony names the gate and its durable receipt", () => {
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
assert.equal(pkg.scripts["gate:substance"], "node scripts/check-substance-gate.mjs");
@@ -459,7 +420,7 @@ function writeGateHarness(root, source, invocationPath) {
mkdirSync(root, { recursive: true });
const gate = join(root, "check-substance-gate.mjs");
writeFileSync(gate, source);
for (const module of ["internal-leak-scanner.mjs", "leak-guard-rules.mjs", "npm-pack-json.mjs"]) {
for (const module of ["internal-leak-scanner.mjs", "leak-guard-rules.mjs"]) {
writeFileSync(join(root, module), readFileSync(resolve("scripts", module)));
}
writeFileSync(join(root, "check-internal-leak-guard.mjs"), [
@@ -513,7 +474,7 @@ function withFixture(run) {
}
}
function runGate({ repo, rules, review, receipt, cutSha, gate = GATE, env = process.env }) {
function runGate({ repo, rules, review, receipt, cutSha, gate = GATE }) {
return spawnSync(process.execPath, [
gate,
"--repo", repo,
@@ -524,7 +485,7 @@ function runGate({ repo, rules, review, receipt, cutSha, gate = GATE, env = proc
"--receipt", receipt,
"--judge", "dev-qa@fixture",
"--cut-sha", cutSha,
], { encoding: "utf8", env });
], { encoding: "utf8" });
}
function write(path, content) {
+5 -6
View File
@@ -4,7 +4,6 @@ import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { test } from "node:test";
import assert from "node:assert/strict";
import { packEntry } from "./npm-pack-json.mjs";
test("the CLI build carries the unchanged repository LICENSE and README into npm pack", () => {
const root = mkdtempSync(join(tmpdir(), "cli-license-"));
@@ -27,14 +26,14 @@ test("the CLI build carries the unchanged repository LICENSE and README into npm
execFileSync("npm", ["run", "build"], { cwd: cli, env, stdio: "pipe" });
const source = readFileSync(join(root, "LICENSE"));
assert.deepEqual(readFileSync(join(cli, "LICENSE")), source, "build must stage the repository license verbatim");
const packed = packEntry(execFileSync("npm", ["pack", "--ignore-scripts", "--json"], { cwd: cli, env, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }));
assert.ok(packed.files.some((file) => file.path === "LICENSE"), "npm archive must include LICENSE");
const bytes = execFileSync("tar", ["-xOf", join(cli, packed.filename), "package/LICENSE"]);
const packed = JSON.parse(execFileSync("npm", ["pack", "--ignore-scripts", "--json"], { cwd: cli, env, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }));
assert.ok(packed[0].files.some((file) => file.path === "LICENSE"), "npm archive must include LICENSE");
const bytes = execFileSync("tar", ["-xOf", join(cli, packed[0].filename), "package/LICENSE"]);
assert.deepEqual(bytes, source, "packed license must equal the existing root bytes");
// npm shows the package-root README on the registry page; without it the page reads "No README data found".
const readme = readFileSync(join(root, "README.md"));
assert.ok(packed.files.some((file) => file.path === "README.md"), "npm archive must include README.md");
const readmeBytes = execFileSync("tar", ["-xOf", join(cli, packed.filename), "package/README.md"]);
assert.ok(packed[0].files.some((file) => file.path === "README.md"), "npm archive must include README.md");
const readmeBytes = execFileSync("tar", ["-xOf", join(cli, packed[0].filename), "package/README.md"]);
assert.deepEqual(readmeBytes, readme, "packed README must equal the existing root bytes");
} finally {
rmSync(root, { recursive: true, force: true });
-25
View File
@@ -1,25 +0,0 @@
// `npm pack --json` prints an array of entries up to npm 11 and, from npm 12, an object keyed by package name
// ("the --json output of `npm pack` and `npm publish` have changed",
// https://github.com/npm/cli/releases/tag/v12.0.0). Every reader of that output goes through here, so both shapes
// give the same entries.
/** The entries `npm pack --json` printed, in order, from its output text or its parsed value. */
export function packEntries(output) {
const parsed = typeof output === "string" ? JSON.parse(output) : output;
const entries = Array.isArray(parsed)
? parsed
: parsed !== null && typeof parsed === "object" ? Object.values(parsed) : null;
if (!entries || !entries.every((entry) => entry !== null && typeof entry === "object" && !Array.isArray(entry))) {
throw new Error("npm pack --json printed neither an array of entries nor an object of per-package entries");
}
return entries;
}
/** The one entry of a `npm pack --json` run that packed one package; any other count throws. */
export function packEntry(output) {
const entries = packEntries(output);
if (entries.length !== 1) {
throw new Error(`npm pack --json printed ${entries.length} entries where one package was packed`);
}
return entries[0];
}
-35
View File
@@ -1,35 +0,0 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { packEntries, packEntry } from "./npm-pack-json.mjs";
const entry = { id: "@openrig/[email protected]", name: "@openrig/cli", version: "1.2.3", filename: "openrig-cli-1.2.3.tgz",
files: [{ path: "LICENSE" }, { path: "package.json" }] };
test("npm 10 and 11: an array of entries", () => {
assert.deepEqual(packEntries(JSON.stringify([entry])), [entry]);
assert.deepEqual(packEntry(JSON.stringify([entry])), entry);
});
test("npm 12: an object of entries keyed by package name", () => {
assert.deepEqual(packEntries(JSON.stringify({ "@openrig/cli": entry })), [entry]);
assert.deepEqual(packEntry(JSON.stringify({ "@openrig/cli": entry })), entry);
});
test("both shapes keep every entry in order, and a parsed value reads like its text", () => {
const other = { ...entry, name: "@openrig/acp", filename: "openrig-acp-1.2.3.tgz" };
assert.deepEqual(packEntries([entry, other]), [entry, other]);
assert.deepEqual(packEntries({ "@openrig/cli": entry, "@openrig/acp": other }), [entry, other]);
});
test("packEntry refuses any count but one", () => {
assert.throws(() => packEntry("[]"), /0 entries/);
assert.throws(() => packEntry("{}"), /0 entries/);
assert.throws(() => packEntry(JSON.stringify([entry, entry])), /2 entries/);
});
test("output that is neither shape throws", () => {
for (const output of ["null", "\"text\"", "3", JSON.stringify(entry), JSON.stringify([[entry]]), JSON.stringify({ a: null })]) {
assert.throws(() => packEntries(output), /neither an array of entries nor an object of per-package entries/, output);
}
assert.throws(() => packEntries("not json"), SyntaxError);
});
+2 -3
View File
@@ -8,7 +8,6 @@ import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { packEntry } from './npm-pack-json.mjs';
const LISTING = ['packages/acp/registry/agent.json', 'packages/acp/registry/icon.svg'];
const readJson = (root, rel) => JSON.parse(readFileSync(join(root, rel), 'utf8'));
@@ -36,8 +35,8 @@ export function packRelease(root, dest) {
mkdirSync(dest, { recursive: true });
// The CLI is packed as the release producer has packed it; the launcher isn't a workspace, so it's packed by path.
// --ignore-scripts skips pack's prepack and postpack, but npm 10 still runs a `prepare`; neither package has one.
const pack = target => packEntry(execFileSync('npm', ['pack', ...target, '--ignore-scripts', '--json', '--pack-destination', dest],
{ cwd: root, encoding: 'utf8' }));
const pack = target => JSON.parse(execFileSync('npm', ['pack', ...target, '--ignore-scripts', '--json', '--pack-destination', dest],
{ cwd: root, encoding: 'utf8' }))[0];
const artifacts = [pack(['--workspace', 'packages/cli']), pack(['./packages/acp'])].map(packed => {
const path = join(dest, packed.filename);
return { name: packed.name, version: packed.version, path, size: packed.size, sha256: sha256(path), integrity: packed.integrity };
+3 -4
View File
@@ -4,7 +4,6 @@ import os from "node:os";
import path from "node:path";
import { execFileSync, spawn, spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { packEntry } from "./npm-pack-json.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const cli = path.join(root, "packages", "cli");
@@ -48,8 +47,8 @@ async function assertDelayedSplits(entry, pkgDir) {
try {
execFileSync("npm", ["run", "build:package"], { cwd: root, stdio: "inherit" });
const packed = packEntry(execFileSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", temp], { cwd: cli, encoding: "utf8" }));
const tarball = path.join(temp, packed.filename);
const packed = JSON.parse(execFileSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", temp], { cwd: cli, encoding: "utf8" }));
const tarball = path.join(temp, packed[0].filename);
execFileSync("tar", ["-xzf", tarball, "-C", temp]);
const pkgDir = path.join(temp, "package");
@@ -69,7 +68,7 @@ try {
});
assert.equal(launched.status, 0, launched.stderr || `packed TUI exited ${launched.status}`);
await assertDelayedSplits(entry, pkgDir);
console.log(`packed TUI launch PASS: ${packed.filename}`);
console.log(`packed TUI launch PASS: ${packed[0].filename}`);
} finally {
fs.rmSync(temp, { recursive: true, force: true });
}