@decidio/sdk 0.1.11 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import { createServer } from "node:http";
6
6
  import { execFileSync } from "node:child_process";
7
7
  import { createInterface } from "node:readline";
8
8
  import { createRequire } from "node:module";
9
- import { generateKeypair } from "./signing.js";
9
+ import { generateKeypair, assertSafeTransport } from "./signing.js";
10
10
  import { withApproval, DecidioRateLimitError, didFromPrivateKey } from "./index.js";
11
11
  import { verifyReceipt, runVerify } from "@decidio/verify";
12
12
  const VERSION = (() => {
@@ -65,9 +65,14 @@ async function api(path, init = {}, bearer) {
65
65
  err(` Fix the file's permissions, or set DECIDIO_API_URL explicitly for this command. Nothing was sent.`);
66
66
  process.exit(1);
67
67
  }
68
- if (auth && /^http:[/][/]/i.test(API) && !/^http:[/][/](localhost|127.0.0.1|.::1.)([:/]|$)/i.test(API)) {
69
- err(`✘ refusing to send credentials over plaintext http to ${API} — use https, or localhost for local dev. Nothing was sent.`);
70
- process.exit(1);
68
+ if (auth) {
69
+ try {
70
+ assertSafeTransport(API, true);
71
+ }
72
+ catch (e) {
73
+ err(`✘ ${String(e?.message ?? e).replace(/^\[decidio\] /, "")}`);
74
+ process.exit(1);
75
+ }
71
76
  }
72
77
  let res;
73
78
  try {
@@ -189,7 +194,10 @@ function ask(question, mute = false) {
189
194
  });
190
195
  }
191
196
  async function acquireSession(purpose) {
192
- if (/^http:\/\//i.test(API) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]|$)/i.test(API)) {
197
+ try {
198
+ assertSafeTransport(API, true);
199
+ }
200
+ catch {
193
201
  err(`✘ refusing to send workspace credentials over plaintext http to ${API}.`);
194
202
  err(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
195
203
  return null;
@@ -250,6 +258,18 @@ async function cmdInit(agentId, opts = {}) {
250
258
  }
251
259
  const { did: localDid, privateKeyBase64: localKey } = generateKeypair();
252
260
  const registerBody = JSON.stringify({ agentId, did: localDid, ...(sourceSystem ? { sourceSystem } : {}) });
261
+ let envPrepared = false;
262
+ const prepareEnvForWrite = () => {
263
+ if (envPrepared)
264
+ return;
265
+ envPrepared = true;
266
+ ensureGitignore();
267
+ if (opts.force && existingKey && existingEnv.DECIDIO_AGENT_ID && existingEnv.DECIDIO_AGENT_ID !== agentId) {
268
+ const backup = `.env.${existingEnv.DECIDIO_AGENT_ID.replace(/[^\w.-]/g, "_")}.bak`;
269
+ writeSecretFile(backup, readFileSync(ENV_PATH, "utf8"));
270
+ log(`✔ backed the previous agent's .env up to ${backup} (its key would otherwise be unrecoverable)`);
271
+ }
272
+ };
253
273
  let session = null;
254
274
  let r = await api("/api/agents/register", { method: "POST", body: registerBody });
255
275
  if (r.status === 401 || r.status === 403) {
@@ -280,6 +300,8 @@ async function cmdInit(agentId, opts = {}) {
280
300
  did = r.body?.did;
281
301
  privateKey = localKey;
282
302
  log(`✔ registered (did ${did}) — key generated locally; only the public half went to Decidio`);
303
+ prepareEnvForWrite();
304
+ upsertEnv({ DECIDIO_AGENT_ID: agentId, DECIDIO_AGENT_DID: did ?? localDid, DECIDIO_AGENT_KEY: localKey });
283
305
  }
284
306
  else {
285
307
  err(`✘ registration failed (${r.status}): ${JSON.stringify(r.body)}`);
@@ -302,7 +324,7 @@ async function cmdInit(agentId, opts = {}) {
302
324
  }
303
325
  else if (session) {
304
326
  err(`✘ could not mint the agent's API token (${mint.status}): ${JSON.stringify(mint.body)}`);
305
- err(` Without it the SDK has no runtime credential. Fix the cause above and re-run init — it's safe to repeat.`);
327
+ err(` Without it the SDK has no runtime credential. The agent IS registered and its key is saved in ${ENV_PATH} — fix the cause above and re-run init; it will reuse that key and mint again.`);
306
328
  return 1;
307
329
  }
308
330
  else {
@@ -323,12 +345,7 @@ async function cmdInit(agentId, opts = {}) {
323
345
  envOut.DECIDIO_WORKSPACE_ID = workspaceId;
324
346
  if (tokenJti)
325
347
  envOut.DECIDIO_TOKEN_JTI = tokenJti;
326
- ensureGitignore();
327
- if (opts.force && existingKey && existingEnv.DECIDIO_AGENT_ID && existingEnv.DECIDIO_AGENT_ID !== agentId) {
328
- const backup = `.env.${existingEnv.DECIDIO_AGENT_ID.replace(/[^\w.-]/g, "_")}.bak`;
329
- writeSecretFile(backup, readFileSync(ENV_PATH, "utf8"));
330
- log(`✔ backed the previous agent's .env up to ${backup} (its key would otherwise be unrecoverable)`);
331
- }
348
+ prepareEnvForWrite();
332
349
  upsertEnv(envOut);
333
350
  for (const [k, v] of Object.entries(envOut))
334
351
  process.env[k] = v;
@@ -350,8 +367,9 @@ async function cmdInit(agentId, opts = {}) {
350
367
  log(` { mode: "blocking" }, // blocking = watch it happen live; durable is the production mode`);
351
368
  log(` );`);
352
369
  log("");
353
- log(` const result = await payInvoice({ id: "INV-2026-001", amount: 86_000 });`);
354
- log(` console.log("executed after approval:", result);`);
370
+ log(` const { value, confirmation } = await payInvoice({ id: "INV-2026-001", amount: 86_000 });`);
371
+ log(` console.log("executed after approval:", value);`);
372
+ log(` console.log("Decidio recorded it as:", confirmation.evidenceTier);`);
355
373
  log("");
356
374
  log(" 2. Run it, approve it, watch it complete:");
357
375
  log("");
@@ -403,13 +421,14 @@ async function cmdDoctor() {
403
421
  if (typeof payload?.exp === "number") {
404
422
  const msLeft = payload.exp * 1000 - Date.now();
405
423
  const days = Math.floor((msLeft + 60_000) / 86_400_000);
406
- const left = msLeft < 86_400_000 ? `${Math.max(1, Math.floor(msLeft / 3_600_000))} hour(s)` : `${days} day(s)`;
424
+ const left = msLeft < 86_400_000 ? `${Math.max(1, Math.floor(msLeft / 3_600_000))} hour(s)` : `${Math.round(msLeft / 86_400_000)} day(s)`;
425
+ const daysRounded = Math.round(msLeft / 86_400_000);
407
426
  if (msLeft < 0)
408
427
  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.`);
409
428
  else if (days <= 14)
410
429
  warn(`agent token: expires in ${left} — mint a fresh one before it does: npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}`);
411
430
  else
412
- log(`✔ agent token: unexpired for another ${days} days (validity is decided by the server — see the token-scope check below)`);
431
+ log(`✔ agent token: unexpired for another ${daysRounded} days (validity is decided by the server — see the token-scope check below)`);
413
432
  }
414
433
  }
415
434
  catch { }
@@ -568,12 +587,18 @@ async function cmdReceipt(id, jsonMode = false) {
568
587
  const detail = await api(`/api/records/${encodeURIComponent(id)}`, {}, bearer);
569
588
  const conf = detail.body?.record?.confirmation;
570
589
  const issuer = r.body?.issuer;
590
+ const resolved = r.body?.resolved;
571
591
  if (jsonMode) {
572
- log(JSON.stringify({ file: name, decisionId: id, evidenceTier: conf?.status ?? null, confirmed: !!conf?.confirmed, issuer: issuer ?? null }, null, 2));
592
+ log(JSON.stringify({ file: name, decisionId: id, evidenceTier: conf?.status ?? null, confirmed: !!conf?.confirmed, issuer: issuer ?? null, ...(resolved ? { resolved } : {}) }, null, 2));
573
593
  return 0;
574
594
  }
575
595
  if (conf?.status)
576
596
  log(` Sealed credential: the authority decision (its confirmation field reads "pending" forever — immutable). Current record evidence tier: ${conf.status}${conf.confirmed ? " (confirmed)" : ""}.`);
597
+ if (resolved && Object.keys(resolved).length) {
598
+ log(` Resolved for you (${Object.keys(resolved).length} field${Object.keys(resolved).length === 1 ? "" : "s"} your access covers — the FILE keeps the tokens, so it stays verifiable and safe to hand on):`);
599
+ for (const [tok, val] of Object.entries(resolved))
600
+ log(` ${tok} → ${val}`);
601
+ }
577
602
  log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
578
603
  if (issuer) {
579
604
  log(` npx @decidio/sdk verify --issuer ${issuer} ${name}`);
@@ -647,12 +672,31 @@ async function cmdQuickstart(opts = {}) {
647
672
  const session = await acquireSession("The quickstart");
648
673
  if (!session)
649
674
  return 1;
650
- const agentId = `quickstart-${randomBytes(2).toString("hex")}`;
651
- log(`① Registering agent "${agentId}" (fresh name each run — no collisions)…`);
675
+ const envFile = readEnvFile().DECIDIO_AGENT_KEY ? ".env.quickstart" : ENV_PATH;
676
+ if (!assertNotTracked(envFile))
677
+ return 1;
678
+ ensureGitignore();
679
+ let agentId = `quickstart-${randomBytes(5).toString("hex")}`;
680
+ log(`① Registering agent "${agentId}"…`);
652
681
  const { did, privateKeyBase64 } = generateKeypair();
653
- const reg = await api("/api/agents/register", { method: "POST", body: JSON.stringify({ agentId, did }) }, session);
682
+ let reg = await api("/api/agents/register", { method: "POST", body: JSON.stringify({ agentId, did }) }, session);
683
+ for (let attempt = 0; reg.status === 409 && attempt < 3; attempt++) {
684
+ agentId = `quickstart-${randomBytes(5).toString("hex")}`;
685
+ reg = await api("/api/agents/register", { method: "POST", body: JSON.stringify({ agentId, did }) }, session);
686
+ if (reg.status >= 200 && reg.status < 300)
687
+ log(` (that name was taken — using "${agentId}" instead)`);
688
+ }
654
689
  if (!(reg.status >= 200 && reg.status < 300)) {
655
- err(`✘ registration failed (${reg.status}): ${JSON.stringify(reg.body)}`);
690
+ err(`✘ could not register the tour's agent (${reg.status}): ${JSON.stringify(reg.body)}`);
691
+ if (reg.status === 409) {
692
+ err(` Every generated name collided, which should be almost impossible — something is likely`);
693
+ err(` wrong with this workspace's agent list rather than with your setup.`);
694
+ err(` See what is registered: npx @decidio/sdk tokens list`);
695
+ }
696
+ else {
697
+ err(` Check the target and your sign-in, then re-run: npx @decidio/sdk doctor`);
698
+ }
699
+ err(` Nothing was written to this directory.`);
656
700
  return 1;
657
701
  }
658
702
  const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "quickstart", expiresInDays: 7 }) }, session);
@@ -666,30 +710,36 @@ async function cmdQuickstart(opts = {}) {
666
710
  DECIDIO_WORKSPACE_ID: mint.body.workspaceId ?? "",
667
711
  ...(mint.body.jti ? { DECIDIO_TOKEN_JTI: mint.body.jti } : {}),
668
712
  };
669
- let envFile = ENV_PATH;
670
- if (readEnvFile().DECIDIO_AGENT_KEY) {
671
- envFile = ".env.quickstart";
672
- if (!assertNotTracked(envFile))
673
- return 1;
674
- ensureGitignore();
713
+ if (envFile !== ENV_PATH) {
675
714
  if (existsSync(envFile)) {
676
715
  const prev = readFileSync(envFile, "utf8");
677
716
  const prevId = prev.match(/^DECIDIO_AGENT_ID=(.+)$/m)?.[1];
717
+ const prevJti = prev.match(/^DECIDIO_TOKEN_JTI=(.+)$/m)?.[1];
678
718
  if (prevId && prevId !== agentId) {
679
- const bak = `.env.quickstart.${prevId.replace(/[^\w.-]/g, "_")}.bak`;
680
- writeSecretFile(bak, prev);
681
- log(` ✔ previous tour agent (${prevId}) backed up to ${bak} — its key would otherwise be unrecoverable (one backup PER agent: serial tours never clobber an earlier backup)`);
719
+ if (prevJti) {
720
+ const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(prevJti)}`, { method: "DELETE" }, session);
721
+ if (rv.status >= 200 && rv.status < 300)
722
+ log(` ✔ the previous tour agent (${prevId}) had its token REVOKED before this tour replaced it — no live credential is left behind`);
723
+ else
724
+ log(` ⚠ could not revoke the previous tour's token (jti ${prevJti}, ${rv.status}) — revoke it by hand: npx @decidio/sdk tokens revoke ${prevJti}`);
725
+ }
726
+ else {
727
+ log(` ⚠ the previous tour file carried no revocation id, so its token (if any) must be revoked from your Decidio app.`);
728
+ }
682
729
  }
683
730
  }
684
731
  writeSecretFile(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
685
732
  log(` ✔ this directory already has an agent in .env — tour credentials went to ${envFile} (your agent is untouched)`);
686
733
  }
687
734
  else {
688
- if (!assertNotTracked(ENV_PATH))
689
- return 1;
690
- ensureGitignore();
691
735
  upsertEnv(envOut);
692
736
  }
737
+ const failTour = async (code) => {
738
+ log("");
739
+ log(" The tour did not complete — cleaning up the credentials it created:");
740
+ await quickstartCleanup(session, envFile).catch(() => { });
741
+ return code;
742
+ };
693
743
  log(` ✔ key generated locally (public half registered) · 7-day floor-limited token minted${mint.body.expiresAt ? ` (expires ${mint.body.expiresAt})` : ""} · ${envFile} written`);
694
744
  log("");
695
745
  log(`② Gating a demo action — the tour runs this (blocking mode: watch it live):`);
@@ -716,15 +766,15 @@ async function cmdQuickstart(opts = {}) {
716
766
  err(`✘ this workspace's governed-action allowance is used up${earlyError.limit != null ? ` (${earlyError.limit} actions/hour on lab workspaces)` : ""}.`);
717
767
  err(` Nothing was routed or executed — the cap is the sandbox protecting itself, not a fault${earlyError.resetAt ? `. It resets ${earlyError.resetAt}` : ""}.`);
718
768
  err(` Re-run the tour after the reset (or talk to us about a production workspace).`);
719
- return 1;
769
+ return failTour(1);
720
770
  }
721
771
  if (!decisionId && earlyError) {
722
772
  err(`✘ the gate refused before routing: ${earlyError?.message ?? earlyError}`);
723
- return 1;
773
+ return failTour(1);
724
774
  }
725
775
  if (!decisionId) {
726
776
  err("✘ the gate did not route within 30s — check connectivity (npx @decidio/sdk doctor) and re-run.");
727
- return 1;
777
+ return failTour(1);
728
778
  }
729
779
  log(` ✔ $86,000 payInvoice ROUTED to a human (decision ${decisionId}) — this agent has no auto-approve rule,`);
730
780
  log(` so deny-by-default sends everything to a person. Nothing executes until someone decides.`);
@@ -749,35 +799,50 @@ async function cmdQuickstart(opts = {}) {
749
799
  log(" Then re-run the quickstart interactively to watch the whole loop — or, for CI, re-run");
750
800
  log(" with --auto-approve-demo to let the tour approve itself under that explicit label.");
751
801
  log(` Tidy up this tour's agent + token any time: npx @decidio/sdk quickstart cleanup`);
752
- process.exit(0);
802
+ log("");
803
+ log(" (exit 3 = the tour stopped at the human moment. Nothing executed and no receipt was");
804
+ log(" written, so this run is deliberately not reported as a completed tour.)");
805
+ log("");
806
+ log(` NOTE: this tour's credentials are still live in ${envFile} (a 7-day token + the agent's`);
807
+ log(` key), because the decision above is still waiting for you. Once you have approved it —`);
808
+ log(` or if you abandon the tour — remove them: npx @decidio/sdk quickstart cleanup`);
809
+ process.exit(3);
753
810
  }
754
811
  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);
755
812
  if (!(ap.status >= 200 && ap.status < 300)) {
756
813
  err(`✘ approve failed (${ap.status}): ${JSON.stringify(ap.body)}`);
757
- return 1;
814
+ return failTour(1);
758
815
  }
759
816
  log(` ✔ approved${ap.body?.alreadyResolved ? " (it was already approved from your app — even better)" : ""}`);
760
- let result;
817
+ let done;
761
818
  try {
762
- result = await running;
819
+ done = await running;
763
820
  }
764
821
  catch (e) {
765
822
  err(`✘ the gated call did not complete: ${e?.message ?? e}`);
766
- return 1;
823
+ return failTour(1);
767
824
  }
768
- log(` ✔ your function executed AFTER approval: ${JSON.stringify(result)}`);
825
+ log(` ✔ your function executed AFTER approval: ${JSON.stringify(done.value)}`);
826
+ if (done.confirmation?.evidenceTier)
827
+ log(` …and the call itself told you what was recorded: confirmation.evidenceTier = ${done.confirmation.evidenceTier}`);
769
828
  log("");
770
829
  log("④ Downloading the sealed Authority Receipt and verifying it OFFLINE…");
771
830
  const rec = await api(`/api/records/${encodeURIComponent(decisionId)}/verify`, {}, session);
772
831
  if (!(rec.status >= 200 && rec.status < 300) || !rec.body?.credential) {
773
832
  err(`✘ receipt fetch failed (${rec.status})`);
774
- return 1;
833
+ return failTour(1);
775
834
  }
776
835
  const fname = `decidio-receipt-${decisionId.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
777
836
  writeFileSync(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
778
837
  const issuer = rec.body?.issuer;
779
838
  const v = verifyReceipt(rec.body.credential, issuer);
780
- log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? `, PINNED to your workspace issuer ${issuer}` : "; issuer unpinned"})`);
839
+ log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? `, PINNED to your workspace issuer ${issuer}` : ""})`);
840
+ if (!issuer) {
841
+ err(` ⚠ the server did not return an issuer DID, so this check ran UNPINNED — it proves the`);
842
+ err(` receipt is internally consistent, NOT who issued it (a self-signed forgery also passes).`);
843
+ err(` Get your workspace's issuer DID from the app's record-verify panel, then re-check:`);
844
+ err(` npx @decidio/sdk verify --issuer <did:key:...> ${fname}`);
845
+ }
781
846
  if (issuer)
782
847
  log(` Keep that DID: it is your trust anchor — npx @decidio/sdk verify --issuer ${issuer} <receipt.json>`);
783
848
  const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
@@ -805,9 +870,8 @@ async function cmdQuickstart(opts = {}) {
805
870
  doCleanup = true;
806
871
  log(`\nAutomated run: cleaning the tour up (pass --keep-agent to keep the agent + token).`);
807
872
  }
808
- if (doCleanup)
809
- await quickstartCleanup(session, envFile);
810
- return v.valid ? 0 : 1;
873
+ const cleanupCode = doCleanup ? await quickstartCleanup(session, envFile) : 0;
874
+ return v.valid ? (cleanupCode || 0) : 1;
811
875
  }
812
876
  async function quickstartCleanup(session, envFile) {
813
877
  const env = (() => {
@@ -834,7 +898,12 @@ async function quickstartCleanup(session, envFile) {
834
898
  }
835
899
  }
836
900
  else {
837
- 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.`);
901
+ err(`✘ no DECIDIO_TOKEN_JTI in ${envFile} (older tour) — this tour's token is STILL LIVE and this`);
902
+ err(` command cannot revoke it. Nothing was deleted. Find and revoke it, then re-run cleanup:`);
903
+ err(` npx @decidio/sdk tokens list`);
904
+ err(` npx @decidio/sdk tokens revoke <jti>`);
905
+ err(` (it also expires on its own within 7 days)`);
906
+ return 1;
838
907
  }
839
908
  if (existsSync(envFile)) {
840
909
  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"]);
@@ -971,6 +1040,8 @@ const USAGE = `decidio — the Decidio SDK command line
971
1040
  at the routed decision; --auto-approve-demo for CI, labeled
972
1041
  automated; ends with a keep-or-cleanup choice)
973
1042
  npx @decidio/sdk quickstart cleanup revoke the tour's token + delete its local credentials
1043
+ npx @decidio/sdk quickstart --auto-approve-demo headless tour; cleans up after itself
1044
+ (add --keep-agent to KEEP the tour agent + token)
974
1045
  npx @decidio/sdk tokens list [--mine] agent tokens with status (active/expired/REVOKED) by
975
1046
  jti — token values are never re-shown; the list is
976
1047
  workspace-wide, --mine narrows to this directory's agent
@@ -984,8 +1055,10 @@ const USAGE = `decidio — the Decidio SDK command line
984
1055
  npx @decidio/sdk approvals [--json] [--mine] list pending agent approvals
985
1056
  npx @decidio/sdk approvals approve <id> [reason]
986
1057
  npx @decidio/sdk approvals reject <id> [reason]
987
- the reason is FREE TEXT and is sealed verbatim. If it
988
- starts with a dash, put it after -- or use --reason:
1058
+ the reason is FREE TEXT, sent exactly as typed. It seals
1059
+ immutably; personal or sensitive values are tokenized at
1060
+ rest, so the STORED text can differ (see: receipt <id>).
1061
+ If it starts with a dash, put it after -- or use --reason:
989
1062
  ... approve <id> -- "-50% cut, per the board"
990
1063
  ... approve <id> --reason "-50% cut, per the board"
991
1064
  npx @decidio/sdk receipt <id> download a sealed decision's Authority Receipt (.json)
@@ -996,7 +1069,9 @@ const USAGE = `decidio — the Decidio SDK command line
996
1069
  Every NETWORK command talks to the hosted sandbox (https://decidio-api.onrender.com) unless
997
1070
  DECIDIO_API_URL says otherwise; the target host is announced on stderr each run. "verify" is
998
1071
  fully offline — it touches no network and reads no .env.
999
- Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error.
1072
+ Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error ·
1073
+ 3 quickstart stopped at the human moment (headless: routed, but nothing executed and no
1074
+ receipt written — pass --auto-approve-demo for a run that completes unattended).
1000
1075
  Errors, and usage shown on ERROR paths, go to stderr; stdout carries results only (safe to
1001
1076
  pipe). Asked-for help — including this bare invocation — IS a result, so it prints here.
1002
1077
  Docs: the README that shipped with this package — node_modules/@decidio/sdk/README.md
@@ -1037,6 +1112,13 @@ async function main(argv) {
1037
1112
  positionals.push(...argsIn.slice(i + 1));
1038
1113
  break;
1039
1114
  }
1115
+ const inFreeTextTail = cmd === "approvals"
1116
+ && (positionals[0] === "approve" || positionals[0] === "reject")
1117
+ && positionals.length >= 2;
1118
+ if (!inFreeTextTail && a.startsWith("-") && !a.startsWith("--")) {
1119
+ err(`✘ unknown option "${a}" for \`${cmd}\` — refusing to guess (a flag you meant to matter must never be silently ignored). Short flags are not supported; use the long form.\n\n${USAGE}`);
1120
+ return 2;
1121
+ }
1040
1122
  if (!a.startsWith("--")) {
1041
1123
  positionals.push(a);
1042
1124
  continue;
@@ -1108,12 +1190,15 @@ async function main(argv) {
1108
1190
  const head = dashdash >= 0 ? argsIn.slice(0, dashdash) : argsIn;
1109
1191
  const literal = dashdash >= 0 ? argsIn.slice(dashdash + 1) : [];
1110
1192
  const rIdx = head.indexOf("--reason");
1193
+ const rEq = head.find((x) => x.startsWith("--reason="));
1111
1194
  const flagless = head.filter((x, i) => !x.startsWith("-") && !(rIdx >= 0 && i === rIdx + 1));
1112
1195
  const sub = flagless[0];
1113
1196
  const id = flagless[1];
1114
1197
  let reason;
1115
1198
  if (rIdx >= 0)
1116
1199
  reason = head[rIdx + 1];
1200
+ else if (rEq !== undefined)
1201
+ reason = rEq.slice("--reason=".length);
1117
1202
  else if (literal.length)
1118
1203
  reason = literal.join(" ");
1119
1204
  else if (sub && id) {
@@ -1121,10 +1206,14 @@ async function main(argv) {
1121
1206
  const rest = head.slice(after).filter((x) => x !== "--json" && x !== "--mine");
1122
1207
  reason = rest.length ? rest.join(" ") : undefined;
1123
1208
  }
1124
- if (rIdx >= 0 && (reason === undefined || reason.startsWith("-"))) {
1209
+ if ((rIdx >= 0 || rEq !== undefined) && (reason === undefined || reason === "" || (rIdx >= 0 && reason.startsWith("--")))) {
1125
1210
  err(`✘ --reason needs the text to record (quote it if it contains spaces): npx @decidio/sdk approvals ${sub ?? "approve"} <id> --reason "over budget -50% cut required"`);
1126
1211
  return 2;
1127
1212
  }
1213
+ if (reason !== undefined && sub !== "approve" && sub !== "reject") {
1214
+ err(`✘ --reason only applies to approve/reject (a sealed reason needs a decision to seal onto): npx @decidio/sdk approvals approve <id> --reason "..."`);
1215
+ return 2;
1216
+ }
1128
1217
  return cmdApprovals(sub, id, reason, { json: head.includes("--json"), mine: head.includes("--mine") });
1129
1218
  }
1130
1219
  case "receipt": return cmdReceipt(argsIn.find((x) => !x.startsWith("-")), argsIn.includes("--json"));
package/dist/errors.d.ts CHANGED
@@ -1,8 +1,3 @@
1
- /** A gate response this SDK could not accept, carrying what a caller needs to BRANCH on rather
2
- * than a stringified body they would have to parse. `status` is the HTTP status; `retryable`
3
- * distinguishes a terminal refusal (401/403 — a credential problem no retry fixes) from a
4
- * transient one (5xx, 429). Every long-running agent hits the terminal case eventually: an
5
- * agent token expires, or `init` is re-run and revokes it. */
6
1
  export declare class DecidioHttpError extends Error {
7
2
  status: number;
8
3
  endpoint: string;
@@ -10,3 +5,15 @@ export declare class DecidioHttpError extends Error {
10
5
  readonly retryable: boolean;
11
6
  constructor(status: number, endpoint: string, body?: unknown | undefined);
12
7
  }
8
+ /** ONE rule for what counts as a usable decision id, returning a REASON rather than throwing, so
9
+ * every door can apply the same check and raise its own error type. (`resume.ts` cannot import
10
+ * `index.ts`, which is why the check was copy-pasted into four places and drifted.)
11
+ *
12
+ * All four copies tested truthiness only, so `" "` passed every one of them: the blocking path
13
+ * would poll `/agent/status/%20%20%20`, the durable path would park under a whitespace key, and
14
+ * the confirmation would attest a decision nobody can look up. Control characters are refused for
15
+ * the same reason a reason-string is sanitised — an id goes into a URL path, a filename and an
16
+ * audit line. Nothing is silently trimmed: an id that needs trimming is not the id the server
17
+ * sent, and quietly rewriting an identifier is how two systems end up disagreeing about which
18
+ * decision they mean. */
19
+ export declare function decisionIdProblem(raw: unknown): string | null;
package/dist/errors.js CHANGED
@@ -1,18 +1,50 @@
1
+ const RETRYABLE_STATUS = (status) => status === 408 || status === 429 || (status >= 500 && status !== 501);
2
+ function sideEffectNote(endpoint) {
3
+ if (endpoint.startsWith("/agent/confirm")) {
4
+ return " Your action ALREADY RAN — only its report failed. Do NOT re-run it.";
5
+ }
6
+ if (endpoint.startsWith("/agent/status")) {
7
+ return " This call is a read, so it changed nothing; the decision itself is unaffected.";
8
+ }
9
+ return " Nothing was routed or executed.";
10
+ }
1
11
  export class DecidioHttpError extends Error {
2
12
  status;
3
13
  endpoint;
4
14
  body;
5
15
  retryable;
6
16
  constructor(status, endpoint, body) {
7
- const terminal = status === 401 || status === 403;
17
+ const credential = status === 401 || status === 403;
18
+ const retryable = !credential && RETRYABLE_STATUS(status);
8
19
  super(`[decidio] ${endpoint} refused this call (HTTP ${status})` +
9
- (terminal
10
- ? ` — 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. Nothing was routed or executed.`
11
- : ` — this looks transient; retrying is safe (a retry creates a NEW request and can never double-execute). Nothing was routed or executed.`));
20
+ (credential
21
+ ? ` — 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.`
22
+ : retryable
23
+ ? ` — this looks transient; retrying is safe (a retry creates a NEW request and can never double-execute).`
24
+ : ` — the server refused the request itself, so retrying it unchanged will fail the same way. Fix the request (or the decision it names) first.`) +
25
+ sideEffectNote(endpoint));
12
26
  this.status = status;
13
27
  this.endpoint = endpoint;
14
28
  this.body = body;
15
29
  this.name = "DecidioHttpError";
16
- this.retryable = !terminal;
30
+ this.retryable = retryable;
31
+ }
32
+ }
33
+ export function decisionIdProblem(raw) {
34
+ if (typeof raw !== "string")
35
+ return `it is ${raw === null ? "null" : typeof raw}, not a string`;
36
+ if (raw.length === 0)
37
+ return "it is empty";
38
+ if (raw.trim().length === 0)
39
+ return "it is only whitespace";
40
+ if (raw !== raw.trim())
41
+ return "it has leading or trailing whitespace (ids are used verbatim in URLs, filenames and audit lines — this SDK will not silently trim one)";
42
+ for (const ch of raw) {
43
+ const cp = ch.codePointAt(0);
44
+ if (cp < 0x20 || cp === 0x7f)
45
+ return "it contains control characters";
17
46
  }
47
+ if (raw.length > 256)
48
+ return `it is ${raw.length} characters long (limit 256)`;
49
+ return null;
18
50
  }
@@ -13,6 +13,29 @@ export interface EvidenceOutcome {
13
13
  /** Present when the outcome is worth explaining (why a signed report did not confirm, or why
14
14
  * a report was not recorded at all) — always actionable, never a shrug. */
15
15
  diagnostic?: string;
16
+ /** Present when the gate reported on the SIGNED execution report: whether the attestation
17
+ * proof VERIFIED (C3, 0.1.12 — this field used to be read off the wire and discarded, so a
18
+ * failed attestation was invisible to every client door). */
19
+ executionReportVerified?: boolean;
20
+ /** WHY the attestation did not verify, verbatim from the gate (unknown did, expired proof,
21
+ * burnt nonce, payload mismatch). It was folded into `diagnostic` prose and nowhere else, so a
22
+ * caller who wanted to BRANCH on the reason — retry vs re-register vs alert — had to regex an
23
+ * English sentence. Same rule as the typed HTTP errors: give callers the fact, not a string to
24
+ * parse. */
25
+ executionReportReason?: string;
26
+ /** Whether re-sending this report could succeed. False for a refusal the server will repeat
27
+ * (an unrecognised tier, a denied success flag); true for a transport or availability failure.
28
+ * Undefined when the report WAS recorded and there is nothing to retry. */
29
+ confirmationRetryable?: boolean;
30
+ /** Whether the reported value reached durable storage, so `reconfirm()` can re-send it. On the
31
+ * inline path this is false by construction — nothing is parked — which is exactly why an
32
+ * inline caller needs to see the outcome at the call site rather than in a log line. */
33
+ reportJournalled?: boolean;
34
+ /** Set when the caller's `evidence` mapper (or the canonical model) rejected the return value,
35
+ * so an EMPTY captured response was reported. The execution is recorded; its evidence is not.
36
+ * This was previously a console.warn and nothing else — invisible to the code that could
37
+ * actually fix it. */
38
+ evidenceError?: string;
16
39
  }
17
40
  /** The SERVER's rule for `application_confirmed`, restated here exactly as implemented so this
18
41
  * explanation can never drift from it: the captured response must carry a RECORD ID
package/dist/evidence.js CHANGED
@@ -20,23 +20,34 @@ export function describeTierGap(capturedResponse) {
20
20
  return "the report was accepted but the gate recorded a weaker tier — check that this agent's registration and signing key still match (`npx @decidio/sdk doctor`).";
21
21
  }
22
22
  export function readConfirmResponse(body, opts) {
23
- const retry = opts.journalled
23
+ const journalled = opts.journalled ?? false;
24
+ const retry = journalled
24
25
  ? "re-send the report with `guard.resume.reconfirm(decisionId)` once connectivity or the service is healthy."
25
26
  : "this path journalled no report, so there is nothing to re-send from here — record the outcome from your own system, or run the action through `guard.protect`'s durable mode, where reports ARE journalled and `reconfirm` can replay them.";
26
27
  const b = body;
27
28
  if (!b || b.ok !== true || typeof b.confirmation !== "string") {
28
- return { reportRecorded: false, executionConfirmed: false, diagnostic: `the gate answered 2xx but named no evidence tier — the execution report is NOT recorded. Your action DID run; ${retry}` };
29
+ return { reportRecorded: false, executionConfirmed: false, confirmationRetryable: true, reportJournalled: journalled, diagnostic: `the gate answered 2xx but named no evidence tier — the execution report is NOT recorded. Your action DID run; ${retry}` };
29
30
  }
30
31
  const tier = b.confirmation;
31
32
  if (!EVIDENCE_TIERS.includes(tier)) {
32
- return { reportRecorded: false, executionConfirmed: false, diagnostic: `the gate reported an evidence tier this SDK does not recognise (${JSON.stringify(tier)}) — refusing to treat it as evidence. Upgrade @decidio/sdk; your action DID run, and ${retry}` };
33
+ return { reportRecorded: false, executionConfirmed: false, confirmationRetryable: false, reportJournalled: journalled, diagnostic: `the gate reported an evidence tier this SDK does not recognise (${JSON.stringify(tier)}) — refusing to treat it as evidence. Upgrade @decidio/sdk; your action DID run, and ${retry}` };
33
34
  }
34
35
  const executionConfirmed = CONFIRMING.includes(tier);
35
- const outcome = { reportRecorded: true, evidenceTier: tier, executionConfirmed };
36
+ const outcome = { reportRecorded: true, evidenceTier: tier, executionConfirmed, reportJournalled: journalled };
36
37
  if (!executionConfirmed && opts.signed)
37
38
  outcome.diagnostic = describeTierGap(opts.capturedResponse);
38
39
  else if (!executionConfirmed)
39
40
  outcome.diagnostic = "no signing key is configured, so the gate capped this report at an unproven agent assertion. Run `npx @decidio/sdk init` to generate the agent's key and earn a confirming tier.";
41
+ const rpt = body?.executionReport;
42
+ if (rpt && typeof rpt === "object" && typeof rpt.verified === "boolean") {
43
+ outcome.executionReportVerified = rpt.verified;
44
+ if (typeof rpt.reason === "string" && rpt.reason)
45
+ outcome.executionReportReason = rpt.reason;
46
+ if (!rpt.verified) {
47
+ const why = typeof rpt.reason === "string" && rpt.reason ? ` (${rpt.reason})` : "";
48
+ outcome.diagnostic = `${outcome.diagnostic ? outcome.diagnostic + " " : ""}The signed execution report did NOT verify${why} — the report was recorded without a proven attestation. Check the agent's key/DID/registration (npx @decidio/sdk doctor).`;
49
+ }
50
+ }
40
51
  return outcome;
41
52
  }
42
53
  import { signExecutionReport, resultDigest } from "./signing.js";
package/dist/guard.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ApprovalContext } from "./index.js";
1
+ import { type ApprovalContext, type ProtectedExecution } from "./index.js";
2
2
  import { type ResumeController, type ResumeOutcome } from "./resume.js";
3
3
  export interface GuardFacadeConfig {
4
4
  apiUrl: string;
@@ -28,6 +28,12 @@ export interface ProtectOptions {
28
28
  * across every door), but their ENGINE integration is verified against structural mocks, not
29
29
  * live engines. Use the core path for production until that changes. */
30
30
  adapter?: "generic" | "langgraph" | "openai" | "temporal" | "inngest";
31
+ /** Hand the action an `ExecutionContext` as its FIRST argument: `{ decisionId, attemptId,
32
+ * idempotencyKey }`. Pass `idempotencyKey` to your downstream API and the one window this SDK
33
+ * cannot close — the vendor committing twice — closes on their side. Opt-in by design: arity
34
+ * inspection would have to guess, and guessing wrong hands a payment call a context object
35
+ * where it expected an invoice. */
36
+ withContext?: boolean;
31
37
  /** "durable" (default) suspends for async approval; "blocking" polls (short approvals only). */
32
38
  mode?: "durable" | "blocking";
33
39
  /** The action name this wrapper produces, declared UP FRONT so the resume registry is populated
@@ -65,7 +71,7 @@ export declare class Guard {
65
71
  /** Register a raw fn under its action name so a COLD process (one that never made the original
66
72
  * call) can resume it. Call this at startup beside `guard.worker()` — or pass
67
73
  * `{ action }` to `protect(...)`, which does it for you at wrap time. */
68
- register(action: string, fn: (...a: any[]) => Promise<any>): void;
74
+ register(action: string, fn: (...a: any[]) => Promise<any>, evidence?: (result: any) => unknown): void;
69
75
  /** Mount on a node/Express/Fastify route: `app.post("/decidio/resume", guard.resumeHandler())`. */
70
76
  resumeHandler(): import("http").RequestListener;
71
77
  /** Mount on a Next.js/Hono/CF/Bun route: `export const POST = (req) => guard.resumeFetchHandler()(req)`. */
@@ -84,7 +90,18 @@ export declare class Guard {
84
90
  /** Throws when the facade has no gate URL — at USE time, not import time, so `import { guard }`
85
91
  * stays safe in envless contexts. */
86
92
  private assertConfigured;
87
- protect<A extends any[], R>(fn: (...a: A) => Promise<R>, describe: (...a: A) => ApprovalContext, opts?: ProtectOptions): (...a: A) => Promise<R>;
93
+ /** Wrap a function so every call is authorized, recorded and — in 0.2.0 — REPORTED BACK.
94
+ *
95
+ * Returns `{ value, decisionId, confirmation }`, not the bare value. Through 0.1.x it returned
96
+ * `R` and the confirmation outcome went to a console warning, so the one thing a caller of an
97
+ * authority SDK could not do was find out what Decidio recorded. If you genuinely do not need
98
+ * it, `protectBestEffort` keeps the old shape. */
99
+ protect<A extends any[], R>(fn: (...a: A) => Promise<R>, describe: (...a: A) => ApprovalContext, opts?: ProtectOptions): (...a: A) => Promise<ProtectedExecution<R>>;
100
+ /** @deprecated The 0.1.x shape: resolves to your function's value alone and DISCARDS what
101
+ * Decidio recorded. Kept because a caller who truly does not care should not have to
102
+ * destructure — but "the report was not recorded" and "the execution could not be attested"
103
+ * become invisible again, which is the gap 0.2.0 exists to close. Prefer `protect`. */
104
+ protectBestEffort<A extends any[], R>(fn: (...a: A) => Promise<R>, describe: (...a: A) => ApprovalContext, opts?: ProtectOptions): (...a: A) => Promise<R>;
88
105
  }
89
106
  /** Preconfigured singleton — `import { guard } from "@decidio/sdk"`. */
90
107
  export declare const guard: Guard;
package/dist/guard.js CHANGED
@@ -24,7 +24,7 @@ export class Guard {
24
24
  appUrl: process.env.DECIDIO_APP_URL,
25
25
  });
26
26
  }
27
- register(action, fn) { this.ensureResume().register(action, fn); }
27
+ register(action, fn, evidence) { this.ensureResume().register(action, fn, evidence); }
28
28
  resumeHandler() { return this.ensureResume().webhookHandler(); }
29
29
  resumeFetchHandler() { return this.ensureResume().fetchHandler(); }
30
30
  worker(opts) {
@@ -68,14 +68,16 @@ export class Guard {
68
68
  `(pollTimeoutMs), and when it does, the decision stays OPEN in Decidio while this process stops waiting: ` +
69
69
  `a later approval cannot resume it. Only safe for short, watched approvals — omit mode:"blocking" for ` +
70
70
  `durable async resume on real (minutes-to-days) approvals.`);
71
- return withApproval(fn, describe, base, opts?.evidence ? { evidence: opts.evidence } : undefined);
71
+ return withApproval(fn, describe, base, { ...(opts?.evidence ? { evidence: opts.evidence } : {}), ...(opts?.withContext ? { withContext: true } : {}) });
72
72
  }
73
73
  const resume = this.ensureResume();
74
74
  if (opts?.action)
75
- resume.register(opts.action, fn);
76
- if (opts?.evidence)
77
- resume.evidence = opts.evidence;
78
- return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume }, opts?.evidence ? { evidence: opts.evidence } : undefined);
75
+ resume.register(opts.action, fn, opts?.evidence, opts?.withContext);
76
+ return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume }, { ...(opts?.evidence ? { evidence: opts.evidence } : {}), ...(opts?.withContext ? { withContext: true } : {}) });
77
+ }
78
+ protectBestEffort(fn, describe, opts) {
79
+ const wrapped = this.protect(fn, describe, opts);
80
+ return async (...a) => (await wrapped(...a)).value;
79
81
  }
80
82
  }
81
83
  export const guard = Guard.fromEnv();