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