@decidio/sdk 0.3.0 → 0.3.5

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,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync } from "node:fs";
2
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, openSync, writeSync, fsyncSync, closeSync, renameSync } from "node:fs";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { createServer } from "node:http";
5
5
  import { execFileSync } from "node:child_process";
@@ -38,7 +38,10 @@ function readEnvFile() {
38
38
  }
39
39
  return out;
40
40
  }
41
- const OFFLINE_CMD = ["verify", "--version", "-v", "-V", "version", "--help", "-h", "help", undefined].includes(process.argv[2]);
41
+ const wantsHelp = (s) => s === "help" || s === "--help" || s === "-h";
42
+ const ARGV_TAIL = process.argv.slice(2);
43
+ const OFFLINE_CMD = ["verify", "--version", "-v", "-V", "version", undefined].includes(ARGV_TAIL[0])
44
+ || wantsHelp(ARGV_TAIL[0]) || wantsHelp(ARGV_TAIL[1]);
42
45
  if (!OFFLINE_CMD) {
43
46
  readEnvFile();
44
47
  if (envUnreadable) {
@@ -109,7 +112,30 @@ async function api(path, init = {}, bearer) {
109
112
  return { status: res.status, body };
110
113
  }
111
114
  function writeSecretFile(path, body) {
112
- writeFileSync(path, body, { mode: 0o600 });
115
+ const tmp = `${path}.${process.pid}.tmp`;
116
+ const fd = openSync(tmp, "w", 0o600);
117
+ try {
118
+ writeSync(fd, body);
119
+ fsyncSync(fd);
120
+ }
121
+ finally {
122
+ closeSync(fd);
123
+ }
124
+ try {
125
+ chmodSync(tmp, 0o600);
126
+ }
127
+ catch { }
128
+ hardenFile(tmp);
129
+ try {
130
+ renameSync(tmp, path);
131
+ }
132
+ catch (e) {
133
+ try {
134
+ rmSync(tmp, { force: true });
135
+ }
136
+ catch { }
137
+ throw e;
138
+ }
113
139
  try {
114
140
  chmodSync(path, 0o600);
115
141
  }
@@ -119,6 +145,83 @@ function writeSecretFile(path, body) {
119
145
  console.warn(`⚠ could not restrict ${path} to owner-only on this Windows volume (${why}) — it keeps the directory's inherited permissions. For production, move these values to a secret manager.`);
120
146
  }
121
147
  }
148
+ async function revokeFreshMint(jti, bearer, writeError, target) {
149
+ err(`✘ could not write ${target} (${writeError?.message ?? writeError}). The token minted a moment ago (jti ${jti}) is LIVE until revoked - revoking it now.`);
150
+ const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, bearer);
151
+ if (rv.status >= 200 && rv.status < 300) {
152
+ journalRetire(jti, "revoked");
153
+ err(` ✔ the token minted a moment ago (jti ${jti}) was REVOKED again, so nothing live was left behind. Fix the cause above and re-run - it will mint a fresh one.`);
154
+ return true;
155
+ }
156
+ let known = false;
157
+ try {
158
+ known = journalOutstanding().some((j) => j.jti === jti);
159
+ }
160
+ catch { }
161
+ if (known)
162
+ err(` Its id is kept in ${JOURNAL_PATH} (owner-only, append-only) until it is revoked - \`quickstart cleanup\` retries it, and \`doctor\` names it.`);
163
+ else
164
+ err(` Its id is NOT recorded anywhere but this terminal (the journal write is what failed) - write it down now and revoke it by hand.`);
165
+ err(` ⚠ the token minted a moment ago (jti ${jti}) is LIVE and could NOT be revoked (${rv.status}). Revoke it by hand, or re-run init - the server keeps one active token per agent, so a fresh mint supersedes it:`);
166
+ err(` npx @decidio/sdk tokens revoke ${jti}`);
167
+ err(` npx @decidio/sdk tokens list --mine`);
168
+ return false;
169
+ }
170
+ const JOURNAL_PATH = ".decidio-token-journal";
171
+ function journalAppend(entry) {
172
+ const fresh = !existsSync(JOURNAL_PATH);
173
+ const fd = openSync(JOURNAL_PATH, "a", 0o600);
174
+ try {
175
+ writeSync(fd, JSON.stringify({ ...entry, at: new Date().toISOString() }) + "\n");
176
+ fsyncSync(fd);
177
+ }
178
+ finally {
179
+ closeSync(fd);
180
+ }
181
+ if (fresh) {
182
+ try {
183
+ chmodSync(JOURNAL_PATH, 0o600);
184
+ }
185
+ catch { }
186
+ hardenFile(JOURNAL_PATH);
187
+ }
188
+ }
189
+ function journalProbe() {
190
+ try {
191
+ journalAppend({ event: "probe" });
192
+ return null;
193
+ }
194
+ catch (e) {
195
+ return String(e?.message ?? e);
196
+ }
197
+ }
198
+ function journalMinted(jti, agentId, label) { journalAppend({ event: "minted", jti, agentId, label, apiUrl: API }); }
199
+ function journalRetire(jti, how) { try {
200
+ journalAppend({ event: "retired", jti, how });
201
+ }
202
+ catch { } }
203
+ function journalOutstanding() {
204
+ if (!existsSync(JOURNAL_PATH))
205
+ return [];
206
+ const minted = new Map();
207
+ for (const line of readFileSync(JOURNAL_PATH, "utf8").split(/\r?\n/)) {
208
+ if (!line.trim())
209
+ continue;
210
+ let e;
211
+ try {
212
+ e = JSON.parse(line);
213
+ }
214
+ catch {
215
+ continue;
216
+ }
217
+ if (e?.event === "minted" && typeof e.jti === "string")
218
+ minted.set(e.jti, { jti: e.jti, agentId: e.agentId, label: e.label, apiUrl: e.apiUrl, at: e.at });
219
+ if (e?.event === "retired" && typeof e.jti === "string")
220
+ minted.delete(e.jti);
221
+ }
222
+ return [...minted.values()];
223
+ }
224
+ const REFUSE_MINT_NO_JOURNAL = (why) => `✘ could not write the token recovery journal (${JOURNAL_PATH}: ${why}) - refusing to mint a credential this directory cannot record a revocation handle for. Fix the directory's permissions or free space and re-run. Nothing was minted.`;
122
225
  function upsertEnv(updates) {
123
226
  const remaining = { ...updates };
124
227
  const updatedKeys = new Set();
@@ -168,7 +271,7 @@ function assertNotTracked(path) {
168
271
  return false;
169
272
  }
170
273
  function ensureGitignore() {
171
- const entries = [".env", ".env.quickstart", ".env.*.bak", ".decidio-pending/"];
274
+ const entries = [".env", ".env.quickstart", ".env.*.bak", ".env.*.tmp", ".decidio-pending/", ".decidio-token-journal"];
172
275
  const existing = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
173
276
  const missing = entries.filter((e) => !existing.split(/\r?\n/).some((l) => l.trim() === e || l.trim() === e.replace(/\/$/, "")));
174
277
  if (!missing.length)
@@ -307,16 +410,33 @@ async function cmdInit(agentId, opts = {}) {
307
410
  let agentToken, workspaceId, tokenJti;
308
411
  const mintBearer = session ?? TOKEN();
309
412
  if (mintBearer) {
413
+ ensureGitignore();
414
+ const noJournal = journalProbe();
415
+ if (noJournal) {
416
+ err(REFUSE_MINT_NO_JOURNAL(noJournal));
417
+ return 1;
418
+ }
310
419
  const expiresInDays = opts.expiresDays ?? 90;
311
420
  const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "sdk-cli", expiresInDays }) }, mintBearer);
312
421
  if (mint.status >= 200 && mint.status < 300 && mint.body?.token) {
313
422
  agentToken = mint.body.token;
314
423
  workspaceId = mint.body.workspaceId;
315
424
  tokenJti = mint.body.jti;
425
+ if (tokenJti) {
426
+ try {
427
+ journalMinted(tokenJti, agentId, "sdk-cli");
428
+ }
429
+ catch (e) {
430
+ await revokeFreshMint(tokenJti, mintBearer, e, JOURNAL_PATH);
431
+ return 1;
432
+ }
433
+ }
316
434
  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)`}`);
