@decidio/sdk 0.1.9 → 0.1.11
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 +134 -29
- package/dist/adapters/_gate.d.ts +25 -1
- package/dist/adapters/_gate.js +69 -26
- package/dist/adapters/langgraph.js +2 -15
- package/dist/adapters/openai.js +2 -5
- package/dist/adapters/temporal.js +18 -20
- package/dist/canonical.d.ts +18 -0
- package/dist/canonical.js +104 -0
- package/dist/cli.js +146 -272
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +18 -0
- package/dist/evidence.d.ts +41 -0
- package/dist/evidence.js +57 -0
- package/dist/guard.d.ts +29 -8
- package/dist/guard.js +28 -35
- package/dist/index.d.ts +19 -10
- package/dist/index.js +49 -113
- package/dist/inngest.js +6 -56
- package/dist/resume.d.ts +83 -26
- package/dist/resume.js +213 -150
- package/dist/signing.d.ts +72 -6
- package/dist/signing.js +107 -41
- package/openapi.yaml +47 -1
- package/package.json +3 -9
package/dist/cli.js
CHANGED
|
@@ -1,31 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// @decidio/sdk CLI — the on-ramp. Run via `npx @decidio/sdk <cmd>` (or tsx in-repo).
|
|
3
|
-
// init [agentId] sign in, register the agent, mint its API token, write .env
|
|
4
|
-
// doctor check config + connectivity + durability readiness
|
|
5
|
-
// approvals list pending agent approvals; `approvals approve|reject <id> [reason]`
|
|
6
|
-
// receipt <id> download a sealed decision's Authority Receipt to a .json file
|
|
7
|
-
// dev [--target U] signal-only relay: forwards Decidio's webhook to your local agent URL
|
|
8
|
-
//
|
|
9
|
-
// TWO TOKEN KINDS, deliberately (self-serve design 2026-08-28): commands that ADMINISTER a
|
|
10
|
-
// workspace (init's register+mint, approvals, receipt) need the owner's SESSION token — acquired
|
|
11
|
-
// interactively per run or from DECIDIO_SESSION_TOKEN, held in memory, NEVER written to disk.
|
|
12
|
-
// What lands in .env as DECIDIO_API_TOKEN is the floor-limited AGENT token the runtime SDK
|
|
13
|
-
// needs (it can call /agent/* and nothing else). A session-capable secret must never live in
|
|
14
|
-
// an agent host's .env — that was the pre-publish design's hole.
|
|
15
|
-
//
|
|
16
|
-
// MESSAGE DISCIPLINE (founder bar 2026-08-29, from the Codex acceptance run): every failure
|
|
17
|
-
// tells the developer WHAT happened, WHY, and the EXACT next command. Every success says what
|
|
18
|
-
// changed, where, and what to do next. No stack traces for expected failures; no green ✔ for a
|
|
19
|
-
// no-op (the replay-approve bug: the server's idempotent alreadyResolved was reported as a
|
|
20
|
-
// fresh success — an authority CLI may never dress a no-op as an action).
|
|
21
2
|
import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync } from "node:fs";
|
|
22
3
|
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { userInfo } from "node:os";
|
|
23
5
|
import { createServer } from "node:http";
|
|
24
6
|
import { execFileSync } from "node:child_process";
|
|
25
7
|
import { createInterface } from "node:readline";
|
|
26
8
|
import { createRequire } from "node:module";
|
|
27
9
|
import { generateKeypair } from "./signing.js";
|
|
28
|
-
import { withApproval, DecidioRateLimitError } from "./index.js";
|
|
10
|
+
import { withApproval, DecidioRateLimitError, didFromPrivateKey } from "./index.js";
|
|
29
11
|
import { verifyReceipt, runVerify } from "@decidio/verify";
|
|
30
12
|
const VERSION = (() => {
|
|
31
13
|
try {
|
|
@@ -35,12 +17,8 @@ const VERSION = (() => {
|
|
|
35
17
|
return "unknown";
|
|
36
18
|
}
|
|
37
19
|
})();
|
|
38
|
-
// ---- environment: load the CWD .env FIRST (Codex finding: init writes .env, but the next
|
|
39
|
-
// command ignored it and pointed at localhost). Process env always wins — .env only fills gaps.
|
|
40
|
-
// NEVER a crash: an unreadable .env (EPERM, EISDIR, a mangled volume) is that file's problem,
|
|
41
|
-
// not this command's — warn on stderr and continue (round-9 catch: it was an unhandled Node
|
|
42
|
-
// stack trace).
|
|
43
20
|
let envWarned = false;
|
|
21
|
+
let envUnreadable = false;
|
|
44
22
|
function readEnvFile() {
|
|
45
23
|
const out = {};
|
|
46
24
|
try {
|
|
@@ -52,22 +30,17 @@ function readEnvFile() {
|
|
|
52
30
|
}
|
|
53
31
|
}
|
|
54
32
|
catch (e) {
|
|
33
|
+
envUnreadable = true;
|
|
55
34
|
if (!envWarned) {
|
|
56
35
|
envWarned = true;
|
|
57
|
-
console.error(`⚠ could not read .env (${e?.code ?? e?.message ?? e})
|
|
36
|
+
console.error(`⚠ could not read .env (${e?.code ?? e?.message ?? e}).`);
|
|
58
37
|
}
|
|
59
38
|
}
|
|
60
39
|
return out;
|
|
61
40
|
}
|
|
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
41
|
const OFFLINE_CMD = ["verify", "--version", "-v", "-V", "version", "--help", "-h", "help", undefined].includes(process.argv[2]);
|
|
66
42
|
if (!OFFLINE_CMD)
|
|
67
43
|
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
44
|
if (k === "DECIDIO_SESSION_TOKEN") {
|
|
72
45
|
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
46
|
continue;
|
|
@@ -75,32 +48,27 @@ if (!OFFLINE_CMD)
|
|
|
75
48
|
if (process.env[k] === undefined)
|
|
76
49
|
process.env[k] = v;
|
|
77
50
|
}
|
|
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
51
|
const API = (process.env.DECIDIO_API_URL ?? "https://decidio-api.onrender.com").replace(/\/$/, "");
|
|
84
|
-
// Token is read AT CALL TIME, not captured at import: init mints a fresh agent token mid-run and
|
|
85
|
-
// its closing doctor must probe with THAT, not the pre-init env (round-3 Codex catch — the stale
|
|
86
|
-
// capture made init's own doctor print "token: REJECTED" seconds after a successful mint).
|
|
87
52
|
const TOKEN = () => process.env.DECIDIO_API_TOKEN;
|
|
88
53
|
const ENV_PATH = ".env";
|
|
89
54
|
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
55
|
const err = (...a) => console.error(...a);
|
|
94
|
-
/** Fetch wrapper that turns transport failures into guidance instead of a stack trace. */
|
|
95
56
|
let hostAnnounced = false;
|
|
96
57
|
async function api(path, init = {}, bearer) {
|
|
97
58
|
if (!hostAnnounced) {
|
|
98
59
|
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
60
|
err(`· decidio → ${API}${process.env.DECIDIO_API_URL ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
|
|
102
61
|
}
|
|
103
62
|
const auth = bearer ?? TOKEN();
|
|
63
|
+
if (auth && envUnreadable && !process.env.DECIDIO_API_URL) {
|
|
64
|
+
err(`✘ .env exists here but could not be read, so the target Decidio is unknown — refusing to send credentials to a guessed host (${API}).`);
|
|
65
|
+
err(` Fix the file's permissions, or set DECIDIO_API_URL explicitly for this command. Nothing was sent.`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
if (auth && /^http:[/][/]/i.test(API) && !/^http:[/][/](localhost|127.0.0.1|.::1.)([:/]|$)/i.test(API)) {
|
|
69
|
+
err(`✘ refusing to send credentials over plaintext http to ${API} — use https, or localhost for local dev. Nothing was sent.`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
104
72
|
let res;
|
|
105
73
|
try {
|
|
106
74
|
res = await fetch(API + path, { ...init, headers: { "content-type": "application/json", ...(auth ? { authorization: `Bearer ${auth}` } : {}), ...(init.headers ?? {}) } });
|
|
@@ -127,32 +95,30 @@ async function api(path, init = {}, bearer) {
|
|
|
127
95
|
}
|
|
128
96
|
return { status: res.status, body };
|
|
129
97
|
}
|
|
130
|
-
/** Write a secrets file OWNER-ONLY (0600). `mode` applies on creation; an existing file keeps its
|
|
131
|
-
* old permissions, so chmod as well. Best-effort: Windows ignores POSIX modes (its ACLs are
|
|
132
|
-
* inherited from the directory), and a chmod failure must never lose the write. */
|
|
133
98
|
function writeSecretFile(path, body) {
|
|
134
99
|
writeFileSync(path, body, { mode: 0o600 });
|
|
135
100
|
try {
|
|
136
101
|
chmodSync(path, 0o600);
|
|
137
102
|
}
|
|
138
|
-
catch {
|
|
103
|
+
catch { }
|
|
139
104
|
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
105
|
try {
|
|
144
|
-
execFileSync("icacls", [path, "/inheritance:r", "/grant:r", `${
|
|
106
|
+
execFileSync("icacls", [path, "/inheritance:r", "/grant:r", `${effectiveUser()}:F`], { stdio: "ignore" });
|
|
145
107
|
}
|
|
146
108
|
catch {
|
|
147
109
|
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
110
|
}
|
|
149
111
|
}
|
|
150
112
|
}
|
|
113
|
+
function effectiveUser() {
|
|
114
|
+
try {
|
|
115
|
+
return userInfo().username;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return process.env.USERNAME ?? process.env.USER ?? "";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
151
121
|
function upsertEnv(updates) {
|
|
152
|
-
// Update IN PLACE, never rewrite (round-2 blind catch: the merge-and-rewrite dropped every
|
|
153
|
-
// comment and blank line — "# Production database — do not touch" vanished unrecoverably,
|
|
154
|
-
// since this file is gitignored). A user's line only changes when its KEY is being updated;
|
|
155
|
-
// everything else survives byte-for-byte; new keys append.
|
|
156
122
|
const remaining = { ...updates };
|
|
157
123
|
const updatedKeys = new Set();
|
|
158
124
|
const lines = [];
|
|
@@ -160,8 +126,6 @@ function upsertEnv(updates) {
|
|
|
160
126
|
if (existsSync(ENV_PATH))
|
|
161
127
|
for (const line of readFileSync(ENV_PATH, "utf8").split(/\r?\n/)) {
|
|
162
128
|
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
129
|
if (m && updatedKeys.has(m[1]))
|
|
166
130
|
continue;
|
|
167
131
|
if (m && m[1] in remaining) {
|
|
@@ -173,49 +137,58 @@ function upsertEnv(updates) {
|
|
|
173
137
|
lines.push(line);
|
|
174
138
|
}
|
|
175
139
|
}
|
|
176
|
-
catch {
|
|
140
|
+
catch { }
|
|
177
141
|
while (lines.length && lines[lines.length - 1] === "")
|
|
178
142
|
lines.pop();
|
|
179
143
|
for (const [k, v] of Object.entries(remaining))
|
|
180
144
|
lines.push(`${k}=${v}`);
|
|
181
145
|
writeSecretFile(ENV_PATH, lines.join("\n") + "\n");
|
|
182
146
|
}
|
|
183
|
-
|
|
184
|
-
|
|
147
|
+
function isTrackedByGit(path) {
|
|
148
|
+
try {
|
|
149
|
+
execFileSync("git", ["ls-files", "--error-unmatch", path], { stdio: "ignore" });
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function assertNotTracked(path) {
|
|
157
|
+
if (!isTrackedByGit(path))
|
|
158
|
+
return true;
|
|
159
|
+
err(`✘ ${path} is ALREADY TRACKED by git — refusing to write the agent's private key into it.`);
|
|
160
|
+
err(` A .gitignore entry does not untrack a file: the next \`git add -A\` would commit`);
|
|
161
|
+
err(` DECIDIO_AGENT_KEY and DECIDIO_API_TOKEN into permanent history. Nothing was written.`);
|
|
162
|
+
err(` Fix it, then re-run:`);
|
|
163
|
+
err(` git rm --cached ${path} # stop tracking it (keeps your local copy)`);
|
|
164
|
+
err(` git commit -m "stop tracking ${path}"`);
|
|
165
|
+
err(` If it has ALREADY been committed with secrets in it, rotate them — history rewriting`);
|
|
166
|
+
err(` alone does not un-leak a key that was pushed.`);
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
185
169
|
function ensureGitignore() {
|
|
186
|
-
// Every file this CLI may write that can hold a private key — including the `--force` backup
|
|
187
|
-
// (.env.<agent>.bak), which git's plain `.env` pattern does NOT match.
|
|
188
170
|
const entries = [".env", ".env.quickstart", ".env.*.bak", ".decidio-pending/"];
|
|
189
171
|
const existing = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
|
|
190
172
|
const missing = entries.filter((e) => !existing.split(/\r?\n/).some((l) => l.trim() === e || l.trim() === e.replace(/\/$/, "")));
|
|
191
173
|
if (!missing.length)
|
|
192
174
|
return;
|
|
193
175
|
appendFileSync(".gitignore", (existing && !existing.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n");
|
|
194
|
-
log(`✔ added ${missing.join(" + ")} to .gitignore (
|
|
176
|
+
log(`✔ added ${missing.join(" + ")} to .gitignore (new secret files here will not be committed)`);
|
|
195
177
|
}
|
|
196
|
-
// ---- interactive input (session sign-in) ---------------------------------------------------
|
|
197
178
|
function ask(question, mute = false) {
|
|
198
179
|
return new Promise((resolve) => {
|
|
199
|
-
const rl = createInterface({ input: process.stdin, output: process.
|
|
180
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
|
|
200
181
|
if (mute) {
|
|
201
|
-
// Hide password echo: after the prompt renders, swallow subsequent output writes.
|
|
202
182
|
const anyRl = rl;
|
|
203
183
|
const write = anyRl._writeToOutput?.bind(rl);
|
|
204
184
|
anyRl._writeToOutput = (s) => { if (s.includes(question))
|
|
205
185
|
write?.(s); };
|
|
206
186
|
}
|
|
207
187
|
rl.question(question, (answer) => { rl.close(); if (mute)
|
|
208
|
-
process.
|
|
188
|
+
process.stderr.write("\n"); resolve(answer.trim()); });
|
|
209
189
|
});
|
|
210
190
|
}
|
|
211
|
-
/** Owner SESSION token, memory-only. Sources in order: DECIDIO_SESSION_TOKEN env, interactive
|
|
212
|
-
* email+password (`/demo/login`), pasted sign-in link (`/demo/login-consume` — for the
|
|
213
|
-
* password-less accounts the welcome flow creates). Never persisted. */
|
|
214
191
|
async function acquireSession(purpose) {
|
|
215
|
-
// TRANSPORT CHECK FIRST — before ANY credential is selected, including one already in the
|
|
216
|
-
// environment. This guard used to sit after the DECIDIO_SESSION_TOKEN shortcut, so an
|
|
217
|
-
// env-supplied session token would ride plaintext http to a remote host without ever being
|
|
218
|
-
// checked. The rule is about the WIRE, so it cannot depend on where the credential came from.
|
|
219
192
|
if (/^http:\/\//i.test(API) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]|$)/i.test(API)) {
|
|
220
193
|
err(`✘ refusing to send workspace credentials over plaintext http to ${API}.`);
|
|
221
194
|
err(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
|
|
@@ -224,8 +197,6 @@ async function acquireSession(purpose) {
|
|
|
224
197
|
if (process.env.DECIDIO_SESSION_TOKEN)
|
|
225
198
|
return process.env.DECIDIO_SESSION_TOKEN;
|
|
226
199
|
if (!process.stdin.isTTY) {
|
|
227
|
-
// WHOLE failure messages ride stderr (round-2 blind catch: the ✘ headline went to stderr
|
|
228
|
-
// while its remediation went to stdout — split mid-sentence for any consumer of either).
|
|
229
200
|
err(`✘ ${purpose} needs a Decidio workspace sign-in, and this terminal can't prompt for one.`);
|
|
230
201
|
err(` Fix: set DECIDIO_SESSION_TOKEN and re-run. To mint one:`);
|
|
231
202
|
err(` 1. Request a sign-in link from your workspace's sign-in screen (or use your welcome email).`);
|
|
@@ -262,16 +233,12 @@ async function acquireSession(purpose) {
|
|
|
262
233
|
return null;
|
|
263
234
|
}
|
|
264
235
|
async function cmdInit(agentId, opts = {}) {
|
|
236
|
+
if (!assertNotTracked(ENV_PATH))
|
|
237
|
+
return 1;
|
|
265
238
|
err(`Registering agent "${agentId}" with Decidio at ${API}…`);
|
|
266
|
-
// CLIENT-SIDE KEYGEN: generate the Ed25519 keypair LOCALLY and register only the PUBLIC
|
|
267
|
-
// did:key. The private key NEVER crosses the wire — it goes straight to this host's env.
|
|
268
239
|
const existingEnv = readEnvFile();
|
|
269
240
|
const existingKey = existingEnv.DECIDIO_AGENT_KEY;
|
|
270
241
|
const sourceSystem = process.env.DECIDIO_SOURCE_SYSTEM || existingEnv.DECIDIO_SOURCE_SYSTEM;
|
|
271
|
-
// NEVER silently orphan a different agent's key. Registering a NEW name in a directory that
|
|
272
|
-
// already holds another agent's key would overwrite it — and keys never leave the machine that
|
|
273
|
-
// made them, so that loss is irrecoverable (it strands the old agent in init's own 409 stop).
|
|
274
|
-
// Checked BEFORE registering, so a refusal leaves nothing behind server-side either.
|
|
275
242
|
if (existingKey && existingEnv.DECIDIO_AGENT_ID && existingEnv.DECIDIO_AGENT_ID !== agentId && !opts.force) {
|
|
276
243
|
err(`✘ this directory's .env already holds the key for a DIFFERENT agent ("${existingEnv.DECIDIO_AGENT_ID}").`);
|
|
277
244
|
err(` Registering "${agentId}" here would overwrite that key — and a private key that is`);
|
|
@@ -283,8 +250,6 @@ async function cmdInit(agentId, opts = {}) {
|
|
|
283
250
|
}
|
|
284
251
|
const { did: localDid, privateKeyBase64: localKey } = generateKeypair();
|
|
285
252
|
const registerBody = JSON.stringify({ agentId, did: localDid, ...(sourceSystem ? { sourceSystem } : {}) });
|
|
286
|
-
// First try whatever bearer the env already carries (the local-dev / operator path — a static
|
|
287
|
-
// admin token, or dev-open auth). On 401/403 fall through to the self-serve session flow.
|
|
288
253
|
let session = null;
|
|
289
254
|
let r = await api("/api/agents/register", { method: "POST", body: registerBody });
|
|
290
255
|
if (r.status === 401 || r.status === 403) {
|
|
@@ -295,10 +260,6 @@ async function cmdInit(agentId, opts = {}) {
|
|
|
295
260
|
}
|
|
296
261
|
let did, privateKey;
|
|
297
262
|
if (r.status === 409) {
|
|
298
|
-
// Already registered to a server-held did. WITHOUT the original private key on this machine
|
|
299
|
-
// this is a HARD STOP, not a degraded continue (round-3 Codex catch: init used to write a
|
|
300
|
-
// keyless .env and print happy-path next steps — every request then routed unproven and
|
|
301
|
-
// execution evidence silently capped at agent_asserted).
|
|
302
263
|
did = r.body?.did;
|
|
303
264
|
if (existingKey) {
|
|
304
265
|
log(`• "${agentId}" is already registered in this workspace (did ${did}) — reusing it with the DECIDIO_AGENT_KEY already in .env.`);
|
|
@@ -325,15 +286,9 @@ async function cmdInit(agentId, opts = {}) {
|
|
|
325
286
|
err(` Fix the cause above and re-run — init is safe to repeat.`);
|
|
326
287
|
return 1;
|
|
327
288
|
}
|
|
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
289
|
let agentToken, workspaceId, tokenJti;
|
|
333
290
|
const mintBearer = session ?? TOKEN();
|
|
334
291
|
if (mintBearer) {
|
|
335
|
-
// Right-sized lifetime, not the server's year-long default: a CLI-minted credential should
|
|
336
|
-
// expire on the horizon of the work it was minted for (--expires-days to override).
|
|
337
292
|
const expiresInDays = opts.expiresDays ?? 90;
|
|
338
293
|
const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "sdk-cli", expiresInDays }) }, mintBearer);
|
|
339
294
|
if (mint.status >= 200 && mint.status < 300 && mint.body?.token) {
|
|
@@ -341,9 +296,6 @@ async function cmdInit(agentId, opts = {}) {
|
|
|
341
296
|
workspaceId = mint.body.workspaceId;
|
|
342
297
|
tokenJti = mint.body.jti;
|
|
343
298
|
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
299
|
const superseded = Array.isArray(mint.body.supersededJtis) ? mint.body.supersededJtis : [];
|
|
348
300
|
for (const s of superseded)
|
|
349
301
|
log(` • this replaces the agent's previous token (jti ${s}) — now REVOKED: the server keeps one active token per agent. Anything still using it will 401.`);
|
|
@@ -364,28 +316,20 @@ async function cmdInit(agentId, opts = {}) {
|
|
|
364
316
|
if (did)
|
|
365
317
|
envOut.DECIDIO_AGENT_DID = did;
|
|
366
318
|
if (privateKey)
|
|
367
|
-
envOut.DECIDIO_AGENT_KEY = privateKey;
|
|
319
|
+
envOut.DECIDIO_AGENT_KEY = privateKey;
|
|
368
320
|
if (agentToken)
|
|
369
|
-
envOut.DECIDIO_API_TOKEN = agentToken;
|
|
321
|
+
envOut.DECIDIO_API_TOKEN = agentToken;
|
|
370
322
|
if (workspaceId)
|
|
371
323
|
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
324
|
if (tokenJti)
|
|
375
325
|
envOut.DECIDIO_TOKEN_JTI = tokenJti;
|
|
376
|
-
// IGNORE RULE BEFORE THE SECRET: writing .env first leaves a window (however short) where a
|
|
377
|
-
// private key sits in a repo with no ignore rule covering it.
|
|
378
326
|
ensureGitignore();
|
|
379
327
|
if (opts.force && existingKey && existingEnv.DECIDIO_AGENT_ID && existingEnv.DECIDIO_AGENT_ID !== agentId) {
|
|
380
|
-
// --force still must not DESTROY the old key: park it beside the new one, owner-only.
|
|
381
328
|
const backup = `.env.${existingEnv.DECIDIO_AGENT_ID.replace(/[^\w.-]/g, "_")}.bak`;
|
|
382
329
|
writeSecretFile(backup, readFileSync(ENV_PATH, "utf8"));
|
|
383
330
|
log(`✔ backed the previous agent's .env up to ${backup} (its key would otherwise be unrecoverable)`);
|
|
384
331
|
}
|
|
385
332
|
upsertEnv(envOut);
|
|
386
|
-
// The closing doctor (and anything else this process does) must see what init just wrote —
|
|
387
|
-
// OVERWRITE process.env with the fresh values (round-3 Codex catch: doctor probed with the
|
|
388
|
-
// stale pre-init token and reported the mint it just watched as REJECTED).
|
|
389
333
|
for (const [k, v] of Object.entries(envOut))
|
|
390
334
|
process.env[k] = v;
|
|
391
335
|
log(`✔ wrote ${ENV_PATH} (owner-only where the OS supports it) — every decidio command reads it from this directory (your script loads it with node --env-file=.env)`);
|
|
@@ -422,38 +366,32 @@ async function cmdDoctor() {
|
|
|
422
366
|
let ok = true;
|
|
423
367
|
let warned = 0;
|
|
424
368
|
const line = (good, label) => { log(`${good ? "✔" : "✘"} ${label}`); ok = ok && good; };
|
|
425
|
-
// Warnings COUNT in the summary (round-3 blind catch: "doctor: all green." directly under a ⚠
|
|
426
|
-
// line contradicts itself — green with a caveat must say so).
|
|
427
369
|
const warn = (label) => { warned++; log(`⚠ ${label}`); };
|
|
428
|
-
// An unset URL is a WORKING default (the hosted sandbox), not a failure — sandbox-first ruling.
|
|
429
370
|
log(`✔ API URL: ${API}${(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL) ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
|
|
430
371
|
line(!!(process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID), `agent id: ${process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID || "(unset — run: npx @decidio/sdk init <agent-name>)"}`);
|
|
431
|
-
// The webhook secret is OPERATOR-ONLY transport config — a missing one must never fail doctor
|
|
432
|
-
// (blind-review catch: doctor exited 1 immediately after the product's own successful
|
|
433
|
-
// quickstart, whose env rightly omits it). Informational either way.
|
|
434
372
|
const secret = process.env.DECIDIO_WEBHOOK_SECRET || env.DECIDIO_WEBHOOK_SECRET;
|
|
435
373
|
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
374
|
: "• webhook secret: not set — only needed for the operator resume-webhook transport; the poll worker guard.worker() is the self-serve resume path (init generates one)");
|
|
437
|
-
// Signing key — REQUIRED to be ELIGIBLE for auto-approve. Whether anything auto-approves is
|
|
438
|
-
// the workspace policy's call, not this key's (Codex finding: the old wording claimed
|
|
439
|
-
// "auto-approve is in effect" while deny-by-default routed everything).
|
|
440
375
|
const signing = (process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY) && (process.env.DECIDIO_AGENT_DID || env.DECIDIO_AGENT_DID);
|
|
441
|
-
if (signing)
|
|
442
|
-
|
|
376
|
+
if (signing) {
|
|
377
|
+
const storedDid = (process.env.DECIDIO_AGENT_DID || env.DECIDIO_AGENT_DID);
|
|
378
|
+
let derived;
|
|
379
|
+
try {
|
|
380
|
+
derived = didFromPrivateKey((process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY));
|
|
381
|
+
}
|
|
382
|
+
catch { }
|
|
383
|
+
if (!derived)
|
|
384
|
+
line(false, "signing key: PRESENT BUT UNREADABLE — DECIDIO_AGENT_KEY is not a valid Ed25519 private key. Re-run `npx @decidio/sdk init` (its public half is what Decidio registered).");
|
|
385
|
+
else if (derived !== storedDid)
|
|
386
|
+
line(false, `signing key: DOES NOT MATCH this agent's DID — the key here derives ${derived}, but .env registers ${storedDid}. Signed requests will not be recognised as this agent (auto-approve and application_confirmed evidence will both fail). Restore the original key, or re-run \`npx @decidio/sdk init\` to register a fresh one.`);
|
|
387
|
+
else
|
|
388
|
+
log("✔ signing key: set and MATCHES the registered DID — the agent can prove its identity, making it ELIGIBLE for policy auto-approve. Whether a rule matches is your workspace policy's decision; with no matching rule every request routes to a human (deny-by-default).");
|
|
389
|
+
}
|
|
443
390
|
else
|
|
444
391
|
warn("signing key: MISSING (DECIDIO_AGENT_KEY/DID) — the agent can't prove identity, so EVERY request routes to a human. Run `npx @decidio/sdk init` to generate one.");
|
|
445
|
-
// Reachability and token role are SEPARATE checks (the old probe called any <500 green, so a
|
|
446
|
-
// wrong-kind token looked healthy). /api/health is unauthenticated truth about reachability;
|
|
447
|
-
// /api/records then classifies the token.
|
|
448
392
|
const h = await api("/api/health", {}, "");
|
|
449
393
|
line(h.status >= 200 && h.status < 300, `connectivity: API reachable (${h.status})`);
|
|
450
|
-
// EXPIRY BEFORE IT BITES: a right-sized token is only an improvement if its end is visible in
|
|
451
|
-
// advance — otherwise a production agent simply starts 401ing one day. Read `exp` out of the
|
|
452
|
-
// token's own payload (display only; the server remains the authority on validity).
|
|
453
394
|
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
395
|
if (!tok) {
|
|
458
396
|
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
397
|
log(warned ? `\ndoctor: fix the ✘ items above, then re-run \`npx @decidio/sdk doctor\`. (${warned} warning${warned === 1 ? "" : "s"})` : "\ndoctor: fix the ✘ items above, then re-run `npx @decidio/sdk doctor`.");
|
|
@@ -464,30 +402,22 @@ async function cmdDoctor() {
|
|
|
464
402
|
const payload = JSON.parse(Buffer.from(tok.split(".")[1] ?? "", "base64url").toString("utf8"));
|
|
465
403
|
if (typeof payload?.exp === "number") {
|
|
466
404
|
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
405
|
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
406
|
const left = msLeft < 86_400_000 ? `${Math.max(1, Math.floor(msLeft / 3_600_000))} hour(s)` : `${days} day(s)`;
|
|
473
407
|
if (msLeft < 0)
|
|
474
408
|
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.`);
|
|
475
409
|
else if (days <= 14)
|
|
476
410
|
warn(`agent token: expires in ${left} — mint a fresh one before it does: npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}`);
|
|
477
411
|
else
|
|
478
|
-
log(`✔ agent token:
|
|
412
|
+
log(`✔ agent token: unexpired for another ${days} days (validity is decided by the server — see the token-scope check below)`);
|
|
479
413
|
}
|
|
480
414
|
}
|
|
481
|
-
catch {
|
|
415
|
+
catch { }
|
|
482
416
|
}
|
|
483
417
|
const rec = await api("/api/records");
|
|
484
418
|
if (rec.status >= 200 && rec.status < 300)
|
|
485
|
-
|
|
419
|
+
warn("token scope: SESSION/ADMIN-capable — this credential can approve and administer, and it does not belong in an agent's environment (the agent runtime needs only the floor-limited token init mints). Move it to your shell/CI secret store.");
|
|
486
420
|
else if (rec.status === 401) {
|
|
487
|
-
// A 401 off the agent floor could also mean an INVALID token (Codex catch: don't
|
|
488
|
-
// false-green expiry). Disambiguate with a floor probe: a valid agent token gets 404 for an
|
|
489
|
-
// unknown decision (authorized, not found); an invalid one gets 401 again. A rejection here
|
|
490
|
-
// flips the overall verdict — "all green" after a red line was round 3's other doctor bug.
|
|
491
421
|
const probe = await api("/agent/status/doctor-scope-probe");
|
|
492
422
|
if (probe.status === 404)
|
|
493
423
|
log("✔ token scope: agent floor — correct for the runtime SDK (approvals/receipt will ask you to sign in)");
|
|
@@ -503,21 +433,19 @@ async function cmdDoctor() {
|
|
|
503
433
|
return ok ? 0 : 1;
|
|
504
434
|
}
|
|
505
435
|
async function cmdApprovals(sub, id, reason, flags = {}) {
|
|
506
|
-
// Approvals are a HUMAN act on the workspace — session scope, never the agent token. If the
|
|
507
|
-
// env's token is agent-floor (401 on /api/*), sign in interactively; memory-only as always.
|
|
508
436
|
const jsonMode = !!flags.json;
|
|
509
437
|
const mineOnly = !!flags.mine;
|
|
510
438
|
if (sub?.startsWith("--"))
|
|
511
439
|
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
440
|
if ((sub === "approve" || sub === "reject") && !id) {
|
|
515
441
|
err(`usage: npx @decidio/sdk approvals ${sub} <decisionId> [reason]`);
|
|
442
|
+
err(` npx @decidio/sdk approvals ${sub} <decisionId> --reason "text with -dashes"`);
|
|
443
|
+
return 2;
|
|
444
|
+
}
|
|
445
|
+
if ((sub === "approve" || sub === "reject") && (flags.json || flags.mine)) {
|
|
446
|
+
err(`✘ ${flags.json ? "--json" : "--mine"} applies to \`approvals\` (the list), not to ${sub} — refusing to ignore a flag you meant to matter.`);
|
|
516
447
|
return 2;
|
|
517
448
|
}
|
|
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
449
|
if (mineOnly && !process.env.DECIDIO_AGENT_ID) {
|
|
522
450
|
err(`✘ --mine needs an agent identity, and there is no DECIDIO_AGENT_ID in this directory's .env or your environment.`);
|
|
523
451
|
err(` Run this from your agent's directory (where init wrote .env), or drop --mine for the workspace-wide list.`);
|
|
@@ -535,9 +463,6 @@ async function cmdApprovals(sub, id, reason, flags = {}) {
|
|
|
535
463
|
if (sub === "approve" || sub === "reject") {
|
|
536
464
|
const r = await api(`/api/decisions/${encodeURIComponent(decisionArg)}/${sub === "approve" ? "approve" : "reject"}`, { method: "POST", body: JSON.stringify({ reason: reason ?? undefined }) }, bearer);
|
|
537
465
|
if (r.status >= 200 && r.status < 300) {
|
|
538
|
-
// HONESTY over green checkmarks (Codex finding: the server resolves each decision exactly
|
|
539
|
-
// once and answers replays idempotently — a replay is a NO-OP and must read as one, never
|
|
540
|
-
// as a fresh approval). Non-zero exit so scripts can tell the difference.
|
|
541
466
|
if (r.body?.alreadyResolved) {
|
|
542
467
|
log(`• ${id} was already resolved — nothing changed.`);
|
|
543
468
|
log(` Decisions are single-use: a second ${sub} can never re-execute the action (that's the replay protection working).`);
|
|
@@ -545,8 +470,18 @@ async function cmdApprovals(sub, id, reason, flags = {}) {
|
|
|
545
470
|
return 1;
|
|
546
471
|
}
|
|
547
472
|
log(`✔ ${sub === "approve" ? "approved" : "rejected"} ${id}${r.body?.resumeSignal ? ` — agent signalled (${r.body.resumeSignal.ok ? "ok" : r.body.resumeSignal.error})` : ""}`);
|
|
548
|
-
if (
|
|
549
|
-
log(`
|
|
473
|
+
if (reason) {
|
|
474
|
+
log(` reason sent verbatim: ${JSON.stringify(reason)}`);
|
|
475
|
+
log(` (it seals immutably; personal or sensitive values are tokenized at rest, so the`);
|
|
476
|
+
log(` stored text can differ — see exactly what was kept: npx @decidio/sdk receipt ${id})`);
|
|
477
|
+
}
|
|
478
|
+
if (sub === "approve") {
|
|
479
|
+
log(` What happens next depends on how the agent is waiting:`);
|
|
480
|
+
log(` • durable (the production mode): its worker or resume route picks this up and executes.`);
|
|
481
|
+
log(` • blocking, still running: it resumes now.`);
|
|
482
|
+
log(` • blocking, already timed out or exited: NOTHING executes — that process is gone, and this`);
|
|
483
|
+
log(` approval resolved the RECORD only. Re-run the action to request again.`);
|
|
484
|
+
}
|
|
550
485
|
else
|
|
551
486
|
log(` The agent's action will NOT run; the rejection seals as a verifiable record.`);
|
|
552
487
|
log(` Download the receipt: npx @decidio/sdk receipt ${id}`);
|
|
@@ -565,10 +500,9 @@ async function cmdApprovals(sub, id, reason, flags = {}) {
|
|
|
565
500
|
return 1;
|
|
566
501
|
}
|
|
567
502
|
const cards = r.body?.cards ?? [];
|
|
503
|
+
if (cards.length >= 50)
|
|
504
|
+
err('⚠ this view is capped at 50 items by the server — older pending approvals may exist beyond it.');
|
|
568
505
|
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
506
|
const myAgent = process.env.DECIDIO_AGENT_ID;
|
|
573
507
|
const isMine = (c) => !!myAgent && typeof c.title === "string" && c.title.startsWith(`${myAgent}:`);
|
|
574
508
|
const shown = mineOnly ? pending.filter(isMine) : pending;
|
|
@@ -600,8 +534,6 @@ async function cmdReceipt(id, jsonMode = false) {
|
|
|
600
534
|
err(" Find decision ids via `npx @decidio/sdk approvals` or in your Decidio app.");
|
|
601
535
|
return 2;
|
|
602
536
|
}
|
|
603
|
-
// Receipts are workspace data — session scope, like approvals. (The record-detail /verify
|
|
604
|
-
// route carries the credential; the plain record route does not — Codex finding.)
|
|
605
537
|
let bearer;
|
|
606
538
|
let r = await api(`/api/records/${encodeURIComponent(id)}/verify`);
|
|
607
539
|
if (r.status === 401 || r.status === 403) {
|
|
@@ -623,28 +555,18 @@ async function cmdReceipt(id, jsonMode = false) {
|
|
|
623
555
|
const name = `decidio-receipt-${id.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
|
|
624
556
|
const nextBytes = JSON.stringify(r.body.credential, null, 2) + "\n";
|
|
625
557
|
if (existsSync(name)) {
|
|
626
|
-
// Never silently overwrite an audit artifact (round-6 catch). Byte-identical is fine (receipts
|
|
627
|
-
// are immutable); a DIFFERENT file at the same name is a refusal.
|
|
628
558
|
if (readFileSync(name, "utf8") !== nextBytes) {
|
|
629
559
|
err(`✘ ${name} already exists with DIFFERENT bytes — refusing to overwrite an audit artifact. Move it aside or delete it, then re-run.`);
|
|
630
560
|
return 1;
|
|
631
561
|
}
|
|
632
|
-
// In --json mode stdout is the JSON result ONLY — narrative rides stderr (stream contract).
|
|
633
562
|
(jsonMode ? err : log)(`• ${name} already exists (byte-identical — receipts are immutable); leaving it in place.`);
|
|
634
563
|
}
|
|
635
564
|
else {
|
|
636
565
|
writeFileSync(name, nextBytes);
|
|
637
566
|
(jsonMode ? err : log)(`✔ wrote ${name} (downloaded from your workspace — the download is online; VERIFYING it is offline)`);
|
|
638
567
|
}
|
|
639
|
-
// The credential seals at DECISION time (immutable), so execution evidence that arrived later
|
|
640
|
-
// lives on the record — surface the CURRENT tier here so the reader doesn't need a second
|
|
641
|
-
// lookup to see it (Codex catch).
|
|
642
568
|
const detail = await api(`/api/records/${encodeURIComponent(id)}`, {}, bearer);
|
|
643
569
|
const conf = detail.body?.record?.confirmation;
|
|
644
|
-
// The trust anchor, delivered over the AUTHENTICATED channel. Reading the issuer out of the
|
|
645
|
-
// receipt you are about to verify is circular; this value comes from the server's own verify
|
|
646
|
-
// response, so pinning it actually means something (round-7 catch: the guided flow sent people
|
|
647
|
-
// to an app panel they could not reach, leaving provenance uncompletable).
|
|
648
570
|
const issuer = r.body?.issuer;
|
|
649
571
|
if (jsonMode) {
|
|
650
572
|
log(JSON.stringify({ file: name, decisionId: id, evidenceTier: conf?.status ?? null, confirmed: !!conf?.confirmed, issuer: issuer ?? null }, null, 2));
|
|
@@ -652,14 +574,14 @@ async function cmdReceipt(id, jsonMode = false) {
|
|
|
652
574
|
}
|
|
653
575
|
if (conf?.status)
|
|
654
576
|
log(` Sealed credential: the authority decision (its confirmation field reads "pending" forever — immutable). Current record evidence tier: ${conf.status}${conf.confirmed ? " (confirmed)" : ""}.`);
|
|
655
|
-
// Emit EXACTLY the deterministic command we document (round-4 catch: the README and this hint
|
|
656
|
-
// disagreed, and bare npx re-probes the registry) — the verifier ships INSIDE this package.
|
|
657
577
|
log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
|
|
658
|
-
log(` npx @decidio/sdk verify ${name}`);
|
|
659
578
|
if (issuer) {
|
|
660
|
-
log(` Your workspace's issuer DID (from the authenticated response — record it once, then pin it forever):`);
|
|
661
|
-
log(` ${issuer}`);
|
|
662
579
|
log(` npx @decidio/sdk verify --issuer ${issuer} ${name}`);
|
|
580
|
+
log(` That pins the check to your workspace's issuer DID (read from the authenticated`);
|
|
581
|
+
log(` response above — record it once, then pin it forever):`);
|
|
582
|
+
log(` ${issuer}`);
|
|
583
|
+
log(` Without --issuer the verifier proves the receipt is internally consistent, but NOT who`);
|
|
584
|
+
log(` issued it; it says so in its output.`);
|
|
663
585
|
}
|
|
664
586
|
else {
|
|
665
587
|
log(` To also prove WHO issued it, pin your workspace's issuer DID (shown in your app's record verify panel):`);
|
|
@@ -667,24 +589,15 @@ async function cmdReceipt(id, jsonMode = false) {
|
|
|
667
589
|
}
|
|
668
590
|
return 0;
|
|
669
591
|
}
|
|
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
592
|
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
593
|
let expectedIssuer;
|
|
681
594
|
const files = [];
|
|
682
595
|
for (let i = 0; i < argsIn.length; i++) {
|
|
683
596
|
const a = argsIn[i];
|
|
684
597
|
if (a === "--issuer") {
|
|
685
598
|
const v = argsIn[i + 1];
|
|
686
|
-
if (v === undefined || v.startsWith("-")) {
|
|
687
|
-
err("✘ --issuer needs a did:key value.");
|
|
599
|
+
if (v === undefined || v === "" || v.startsWith("-")) {
|
|
600
|
+
err("✘ --issuer needs a did:key value (a bare or EMPTY --issuer must not silently verify unpinned).");
|
|
688
601
|
return 2;
|
|
689
602
|
}
|
|
690
603
|
expectedIssuer = v;
|
|
@@ -707,8 +620,6 @@ function cmdVerify(argsIn) {
|
|
|
707
620
|
err(" Verifies Authority Receipts OFFLINE — signature, content binding, and (for several files) chain order.");
|
|
708
621
|
return 2;
|
|
709
622
|
}
|
|
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
623
|
const texts = new Map();
|
|
713
624
|
for (const f of files) {
|
|
714
625
|
try {
|
|
@@ -724,13 +635,11 @@ function cmdVerify(argsIn) {
|
|
|
724
635
|
log(l);
|
|
725
636
|
return ok ? 0 : 1;
|
|
726
637
|
}
|
|
727
|
-
// ---- quickstart (the guided two-minute tour) ------------------------------------------------
|
|
728
638
|
async function cmdQuickstart(opts = {}) {
|
|
729
639
|
const t0 = Date.now();
|
|
730
640
|
log("Decidio quickstart — gate an action, route it to a human, execute on approval, hold the proof.");
|
|
731
641
|
log(` Target: ${API}${process.env.DECIDIO_API_URL ? "" : " (the hosted synthetic sandbox — set DECIDIO_API_URL to use your own)"}`);
|
|
732
642
|
log("");
|
|
733
|
-
// SIDE EFFECTS UP FRONT (round-8 ask): say what the tour creates before creating any of it.
|
|
734
643
|
log(" This tour will: register one synthetic agent · mint a 7-DAY floor-limited token ·");
|
|
735
644
|
log(" write .env (or .env.quickstart) + .gitignore entries · create one synthetic decision");
|
|
736
645
|
log(" and its sealed record (records are permanent by design). Cleanup offer at the end.");
|
|
@@ -738,9 +647,6 @@ async function cmdQuickstart(opts = {}) {
|
|
|
738
647
|
const session = await acquireSession("The quickstart");
|
|
739
648
|
if (!session)
|
|
740
649
|
return 1;
|
|
741
|
-
// 1. A collision-proof agent, registered fresh every run (round-4 ask: idempotent sample
|
|
742
|
-
// registration). Key generated locally; floor-limited token minted; .env written so the
|
|
743
|
-
// take-away snippet works in this directory afterward.
|
|
744
650
|
const agentId = `quickstart-${randomBytes(2).toString("hex")}`;
|
|
745
651
|
log(`① Registering agent "${agentId}" (fresh name each run — no collisions)…`);
|
|
746
652
|
const { did, privateKeyBase64 } = generateKeypair();
|
|
@@ -749,33 +655,23 @@ async function cmdQuickstart(opts = {}) {
|
|
|
749
655
|
err(`✘ registration failed (${reg.status}): ${JSON.stringify(reg.body)}`);
|
|
750
656
|
return 1;
|
|
751
657
|
}
|
|
752
|
-
// A tour credential should outlive the tour and not much else — 7 days, disclosed.
|
|
753
658
|
const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "quickstart", expiresInDays: 7 }) }, session);
|
|
754
659
|
if (!(mint.status >= 200 && mint.status < 300) || !mint.body?.token) {
|
|
755
660
|
err(`✘ token mint failed (${mint.status}): ${JSON.stringify(mint.body)}`);
|
|
756
661
|
return 1;
|
|
757
662
|
}
|
|
758
|
-
// No webhook secret here (round-8 catch): the sandbox tour never uses the webhook transport,
|
|
759
|
-
// and inheriting the .env's existing secret duplicated one agent's secret into another's file.
|
|
760
|
-
// DECIDIO_TOKEN_JTI is revocation METADATA (not a secret) — it is what `quickstart cleanup`
|
|
761
|
-
// and `tokens revoke` need to kill the token later.
|
|
762
663
|
const envOut = {
|
|
763
664
|
DECIDIO_API_URL: API, DECIDIO_AGENT_ID: agentId, DECIDIO_AGENT_DID: reg.body?.did ?? did,
|
|
764
665
|
DECIDIO_AGENT_KEY: privateKeyBase64, DECIDIO_API_TOKEN: mint.body.token,
|
|
765
666
|
DECIDIO_WORKSPACE_ID: mint.body.workspaceId ?? "",
|
|
766
667
|
...(mint.body.jti ? { DECIDIO_TOKEN_JTI: mint.body.jti } : {}),
|
|
767
668
|
};
|
|
768
|
-
// NEVER clobber a real agent's key (guard catch: keys never leave this machine — overwriting
|
|
769
|
-
// .env's DECIDIO_AGENT_KEY orphans that agent irrecoverably). A directory that already holds
|
|
770
|
-
// one gets a TOUR-SCOPED env file instead — through the SAME secure writer as .env (round-8
|
|
771
|
-
// catch: this path used plain writeFileSync).
|
|
772
669
|
let envFile = ENV_PATH;
|
|
773
670
|
if (readEnvFile().DECIDIO_AGENT_KEY) {
|
|
774
671
|
envFile = ".env.quickstart";
|
|
672
|
+
if (!assertNotTracked(envFile))
|
|
673
|
+
return 1;
|
|
775
674
|
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
675
|
if (existsSync(envFile)) {
|
|
780
676
|
const prev = readFileSync(envFile, "utf8");
|
|
781
677
|
const prevId = prev.match(/^DECIDIO_AGENT_ID=(.+)$/m)?.[1];
|
|
@@ -789,14 +685,13 @@ async function cmdQuickstart(opts = {}) {
|
|
|
789
685
|
log(` ✔ this directory already has an agent in .env — tour credentials went to ${envFile} (your agent is untouched)`);
|
|
790
686
|
}
|
|
791
687
|
else {
|
|
688
|
+
if (!assertNotTracked(ENV_PATH))
|
|
689
|
+
return 1;
|
|
792
690
|
ensureGitignore();
|
|
793
691
|
upsertEnv(envOut);
|
|
794
692
|
}
|
|
795
693
|
log(` ✔ key generated locally (public half registered) · 7-day floor-limited token minted${mint.body.expiresAt ? ` (expires ${mint.body.expiresAt})` : ""} · ${envFile} written`);
|
|
796
694
|
log("");
|
|
797
|
-
// 2. Gate a demo action. Show the ACTUAL wrap the tour runs (round-6 catch: the printed line
|
|
798
|
-
// differed from the code — this tour uses blocking mode; the bare guard.protect default is
|
|
799
|
-
// durable and needs a worker/webhook, which the README covers).
|
|
800
695
|
log(`② Gating a demo action — the tour runs this (blocking mode: watch it live):`);
|
|
801
696
|
log("");
|
|
802
697
|
log(` const payInvoice = guard.protect(payInvoiceRaw,`);
|
|
@@ -812,9 +707,6 @@ async function cmdQuickstart(opts = {}) {
|
|
|
812
707
|
};
|
|
813
708
|
const payInvoice = withApproval(async (inv) => ({ paid: true, id: inv.id }), (inv) => ({ action: "payInvoice", amount: inv.amount, scope: "Invoice" }), cfg);
|
|
814
709
|
const running = payInvoice({ id: "INV-2026-001", amount: 86_000 });
|
|
815
|
-
// An EARLY rejection (before routing) must be diagnosed as ITSELF, not as a routing timeout
|
|
816
|
-
// (round-2 blind catch: the sandbox's per-hour action cap surfaced here as "check
|
|
817
|
-
// connectivity", and doctor then corroborated the wrong suspect with "all green").
|
|
818
710
|
let earlyError;
|
|
819
711
|
running.catch((e) => { earlyError = e; });
|
|
820
712
|
const waitStart = Date.now();
|
|
@@ -837,10 +729,6 @@ async function cmdQuickstart(opts = {}) {
|
|
|
837
729
|
log(` ✔ $86,000 payInvoice ROUTED to a human (decision ${decisionId}) — this agent has no auto-approve rule,`);
|
|
838
730
|
log(` so deny-by-default sends everything to a person. Nothing executes until someone decides.`);
|
|
839
731
|
log("");
|
|
840
|
-
// 3. The human moment — and it must BE one. The tour's whole claim is "routes it to YOU,
|
|
841
|
-
// executes on your yes"; a headless run that approves itself has no discrete yes (round-8
|
|
842
|
-
// catch: "headless quickstart breaks the central story"). So headless STOPS here unless
|
|
843
|
-
// --auto-approve-demo was passed explicitly, and that path is labeled automated authority.
|
|
844
732
|
const interactive = process.stdin.isTTY;
|
|
845
733
|
if (interactive) {
|
|
846
734
|
await ask(`③ YOU are the approver. Press Enter to approve it here (or approve it in your Decidio queue first, then Enter): `);
|
|
@@ -851,14 +739,16 @@ async function cmdQuickstart(opts = {}) {
|
|
|
851
739
|
}
|
|
852
740
|
else {
|
|
853
741
|
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
|
|
742
|
+
log(" stops here rather than approve its own request. The decision is routed and waiting —");
|
|
743
|
+
log(" but note: this BLOCKING demo process is about to exit, so approving later resolves the");
|
|
744
|
+
log(" RECORD only; the demo function itself cannot run afterwards (durable mode is what");
|
|
745
|
+
log(" survives a process exit — the README's worker section shows it):");
|
|
855
746
|
log("");
|
|
856
747
|
log(` npx @decidio/sdk approvals approve ${decisionId} (or approve it in your Decidio app)`);
|
|
857
748
|
log("");
|
|
858
749
|
log(" Then re-run the quickstart interactively to watch the whole loop — or, for CI, re-run");
|
|
859
750
|
log(" with --auto-approve-demo to let the tour approve itself under that explicit label.");
|
|
860
751
|
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
752
|
process.exit(0);
|
|
863
753
|
}
|
|
864
754
|
const ap = await api(`/api/decisions/${encodeURIComponent(decisionId)}/approve`, { method: "POST", body: JSON.stringify({ reason: opts.autoApproveDemo && !interactive ? "quickstart --auto-approve-demo (automated)" : "quickstart" }) }, session);
|
|
@@ -867,7 +757,6 @@ async function cmdQuickstart(opts = {}) {
|
|
|
867
757
|
return 1;
|
|
868
758
|
}
|
|
869
759
|
log(` ✔ approved${ap.body?.alreadyResolved ? " (it was already approved from your app — even better)" : ""}`);
|
|
870
|
-
// 4. The blocking wrapper wakes and runs the agent's own function.
|
|
871
760
|
let result;
|
|
872
761
|
try {
|
|
873
762
|
result = await running;
|
|
@@ -878,7 +767,6 @@ async function cmdQuickstart(opts = {}) {
|
|
|
878
767
|
}
|
|
879
768
|
log(` ✔ your function executed AFTER approval: ${JSON.stringify(result)}`);
|
|
880
769
|
log("");
|
|
881
|
-
// 5. The proof: download the receipt and verify it offline, all in-process.
|
|
882
770
|
log("④ Downloading the sealed Authority Receipt and verifying it OFFLINE…");
|
|
883
771
|
const rec = await api(`/api/records/${encodeURIComponent(decisionId)}/verify`, {}, session);
|
|
884
772
|
if (!(rec.status >= 200 && rec.status < 300) || !rec.body?.credential) {
|
|
@@ -887,8 +775,6 @@ async function cmdQuickstart(opts = {}) {
|
|
|
887
775
|
}
|
|
888
776
|
const fname = `decidio-receipt-${decisionId.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
|
|
889
777
|
writeFileSync(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
|
|
890
|
-
// Verify PINNED to the issuer the server just told us over the authenticated channel — an
|
|
891
|
-
// unpinned VALID only proves self-consistency, and the tour should demonstrate the real thing.
|
|
892
778
|
const issuer = rec.body?.issuer;
|
|
893
779
|
const v = verifyReceipt(rec.body.credential, issuer);
|
|
894
780
|
log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? `, PINNED to your workspace issuer ${issuer}` : "; issuer unpinned"})`);
|
|
@@ -908,9 +794,6 @@ async function cmdQuickstart(opts = {}) {
|
|
|
908
794
|
log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
|
|
909
795
|
log("");
|
|
910
796
|
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
797
|
let doCleanup = false;
|
|
915
798
|
if (interactive) {
|
|
916
799
|
const answer = await ask(`\nKeep this agent for development? [Enter = keep · c + Enter = clean up now]: `);
|
|
@@ -926,9 +809,6 @@ async function cmdQuickstart(opts = {}) {
|
|
|
926
809
|
await quickstartCleanup(session, envFile);
|
|
927
810
|
return v.valid ? 0 : 1;
|
|
928
811
|
}
|
|
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
812
|
async function quickstartCleanup(session, envFile) {
|
|
933
813
|
const env = (() => {
|
|
934
814
|
const out = {};
|
|
@@ -946,16 +826,16 @@ async function quickstartCleanup(session, envFile) {
|
|
|
946
826
|
log(` • token was already revoked (jti ${env.DECIDIO_TOKEN_JTI}) — nothing to do.`);
|
|
947
827
|
else if (rev.status >= 200 && rev.status < 300)
|
|
948
828
|
log(` ✔ token revoked (jti ${env.DECIDIO_TOKEN_JTI})`);
|
|
949
|
-
else
|
|
950
|
-
|
|
829
|
+
else {
|
|
830
|
+
err(`✘ token revoke returned ${rev.status} — the tour's token is STILL LIVE (it would expire on its own within 7 days).`);
|
|
831
|
+
err(` Nothing was deleted: ${envFile} still holds the jti so you can retry. Run \`npx @decidio/sdk quickstart cleanup\` again, or revoke it directly:`);
|
|
832
|
+
err(` npx @decidio/sdk tokens revoke ${env.DECIDIO_TOKEN_JTI}`);
|
|
833
|
+
return 1;
|
|
834
|
+
}
|
|
951
835
|
}
|
|
952
836
|
else {
|
|
953
837
|
log(` ⚠ no DECIDIO_TOKEN_JTI in ${envFile} (older tour) — revoke by hand: npx @decidio/sdk tokens list, then tokens revoke <jti>. It expires on its own within 7 days.`);
|
|
954
838
|
}
|
|
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
839
|
if (existsSync(envFile)) {
|
|
960
840
|
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
841
|
const kept = readFileSync(envFile, "utf8").split(/\r?\n/).filter((line) => {
|
|
@@ -971,9 +851,6 @@ async function quickstartCleanup(session, envFile) {
|
|
|
971
851
|
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
852
|
}
|
|
973
853
|
}
|
|
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
854
|
const tourAgent = env.DECIDIO_AGENT_ID;
|
|
978
855
|
if (tourAgent) {
|
|
979
856
|
const cards = await api("/api/decisions/cards", {}, session);
|
|
@@ -987,7 +864,6 @@ async function quickstartCleanup(session, envFile) {
|
|
|
987
864
|
log(` and the agent's registration row (inert without key or token; server-side deregistration is on the roadmap).`);
|
|
988
865
|
return 0;
|
|
989
866
|
}
|
|
990
|
-
/** tokens list [--mine] / tokens revoke <jti> — the workspace's agent-token lifecycle. */
|
|
991
867
|
async function cmdTokens(sub, jti, mineOnly = false) {
|
|
992
868
|
if (sub !== "list" && sub !== "revoke") {
|
|
993
869
|
err("usage: npx @decidio/sdk tokens list [--mine]");
|
|
@@ -999,9 +875,6 @@ async function cmdTokens(sub, jti, mineOnly = false) {
|
|
|
999
875
|
err("usage: npx @decidio/sdk tokens revoke <jti> (find the jti with: npx @decidio/sdk tokens list)");
|
|
1000
876
|
return 2;
|
|
1001
877
|
}
|
|
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
878
|
if (mineOnly && !process.env.DECIDIO_AGENT_ID) {
|
|
1006
879
|
err(`✘ --mine needs an agent identity, and there is no DECIDIO_AGENT_ID in this directory's .env or your environment.`);
|
|
1007
880
|
err(` Run this from your agent's directory (where init wrote .env), or drop --mine for the workspace-wide list.`);
|
|
@@ -1020,9 +893,6 @@ async function cmdTokens(sub, jti, mineOnly = false) {
|
|
|
1020
893
|
if (sub === "revoke") {
|
|
1021
894
|
const rev = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, bearer);
|
|
1022
895
|
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
896
|
if (rev.body?.changed === false) {
|
|
1027
897
|
log(`• ${jti} was already revoked — nothing changed.`);
|
|
1028
898
|
log(` Revocation is permanent; there is no re-revoke. See it: npx @decidio/sdk tokens list`);
|
|
@@ -1049,16 +919,11 @@ async function cmdTokens(sub, jti, mineOnly = false) {
|
|
|
1049
919
|
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
920
|
return 0;
|
|
1051
921
|
}
|
|
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
922
|
const status = (t) => t.revokedAt ? `REVOKED ${String(t.revokedAt).slice(0, 10)}`
|
|
1055
923
|
: (t.expiresAt && Date.parse(t.expiresAt) < Date.now()) ? "expired" : "active";
|
|
1056
924
|
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
925
|
for (const t of tokens)
|
|
1058
926
|
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
927
|
const al = r.body?.allowance;
|
|
1063
928
|
if (al?.limit != null)
|
|
1064
929
|
log(`\nGoverned-action allowance: ${al.remaining}/${al.limit} remaining this hour${al.resetAt ? ` (resets ${al.resetAt})` : ""}.`);
|
|
@@ -1066,8 +931,6 @@ async function cmdTokens(sub, jti, mineOnly = false) {
|
|
|
1066
931
|
return 0;
|
|
1067
932
|
}
|
|
1068
933
|
function cmdDev(target, port) {
|
|
1069
|
-
// Signal-only relay: receive Decidio's webhook here (a stable URL) and forward it to
|
|
1070
|
-
// your local agent's resume listener. Useful when the agent isn't directly reachable.
|
|
1071
934
|
log(`decidio dev relay on http://localhost:${port}/decidio/resume → forwarding to ${target}`);
|
|
1072
935
|
createServer((req, res) => {
|
|
1073
936
|
if (req.method !== "POST") {
|
|
@@ -1121,6 +984,10 @@ const USAGE = `decidio — the Decidio SDK command line
|
|
|
1121
984
|
npx @decidio/sdk approvals [--json] [--mine] list pending agent approvals
|
|
1122
985
|
npx @decidio/sdk approvals approve <id> [reason]
|
|
1123
986
|
npx @decidio/sdk approvals reject <id> [reason]
|
|
987
|
+
the reason is FREE TEXT and is sealed verbatim. If it
|
|
988
|
+
starts with a dash, put it after -- or use --reason:
|
|
989
|
+
... approve <id> -- "-50% cut, per the board"
|
|
990
|
+
... approve <id> --reason "-50% cut, per the board"
|
|
1124
991
|
npx @decidio/sdk receipt <id> download a sealed decision's Authority Receipt (.json)
|
|
1125
992
|
npx @decidio/sdk verify [--issuer <did>] <receipt.json> [more...] verify receipts OFFLINE
|
|
1126
993
|
npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
|
|
@@ -1132,7 +999,8 @@ fully offline — it touches no network and reads no .env.
|
|
|
1132
999
|
Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error.
|
|
1133
1000
|
Errors, and usage shown on ERROR paths, go to stderr; stdout carries results only (safe to
|
|
1134
1001
|
pipe). Asked-for help — including this bare invocation — IS a result, so it prints here.
|
|
1135
|
-
Docs: the README that shipped with this package
|
|
1002
|
+
Docs: the README that shipped with this package — node_modules/@decidio/sdk/README.md
|
|
1003
|
+
(quickstart, durable resume, workers, adapters).`;
|
|
1136
1004
|
async function main(argv) {
|
|
1137
1005
|
const [cmd, a, b, ...rest] = argv.slice(2);
|
|
1138
1006
|
const wantsHelp = (s) => s === "help" || s === "--help" || s === "-h";
|
|
@@ -1148,15 +1016,12 @@ async function main(argv) {
|
|
|
1148
1016
|
log(USAGE);
|
|
1149
1017
|
return 0;
|
|
1150
1018
|
}
|
|
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
1019
|
const argsIn = argv.slice(3);
|
|
1155
1020
|
const KNOWN = {
|
|
1156
1021
|
quickstart: { flags: ["--auto-approve-demo", "--keep-agent"] },
|
|
1157
1022
|
init: { flags: ["--unique", "--force"], valued: ["--expires-days"] },
|
|
1158
1023
|
doctor: { flags: [] },
|
|
1159
|
-
approvals: { flags: ["--json", "--mine"] },
|
|
1024
|
+
approvals: { flags: ["--json", "--mine"], valued: ["--reason"] },
|
|
1160
1025
|
receipt: { flags: ["--json"] },
|
|
1161
1026
|
verify: { flags: [], valued: ["--issuer"] },
|
|
1162
1027
|
tokens: { flags: ["--mine"] },
|
|
@@ -1168,6 +1033,10 @@ async function main(argv) {
|
|
|
1168
1033
|
const positionals = [];
|
|
1169
1034
|
for (let i = 0; i < argsIn.length; i++) {
|
|
1170
1035
|
const a = argsIn[i];
|
|
1036
|
+
if (a === "--") {
|
|
1037
|
+
positionals.push(...argsIn.slice(i + 1));
|
|
1038
|
+
break;
|
|
1039
|
+
}
|
|
1171
1040
|
if (!a.startsWith("--")) {
|
|
1172
1041
|
positionals.push(a);
|
|
1173
1042
|
continue;
|
|
@@ -1175,19 +1044,14 @@ async function main(argv) {
|
|
|
1175
1044
|
if (valued.includes(a)) {
|
|
1176
1045
|
i++;
|
|
1177
1046
|
continue;
|
|
1178
|
-
}
|
|
1047
|
+
}
|
|
1179
1048
|
if (valued.some((v) => a.startsWith(v + "=")))
|
|
1180
|
-
continue;
|
|
1049
|
+
continue;
|
|
1181
1050
|
if (!spec.flags.includes(a)) {
|
|
1182
1051
|
err(`✘ unknown option "${a}" for \`${cmd}\` — refusing to guess (a flag you meant to matter must never be silently ignored).\n\n${USAGE}`);
|
|
1183
1052
|
return 2;
|
|
1184
1053
|
}
|
|
1185
1054
|
}
|
|
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
1055
|
const MAX_POS = { quickstart: 1, init: 1, doctor: 0, receipt: 1, tokens: 2, dev: 0 };
|
|
1192
1056
|
const exemptTail = (cmd === "approvals" && (positionals[0] === "approve" || positionals[0] === "reject")) || cmd === "verify";
|
|
1193
1057
|
if (!exemptTail && cmd === "approvals" && positionals.length > 0) {
|
|
@@ -1210,7 +1074,6 @@ async function main(argv) {
|
|
|
1210
1074
|
switch (cmd) {
|
|
1211
1075
|
case "quickstart": {
|
|
1212
1076
|
if (argsIn.includes("cleanup")) {
|
|
1213
|
-
// Standalone cleanup: prefer the tour-scoped file; fall back to a tour-created .env.
|
|
1214
1077
|
const envFile = existsSync(".env.quickstart") ? ".env.quickstart"
|
|
1215
1078
|
: (readEnvFile().DECIDIO_AGENT_ID ?? "").startsWith("quickstart-") ? ENV_PATH : undefined;
|
|
1216
1079
|
if (!envFile) {
|
|
@@ -1226,9 +1089,6 @@ async function main(argv) {
|
|
|
1226
1089
|
}
|
|
1227
1090
|
case "init": {
|
|
1228
1091
|
const unique = argsIn.includes("--unique");
|
|
1229
|
-
// BOTH forms (guard catch: the strict table's generic `=` allowance let --expires-days=30
|
|
1230
|
-
// pass the gate while this parser read only the space form — a silently-ignored
|
|
1231
|
-
// credential-LIFETIME flag, the exact class the verifier doors just fixed).
|
|
1232
1092
|
const expIdx = argsIn.indexOf("--expires-days");
|
|
1233
1093
|
const expEq = argsIn.find((x) => x.startsWith("--expires-days="));
|
|
1234
1094
|
const expRaw = expIdx >= 0 ? argsIn[expIdx + 1] : expEq ? expEq.slice("--expires-days=".length) : undefined;
|
|
@@ -1244,8 +1104,28 @@ async function main(argv) {
|
|
|
1244
1104
|
}
|
|
1245
1105
|
case "doctor": return cmdDoctor();
|
|
1246
1106
|
case "approvals": {
|
|
1247
|
-
const
|
|
1248
|
-
|
|
1107
|
+
const dashdash = argsIn.indexOf("--");
|
|
1108
|
+
const head = dashdash >= 0 ? argsIn.slice(0, dashdash) : argsIn;
|
|
1109
|
+
const literal = dashdash >= 0 ? argsIn.slice(dashdash + 1) : [];
|
|
1110
|
+
const rIdx = head.indexOf("--reason");
|
|
1111
|
+
const flagless = head.filter((x, i) => !x.startsWith("-") && !(rIdx >= 0 && i === rIdx + 1));
|
|
1112
|
+
const sub = flagless[0];
|
|
1113
|
+
const id = flagless[1];
|
|
1114
|
+
let reason;
|
|
1115
|
+
if (rIdx >= 0)
|
|
1116
|
+
reason = head[rIdx + 1];
|
|
1117
|
+
else if (literal.length)
|
|
1118
|
+
reason = literal.join(" ");
|
|
1119
|
+
else if (sub && id) {
|
|
1120
|
+
const after = head.indexOf(id) + 1;
|
|
1121
|
+
const rest = head.slice(after).filter((x) => x !== "--json" && x !== "--mine");
|
|
1122
|
+
reason = rest.length ? rest.join(" ") : undefined;
|
|
1123
|
+
}
|
|
1124
|
+
if (rIdx >= 0 && (reason === undefined || reason.startsWith("-"))) {
|
|
1125
|
+
err(`✘ --reason needs the text to record (quote it if it contains spaces): npx @decidio/sdk approvals ${sub ?? "approve"} <id> --reason "over budget -50% cut required"`);
|
|
1126
|
+
return 2;
|
|
1127
|
+
}
|
|
1128
|
+
return cmdApprovals(sub, id, reason, { json: head.includes("--json"), mine: head.includes("--mine") });
|
|
1249
1129
|
}
|
|
1250
1130
|
case "receipt": return cmdReceipt(argsIn.find((x) => !x.startsWith("-")), argsIn.includes("--json"));
|
|
1251
1131
|
case "verify": return cmdVerify(argsIn);
|
|
@@ -1262,8 +1142,6 @@ async function main(argv) {
|
|
|
1262
1142
|
return eq ? eq.slice(name.length + 1) : undefined;
|
|
1263
1143
|
};
|
|
1264
1144
|
const target = flag("--target") || process.env.DECIDIO_AGENT_RESUME_URL || "http://localhost:4100/decidio/resume";
|
|
1265
|
-
// Validate on shape, before binding anything (blind-review catch: `--port abc` printed
|
|
1266
|
-
// localhost:NaN and died with a raw internal error).
|
|
1267
1145
|
const portRaw = flag("--port") || process.env.DECIDIO_DEV_PORT || "4099";
|
|
1268
1146
|
if (!/^\d+$/.test(portRaw) || Number(portRaw) < 1 || Number(portRaw) > 65535) {
|
|
1269
1147
|
err(`✘ --port needs a number between 1 and 65535 (got "${portRaw}").`);
|
|
@@ -1276,10 +1154,6 @@ async function main(argv) {
|
|
|
1276
1154
|
return cmdDev(target, Number(portRaw));
|
|
1277
1155
|
}
|
|
1278
1156
|
default:
|
|
1279
|
-
// Exit 2, not 1: an unknown command is a USAGE error, and the help text promised that
|
|
1280
|
-
// contract (round-8 catch).
|
|
1281
|
-
// A flag in command position gets a NAMED fix, not just a refusal (round-3 blind nit:
|
|
1282
|
-
// `--json approvals` earned "unknown command" with no hint that flags follow the command).
|
|
1283
1157
|
if (cmd.startsWith("-"))
|
|
1284
1158
|
err(`✘ unknown command "${cmd}" — flags go AFTER the command (e.g. npx @decidio/sdk approvals ${cmd}).\n\n${USAGE}`);
|
|
1285
1159
|
else
|