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