@decidio/sdk 0.1.9 → 0.1.10

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/dist/cli.js CHANGED
@@ -1,25 +1,7 @@
1
1
  #!/usr/bin/env node
2
- // @decidio/sdk CLI — the on-ramp. Run via `npx @decidio/sdk <cmd>` (or tsx in-repo).
3
- // init [agentId] sign in, register the agent, mint its API token, write .env
4
- // doctor check config + connectivity + durability readiness
5
- // approvals list pending agent approvals; `approvals approve|reject <id> [reason]`
6
- // receipt <id> download a sealed decision's Authority Receipt to a .json file
7
- // dev [--target U] signal-only relay: forwards Decidio's webhook to your local agent URL
8
- //
9
- // TWO TOKEN KINDS, deliberately (self-serve design 2026-08-28): commands that ADMINISTER a
10
- // workspace (init's register+mint, approvals, receipt) need the owner's SESSION token — acquired
11
- // interactively per run or from DECIDIO_SESSION_TOKEN, held in memory, NEVER written to disk.
12
- // What lands in .env as DECIDIO_API_TOKEN is the floor-limited AGENT token the runtime SDK
13
- // needs (it can call /agent/* and nothing else). A session-capable secret must never live in
14
- // an agent host's .env — that was the pre-publish design's hole.
15
- //
16
- // MESSAGE DISCIPLINE (founder bar 2026-08-29, from the Codex acceptance run): every failure
17
- // tells the developer WHAT happened, WHY, and the EXACT next command. Every success says what
18
- // changed, where, and what to do next. No stack traces for expected failures; no green ✔ for a
19
- // no-op (the replay-approve bug: the server's idempotent alreadyResolved was reported as a
20
- // fresh success — an authority CLI may never dress a no-op as an action).
21
2
  import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync } from "node:fs";
22
3
  import { randomBytes } from "node:crypto";
4
+ import { userInfo } from "node:os";
23
5
  import { createServer } from "node:http";
24
6
  import { execFileSync } from "node:child_process";
25
7
  import { createInterface } from "node:readline";
@@ -35,11 +17,6 @@ const VERSION = (() => {
35
17
  return "unknown";
36
18
  }
37
19
  })();
38
- // ---- environment: load the CWD .env FIRST (Codex finding: init writes .env, but the next
39
- // command ignored it and pointed at localhost). Process env always wins — .env only fills gaps.
40
- // NEVER a crash: an unreadable .env (EPERM, EISDIR, a mangled volume) is that file's problem,
41
- // not this command's — warn on stderr and continue (round-9 catch: it was an unhandled Node
42
- // stack trace).
43
20
  let envWarned = false;
44
21
  function readEnvFile() {
45
22
  const out = {};
@@ -59,15 +36,9 @@ function readEnvFile() {
59
36
  }
60
37
  return out;
61
38
  }
62
- // OFFLINE commands never touch .env at all (round-9 catch: `verify` is cryptographically
63
- // offline and needs no credentials, yet the autoload coupled it to the directory's .env —
64
- // and the help text's "every command talks to the hosted sandbox" was wrong for it).
65
39
  const OFFLINE_CMD = ["verify", "--version", "-v", "-V", "version", "--help", "-h", "help", undefined].includes(process.argv[2]);
66
40
  if (!OFFLINE_CMD)