317
435
  const superseded = Array.isArray(mint.body.supersededJtis) ? mint.body.supersededJtis : [];
318
- for (const s of superseded)
436
+ for (const s of superseded) {
437
+ journalRetire(s, "revoked");
319
438
  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.`);
439
+ }
320
440
  }
321
441
  else if (session) {
322
442
  err(`✘ could not mint the agent's API token (${mint.status}): ${JSON.stringify(mint.body)}`);
@@ -341,8 +461,21 @@ async function cmdInit(agentId, opts = {}) {
341
461
  envOut.DECIDIO_WORKSPACE_ID = workspaceId;
342
462
  if (tokenJti)
343
463
  envOut.DECIDIO_TOKEN_JTI = tokenJti;
344
- prepareEnvForWrite();
345
- upsertEnv(envOut);
464
+ try {
465
+ prepareEnvForWrite();
466
+ upsertEnv(envOut);
467
+ if (tokenJti)
468
+ journalRetire(tokenJti, "persisted");
469
+ }
470
+ catch (e) {
471
+ if (agentToken && tokenJti)
472
+ await revokeFreshMint(tokenJti, mintBearer, e, ENV_PATH);
473
+ else if (agentToken)
474
+ err(`✘ could not write ${ENV_PATH} (${e?.message ?? e}). The token minted a moment ago is LIVE and the server returned no revocation id - find and revoke it: npx @decidio/sdk tokens list --mine`);
475
+ else
476
+ err(`✘ could not write ${ENV_PATH} (${e?.message ?? e}). No token was minted this run.`);
477
+ return 1;
478
+ }
346
479
  for (const [k, v] of Object.entries(envOut))
347
480
  process.env[k] = v;
348
481
  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)`);
@@ -381,6 +514,17 @@ async function cmdDoctor() {
381
514
  let warned = 0;
382
515
  const line = (good, label) => { log(`${good ? "✔" : "✘"} ${label}`); ok = ok && good; };
383
516
  const warn = (label) => { warned++; log(`⚠ ${label}`); };
517
+ let journalRows = [];
518
+ try {
519
+ journalRows = journalOutstanding();
520
+ }
521
+ catch (e) {
522
+ warn(`token recovery journal (${JOURNAL_PATH}) could not be read (${e?.message ?? e}) — it may hold live token ids; fix its permissions and re-run doctor.`);
523
+ }
524
+ for (const j of journalRows) {
525
+ const where = j.apiUrl && j.apiUrl !== API ? `DECIDIO_API_URL=${j.apiUrl} ` : "";
526
+ warn(`token recovery journal: jti ${j.jti} (${j.label ?? "token"}, agent ${j.agentId ?? "?"}, minted ${j.at ?? "?"} against ${j.apiUrl ?? API}) has no recorded revocation in this directory's journal — it may still be live. Revoke it, or confirm it is gone: ${where}npx @decidio/sdk quickstart cleanup (or: ${where}npx @decidio/sdk tokens revoke ${j.jti})`);
527
+ }
384
528
  log(`✔ API URL: ${API}${(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL) ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
385
529
  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>)"}`);
