@decidio/sdk 0.1.0 → 0.1.1
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 +113 -75
- package/dist/cli.js +229 -81
- package/openapi.yaml +5 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,83 +4,107 @@ One line to put a human-or-policy approval gate in front of any AI-agent action
|
|
|
4
4
|
|
|
5
5
|
## Quickstart — seven steps to your first sealed receipt
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Under 30 minutes, no help needed. You'll need Node 20+, a terminal, and a Decidio workspace
|
|
8
|
+
(request one at [decidioai.com](https://decidioai.com/#early-access) — the hosted sandbox is
|
|
9
|
+
synthetic data, safe to experiment in).
|
|
8
10
|
|
|
9
11
|
**1. Install**
|
|
10
12
|
|
|
11
13
|
```bash
|
|
14
|
+
mkdir decidio-quickstart && cd decidio-quickstart
|
|
12
15
|
npm install @decidio/sdk
|
|
13
16
|
```
|
|
14
17
|
|
|
15
|
-
**2.
|
|
18
|
+
**2. Point at your Decidio and register your agent**
|
|
16
19
|
|
|
17
20
|
```bash
|
|
21
|
+
export DECIDIO_API_URL=https://decidio-api.onrender.com # the hosted sandbox
|
|
18
22
|
npx @decidio/sdk init my-agent
|
|
19
23
|
```
|
|
20
24
|
|
|
21
|
-
`init` signs you into your
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
`init` signs you into your workspace (email + password, or paste the sign-in link from your
|
|
26
|
+
workspace email), generates the agent's Ed25519 keypair **locally** (only the public `did:key`
|
|
27
|
+
is sent), registers the agent, mints its **floor-limited API token** (it can request approvals
|
|
28
|
+
and nothing else — your workspace session never touches the agent's disk), and writes `.env`.
|
|
29
|
+
Every later command reads `.env` from this directory automatically.
|
|
26
30
|
|
|
27
|
-
**3. Protect one function** —
|
|
31
|
+
**3. Protect one function** — save this as `quickstart.mjs`:
|
|
28
32
|
|
|
29
|
-
```
|
|
33
|
+
```js
|
|
30
34
|
import { guard } from "@decidio/sdk";
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
// Your real action — a Salesforce write, a payment, a DB change. Here: a stand-in.
|
|
37
|
+
const payInvoiceRaw = async (invoice) => ({ paid: true, id: invoice.id });
|
|
33
38
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
)
|
|
39
|
+
// One line: wrap it. `describe` tells Decidio what is being asked.
|
|
40
|
+
const payInvoice = guard.protect(
|
|
41
|
+
payInvoiceRaw,
|
|
42
|
+
(invoice) => ({ action: "payInvoice", amount: invoice.amount, scope: "Invoice" }),
|
|
43
|
+
{ mode: "blocking" }, // blocking = watch the whole loop live in one terminal sitting.
|
|
44
|
+
); // Production agents use durable mode instead — see below.
|
|
45
|
+
|
|
46
|
+
const result = await payInvoice({ id: "INV-2026-001", amount: 86_000 });
|
|
47
|
+
console.log("executed after approval:", result);
|
|
38
48
|
```
|
|
39
49
|
|
|
40
|
-
**4. Trigger
|
|
50
|
+
**4. Trigger it:**
|
|
41
51
|
|
|
42
|
-
```
|
|
43
|
-
|
|
52
|
+
```bash
|
|
53
|
+
node --env-file=.env quickstart.mjs
|
|
44
54
|
```
|
|
45
55
|
|
|
46
56
|
A brand-new agent matches **no** auto-approve rule, so Decidio's deny-by-default **routes every
|
|
47
57
|
request to a human** — that is the point: nothing executes without either a named policy rule or
|
|
48
|
-
a person.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
auto-approves
|
|
52
|
-
|
|
58
|
+
a person. The script prints the decision id and waits. (To later see a policy `proceed`,
|
|
59
|
+
register a **new** agent in the sandbox's pre-policied lane — an existing agent's lane can't be
|
|
60
|
+
changed: `DECIDIO_SOURCE_SYSTEM=salesforce npx @decidio/sdk init my-sf-agent`. That lane's
|
|
61
|
+
named rule auto-approves amounts under $1M when the request carries the agent's signed
|
|
62
|
+
identity proof, so even this $86k example would proceed there.)
|
|
53
63
|
|
|
54
|
-
**5. Approve it** —
|
|
64
|
+
**5. Approve it** — in a second terminal (same directory):
|
|
55
65
|
|
|
56
66
|
```bash
|
|
57
|
-
npx @decidio/sdk approvals
|
|
67
|
+
npx @decidio/sdk approvals # see it pending
|
|
58
68
|
npx @decidio/sdk approvals approve <decisionId>
|
|
59
69
|
```
|
|
60
70
|
|
|
61
|
-
|
|
62
|
-
|
|
71
|
+
The moment you approve, the first terminal wakes: **your own function executes** — Decidio never
|
|
72
|
+
holds your credentials or runs your code — and the SDK reports the captured result back, which
|
|
73
|
+
becomes the record's execution evidence.
|
|
63
74
|
|
|
64
|
-
**6.
|
|
65
|
-
**Download receipt (.json)**, then verify it offline with the open, zero-dependency verifier:
|
|
75
|
+
**6. Download the receipt and verify it offline:**
|
|
66
76
|
|
|
67
77
|
```bash
|
|
68
|
-
npx @decidio/
|
|
78
|
+
npx @decidio/sdk receipt <decisionId>
|
|
79
|
+
npx @decidio/verify decidio-receipt-<decisionId>.json
|
|
69
80
|
```
|
|
70
81
|
|
|
71
|
-
The
|
|
72
|
-
network — the evidence is
|
|
82
|
+
The receipt is a W3C Verifiable Credential: the signature and content binding verify with no
|
|
83
|
+
Decidio account and no network (after the one-time `npx` package download) — the evidence is
|
|
84
|
+
yours, not ours. To also prove *who* issued it, pin your workspace's issuer:
|
|
85
|
+
`npx @decidio/verify --issuer <did:key:...> receipt.json` (the DID is shown in your app's
|
|
86
|
+
record verify panel). One receipt proves itself; chain order across receipts is verified when
|
|
87
|
+
you pass several files at once.
|
|
88
|
+
|
|
89
|
+
What the credential contains, honestly: the sealed **authority decision** — who decided, what
|
|
90
|
+
was authorized, when, under which policy. It seals at decision time and is immutable, so
|
|
91
|
+
execution evidence that arrives *after* the seal (your function's captured result) lives on the
|
|
92
|
+
**record**, not inside the credential bytes: your Decidio record page shows the evidence tier
|
|
93
|
+
(`application_confirmed` once the wrapper's signed report lands). Immutable seal + accruing
|
|
94
|
+
evidence is the design, not an omission — corrections and later facts supersede, they never
|
|
95
|
+
rewrite.
|
|
73
96
|
|
|
74
|
-
**7. Prove a replay is rejected
|
|
97
|
+
**7. Prove a replay is rejected:**
|
|
75
98
|
|
|
76
99
|
```bash
|
|
77
|
-
npx @decidio/sdk approvals approve <the same decisionId>
|
|
78
|
-
curl -X POST http://localhost:4100/decidio/resume \
|
|
79
|
-
-H 'content-type: application/json' -d '{"decisionId":"<id>","verdict":"approved"}'
|
|
80
|
-
# → refused: the resume handler fails closed without a verified x-decidio-signature,
|
|
81
|
-
# and the parked entry was deleted on first completion — a duplicate signal can't double-write.
|
|
100
|
+
npx @decidio/sdk approvals approve <the same decisionId>
|
|
82
101
|
```
|
|
83
102
|
|
|
103
|
+
Decisions are single-use: the CLI reports "already resolved — nothing changed" and exits
|
|
104
|
+
non-zero. The server answers replays idempotently — a second approve can never re-execute the
|
|
105
|
+
action. (The durable resume path has the same property: parked actions are claimed exactly
|
|
106
|
+
once, and its webhook handler fails closed without a verified signature.)
|
|
107
|
+
|
|
84
108
|
That's the whole contract: gate → human decision → your execution → owned, verifiable evidence.
|
|
85
109
|
|
|
86
110
|
## What happens on a call
|
|
@@ -90,70 +114,84 @@ That's the whole contract: gate → human decision → your execution → owned,
|
|
|
90
114
|
- **proceed** — your function runs immediately (auto-approved under a named, versioned rule).
|
|
91
115
|
- **route** — the action suspends (durable) or waits (blocking) for a human decision in Decidio's queue, then runs your function (or throws `DecidioRejectedError`).
|
|
92
116
|
- **block** — the wrapper throws `DecidioBlockedError`; your function never runs.
|
|
93
|
-
3. After your function runs, the wrapper reports the **real response it captured** back to Decidio, which
|
|
117
|
+
3. After your function runs, the wrapper reports the **real response it captured** back to Decidio, which records it as `application_confirmed` execution evidence (Decidio minimizes + tokenizes before storing — the immutable record never stores raw payloads).
|
|
94
118
|
|
|
95
119
|
Your agent executes its own action. **Decidio holds no write credentials for your system** — it authorizes the decision, records it, and (optionally) independently verifies it.
|
|
96
120
|
|
|
97
|
-
##
|
|
98
|
-
|
|
99
|
-
A real human approval takes minutes to days. Nobody watches a 60-second polling window, so blocking the call is only honest for short, supervised approvals. For real ones, add a **resume controller** and the *same wrap* becomes durable: a routed action **suspends** instead of blocking — it parks its call args in an **agent-side store** (Decidio stores none of your downstream payload) and throws `DecidioSuspendedError`. The requesting process can exit. When a human approves, **Decidio POSTs a signed verdict** to your agent's resume URL; the controller re-runs *your own function* and confirms the result. Decidio signals — it never executes.
|
|
100
|
-
|
|
101
|
-
```ts
|
|
102
|
-
import { withApproval, createDecidioResume, FilePendingStore } from "@decidio/sdk";
|
|
103
|
-
import { createServer } from "node:http";
|
|
121
|
+
## Production mode: durable async resume
|
|
104
122
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
store: new FilePendingStore(".decidio-pending"), // swap for Redis/Postgres/your queue
|
|
111
|
-
});
|
|
123
|
+
Real approvals take minutes to days, and nobody keeps a terminal open for them. Omit
|
|
124
|
+
`mode: "blocking"` (durable is the default) and a routed action **suspends** instead of
|
|
125
|
+
waiting: it parks its call arguments agent-side (`.decidio-pending/` by default — Decidio
|
|
126
|
+
stores none of your downstream payload) and throws `DecidioSuspendedError`. The process may
|
|
127
|
+
exit. Two ways the parked action resumes when a human decides:
|
|
112
128
|
|
|
113
|
-
|
|
129
|
+
**The worker (self-serve — start here).** One line in any long-lived process:
|
|
114
130
|
|
|
115
|
-
|
|
116
|
-
|
|
131
|
+
```js
|
|
132
|
+
import { guard } from "@decidio/sdk";
|
|
133
|
+
guard.worker(); // polls Decidio outward, re-executes parked actions on approval, exactly once
|
|
117
134
|
```
|
|
118
135
|
|
|
119
|
-
No inbound URL
|
|
136
|
+
No inbound URL, no shared secrets, survives restarts. This is the transport to use against the
|
|
137
|
+
hosted sandbox.
|
|
120
138
|
|
|
121
|
-
|
|
139
|
+
**The signed webhook (operator deployments).** Decidio POSTs a signed verdict to your agent's
|
|
140
|
+
`resumeUrl`; the handler verifies the HMAC signature fail-closed, then re-runs your function:
|
|
122
141
|
|
|
123
|
-
|
|
142
|
+
```js
|
|
143
|
+
// Express: app.post("/decidio/resume", guard.resumeHandler())
|
|
144
|
+
// Next.js: export const POST = (req) => guard.resumeFetchHandler()(req)
|
|
145
|
+
```
|
|
124
146
|
|
|
125
|
-
|
|
126
|
-
|
|
147
|
+
Honest requirement: webhook signing uses a **shared secret configured on both sides** — your
|
|
148
|
+
`DECIDIO_WEBHOOK_SECRET` must equal the Decidio server's, and self-hosted production deployments
|
|
149
|
+
also allow-list resume hosts (default-deny). That's operator territory: if you run your own
|
|
150
|
+
Decidio (or we run a pilot with you), the webhook is set up then. Against the hosted sandbox,
|
|
151
|
+
use the worker — a webhook secret your server never learned would (correctly) fail closed.
|
|
127
152
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
step, config: { ...guard, resumeUrl: "https://my-agent/decidio/resume" },
|
|
131
|
-
ctx: { action: "createOpportunity", amount: event.data.Amount, scope: "Opportunity" },
|
|
132
|
-
run: () => sf.create("Opportunity", event.data), // YOUR write, on resume
|
|
133
|
-
}));
|
|
153
|
+
Either way, re-execution is **single-use** — the parked entry is claimed on completion, so a
|
|
154
|
+
duplicate signal can't double-write.
|
|
134
155
|
|
|
135
|
-
|
|
136
|
-
// Inngest event that wakes the suspended run:
|
|
137
|
-
const bridge = createInngestResumeBridge({ inngest, webhookSecret: process.env.DECIDIO_WEBHOOK_SECRET });
|
|
138
|
-
```
|
|
156
|
+
### Already on a durable engine? Use its native wait
|
|
139
157
|
|
|
140
|
-
|
|
158
|
+
If your agent runs on Inngest/Temporal/LangGraph, the engine *is* the durable store.
|
|
159
|
+
`@decidio/sdk/inngest` maps the gate onto `step.waitForEvent`; `@decidio/sdk/langgraph` is a
|
|
160
|
+
drop-in via `interrupt()`; `@decidio/sdk/temporal` uses `condition` + signal;
|
|
161
|
+
`@decidio/sdk/openai` gates RunState approvals. All optional peer dependencies — the core SDK
|
|
162
|
+
imports none of them. See each adapter's header for its wiring, and `example/` in the repo for
|
|
163
|
+
runnable end-to-ends.
|
|
141
164
|
|
|
142
165
|
## One surface, every runtime — and Python too
|
|
143
166
|
|
|
144
|
-
- **`guard.protect(fn, describe, { adapter })`** is the same call everywhere
|
|
167
|
+
- **`guard.protect(fn, describe, { adapter })`** is the same call everywhere; the engine adapters above translate it onto each engine's native durable wait. No engine? The worker/webhook resume is the universal path.
|
|
145
168
|
- **Python twin:** `pip install decidio` exposes the identical `guard.protect` (plus the `@guard.approve` decorator, adapters, and the offline verifier); a conformance suite asserts both languages emit an identical request + receipt.
|
|
146
169
|
|
|
147
170
|
## CLI
|
|
148
171
|
|
|
149
|
-
`npx @decidio/sdk <cmd
|
|
172
|
+
`npx @decidio/sdk <cmd>`:
|
|
173
|
+
|
|
174
|
+
- `init [agentId]` — sign in, register, mint the agent token, write `.env` (+ `.gitignore` entries)
|
|
175
|
+
- `doctor` — config, connectivity, and what your current token can do
|
|
176
|
+
- `approvals` / `approvals approve|reject <id> [reason]` — the human side, from your terminal
|
|
177
|
+
- `receipt <id>` — download a sealed decision's Authority Receipt to a `.json` file
|
|
178
|
+
- `dev [--target URL]` — local relay for the resume webhook (operator setups)
|
|
179
|
+
|
|
180
|
+
Commands that administer your workspace (`init`, `approvals`, `receipt`) use a **session**
|
|
181
|
+
sign-in held in memory for that run. What lands on disk in `.env`: the agent's floor-limited
|
|
182
|
+
API token, its locally-generated private signing key, and a webhook secret (used only in
|
|
183
|
+
operator deployments) — `init` adds `.env` to `.gitignore` for you; move secrets to a secret
|
|
184
|
+
manager for production.
|
|
150
185
|
|
|
151
186
|
## Errors
|
|
152
187
|
|
|
153
188
|
- `DecidioBlockedError` — policy blocked the action (incl. the universal deny-by-default backstop).
|
|
154
189
|
- `DecidioRejectedError` — a human rejected the routed action.
|
|
155
|
-
- `DecidioSuspendedError` — (
|
|
156
|
-
- `DecidioTimeoutError` — (blocking mode only) no human decided within `pollTimeoutMs` (default 10 min).
|
|
190
|
+
- `DecidioSuspendedError` — (durable mode) the action was routed and is parked for async approval; the process may exit and resume later. Not a failure.
|
|
191
|
+
- `DecidioTimeoutError` — (blocking mode only) no human decided within `pollTimeoutMs` (default 10 min); the decision stays open in Decidio, nothing executed locally.
|
|
192
|
+
|
|
193
|
+
Every error carries the `decisionId`, so a suspended or timed-out action is always findable in
|
|
194
|
+
your queue.
|
|
157
195
|
|
|
158
196
|
## Notes
|
|
159
197
|
|
package/dist/cli.js
CHANGED
|
@@ -3,26 +3,72 @@
|
|
|
3
3
|
// init [agentId] sign in, register the agent, mint its API token, write .env
|
|
4
4
|
// doctor check config + connectivity + durability readiness
|
|
5
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
|
|
6
7
|
// dev [--target U] signal-only relay: forwards Decidio's webhook to your local agent URL
|
|
7
8
|
//
|
|
8
9
|
// TWO TOKEN KINDS, deliberately (self-serve design 2026-08-28): commands that ADMINISTER a
|
|
9
|
-
// workspace (init's register+mint, approvals) need the owner's SESSION token — acquired
|
|
10
|
+
// workspace (init's register+mint, approvals, receipt) need the owner's SESSION token — acquired
|
|
10
11
|
// interactively per run or from DECIDIO_SESSION_TOKEN, held in memory, NEVER written to disk.
|
|
11
12
|
// What lands in .env as DECIDIO_API_TOKEN is the floor-limited AGENT token the runtime SDK
|
|
12
13
|
// needs (it can call /agent/* and nothing else). A session-capable secret must never live in
|
|
13
14
|
// an agent host's .env — that was the pre-publish design's hole.
|
|
14
|
-
|
|
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 } from "node:fs";
|
|
15
22
|
import { randomBytes } from "node:crypto";
|
|
16
23
|
import { createServer } from "node:http";
|
|
17
24
|
import { createInterface } from "node:readline";
|
|
18
25
|
import { generateKeypair } from "./signing.js";
|
|
26
|
+
// ---- environment: load the CWD .env FIRST (Codex finding: init writes .env, but the next
|
|
27
|
+
// command ignored it and pointed at localhost). Process env always wins — .env only fills gaps.
|
|
28
|
+
function readEnvFile() {
|
|
29
|
+
const out = {};
|
|
30
|
+
if (existsSync(".env"))
|
|
31
|
+
for (const line of readFileSync(".env", "utf8").split(/\r?\n/)) {
|
|
32
|
+
const m = line.match(/^\s*([A-Z_]+)=(.*)$/);
|
|
33
|
+
if (m)
|
|
34
|
+
out[m[1]] = m[2];
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
for (const [k, v] of Object.entries(readEnvFile())) {
|
|
39
|
+
// NEVER autoload the session token from disk: the whole design splits session (memory-only)
|
|
40
|
+
// from the floor-limited agent token (.env). Silently honoring a parked session token would
|
|
41
|
+
// reward exactly the credential-at-rest the split exists to prevent (guard catch 2026-08-29).
|
|
42
|
+
if (k === "DECIDIO_SESSION_TOKEN") {
|
|
43
|
+
console.log("⚠ 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.");
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (process.env[k] === undefined)
|
|
47
|
+
process.env[k] = v;
|
|
48
|
+
}
|
|
19
49
|
const API = (process.env.DECIDIO_API_URL ?? "http://localhost:4000").replace(/\/$/, "");
|
|
20
50
|
const TOKEN = process.env.DECIDIO_API_TOKEN;
|
|
21
51
|
const ENV_PATH = ".env";
|
|
22
52
|
const log = (...a) => console.log(...a);
|
|
53
|
+
/** Fetch wrapper that turns transport failures into guidance instead of a stack trace. */
|
|
23
54
|
async function api(path, init = {}, bearer) {
|
|
24
55
|
const auth = bearer ?? TOKEN;
|
|
25
|
-
|
|
56
|
+
let res;
|
|
57
|
+
try {
|
|
58
|
+
res = await fetch(API + path, { ...init, headers: { "content-type": "application/json", ...(auth ? { authorization: `Bearer ${auth}` } : {}), ...(init.headers ?? {}) } });
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
log(`✘ could not reach Decidio at ${API} (${e?.cause?.code ?? e?.message ?? "network error"}).`);
|
|
62
|
+
if (API.includes("localhost")) {
|
|
63
|
+
log(` You're pointed at localhost — for the hosted sandbox run:`);
|
|
64
|
+
log(` export DECIDIO_API_URL=https://decidio-api.onrender.com`);
|
|
65
|
+
log(` (or put that line in .env), then re-run this command.`);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
log(` Check your network, then re-run. If the URL is wrong, set DECIDIO_API_URL and retry.`);
|
|
69
|
+
}
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
26
72
|
const text = await res.text();
|
|
27
73
|
let body = null;
|
|
28
74
|
try {
|
|
@@ -33,22 +79,23 @@ async function api(path, init = {}, bearer) {
|
|
|
33
79
|
}
|
|
34
80
|
return { status: res.status, body };
|
|
35
81
|
}
|
|
36
|
-
function readEnv() {
|
|
37
|
-
const out = {};
|
|
38
|
-
if (existsSync(ENV_PATH))
|
|
39
|
-
for (const line of readFileSync(ENV_PATH, "utf8").split(/\r?\n/)) {
|
|
40
|
-
const m = line.match(/^\s*([A-Z_]+)=(.*)$/);
|
|
41
|
-
if (m)
|
|
42
|
-
out[m[1]] = m[2];
|
|
43
|
-
}
|
|
44
|
-
return out;
|
|
45
|
-
}
|
|
46
82
|
function upsertEnv(updates) {
|
|
47
|
-
const cur =
|
|
83
|
+
const cur = readEnvFile();
|
|
48
84
|
const merged = { ...cur, ...updates };
|
|
49
85
|
const body = Object.entries(merged).map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
|
|
50
86
|
writeFileSync(ENV_PATH, body);
|
|
51
87
|
}
|
|
88
|
+
/** Keep secrets out of git without nagging: append the two entries to .gitignore when they're
|
|
89
|
+
* not already covered. Creating the file in a non-git directory is harmless. */
|
|
90
|
+
function ensureGitignore() {
|
|
91
|
+
const entries = [".env", ".decidio-pending/"];
|
|
92
|
+
const existing = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
|
|
93
|
+
const missing = entries.filter((e) => !existing.split(/\r?\n/).some((l) => l.trim() === e || l.trim() === e.replace(/\/$/, "")));
|
|
94
|
+
if (!missing.length)
|
|
95
|
+
return;
|
|
96
|
+
appendFileSync(".gitignore", (existing && !existing.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n");
|
|
97
|
+
log(`✔ added ${missing.join(" + ")} to .gitignore (your agent key and parked actions stay out of git)`);
|
|
98
|
+
}
|
|
52
99
|
// ---- interactive input (session sign-in) ---------------------------------------------------
|
|
53
100
|
function ask(question, mute = false) {
|
|
54
101
|
return new Promise((resolve) => {
|
|
@@ -67,21 +114,27 @@ function ask(question, mute = false) {
|
|
|
67
114
|
/** Owner SESSION token, memory-only. Sources in order: DECIDIO_SESSION_TOKEN env, interactive
|
|
68
115
|
* email+password (`/demo/login`), pasted sign-in link (`/demo/login-consume` — for the
|
|
69
116
|
* password-less accounts the welcome flow creates). Never persisted. */
|
|
70
|
-
async function acquireSession() {
|
|
117
|
+
async function acquireSession(purpose) {
|
|
71
118
|
if (process.env.DECIDIO_SESSION_TOKEN)
|
|
72
119
|
return process.env.DECIDIO_SESSION_TOKEN;
|
|
73
120
|
// Same rule the server applies to resume URLs: credentials never ride plaintext http to a
|
|
74
121
|
// non-loopback host. Refuse before prompting, not after collecting a password.
|
|
75
122
|
if (/^http:\/\//i.test(API) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]|$)/i.test(API)) {
|
|
76
|
-
log(`✘ refusing to send sign-in credentials over plaintext http to ${API}
|
|
123
|
+
log(`✘ refusing to send sign-in credentials over plaintext http to ${API}.`);
|
|
124
|
+
log(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
|
|
77
125
|
return null;
|
|
78
126
|
}
|
|
79
127
|
if (!process.stdin.isTTY) {
|
|
80
|
-
log(
|
|
81
|
-
log(
|
|
128
|
+
log(`✘ ${purpose} needs a Decidio workspace sign-in, and this terminal can't prompt for one.`);
|
|
129
|
+
log(` Fix: set DECIDIO_SESSION_TOKEN and re-run. To mint one:`);
|
|
130
|
+
log(` 1. Request a sign-in link from your workspace's sign-in screen (or use your welcome email).`);
|
|
131
|
+
log(` 2. POST its ?login= value — the response's "token" is your session token:`);
|
|
132
|
+
log(` curl -X POST ${API}/demo/login-consume -H "content-type: application/json" -d '{"token":"<login value>"}'`);
|
|
82
133
|
return null;
|
|
83
134
|
}
|
|
84
|
-
log(
|
|
135
|
+
log(`${purpose} needs your Decidio workspace sign-in (${API}).`);
|
|
136
|
+
log(` The session stays in memory for this command only — it is never written to disk.`);
|
|
137
|
+
log(` Press Enter at the password prompt to use an emailed sign-in link instead.`);
|
|
85
138
|
const email = await ask(" email: ");
|
|
86
139
|
if (email) {
|
|
87
140
|
const password = await ask(" password (Enter to skip): ", true);
|
|
@@ -89,27 +142,30 @@ async function acquireSession() {
|
|
|
89
142
|
const r = await api("/demo/login", { method: "POST", body: JSON.stringify({ email, password }) }, "");
|
|
90
143
|
if (r.status >= 200 && r.status < 300 && r.body?.token)
|
|
91
144
|
return r.body.token;
|
|
92
|
-
log(` ✘ sign-in failed (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}.
|
|
145
|
+
log(` ✘ password sign-in failed (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}. No problem — the link path works for every account:`);
|
|
93
146
|
}
|
|
94
147
|
}
|
|
95
|
-
log(
|
|
148
|
+
log(` Request a sign-in link from the app's sign-in screen, then paste the LINK (or just its token) here.`);
|
|
96
149
|
const pasted = await ask(" link or token: ");
|
|
97
|
-
if (!pasted)
|
|
150
|
+
if (!pasted) {
|
|
151
|
+
log(" ✘ nothing pasted — re-run when you have the link.");
|
|
98
152
|
return null;
|
|
153
|
+
}
|
|
99
154
|
const m = pasted.match(/[?&]login=([^&\s]+)/);
|
|
100
155
|
const linkToken = m ? decodeURIComponent(m[1]) : pasted;
|
|
101
156
|
const r = await api("/demo/login-consume", { method: "POST", body: JSON.stringify({ token: linkToken }) }, "");
|
|
102
157
|
if (r.status >= 200 && r.status < 300 && r.body?.token)
|
|
103
158
|
return r.body.token;
|
|
104
|
-
log(` ✘ link
|
|
159
|
+
log(` ✘ that link didn't work (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}.`);
|
|
160
|
+
log(` Sign-in links are single-use and expire in 15 minutes — request a fresh one and re-run.`);
|
|
105
161
|
return null;
|
|
106
162
|
}
|
|
107
163
|
async function cmdInit(agentId) {
|
|
108
164
|
log(`Registering agent "${agentId}" with Decidio at ${API}…`);
|
|
109
165
|
// CLIENT-SIDE KEYGEN: generate the Ed25519 keypair LOCALLY and register only the PUBLIC
|
|
110
166
|
// did:key. The private key NEVER crosses the wire — it goes straight to this host's env.
|
|
111
|
-
const existingKey =
|
|
112
|
-
const sourceSystem = process.env.DECIDIO_SOURCE_SYSTEM ||
|
|
167
|
+
const existingKey = readEnvFile().DECIDIO_AGENT_KEY;
|
|
168
|
+
const sourceSystem = process.env.DECIDIO_SOURCE_SYSTEM || readEnvFile().DECIDIO_SOURCE_SYSTEM;
|
|
113
169
|
const { did: localDid, privateKeyBase64: localKey } = generateKeypair();
|
|
114
170
|
const registerBody = JSON.stringify({ agentId, did: localDid, ...(sourceSystem ? { sourceSystem } : {}) });
|
|
115
171
|
// First try whatever bearer the env already carries (the local-dev / operator path — a static
|
|
@@ -117,29 +173,35 @@ async function cmdInit(agentId) {
|
|
|
117
173
|
let session = null;
|
|
118
174
|
let r = await api("/api/agents/register", { method: "POST", body: registerBody });
|
|
119
175
|
if (r.status === 401 || r.status === 403) {
|
|
120
|
-
session = await acquireSession();
|
|
121
|
-
if (!session)
|
|
122
|
-
log("✘ unauthorized — sign in (or set DECIDIO_SESSION_TOKEN / DECIDIO_API_TOKEN) and retry.");
|
|
176
|
+
session = await acquireSession("Registering an agent");
|
|
177
|
+
if (!session)
|
|
123
178
|
return 1;
|
|
124
|
-
}
|
|
125
179
|
r = await api("/api/agents/register", { method: "POST", body: registerBody }, session);
|
|
126
180
|
}
|
|
127
181
|
let did, privateKey;
|
|
128
182
|
if (r.status === 409) {
|
|
129
183
|
// Already registered to a different (server-held) did. Reuse it; the local key we just
|
|
130
184
|
// minted does NOT match, so don't write it — the operator must supply the existing key.
|
|
131
|
-
log(`• agent already registered (did ${r.body?.did}); reusing it.`);
|
|
132
185
|
did = r.body?.did;
|
|
133
|
-
|
|
134
|
-
|
|
186
|
+
log(`• "${agentId}" is already registered in this workspace (did ${did}) — reusing it.`);
|
|
187
|
+
if (sourceSystem)
|
|
188
|
+
log(` Note: sourceSystem can't change on an existing agent — the registered lane stands. To use the "${sourceSystem}" lane, register a NEW name: npx @decidio/sdk init ${agentId}-${sourceSystem}`);
|
|
189
|
+
if (existingKey)
|
|
190
|
+
log(` Your .env already carries a DECIDIO_AGENT_KEY; if it's this agent's original key, signing keeps working.`);
|
|
191
|
+
else {
|
|
192
|
+
log(` ⚠ Its private key lives wherever this agent was FIRST initialized — keys never leave the machine that made them.`);
|
|
193
|
+
log(` Either restore that DECIDIO_AGENT_KEY into .env, or start fresh under a new name:`);
|
|
194
|
+
log(` npx @decidio/sdk init ${agentId}-2`);
|
|
195
|
+
}
|
|
135
196
|
}
|
|
136
197
|
else if (r.status >= 200 && r.status < 300) {
|
|
137
198
|
did = r.body?.did;
|
|
138
199
|
privateKey = localKey;
|
|
139
|
-
log(`✔ registered (did ${did}) — key generated locally
|
|
200
|
+
log(`✔ registered (did ${did}) — key generated locally; only the public half went to Decidio`);
|
|
140
201
|
}
|
|
141
202
|
else {
|
|
142
|
-
log(`✘
|
|
203
|
+
log(`✘ registration failed (${r.status}): ${JSON.stringify(r.body)}`);
|
|
204
|
+
log(` Fix the cause above and re-run — init is safe to repeat.`);
|
|
143
205
|
return 1;
|
|
144
206
|
}
|
|
145
207
|
// Mint the runtime credential: the floor-limited AGENT token (can call /agent/* only). This is
|
|
@@ -153,17 +215,18 @@ async function cmdInit(agentId) {
|
|
|
153
215
|
if (mint.status >= 200 && mint.status < 300 && mint.body?.token) {
|
|
154
216
|
agentToken = mint.body.token;
|
|
155
217
|
workspaceId = mint.body.workspaceId;
|
|
156
|
-
log(`✔ minted the agent API token
|
|
218
|
+
log(`✔ minted the agent's API token — floor-limited: it can request approvals and nothing else${mint.body.expiresAt ? ` (expires ${mint.body.expiresAt})` : ""}`);
|
|
157
219
|
}
|
|
158
220
|
else if (session) {
|
|
159
|
-
log(`✘ could not mint the agent token (${mint.status}): ${JSON.stringify(mint.body)}
|
|
221
|
+
log(`✘ could not mint the agent's API token (${mint.status}): ${JSON.stringify(mint.body)}`);
|
|
222
|
+
log(` Without it the SDK has no runtime credential. Fix the cause above and re-run init — it's safe to repeat.`);
|
|
160
223
|
return 1;
|
|
161
224
|
}
|
|
162
225
|
else {
|
|
163
226
|
log(`• no agent token minted (${mint.status}) — assuming local/operator auth via the existing DECIDIO_API_TOKEN.`);
|
|
164
227
|
}
|
|
165
228
|
}
|
|
166
|
-
const secret =
|
|
229
|
+
const secret = readEnvFile().DECIDIO_WEBHOOK_SECRET || randomBytes(32).toString("hex");
|
|
167
230
|
const envOut = { DECIDIO_API_URL: API, DECIDIO_AGENT_ID: agentId, DECIDIO_WEBHOOK_SECRET: secret };
|
|
168
231
|
if (sourceSystem)
|
|
169
232
|
envOut.DECIDIO_SOURCE_SYSTEM = sourceSystem;
|
|
@@ -176,52 +239,59 @@ async function cmdInit(agentId) {
|
|
|
176
239
|
if (workspaceId)
|
|
177
240
|
envOut.DECIDIO_WORKSPACE_ID = workspaceId;
|
|
178
241
|
upsertEnv(envOut);
|
|
179
|
-
log(`✔ wrote ${ENV_PATH}
|
|
242
|
+
log(`✔ wrote ${ENV_PATH} — every decidio command and the SDK read it from this directory`);
|
|
243
|
+
ensureGitignore();
|
|
180
244
|
if (privateKey)
|
|
181
|
-
log(
|
|
182
|
-
log("
|
|
183
|
-
log(
|
|
184
|
-
log(
|
|
185
|
-
log("
|
|
186
|
-
log(
|
|
187
|
-
log(`
|
|
245
|
+
log(` (the agent's private signing key is in there — for production, move it to your secret manager)`);
|
|
246
|
+
log("");
|
|
247
|
+
log("Next: protect one function and watch it route. Two steps:");
|
|
248
|
+
log("");
|
|
249
|
+
log(" 1. Save this as quickstart.mjs:");
|
|
250
|
+
log("");
|
|
251
|
+
log(` import { guard } from "@decidio/sdk";`);
|
|
252
|
+
log(` const payInvoiceRaw = async (inv) => ({ paid: true, id: inv.id }); // your real action`);
|
|
253
|
+
log(` const payInvoice = guard.protect(payInvoiceRaw,`);
|
|
254
|
+
log(` (inv) => ({ action: "payInvoice", amount: inv.amount, scope: "Invoice" }),`);
|
|
255
|
+
log(` { mode: "blocking" }); // blocking = watch it happen live; durable is the production mode`);
|
|
256
|
+
log(` await payInvoice({ id: "INV-1", amount: 86000 });`);
|
|
257
|
+
log("");
|
|
258
|
+
log(" 2. Run it, approve it, watch it complete:");
|
|
259
|
+
log("");
|
|
260
|
+
log(` node --env-file=.env quickstart.mjs # routes to a human and waits`);
|
|
261
|
+
log(` npx @decidio/sdk approvals # (second terminal) see it pending`);
|
|
262
|
+
log(` npx @decidio/sdk approvals approve <id> # approve -> your function runs -> sealed`);
|
|
188
263
|
log("");
|
|
189
264
|
return cmdDoctor();
|
|
190
265
|
}
|
|
191
266
|
async function cmdDoctor() {
|
|
192
|
-
const env =
|
|
267
|
+
const env = readEnvFile();
|
|
193
268
|
let ok = true;
|
|
194
269
|
const line = (good, label) => { log(`${good ? "✔" : "✘"} ${label}`); ok = ok && good; };
|
|
195
|
-
line(!!(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL), `API URL: ${API}`);
|
|
196
|
-
line(!!(process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID), `agent id: ${process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID || "(unset)"}`);
|
|
270
|
+
line(!!(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL), `API URL: ${API}${(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL) ? "" : " (unset — defaulting to localhost; the hosted sandbox is https://decidio-api.onrender.com)"}`);
|
|
271
|
+
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>)"}`);
|
|
197
272
|
const secret = process.env.DECIDIO_WEBHOOK_SECRET || env.DECIDIO_WEBHOOK_SECRET;
|
|
198
|
-
line(!!secret, secret ? "webhook secret: set (durable resume will fail-closed verify)" : "webhook secret: MISSING — resume webhook will fail closed");
|
|
199
|
-
// Signing key — REQUIRED for auto-approve.
|
|
200
|
-
//
|
|
273
|
+
line(!!secret, secret ? "webhook secret: set (durable resume will fail-closed verify)" : "webhook secret: MISSING — the resume webhook will fail closed (init generates one)");
|
|
274
|
+
// Signing key — REQUIRED to be ELIGIBLE for auto-approve. Whether anything auto-approves is
|
|
275
|
+
// the workspace policy's call, not this key's (Codex finding: the old wording claimed
|
|
276
|
+
// "auto-approve is in effect" while deny-by-default routed everything).
|
|
201
277
|
const signing = (process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY) && (process.env.DECIDIO_AGENT_DID || env.DECIDIO_AGENT_DID);
|
|
202
278
|
if (signing)
|
|
203
|
-
log("✔ signing key: set — agent
|
|
279
|
+
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).");
|
|
204
280
|
else
|
|
205
|
-
log("⚠ signing key: MISSING (DECIDIO_AGENT_KEY/DID) —
|
|
281
|
+
log("⚠ signing key: MISSING (DECIDIO_AGENT_KEY/DID) — the agent can't prove identity, so EVERY request routes to a human. Run `npx @decidio/sdk init` to generate one.");
|
|
206
282
|
// Reachability and token role are SEPARATE checks (the old probe called any <500 green, so a
|
|
207
283
|
// wrong-kind token looked healthy). /api/health is unauthenticated truth about reachability;
|
|
208
|
-
// /api/records then classifies the token
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
log(`⚠ token scope: unexpected ${rec.status} from /api/records`);
|
|
220
|
-
}
|
|
221
|
-
catch (e) {
|
|
222
|
-
line(false, `connectivity: cannot reach ${API} — ${e?.message}`);
|
|
223
|
-
}
|
|
224
|
-
log(ok ? "\ndoctor: all green — durable resume ready." : "\ndoctor: fix the ✘ items above.");
|
|
284
|
+
// /api/records then classifies the token.
|
|
285
|
+
const h = await api("/api/health", {}, "");
|
|
286
|
+
line(h.status >= 200 && h.status < 300, `connectivity: API reachable (${h.status})`);
|
|
287
|
+
const rec = await api("/api/records");
|
|
288
|
+
if (rec.status >= 200 && rec.status < 300)
|
|
289
|
+
log("✔ token scope: session/admin (init, approvals and receipt work from this env)");
|
|
290
|
+
else if (rec.status === 401)
|
|
291
|
+
log("✔ token scope: agent floor — correct for the runtime SDK (approvals/receipt will ask you to sign in)");
|
|
292
|
+
else
|
|
293
|
+
log(`⚠ token scope: unexpected ${rec.status} from /api/records`);
|
|
294
|
+
log(ok ? "\ndoctor: all green." : "\ndoctor: fix the ✘ items above, then re-run `npx @decidio/sdk doctor`.");
|
|
225
295
|
return ok ? 0 : 1;
|
|
226
296
|
}
|
|
227
297
|
async function cmdApprovals(sub, id, reason) {
|
|
@@ -230,31 +300,51 @@ async function cmdApprovals(sub, id, reason) {
|
|
|
230
300
|
let bearer;
|
|
231
301
|
const probe = await api("/api/decisions/cards");
|
|
232
302
|
if (probe.status === 401 || probe.status === 403) {
|
|
233
|
-
const session = await acquireSession();
|
|
234
|
-
if (!session)
|
|
235
|
-
log("✘ approvals need a workspace sign-in.");
|
|
303
|
+
const session = await acquireSession("Reviewing approvals");
|
|
304
|
+
if (!session)
|
|
236
305
|
return 1;
|
|
237
|
-
}
|
|
238
306
|
bearer = session;
|
|
239
307
|
}
|
|
240
308
|
if (sub === "approve" || sub === "reject") {
|
|
241
309
|
if (!id) {
|
|
242
|
-
log(`usage: approvals ${sub} <decisionId> [reason]`);
|
|
310
|
+
log(`usage: npx @decidio/sdk approvals ${sub} <decisionId> [reason]`);
|
|
243
311
|
return 2;
|
|
244
312
|
}
|
|
245
313
|
const r = await api(`/api/decisions/${encodeURIComponent(id)}/${sub === "approve" ? "approve" : "reject"}`, { method: "POST", body: JSON.stringify({ reason: reason ?? undefined }) }, bearer);
|
|
246
314
|
if (r.status >= 200 && r.status < 300) {
|
|
315
|
+
// HONESTY over green checkmarks (Codex finding: the server resolves each decision exactly
|
|
316
|
+
// once and answers replays idempotently — a replay is a NO-OP and must read as one, never
|
|
317
|
+
// as a fresh approval). Non-zero exit so scripts can tell the difference.
|
|
318
|
+
if (r.body?.alreadyResolved) {
|
|
319
|
+
log(`• ${id} was already resolved — nothing changed.`);
|
|
320
|
+
log(` Decisions are single-use: a second ${sub} can never re-execute the action (that's the replay protection working).`);
|
|
321
|
+
log(` See its sealed record: npx @decidio/sdk receipt ${id}`);
|
|
322
|
+
return 1;
|
|
323
|
+
}
|
|
247
324
|
log(`✔ ${sub}d ${id}${r.body?.resumeSignal ? ` — agent signalled (${r.body.resumeSignal.ok ? "ok" : r.body.resumeSignal.error})` : ""}`);
|
|
325
|
+
if (sub === "approve")
|
|
326
|
+
log(` The agent's function runs next — a blocking call resumes now; a durable one when its worker or resume route picks this up.`);
|
|
327
|
+
else
|
|
328
|
+
log(` The agent's action will NOT run; the rejection seals as a verifiable record.`);
|
|
329
|
+
log(` Download the receipt: npx @decidio/sdk receipt ${id}`);
|
|
248
330
|
return 0;
|
|
249
331
|
}
|
|
332
|
+
if (r.status === 404) {
|
|
333
|
+
log(`✘ no decision "${id}" in this workspace — check the id (npx @decidio/sdk approvals lists pending ones).`);
|
|
334
|
+
return 1;
|
|
335
|
+
}
|
|
250
336
|
log(`✘ ${sub} failed (${r.status}): ${JSON.stringify(r.body)}`);
|
|
251
337
|
return 1;
|
|
252
338
|
}
|
|
253
339
|
const r = bearer ? await api("/api/decisions/cards", {}, bearer) : probe;
|
|
340
|
+
if (!(r.status >= 200 && r.status < 300)) {
|
|
341
|
+
log(`✘ could not list approvals (${r.status}): ${JSON.stringify(r.body)}`);
|
|
342
|
+
return 1;
|
|
343
|
+
}
|
|
254
344
|
const cards = r.body?.cards ?? [];
|
|
255
345
|
const pending = cards.filter((c) => c.isAgentRequest && c.status !== "sealed");
|
|
256
346
|
if (!pending.length) {
|
|
257
|
-
log("No pending agent approvals.");
|
|
347
|
+
log("No pending agent approvals. Trigger one from your protected function, then re-run this.");
|
|
258
348
|
return 0;
|
|
259
349
|
}
|
|
260
350
|
log(`Pending agent approvals (${pending.length}):`);
|
|
@@ -262,7 +352,44 @@ async function cmdApprovals(sub, id, reason) {
|
|
|
262
352
|
const ar = c.agentRequest ?? {};
|
|
263
353
|
log(` ${c.id} ${ar.action ?? "?"}${ar.amount != null ? ` $${Number(ar.amount).toLocaleString()}` : ""} — ${c.title ?? ""}`);
|
|
264
354
|
}
|
|
265
|
-
log(`\nApprove: decidio approvals approve <id> [reason]
|
|
355
|
+
log(`\nApprove: npx @decidio/sdk approvals approve <id> [reason]`);
|
|
356
|
+
log(`Reject: npx @decidio/sdk approvals reject <id> [reason]`);
|
|
357
|
+
return 0;
|
|
358
|
+
}
|
|
359
|
+
async function cmdReceipt(id) {
|
|
360
|
+
if (!id) {
|
|
361
|
+
log("usage: npx @decidio/sdk receipt <decisionId>");
|
|
362
|
+
log(" Downloads the decision's sealed Authority Receipt (a W3C Verifiable Credential) to a .json file.");
|
|
363
|
+
log(" Find decision ids via `npx @decidio/sdk approvals` or in your Decidio app.");
|
|
364
|
+
return 2;
|
|
365
|
+
}
|
|
366
|
+
// Receipts are workspace data — session scope, like approvals. (The record-detail /verify
|
|
367
|
+
// route carries the credential; the plain record route does not — Codex finding.)
|
|
368
|
+
let bearer;
|
|
369
|
+
let r = await api(`/api/records/${encodeURIComponent(id)}/verify`);
|
|
370
|
+
if (r.status === 401 || r.status === 403) {
|
|
371
|
+
const session = await acquireSession("Downloading a receipt");
|
|
372
|
+
if (!session)
|
|
373
|
+
return 1;
|
|
374
|
+
bearer = session;
|
|
375
|
+
r = await api(`/api/records/${encodeURIComponent(id)}/verify`, {}, bearer);
|
|
376
|
+
}
|
|
377
|
+
if (r.status === 404) {
|
|
378
|
+
log(`✘ no sealed record for "${id}" in this workspace.`);
|
|
379
|
+
log(` Receipts exist once a decision is SEALED (approved or rejected). Still pending? npx @decidio/sdk approvals`);
|
|
380
|
+
return 1;
|
|
381
|
+
}
|
|
382
|
+
if (!(r.status >= 200 && r.status < 300) || !r.body?.credential) {
|
|
383
|
+
log(`✘ could not fetch the receipt (${r.status}): ${JSON.stringify(r.body).slice(0, 300)}`);
|
|
384
|
+
return 1;
|
|
385
|
+
}
|
|
386
|
+
const name = `decidio-receipt-${id.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
|
|
387
|
+
writeFileSync(name, JSON.stringify(r.body.credential, null, 2) + "\n");
|
|
388
|
+
log(`✔ wrote ${name}`);
|
|
389
|
+
log(` Verify it offline — no Decidio account or network needed:`);
|
|
390
|
+
log(` npx @decidio/verify ${name}`);
|
|
391
|
+
log(` To also prove WHO issued it, pin your workspace's issuer DID (shown in your app's record verify panel):`);
|
|
392
|
+
log(` npx @decidio/verify --issuer <did:key:...> ${name}`);
|
|
266
393
|
return 0;
|
|
267
394
|
}
|
|
268
395
|
function cmdDev(target, port) {
|
|
@@ -292,12 +419,33 @@ function cmdDev(target, port) {
|
|
|
292
419
|
}).listen(port);
|
|
293
420
|
return 0;
|
|
294
421
|
}
|
|
422
|
+
const USAGE = `decidio — the Decidio SDK command line
|
|
423
|
+
|
|
424
|
+
npx @decidio/sdk init [agentId] sign in, register the agent, mint its API token, write .env
|
|
425
|
+
npx @decidio/sdk doctor check config, connectivity, and what your token can do
|
|
426
|
+
npx @decidio/sdk approvals list pending agent approvals
|
|
427
|
+
npx @decidio/sdk approvals approve <id> [reason]
|
|
428
|
+
npx @decidio/sdk approvals reject <id> [reason]
|
|
429
|
+
npx @decidio/sdk receipt <id> download a sealed decision's Authority Receipt (.json)
|
|
430
|
+
npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
|
|
431
|
+
|
|
432
|
+
Docs: the README that shipped with this package (quickstart, durable resume, adapters).`;
|
|
295
433
|
async function main(argv) {
|
|
296
434
|
const [cmd, a, b, ...rest] = argv.slice(2);
|
|
435
|
+
const wantsHelp = (s) => s === "help" || s === "--help" || s === "-h";
|
|
436
|
+
if (!cmd || wantsHelp(cmd)) {
|
|
437
|
+
log(USAGE);
|
|
438
|
+
return 0;
|
|
439
|
+
}
|
|
440
|
+
if (wantsHelp(a)) {
|
|
441
|
+
log(USAGE);
|
|
442
|
+
return 0;
|
|
443
|
+
}
|
|
297
444
|
switch (cmd) {
|
|
298
|
-
case "init": return cmdInit(a ?? process.env.DECIDIO_AGENT_ID ?? "
|
|
445
|
+
case "init": return cmdInit((a && !a.startsWith("-") ? a : undefined) ?? process.env.DECIDIO_AGENT_ID ?? "my-agent");
|
|
299
446
|
case "doctor": return cmdDoctor();
|
|
300
447
|
case "approvals": return cmdApprovals(a, b, rest.join(" ") || undefined);
|
|
448
|
+
case "receipt": return cmdReceipt(a);
|
|
301
449
|
case "dev": {
|
|
302
450
|
const args = argv.slice(3);
|
|
303
451
|
const flag = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
|
|
@@ -306,9 +454,9 @@ async function main(argv) {
|
|
|
306
454
|
return cmdDev(target, port);
|
|
307
455
|
}
|
|
308
456
|
default:
|
|
309
|
-
log(
|
|
310
|
-
return
|
|
457
|
+
log(`✘ unknown command "${cmd}"\n\n${USAGE}`);
|
|
458
|
+
return 1;
|
|
311
459
|
}
|
|
312
460
|
}
|
|
313
461
|
main(process.argv).then((code) => { if (code !== 0)
|
|
314
|
-
process.exitCode = code; }).catch((e) => { console.error(e); process.exit(1); });
|
|
462
|
+
process.exitCode = code; }).catch((e) => { console.error(`✘ unexpected error: ${e?.message ?? e}`); process.exit(1); });
|
package/openapi.yaml
CHANGED
|
@@ -107,6 +107,11 @@ components:
|
|
|
107
107
|
OUTBOUND — Decidio → your resumeUrl when a routed decision is decided. The raw body is
|
|
108
108
|
HMAC-signed; verify `x-decidio-signature: sha256=<hex>` with your DECIDIO_WEBHOOK_SECRET
|
|
109
109
|
BEFORE acting (the SDK resume controllers fail closed). Re-execution is single-use.
|
|
110
|
+
OPERATOR DEPLOYMENTS ONLY: signing uses a shared secret configured on both the Decidio
|
|
111
|
+
server and the agent, and self-hosted production allow-lists resume hosts (default-deny).
|
|
112
|
+
Self-serve agents against the hosted sandbox use the SDK's poll worker (guard.worker())
|
|
113
|
+
instead — a locally generated secret the server never learned would (correctly) fail
|
|
114
|
+
closed here.
|
|
110
115
|
required: [decisionId, verdict]
|
|
111
116
|
properties:
|
|
112
117
|
decisionId: { type: string }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decidio/sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "One-line approval gate for AI-agent actions. Wrap a risky tool call with guard.protect(); Decidio's policy engine decides proceed | route | block, a human approves routed actions, and every outcome is sealed as a verifiable Authority Receipt.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"LICENSE"
|
|
43
43
|
],
|
|
44
44
|
"engines": {
|
|
45
|
-
"node": ">=20"
|
|
45
|
+
"node": ">=20.6"
|
|
46
46
|
},
|
|
47
47
|
"keywords": [
|
|
48
48
|
"ai-agents",
|