@decidio/sdk 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -22
- package/dist/cli.js +512 -110
- package/dist/guard.d.ts +2 -0
- package/dist/guard.js +5 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +20 -0
- package/dist/resume.js +38 -8
- package/openapi.yaml +1 -1
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs";
|
|
3
|
-
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync } from "node:fs";
|
|
3
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
5
6
|
import { createInterface } from "node:readline";
|
|
6
7
|
import { createRequire } from "node:module";
|
|
7
8
|
import { generateKeypair } from "./signing.js";
|
|
8
|
-
import { withApproval } from "./index.js";
|
|
9
|
-
import { verifyReceipt
|
|
9
|
+
import { withApproval, DecidioRateLimitError } from "./index.js";
|
|
10
|
+
import { verifyReceipt } from "@decidio/verify";
|
|
10
11
|
const VERSION = (() => {
|
|
11
12
|
try {
|
|
12
13
|
return createRequire(import.meta.url)("../package.json").version;
|
|
@@ -27,32 +28,37 @@ function readEnvFile() {
|
|
|
27
28
|
}
|
|
28
29
|
for (const [k, v] of Object.entries(readEnvFile())) {
|
|
29
30
|
if (k === "DECIDIO_SESSION_TOKEN") {
|
|
30
|
-
console.
|
|
31
|
+
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.");
|
|
31
32
|
continue;
|
|
32
33
|
}
|
|
33
34
|
if (process.env[k] === undefined)
|
|
34
35
|
process.env[k] = v;
|
|
35
36
|
}
|
|
36
|
-
const
|
|
37
|
-
const API = (process.env.DECIDIO_API_URL ?? (IS_QUICKSTART ? "https://decidio-api.onrender.com" : "http://localhost:4000")).replace(/\/$/, "");
|
|
37
|
+
const API = (process.env.DECIDIO_API_URL ?? "https://decidio-api.onrender.com").replace(/\/$/, "");
|
|
38
38
|
const TOKEN = () => process.env.DECIDIO_API_TOKEN;
|
|
39
39
|
const ENV_PATH = ".env";
|
|
40
40
|
const log = (...a) => console.log(...a);
|
|
41
|
+
const err = (...a) => console.error(...a);
|
|
42
|
+
let hostAnnounced = false;
|
|
41
43
|
async function api(path, init = {}, bearer) {
|
|
44
|
+
if (!hostAnnounced) {
|
|
45
|
+
hostAnnounced = true;
|
|
46
|
+
err(`· decidio → ${API}${process.env.DECIDIO_API_URL ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
|
|
47
|
+
}
|
|
42
48
|
const auth = bearer ?? TOKEN();
|
|
43
49
|
let res;
|
|
44
50
|
try {
|
|
45
51
|
res = await fetch(API + path, { ...init, headers: { "content-type": "application/json", ...(auth ? { authorization: `Bearer ${auth}` } : {}), ...(init.headers ?? {}) } });
|
|
46
52
|
}
|
|
47
53
|
catch (e) {
|
|
48
|
-
|
|
54
|
+
err(`✘ could not reach Decidio at ${API} (${e?.cause?.code ?? e?.message ?? "network error"}).`);
|
|
49
55
|
if (API.includes("localhost")) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
56
|
+
err(` You're pointed at localhost — for the hosted sandbox run:`);
|
|
57
|
+
err(` export DECIDIO_API_URL=https://decidio-api.onrender.com`);
|
|
58
|
+
err(` (or put that line in .env), then re-run this command.`);
|
|
53
59
|
}
|
|
54
60
|
else {
|
|
55
|
-
|
|
61
|
+
err(` Check your network, then re-run. If the URL is wrong, set DECIDIO_API_URL and retry.`);
|
|
56
62
|
}
|
|
57
63
|
process.exit(1);
|
|
58
64
|
}
|
|
@@ -66,14 +72,29 @@ async function api(path, init = {}, bearer) {
|
|
|
66
72
|
}
|
|
67
73
|
return { status: res.status, body };
|
|
68
74
|
}
|
|
75
|
+
function writeSecretFile(path, body) {
|
|
76
|
+
writeFileSync(path, body, { mode: 0o600 });
|
|
77
|
+
try {
|
|
78
|
+
chmodSync(path, 0o600);
|
|
79
|
+
}
|
|
80
|
+
catch { }
|
|
81
|
+
if (process.platform === "win32") {
|
|
82
|
+
try {
|
|
83
|
+
execFileSync("icacls", [path, "/inheritance:r", "/grant:r", `${process.env.USERNAME ?? process.env.USER ?? ""}:F`], { stdio: "ignore" });
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
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.`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
69
90
|
function upsertEnv(updates) {
|
|
70
91
|
const cur = readEnvFile();
|
|
71
92
|
const merged = { ...cur, ...updates };
|
|
72
93
|
const body = Object.entries(merged).map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
|
|
73
|
-
|
|
94
|
+
writeSecretFile(ENV_PATH, body);
|
|
74
95
|
}
|
|
75
96
|
function ensureGitignore() {
|
|
76
|
-
const entries = [".env", ".env.quickstart", ".decidio-pending/"];
|
|
97
|
+
const entries = [".env", ".env.quickstart", ".env.*.bak", ".decidio-pending/"];
|
|
77
98
|
const existing = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
|
|
78
99
|
const missing = entries.filter((e) => !existing.split(/\r?\n/).some((l) => l.trim() === e || l.trim() === e.replace(/\/$/, "")));
|
|
79
100
|
if (!missing.length)
|
|
@@ -95,15 +116,15 @@ function ask(question, mute = false) {
|
|
|
95
116
|
});
|
|
96
117
|
}
|
|
97
118
|
async function acquireSession(purpose) {
|
|
98
|
-
if (process.env.DECIDIO_SESSION_TOKEN)
|
|
99
|
-
return process.env.DECIDIO_SESSION_TOKEN;
|
|
100
119
|
if (/^http:\/\//i.test(API) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]|$)/i.test(API)) {
|
|
101
|
-
|
|
120
|
+
err(`✘ refusing to send workspace credentials over plaintext http to ${API}.`);
|
|
102
121
|
log(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
|
|
103
122
|
return null;
|
|
104
123
|
}
|
|
124
|
+
if (process.env.DECIDIO_SESSION_TOKEN)
|
|
125
|
+
return process.env.DECIDIO_SESSION_TOKEN;
|
|
105
126
|
if (!process.stdin.isTTY) {
|
|
106
|
-
|
|
127
|
+
err(`✘ ${purpose} needs a Decidio workspace sign-in, and this terminal can't prompt for one.`);
|
|
107
128
|
log(` Fix: set DECIDIO_SESSION_TOKEN and re-run. To mint one:`);
|
|
108
129
|
log(` 1. Request a sign-in link from your workspace's sign-in screen (or use your welcome email).`);
|
|
109
130
|
log(` 2. POST its ?login= value — the response's "token" is your session token:`);
|
|
@@ -138,10 +159,20 @@ async function acquireSession(purpose) {
|
|
|
138
159
|
log(` Sign-in links are single-use and expire in 15 minutes — request a fresh one and re-run.`);
|
|
139
160
|
return null;
|
|
140
161
|
}
|
|
141
|
-
async function cmdInit(agentId) {
|
|
162
|
+
async function cmdInit(agentId, opts = {}) {
|
|
142
163
|
log(`Registering agent "${agentId}" with Decidio at ${API}…`);
|
|
143
|
-
const
|
|
144
|
-
const
|
|
164
|
+
const existingEnv = readEnvFile();
|
|
165
|
+
const existingKey = existingEnv.DECIDIO_AGENT_KEY;
|
|
166
|
+
const sourceSystem = process.env.DECIDIO_SOURCE_SYSTEM || existingEnv.DECIDIO_SOURCE_SYSTEM;
|
|
167
|
+
if (existingKey && existingEnv.DECIDIO_AGENT_ID && existingEnv.DECIDIO_AGENT_ID !== agentId && !opts.force) {
|
|
168
|
+
err(`✘ this directory's .env already holds the key for a DIFFERENT agent ("${existingEnv.DECIDIO_AGENT_ID}").`);
|
|
169
|
+
log(` Registering "${agentId}" here would overwrite that key — and a private key that is`);
|
|
170
|
+
log(` overwritten is gone for good (it never left this machine). Nothing was written.`);
|
|
171
|
+
log(` Pick one:`);
|
|
172
|
+
log(` 1. New directory (cleanest): mkdir ${agentId} && cd ${agentId} && npx @decidio/sdk init ${agentId}`);
|
|
173
|
+
log(` 2. Replace the old agent here, backing its .env up first: npx @decidio/sdk init ${agentId} --force`);
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
145
176
|
const { did: localDid, privateKeyBase64: localKey } = generateKeypair();
|
|
146
177
|
const registerBody = JSON.stringify({ agentId, did: localDid, ...(sourceSystem ? { sourceSystem } : {}) });
|
|
147
178
|
let session = null;
|
|
@@ -162,7 +193,7 @@ async function cmdInit(agentId) {
|
|
|
162
193
|
}
|
|
163
194
|
else {
|
|
164
195
|
const suggestion = `${agentId}-${randomBytes(2).toString("hex")}`;
|
|
165
|
-
|
|
196
|
+
err(`✘ "${agentId}" is already registered in this workspace, and its private key is not on this machine`);
|
|
166
197
|
log(` (keys never leave the machine that generated them — likely a previous run, or another directory).`);
|
|
167
198
|
log(` Nothing was written. Two ways forward:`);
|
|
168
199
|
log(` 1. Fresh agent (most common): npx @decidio/sdk init ${suggestion}`);
|
|
@@ -176,21 +207,23 @@ async function cmdInit(agentId) {
|
|
|
176
207
|
log(`✔ registered (did ${did}) — key generated locally; only the public half went to Decidio`);
|
|
177
208
|
}
|
|
178
209
|
else {
|
|
179
|
-
|
|
210
|
+
err(`✘ registration failed (${r.status}): ${JSON.stringify(r.body)}`);
|
|
180
211
|
log(` Fix the cause above and re-run — init is safe to repeat.`);
|
|
181
212
|
return 1;
|
|
182
213
|
}
|
|
183
|
-
let agentToken, workspaceId;
|
|
214
|
+
let agentToken, workspaceId, tokenJti;
|
|
184
215
|
const mintBearer = session ?? TOKEN();
|
|
185
216
|
if (mintBearer) {
|
|
186
|
-
const
|
|
217
|
+
const expiresInDays = opts.expiresDays ?? 90;
|
|
218
|
+
const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "sdk-cli", expiresInDays }) }, mintBearer);
|
|
187
219
|
if (mint.status >= 200 && mint.status < 300 && mint.body?.token) {
|
|
188
220
|
agentToken = mint.body.token;
|
|
189
221
|
workspaceId = mint.body.workspaceId;
|
|
190
|
-
|
|
222
|
+
tokenJti = mint.body.jti;
|
|
223
|
+
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)`}`);
|
|
191
224
|
}
|
|
192
225
|
else if (session) {
|
|
193
|
-
|
|
226
|
+
err(`✘ could not mint the agent's API token (${mint.status}): ${JSON.stringify(mint.body)}`);
|
|
194
227
|
log(` Without it the SDK has no runtime credential. Fix the cause above and re-run init — it's safe to repeat.`);
|
|
195
228
|
return 1;
|
|
196
229
|
}
|
|
@@ -210,11 +243,18 @@ async function cmdInit(agentId) {
|
|
|
210
243
|
envOut.DECIDIO_API_TOKEN = agentToken;
|
|
211
244
|
if (workspaceId)
|
|
212
245
|
envOut.DECIDIO_WORKSPACE_ID = workspaceId;
|
|
246
|
+
if (tokenJti)
|
|
247
|
+
envOut.DECIDIO_TOKEN_JTI = tokenJti;
|
|
248
|
+
ensureGitignore();
|
|
249
|
+
if (opts.force && existingKey && existingEnv.DECIDIO_AGENT_ID && existingEnv.DECIDIO_AGENT_ID !== agentId) {
|
|
250
|
+
const backup = `.env.${existingEnv.DECIDIO_AGENT_ID.replace(/[^\w.-]/g, "_")}.bak`;
|
|
251
|
+
writeSecretFile(backup, readFileSync(ENV_PATH, "utf8"));
|
|
252
|
+
log(`✔ backed the previous agent's .env up to ${backup} (its key would otherwise be unrecoverable)`);
|
|
253
|
+
}
|
|
213
254
|
upsertEnv(envOut);
|
|
214
255
|
for (const [k, v] of Object.entries(envOut))
|
|
215
256
|
process.env[k] = v;
|
|
216
|
-
log(`✔ wrote ${ENV_PATH} — every decidio command reads it from this directory (your script loads it with node --env-file=.env)`);
|
|
217
|
-
ensureGitignore();
|
|
257
|
+
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)`);
|
|
218
258
|
if (privateKey)
|
|
219
259
|
log(` (the agent's private signing key is in there — for production, move it to your secret manager)`);
|
|
220
260
|
log("");
|
|
@@ -247,10 +287,11 @@ async function cmdDoctor() {
|
|
|
247
287
|
const env = readEnvFile();
|
|
248
288
|
let ok = true;
|
|
249
289
|
const line = (good, label) => { log(`${good ? "✔" : "✘"} ${label}`); ok = ok && good; };
|
|
250
|
-
|
|
290
|
+
log(`✔ API URL: ${API}${(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL) ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
|
|
251
291
|
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>)"}`);
|
|
252
292
|
const secret = process.env.DECIDIO_WEBHOOK_SECRET || env.DECIDIO_WEBHOOK_SECRET;
|
|
253
|
-
|
|
293
|
+
log(secret ? "✔ webhook secret: set — operator-only transport (inactive against the hosted sandbox; the poll worker guard.worker() is the self-serve resume path)"
|
|
294
|
+
: "• 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)");
|
|
254
295
|
const signing = (process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY) && (process.env.DECIDIO_AGENT_DID || env.DECIDIO_AGENT_DID);
|
|
255
296
|
if (signing)
|
|
256
297
|
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).");
|
|
@@ -258,6 +299,24 @@ async function cmdDoctor() {
|
|
|
258
299
|
log("⚠ 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.");
|
|
259
300
|
const h = await api("/api/health", {}, "");
|
|
260
301
|
line(h.status >= 200 && h.status < 300, `connectivity: API reachable (${h.status})`);
|
|
302
|
+
const tok = process.env.DECIDIO_API_TOKEN || env.DECIDIO_API_TOKEN;
|
|
303
|
+
if (tok) {
|
|
304
|
+
try {
|
|
305
|
+
const payload = JSON.parse(Buffer.from(tok.split(".")[1] ?? "", "base64url").toString("utf8"));
|
|
306
|
+
if (typeof payload?.exp === "number") {
|
|
307
|
+
const msLeft = payload.exp * 1000 - Date.now();
|
|
308
|
+
const days = Math.floor(msLeft / 86_400_000);
|
|
309
|
+
const left = msLeft < 86_400_000 ? `${Math.max(1, Math.floor(msLeft / 3_600_000))} hour(s)` : `${days} day(s)`;
|
|
310
|
+
if (msLeft < 0)
|
|
311
|
+
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.`);
|
|
312
|
+
else if (days <= 14)
|
|
313
|
+
log(`⚠ agent token: expires in ${left} — mint a fresh one before it does: npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}`);
|
|
314
|
+
else
|
|
315
|
+
log(`✔ agent token: valid for another ${days} days`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
catch { }
|
|
319
|
+
}
|
|
261
320
|
const rec = await api("/api/records");
|
|
262
321
|
if (rec.status >= 200 && rec.status < 300)
|
|
263
322
|
log("✔ token scope: session/admin (init, approvals and receipt work from this env)");
|
|
@@ -275,10 +334,21 @@ async function cmdDoctor() {
|
|
|
275
334
|
log(ok ? "\ndoctor: all green." : "\ndoctor: fix the ✘ items above, then re-run `npx @decidio/sdk doctor`.");
|
|
276
335
|
return ok ? 0 : 1;
|
|
277
336
|
}
|
|
278
|
-
async function cmdApprovals(sub, id, reason) {
|
|
279
|
-
const jsonMode =
|
|
280
|
-
|
|
337
|
+
async function cmdApprovals(sub, id, reason, flags = {}) {
|
|
338
|
+
const jsonMode = !!flags.json;
|
|
339
|
+
const mineOnly = !!flags.mine;
|
|
340
|
+
if (sub?.startsWith("--"))
|
|
281
341
|
sub = undefined;
|
|
342
|
+
if ((sub === "approve" || sub === "reject") && !id) {
|
|
343
|
+
err(`usage: npx @decidio/sdk approvals ${sub} <decisionId> [reason]`);
|
|
344
|
+
return 2;
|
|
345
|
+
}
|
|
346
|
+
if (mineOnly && !process.env.DECIDIO_AGENT_ID) {
|
|
347
|
+
err(`✘ --mine needs an agent identity, and there is no DECIDIO_AGENT_ID in this directory's .env or your environment.`);
|
|
348
|
+
err(` Run this from your agent's directory (where init wrote .env), or drop --mine for the workspace-wide list.`);
|
|
349
|
+
return 2;
|
|
350
|
+
}
|
|
351
|
+
const decisionArg = id ?? "";
|
|
282
352
|
let bearer;
|
|
283
353
|
const probe = await api("/api/decisions/cards");
|
|
284
354
|
if (probe.status === 401 || probe.status === 403) {
|
|
@@ -288,11 +358,7 @@ async function cmdApprovals(sub, id, reason) {
|
|
|
288
358
|
bearer = session;
|
|
289
359
|
}
|
|
290
360
|
if (sub === "approve" || sub === "reject") {
|
|
291
|
-
|
|
292
|
-
log(`usage: npx @decidio/sdk approvals ${sub} <decisionId> [reason]`);
|
|
293
|
-
return 2;
|
|
294
|
-
}
|
|
295
|
-
const r = await api(`/api/decisions/${encodeURIComponent(id)}/${sub === "approve" ? "approve" : "reject"}`, { method: "POST", body: JSON.stringify({ reason: reason ?? undefined }) }, bearer);
|
|
361
|
+
const r = await api(`/api/decisions/${encodeURIComponent(decisionArg)}/${sub === "approve" ? "approve" : "reject"}`, { method: "POST", body: JSON.stringify({ reason: reason ?? undefined }) }, bearer);
|
|
296
362
|
if (r.status >= 200 && r.status < 300) {
|
|
297
363
|
if (r.body?.alreadyResolved) {
|
|
298
364
|
log(`• ${id} was already resolved — nothing changed.`);
|
|
@@ -300,7 +366,7 @@ async function cmdApprovals(sub, id, reason) {
|
|
|
300
366
|
log(` See its sealed record: npx @decidio/sdk receipt ${id}`);
|
|
301
367
|
return 1;
|
|
302
368
|
}
|
|
303
|
-
log(`✔ ${sub}
|
|
369
|
+
log(`✔ ${sub === "approve" ? "approved" : "rejected"} ${id}${r.body?.resumeSignal ? ` — agent signalled (${r.body.resumeSignal.ok ? "ok" : r.body.resumeSignal.error})` : ""}`);
|
|
304
370
|
if (sub === "approve")
|
|
305
371
|
log(` The agent's function runs next — a blocking call resumes now; a durable one when its worker or resume route picks this up.`);
|
|
306
372
|
else
|
|
@@ -309,33 +375,38 @@ async function cmdApprovals(sub, id, reason) {
|
|
|
309
375
|
return 0;
|
|
310
376
|
}
|
|
311
377
|
if (r.status === 404) {
|
|
312
|
-
|
|
378
|
+
err(`✘ no decision "${id}" in this workspace — check the id (npx @decidio/sdk approvals lists pending ones).`);
|
|
313
379
|
return 1;
|
|
314
380
|
}
|
|
315
|
-
|
|
381
|
+
err(`✘ ${sub} failed (${r.status}): ${JSON.stringify(r.body).slice(0, 300)}`);
|
|
316
382
|
return 1;
|
|
317
383
|
}
|
|
318
384
|
const r = bearer ? await api("/api/decisions/cards", {}, bearer) : probe;
|
|
319
385
|
if (!(r.status >= 200 && r.status < 300)) {
|
|
320
|
-
|
|
386
|
+
err(`✘ could not list approvals (${r.status}): ${JSON.stringify(r.body).slice(0, 300)}`);
|
|
321
387
|
return 1;
|
|
322
388
|
}
|
|
323
389
|
const cards = r.body?.cards ?? [];
|
|
324
390
|
const pending = cards.filter((c) => c.isAgentRequest && c.status !== "sealed");
|
|
391
|
+
const myAgent = process.env.DECIDIO_AGENT_ID;
|
|
392
|
+
const isMine = (c) => !!myAgent && typeof c.title === "string" && c.title.startsWith(`${myAgent}:`);
|
|
393
|
+
const shown = mineOnly ? pending.filter(isMine) : pending;
|
|
325
394
|
if (jsonMode) {
|
|
326
|
-
log(JSON.stringify(
|
|
395
|
+
log(JSON.stringify(shown.map((c) => ({ decisionId: c.id, action: c.agentRequest?.action ?? null, amount: c.agentRequest?.amount ?? null, title: c.title ?? null })), null, 2));
|
|
327
396
|
return 0;
|
|
328
397
|
}
|
|
329
398
|
if (!pending.length) {
|
|
330
399
|
log("No pending agent approvals. Trigger one from your protected function, then re-run this.");
|
|
331
400
|
return 0;
|
|
332
401
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
402
|
+
if (mineOnly && !shown.length) {
|
|
403
|
+
log(`No pending approvals for "${myAgent}" (${pending.length} pending elsewhere in this workspace — drop --mine to see them).`);
|
|
404
|
+
return 0;
|
|
405
|
+
}
|
|
406
|
+
log(`Pending agent approvals (${shown.length}${mineOnly ? ` for "${myAgent}"` : ""})${!mineOnly && myAgent ? ` — workspace-wide; "←" marks this directory's agent (${myAgent}). Use --mine to list only those.` : ""}:`);
|
|
407
|
+
for (const c of shown) {
|
|
336
408
|
const ar = c.agentRequest ?? {};
|
|
337
|
-
|
|
338
|
-
log(` ${c.id} ${ar.action ?? "?"}${ar.amount != null ? ` $${Number(ar.amount).toLocaleString()}` : ""} — ${c.title ?? ""}${mine ? " ←" : ""}`);
|
|
409
|
+
log(` ${c.id} ${ar.action ?? "?"}${ar.amount != null ? ` $${Number(ar.amount).toLocaleString()}` : ""} — ${c.title ?? ""}${!mineOnly && isMine(c) ? " ←" : ""}`);
|
|
339
410
|
}
|
|
340
411
|
log(`\nApprove: npx @decidio/sdk approvals approve <id> [reason]`);
|
|
341
412
|
log(`Reject: npx @decidio/sdk approvals reject <id> [reason]`);
|
|
@@ -343,9 +414,9 @@ async function cmdApprovals(sub, id, reason) {
|
|
|
343
414
|
}
|
|
344
415
|
async function cmdReceipt(id, jsonMode = false) {
|
|
345
416
|
if (!id) {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
417
|
+
err("usage: npx @decidio/sdk receipt <decisionId>");
|
|
418
|
+
err(" Downloads the decision's sealed Authority Receipt (a W3C Verifiable Credential) to a .json file.");
|
|
419
|
+
err(" Find decision ids via `npx @decidio/sdk approvals` or in your Decidio app.");
|
|
349
420
|
return 2;
|
|
350
421
|
}
|
|
351
422
|
let bearer;
|
|
@@ -358,39 +429,47 @@ async function cmdReceipt(id, jsonMode = false) {
|
|
|
358
429
|
r = await api(`/api/records/${encodeURIComponent(id)}/verify`, {}, bearer);
|
|
359
430
|
}
|
|
360
431
|
if (r.status === 404) {
|
|
361
|
-
|
|
362
|
-
|
|
432
|
+
err(`✘ no sealed record for "${id}" in this workspace.`);
|
|
433
|
+
err(` Receipts exist once a decision is SEALED (approved or rejected). Still pending? npx @decidio/sdk approvals`);
|
|
363
434
|
return 1;
|
|
364
435
|
}
|
|
365
436
|
if (!(r.status >= 200 && r.status < 300) || !r.body?.credential) {
|
|
366
|
-
|
|
437
|
+
err(`✘ could not fetch the receipt (${r.status}): ${JSON.stringify(r.body).slice(0, 300)}`);
|
|
367
438
|
return 1;
|
|
368
439
|
}
|
|
369
440
|
const name = `decidio-receipt-${id.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
|
|
370
441
|
const nextBytes = JSON.stringify(r.body.credential, null, 2) + "\n";
|
|
371
442
|
if (existsSync(name)) {
|
|
372
443
|
if (readFileSync(name, "utf8") !== nextBytes) {
|
|
373
|
-
|
|
444
|
+
err(`✘ ${name} already exists with DIFFERENT bytes — refusing to overwrite an audit artifact. Move it aside or delete it, then re-run.`);
|
|
374
445
|
return 1;
|
|
375
446
|
}
|
|
376
|
-
log(`• ${name} already exists (byte-identical — receipts are immutable); leaving it in place.`);
|
|
447
|
+
(jsonMode ? err : log)(`• ${name} already exists (byte-identical — receipts are immutable); leaving it in place.`);
|
|
377
448
|
}
|
|
378
449
|
else {
|
|
379
450
|
writeFileSync(name, nextBytes);
|
|
380
|
-
log(`✔ wrote ${name} (downloaded from your workspace — the download is online; VERIFYING it is offline)`);
|
|
451
|
+
(jsonMode ? err : log)(`✔ wrote ${name} (downloaded from your workspace — the download is online; VERIFYING it is offline)`);
|
|
381
452
|
}
|
|
382
453
|
const detail = await api(`/api/records/${encodeURIComponent(id)}`, {}, bearer);
|
|
383
454
|
const conf = detail.body?.record?.confirmation;
|
|
455
|
+
const issuer = r.body?.issuer;
|
|
384
456
|
if (jsonMode) {
|
|
385
|
-
log(JSON.stringify({ file: name, decisionId: id, evidenceTier: conf?.status ?? null, confirmed: !!conf?.confirmed }, null, 2));
|
|
457
|
+
log(JSON.stringify({ file: name, decisionId: id, evidenceTier: conf?.status ?? null, confirmed: !!conf?.confirmed, issuer: issuer ?? null }, null, 2));
|
|
386
458
|
return 0;
|
|
387
459
|
}
|
|
388
460
|
if (conf?.status)
|
|
389
461
|
log(` Sealed credential: the authority decision (its confirmation field reads "pending" forever — immutable). Current record evidence tier: ${conf.status}${conf.confirmed ? " (confirmed)" : ""}.`);
|
|
390
462
|
log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
|
|
391
463
|
log(` npx @decidio/sdk verify ${name}`);
|
|
392
|
-
|
|
393
|
-
|
|
464
|
+
if (issuer) {
|
|
465
|
+
log(` Your workspace's issuer DID (from the authenticated response — record it once, then pin it forever):`);
|
|
466
|
+
log(` ${issuer}`);
|
|
467
|
+
log(` npx @decidio/sdk verify --issuer ${issuer} ${name}`);
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
log(` To also prove WHO issued it, pin your workspace's issuer DID (shown in your app's record verify panel):`);
|
|
471
|
+
log(` npx @decidio/sdk verify --issuer <did:key:...> ${name}`);
|
|
472
|
+
}
|
|
394
473
|
return 0;
|
|
395
474
|
}
|
|
396
475
|
function parseReceiptStrict(text, file) {
|
|
@@ -440,22 +519,82 @@ function parseReceiptStrict(text, file) {
|
|
|
440
519
|
}
|
|
441
520
|
return value;
|
|
442
521
|
}
|
|
522
|
+
function chainOf(vcs, expectedIssuer) {
|
|
523
|
+
if (!vcs.length)
|
|
524
|
+
return { valid: false, length: 0, reason: "no receipts supplied" };
|
|
525
|
+
const issuers = [...new Set(vcs.map((v) => String(v?.issuer ?? "")))];
|
|
526
|
+
if (issuers.length > 1)
|
|
527
|
+
return { valid: false, length: 0, reason: `these receipts come from ${issuers.length} DIFFERENT issuers — a chain must be issued end to end by one issuer` };
|
|
528
|
+
let prev = null;
|
|
529
|
+
let anchor = "segment";
|
|
530
|
+
for (let i = 0; i < vcs.length; i++) {
|
|
531
|
+
const r = verifyReceiptDoor(vcs[i], expectedIssuer);
|
|
532
|
+
if (!r.valid)
|
|
533
|
+
return { valid: false, length: i, reason: `receipt ${i} (${r.recordId ?? "?"}) invalid: ${r.reason}` };
|
|
534
|
+
const cs = vcs[i]?.credentialSubject ?? {};
|
|
535
|
+
if (i === 0)
|
|
536
|
+
anchor = (cs.prevHash ?? null) === null ? "genesis" : "segment";
|
|
537
|
+
else if ((cs.prevHash ?? null) !== prev)
|
|
538
|
+
return { valid: false, length: i, reason: `receipt ${i} does not follow receipt ${i - 1} (prevHash mismatch)` };
|
|
539
|
+
prev = cs.contentHash;
|
|
540
|
+
}
|
|
541
|
+
return { valid: true, length: vcs.length, anchor };
|
|
542
|
+
}
|
|
543
|
+
function verifyReceiptDoor(vc, expectedIssuer) {
|
|
544
|
+
const res = verifyReceipt(vc, expectedIssuer);
|
|
545
|
+
if (!res.valid && res.reason === "issuer DID is not the expected issuer") {
|
|
546
|
+
const cs = vc?.credentialSubject ?? {};
|
|
547
|
+
const derive = (canonical, prevHash) => createHash("sha256").update((prevHash ?? "") + canonical).digest("hex");
|
|
548
|
+
const bound = typeof cs.recordCanonical === "string" && typeof cs.contentHash === "string"
|
|
549
|
+
&& derive(cs.recordCanonical, typeof cs.prevHash === "string" ? cs.prevHash : null) === cs.contentHash;
|
|
550
|
+
return { ...res, contentBound: bound, recordId: typeof cs.recordId === "string" ? cs.recordId : res.recordId, reason: "issuer DID is not the expected issuer (the receipt itself may be internally consistent — it was just not issued by who you pinned)" };
|
|
551
|
+
}
|
|
552
|
+
return res;
|
|
553
|
+
}
|
|
443
554
|
function cmdVerify(argsIn) {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
555
|
+
let expectedIssuer;
|
|
556
|
+
const files = [];
|
|
557
|
+
for (let i = 0; i < argsIn.length; i++) {
|
|
558
|
+
const a = argsIn[i];
|
|
559
|
+
if (a === "--issuer") {
|
|
560
|
+
const v = argsIn[i + 1];
|
|
561
|
+
if (v === undefined || v.startsWith("-")) {
|
|
562
|
+
err("✘ --issuer needs a did:key value.");
|
|
563
|
+
return 2;
|
|
564
|
+
}
|
|
565
|
+
expectedIssuer = v;
|
|
566
|
+
i++;
|
|
567
|
+
}
|
|
568
|
+
else if (a.startsWith("--issuer=")) {
|
|
569
|
+
const v = a.slice("--issuer=".length);
|
|
570
|
+
if (!v) {
|
|
571
|
+
err("✘ --issuer needs a did:key value.");
|
|
572
|
+
return 2;
|
|
573
|
+
}
|
|
574
|
+
expectedIssuer = v;
|
|
575
|
+
}
|
|
576
|
+
else if (!a.startsWith("-")) {
|
|
577
|
+
files.push(a);
|
|
578
|
+
}
|
|
448
579
|
}
|
|
449
|
-
const expectedIssuer = issIdx >= 0 ? argsIn[issIdx + 1] : undefined;
|
|
450
|
-
const files = argsIn.filter((a, i) => !a.startsWith("--") && !(issIdx >= 0 && i === issIdx + 1));
|
|
451
580
|
if (!files.length) {
|
|
452
|
-
|
|
453
|
-
|
|
581
|
+
err("usage: npx @decidio/sdk verify [--issuer <did:key>] <receipt.json> [more.json ...]");
|
|
582
|
+
err(" Verifies Authority Receipts OFFLINE — signature, content binding, and (for several files) chain order.");
|
|
454
583
|
return 2;
|
|
455
584
|
}
|
|
585
|
+
const texts = [];
|
|
586
|
+
for (const f of files) {
|
|
587
|
+
try {
|
|
588
|
+
texts.push(readFileSync(f, "utf8"));
|
|
589
|
+
}
|
|
590
|
+
catch (e) {
|
|
591
|
+
err(`✘ cannot read ${f}: ${e?.code === "ENOENT" ? "no such file" : e?.message ?? e} (nothing was verified)`);
|
|
592
|
+
return 1;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
456
595
|
let vcs;
|
|
457
596
|
try {
|
|
458
|
-
vcs = files.map((f) => parseReceiptStrict(
|
|
597
|
+
vcs = files.map((f, i) => parseReceiptStrict(texts[i], f));
|
|
459
598
|
}
|
|
460
599
|
catch (e) {
|
|
461
600
|
log(`✘ INVALID ${String(e?.message ?? e)}`);
|
|
@@ -463,24 +602,28 @@ function cmdVerify(argsIn) {
|
|
|
463
602
|
}
|
|
464
603
|
let allOk = true;
|
|
465
604
|
vcs.forEach((vc, i) => {
|
|
466
|
-
const res =
|
|
605
|
+
const res = verifyReceiptDoor(vc, expectedIssuer);
|
|
467
606
|
allOk = allOk && res.valid;
|
|
468
607
|
log(`${res.valid ? "✔ VALID " : "✘ INVALID"} ${files[i]}`);
|
|
469
608
|
log(` record: ${res.recordId ?? "?"} issuer: ${res.issuer}${expectedIssuer ? "" : " (unpinned — pass --issuer to bind to your workspace's DID)"}`);
|
|
470
609
|
log(` content-bound: ${res.contentBound}${res.reason ? ` reason: ${res.reason}` : ""}`);
|
|
471
610
|
});
|
|
472
611
|
if (vcs.length > 1) {
|
|
473
|
-
const c =
|
|
612
|
+
const c = chainOf(vcs, expectedIssuer);
|
|
474
613
|
allOk = allOk && c.valid;
|
|
475
|
-
log(`\nchain: ${c.valid ? "✔ intact" : "✘ broken"} (${c.length}/${vcs.length})${c.reason ? ` — ${c.reason}` : ""}`);
|
|
614
|
+
log(`\nchain: ${c.valid ? "✔ intact" : "✘ broken"} (${c.length}/${vcs.length})${c.valid ? (c.anchor === "segment" ? " — segment: ordering proven from the FIRST file forward; history before it is not covered by these receipts" : " — from genesis") : ""}${c.reason ? ` — ${c.reason}` : ""}`);
|
|
476
615
|
}
|
|
477
616
|
return allOk ? 0 : 1;
|
|
478
617
|
}
|
|
479
|
-
async function cmdQuickstart() {
|
|
618
|
+
async function cmdQuickstart(opts = {}) {
|
|
480
619
|
const t0 = Date.now();
|
|
481
620
|
log("Decidio quickstart — gate an action, route it to a human, execute on approval, hold the proof.");
|
|
482
621
|
log(` Target: ${API}${process.env.DECIDIO_API_URL ? "" : " (the hosted synthetic sandbox — set DECIDIO_API_URL to use your own)"}`);
|
|
483
622
|
log("");
|
|
623
|
+
log(" This tour will: register one synthetic agent · mint a 7-DAY floor-limited token ·");
|
|
624
|
+
log(" write .env (or .env.quickstart) + .gitignore entries · create one synthetic decision");
|
|
625
|
+
log(" and its sealed record (records are permanent by design). Cleanup offer at the end.");
|
|
626
|
+
log("");
|
|
484
627
|
const session = await acquireSession("The quickstart");
|
|
485
628
|
if (!session)
|
|
486
629
|
return 1;
|
|
@@ -489,30 +632,40 @@ async function cmdQuickstart() {
|
|
|
489
632
|
const { did, privateKeyBase64 } = generateKeypair();
|
|
490
633
|
const reg = await api("/api/agents/register", { method: "POST", body: JSON.stringify({ agentId, did }) }, session);
|
|
491
634
|
if (!(reg.status >= 200 && reg.status < 300)) {
|
|
492
|
-
|
|
635
|
+
err(`✘ registration failed (${reg.status}): ${JSON.stringify(reg.body)}`);
|
|
493
636
|
return 1;
|
|
494
637
|
}
|
|
495
|
-
const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "quickstart" }) }, session);
|
|
638
|
+
const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "quickstart", expiresInDays: 7 }) }, session);
|
|
496
639
|
if (!(mint.status >= 200 && mint.status < 300) || !mint.body?.token) {
|
|
497
|
-
|
|
640
|
+
err(`✘ token mint failed (${mint.status}): ${JSON.stringify(mint.body)}`);
|
|
498
641
|
return 1;
|
|
499
642
|
}
|
|
500
643
|
const envOut = {
|
|
501
644
|
DECIDIO_API_URL: API, DECIDIO_AGENT_ID: agentId, DECIDIO_AGENT_DID: reg.body?.did ?? did,
|
|
502
645
|
DECIDIO_AGENT_KEY: privateKeyBase64, DECIDIO_API_TOKEN: mint.body.token,
|
|
503
|
-
DECIDIO_WORKSPACE_ID: mint.body.workspaceId ?? "",
|
|
646
|
+
DECIDIO_WORKSPACE_ID: mint.body.workspaceId ?? "",
|
|
647
|
+
...(mint.body.jti ? { DECIDIO_TOKEN_JTI: mint.body.jti } : {}),
|
|
504
648
|
};
|
|
505
649
|
let envFile = ENV_PATH;
|
|
506
650
|
if (readEnvFile().DECIDIO_AGENT_KEY) {
|
|
507
651
|
envFile = ".env.quickstart";
|
|
508
|
-
|
|
652
|
+
ensureGitignore();
|
|
653
|
+
if (existsSync(envFile)) {
|
|
654
|
+
const prev = readFileSync(envFile, "utf8");
|
|
655
|
+
const prevId = prev.match(/^DECIDIO_AGENT_ID=(.+)$/m)?.[1];
|
|
656
|
+
if (prevId && prevId !== agentId) {
|
|
657
|
+
writeSecretFile(".env.quickstart.bak", prev);
|
|
658
|
+
log(` ✔ previous tour agent (${prevId}) backed up to .env.quickstart.bak — its key would otherwise be unrecoverable`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
writeSecretFile(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
|
|
509
662
|
log(` ✔ this directory already has an agent in .env — tour credentials went to ${envFile} (your agent is untouched)`);
|
|
510
663
|
}
|
|
511
664
|
else {
|
|
665
|
+
ensureGitignore();
|
|
512
666
|
upsertEnv(envOut);
|
|
513
667
|
}
|
|
514
|
-
|
|
515
|
-
log(` ✔ key generated locally (public half registered) · floor-limited token minted · ${envFile} written`);
|
|
668
|
+
log(` ✔ key generated locally (public half registered) · 7-day floor-limited token minted${mint.body.expiresAt ? ` (expires ${mint.body.expiresAt})` : ""} · ${envFile} written`);
|
|
516
669
|
log("");
|
|
517
670
|
log(`② Gating a demo action — the tour runs this (blocking mode: watch it live):`);
|
|
518
671
|
log("");
|
|
@@ -529,26 +682,50 @@ async function cmdQuickstart() {
|
|
|
529
682
|
};
|
|
530
683
|
const payInvoice = withApproval(async (inv) => ({ paid: true, id: inv.id }), (inv) => ({ action: "payInvoice", amount: inv.amount, scope: "Invoice" }), cfg);
|
|
531
684
|
const running = payInvoice({ id: "INV-2026-001", amount: 86_000 });
|
|
532
|
-
|
|
685
|
+
let earlyError;
|
|
686
|
+
running.catch((e) => { earlyError = e; });
|
|
533
687
|
const waitStart = Date.now();
|
|
534
|
-
while (!decisionId && Date.now() - waitStart < 30_000)
|
|
688
|
+
while (!decisionId && !earlyError && Date.now() - waitStart < 30_000)
|
|
535
689
|
await new Promise((r) => setTimeout(r, 250));
|
|
690
|
+
if (!decisionId && earlyError instanceof DecidioRateLimitError) {
|
|
691
|
+
err(`✘ this workspace's governed-action allowance is used up${earlyError.limit != null ? ` (${earlyError.limit} actions/hour on lab workspaces)` : ""}.`);
|
|
692
|
+
err(` Nothing was routed or executed — the cap is the sandbox protecting itself, not a fault${earlyError.resetAt ? `. It resets ${earlyError.resetAt}` : ""}.`);
|
|
693
|
+
err(` Re-run the tour after the reset (or talk to us about a production workspace).`);
|
|
694
|
+
return 1;
|
|
695
|
+
}
|
|
696
|
+
if (!decisionId && earlyError) {
|
|
697
|
+
err(`✘ the gate refused before routing: ${earlyError?.message ?? earlyError}`);
|
|
698
|
+
return 1;
|
|
699
|
+
}
|
|
536
700
|
if (!decisionId) {
|
|
537
|
-
|
|
701
|
+
err("✘ the gate did not route within 30s — check connectivity (npx @decidio/sdk doctor) and re-run.");
|
|
538
702
|
return 1;
|
|
539
703
|
}
|
|
540
704
|
log(` ✔ $86,000 payInvoice ROUTED to a human (decision ${decisionId}) — this agent has no auto-approve rule,`);
|
|
541
705
|
log(` so deny-by-default sends everything to a person. Nothing executes until someone decides.`);
|
|
542
706
|
log("");
|
|
543
|
-
|
|
707
|
+
const interactive = process.stdin.isTTY;
|
|
708
|
+
if (interactive) {
|
|
544
709
|
await ask(`③ YOU are the approver. Press Enter to approve it here (or approve it in your Decidio queue first, then Enter): `);
|
|
545
710
|
}
|
|
711
|
+
else if (opts.autoApproveDemo) {
|
|
712
|
+
log("③ --auto-approve-demo: approving with your session credential. This is AUTOMATED authority");
|
|
713
|
+
log(" (a flag you passed, not a human decision in the moment) — fine for CI, not the real story.");
|
|
714
|
+
}
|
|
546
715
|
else {
|
|
547
|
-
log("③
|
|
716
|
+
log("③ This terminal can't put a human in the loop, and an approval IS a human act — the tour");
|
|
717
|
+
log(" stops here rather than approve its own request. The decision is routed and waiting:");
|
|
718
|
+
log("");
|
|
719
|
+
log(` npx @decidio/sdk approvals approve ${decisionId} (or approve it in your Decidio app)`);
|
|
720
|
+
log("");
|
|
721
|
+
log(" Then re-run the quickstart interactively to watch the whole loop — or, for CI, re-run");
|
|
722
|
+
log(" with --auto-approve-demo to let the tour approve itself under that explicit label.");
|
|
723
|
+
log(` Tidy up this tour's agent + token any time: npx @decidio/sdk quickstart cleanup`);
|
|
724
|
+
process.exit(0);
|
|
548
725
|
}
|
|
549
|
-
const ap = await api(`/api/decisions/${encodeURIComponent(decisionId)}/approve`, { method: "POST", body: JSON.stringify({ reason: "quickstart" }) }, session);
|
|
726
|
+
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);
|
|
550
727
|
if (!(ap.status >= 200 && ap.status < 300)) {
|
|
551
|
-
|
|
728
|
+
err(`✘ approve failed (${ap.status}): ${JSON.stringify(ap.body)}`);
|
|
552
729
|
return 1;
|
|
553
730
|
}
|
|
554
731
|
log(` ✔ approved${ap.body?.alreadyResolved ? " (it was already approved from your app — even better)" : ""}`);
|
|
@@ -557,7 +734,7 @@ async function cmdQuickstart() {
|
|
|
557
734
|
result = await running;
|
|
558
735
|
}
|
|
559
736
|
catch (e) {
|
|
560
|
-
|
|
737
|
+
err(`✘ the gated call did not complete: ${e?.message ?? e}`);
|
|
561
738
|
return 1;
|
|
562
739
|
}
|
|
563
740
|
log(` ✔ your function executed AFTER approval: ${JSON.stringify(result)}`);
|
|
@@ -565,13 +742,16 @@ async function cmdQuickstart() {
|
|
|
565
742
|
log("④ Downloading the sealed Authority Receipt and verifying it OFFLINE…");
|
|
566
743
|
const rec = await api(`/api/records/${encodeURIComponent(decisionId)}/verify`, {}, session);
|
|
567
744
|
if (!(rec.status >= 200 && rec.status < 300) || !rec.body?.credential) {
|
|
568
|
-
|
|
745
|
+
err(`✘ receipt fetch failed (${rec.status})`);
|
|
569
746
|
return 1;
|
|
570
747
|
}
|
|
571
748
|
const fname = `decidio-receipt-${decisionId.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
|
|
572
749
|
writeFileSync(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
|
|
573
|
-
const
|
|
574
|
-
|
|
750
|
+
const issuer = rec.body?.issuer;
|
|
751
|
+
const v = verifyReceipt(rec.body.credential, issuer);
|
|
752
|
+
log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? `, PINNED to your workspace issuer ${issuer}` : "; issuer unpinned"})`);
|
|
753
|
+
if (issuer)
|
|
754
|
+
log(` Keep that DID: it is your trust anchor — npx @decidio/sdk verify --issuer ${issuer} <receipt.json>`);
|
|
575
755
|
const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
|
|
576
756
|
const tier = detail.body?.record?.confirmation?.status;
|
|
577
757
|
if (tier)
|
|
@@ -586,8 +766,129 @@ async function cmdQuickstart() {
|
|
|
586
766
|
log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
|
|
587
767
|
log("");
|
|
588
768
|
log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")} · Next: the README's 7-step quickstart (durable, workers, adapters).`);
|
|
769
|
+
let doCleanup = false;
|
|
770
|
+
if (interactive) {
|
|
771
|
+
const answer = await ask(`\nKeep this agent for development? [Enter = keep · c + Enter = clean up now]: `);
|
|
772
|
+
doCleanup = answer.trim().toLowerCase() === "c";
|
|
773
|
+
if (!doCleanup)
|
|
774
|
+
log(` Kept. Tidy up any time: npx @decidio/sdk quickstart cleanup`);
|
|
775
|
+
}
|
|
776
|
+
else if (opts.autoApproveDemo && !opts.keepAgent) {
|
|
777
|
+
doCleanup = true;
|
|
778
|
+
log(`\nAutomated run: cleaning the tour up (pass --keep-agent to keep the agent + token).`);
|
|
779
|
+
}
|
|
780
|
+
if (doCleanup)
|
|
781
|
+
await quickstartCleanup(session, envFile);
|
|
589
782
|
return v.valid ? 0 : 1;
|
|
590
783
|
}
|
|
784
|
+
async function quickstartCleanup(session, envFile) {
|
|
785
|
+
const env = (() => {
|
|
786
|
+
const out = {};
|
|
787
|
+
if (existsSync(envFile))
|
|
788
|
+
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
|
789
|
+
const m = line.match(/^\s*([A-Z_]+)=(.*)$/);
|
|
790
|
+
if (m)
|
|
791
|
+
out[m[1]] = m[2];
|
|
792
|
+
}
|
|
793
|
+
return out;
|
|
794
|
+
})();
|
|
795
|
+
if (env.DECIDIO_TOKEN_JTI) {
|
|
796
|
+
const rev = await api(`/api/workspace/agent-token/${encodeURIComponent(env.DECIDIO_TOKEN_JTI)}`, { method: "DELETE" }, session);
|
|
797
|
+
if (rev.status >= 200 && rev.status < 300)
|
|
798
|
+
log(` ✔ token revoked (jti ${env.DECIDIO_TOKEN_JTI})`);
|
|
799
|
+
else
|
|
800
|
+
log(` ⚠ token revoke returned ${rev.status} — it still expires on its own within 7 days. List tokens: npx @decidio/sdk tokens list`);
|
|
801
|
+
}
|
|
802
|
+
else {
|
|
803
|
+
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.`);
|
|
804
|
+
}
|
|
805
|
+
if (existsSync(envFile)) {
|
|
806
|
+
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"]);
|
|
807
|
+
const kept = readFileSync(envFile, "utf8").split(/\r?\n/).filter((line) => {
|
|
808
|
+
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=/);
|
|
809
|
+
return m ? !TOUR_KEYS.has(m[1]) : line.trim() !== "";
|
|
810
|
+
});
|
|
811
|
+
if (kept.length === 0) {
|
|
812
|
+
rmSync(envFile);
|
|
813
|
+
log(` ✔ ${envFile} deleted (the agent's local key is gone — the registered agent is now inert)`);
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
writeSecretFile(envFile, kept.join("\n") + "\n");
|
|
817
|
+
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)`);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
const tourAgent = env.DECIDIO_AGENT_ID;
|
|
821
|
+
if (tourAgent) {
|
|
822
|
+
const cards = await api("/api/decisions/cards", {}, session);
|
|
823
|
+
const stranded = (cards.body?.cards ?? []).filter((c) => c.isAgentRequest && c.status !== "sealed" && typeof c.title === "string" && c.title.startsWith(`${tourAgent}:`));
|
|
824
|
+
for (const c of stranded) {
|
|
825
|
+
log(` ⚠ this tour's decision ${c.id} is still PENDING in your workspace queue — clear it with:`);
|
|
826
|
+
log(` npx @decidio/sdk approvals reject ${c.id} "quickstart cleanup"`);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
log(` • What remains, by design: the sealed decision record (records are permanent — that is the product)`);
|
|
830
|
+
log(` and the agent's registration row (inert without key or token; server-side deregistration is on the roadmap).`);
|
|
831
|
+
return 0;
|
|
832
|
+
}
|
|
833
|
+
async function cmdTokens(sub, jti) {
|
|
834
|
+
if (sub !== "list" && sub !== "revoke") {
|
|
835
|
+
err("usage: npx @decidio/sdk tokens list");
|
|
836
|
+
err(" npx @decidio/sdk tokens revoke <jti>");
|
|
837
|
+
err(" Agent tokens are shown by jti (their revocation id) — the token VALUE is never shown again after minting.");
|
|
838
|
+
return 2;
|
|
839
|
+
}
|
|
840
|
+
if (sub === "revoke" && !jti) {
|
|
841
|
+
err("usage: npx @decidio/sdk tokens revoke <jti> (find the jti with: npx @decidio/sdk tokens list)");
|
|
842
|
+
return 2;
|
|
843
|
+
}
|
|
844
|
+
let bearer;
|
|
845
|
+
let r = await api("/api/workspace/agent-token");
|
|
846
|
+
if (r.status === 401 || r.status === 403) {
|
|
847
|
+
const session = await acquireSession("Managing tokens");
|
|
848
|
+
if (!session)
|
|
849
|
+
return 1;
|
|
850
|
+
bearer = session;
|
|
851
|
+
if (sub === "list")
|
|
852
|
+
r = await api("/api/workspace/agent-token", {}, bearer);
|
|
853
|
+
}
|
|
854
|
+
if (sub === "revoke") {
|
|
855
|
+
const rev = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, bearer);
|
|
856
|
+
if (rev.status >= 200 && rev.status < 300) {
|
|
857
|
+
if (rev.body?.changed === false) {
|
|
858
|
+
log(`• ${jti} was already revoked — nothing changed.`);
|
|
859
|
+
log(` Revocation is permanent; there is no re-revoke. See it: npx @decidio/sdk tokens list`);
|
|
860
|
+
return 1;
|
|
861
|
+
}
|
|
862
|
+
log(`✔ revoked ${jti} — the token is dead now (its agent stays registered; mint a new one with init).`);
|
|
863
|
+
return 0;
|
|
864
|
+
}
|
|
865
|
+
if (rev.status === 404) {
|
|
866
|
+
err(`✘ no token with jti "${jti}" — list them: npx @decidio/sdk tokens list`);
|
|
867
|
+
return 1;
|
|
868
|
+
}
|
|
869
|
+
err(`✘ revoke failed (${rev.status}): ${JSON.stringify(rev.body).slice(0, 300)}`);
|
|
870
|
+
return 1;
|
|
871
|
+
}
|
|
872
|
+
if (!(r.status >= 200 && r.status < 300)) {
|
|
873
|
+
err(`✘ could not list tokens (${r.status}): ${JSON.stringify(r.body).slice(0, 300)}`);
|
|
874
|
+
return 1;
|
|
875
|
+
}
|
|
876
|
+
const tokens = r.body?.tokens ?? r.body ?? [];
|
|
877
|
+
if (!Array.isArray(tokens) || !tokens.length) {
|
|
878
|
+
log("No agent tokens in this workspace.");
|
|
879
|
+
return 0;
|
|
880
|
+
}
|
|
881
|
+
const status = (t) => t.revokedAt ? `REVOKED ${String(t.revokedAt).slice(0, 10)}`
|
|
882
|
+
: (t.expiresAt && Date.parse(t.expiresAt) < Date.now()) ? "expired" : "active";
|
|
883
|
+
log(`Agent tokens (${tokens.length}):`);
|
|
884
|
+
for (const t of tokens)
|
|
885
|
+
log(` ${t.jti} ${status(t)} agent=${t.agentId ?? "?"} label=${t.label ?? ""} expires=${t.expiresAt ?? "?"}`);
|
|
886
|
+
const al = r.body?.allowance;
|
|
887
|
+
if (al?.limit != null)
|
|
888
|
+
log(`\nGoverned-action allowance: ${al.remaining}/${al.limit} remaining this hour${al.resetAt ? ` (resets ${al.resetAt})` : ""}.`);
|
|
889
|
+
log(`\nRevoke one: npx @decidio/sdk tokens revoke <jti>`);
|
|
890
|
+
return 0;
|
|
891
|
+
}
|
|
591
892
|
function cmdDev(target, port) {
|
|
592
893
|
log(`decidio dev relay on http://localhost:${port}/decidio/resume → forwarding to ${target}`);
|
|
593
894
|
createServer((req, res) => {
|
|
@@ -624,10 +925,20 @@ const USAGE = `decidio — the Decidio SDK command line
|
|
|
624
925
|
in, or writes files unless you ask it to.)
|
|
625
926
|
|
|
626
927
|
npx @decidio/sdk quickstart the guided two-minute tour: gate → approve → execute → proof
|
|
928
|
+
(interactive: YOUR Enter is the approval. Headless it stops
|
|
929
|
+
at the routed decision; --auto-approve-demo for CI, labeled
|
|
930
|
+
automated; ends with a keep-or-cleanup choice)
|
|
931
|
+
npx @decidio/sdk quickstart cleanup revoke the tour's token + delete its local credentials
|
|
932
|
+
npx @decidio/sdk tokens list agent tokens with status (active/expired/REVOKED) by jti —
|
|
933
|
+
token values are never re-shown
|
|
934
|
+
npx @decidio/sdk tokens revoke <jti> kill a token now instead of waiting out its lifetime
|
|
627
935
|
npx @decidio/sdk init [agentId] sign in, register the agent, mint its API token, write .env
|
|
628
|
-
|
|
936
|
+
--unique append a random suffix (collision-proof samples)
|
|
937
|
+
--force replace a different agent already in this .env
|
|
938
|
+
(its .env is backed up first)
|
|
939
|
+
--expires-days N agent-token lifetime (default 90)
|
|
629
940
|
npx @decidio/sdk doctor check config, connectivity, and what your token can do
|
|
630
|
-
npx @decidio/sdk approvals [--json]
|
|
941
|
+
npx @decidio/sdk approvals [--json] [--mine] list pending agent approvals
|
|
631
942
|
npx @decidio/sdk approvals approve <id> [reason]
|
|
632
943
|
npx @decidio/sdk approvals reject <id> [reason]
|
|
633
944
|
npx @decidio/sdk receipt <id> download a sealed decision's Authority Receipt (.json)
|
|
@@ -635,12 +946,15 @@ const USAGE = `decidio — the Decidio SDK command line
|
|
|
635
946
|
npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
|
|
636
947
|
npx @decidio/sdk --version print the CLI version
|
|
637
948
|
|
|
949
|
+
Every command talks to the hosted sandbox (https://decidio-api.onrender.com) unless
|
|
950
|
+
DECIDIO_API_URL says otherwise; the target host is announced on stderr each run.
|
|
638
951
|
Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error.
|
|
952
|
+
Errors and usage go to stderr; stdout carries results only (safe to pipe).
|
|
639
953
|
Docs: the README that shipped with this package (quickstart, durable resume, adapters).`;
|
|
640
954
|
async function main(argv) {
|
|
641
955
|
const [cmd, a, b, ...rest] = argv.slice(2);
|
|
642
956
|
const wantsHelp = (s) => s === "help" || s === "--help" || s === "-h";
|
|
643
|
-
if (cmd === "--version" || cmd === "-v" || cmd === "version") {
|
|
957
|
+
if (cmd === "--version" || cmd === "-v" || cmd === "-V" || cmd === "version") {
|
|
644
958
|
log(VERSION);
|
|
645
959
|
return 0;
|
|
646
960
|
}
|
|
@@ -652,34 +966,122 @@ async function main(argv) {
|
|
|
652
966
|
log(USAGE);
|
|
653
967
|
return 0;
|
|
654
968
|
}
|
|
969
|
+
const argsIn = argv.slice(3);
|
|
970
|
+
const KNOWN = {
|
|
971
|
+
quickstart: { flags: ["--auto-approve-demo", "--keep-agent"] },
|
|
972
|
+
init: { flags: ["--unique", "--force"], valued: ["--expires-days"] },
|
|
973
|
+
doctor: { flags: [] },
|
|
974
|
+
approvals: { flags: ["--json", "--mine"] },
|
|
975
|
+
receipt: { flags: ["--json"] },
|
|
976
|
+
verify: { flags: [], valued: ["--issuer"] },
|
|
977
|
+
tokens: { flags: [] },
|
|
978
|
+
dev: { flags: [], valued: ["--target", "--port"] },
|
|
979
|
+
};
|
|
980
|
+
const spec = KNOWN[cmd];
|
|
981
|
+
if (spec) {
|
|
982
|
+
const valued = spec.valued ?? [];
|
|
983
|
+
const positionals = [];
|
|
984
|
+
for (let i = 0; i < argsIn.length; i++) {
|
|
985
|
+
const a = argsIn[i];
|
|
986
|
+
if (!a.startsWith("--")) {
|
|
987
|
+
positionals.push(a);
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
if (valued.includes(a)) {
|
|
991
|
+
i++;
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
if (valued.some((v) => a.startsWith(v + "=")))
|
|
995
|
+
continue;
|
|
996
|
+
if (!spec.flags.includes(a)) {
|
|
997
|
+
err(`✘ unknown option "${a}" for \`${cmd}\` — refusing to guess (a flag you meant to matter must never be silently ignored).\n\n${USAGE}`);
|
|
998
|
+
return 2;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
const MAX_POS = { quickstart: 1, init: 1, doctor: 0, receipt: 1, tokens: 2, dev: 0 };
|
|
1002
|
+
const exemptTail = (cmd === "approvals" && (positionals[0] === "approve" || positionals[0] === "reject")) || cmd === "verify";
|
|
1003
|
+
if (!exemptTail && cmd === "approvals" && positionals.length > 0) {
|
|
1004
|
+
err(`✘ unknown approvals subcommand "${positionals[0]}" — use approve or reject (or no subcommand to list).`);
|
|
1005
|
+
return 2;
|
|
1006
|
+
}
|
|
1007
|
+
if (!exemptTail && MAX_POS[cmd] !== undefined && positionals.length > MAX_POS[cmd]) {
|
|
1008
|
+
err(`✘ unexpected argument "${positionals[MAX_POS[cmd]]}" for \`${cmd}\` — refusing to guess (an argument you meant to matter must never be silently ignored).\n\n${USAGE}`);
|
|
1009
|
+
return 2;
|
|
1010
|
+
}
|
|
1011
|
+
if (cmd === "quickstart" && positionals.length === 1 && positionals[0] !== "cleanup") {
|
|
1012
|
+
err(`✘ unknown quickstart subcommand "${positionals[0]}" — the only one is: npx @decidio/sdk quickstart cleanup`);
|
|
1013
|
+
return 2;
|
|
1014
|
+
}
|
|
1015
|
+
if (cmd === "tokens" && positionals[0] === "list" && positionals.length > 1) {
|
|
1016
|
+
err(`✘ unexpected argument "${positionals[1]}" for \`tokens list\` — it takes none (an argument you meant to matter must never be silently ignored).`);
|
|
1017
|
+
return 2;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
655
1020
|
switch (cmd) {
|
|
656
|
-
case "quickstart":
|
|
1021
|
+
case "quickstart": {
|
|
1022
|
+
if (argsIn.includes("cleanup")) {
|
|
1023
|
+
const envFile = existsSync(".env.quickstart") ? ".env.quickstart"
|
|
1024
|
+
: (readEnvFile().DECIDIO_AGENT_ID ?? "").startsWith("quickstart-") ? ENV_PATH : undefined;
|
|
1025
|
+
if (!envFile) {
|
|
1026
|
+
log("Nothing to clean up here: no .env.quickstart, and .env doesn't belong to a quickstart agent.");
|
|
1027
|
+
return 0;
|
|
1028
|
+
}
|
|
1029
|
+
const session = await acquireSession("Cleaning the quickstart up");
|
|
1030
|
+
if (!session)
|
|
1031
|
+
return 1;
|
|
1032
|
+
return quickstartCleanup(session, envFile);
|
|
1033
|
+
}
|
|
1034
|
+
return cmdQuickstart({ autoApproveDemo: argsIn.includes("--auto-approve-demo"), keepAgent: argsIn.includes("--keep-agent") });
|
|
1035
|
+
}
|
|
657
1036
|
case "init": {
|
|
658
|
-
const argsIn = argv.slice(3);
|
|
659
1037
|
const unique = argsIn.includes("--unique");
|
|
660
|
-
const
|
|
1038
|
+
const expIdx = argsIn.indexOf("--expires-days");
|
|
1039
|
+
const expEq = argsIn.find((x) => x.startsWith("--expires-days="));
|
|
1040
|
+
const expRaw = expIdx >= 0 ? argsIn[expIdx + 1] : expEq ? expEq.slice("--expires-days=".length) : undefined;
|
|
1041
|
+
if ((expIdx >= 0 || expEq) && (!expRaw || !/^\d+$/.test(expRaw) || Number(expRaw) < 1)) {
|
|
1042
|
+
err("✘ --expires-days needs a positive whole number of days (e.g. --expires-days 30).");
|
|
1043
|
+
return 2;
|
|
1044
|
+
}
|
|
1045
|
+
const nameArg = argsIn.find((x, i) => !x.startsWith("-") && !(expIdx >= 0 && i === expIdx + 1));
|
|
661
1046
|
let name = nameArg ?? process.env.DECIDIO_AGENT_ID ?? "my-agent";
|
|
662
1047
|
if (unique)
|
|
663
1048
|
name = `${name}-${randomBytes(2).toString("hex")}`;
|
|
664
|
-
return cmdInit(name);
|
|
1049
|
+
return cmdInit(name, { force: argsIn.includes("--force"), expiresDays: expRaw ? Number(expRaw) : undefined });
|
|
665
1050
|
}
|
|
666
1051
|
case "doctor": return cmdDoctor();
|
|
667
|
-
case "approvals":
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
1052
|
+
case "approvals": {
|
|
1053
|
+
const positional = argsIn.filter((x) => !x.startsWith("-"));
|
|
1054
|
+
return cmdApprovals(positional[0], positional[1], positional.slice(2).join(" ") || undefined, { json: argsIn.includes("--json"), mine: argsIn.includes("--mine") });
|
|
1055
|
+
}
|
|
1056
|
+
case "receipt": return cmdReceipt(argsIn.find((x) => !x.startsWith("-")), argsIn.includes("--json"));
|
|
1057
|
+
case "verify": return cmdVerify(argsIn);
|
|
1058
|
+
case "tokens": {
|
|
1059
|
+
const positional = argsIn.filter((x) => !x.startsWith("-"));
|
|
1060
|
+
return cmdTokens(positional[0], positional[1]);
|
|
671
1061
|
}
|
|
672
|
-
case "verify": return cmdVerify(argv.slice(3));
|
|
673
1062
|
case "dev": {
|
|
674
|
-
const
|
|
675
|
-
|
|
1063
|
+
const flag = (name) => {
|
|
1064
|
+
const i = argsIn.indexOf(name);
|
|
1065
|
+
if (i >= 0)
|
|
1066
|
+
return argsIn[i + 1];
|
|
1067
|
+
const eq = argsIn.find((x) => x.startsWith(name + "="));
|
|
1068
|
+
return eq ? eq.slice(name.length + 1) : undefined;
|
|
1069
|
+
};
|
|
676
1070
|
const target = flag("--target") || process.env.DECIDIO_AGENT_RESUME_URL || "http://localhost:4100/decidio/resume";
|
|
677
|
-
const
|
|
678
|
-
|
|
1071
|
+
const portRaw = flag("--port") || process.env.DECIDIO_DEV_PORT || "4099";
|
|
1072
|
+
if (!/^\d+$/.test(portRaw) || Number(portRaw) < 1 || Number(portRaw) > 65535) {
|
|
1073
|
+
err(`✘ --port needs a number between 1 and 65535 (got "${portRaw}").`);
|
|
1074
|
+
return 2;
|
|
1075
|
+
}
|
|
1076
|
+
if (!/^https?:\/\//.test(target)) {
|
|
1077
|
+
err(`✘ --target needs an http(s) URL to forward the webhook to (got "${target}").`);
|
|
1078
|
+
return 2;
|
|
1079
|
+
}
|
|
1080
|
+
return cmdDev(target, Number(portRaw));
|
|
679
1081
|
}
|
|
680
1082
|
default:
|
|
681
|
-
|
|
682
|
-
return
|
|
1083
|
+
err(`✘ unknown command "${cmd}"\n\n${USAGE}`);
|
|
1084
|
+
return 2;
|
|
683
1085
|
}
|
|
684
1086
|
}
|
|
685
1087
|
main(process.argv).then((code) => { if (code !== 0)
|