386
530
  const secret = process.env.DECIDIO_WEBHOOK_SECRET || env.DECIDIO_WEBHOOK_SECRET;
@@ -399,7 +543,7 @@ async function cmdDoctor() {
399
543
  else if (derived !== storedDid)
400
544
  line(false, `signing key: DOES NOT MATCH this agent's DID — the key here derives ${derived}, but .env registers ${storedDid}. Signed requests will not be recognised as this agent (auto-approve and application_confirmed evidence will both fail). Restore the original key, or re-run \`npx @decidio/sdk init\` to register a fresh one.`);
401
545
  else
402
- log("✔ signing key: set and MATCHES the registered DID — 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).");
546
+ log("✔ signing key: set and MATCHES the registered DID — 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 (routing-by-default - a block is different: a named rule, or the universal backstop when no active policy governs agents at all).");
403
547
  }
404
548
  else
405
549
  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.");
@@ -597,10 +741,13 @@ async function cmdReceipt(id, jsonMode = false) {
597
741
  }
598
742
  log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
599
743
  if (issuer) {
744
+ log(` Record your workspace's issuer DID — copy this line, and pin it forever:`);
745
+ log(``);
746
+ log(`${issuer}`);
747
+ log(``);
748
+ log(` Then verify, pinned to it (the same DID, inline — copy from the line above, not this one,`);
749
+ log(` which your terminal may wrap):`);
600
750
  log(` npx @decidio/sdk verify --issuer ${issuer} ${name}`);
601
- log(` That pins the check to your workspace's issuer DID (read from the authenticated`);
602
- log(` response above — record it once, then pin it forever):`);
603
- log(` ${issuer}`);
604
751
  log(` Without --issuer the verifier proves the receipt is internally consistent, but NOT who`);
605
752
  log(` issued it; it says so in its output.`);
606
753
  }
@@ -621,6 +768,10 @@ function cmdVerify(argsIn) {
621
768
  err("✘ --issuer needs a did:key value (a bare or EMPTY --issuer must not silently verify unpinned).");
622
769
  return 2;
623
770
  }
771
+ if (expectedIssuer !== undefined && expectedIssuer !== v) {
772
+ err(`✘ --issuer given twice with different values (${expectedIssuer} then ${v}) — refusing to choose which one you meant to pin to.`);
773
+ return 2;
774
+ }
624
775
  expectedIssuer = v;
625
776
  i++;
626
777
  }
@@ -630,6 +781,10 @@ function cmdVerify(argsIn) {
630
781
  err("✘ --issuer needs a did:key value.");
631
782
  return 2;
632
783
  }
784
+ if (expectedIssuer !== undefined && expectedIssuer !== v) {
785
+ err(`✘ --issuer given twice with different values (${expectedIssuer} then ${v}) — refusing to choose which one you meant to pin to.`);
786
+ return 2;
787
+ }
633
788
  expectedIssuer = v;
634
789
  }
635
790
  else if (!a.startsWith("-")) {
@@ -695,11 +850,35 @@ async function cmdQuickstart(opts = {}) {
695
850
  err(` Nothing was written to this directory.`);
696
851
  return 1;
697
852
  }
853
+ let prevTourFile = null;
854
+ if (envFile !== ENV_PATH && existsSync(envFile)) {
855
+ try {
856
+ prevTourFile = readFileSync(envFile, "utf8");
857
+ }
858
+ catch (e) {
859
+ err(`✘ ${envFile} exists but could not be read (${e?.message ?? e}) - a previous tour's credentials may be in it, and this tour would have to replace it. Refusing to mint a new token on top of a file this command cannot read: fix its permissions or move it aside, then re-run. Nothing was minted.`);
860
+ return 1;
861
+ }
862
+ }
863
+ const noJournal = journalProbe();
864
+ if (noJournal) {
865
+ err(REFUSE_MINT_NO_JOURNAL(noJournal));
866
+ return 1;
867
+ }
698
868
  const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "quickstart", expiresInDays: 7 }) }, session);
