run402 4.17.8 → 4.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -63,6 +63,8 @@ run402 allowance export # print {"address":"0x..."} for funding
63
63
 
64
64
  ### Public Buzz/Nostr identity attribution
65
65
 
66
+ Human accounts connect through <https://console.run402.com/identity-links/connect>: a normal browser, fresh passkey, and Buzz approval, with no terminal/event/passkey credential handling. The CLI commands below are the agent EOA ceremony. `identity link list` preserves every active/revoked record and its proof protocol; public identity links and organization memberships are independently revocable.
67
+
66
68
  ```bash
67
69
  run402 identity link nostr begin --pubkey <npub-or-hex> --visibility public
68
70
  # Publish the returned proof_content as a standalone Buzz kind-1 event.
package/cli.mjs CHANGED
@@ -45,6 +45,8 @@ Commands:
45
45
  grants Per-project capability grants for agent/CI principals (create, revoke)
46
46
  delegates Scoped deploy credentials for agents (create, list, revoke, rotate)
47
47
  events What happened to your project since you last looked (cursored feed)
48
+ rooms Coordinate with the other agents on your project (who/send/ack)
49
+ claims Say what you're working on before you collide (advisory)
48
50
  errors Grouped error fingerprints + a promote/revert verdict (release-baselined)
49
51
  jobs Submit and inspect platform-managed jobs
50
52
  functions Manage serverless functions (deploy, invoke, logs, list, delete)
@@ -273,6 +275,16 @@ switch (cmd) {
273
275
  await run(sub, rest);
274
276
  break;
275
277
  }
278
+ case "rooms": {
279
+ const { run } = await import("./lib/rooms.mjs");
280
+ await run(sub, rest);
281
+ break;
282
+ }
283
+ case "claims": {
284
+ const { run } = await import("./lib/claims.mjs");
285
+ await run(sub, rest);
286
+ break;
287
+ }
276
288
  case "errors": {
277
289
  const { run } = await import("./lib/errors.mjs");
278
290
  await run(sub, rest);
package/lib/buzz.mjs CHANGED
@@ -22,6 +22,9 @@ The four independent states are:
22
22
  Canonical workflows:
23
23
  run402 buzz status
24
24
  run402 buzz adopt offer --org <org_id> --identity-link <idlnk_id> [--deployment-context-file <json>]
25
+ --org the Run402 organization id as "run402 org whoami" returns it (a UUID)
26
+ --deployment-context-file JSON with exactly these five non-empty strings, and no others:
27
+ project_id, release_id, live_url, source_revision, verified_at
25
28
  run402 buzz install --org <org_id> --community <buzz:community:host> --authority <hex-pubkey>
26
29
  run402 buzz enroll --installation <buzzci_id> --identity-link <idlnk_id> --grants-file <json> --expires-at <ISO-8601>
27
30
 
@@ -76,6 +79,9 @@ function print(result) {
76
79
  if (result?.status === "pending") {
77
80
  console.error("Buzz control-plane state is pending; follow the single next_actions entry in the JSON response.");
78
81
  }
82
+ if (result?.status === "completed" && result?.completed_buzz_human_adoption) {
83
+ console.error("Completed: Run402 recorded a terminal consent receipt. The public identity attribution does not grant organization authority; the ordinary owner membership is the only source of organization authority. The identity link and membership can be revoked independently, while the receipt remains completed.");
84
+ }
79
85
  }
80
86
 
81
87
  async function invoke(operation) {
package/lib/buzz.test.mjs CHANGED
@@ -120,6 +120,39 @@ describe("run402 buzz CLI", () => {
120
120
  assert.deepEqual(authModes, ["wallet", "wallet"]);
121
121
  });
122
122
 
123
+ it("explains completed receipt, public identity attribution, and membership as independent effects", async () => {
124
+ sdk = {
125
+ buzz: {
126
+ humanAdoptionOffers: {
127
+ get: async () => ({
128
+ status: "completed",
129
+ completed_buzz_human_adoption: {
130
+ status: "completed",
131
+ consent_receipt: { status: "completed" },
132
+ public_identity_attribution: {
133
+ human_identity_link_id: `idlnk_${"1".repeat(32)}`,
134
+ authority_for_organization: false,
135
+ revoke_independently: true,
136
+ },
137
+ organization_authority: {
138
+ membership_id: `org_${"2".repeat(32)}:prin_human`,
139
+ role: "owner",
140
+ source: "org_membership",
141
+ revoke_independently: true,
142
+ },
143
+ },
144
+ }),
145
+ },
146
+ },
147
+ };
148
+ await run("adopt", ["offer", "show", `buzzhao_${"3".repeat(32)}`]);
149
+ assert.equal(JSON.parse(stdout[0]).status, "completed");
150
+ assert.match(stderr.join("\n"), /terminal consent receipt/i);
151
+ assert.match(stderr.join("\n"), /public identity attribution.*does not grant organization authority/i);
152
+ assert.match(stderr.join("\n"), /ordinary owner membership.*only source of organization authority/i);
153
+ assert.match(stderr.join("\n"), /revoke.*independently/i);
154
+ });
155
+
123
156
  it("maps a Honey enrollment file to the goal-shaped SDK call and prints only JSON", async () => {
124
157
  const directory = mkdtempSync(join(tmpdir(), "run402-buzz-cli-"));
125
158
  const grantsPath = join(directory, "grants.json");
package/lib/claims.mjs ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * `run402 claims` — advisory work claims for agent coordination.
3
+ *
4
+ * Gateway subsystem: add-agent-messaging (/orgs/v1/:org_id/rooms/:room_key/claims).
5
+ * A claim declares "I'm working on X until T" so concurrent agents discover
6
+ * collisions BEFORE they happen. Claims are ADVISORY: creating a conflicting
7
+ * claim succeeds and reports the complete conflict set — nothing, ever, is
8
+ * blocked by a claim (deploys included). JSON to stdout (pipe contract).
9
+ */
10
+ import { getSdk } from "./sdk.mjs";
11
+ import { reportSdkError } from "./sdk-errors.mjs";
12
+ import {
13
+ normalizeArgv,
14
+ hasHelp,
15
+ assertKnownFlags,
16
+ assertAllowedValue,
17
+ parseIntegerFlag,
18
+ flagValue,
19
+ positionalArgs,
20
+ requirePositionalCount,
21
+ failUnknownSubcommand,
22
+ } from "./argparse.mjs";
23
+ import { resolveRoom, withPresenceRetry } from "./rooms-context.mjs";
24
+
25
+ export const MODES = ["exclusive", "shared"];
26
+
27
+ const ROOM_FLAGS = ["--project", "--org", "--room"];
28
+
29
+ const HELP = `run402 claims — say what you're working on before you collide
30
+
31
+ Usage:
32
+ run402 claims create <resource> [--mode exclusive|shared] [--ttl <seconds>]
33
+ run402 claims list [--all]
34
+ run402 claims release <claim_id>
35
+
36
+ Resources (namespaced; conflicts never cross namespaces):
37
+ repo:<glob> Repo paths — repo:src/auth/** (glob overlap detection)
38
+ function:<name> A deployed function by name
39
+ table:<name> A database table
40
+ deploy The deploy itself (a soft mutex by convention)
41
+ <free-form> Anything else (exact-match overlap)
42
+
43
+ Advisory, always:
44
+ Creating a conflicting claim SUCCEEDS — the response carries the complete
45
+ conflicts[] (holder, resource, mode, expiry) and the deploy-path responses
46
+ surface other agents' claims automatically. A claim never blocks anything;
47
+ it makes collisions visible before they happen. Claims auto-expire (default
48
+ 1h, max 24h) so a dead session cannot wedge the room; <=32 active per
49
+ presence.
50
+
51
+ Room addressing:
52
+ Default room of the active project with no flags; --project <id> for another
53
+ project; --org <org_id> --room <key> (or RUN402_ROOM=<org_id>/<key>) for a
54
+ named org room.
55
+
56
+ Options:
57
+ --mode <m> exclusive (default) — one worker; shared — parallel-safe.
58
+ --ttl <seconds> Claim lifetime (default 3600, max 86400).
59
+ --note <text> Why you're claiming it (shown to everyone).
60
+ --all list: include released/expired history.
61
+
62
+ Tip: claim before you edit, release when you hand off — and put the handoff
63
+ in \`run402 rooms send\` so the room's timeline tells the story.
64
+
65
+ Examples:
66
+ run402 claims create repo:src/auth/** --note "migrating to passkeys"
67
+ run402 claims create deploy --ttl 900
68
+ run402 claims list
69
+ run402 claims release clm_1a
70
+ `;
71
+
72
+ async function create(args) {
73
+ const a = normalizeArgv(args);
74
+ const valueFlags = [...ROOM_FLAGS, "--mode", "--ttl", "--note"];
75
+ assertKnownFlags(a, [...valueFlags, "--help", "-h"], valueFlags);
76
+ const positionals = positionalArgs(a, valueFlags);
77
+ requirePositionalCount(positionals, valueFlags, {
78
+ min: 1, max: 1, command: "run402 claims create <resource>", missing: "<resource>",
79
+ });
80
+ const mode = flagValue(a, "--mode") ?? "exclusive";
81
+ assertAllowedValue(mode, MODES, "--mode");
82
+ const ttl = flagValue(a, "--ttl");
83
+ const room = await resolveRoom({
84
+ org: flagValue(a, "--org"), room: flagValue(a, "--room"), project: flagValue(a, "--project"),
85
+ });
86
+ try {
87
+ const created = await withPresenceRetry(room.orgId, room.roomKey, (presenceId) =>
88
+ getSdk().rooms.createClaim(room.orgId, room.roomKey, {
89
+ resource: positionals[0],
90
+ mode,
91
+ ttlSeconds: ttl != null ? parseIntegerFlag("--ttl", ttl, { min: 1, max: 86400 }) : undefined,
92
+ note: flagValue(a, "--note") ?? undefined,
93
+ presenceId: presenceId ?? undefined,
94
+ }));
95
+ console.log(JSON.stringify(created, null, 2));
96
+ const conflicts = Array.isArray(created.conflicts) ? created.conflicts : [];
97
+ if (conflicts.length > 0) {
98
+ console.error(`Granted with ${conflicts.length} conflict(s) — advisory, nothing is blocked. Coordinate via run402 rooms send.`);
99
+ }
100
+ } catch (err) {
101
+ reportSdkError(err);
102
+ }
103
+ }
104
+
105
+ async function list(args) {
106
+ const a = normalizeArgv(args);
107
+ assertKnownFlags(a, [...ROOM_FLAGS, "--all", "--help", "-h"], ROOM_FLAGS);
108
+ requirePositionalCount(positionalArgs(a, ROOM_FLAGS), ROOM_FLAGS, {
109
+ min: 0, max: 0, command: "run402 claims list", missing: "",
110
+ });
111
+ const room = await resolveRoom({
112
+ org: flagValue(a, "--org"), room: flagValue(a, "--room"), project: flagValue(a, "--project"),
113
+ });
114
+ try {
115
+ const page = await getSdk().rooms.listClaims(room.orgId, room.roomKey, {
116
+ includeInactive: a.includes("--all"),
117
+ });
118
+ console.log(JSON.stringify(page, null, 2));
119
+ } catch (err) {
120
+ reportSdkError(err);
121
+ }
122
+ }
123
+
124
+ async function release(args) {
125
+ const a = normalizeArgv(args);
126
+ assertKnownFlags(a, [...ROOM_FLAGS, "--help", "-h"], ROOM_FLAGS);
127
+ const positionals = positionalArgs(a, ROOM_FLAGS);
128
+ requirePositionalCount(positionals, ROOM_FLAGS, {
129
+ min: 1, max: 1, command: "run402 claims release <claim_id>", missing: "<claim_id>",
130
+ });
131
+ const room = await resolveRoom({
132
+ org: flagValue(a, "--org"), room: flagValue(a, "--room"), project: flagValue(a, "--project"),
133
+ });
134
+ try {
135
+ console.log(JSON.stringify(await getSdk().rooms.releaseClaim(room.orgId, room.roomKey, positionals[0]), null, 2));
136
+ } catch (err) {
137
+ reportSdkError(err);
138
+ }
139
+ }
140
+
141
+ export async function run(sub, args) {
142
+ const argv = Array.isArray(args) ? args : [];
143
+ if (!sub || hasHelp([sub, ...argv])) {
144
+ console.log(HELP);
145
+ process.exit(0);
146
+ }
147
+ switch (sub) {
148
+ case "create": {
149
+ await create(argv);
150
+ break;
151
+ }
152
+ case "list": {
153
+ await list(argv);
154
+ break;
155
+ }
156
+ case "release": {
157
+ await release(argv);
158
+ break;
159
+ }
160
+ default:
161
+ failUnknownSubcommand("claims", sub, {
162
+ hint: "Run `run402 claims --help` for usage.",
163
+ });
164
+ }
165
+ }
@@ -200,6 +200,14 @@ export const COMMAND_MANIFEST = [
200
200
 
201
201
  // ── events / errors (flat, merged runners) ───────────────────────────────
202
202
  { path: ["events"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "merged" },
203
+ { path: ["rooms", "who"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
204
+ { path: ["rooms", "send"], positionals: [p("body")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["hello"], runStyle: "sub" },
205
+ { path: ["rooms", "list"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
206
+ { path: ["rooms", "get"], positionals: [p("message_id")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["msg_1"], runStyle: "sub" },
207
+ { path: ["rooms", "ack"], positionals: [p("message_id")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["msg_1"], runStyle: "sub" },
208
+ { path: ["claims", "create"], positionals: [p("resource")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["deploy"], runStyle: "sub" },
209
+ { path: ["claims", "list"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
210
+ { path: ["claims", "release"], positionals: [p("claim_id")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["clm_1"], runStyle: "sub" },
203
211
  { path: ["errors"], positionals: [p("fingerprint_id", { required: false })], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "merged" },
204
212
 
205
213
  // ── jobs ─────────────────────────────────────────────────────────────────
package/lib/identity.mjs CHANGED
@@ -3,7 +3,7 @@ import { getSdk } from "./sdk.mjs";
3
3
  import { reportSdkError, fail } from "./sdk-errors.mjs";
4
4
  import { normalizeArgv, assertKnownFlags, flagValue, requirePositionalCount, failUnknownSubcommand } from "./argparse.mjs";
5
5
 
6
- const HELP = `run402 identity link — public proof-backed external agent identities
6
+ const HELP = `run402 identity link — public proof-backed external identities
7
7
 
8
8
  Usage:
9
9
  run402 identity link nostr begin --pubkey <npub|hex> --visibility public [--idempotency-key <key>]
@@ -14,8 +14,15 @@ Usage:
14
14
  run402 identity link revoke <identity_link_id>
15
15
 
16
16
  Security and disclosure:
17
+ - Human linking is a browser/passkey/Buzz ceremony. Open
18
+ https://console.run402.com/identity-links/connect; do not paste a signed
19
+ event, passkey, session, private key, or resource id into the CLI.
20
+ - A human identity link and an organization membership are independent:
21
+ either can be revoked without implicitly revoking the other.
22
+ - list uses the active CLI identity: an agent wallet when present, otherwise
23
+ the signed-in human control-plane session.
17
24
  - begin publishes a standalone public kind-1 Nostr event and creates a durable
18
- public run402 proof. Revocation does not erase either historical proof.
25
+ public run402 proof for the agent. Revocation does not erase either historical proof.
19
26
  - the Nostr key and run402 wallet stay separate. This command never accepts,
20
27
  derives, reads, or prints an nsec, Nostr private key, mnemonic, or seed.
21
28
  - the wallet, agent pubkey, and optional Buzz NIP-OA owner attestation become
@@ -84,7 +91,7 @@ async function list(args) {
84
91
  const a = normalizeArgv(args);
85
92
  assertKnownFlags(a, ["--help", "-h"]);
86
93
  requirePositionalCount(a, [], { min: 0, max: 0, command: "run402 identity link list" });
87
- try { console.log(JSON.stringify(await getSdk({ authMode: "wallet" }).identityLinks.list(), null, 2)); }
94
+ try { console.log(JSON.stringify(await getSdk().identityLinks.list(), null, 2)); }
88
95
  catch (error) { reportSdkError(error); }
89
96
  }
90
97
 
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Shared room resolution + per-checkout session state for the agent-messaging
3
+ * commands (`run402 messages`, `run402 claims`).
4
+ *
5
+ * Room addressing (precedence):
6
+ * --org <org_id> + --room <key> explicit (named org rooms)
7
+ * RUN402_ROOM=<org_id>/<key> env form of the same
8
+ * (default) the project's DEFAULT room — the room key
9
+ * IS the project id; org resolved via the
10
+ * project overview (`rooms.forProject`).
11
+ *
12
+ * Session state lives at ./.run402/messaging.json (per-checkout — the house
13
+ * discipline of one worktree per agent session makes per-checkout equal
14
+ * per-session). It caches, per room: the session's presence_id + name and the
15
+ * read cursor. Add `.run402/` to .gitignore. Two sessions sharing one checkout
16
+ * share a presence (coherent, just undifferentiated); RUN402_PRESENCE_ID
17
+ * overrides for harnesses that multiplex.
18
+ */
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { fail } from "./sdk-errors.mjs";
22
+ import { resolveProjectId } from "./config.mjs";
23
+ import { getSdk } from "./sdk.mjs";
24
+
25
+ export const ROOM_ENV = "RUN402_ROOM";
26
+ export const PRESENCE_ENV = "RUN402_PRESENCE_ID";
27
+
28
+ const STATE_DIR = ".run402";
29
+ const STATE_FILE = "messaging.json";
30
+
31
+ /** Resolve the addressed room to { orgId, roomKey } (one SDK lookup at most). */
32
+ export async function resolveRoom({ org, room, project } = {}) {
33
+ if (room && !org) {
34
+ fail({
35
+ code: "BAD_USAGE",
36
+ message: "--room names an org room and needs --org <org_id> beside it.",
37
+ hint: "For the project's default room, omit both (or pass --project). For a named room: --org <org_id> --room <key>.",
38
+ });
39
+ }
40
+ if (org && room) return { orgId: org, roomKey: room };
41
+ const envRoom = (process.env[ROOM_ENV] ?? "").trim();
42
+ if (envRoom) {
43
+ const slash = envRoom.indexOf("/");
44
+ if (slash <= 0 || slash === envRoom.length - 1) {
45
+ fail({
46
+ code: "BAD_USAGE",
47
+ message: `${ROOM_ENV} must be "<org_id>/<room_key>".`,
48
+ details: { value: envRoom },
49
+ });
50
+ }
51
+ return { orgId: envRoom.slice(0, slash), roomKey: envRoom.slice(slash + 1) };
52
+ }
53
+ const projectId = resolveProjectId(project);
54
+ const scoped = await getSdk().rooms.forProject(projectId);
55
+ return { orgId: scoped.orgId, roomKey: scoped.roomKey };
56
+ }
57
+
58
+ function statePath() {
59
+ return join(process.cwd(), STATE_DIR, STATE_FILE);
60
+ }
61
+
62
+ function loadState() {
63
+ try {
64
+ const raw = readFileSync(statePath(), "utf8");
65
+ const parsed = JSON.parse(raw);
66
+ return parsed && typeof parsed === "object" && parsed.rooms && typeof parsed.rooms === "object"
67
+ ? parsed
68
+ : { rooms: {} };
69
+ } catch {
70
+ return { rooms: {} };
71
+ }
72
+ }
73
+
74
+ function saveState(state) {
75
+ try {
76
+ const dir = join(process.cwd(), STATE_DIR);
77
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
78
+ writeFileSync(statePath(), `${JSON.stringify(state, null, 2)}\n`);
79
+ } catch {
80
+ // Best-effort cache: an unwritable checkout degrades to stateless calls.
81
+ }
82
+ }
83
+
84
+ const roomStateKey = (orgId, roomKey) => `${orgId}/${roomKey}`;
85
+
86
+ export function getRoomState(orgId, roomKey) {
87
+ return loadState().rooms[roomStateKey(orgId, roomKey)] ?? {};
88
+ }
89
+
90
+ export function updateRoomState(orgId, roomKey, patch) {
91
+ const state = loadState();
92
+ const key = roomStateKey(orgId, roomKey);
93
+ state.rooms[key] = { ...(state.rooms[key] ?? {}), ...patch };
94
+ saveState(state);
95
+ }
96
+
97
+ /** The session's presence id for a room: env override, else the checkout cache. */
98
+ export function cachedPresenceId(orgId, roomKey) {
99
+ const env = (process.env[PRESENCE_ENV] ?? "").trim();
100
+ if (env) return env;
101
+ const cached = getRoomState(orgId, roomKey).presence_id;
102
+ return typeof cached === "string" && cached ? cached : null;
103
+ }
104
+
105
+ /**
106
+ * Run a room call that carries the cached presence. On PRESENCE_EXPIRED (410 —
107
+ * this session's presence aged out), drop the cache and retry ONCE without a
108
+ * presence_id so the gateway's resolve-or-create issues a fresh one.
109
+ */
110
+ export async function withPresenceRetry(orgId, roomKey, call) {
111
+ const presenceId = cachedPresenceId(orgId, roomKey);
112
+ try {
113
+ return await call(presenceId);
114
+ } catch (err) {
115
+ const code = err?.body?.code ?? err?.code;
116
+ if (code === "PRESENCE_EXPIRED" && presenceId) {
117
+ updateRoomState(orgId, roomKey, { presence_id: null, name: null });
118
+ return call(null);
119
+ }
120
+ throw err;
121
+ }
122
+ }
123
+
124
+ /** Cache the presence a response attributed this session to. */
125
+ export function rememberPresence(orgId, roomKey, presence) {
126
+ if (!presence || typeof presence !== "object") return;
127
+ const id = presence.presence_id;
128
+ const name = presence.name;
129
+ if (typeof id === "string" && id) {
130
+ updateRoomState(orgId, roomKey, { presence_id: id, ...(typeof name === "string" ? { name } : {}) });
131
+ }
132
+ }