67
41
  for (const [k, v] of Object.entries(readEnvFile())) {
68
- // NEVER autoload the session token from disk: the whole design splits session (memory-only)
69
- // from the floor-limited agent token (.env). Silently honoring a parked session token would
70
- // reward exactly the credential-at-rest the split exists to prevent (guard catch 2026-08-29).
71
42
  if (k === "DECIDIO_SESSION_TOKEN") {
72
43
  console.error("⚠ ignoring DECIDIO_SESSION_TOKEN found in .env — session tokens belong in your shell or CI secret store, never on disk. Remove that line; export it in the shell instead.");
73
44
  continue;
@@ -75,32 +46,22 @@ if (!OFFLINE_CMD)
75
46
  if (process.env[k] === undefined)
76
47
  process.env[k] = v;
77
48
  }
78
- // SANDBOX-FIRST (blind-review catch, founder-ruled): every command defaults to the hosted sandbox,
79
- // because that is the product people actually receive — the old localhost default was our own dev
80
- // environment leaking into the package, and it broke the tour's own follow-up commands the moment
81
- // cleanup removed .env. Explicit config (env or .env) always wins; the target host is announced on
82
- // stderr so no command ever talks to a server silently.
83
49
  const API = (process.env.DECIDIO_API_URL ?? "https://decidio-api.onrender.com").replace(/\/$/, "");
84
- // Token is read AT CALL TIME, not captured at import: init mints a fresh agent token mid-run and
85
- // its closing doctor must probe with THAT, not the pre-init env (round-3 Codex catch — the stale
86
- // capture made init's own doctor print "token: REJECTED" seconds after a successful mint).
87
50
  const TOKEN = () => process.env.DECIDIO_API_TOKEN;
88
51
  const ENV_PATH = ".env";
89
52
  const log = (...a) => console.log(...a);
90
- // Errors and usage guidance go to STDERR (blind-review catch: error text on stdout corrupts every
91
- // `--json | jq` pipeline — and any consumer of a CLI's stdout). Verdict output (verify's VALID/
92
- // INVALID lines) is the product and stays on stdout.
93
53
  const err = (...a) => console.error(...a);
94
- /** Fetch wrapper that turns transport failures into guidance instead of a stack trace. */
95
54
  let hostAnnounced = false;
96
55
  async function api(path, init = {}, bearer) {
97
56
  if (!hostAnnounced) {
98
57
  hostAnnounced = true;
99
- // Once per process, on stderr: WHICH server this command talks to (sandbox-first means a
100
- // missing config no longer fails loud, so the target must never be silent instead).
101
58
  err(`· decidio → ${API}${process.env.DECIDIO_API_URL ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
102
59
  }
103
60
  const auth = bearer ?? TOKEN();
61
+ if (auth && /^http:[/][/]/i.test(API) && !/^http:[/][/](localhost|127.0.0.1|.::1.)([:/]|$)/i.test(API)) {
62
+ err(`✘ refusing to send credentials over plaintext http to ${API} — use https, or localhost for local dev. Nothing was sent.`);
63
+ process.exit(1);
64
+ }
104
65
  let res;
105
66
  try {
106
67
  res = await fetch(API + path, { ...init, headers: { "content-type": "application/json", ...(auth ? { authorization: `Bearer ${auth}` } : {}), ...(init.headers ?? {}) } });
@@ -127,32 +88,30 @@ async function api(path, init = {}, bearer) {
127
88
  }
128
89
  return { status: res.status, body };
129
90
  }
130
- /** Write a secrets file OWNER-ONLY (0600). `mode` applies on creation; an existing file keeps its
131
- * old permissions, so chmod as well. Best-effort: Windows ignores POSIX modes (its ACLs are
132
- * inherited from the directory), and a chmod failure must never lose the write. */
133
91
  function writeSecretFile(path, body) {
134
92
  writeFileSync(path, body, { mode: 0o600 });
135
93
  try {
136
94
  chmodSync(path, 0o600);
137
95
  }
138
- catch { /* restricted FS — the write itself stands */ }
96
+ catch { }
139
97
  if (process.platform === "win32") {
140
- // POSIX modes are a no-op on NTFS (round-8 verified the file kept inherited Modify ACLs).
141
- // Do it the Windows way: strip inheritance, grant only the current user. Best-effort — a
142
- // failure never loses the write, but it is SAID, not swallowed.
143
98
  try {
144
- execFileSync("icacls", [path, "/inheritance:r", "/grant:r", `${process.env.USERNAME ?? process.env.USER ?? ""}:F`], { stdio: "ignore" });
99
+ execFileSync("icacls", [path, "/inheritance:r", "/grant:r", `${effectiveUser()}:F`], { stdio: "ignore" });
145
100
  }
146
101
  catch {
147
102
  console.warn(`⚠ could not restrict ${path} to owner-only on this Windows volume — it keeps the directory's inherited permissions. For production, move these values to a secret manager.`);
148
103
  }
149
104
  }
150
105
  }
106
+ function effectiveUser() {
107
+ try {
108
+ return userInfo().username;
109
+ }
110
+ catch {
111
+ return process.env.USERNAME ?? process.env.USER ?? "";
112
+ }
113
+ }
151
114
  function upsertEnv(updates) {
152
- // Update IN PLACE, never rewrite (round-2 blind catch: the merge-and-rewrite dropped every
153
- // comment and blank line — "# Production database — do not touch" vanished unrecoverably,
154
- // since this file is gitignored). A user's line only changes when its KEY is being updated;
155
- // everything else survives byte-for-byte; new keys append.
156
115
  const remaining = { ...updates };
157
116
  const updatedKeys = new Set();
158
117
  const lines = [];
@@ -160,8 +119,6 @@ function upsertEnv(updates) {
160
119
  if (existsSync(ENV_PATH))
161
120
  for (const line of readFileSync(ENV_PATH, "utf8").split(/\r?\n/)) {
162
121
  const m = line.match(/^\s*([A-Z_]+)=/);
163
- // A hand-edited duplicate of an updated key is DROPPED, not kept (guard note: last-wins on
164
- // the next read would resurrect the stale value — e.g. an old token — over the fresh one).
165
122
  if (m && updatedKeys.has(m[1]))
166
123
  continue;
167
124
  if (m && m[1] in remaining) {
@@ -173,18 +130,14 @@ function upsertEnv(updates) {
173
130
  lines.push(line);
174
131
  }
175
132
  }
176
- catch { /* unreadable existing file: start fresh below — the write still lands */ }
133
+ catch { }
177
134
  while (lines.length && lines[lines.length - 1] === "")
178
135
  lines.pop();
179
136
  for (const [k, v] of Object.entries(remaining))
180
137
  lines.push(`${k}=${v}`);
181
138
  writeSecretFile(ENV_PATH, lines.join("\n") + "\n");
182
139
  }
183
- /** Keep secrets out of git without nagging: append the two entries to .gitignore when they're
184
- * not already covered. Creating the file in a non-git directory is harmless. */
185
140
  function ensureGitignore() {
186
- // Every file this CLI may write that can hold a private key — including the `--force` backup
187
- // (.env.<agent>.bak), which git's plain `.env` pattern does NOT match.
188
141
  const entries = [".env", ".env.quickstart", ".env.*.bak", ".decidio-pending/"];
189
142
  const existing = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
190
143
  const missing = entries.filter((e) => !existing.split(/\r?\n/).some((l) => l.trim() === e || l.trim() === e.replace(/\/$/, "")));
@@ -193,29 +146,20 @@ function ensureGitignore() {
193
146
  appendFileSync(".gitignore", (existing && !existing.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n");
194
147
  log(`✔ added ${missing.join(" + ")} to .gitignore (your agent key and parked actions stay out of git)`);
195
148
  }
196
- // ---- interactive input (session sign-in) ---------------------------------------------------
197
149
  function ask(question, mute = false) {
198
150
  return new Promise((resolve) => {
199
- const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
151
+ const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
200
152
  if (mute) {
201
- // Hide password echo: after the prompt renders, swallow subsequent output writes.
202
153
  const anyRl = rl;
203
154
  const write = anyRl._writeToOutput?.bind(rl);
204
155
  anyRl._writeToOutput = (s) => { if (s.includes(question))
205
156
  write?.(s); };
206
157
  }
207
158
  rl.question(question, (answer) => { rl.close(); if (mute)
208
- process.stdout.write("\n"); resolve(answer.trim()); });
159
+ process.stderr.write("\n"); resolve(answer.trim()); });
209
160
  });
210
161
  }
211
- /** Owner SESSION token, memory-only. Sources in order: DECIDIO_SESSION_TOKEN env, interactive
212
- * email+password (`/demo/login`), pasted sign-in link (`/demo/login-consume` — for the
213
- * password-less accounts the welcome flow creates). Never persisted. */
214
162
  async function acquireSession(purpose) {
215
- // TRANSPORT CHECK FIRST — before ANY credential is selected, including one already in the
216
- // environment. This guard used to sit after the DECIDIO_SESSION_TOKEN shortcut, so an
217
- // env-supplied session token would ride plaintext http to a remote host without ever being
218
- // checked. The rule is about the WIRE, so it cannot depend on where the credential came from.
219
163
  if (/^http:\/\//i.test(API) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]|$)/i.test(API)) {
220
164
  err(`✘ refusing to send workspace credentials over plaintext http to ${API}.`);
221
165
  err(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
@@ -224,8 +168,6 @@ async function acquireSession(purpose) {
224
168
  if (process.env.DECIDIO_SESSION_TOKEN)
225
169
  return process.env.DECIDIO_SESSION_TOKEN;
226
170
  if (!process.stdin.isTTY) {
227
- // WHOLE failure messages ride stderr (round-2 blind catch: the ✘ headline went to stderr
228
- // while its remediation went to stdout — split mid-sentence for any consumer of either).
229
171
  err(`✘ ${purpose} needs a Decidio workspace sign-in, and this terminal can't prompt for one.`);
230
172
  err(` Fix: set DECIDIO_SESSION_TOKEN and re-run. To mint one:`);
231
173
  err(` 1. Request a sign-in link from your workspace's sign-in screen (or use your welcome email).`);
@@ -263,15 +205,9 @@ async function acquireSession(purpose) {
263
205
  }
264
206
  async function cmdInit(agentId, opts = {}) {
265
207
  err(`Registering agent "${agentId}" with Decidio at ${API}…`);
266
- // CLIENT-SIDE KEYGEN: generate the Ed25519 keypair LOCALLY and register only the PUBLIC
267
- // did:key. The private key NEVER crosses the wire — it goes straight to this host's env.
268
208
  const existingEnv = readEnvFile();
269
209
  const existingKey = existingEnv.DECIDIO_AGENT_KEY;
270
210
  const sourceSystem = process.env.DECIDIO_SOURCE_SYSTEM || existingEnv.DECIDIO_SOURCE_SYSTEM;
271
- // NEVER silently orphan a different agent's key. Registering a NEW name in a directory that
272
- // already holds another agent's key would overwrite it — and keys never leave the machine that
273
- // made them, so that loss is irrecoverable (it strands the old agent in init's own 409 stop).
274
- // Checked BEFORE registering, so a refusal leaves nothing behind server-side either.
275
211
  if (existingKey && existingEnv.DECIDIO_AGENT_ID && existingEnv.DECIDIO_AGENT_ID !== agentId && !opts.force) {
276
212
  err(`✘ this directory's .env already holds the key for a DIFFERENT agent ("${existingEnv.DECIDIO_AGENT_ID}").`);
277
213
  err(` Registering "${agentId}" here would overwrite that key — and a private key that is`);
@@ -283,8 +219,6 @@ async function cmdInit(agentId, opts = {}) {
283
219
  }
284
220
  const { did: localDid, privateKeyBase64: localKey } = generateKeypair();
285
221
  const registerBody = JSON.stringify({ agentId, did: localDid, ...(sourceSystem ? { sourceSystem } : {}) });
286
- // First try whatever bearer the env already carries (the local-dev / operator path — a static
287
- // admin token, or dev-open auth). On 401/403 fall through to the self-serve session flow.
288
222
  let session = null;
289
223
  let r = await api("/api/agents/register", { method: "POST", body: registerBody });
290
224
  if (r.status === 401 || r.status === 403) {
@@ -295,10 +229,6 @@ async function cmdInit(agentId, opts = {}) {
295
229
  }
296
230
  let did, privateKey;
297
231
  if (r.status === 409) {
298
- // Already registered to a server-held did. WITHOUT the original private key on this machine
299
- // this is a HARD STOP, not a degraded continue (round-3 Codex catch: init used to write a
300
- // keyless .env and print happy-path next steps — every request then routed unproven and
301
- // execution evidence silently capped at agent_asserted).
302
232
  did = r.body?.did;
303
233
  if (existingKey) {
304
234
  log(`• "${agentId}" is already registered in this workspace (did ${did}) — reusing it with the DECIDIO_AGENT_KEY already in .env.`);
@@ -325,15 +255,9 @@ async function cmdInit(agentId, opts = {}) {
325
255
  err(` Fix the cause above and re-run — init is safe to repeat.`);
326
256
  return 1;
327
257
  }
328
- // Mint the runtime credential: the floor-limited AGENT token (can call /agent/* only). This is
329
- // what .env gets — the session token stays in memory and dies with this process. On boxes
330
- // without the tester-lab fence (plain local dev) the mint endpoint refuses; that's fine —
331
- // local dev auths with its existing env and we say so instead of failing the init.
332
258
  let agentToken, workspaceId, tokenJti;
333
259
  const mintBearer = session ?? TOKEN();
334
260
  if (mintBearer) {
335
- // Right-sized lifetime, not the server's year-long default: a CLI-minted credential should
336
- // expire on the horizon of the work it was minted for (--expires-days to override).
337
261
  const expiresInDays = opts.expiresDays ?? 90;
338
262
  const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "sdk-cli", expiresInDays }) }, mintBearer);
339
263
  if (mint.status >= 200 && mint.status < 300 && mint.body?.token) {
@@ -341,9 +265,6 @@ async function cmdInit(agentId, opts = {}) {
341
265
  workspaceId = mint.body.workspaceId;
342
266
  tokenJti = mint.body.jti;
343
267
  log(`✔ minted the agent's API token — floor-limited: it can request authorization, poll status and report confirmation; it cannot administer the workspace or approve anything${mint.body.expiresAt ? ` (expires ${mint.body.expiresAt})` : ` (${expiresInDays} days)`}`);
344
- // The server keeps ONE active token per agent, so a re-mint REVOKES the previous one — an
345
- // authority event the person triggering it must see, not discover later in tokens list
346
- // (round-3 blind catch: a still-valid token silently died with no mention).
347
268
  const superseded = Array.isArray(mint.body.supersededJtis) ? mint.body.supersededJtis : [];
348
269
  for (const s of superseded)
349
270
  log(` • this replaces the agent's previous token (jti ${s}) — now REVOKED: the server keeps one active token per agent. Anything still using it will 401.`);
@@ -364,28 +285,20 @@ async function cmdInit(agentId, opts = {}) {
364
285
  if (did)
365
286
  envOut.DECIDIO_AGENT_DID = did;
366
287
  if (privateKey)
367
- envOut.DECIDIO_AGENT_KEY = privateKey; // written locally; never left this host
288
+ envOut.DECIDIO_AGENT_KEY = privateKey;
368
289
  if (agentToken)
369
- envOut.DECIDIO_API_TOKEN = agentToken; // the FLOOR-LIMITED token — never the session
290
+ envOut.DECIDIO_API_TOKEN = agentToken;
370
291
  if (workspaceId)
371
292
  envOut.DECIDIO_WORKSPACE_ID = workspaceId;
372
- // Revocation metadata, not a secret — parity with the quickstart env (blind-review catch: the
373
- // two files disagreed, and `tokens revoke` is unusable without the jti in hand).
374
293
  if (tokenJti)
375
294
  envOut.DECIDIO_TOKEN_JTI = tokenJti;
376
- // IGNORE RULE BEFORE THE SECRET: writing .env first leaves a window (however short) where a
377
- // private key sits in a repo with no ignore rule covering it.
378
295
  ensureGitignore();
379
296
  if (opts.force && existingKey && existingEnv.DECIDIO_AGENT_ID && existingEnv.DECIDIO_AGENT_ID !== agentId) {
380
- // --force still must not DESTROY the old key: park it beside the new one, owner-only.
381
297
  const backup = `.env.${existingEnv.DECIDIO_AGENT_ID.replace(/[^\w.-]/g, "_")}.bak`;
382
298
  writeSecretFile(backup, readFileSync(ENV_PATH, "utf8"));
383
299
  log(`✔ backed the previous agent's .env up to ${backup} (its key would otherwise be unrecoverable)`);
384
300
  }
385
301
  upsertEnv(envOut);
386
- // The closing doctor (and anything else this process does) must see what init just wrote —
387
- // OVERWRITE process.env with the fresh values (round-3 Codex catch: doctor probed with the
388
- // stale pre-init token and reported the mint it just watched as REJECTED).
389
302
  for (const [k, v] of Object.entries(envOut))
390
303
  process.env[k] = v;
391
304
  log(`✔ wrote ${ENV_PATH} (owner-only where the OS supports it) — every decidio command reads it from this directory (your script loads it with node --env-file=.env)`);
@@ -422,38 +335,20 @@ async function cmdDoctor() {
422
335
  let ok = true;
423
336
  let warned = 0;
424
337
  const line = (good, label) => { log(`${good ? "✔" : "✘"} ${label}`); ok = ok && good; };
425
- // Warnings COUNT in the summary (round-3 blind catch: "doctor: all green." directly under a ⚠
426
- // line contradicts itself — green with a caveat must say so).
427
338
  const warn = (label) => { warned++; log(`⚠ ${label}`); };
428
- // An unset URL is a WORKING default (the hosted sandbox), not a failure — sandbox-first ruling.
429
339
  log(`✔ API URL: ${API}${(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL) ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
430
340
  line(!!(process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID), `agent id: ${process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID || "(unset — run: npx @decidio/sdk init <agent-name>)"}`);
431
- // The webhook secret is OPERATOR-ONLY transport config — a missing one must never fail doctor
432
- // (blind-review catch: doctor exited 1 immediately after the product's own successful
433
- // quickstart, whose env rightly omits it). Informational either way.
434
341
  const secret = process.env.DECIDIO_WEBHOOK_SECRET || env.DECIDIO_WEBHOOK_SECRET;
435
342
  log(secret ? "✔ webhook secret: set — operator-only transport (inactive against the hosted sandbox; the poll worker guard.worker() is the self-serve resume path)"
436
343
  : "• webhook secret: not set — only needed for the operator resume-webhook transport; the poll worker guard.worker() is the self-serve resume path (init generates one)");
437
- // Signing key — REQUIRED to be ELIGIBLE for auto-approve. Whether anything auto-approves is
438
- // the workspace policy's call, not this key's (Codex finding: the old wording claimed
439
- // "auto-approve is in effect" while deny-by-default routed everything).
440
344
  const signing = (process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY) && (process.env.DECIDIO_AGENT_DID || env.DECIDIO_AGENT_DID);
441
345
  if (signing)
442
346
  log("✔ signing key: set — the agent can prove its identity, making it ELIGIBLE for policy auto-approve. Whether a rule matches is your workspace policy's decision; with no matching rule every request routes to a human (deny-by-default).");
443
347
  else
444
348
  warn("signing key: MISSING (DECIDIO_AGENT_KEY/DID) — the agent can't prove identity, so EVERY request routes to a human. Run `npx @decidio/sdk init` to generate one.");
445
- // Reachability and token role are SEPARATE checks (the old probe called any <500 green, so a
446
- // wrong-kind token looked healthy). /api/health is unauthenticated truth about reachability;
447
- // /api/records then classifies the token.
448
349
  const h = await api("/api/health", {}, "");
449
350
  line(h.status >= 200 && h.status < 300, `connectivity: API reachable (${h.status})`);
450
- // EXPIRY BEFORE IT BITES: a right-sized token is only an improvement if its end is visible in
451
- // advance — otherwise a production agent simply starts 401ing one day. Read `exp` out of the
452
- // token's own payload (display only; the server remains the authority on validity).
453
351
  const tok = process.env.DECIDIO_API_TOKEN || env.DECIDIO_API_TOKEN;
454
- // UNSET is its own diagnosis (round-3 blind catch: a bare directory probed the server with an
455
- // EMPTY credential and told a first-run user their never-existent token was "invalid or
456
- // expired"). Say what is true and stop — there is nothing to probe.
457
352
  if (!tok) {
458
353
  line(false, "agent token: NOT CONFIGURED — no DECIDIO_API_TOKEN in this directory's .env or your environment. Run `npx @decidio/sdk init <agent-name>` to register an agent and mint one.");
459
354
  log(warned ? `\ndoctor: fix the ✘ items above, then re-run \`npx @decidio/sdk doctor\`. (${warned} warning${warned === 1 ? "" : "s"})` : "\ndoctor: fix the ✘ items above, then re-run `npx @decidio/sdk doctor`.");
@@ -464,11 +359,7 @@ async function cmdDoctor() {
464
359
  const payload = JSON.parse(Buffer.from(tok.split(".")[1] ?? "", "base64url").toString("utf8"));
465
360
  if (typeof payload?.exp === "number") {
466
361
  const msLeft = payload.exp * 1000 - Date.now();
467
- // A minute of grace absorbs mint-to-doctor latency so a fresh 90-day token reads "90",
468
- // matching the Python doctor (round-3 blind nit: 89 vs 90 for the same expiry).
469
362
  const days = Math.floor((msLeft + 60_000) / 86_400_000);
470
- // Under a day, days-precision reads as "0 day(s)" — which looks expired while ~23h remain
471
- // (blind-review catch). Switch units instead of rounding away the truth.
472
363
  const left = msLeft < 86_400_000 ? `${Math.max(1, Math.floor(msLeft / 3_600_000))} hour(s)` : `${days} day(s)`;
473
364
  if (msLeft < 0)
474
365
  line(false, `agent token: EXPIRED ${-days} day(s) ago — re-run \`npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}\` to mint a fresh one.`);
@@ -478,16 +369,12 @@ async function cmdDoctor() {
478
369
  log(`✔ agent token: valid for another ${days} days`);
479
370
  }
480
371
  }
481
- catch { /* not a JWT we can read — the scope probe below is the real check */ }
372
+ catch { }
482
373
  }
483
374
  const rec = await api("/api/records");
484
375
  if (rec.status >= 200 && rec.status < 300)
485
- log("✔ token scope: session/admin (init, approvals and receipt work from this env)");
376
+ warn("token scope: SESSION/ADMIN-capable — this credential can approve and administer, and it does not belong in an agent's environment (the agent runtime needs only the floor-limited token init mints). Move it to your shell/CI secret store.");
486
377
  else if (rec.status === 401) {
487
- // A 401 off the agent floor could also mean an INVALID token (Codex catch: don't
488
- // false-green expiry). Disambiguate with a floor probe: a valid agent token gets 404 for an
489
- // unknown decision (authorized, not found); an invalid one gets 401 again. A rejection here
490
- // flips the overall verdict — "all green" after a red line was round 3's other doctor bug.
491
378
  const probe = await api("/agent/status/doctor-scope-probe");
492
379
  if (probe.status === 404)
493
380
  log("✔ token scope: agent floor — correct for the runtime SDK (approvals/receipt will ask you to sign in)");
@@ -503,21 +390,14 @@ async function cmdDoctor() {
503
390
  return ok ? 0 : 1;
504
391
  }
505
392
  async function cmdApprovals(sub, id, reason, flags = {}) {
506
- // Approvals are a HUMAN act on the workspace — session scope, never the agent token. If the
507
- // env's token is agent-floor (401 on /api/*), sign in interactively; memory-only as always.
508
393
  const jsonMode = !!flags.json;
509
394
  const mineOnly = !!flags.mine;
510
395
  if (sub?.startsWith("--"))
511
396
  sub = undefined;
512
- // USAGE BEFORE AUTH (round-8 catch): a malformed command must fail on its shape, not probe the
513
- // network first — validation needs no credentials.
514
397
  if ((sub === "approve" || sub === "reject") && !id) {
515
398
  err(`usage: npx @decidio/sdk approvals ${sub} <decisionId> [reason]`);
516
399
  return 2;
517
400
  }
518
- // --mine without an agent identity cannot mean anything — and quietly returning [] (exit 0)
519
- // would tell a script "no pending work" when the truth is "misconfigured" (blind-review catch:
520
- // it leaked "undefined" in human mode and lied with [] in JSON mode). Shape error, before auth.
521
401
  if (mineOnly && !process.env.DECIDIO_AGENT_ID) {
522
402
  err(`✘ --mine needs an agent identity, and there is no DECIDIO_AGENT_ID in this directory's .env or your environment.`);
523
403
  err(` Run this from your agent's directory (where init wrote .env), or drop --mine for the workspace-wide list.`);
@@ -535,9 +415,6 @@ async function cmdApprovals(sub, id, reason, flags = {}) {
535
415
  if (sub === "approve" || sub === "reject") {
536
416
  const r = await api(`/api/decisions/${encodeURIComponent(decisionArg)}/${sub === "approve" ? "approve" : "reject"}`, { method: "POST", body: JSON.stringify({ reason: reason ?? undefined }) }, bearer);
537
417
  if (r.status >= 200 && r.status < 300) {
538
- // HONESTY over green checkmarks (Codex finding: the server resolves each decision exactly
539
- // once and answers replays idempotently — a replay is a NO-OP and must read as one, never
540
- // as a fresh approval). Non-zero exit so scripts can tell the difference.
541
418
  if (r.body?.alreadyResolved) {
542
419
  log(`• ${id} was already resolved — nothing changed.`);
543
420
  log(` Decisions are single-use: a second ${sub} can never re-execute the action (that's the replay protection working).`);
@@ -565,10 +442,9 @@ async function cmdApprovals(sub, id, reason, flags = {}) {
565
442
  return 1;
566
443
  }
567
444
  const cards = r.body?.cards ?? [];
445
+ if (cards.length >= 50)
446
+ err('⚠ this view is capped at 50 items by the server — older pending approvals may exist beyond it.');
568
447
  const pending = cards.filter((c) => c.isAgentRequest && c.status !== "sealed");
569
- // FILTER FIRST, FORMAT SECOND (round-8 catch: --mine --json returned the UNFILTERED workspace
570
- // list — and automation is exactly the consumer of the JSON form, so the human view and the
571
- // machine view must be the same set).
572
448
  const myAgent = process.env.DECIDIO_AGENT_ID;
573
449
  const isMine = (c) => !!myAgent && typeof c.title === "string" && c.title.startsWith(`${myAgent}:`);
574
450
  const shown = mineOnly ? pending.filter(isMine) : pending;
@@ -600,8 +476,6 @@ async function cmdReceipt(id, jsonMode = false) {
600
476
  err(" Find decision ids via `npx @decidio/sdk approvals` or in your Decidio app.");
601
477
  return 2;
602
478
  }
603
- // Receipts are workspace data — session scope, like approvals. (The record-detail /verify
604
- // route carries the credential; the plain record route does not — Codex finding.)
605
479
  let bearer;
606
480
  let r = await api(`/api/records/${encodeURIComponent(id)}/verify`);
607
481
  if (r.status === 401 || r.status === 403) {
@@ -623,28 +497,18 @@ async function cmdReceipt(id, jsonMode = false) {
623
497
  const name = `decidio-receipt-${id.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
624
498
  const nextBytes = JSON.stringify(r.body.credential, null, 2) + "\n";
625
499
  if (existsSync(name)) {
626
- // Never silently overwrite an audit artifact (round-6 catch). Byte-identical is fine (receipts
627
- // are immutable); a DIFFERENT file at the same name is a refusal.
628
500
  if (readFileSync(name, "utf8") !== nextBytes) {
629
501
  err(`✘ ${name} already exists with DIFFERENT bytes — refusing to overwrite an audit artifact. Move it aside or delete it, then re-run.`);
630
502
  return 1;
631
503
  }
632
- // In --json mode stdout is the JSON result ONLY — narrative rides stderr (stream contract).
633
504
  (jsonMode ? err : log)(`• ${name} already exists (byte-identical — receipts are immutable); leaving it in place.`);
634
505
  }
635
506
  else {
636
507
  writeFileSync(name, nextBytes);
637
508
  (jsonMode ? err : log)(`✔ wrote ${name} (downloaded from your workspace — the download is online; VERIFYING it is offline)`);
638
509
  }
639
- // The credential seals at DECISION time (immutable), so execution evidence that arrived later
640
- // lives on the record — surface the CURRENT tier here so the reader doesn't need a second
641
- // lookup to see it (Codex catch).
642
510
  const detail = await api(`/api/records/${encodeURIComponent(id)}`, {}, bearer);
643
511
  const conf = detail.body?.record?.confirmation;
644
- // The trust anchor, delivered over the AUTHENTICATED channel. Reading the issuer out of the
645
- // receipt you are about to verify is circular; this value comes from the server's own verify
646
- // response, so pinning it actually means something (round-7 catch: the guided flow sent people
647
- // to an app panel they could not reach, leaving provenance uncompletable).
648
512
  const issuer = r.body?.issuer;
649
513
  if (jsonMode) {
650
514
  log(JSON.stringify({ file: name, decisionId: id, evidenceTier: conf?.status ?? null, confirmed: !!conf?.confirmed, issuer: issuer ?? null }, null, 2));
@@ -652,8 +516,6 @@ async function cmdReceipt(id, jsonMode = false) {
652
516
  }
653
517
  if (conf?.status)
654
518
  log(` Sealed credential: the authority decision (its confirmation field reads "pending" forever — immutable). Current record evidence tier: ${conf.status}${conf.confirmed ? " (confirmed)" : ""}.`);
655
- // Emit EXACTLY the deterministic command we document (round-4 catch: the README and this hint
656
- // disagreed, and bare npx re-probes the registry) — the verifier ships INSIDE this package.
657
519
  log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
658
520
  log(` npx @decidio/sdk verify ${name}`);
659
521
  if (issuer) {
@@ -667,16 +529,7 @@ async function cmdReceipt(id, jsonMode = false) {
667
529
  }
668
530
  return 0;
669
531
  }
670
- // ---- verify (embedded) ----------------------------------------------------------------------
671
- // ONE implementation, all doors (0.1.9): the dependency floor now carries `runVerify`, so the
672
- // strict-parse / chain / wrong-issuer local copies this door carried since 0.1.4 are DELETED —
673
- // the sdk door and the standalone decidio-verify CLI are literally the same function, and the
674
- // conformance runner's third door keeps them honest. This CLI keeps only what is CLI-shaped:
675
- // flag grammar, I/O-vs-verdict separation, and usage.
676
532
  function cmdVerify(argsIn) {
677
- // Positional parse mirroring the standalone decidio-verify CLI: --issuer in space AND `=` form
678
- // (the `=` form silently dropped was the standalone's unpinned-VALID bug — one grammar, all
679
- // doors). Unknown flags never reach here (main()'s strict table refuses them first).
680
533
  let expectedIssuer;
681
534
  const files = [];
682
535
  for (let i = 0; i < argsIn.length; i++) {
@@ -707,8 +560,6 @@ function cmdVerify(argsIn) {
707
560
  err(" Verifies Authority Receipts OFFLINE — signature, content binding, and (for several files) chain order.");
708
561
  return 2;
709
562
  }
710
- // An unreadable file is an I/O problem, not a cryptographic verdict (blind-review catch: a
711
- // typo'd filename got the same "INVALID" a forged receipt gets).
712
563
  const texts = new Map();
713
564
  for (const f of files) {
714
565
  try {
@@ -724,13 +575,11 @@ function cmdVerify(argsIn) {
724
575
  log(l);
725
576
  return ok ? 0 : 1;
726
577
  }
727
- // ---- quickstart (the guided two-minute tour) ------------------------------------------------
728
578
  async function cmdQuickstart(opts = {}) {
729
579
  const t0 = Date.now();
730
580
  log("Decidio quickstart — gate an action, route it to a human, execute on approval, hold the proof.");
731
581
  log(` Target: ${API}${process.env.DECIDIO_API_URL ? "" : " (the hosted synthetic sandbox — set DECIDIO_API_URL to use your own)"}`);
732
582
  log("");
733
- // SIDE EFFECTS UP FRONT (round-8 ask): say what the tour creates before creating any of it.
734
583
  log(" This tour will: register one synthetic agent · mint a 7-DAY floor-limited token ·");
735
584
  log(" write .env (or .env.quickstart) + .gitignore entries · create one synthetic decision");
736
585
  log(" and its sealed record (records are permanent by design). Cleanup offer at the end.");
@@ -738,9 +587,6 @@ async function cmdQuickstart(opts = {}) {
738
587
  const session = await acquireSession("The quickstart");
739
588
  if (!session)
740
589
  return 1;
741
- // 1. A collision-proof agent, registered fresh every run (round-4 ask: idempotent sample
742
- // registration). Key generated locally; floor-limited token minted; .env written so the
743
- // take-away snippet works in this directory afterward.
744
590
  const agentId = `quickstart-${randomBytes(2).toString("hex")}`;
745
591
  log(`① Registering agent "${agentId}" (fresh name each run — no collisions)…`);
746
592
  const { did, privateKeyBase64 } = generateKeypair();
@@ -749,33 +595,21 @@ async function cmdQuickstart(opts = {}) {
749
595
  err(`✘ registration failed (${reg.status}): ${JSON.stringify(reg.body)}`);
750
596
  return 1;
751
597
  }
752
- // A tour credential should outlive the tour and not much else — 7 days, disclosed.
753
598
  const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "quickstart", expiresInDays: 7 }) }, session);
754
599
  if (!(mint.status >= 200 && mint.status < 300) || !mint.body?.token) {
755
600
  err(`✘ token mint failed (${mint.status}): ${JSON.stringify(mint.body)}`);
756
601
  return 1;
757
602
  }
758
- // No webhook secret here (round-8 catch): the sandbox tour never uses the webhook transport,
759
- // and inheriting the .env's existing secret duplicated one agent's secret into another's file.
760
- // DECIDIO_TOKEN_JTI is revocation METADATA (not a secret) — it is what `quickstart cleanup`
761
- // and `tokens revoke` need to kill the token later.
762
603
  const envOut = {
763
604
  DECIDIO_API_URL: API, DECIDIO_AGENT_ID: agentId, DECIDIO_AGENT_DID: reg.body?.did ?? did,
764
605
  DECIDIO_AGENT_KEY: privateKeyBase64, DECIDIO_API_TOKEN: mint.body.token,
765
606
  DECIDIO_WORKSPACE_ID: mint.body.workspaceId ?? "",
766
607
  ...(mint.body.jti ? { DECIDIO_TOKEN_JTI: mint.body.jti } : {}),
767
608
  };
768
- // NEVER clobber a real agent's key (guard catch: keys never leave this machine — overwriting
769
- // .env's DECIDIO_AGENT_KEY orphans that agent irrecoverably). A directory that already holds
770
- // one gets a TOUR-SCOPED env file instead — through the SAME secure writer as .env (round-8
771
- // catch: this path used plain writeFileSync).
772
609
  let envFile = ENV_PATH;
773
610
  if (readEnvFile().DECIDIO_AGENT_KEY) {
774
611
  envFile = ".env.quickstart";
775
612
  ensureGitignore();
776
- // A THIRD tour in the same directory would otherwise overwrite the previous tour agent's
777
- // private key — unrecoverable, same class as the .env clobber init refuses (blind-review
778
- // catch). Same remedy: back the old file up first, owner-only.
779
613
  if (existsSync(envFile)) {
780
614
  const prev = readFileSync(envFile, "utf8");
781
615
  const prevId = prev.match(/^DECIDIO_AGENT_ID=(.+)$/m)?.[1];
@@ -794,9 +628,6 @@ async function cmdQuickstart(opts = {}) {
794
628
  }
795
629
  log(` ✔ key generated locally (public half registered) · 7-day floor-limited token minted${mint.body.expiresAt ? ` (expires ${mint.body.expiresAt})` : ""} · ${envFile} written`);
796
630
  log("");
797
- // 2. Gate a demo action. Show the ACTUAL wrap the tour runs (round-6 catch: the printed line
798
- // differed from the code — this tour uses blocking mode; the bare guard.protect default is
799
- // durable and needs a worker/webhook, which the README covers).
800
631
  log(`② Gating a demo action — the tour runs this (blocking mode: watch it live):`);
801
632
  log("");
802
633
  log(` const payInvoice = guard.protect(payInvoiceRaw,`);
@@ -812,9 +643,6 @@ async function cmdQuickstart(opts = {}) {
812
643
  };
813
644
  const payInvoice = withApproval(async (inv) => ({ paid: true, id: inv.id }), (inv) => ({ action: "payInvoice", amount: inv.amount, scope: "Invoice" }), cfg);
814
645
  const running = payInvoice({ id: "INV-2026-001", amount: 86_000 });
815
- // An EARLY rejection (before routing) must be diagnosed as ITSELF, not as a routing timeout
816
- // (round-2 blind catch: the sandbox's per-hour action cap surfaced here as "check
817
- // connectivity", and doctor then corroborated the wrong suspect with "all green").
818
646
  let earlyError;
819
647
  running.catch((e) => { earlyError = e; });
820
648
  const waitStart = Date.now();
@@ -837,10 +665,6 @@ async function cmdQuickstart(opts = {}) {
837
665
  log(` ✔ $86,000 payInvoice ROUTED to a human (decision ${decisionId}) — this agent has no auto-approve rule,`);
838
666
  log(` so deny-by-default sends everything to a person. Nothing executes until someone decides.`);
839
667
  log("");
840
- // 3. The human moment — and it must BE one. The tour's whole claim is "routes it to YOU,
841
- // executes on your yes"; a headless run that approves itself has no discrete yes (round-8
842
- // catch: "headless quickstart breaks the central story"). So headless STOPS here unless
843
- // --auto-approve-demo was passed explicitly, and that path is labeled automated authority.
844
668
  const interactive = process.stdin.isTTY;
845
669
  if (interactive) {
846
670
  await ask(`③ YOU are the approver. Press Enter to approve it here (or approve it in your Decidio queue first, then Enter): `);
@@ -851,14 +675,16 @@ async function cmdQuickstart(opts = {}) {
851
675
  }
852
676
  else {
853
677
  log("③ This terminal can't put a human in the loop, and an approval IS a human act — the tour");
854
- log(" stops here rather than approve its own request. The decision is routed and waiting:");
678
+ log(" stops here rather than approve its own request. The decision is routed and waiting —");
679
+ log(" but note: this BLOCKING demo process is about to exit, so approving later resolves the");
680
+ log(" RECORD only; the demo function itself cannot run afterwards (durable mode is what");
681
+ log(" survives a process exit — the README's worker section shows it):");
855
682
  log("");
856
683
  log(` npx @decidio/sdk approvals approve ${decisionId} (or approve it in your Decidio app)`);
857
684
  log("");
858
685
  log(" Then re-run the quickstart interactively to watch the whole loop — or, for CI, re-run");
859
686
  log(" with --auto-approve-demo to let the tour approve itself under that explicit label.");
860
687
  log(` Tidy up this tour's agent + token any time: npx @decidio/sdk quickstart cleanup`);
861
- // The blocking poll keeps the event loop alive; the tour is over, so leave cleanly.
862
688
  process.exit(0);
863
689
  }
864
690
  const ap = await api(`/api/decisions/${encodeURIComponent(decisionId)}/approve`, { method: "POST", body: JSON.stringify({ reason: opts.autoApproveDemo && !interactive ? "quickstart --auto-approve-demo (automated)" : "quickstart" }) }, session);
@@ -867,7 +693,6 @@ async function cmdQuickstart(opts = {}) {
867
693
  return 1;
868
694
  }
869
695
  log(` ✔ approved${ap.body?.alreadyResolved ? " (it was already approved from your app — even better)" : ""}`);
870
- // 4. The blocking wrapper wakes and runs the agent's own function.
871
696
  let result;
872
697
  try {
873
698
  result = await running;
@@ -878,7 +703,6 @@ async function cmdQuickstart(opts = {}) {
878
703
  }
879
704
  log(` ✔ your function executed AFTER approval: ${JSON.stringify(result)}`);
880
705
  log("");
881
- // 5. The proof: download the receipt and verify it offline, all in-process.
882
706
  log("④ Downloading the sealed Authority Receipt and verifying it OFFLINE…");
883
707
  const rec = await api(`/api/records/${encodeURIComponent(decisionId)}/verify`, {}, session);
884
708
  if (!(rec.status >= 200 && rec.status < 300) || !rec.body?.credential) {
@@ -887,8 +711,6 @@ async function cmdQuickstart(opts = {}) {
887
711
  }
888
712
  const fname = `decidio-receipt-${decisionId.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
889
713
  writeFileSync(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
890
- // Verify PINNED to the issuer the server just told us over the authenticated channel — an
891
- // unpinned VALID only proves self-consistency, and the tour should demonstrate the real thing.
892
714
  const issuer = rec.body?.issuer;
893
715
  const v = verifyReceipt(rec.body.credential, issuer);
894
716
  log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? `, PINNED to your workspace issuer ${issuer}` : "; issuer unpinned"})`);
@@ -908,9 +730,6 @@ async function cmdQuickstart(opts = {}) {
908
730
  log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
909
731
  log("");
910
732
  log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")} · Next: the README's 7-step quickstart (durable, workers, adapters).`);
911
- // 6. Leave nothing behind unless asked to (round-8 ask: each tour left a live 7-day token and
912
- // local credentials with no cleanup path). Interactive: offer, default KEEP (they may want
913
- // to build on it). Automated (--auto-approve-demo): default CLEANUP unless --keep-agent.
914
733
  let doCleanup = false;
915
734
  if (interactive) {
916
735
  const answer = await ask(`\nKeep this agent for development? [Enter = keep · c + Enter = clean up now]: `);
@@ -926,9 +745,6 @@ async function cmdQuickstart(opts = {}) {
926
745
  await quickstartCleanup(session, envFile);
927
746
  return v.valid ? 0 : 1;
928
747
  }
929
- /** Revoke the tour's token and remove its local credential file. Honest about what remains:
930
- * sealed records are PERMANENT by design, and the agent's registration row stays (there is no
931
- * deregistration endpoint yet) — but with its token revoked and its key deleted it is inert. */
932
748
  async function quickstartCleanup(session, envFile) {
933
749
  const env = (() => {
934
750
  const out = {};
@@ -952,10 +768,6 @@ async function quickstartCleanup(session, envFile) {
952
768
  else {
953
769
  log(` ⚠ no DECIDIO_TOKEN_JTI in ${envFile} (older tour) — revoke by hand: npx @decidio/sdk tokens list, then tokens revoke <jti>. It expires on its own within 7 days.`);
954
770
  }
955
- // Remove ONLY what the tour wrote (guard catch: in a project directory the tour MERGES into an
956
- // existing .env, so deleting the whole file would destroy the project's own variables — a
957
- // cleanup must never remove anything it didn't create). The file itself goes only when nothing
958
- // else lives in it.
959
771
  if (existsSync(envFile)) {
960
772
  const TOUR_KEYS = new Set(["DECIDIO_API_URL", "DECIDIO_AGENT_ID", "DECIDIO_AGENT_DID", "DECIDIO_AGENT_KEY", "DECIDIO_API_TOKEN", "DECIDIO_WORKSPACE_ID", "DECIDIO_TOKEN_JTI"]);
961
773
  const kept = readFileSync(envFile, "utf8").split(/\r?\n/).filter((line) => {
@@ -971,9 +783,6 @@ async function quickstartCleanup(session, envFile) {
971
783
  log(` ✔ tour credentials removed from ${envFile} — its ${kept.length} other line${kept.length === 1 ? "" : "s"} untouched (the agent's local key is gone — the registered agent is now inert)`);
972
784
  }
973
785
  }
974
- // HONEST ACCOUNTING includes the queue (blind-review catch: a headless-stopped tour leaves its
975
- // routed decision pending in the SHARED workspace queue, and cleanup said nothing about it).
976
- // Listing is a read; clearing it stays a human act — we print the command, we don't run it.
977
786
  const tourAgent = env.DECIDIO_AGENT_ID;
978
787
  if (tourAgent) {
979
788
  const cards = await api("/api/decisions/cards", {}, session);
@@ -987,7 +796,6 @@ async function quickstartCleanup(session, envFile) {
987
796
  log(` and the agent's registration row (inert without key or token; server-side deregistration is on the roadmap).`);
988
797
  return 0;
989
798
  }
990
- /** tokens list [--mine] / tokens revoke <jti> — the workspace's agent-token lifecycle. */
991
799
  async function cmdTokens(sub, jti, mineOnly = false) {
992
800
  if (sub !== "list" && sub !== "revoke") {
993
801
  err("usage: npx @decidio/sdk tokens list [--mine]");
@@ -999,9 +807,6 @@ async function cmdTokens(sub, jti, mineOnly = false) {
999
807
  err("usage: npx @decidio/sdk tokens revoke <jti> (find the jti with: npx @decidio/sdk tokens list)");
1000
808
  return 2;
1001
809
  }
1002
- // Same grammar and same shape-error as approvals --mine (round-9 catch: a first run in the
1003
- // SHARED sandbox listed 138 workspace-wide rows — accurate, documented, and still terrible
1004
- // isolation optics with no way to narrow).
1005
810
  if (mineOnly && !process.env.DECIDIO_AGENT_ID) {
1006
811
  err(`✘ --mine needs an agent identity, and there is no DECIDIO_AGENT_ID in this directory's .env or your environment.`);
1007
812
  err(` Run this from your agent's directory (where init wrote .env), or drop --mine for the workspace-wide list.`);
@@ -1020,9 +825,6 @@ async function cmdTokens(sub, jti, mineOnly = false) {
1020
825
  if (sub === "revoke") {
1021
826
  const rev = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, bearer);
1022
827
  if (rev.status >= 200 && rev.status < 300) {
1023
- // Replay honesty (blind-review catch + the SDK's own contract): revoking an already-revoked
1024
- // token changes nothing and must read as a no-op, not a fresh kill. The server says which
1025
- // via `changed`.
1026
828
  if (rev.body?.changed === false) {
1027
829
  log(`• ${jti} was already revoked — nothing changed.`);
1028
830
  log(` Revocation is permanent; there is no re-revoke. See it: npx @decidio/sdk tokens list`);
@@ -1049,16 +851,11 @@ async function cmdTokens(sub, jti, mineOnly = false) {
1049
851
  log(mineOnly ? `No agent tokens for "${myAgent}"${all.length ? ` (${all.length} elsewhere in this workspace — drop --mine to see them)` : ""}.` : "No agent tokens in this workspace.");
1050
852
  return 0;
1051
853
  }
1052
- // Status is derived per row, revoked first — a list that showed revoked tokens as "Active" made
1053
- // the revocation audit surface unable to confirm its own revocations (blind-review catch).
1054
854
  const status = (t) => t.revokedAt ? `REVOKED ${String(t.revokedAt).slice(0, 10)}`
1055
855
  : (t.expiresAt && Date.parse(t.expiresAt) < Date.now()) ? "expired" : "active";
1056
856
  log(`Agent tokens (${tokens.length}${mineOnly ? ` for "${myAgent}"` : ""})${!mineOnly && myAgent ? ` — workspace-wide (a shared workspace shows every member's agents; --mine narrows to this directory's agent, ${myAgent})` : ""}:`);
1057
857
  for (const t of tokens)
1058
858
  log(` ${t.jti} ${status(t)} agent=${t.agentId ?? "?"} label=${t.label ?? ""} expires=${t.expiresAt ?? "?"}`);
1059
- // The governed-action allowance rides the same response — showing it here is what keeps the
1060
- // per-hour cap from ever being a surprise 429 (round-2 blind catch: the cap was undocumented
1061
- // and its refusal untyped).
1062
859
  const al = r.body?.allowance;
1063
860
  if (al?.limit != null)
1064
861
  log(`\nGoverned-action allowance: ${al.remaining}/${al.limit} remaining this hour${al.resetAt ? ` (resets ${al.resetAt})` : ""}.`);
@@ -1066,8 +863,6 @@ async function cmdTokens(sub, jti, mineOnly = false) {
1066
863
  return 0;
1067
864
  }
1068
865
  function cmdDev(target, port) {
1069
- // Signal-only relay: receive Decidio's webhook here (a stable URL) and forward it to
1070
- // your local agent's resume listener. Useful when the agent isn't directly reachable.
1071
866
  log(`decidio dev relay on http://localhost:${port}/decidio/resume → forwarding to ${target}`);
1072
867
  createServer((req, res) => {
1073
868
  if (req.method !== "POST") {
@@ -1132,7 +927,8 @@ fully offline — it touches no network and reads no .env.
1132
927
  Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error.
1133
928
  Errors, and usage shown on ERROR paths, go to stderr; stdout carries results only (safe to
1134
929
  pipe). Asked-for help — including this bare invocation — IS a result, so it prints here.
1135
- Docs: the README that shipped with this package (quickstart, durable resume, adapters).`;
930
+ Docs: the README that shipped with this package — node_modules/@decidio/sdk/README.md
931
+ (quickstart, durable resume, workers, adapters).`;
1136
932
  async function main(argv) {
1137
933
  const [cmd, a, b, ...rest] = argv.slice(2);
1138
934
  const wantsHelp = (s) => s === "help" || s === "--help" || s === "-h";
@@ -1148,9 +944,6 @@ async function main(argv) {
1148
944
  log(USAGE);
1149
945
  return 0;
1150
946
  }
1151
- // STRICT FLAGS (round-8 catch: `verify --strict` was silently ignored — a security CLI must
1152
- // fail closed on options it doesn't understand, BEFORE any auth or network). Each command
1153
- // whitelists its flags; anything else exits 2. Value-taking flags name themselves.
1154
947
  const argsIn = argv.slice(3);
1155
948
  const KNOWN = {
1156
949
  quickstart: { flags: ["--auto-approve-demo", "--keep-agent"] },
@@ -1175,19 +968,14 @@ async function main(argv) {
1175
968
  if (valued.includes(a)) {
1176
969
  i++;
1177
970
  continue;
1178
- } // its value is consumed
971
+ }
1179
972
  if (valued.some((v) => a.startsWith(v + "=")))
1180
- continue; // --flag=value form
973
+ continue;
1181
974
  if (!spec.flags.includes(a)) {
1182
975
  err(`✘ unknown option "${a}" for \`${cmd}\` — refusing to guess (a flag you meant to matter must never be silently ignored).\n\n${USAGE}`);
1183
976
  return 2;
1184
977
  }
1185
978
  }
1186
- // POSITIONAL arity is part of the same contract (round-2 blind catch: `tokens revoke jti1
1187
- // jti2` revoked only the first and reported unqualified success — silent false success on a
1188
- // security control). Anything beyond what the command reads is refused, not dropped.
1189
- // approvals approve/reject take a free-text reason, so their tail is exempt; verify takes
1190
- // any number of files.
1191
979
  const MAX_POS = { quickstart: 1, init: 1, doctor: 0, receipt: 1, tokens: 2, dev: 0 };
1192
980
  const exemptTail = (cmd === "approvals" && (positionals[0] === "approve" || positionals[0] === "reject")) || cmd === "verify";
1193
981
  if (!exemptTail && cmd === "approvals" && positionals.length > 0) {
@@ -1210,7 +998,6 @@ async function main(argv) {
1210
998
  switch (cmd) {
1211
999
  case "quickstart": {
1212
1000
  if (argsIn.includes("cleanup")) {
1213
- // Standalone cleanup: prefer the tour-scoped file; fall back to a tour-created .env.
1214
1001
  const envFile = existsSync(".env.quickstart") ? ".env.quickstart"
1215
1002
  : (readEnvFile().DECIDIO_AGENT_ID ?? "").startsWith("quickstart-") ? ENV_PATH : undefined;
1216
1003
  if (!envFile) {
@@ -1226,9 +1013,6 @@ async function main(argv) {
1226
1013
  }
1227
1014
  case "init": {
1228
1015
  const unique = argsIn.includes("--unique");
1229
- // BOTH forms (guard catch: the strict table's generic `=` allowance let --expires-days=30
1230
- // pass the gate while this parser read only the space form — a silently-ignored
1231
- // credential-LIFETIME flag, the exact class the verifier doors just fixed).
1232
1016
  const expIdx = argsIn.indexOf("--expires-days");
1233
1017
  const expEq = argsIn.find((x) => x.startsWith("--expires-days="));
1234
1018
  const expRaw = expIdx >= 0 ? argsIn[expIdx + 1] : expEq ? expEq.slice("--expires-days=".length) : undefined;
@@ -1262,8 +1046,6 @@ async function main(argv) {
1262
1046
  return eq ? eq.slice(name.length + 1) : undefined;
1263
1047
  };
1264
1048
  const target = flag("--target") || process.env.DECIDIO_AGENT_RESUME_URL || "http://localhost:4100/decidio/resume";
1265
- // Validate on shape, before binding anything (blind-review catch: `--port abc` printed
1266
- // localhost:NaN and died with a raw internal error).
1267
1049
  const portRaw = flag("--port") || process.env.DECIDIO_DEV_PORT || "4099";
1268
1050
  if (!/^\d+$/.test(portRaw) || Number(portRaw) < 1 || Number(portRaw) > 65535) {
1269
1051
  err(`✘ --port needs a number between 1 and 65535 (got "${portRaw}").`);
@@ -1276,10 +1058,6 @@ async function main(argv) {
1276
1058
  return cmdDev(target, Number(portRaw));
1277
1059
  }
1278
1060
  default:
1279
- // Exit 2, not 1: an unknown command is a USAGE error, and the help text promised that
1280
- // contract (round-8 catch).
1281
- // A flag in command position gets a NAMED fix, not just a refusal (round-3 blind nit:
1282
- // `--json approvals` earned "unknown command" with no hint that flags follow the command).
1283
1061
  if (cmd.startsWith("-"))
1284
1062
  err(`✘ unknown command "${cmd}" — flags go AFTER the command (e.g. npx @decidio/sdk approvals ${cmd}).\n\n${USAGE}`);
1285
1063
  else