699
869
  if (!(mint.status >= 200 && mint.status < 300) || !mint.body?.token) {
700
870
  err(`✘ token mint failed (${mint.status}): ${JSON.stringify(mint.body)}`);
701
871
  return 1;
702
872
  }
873
+ if (mint.body.jti) {
874
+ try {
875
+ journalMinted(mint.body.jti, agentId, "quickstart");
876
+ }
877
+ catch (e) {
878
+ await revokeFreshMint(mint.body.jti, session, e, JOURNAL_PATH);
879
+ return 1;
880
+ }
881
+ }
703
882
  const envOut = {
704
883
  DECIDIO_API_URL: API, DECIDIO_AGENT_ID: agentId, DECIDIO_AGENT_DID: reg.body?.did ?? did,
705
884
  DECIDIO_AGENT_KEY: privateKeyBase64, DECIDIO_API_TOKEN: mint.body.token,
@@ -707,18 +886,18 @@ async function cmdQuickstart(opts = {}) {
707
886
  ...(mint.body.jti ? { DECIDIO_TOKEN_JTI: mint.body.jti } : {}),
708
887
  };
709
888
  if (envFile !== ENV_PATH) {
710
- if (existsSync(envFile)) {
711
- const prev = readFileSync(envFile, "utf8");
889
+ if (prevTourFile !== null) {
890
+ const prev = prevTourFile;
712
891
  const prevId = prev.match(/^DECIDIO_AGENT_ID=(.+)$/m)?.[1];
713
892
  const prevJti = prev.match(/^DECIDIO_TOKEN_JTI=(.+)$/m)?.[1];
893
+ const carried = new Set((prev.match(/^DECIDIO_ORPHANED_TOKEN_JTI=(.+)$/m)?.[1] ?? "").split(",").map((s) => s.trim()).filter(Boolean));
714
894
  if (prevId && prevId !== agentId) {
715
895
  if (prevJti) {
716
896
  const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(prevJti)}`, { method: "DELETE" }, session);
717
897
  if (rv.status >= 200 && rv.status < 300)
718
898
  log(` ✔ the previous tour agent (${prevId}) had its token REVOKED before this tour replaced it — no live credential is left behind`);
719
899
  else {
720
- const carried = [...(prev.match(/^DECIDIO_ORPHANED_TOKEN_JTI=(.+)$/m)?.[1] ?? "").split(",").filter(Boolean), prevJti];
721
- envOut.DECIDIO_ORPHANED_TOKEN_JTI = [...new Set(carried)].join(",");
900
+ carried.add(prevJti);
722
901
  log(` ⚠ could not revoke the previous tour's token (jti ${prevJti}, ${rv.status}) — it is still LIVE. Its id is kept in ${envFile} as DECIDIO_ORPHANED_TOKEN_JTI, and \`quickstart cleanup\` will retry it; or revoke it by hand: npx @decidio/sdk tokens revoke ${prevJti}`);
723
902
  }
724
903
  }
@@ -726,12 +905,38 @@ async function cmdQuickstart(opts = {}) {
726
905
  log(` ⚠ the previous tour file carried no revocation id, so its token (if any) must be revoked from your Decidio app.`);
727
906
  }
728
907
  }
908
+ if (carried.size) {
909
+ envOut.DECIDIO_ORPHANED_TOKEN_JTI = [...carried].join(",");
910
+ log(` • ${carried.size} orphaned token id(s) from earlier replacements carried forward — \`quickstart cleanup\` will retry them.`);
911
+ }
912
+ }
913
+ try {
914
+ writeSecretFile(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
915
+ if (mint.body.jti)
916
+ journalRetire(mint.body.jti, "persisted");
917
+ }
918
+ catch (e) {
919
+ if (mint.body?.jti)
920
+ await revokeFreshMint(mint.body.jti, session, e, envFile);
921
+ else
922
+ err(`✘ could not write ${envFile} (${e?.message ?? e}). The tour token minted a moment ago is LIVE and the server returned no revocation id - find and revoke it: npx @decidio/sdk tokens list --mine`);
923
+ return 1;
729
924
  }
730
- writeSecretFile(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
731
925
  log(` ✔ this directory already has an agent in .env — tour credentials went to ${envFile} (your agent is untouched)`);
732
926
  }
733
927
  else {
734
- upsertEnv(envOut);
928
+ try {
929
+ upsertEnv(envOut);
930
+ if (mint.body.jti)
931
+ journalRetire(mint.body.jti, "persisted");
932
+ }
933
+ catch (e) {
934
+ if (mint.body?.jti)
935
+ await revokeFreshMint(mint.body.jti, session, e, envFile);
936
+ else
937
+ err(`✘ could not write ${envFile} (${e?.message ?? e}). The tour token minted a moment ago is LIVE and the server returned no revocation id - find and revoke it: npx @decidio/sdk tokens list --mine`);
938
+ return 1;
939
+ }
735
940
  }
736
941
  const failTour = async (code) => {
737
942
  log("");
@@ -776,7 +981,9 @@ async function cmdQuickstart(opts = {}) {
776
981
  return failTour(1);
777
982
  }
778
983
  log(` ✔ $86,000 payInvoice ROUTED to a human (decision ${decisionId}) — this agent has no auto-approve rule,`);
779
- log(` so deny-by-default sends everything to a person. Nothing executes until someone decides.`);
984
+ log(` so the policy ROUTES it to a person (routing-by-default). That is distinct from a BLOCK, which no human can`);
985
+ log(` override: a block is a named rule, or the universal backstop when no active policy governs agents at all.`);
986
+ log(` Nothing executes until someone decides.`);
780
987
  log("");
781
988
  const interactive = process.stdin.isTTY;
782
989
  if (interactive) {
@@ -857,7 +1064,10 @@ async function cmdQuickstart(opts = {}) {
857
1064
  log(` const doIt = guard.protect(doItRaw, (a) => ({ action: "yourAction", amount: a.amount, scope: "YourScope" }));`);
858
1065
  log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
859
1066
  log("");
860
- log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")} · Next: the README's 7-step quickstart (durable, workers, adapters).`);
1067
+ log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")}`);
1068
+ log(`Next: the README section "Production mode: durable async resume" (the worker; the engine adapters follow it).`);
1069
+ log(`The README's seven-step quickstart is this same blocking flow in your own code - if you keep this agent, its .env`);
1070
+ log(`already works here, so skip that flow's init step.`);
861
1071
  log(`Built by OmniTwin — decidioai.com · Putting this in front of real approvals? decidioai.com/#early-access`);
862
1072
  let doCleanup = false;
863
1073
  if (interactive) {
@@ -876,14 +1086,47 @@ async function cmdQuickstart(opts = {}) {
876
1086
  async function quickstartCleanup(session, envFile) {
877
1087
  const env = (() => {
878
1088
  const out = {};
879
- if (existsSync(envFile))
880
- for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
881
- const m = line.match(/^\s*([A-Z_]+)=(.*)$/);
882
- if (m)
883
- out[m[1]] = m[2];
884
- }
1089
+ try {
1090
+ if (existsSync(envFile))
1091
+ for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
1092
+ const m = line.match(/^\s*([A-Z_]+)=(.*)$/);
1093
+ if (m)
1094
+ out[m[1]] = m[2];
1095
+ }
1096
+ }
1097
+ catch (e) {
1098
+ err(` ⚠ ${envFile} could not be read (${e?.message ?? e}) - sweeping the token recovery journal instead.`);
1099
+ }
885
1100
  return out;
886
1101
  })();
1102
+ let journalRevoked = 0;
1103
+ const journalStuck = [];
1104
+ for (const j of journalOutstanding()) {
1105
+ if (j.apiUrl && j.apiUrl !== API) {
1106
+ journalStuck.push(j.jti);
1107
+ err(` ⚠ journalled token ${j.jti} was minted against ${j.apiUrl}, not ${API} — revoke it there: DECIDIO_API_URL=${j.apiUrl} npx @decidio/sdk tokens revoke ${j.jti}`);
1108
+ continue;
1109
+ }
1110
+ const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(j.jti)}`, { method: "DELETE" }, session);
1111
+ if (rv.status >= 200 && rv.status < 300) {
1112
+ journalRetire(j.jti, "revoked");
1113
+ journalRevoked++;
1114
+ if (rv.body?.changed === false)
1115
+ log(` • journalled token ${j.jti} was already revoked — retiring its journal entry.`);
1116
+ else
1117
+ log(` ✔ journalled token revoked (jti ${j.jti}${j.label ? `, ${j.label}` : ""}) — its credential file never landed, so this was its only handle`);
1118
+ }
1119
+ else {
1120
+ journalStuck.push(j.jti);
1121
+ err(` ⚠ journalled token ${j.jti} could NOT be revoked (${rv.status}) — it is still live; its id stays in ${JOURNAL_PATH}.`);
1122
+ }
1123
+ }
1124
+ if (journalStuck.length) {
1125
+ err(`✘ ${journalStuck.length} journalled token(s) are STILL LIVE and could not be revoked. Nothing was deleted; retry, or revoke by hand:`);
1126
+ for (const jti of journalStuck)
1127
+ err(` npx @decidio/sdk tokens revoke ${jti}`);
1128
+ return 1;
1129
+ }
887
1130
  const orphans = (env.DECIDIO_ORPHANED_TOKEN_JTI ?? "").split(",").map((s) => s.trim()).filter(Boolean);
888
1131
  const stillOrphaned = [];
889
1132
  for (const jti of orphans) {
@@ -914,6 +1157,9 @@ async function quickstartCleanup(session, envFile) {
914
1157
  return 1;
915
1158
  }
916
1159
  }
1160
+ else if (journalRevoked > 0 && !existsSync(envFile) || (journalRevoked > 0 && Object.keys(env).length === 0)) {
1161
+ log(` • no credential file to clean: the journal sweep revoked ${journalRevoked} token(s) this directory owed.`);
1162
+ }
917
1163
  else {
918
1164
  err(`✘ no DECIDIO_TOKEN_JTI in ${envFile} (older tour) — this tour's token is STILL LIVE and this`);
919
1165
  err(` command cannot revoke it. Nothing was deleted. Find and revoke it, then re-run cleanup:`);
@@ -922,9 +1168,18 @@ async function quickstartCleanup(session, envFile) {
922
1168
  err(` (it also expires on its own within 7 days)`);
923
1169
  return 1;
924
1170
  }
1171
+ let envRaw = null;
925
1172
  if (existsSync(envFile)) {
1173
+ try {
1174
+ envRaw = readFileSync(envFile, "utf8");
1175
+ }
1176
+ catch (e) {
1177
+ err(` ⚠ ${envFile} could not be read (${e?.message ?? e}) — left as it is; remove it by hand once it is readable.`);
1178
+ }
1179
+ }
1180
+ if (envRaw !== null) {
926
1181
  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", "DECIDIO_ORPHANED_TOKEN_JTI"]);
927
- const kept = readFileSync(envFile, "utf8").split(/\r?\n/).filter((line) => {
1182
+ const kept = envRaw.split(/\r?\n/).filter((line) => {
928
1183
  const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=/);
929
1184
  return m ? !TOUR_KEYS.has(m[1]) : line.trim() !== "";
930
1185
  });
@@ -948,6 +1203,12 @@ async function quickstartCleanup(session, envFile) {
948
1203
  }
949
1204
  log(` • What remains, by design: the sealed decision record (records are permanent — that is the product)`);
950
1205
  log(` and the agent's registration row (inert without key or token; server-side deregistration is on the roadmap).`);
1206
+ if (existsSync(".decidio-pending")) {
1207
+ log(` • Also kept: .decidio-pending/ — this agent's own execution ledger: whatever this agent parked (call arguments,`);
1208
+ log(` execution evidence; never credentials, by design). Gitignored; owner-only where the OS supports it. If nothing is`);
1209
+ log(` pending and you do not want the history, remove it:`);
1210
+ log(` ${process.platform === "win32" ? "Remove-Item -Recurse .decidio-pending" : "rm -r .decidio-pending"}`);
1211
+ }
951
1212
  return 0;
952
1213
  }
953
1214
  async function cmdTokens(sub, jti, mineOnly = false) {
@@ -980,10 +1241,12 @@ async function cmdTokens(sub, jti, mineOnly = false) {
980
1241
  const rev = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, bearer);
981
1242
  if (rev.status >= 200 && rev.status < 300) {
982
1243
  if (rev.body?.changed === false) {
1244
+ journalRetire(jti, "revoked");
983
1245
  log(`• ${jti} was already revoked — nothing changed.`);
984
1246
  log(` Revocation is permanent; there is no re-revoke. See it: npx @decidio/sdk tokens list`);
985
1247
  return 1;
986
1248
  }
1249
+ journalRetire(jti, "revoked");
987
1250
  log(`✔ revoked ${jti} — the token is dead now (its agent stays registered; mint a new one with init).`);
988
1251
  return 0;
989
1252
  }
@@ -1095,7 +1358,6 @@ Docs: the README that shipped with this package — node_modules/@decidio/sdk/RE
1095
1358
  (quickstart, durable resume, workers, adapters).`;
1096
1359
  async function main(argv) {
1097
1360
  const [cmd, a, b, ...rest] = argv.slice(2);
1098
- const wantsHelp = (s) => s === "help" || s === "--help" || s === "-h";
1099
1361
  if (cmd === "--version" || cmd === "-v" || cmd === "-V" || cmd === "version") {
1100
1362
  log(VERSION);
1101
1363
  return 0;
@@ -1174,9 +1436,10 @@ async function main(argv) {
1174
1436
  case "quickstart": {
1175
1437
  if (argsIn.includes("cleanup")) {
1176
1438
  const envFile = existsSync(".env.quickstart") ? ".env.quickstart"
1177
- : (readEnvFile().DECIDIO_AGENT_ID ?? "").startsWith("quickstart-") ? ENV_PATH : undefined;
1439
+ : (readEnvFile().DECIDIO_AGENT_ID ?? "").startsWith("quickstart-") ? ENV_PATH
1440
+ : journalOutstanding().length ? ".env.quickstart" : undefined;
1178
1441
  if (!envFile) {
1179
- log("Nothing to clean up here: no .env.quickstart, and .env doesn't belong to a quickstart agent.");
1442
+ log("Nothing to clean up here: no .env.quickstart, .env doesn't belong to a quickstart agent, and the token recovery journal owes nothing.");
1180
1443
  return 0;
1181
1444
  }
1182
1445
  const session = await acquireSession("Cleaning the quickstart up");
package/dist/errors.js CHANGED
@@ -26,7 +26,11 @@ export class DecidioHttpError extends Error {
26
26
  (credential
27
27
  ? ` — the credential was rejected. The agent token may have expired or been revoked (re-running \`init\` for the same agent revokes the previous one). Mint a fresh one: npx @decidio/sdk init <agent-name>, then restart this process.`
28
28
  : retryable
29
- ? ` — this looks transient; retrying is safe (a retry creates a NEW request and can never double-execute).`
29
+ ? (/\/agent\/confirm/.test(endpoint)
30
+ ? ` — this looks transient. Your action ALREADY RAN; do NOT re-run it — only its report failed to land, and the door's own note says whether a report was kept to re-send.`
31
+ : /\/agent\/status/.test(endpoint)
32
+ ? ` — this looks transient; polling again is safe (a status read changes nothing).`
33
+ : ` — this looks transient; retrying is safe (a retry creates a NEW request and can never double-execute).`)
30
34
  : ` — the server refused the request itself, so retrying it unchanged will fail the same way. Fix the request (or the decision it names) first.`) +
31
35
  sideEffectNote(endpoint, typeof body?.code === "string" ? body.code : undefined));
32
36
  this.status = status;
@@ -3,6 +3,15 @@
3
3
  * we do not understand is never treated as evidence. */
4
4
  export declare const EVIDENCE_TIERS: readonly ["pending", "execution_unconfirmed", "agent_asserted", "sensor_observed", "application_confirmed", "independently_reconciled"];
5
5
  export type EvidenceTier = (typeof EVIDENCE_TIERS)[number];
6
+ /** Whether a recorded tier attests a COMMITTED execution - the same set the live reader uses, so a
7
+ * tombstone that kept only the tier can still answer the question honestly (retest V030-07). */
8
+ export declare function tierConfirms(tier: string | undefined | null): boolean;
9
+ /** The tier a `reported` tombstone records. The server caps a confirming tier whose proof failed to
10
+ * `execution_unconfirmed` (apps/api agent-confirm.ts, lint-locked CONFIRM-TRUTH), so a body that
11
+ * says otherwise is one the current server cannot emit; the tombstone applies the same rule, so
12
+ * reconfirm() - which derives from the tier - can never answer "confirmed" where resolve() answered
13
+ * "not confirmed" over the same record (invariant-guard, 0.3.3). Every other tier is stored verbatim. */
14
+ export declare function tombstoneTier(ev: EvidenceOutcome): string;
6
15
  export interface EvidenceOutcome {
7
16
  /** Decidio accepted the report and named the tier it recorded. */
8
17
  reportRecorded: boolean;
package/dist/evidence.js CHANGED
@@ -7,6 +7,10 @@ export const EVIDENCE_TIERS = [
7
7
  "independently_reconciled",
8
8
  ];
9
9
  const CONFIRMING = ["sensor_observed", "application_confirmed", "independently_reconciled"];
10
+ export function tierConfirms(tier) { return typeof tier === "string" && CONFIRMING.includes(tier); }
11
+ export function tombstoneTier(ev) {
12
+ return ev.executionReportVerified === false && tierConfirms(ev.evidenceTier) ? "execution_unconfirmed" : (ev.evidenceTier ?? "recorded");
13
+ }
10
14
  export function describeTierGap(capturedResponse) {
11
15
  const r = (capturedResponse ?? {});
12
16
  const id = r.id ?? r.recordId ?? r.Id ?? null;
@@ -47,7 +51,7 @@ export function readConfirmResponse(body, opts) {
47
51
  outcome.executionReportReason = rpt.reason;
48
52
  if (proofExplicitlyFailed) {
49
53
  const why = typeof rpt.reason === "string" && rpt.reason ? ` (${rpt.reason})` : "";
50
- outcome.diagnostic = `the signed execution report did NOT verify${why}, so this is NOT a confirmed execution — the gate reported tier "${tier}", and this SDK refuses to read an unproven attestation as confirmation. Your action DID run; check the agent's key/DID/registration (npx @decidio/sdk doctor), then ${retry}`;
54
+ outcome.diagnostic = `the signed execution report did NOT verify${why}, so this is NOT a confirmed execution — the gate reported tier "${tier}", and this SDK refuses to read an unproven attestation as confirmation. Your action DID run and the report IS recorded at that tier - there is nothing to re-send, and re-sending would not change the verdict; the record stands as unverified. Check the agent's key/DID/registration (npx @decidio/sdk doctor) so the NEXT execution verifies.`;
51
55
  }
52
56
  }
53
57
  return outcome;
package/dist/index.d.ts CHANGED
@@ -12,7 +12,10 @@ export { readConfirmResponse, buildConfirmBody, describeTierGap, EVIDENCE_TIERS
12
12
  export type { EvidenceOutcome, EvidenceTier } from "./evidence.js";
13
13
  export type { RequesterIdentity } from "./signing.js";
14
14
  export interface GuardConfig {
15
- /** Base URL of the Decidio gate, e.g. "https://api.decidio.io". */
15
+ /** Base URL of the Decidio gate. The hosted sandbox is "https://decidio-api.onrender.com",
16
+ * which is also the default when this is unset; a self-hosted deployment is your own origin.
17
+ * (This example named a host the product does not use — it shipped in the .d.ts, so it was the
18
+ * first URL a typing-aware editor suggested to anyone configuring the SDK.) */
16
19
  apiUrl: string;
17
20
  /** The agent identity registered with Decidio (onboarding wizard), e.g. "salesforce/SalesforceBot". */
18
21
  agentId: string;
@@ -100,6 +103,11 @@ export declare class DecidioNetworkError extends Error {
100
103
  code: string | undefined;
101
104
  constructor(endpoint: string, op: string, code: string | undefined, cause: unknown);
102
105
  }
106
+ /** THE ONE DESCRIBE VALIDATION (prodsec V033-05). The core wrapper refused a blank `action` and a
107
+ * negative / non-finite `amount` before routing; the adapter gate canonicalized and posted them, so
108
+ * every engine door reached the mock authorization path with a request the core would have refused.
109
+ * Exported so `adapters/_gate.ts` calls the same function rather than a second copy that can drift. */
110
+ export declare function assertDescribed(ctx: ApprovalContext): void;
103
111
  /**
104
112
  * Wrap an async action behind the Decidio gate. `describe` extracts the approval
105
113
  * context (action, amount, scope) from the call arguments. Returns a function with
package/dist/index.js CHANGED
@@ -78,6 +78,14 @@ async function reach(f, url, op, init) {
78
78
  throw new DecidioNetworkError(url, op, e?.cause?.code ?? e?.code, e);
79
79
  }
80
80
  }
81
+ export function assertDescribed(ctx) {
82
+ if (typeof ctx?.action !== "string" || ctx.action.trim().length === 0) {
83
+ throw new Error(`[decidio] describe(...) must return an \`action\` string naming what is being asked (got ${JSON.stringify(ctx?.action ?? null)}). Nothing was routed or executed.`);
84
+ }
85
+ if (typeof ctx.amount !== "number" || !Number.isFinite(ctx.amount) || ctx.amount < 0) {
86
+ throw new Error(`[decidio] describe(...) must return a finite, non-negative \`amount\` — the policy decides on it, so a request without one is not a request Decidio can rule on (got ${JSON.stringify(ctx.amount ?? null)}). Use 0 for an action with no monetary value. Nothing was routed or executed.`);
87
+ }
88
+ }
81
89
  async function post(cfg, path, body) {
82
90
  const f = cfg.fetchImpl ?? fetch;
83
91
  assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
@@ -138,16 +146,11 @@ export function withApproval(fn, describe, config, opts) {
138
146
  }
139
147
  throw e;
140
148
  }
149
+ ctx = canonicalize(ctx, "describe(...)");
141
150
  const requesterIdentity = (config.agentKey && config.agentDid)
142
151
  ? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
143
152
  : undefined;
144
- canonicalize(ctx, "describe(...)");
145
- if (typeof ctx?.action !== "string" || ctx.action.trim().length === 0) {
146
- throw new Error(`[decidio] describe(...) must return an \`action\` string naming what is being asked (got ${JSON.stringify(ctx?.action ?? null)}). Nothing was routed or executed.`);
147
- }
148
- if (typeof ctx.amount !== "number" || !Number.isFinite(ctx.amount) || ctx.amount < 0) {
149
- throw new Error(`[decidio] describe(...) must return a finite, non-negative \`amount\` — the policy decides on it, so a request without one is not a request Decidio can rule on (got ${JSON.stringify(ctx.amount ?? null)}). Use 0 for an action with no monetary value. Nothing was routed or executed.`);
150
- }
153
+ assertDescribed(ctx);
151
154
  const auth = await post(config, "/agent/authorize", {
152
155
  requester: config.agentId, action: ctx.action, amount: ctx.amount,
153
156
  scope: ctx.scope, sourceSystem: config.sourceSystem,
package/dist/inngest.js CHANGED
@@ -28,6 +28,8 @@ export async function decidioGateStep(args) {
28
28
  export function createInngestResumeBridge(opts) {
29
29
  const secret = opts.webhookSecret ?? "";
30
30
  const eventName = opts.eventName ?? DECIDIO_RESUME_EVENT;
31
+ if (!secret && opts.allowUnsigned)
32
+ console.warn("[decidio] inngest resume bridge: allowUnsigned=true and no webhookSecret — resume signals are NOT authenticated; anyone who can reach this endpoint can emit a resume event. Development only.");
31
33
  return (req, res) => {
32
34
  if (req.method !== "POST") {
33
35
  res.statusCode = 405;