@decidio/sdk 0.3.3 → 0.3.5
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 +39 -19
- package/dist/adapters/_gate.js +2 -1
- package/dist/adapters/openai.d.ts +1 -1
- package/dist/cli.js +227 -20
- package/dist/errors.js +5 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +9 -6
- package/dist/inngest.js +2 -0
- package/dist/resume.d.ts +7 -2
- package/dist/resume.js +7 -2
- package/dist/signing.d.ts +4 -0
- package/dist/signing.js +14 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -57,6 +57,9 @@ npm install @decidio/sdk
|
|
|
57
57
|
npx @decidio/sdk init --unique my-agent # --unique appends a random suffix: collision-proof
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
+
> Just ran `npx @decidio/sdk quickstart` in this directory and kept its agent? Its `.env` already
|
|
61
|
+
> works — skip this step and continue at step 3.
|
|
62
|
+
|
|
60
63
|
Every CLI command talks to the hosted sandbox by default and announces its target host on
|
|
61
64
|
stderr each run — set `DECIDIO_API_URL` (env or `.env`) to point at another Decidio instead.
|
|
62
65
|
|
|
@@ -230,7 +233,11 @@ Real approvals take minutes to days, and nobody keeps a terminal open for them.
|
|
|
230
233
|
`mode: "blocking"` (durable is the default) and a routed action **suspends** instead of
|
|
231
234
|
waiting: it parks its call arguments agent-side (`.decidio-pending/` by default — Decidio
|
|
232
235
|
stores none of your downstream payload) and throws `DecidioSuspendedError`. The process may
|
|
233
|
-
exit.
|
|
236
|
+
exit. **The parked payload is plaintext on the agent's disk:** the full call arguments and their
|
|
237
|
+
signature sit in `.decidio-pending/` until the decision resolves — owner-only permissions where the
|
|
238
|
+
OS supports it, gitignored by `init`, but not encrypted. Treat that directory as you treat the `.env`
|
|
239
|
+
beside it; a custom `PendingStore` can encrypt at rest. Two ways the parked action resumes when a
|
|
240
|
+
human decides:
|
|
234
241
|
|
|
235
242
|
**The worker (self-serve — start here).** No inbound URL, no shared secrets, and it resumes work
|
|
236
243
|
the current process never started. Here is a complete `agent.mjs` you can run:
|
|
@@ -421,11 +428,12 @@ the store you configured, so two workers sharing one Redis or Postgres store can
|
|
|
421
428
|
other's work. (Through 0.1.x this was a separate local-filesystem journal, which meant two
|
|
422
429
|
workers kept two disjoint journals and neither could. `reportDir` is accepted and ignored now.)
|
|
423
430
|
|
|
424
|
-
**Parked arguments must be JSON-serializable
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
431
|
+
**Parked arguments must be JSON-serializable — and that is checked BEFORE routing.** Values that
|
|
432
|
+
do not survive JSON with their meaning intact — `Date`, `Map`, `undefined`, `NaN`, class instances,
|
|
433
|
+
functions — are refused with `DecidioUnsafeArgumentsError` before any decision exists and before any
|
|
434
|
+
network call (since 0.1.12): nothing is parked, nothing is routed. The parked payload is also
|
|
435
|
+
signed, so a file edited on disk fails the integrity check on resume (a refusal, never a wrong
|
|
436
|
+
execution). Pass plain data; rehydrate inside your function.
|
|
429
437
|
|
|
430
438
|
**Payload integrity.** When a routed action is parked, the SDK signs its arguments (bound to the
|
|
431
439
|
decision id) with your agent key; on resume it **refuses to execute** if the parked payload was
|
|
@@ -451,7 +459,7 @@ for engines you don't. Each adapter's shipped `.d.ts` header documents its wirin
|
|
|
451
459
|
|
|
452
460
|
|
|
453
461
|
|
|
454
|
-
## The durable store is a revision chain (0.3.2)
|
|
462
|
+
## The durable store is a revision chain (since 0.3.2)
|
|
455
463
|
|
|
456
464
|
`FilePendingStore` keeps each decision's execution record as an **append-only revision chain** —
|
|
457
465
|
`<id>.rev.1`, `<id>.rev.2`, … — and the head is the highest number. Every state change reads the
|
|
@@ -484,11 +492,19 @@ the store refuses to pick one.
|
|
|
484
492
|
|
|
485
493
|
**Custom stores.** The `PendingStore` contract has said since 0.2.0 that every method must be atomic
|
|
486
494
|
against concurrent callers. The shipped file store did not honor that until now; a custom store must.
|
|
487
|
-
The deterministic two-process schedules that proved the defect live in
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
paused while the approver publishes four revisions past it. A store that cannot pass
|
|
491
|
-
atomic, whatever its methods say.
|
|
495
|
+
The deterministic two-process schedules that proved the defect live in the Decidio source
|
|
496
|
+
repository's conformance suite (not part of this package): a denial landing while an invoker is
|
|
497
|
+
paused between validating and publishing; two conflicting settlements from one head; and a denier
|
|
498
|
+
paused while the approver publishes four revisions past it. A store that cannot pass those schedules
|
|
499
|
+
is not atomic, whatever its methods say.
|
|
500
|
+
|
|
501
|
+
**Sizing envelope.** Nothing deletes a revision, so each decision leaves a handful of small files (one
|
|
502
|
+
per transition, typically four to six) and a head lookup lists the directory. That is comfortable for
|
|
503
|
+
tens of thousands of decisions on a local disk; beyond that, or for any deployment that shares a store
|
|
504
|
+
across hosts, use a transactional custom `PendingStore`. The tested envelope is a **single host on a local
|
|
505
|
+
filesystem with hard links** — network, FUSE and multi-host volumes are outside it, and their link
|
|
506
|
+
semantics have not been exercised. Never compact the directory while a worker runs; if compaction is
|
|
507
|
+
ever wanted it is an offline job with every writer stopped.
|
|
492
508
|
|
|
493
509
|
## Reading a receipt
|
|
494
510
|
|
|
@@ -510,8 +526,9 @@ request was compared to and did not clear, not a limit that was applied to it. R
|
|
|
510
526
|
`requested_amount` as the ask, the other two as ceilings.
|
|
511
527
|
|
|
512
528
|
**`firedRules` is what the policy engine actually matched — for any verdict.** It is not an
|
|
513
|
-
auto-approve indicator: a **blocked
|
|
514
|
-
non-empty does not mean "approved" and empty does
|
|
529
|
+
auto-approve indicator: a request **blocked by the universal backstop** (no active policy governs
|
|
530
|
+
agents at all) carries that backstop rule here, so non-empty does not mean "approved" and empty does
|
|
531
|
+
not mean "blocked". The field that states the
|
|
515
532
|
disposition is `authority.result`; `firedRules` tells you which named, versioned rules produced it.
|
|
516
533
|
|
|
517
534
|
**`payloadSchemaUri` is a `urn:`, not a URL.** It is content-addressed —
|
|
@@ -541,8 +558,10 @@ verdict, reason?)` is now REQUIRED — it writes the tombstone that makes a deni
|
|
|
541
558
|
permanently unclaimable, and a store that cannot record a denial cannot uphold the guarantee, so
|
|
542
559
|
this is refused at construction rather than deep inside a resolve. `delete` also takes an optional
|
|
543
560
|
owning attempt (`delete(id, attemptId?)`) so a superseded attempt cannot clean up the live one's
|
|
544
|
-
payload, and `settle` takes `workerStopped`. The shipped stores show the shape
|
|
545
|
-
|
|
561
|
+
payload, and `settle` takes `workerStopped`. The shipped stores show the shape; the ownership
|
|
562
|
+
corpus that pins these rules (claim fencing, terminal denial, advance-only transitions, settle
|
|
563
|
+
preconditions) lives in the Decidio source repository and is not part of this package — the rules
|
|
564
|
+
themselves are the ones stated above.
|
|
546
565
|
|
|
547
566
|
**`recover()` now throws instead of returning `[]`** when the ledger cannot be read
|
|
548
567
|
(`DecidioRecoveryUnavailableError`). `[]` and "I cannot tell you what you owe" are the same value
|
|
@@ -581,8 +600,9 @@ that fits every other confirm error.
|
|
|
581
600
|
|
|
582
601
|
- **Engine adapters are BETA.** They share the core's gate semantics — the verdict allow-list,
|
|
583
602
|
the canonical argument model, the transport rule and evidence handling are ONE implementation,
|
|
584
|
-
and a conformance matrix runs the same malformed-verdict, unsafe-argument
|
|
585
|
-
battery against every door. What is *not* yet
|
|
603
|
+
and a conformance matrix runs the same malformed-verdict, unsafe-argument, plaintext-transport and
|
|
604
|
+
malformed-description (blank action, negative amount) battery against every door. What is *not* yet
|
|
605
|
+
verified is the engine integration itself: those
|
|
586
606
|
paths are exercised against structural mocks, not live LangGraph/Temporal/Inngest/OpenAI
|
|
587
607
|
runtimes. Use the core path for production until that changes. Each adapter is bound through its
|
|
588
608
|
own `@decidio/sdk/<engine>` subpath (they need the engine handle at call time); no engine at all?
|
|
@@ -631,7 +651,7 @@ manager for production.
|
|
|
631
651
|
|
|
632
652
|
## Errors
|
|
633
653
|
|
|
634
|
-
- `DecidioBlockedError` — policy blocked the action
|
|
654
|
+
- `DecidioBlockedError` — policy blocked the action: a named block rule, or the universal backstop that fires when NO active policy governs agents at all (fail closed, no human involved). Distinct from routing-by-default — with an active policy and no matching auto-approve rule, a request is not blocked, it is ROUTED to a person.
|
|
635
655
|
- `DecidioRejectedError` — a human rejected the routed action.
|
|
636
656
|
- `DecidioSuspendedError` — (durable mode) the action was routed and is parked for async approval; the process may exit and resume later. Not a failure.
|
|
637
657
|
- `DecidioTimeoutError` — (blocking mode only) no human decided within `pollTimeoutMs` (default 10 min); the decision stays open in Decidio, nothing executed locally.
|
package/dist/adapters/_gate.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, carriesCredential, canonicalize, readConfirmResponse, buildConfirmBody, decisionIdProblem } from "../index.js";
|
|
2
|
+
import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, carriesCredential, canonicalize, assertDescribed, readConfirmResponse, buildConfirmBody, decisionIdProblem } from "../index.js";
|
|
3
3
|
export const AUTHORIZE_VERDICTS = ["proceed", "route", "block"];
|
|
4
4
|
export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
|
|
5
5
|
const a = auth;
|
|
@@ -15,6 +15,7 @@ export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
|
|
|
15
15
|
}
|
|
16
16
|
export async function gateAuthorize(config, ctx) {
|
|
17
17
|
ctx = canonicalize(ctx, "describe(...)");
|
|
18
|
+
assertDescribed(ctx);
|
|
18
19
|
assertSafeTransport(config.apiUrl, carriesCredential(config));
|
|
19
20
|
const f = config.fetchImpl ?? fetch;
|
|
20
21
|
const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
|
|
@@ -15,7 +15,7 @@ interface RunStateLike {
|
|
|
15
15
|
* park the serialized state agent-side on `route`, resume on Decidio's approval.
|
|
16
16
|
*
|
|
17
17
|
* const state = result.state; // or RunState from the run
|
|
18
|
-
* const { resolved, pending } = await
|
|
18
|
+
* const { resolved, pending } = await authorizeInterruptions(config, state, describe);
|
|
19
19
|
* if (pending.length) { await store.put(decisionId, state.toString()); return; } // suspend
|
|
20
20
|
* result = await run(agent, state); // all resolved → resume
|
|
21
21
|
* // on Decidio approval webhook: state = await RunState.fromString(agent, parked);
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync } from "node:fs";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, openSync, writeSync, fsyncSync, closeSync, renameSync } from "node:fs";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
5
|
import { execFileSync } from "node:child_process";
|
|
@@ -38,7 +38,10 @@ function readEnvFile() {
|
|
|
38
38
|
}
|
|
39
39
|
return out;
|
|
40
40
|
}
|
|
41
|
-
const
|
|
41
|
+
const wantsHelp = (s) => s === "help" || s === "--help" || s === "-h";
|
|
42
|
+
const ARGV_TAIL = process.argv.slice(2);
|
|
43
|
+
const OFFLINE_CMD = ["verify", "--version", "-v", "-V", "version", undefined].includes(ARGV_TAIL[0])
|
|
44
|
+
|| wantsHelp(ARGV_TAIL[0]) || wantsHelp(ARGV_TAIL[1]);
|
|
42
45
|
if (!OFFLINE_CMD) {
|
|
43
46
|
readEnvFile();
|
|
44
47
|
if (envUnreadable) {
|
|
@@ -109,7 +112,30 @@ async function api(path, init = {}, bearer) {
|
|
|
109
112
|
return { status: res.status, body };
|
|
110
113
|
}
|
|
111
114
|
function writeSecretFile(path, body) {
|
|
112
|
-
|
|
115
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
116
|
+
const fd = openSync(tmp, "w", 0o600);
|
|
117
|
+
try {
|
|
118
|
+
writeSync(fd, body);
|
|
119
|
+
fsyncSync(fd);
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
closeSync(fd);
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
chmodSync(tmp, 0o600);
|
|
126
|
+
}
|
|
127
|
+
catch { }
|
|
128
|
+
hardenFile(tmp);
|
|
129
|
+
try {
|
|
130
|
+
renameSync(tmp, path);
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
try {
|
|
134
|
+
rmSync(tmp, { force: true });
|
|
135
|
+
}
|
|
136
|
+
catch { }
|
|
137
|
+
throw e;
|
|
138
|
+
}
|
|
113
139
|
try {
|
|
114
140
|
chmodSync(path, 0o600);
|
|
115
141
|
}
|
|
@@ -123,14 +149,79 @@ async function revokeFreshMint(jti, bearer, writeError, target) {
|
|
|
123
149
|
err(`✘ could not write ${target} (${writeError?.message ?? writeError}). The token minted a moment ago (jti ${jti}) is LIVE until revoked - revoking it now.`);
|
|
124
150
|
const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, bearer);
|
|
125
151
|
if (rv.status >= 200 && rv.status < 300) {
|
|
152
|
+
journalRetire(jti, "revoked");
|
|
126
153
|
err(` ✔ the token minted a moment ago (jti ${jti}) was REVOKED again, so nothing live was left behind. Fix the cause above and re-run - it will mint a fresh one.`);
|
|
127
154
|
return true;
|
|
128
155
|
}
|
|
156
|
+
let known = false;
|
|
157
|
+
try {
|
|
158
|
+
known = journalOutstanding().some((j) => j.jti === jti);
|
|
159
|
+
}
|
|
160
|
+
catch { }
|
|
161
|
+
if (known)
|
|
162
|
+
err(` Its id is kept in ${JOURNAL_PATH} (owner-only, append-only) until it is revoked - \`quickstart cleanup\` retries it, and \`doctor\` names it.`);
|
|
163
|
+
else
|
|
164
|
+
err(` Its id is NOT recorded anywhere but this terminal (the journal write is what failed) - write it down now and revoke it by hand.`);
|
|
129
165
|
err(` ⚠ the token minted a moment ago (jti ${jti}) is LIVE and could NOT be revoked (${rv.status}). Revoke it by hand, or re-run init - the server keeps one active token per agent, so a fresh mint supersedes it:`);
|
|
130
166
|
err(` npx @decidio/sdk tokens revoke ${jti}`);
|
|
131
167
|
err(` npx @decidio/sdk tokens list --mine`);
|
|
132
168
|
return false;
|
|
133
169
|
}
|
|
170
|
+
const JOURNAL_PATH = ".decidio-token-journal";
|
|
171
|
+
function journalAppend(entry) {
|
|
172
|
+
const fresh = !existsSync(JOURNAL_PATH);
|
|
173
|
+
const fd = openSync(JOURNAL_PATH, "a", 0o600);
|
|
174
|
+
try {
|
|
175
|
+
writeSync(fd, JSON.stringify({ ...entry, at: new Date().toISOString() }) + "\n");
|
|
176
|
+
fsyncSync(fd);
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
closeSync(fd);
|
|
180
|
+
}
|
|
181
|
+
if (fresh) {
|
|
182
|
+
try {
|
|
183
|
+
chmodSync(JOURNAL_PATH, 0o600);
|
|
184
|
+
}
|
|
185
|
+
catch { }
|
|
186
|
+
hardenFile(JOURNAL_PATH);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function journalProbe() {
|
|
190
|
+
try {
|
|
191
|
+
journalAppend({ event: "probe" });
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
return String(e?.message ?? e);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function journalMinted(jti, agentId, label) { journalAppend({ event: "minted", jti, agentId, label, apiUrl: API }); }
|
|
199
|
+
function journalRetire(jti, how) { try {
|
|
200
|
+
journalAppend({ event: "retired", jti, how });
|
|
201
|
+
}
|
|
202
|
+
catch { } }
|
|
203
|
+
function journalOutstanding() {
|
|
204
|
+
if (!existsSync(JOURNAL_PATH))
|
|
205
|
+
return [];
|
|
206
|
+
const minted = new Map();
|
|
207
|
+
for (const line of readFileSync(JOURNAL_PATH, "utf8").split(/\r?\n/)) {
|
|
208
|
+
if (!line.trim())
|
|
209
|
+
continue;
|
|
210
|
+
let e;
|
|
211
|
+
try {
|
|
212
|
+
e = JSON.parse(line);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (e?.event === "minted" && typeof e.jti === "string")
|
|
218
|
+
minted.set(e.jti, { jti: e.jti, agentId: e.agentId, label: e.label, apiUrl: e.apiUrl, at: e.at });
|
|
219
|
+
if (e?.event === "retired" && typeof e.jti === "string")
|
|
220
|
+
minted.delete(e.jti);
|
|
221
|
+
}
|
|
222
|
+
return [...minted.values()];
|
|
223
|
+
}
|
|
224
|
+
const REFUSE_MINT_NO_JOURNAL = (why) => `✘ could not write the token recovery journal (${JOURNAL_PATH}: ${why}) - refusing to mint a credential this directory cannot record a revocation handle for. Fix the directory's permissions or free space and re-run. Nothing was minted.`;
|
|
134
225
|
function upsertEnv(updates) {
|
|
135
226
|
const remaining = { ...updates };
|
|
136
227
|
const updatedKeys = new Set();
|
|
@@ -180,7 +271,7 @@ function assertNotTracked(path) {
|
|
|
180
271
|
return false;
|
|
181
272
|
}
|
|
182
273
|
function ensureGitignore() {
|
|
183
|
-
const entries = [".env", ".env.quickstart", ".env.*.bak", ".decidio-pending/"];
|
|
274
|
+
const entries = [".env", ".env.quickstart", ".env.*.bak", ".env.*.tmp", ".decidio-pending/", ".decidio-token-journal"];
|
|
184
275
|
const existing = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
|
|
185
276
|
const missing = entries.filter((e) => !existing.split(/\r?\n/).some((l) => l.trim() === e || l.trim() === e.replace(/\/$/, "")));
|
|
186
277
|
if (!missing.length)
|
|
@@ -319,16 +410,33 @@ async function cmdInit(agentId, opts = {}) {
|
|
|
319
410
|
let agentToken, workspaceId, tokenJti;
|
|
320
411
|
const mintBearer = session ?? TOKEN();
|
|
321
412
|
if (mintBearer) {
|
|
413
|
+
ensureGitignore();
|
|
414
|
+
const noJournal = journalProbe();
|
|
415
|
+
if (noJournal) {
|
|
416
|
+
err(REFUSE_MINT_NO_JOURNAL(noJournal));
|
|
417
|
+
return 1;
|
|
418
|
+
}
|
|
322
419
|
const expiresInDays = opts.expiresDays ?? 90;
|
|
323
420
|
const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "sdk-cli", expiresInDays }) }, mintBearer);
|
|
324
421
|
if (mint.status >= 200 && mint.status < 300 && mint.body?.token) {
|
|
325
422
|
agentToken = mint.body.token;
|
|
326
423
|
workspaceId = mint.body.workspaceId;
|
|
327
424
|
tokenJti = mint.body.jti;
|
|
425
|
+
if (tokenJti) {
|
|
426
|
+
try {
|
|
427
|
+
journalMinted(tokenJti, agentId, "sdk-cli");
|
|
428
|
+
}
|
|
429
|
+
catch (e) {
|
|
430
|
+
await revokeFreshMint(tokenJti, mintBearer, e, JOURNAL_PATH);
|
|
431
|
+
return 1;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
328
434
|
log(`✔ minted the agent's API token — floor-limited: it can request authorization, poll status and report confirmation; it cannot administer the workspace or approve anything${mint.body.expiresAt ? ` (expires ${mint.body.expiresAt})` : ` (${expiresInDays} days)`}`);
|
|
329
435
|
const superseded = Array.isArray(mint.body.supersededJtis) ? mint.body.supersededJtis : [];
|
|
330
|
-
for (const s of superseded)
|
|
436
|
+
for (const s of superseded) {
|
|
437
|
+
journalRetire(s, "revoked");
|
|
331
438
|
log(` • this replaces the agent's previous token (jti ${s}) — now REVOKED: the server keeps one active token per agent. Anything still using it will 401.`);
|
|
439
|
+
}
|
|
332
440
|
}
|
|
333
441
|
else if (session) {
|
|
334
442
|
err(`✘ could not mint the agent's API token (${mint.status}): ${JSON.stringify(mint.body)}`);
|
|
@@ -356,6 +464,8 @@ async function cmdInit(agentId, opts = {}) {
|
|
|
356
464
|
try {
|
|
357
465
|
prepareEnvForWrite();
|
|
358
466
|
upsertEnv(envOut);
|
|
467
|
+
if (tokenJti)
|
|
468
|
+
journalRetire(tokenJti, "persisted");
|
|
359
469
|
}
|
|
360
470
|
catch (e) {
|
|
361
471
|
if (agentToken && tokenJti)
|
|
@@ -404,6 +514,17 @@ async function cmdDoctor() {
|
|
|
404
514
|
let warned = 0;
|
|
405
515
|
const line = (good, label) => { log(`${good ? "✔" : "✘"} ${label}`); ok = ok && good; };
|
|
406
516
|
const warn = (label) => { warned++; log(`⚠ ${label}`); };
|
|
517
|
+
let journalRows = [];
|
|
518
|
+
try {
|
|
519
|
+
journalRows = journalOutstanding();
|
|
520
|
+
}
|
|
521
|
+
catch (e) {
|
|
522
|
+
warn(`token recovery journal (${JOURNAL_PATH}) could not be read (${e?.message ?? e}) — it may hold live token ids; fix its permissions and re-run doctor.`);
|
|
523
|
+
}
|
|
524
|
+
for (const j of journalRows) {
|
|
525
|
+
const where = j.apiUrl && j.apiUrl !== API ? `DECIDIO_API_URL=${j.apiUrl} ` : "";
|
|
526
|
+
warn(`token recovery journal: jti ${j.jti} (${j.label ?? "token"}, agent ${j.agentId ?? "?"}, minted ${j.at ?? "?"} against ${j.apiUrl ?? API}) has no recorded revocation in this directory's journal — it may still be live. Revoke it, or confirm it is gone: ${where}npx @decidio/sdk quickstart cleanup (or: ${where}npx @decidio/sdk tokens revoke ${j.jti})`);
|
|
527
|
+
}
|
|
407
528
|
log(`✔ API URL: ${API}${(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL) ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
|
|
408
529
|
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>)"}`);
|
|
409
530
|
const secret = process.env.DECIDIO_WEBHOOK_SECRET || env.DECIDIO_WEBHOOK_SECRET;
|
|
@@ -422,7 +543,7 @@ async function cmdDoctor() {
|
|
|
422
543
|
else if (derived !== storedDid)
|
|
423
544
|
line(false, `signing key: DOES NOT MATCH this agent's DID — the key here derives ${derived}, but .env registers ${storedDid}. Signed requests will not be recognised as this agent (auto-approve and application_confirmed evidence will both fail). Restore the original key, or re-run \`npx @decidio/sdk init\` to register a fresh one.`);
|
|
424
545
|
else
|
|
425
|
-
log("✔ signing key: set and MATCHES the registered DID — the agent can prove its identity, making it ELIGIBLE for policy auto-approve. Whether a rule matches is your workspace policy's decision; with no matching rule every request routes to a human (
|
|
546
|
+
log("✔ signing key: set and MATCHES the registered DID — the agent can prove its identity, making it ELIGIBLE for policy auto-approve. Whether a rule matches is your workspace policy's decision; with no matching rule every request routes to a human (routing-by-default - a block is different: a named rule, or the universal backstop when no active policy governs agents at all).");
|
|
426
547
|
}
|
|
427
548
|
else
|
|
428
549
|
warn("signing key: MISSING (DECIDIO_AGENT_KEY/DID) — the agent can't prove identity, so EVERY request routes to a human. Run `npx @decidio/sdk init` to generate one.");
|
|
@@ -729,11 +850,35 @@ async function cmdQuickstart(opts = {}) {
|
|
|
729
850
|
err(` Nothing was written to this directory.`);
|
|
730
851
|
return 1;
|
|
731
852
|
}
|
|
853
|
+
let prevTourFile = null;
|
|
854
|
+
if (envFile !== ENV_PATH && existsSync(envFile)) {
|
|
855
|
+
try {
|
|
856
|
+
prevTourFile = readFileSync(envFile, "utf8");
|
|
857
|
+
}
|
|
858
|
+
catch (e) {
|
|
859
|
+
err(`✘ ${envFile} exists but could not be read (${e?.message ?? e}) - a previous tour's credentials may be in it, and this tour would have to replace it. Refusing to mint a new token on top of a file this command cannot read: fix its permissions or move it aside, then re-run. Nothing was minted.`);
|
|
860
|
+
return 1;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
const noJournal = journalProbe();
|
|
864
|
+
if (noJournal) {
|
|
865
|
+
err(REFUSE_MINT_NO_JOURNAL(noJournal));
|
|
866
|
+
return 1;
|
|
867
|
+
}
|
|
732
868
|
const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "quickstart", expiresInDays: 7 }) }, session);
|
|
733
869
|
if (!(mint.status >= 200 && mint.status < 300) || !mint.body?.token) {
|
|
734
870
|
err(`✘ token mint failed (${mint.status}): ${JSON.stringify(mint.body)}`);
|
|
735
871
|
return 1;
|
|
736
872
|
}
|
|
873
|
+
if (mint.body.jti) {
|
|
874
|
+
try {
|
|
875
|
+
journalMinted(mint.body.jti, agentId, "quickstart");
|
|
876
|
+
}
|
|
877
|
+
catch (e) {
|
|
878
|
+
await revokeFreshMint(mint.body.jti, session, e, JOURNAL_PATH);
|
|
879
|
+
return 1;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
737
882
|
const envOut = {
|
|
738
883
|
DECIDIO_API_URL: API, DECIDIO_AGENT_ID: agentId, DECIDIO_AGENT_DID: reg.body?.did ?? did,
|
|
739
884
|
DECIDIO_AGENT_KEY: privateKeyBase64, DECIDIO_API_TOKEN: mint.body.token,
|
|
@@ -741,8 +886,8 @@ async function cmdQuickstart(opts = {}) {
|
|
|
741
886
|
...(mint.body.jti ? { DECIDIO_TOKEN_JTI: mint.body.jti } : {}),
|
|
742
887
|
};
|
|
743
888
|
if (envFile !== ENV_PATH) {
|
|
744
|
-
if (
|
|
745
|
-
const prev =
|
|
889
|
+
if (prevTourFile !== null) {
|
|
890
|
+
const prev = prevTourFile;
|
|
746
891
|
const prevId = prev.match(/^DECIDIO_AGENT_ID=(.+)$/m)?.[1];
|
|
747
892
|
const prevJti = prev.match(/^DECIDIO_TOKEN_JTI=(.+)$/m)?.[1];
|
|
748
893
|
const carried = new Set((prev.match(/^DECIDIO_ORPHANED_TOKEN_JTI=(.+)$/m)?.[1] ?? "").split(",").map((s) => s.trim()).filter(Boolean));
|
|
@@ -767,6 +912,8 @@ async function cmdQuickstart(opts = {}) {
|
|
|
767
912
|
}
|
|
768
913
|
try {
|
|
769
914
|
writeSecretFile(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
|
|
915
|
+
if (mint.body.jti)
|
|
916
|
+
journalRetire(mint.body.jti, "persisted");
|
|
770
917
|
}
|
|
771
918
|
catch (e) {
|
|
772
919
|
if (mint.body?.jti)
|
|
@@ -780,6 +927,8 @@ async function cmdQuickstart(opts = {}) {
|
|
|
780
927
|
else {
|
|
781
928
|
try {
|
|
782
929
|
upsertEnv(envOut);
|
|
930
|
+
if (mint.body.jti)
|
|
931
|
+
journalRetire(mint.body.jti, "persisted");
|
|
783
932
|
}
|
|
784
933
|
catch (e) {
|
|
785
934
|
if (mint.body?.jti)
|
|
@@ -832,7 +981,9 @@ async function cmdQuickstart(opts = {}) {
|
|
|
832
981
|
return failTour(1);
|
|
833
982
|
}
|
|
834
983
|
log(` ✔ $86,000 payInvoice ROUTED to a human (decision ${decisionId}) — this agent has no auto-approve rule,`);
|
|
835
|
-
log(` so
|
|
984
|
+
log(` so the policy ROUTES it to a person (routing-by-default). That is distinct from a BLOCK, which no human can`);
|
|
985
|
+
log(` override: a block is a named rule, or the universal backstop when no active policy governs agents at all.`);
|
|
986
|
+
log(` Nothing executes until someone decides.`);
|
|
836
987
|
log("");
|
|
837
988
|
const interactive = process.stdin.isTTY;
|
|
838
989
|
if (interactive) {
|
|
@@ -913,7 +1064,10 @@ async function cmdQuickstart(opts = {}) {
|
|
|
913
1064
|
log(` const doIt = guard.protect(doItRaw, (a) => ({ action: "yourAction", amount: a.amount, scope: "YourScope" }));`);
|
|
914
1065
|
log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
|
|
915
1066
|
log("");
|
|
916
|
-
log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")}
|
|
1067
|
+
log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")}`);
|
|
1068
|
+
log(`Next: the README section "Production mode: durable async resume" (the worker; the engine adapters follow it).`);
|
|
1069
|
+
log(`The README's seven-step quickstart is this same blocking flow in your own code - if you keep this agent, its .env`);
|
|
1070
|
+
log(`already works here, so skip that flow's init step.`);
|
|
917
1071
|
log(`Built by OmniTwin — decidioai.com · Putting this in front of real approvals? decidioai.com/#early-access`);
|
|
918
1072
|
let doCleanup = false;
|
|
919
1073
|
if (interactive) {
|
|
@@ -932,14 +1086,47 @@ async function cmdQuickstart(opts = {}) {
|
|
|
932
1086
|
async function quickstartCleanup(session, envFile) {
|
|
933
1087
|
const env = (() => {
|
|
934
1088
|
const out = {};
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
const
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1089
|
+
try {
|
|
1090
|
+
if (existsSync(envFile))
|
|
1091
|
+
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
|
1092
|
+
const m = line.match(/^\s*([A-Z_]+)=(.*)$/);
|
|
1093
|
+
if (m)
|
|
1094
|
+
out[m[1]] = m[2];
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
catch (e) {
|
|
1098
|
+
err(` ⚠ ${envFile} could not be read (${e?.message ?? e}) - sweeping the token recovery journal instead.`);
|
|
1099
|
+
}
|
|
941
1100
|
return out;
|
|
942
1101
|
})();
|
|
1102
|
+
let journalRevoked = 0;
|
|
1103
|
+
const journalStuck = [];
|
|
1104
|
+
for (const j of journalOutstanding()) {
|
|
1105
|
+
if (j.apiUrl && j.apiUrl !== API) {
|
|
1106
|
+
journalStuck.push(j.jti);
|
|
1107
|
+
err(` ⚠ journalled token ${j.jti} was minted against ${j.apiUrl}, not ${API} — revoke it there: DECIDIO_API_URL=${j.apiUrl} npx @decidio/sdk tokens revoke ${j.jti}`);
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(j.jti)}`, { method: "DELETE" }, session);
|
|
1111
|
+
if (rv.status >= 200 && rv.status < 300) {
|
|
1112
|
+
journalRetire(j.jti, "revoked");
|
|
1113
|
+
journalRevoked++;
|
|
1114
|
+
if (rv.body?.changed === false)
|
|
1115
|
+
log(` • journalled token ${j.jti} was already revoked — retiring its journal entry.`);
|
|
1116
|
+
else
|
|
1117
|
+
log(` ✔ journalled token revoked (jti ${j.jti}${j.label ? `, ${j.label}` : ""}) — its credential file never landed, so this was its only handle`);
|
|
1118
|
+
}
|
|
1119
|
+
else {
|
|
1120
|
+
journalStuck.push(j.jti);
|
|
1121
|
+
err(` ⚠ journalled token ${j.jti} could NOT be revoked (${rv.status}) — it is still live; its id stays in ${JOURNAL_PATH}.`);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
if (journalStuck.length) {
|
|
1125
|
+
err(`✘ ${journalStuck.length} journalled token(s) are STILL LIVE and could not be revoked. Nothing was deleted; retry, or revoke by hand:`);
|
|
1126
|
+
for (const jti of journalStuck)
|
|
1127
|
+
err(` npx @decidio/sdk tokens revoke ${jti}`);
|
|
1128
|
+
return 1;
|
|
1129
|
+
}
|
|
943
1130
|
const orphans = (env.DECIDIO_ORPHANED_TOKEN_JTI ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
944
1131
|
const stillOrphaned = [];
|
|
945
1132
|
for (const jti of orphans) {
|
|
@@ -970,6 +1157,9 @@ async function quickstartCleanup(session, envFile) {
|
|
|
970
1157
|
return 1;
|
|
971
1158
|
}
|
|
972
1159
|
}
|
|
1160
|
+
else if (journalRevoked > 0 && !existsSync(envFile) || (journalRevoked > 0 && Object.keys(env).length === 0)) {
|
|
1161
|
+
log(` • no credential file to clean: the journal sweep revoked ${journalRevoked} token(s) this directory owed.`);
|
|
1162
|
+
}
|
|
973
1163
|
else {
|
|
974
1164
|
err(`✘ no DECIDIO_TOKEN_JTI in ${envFile} (older tour) — this tour's token is STILL LIVE and this`);
|
|
975
1165
|
err(` command cannot revoke it. Nothing was deleted. Find and revoke it, then re-run cleanup:`);
|
|
@@ -978,9 +1168,18 @@ async function quickstartCleanup(session, envFile) {
|
|
|
978
1168
|
err(` (it also expires on its own within 7 days)`);
|
|
979
1169
|
return 1;
|
|
980
1170
|
}
|
|
1171
|
+
let envRaw = null;
|
|
981
1172
|
if (existsSync(envFile)) {
|
|
1173
|
+
try {
|
|
1174
|
+
envRaw = readFileSync(envFile, "utf8");
|
|
1175
|
+
}
|
|
1176
|
+
catch (e) {
|
|
1177
|
+
err(` ⚠ ${envFile} could not be read (${e?.message ?? e}) — left as it is; remove it by hand once it is readable.`);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
if (envRaw !== null) {
|
|
982
1181
|
const TOUR_KEYS = new Set(["DECIDIO_API_URL", "DECIDIO_AGENT_ID", "DECIDIO_AGENT_DID", "DECIDIO_AGENT_KEY", "DECIDIO_API_TOKEN", "DECIDIO_WORKSPACE_ID", "DECIDIO_TOKEN_JTI", "DECIDIO_ORPHANED_TOKEN_JTI"]);
|
|
983
|
-
const kept =
|
|
1182
|
+
const kept = envRaw.split(/\r?\n/).filter((line) => {
|
|
984
1183
|
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=/);
|
|
985
1184
|
return m ? !TOUR_KEYS.has(m[1]) : line.trim() !== "";
|
|
986
1185
|
});
|
|
@@ -1004,6 +1203,12 @@ async function quickstartCleanup(session, envFile) {
|
|
|
1004
1203
|
}
|
|
1005
1204
|
log(` • What remains, by design: the sealed decision record (records are permanent — that is the product)`);
|
|
1006
1205
|
log(` and the agent's registration row (inert without key or token; server-side deregistration is on the roadmap).`);
|
|
1206
|
+
if (existsSync(".decidio-pending")) {
|
|
1207
|
+
log(` • Also kept: .decidio-pending/ — this agent's own execution ledger: whatever this agent parked (call arguments,`);
|
|
1208
|
+
log(` execution evidence; never credentials, by design). Gitignored; owner-only where the OS supports it. If nothing is`);
|
|
1209
|
+
log(` pending and you do not want the history, remove it:`);
|
|
1210
|
+
log(` ${process.platform === "win32" ? "Remove-Item -Recurse .decidio-pending" : "rm -r .decidio-pending"}`);
|
|
1211
|
+
}
|
|
1007
1212
|
return 0;
|
|
1008
1213
|
}
|
|
1009
1214
|
async function cmdTokens(sub, jti, mineOnly = false) {
|
|
@@ -1036,10 +1241,12 @@ async function cmdTokens(sub, jti, mineOnly = false) {
|
|
|
1036
1241
|
const rev = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, bearer);
|
|
1037
1242
|
if (rev.status >= 200 && rev.status < 300) {
|
|
1038
1243
|
if (rev.body?.changed === false) {
|
|
1244
|
+
journalRetire(jti, "revoked");
|
|
1039
1245
|
log(`• ${jti} was already revoked — nothing changed.`);
|
|
1040
1246
|
log(` Revocation is permanent; there is no re-revoke. See it: npx @decidio/sdk tokens list`);
|
|
1041
1247
|
return 1;
|
|
1042
1248
|
}
|
|
1249
|
+
journalRetire(jti, "revoked");
|
|
1043
1250
|
log(`✔ revoked ${jti} — the token is dead now (its agent stays registered; mint a new one with init).`);
|
|
1044
1251
|
return 0;
|
|
1045
1252
|
}
|
|
@@ -1151,7 +1358,6 @@ Docs: the README that shipped with this package — node_modules/@decidio/sdk/RE
|
|
|
1151
1358
|
(quickstart, durable resume, workers, adapters).`;
|
|
1152
1359
|
async function main(argv) {
|
|
1153
1360
|
const [cmd, a, b, ...rest] = argv.slice(2);
|
|
1154
|
-
const wantsHelp = (s) => s === "help" || s === "--help" || s === "-h";
|
|
1155
1361
|
if (cmd === "--version" || cmd === "-v" || cmd === "-V" || cmd === "version") {
|
|
1156
1362
|
log(VERSION);
|
|
1157
1363
|
return 0;
|
|
@@ -1230,9 +1436,10 @@ async function main(argv) {
|
|
|
1230
1436
|
case "quickstart": {
|
|
1231
1437
|
if (argsIn.includes("cleanup")) {
|
|
1232
1438
|
const envFile = existsSync(".env.quickstart") ? ".env.quickstart"
|
|
1233
|
-
: (readEnvFile().DECIDIO_AGENT_ID ?? "").startsWith("quickstart-") ? ENV_PATH
|
|
1439
|
+
: (readEnvFile().DECIDIO_AGENT_ID ?? "").startsWith("quickstart-") ? ENV_PATH
|
|
1440
|
+
: journalOutstanding().length ? ".env.quickstart" : undefined;
|
|
1234
1441
|
if (!envFile) {
|
|
1235
|
-
log("Nothing to clean up here: no .env.quickstart,
|
|
1442
|
+
log("Nothing to clean up here: no .env.quickstart, .env doesn't belong to a quickstart agent, and the token recovery journal owes nothing.");
|
|
1236
1443
|
return 0;
|
|
1237
1444
|
}
|
|
1238
1445
|
const session = await acquireSession("Cleaning the quickstart up");
|
package/dist/errors.js
CHANGED
|
@@ -26,7 +26,11 @@ export class DecidioHttpError extends Error {
|
|
|
26
26
|
(credential
|
|
27
27
|
? ` — the credential was rejected. The agent token may have expired or been revoked (re-running \`init\` for the same agent revokes the previous one). Mint a fresh one: npx @decidio/sdk init <agent-name>, then restart this process.`
|
|
28
28
|
: retryable
|
|
29
|
-
?
|
|
29
|
+
? (/\/agent\/confirm/.test(endpoint)
|
|
30
|
+
? ` — this looks transient. Your action ALREADY RAN; do NOT re-run it — only its report failed to land, and the door's own note says whether a report was kept to re-send.`
|
|
31
|
+
: /\/agent\/status/.test(endpoint)
|
|
32
|
+
? ` — this looks transient; polling again is safe (a status read changes nothing).`
|
|
33
|
+
: ` — this looks transient; retrying is safe (a retry creates a NEW request and can never double-execute).`)
|
|
30
34
|
: ` — the server refused the request itself, so retrying it unchanged will fail the same way. Fix the request (or the decision it names) first.`) +
|
|
31
35
|
sideEffectNote(endpoint, typeof body?.code === "string" ? body.code : undefined));
|
|
32
36
|
this.status = status;
|
package/dist/index.d.ts
CHANGED
|
@@ -103,6 +103,11 @@ export declare class DecidioNetworkError extends Error {
|
|
|
103
103
|
code: string | undefined;
|
|
104
104
|
constructor(endpoint: string, op: string, code: string | undefined, cause: unknown);
|
|
105
105
|
}
|
|
106
|
+
/** THE ONE DESCRIBE VALIDATION (prodsec V033-05). The core wrapper refused a blank `action` and a
|
|
107
|
+
* negative / non-finite `amount` before routing; the adapter gate canonicalized and posted them, so
|
|
108
|
+
* every engine door reached the mock authorization path with a request the core would have refused.
|
|
109
|
+
* Exported so `adapters/_gate.ts` calls the same function rather than a second copy that can drift. */
|
|
110
|
+
export declare function assertDescribed(ctx: ApprovalContext): void;
|
|
106
111
|
/**
|
|
107
112
|
* Wrap an async action behind the Decidio gate. `describe` extracts the approval
|
|
108
113
|
* context (action, amount, scope) from the call arguments. Returns a function with
|
package/dist/index.js
CHANGED
|
@@ -78,6 +78,14 @@ async function reach(f, url, op, init) {
|
|
|
78
78
|
throw new DecidioNetworkError(url, op, e?.cause?.code ?? e?.code, e);
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
+
export function assertDescribed(ctx) {
|
|
82
|
+
if (typeof ctx?.action !== "string" || ctx.action.trim().length === 0) {
|
|
83
|
+
throw new Error(`[decidio] describe(...) must return an \`action\` string naming what is being asked (got ${JSON.stringify(ctx?.action ?? null)}). Nothing was routed or executed.`);
|
|
84
|
+
}
|
|
85
|
+
if (typeof ctx.amount !== "number" || !Number.isFinite(ctx.amount) || ctx.amount < 0) {
|
|
86
|
+
throw new Error(`[decidio] describe(...) must return a finite, non-negative \`amount\` — the policy decides on it, so a request without one is not a request Decidio can rule on (got ${JSON.stringify(ctx.amount ?? null)}). Use 0 for an action with no monetary value. Nothing was routed or executed.`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
81
89
|
async function post(cfg, path, body) {
|
|
82
90
|
const f = cfg.fetchImpl ?? fetch;
|
|
83
91
|
assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
|
|
@@ -142,12 +150,7 @@ export function withApproval(fn, describe, config, opts) {
|
|
|
142
150
|
const requesterIdentity = (config.agentKey && config.agentDid)
|
|
143
151
|
? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
|
|
144
152
|
: undefined;
|
|
145
|
-
|
|
146
|
-
throw new Error(`[decidio] describe(...) must return an \`action\` string naming what is being asked (got ${JSON.stringify(ctx?.action ?? null)}). Nothing was routed or executed.`);
|
|
147
|
-
}
|
|
148
|
-
if (typeof ctx.amount !== "number" || !Number.isFinite(ctx.amount) || ctx.amount < 0) {
|
|
149
|
-
throw new Error(`[decidio] describe(...) must return a finite, non-negative \`amount\` — the policy decides on it, so a request without one is not a request Decidio can rule on (got ${JSON.stringify(ctx.amount ?? null)}). Use 0 for an action with no monetary value. Nothing was routed or executed.`);
|
|
150
|
-
}
|
|
153
|
+
assertDescribed(ctx);
|
|
151
154
|
const auth = await post(config, "/agent/authorize", {
|
|
152
155
|
requester: config.agentId, action: ctx.action, amount: ctx.amount,
|
|
153
156
|
scope: ctx.scope, sourceSystem: config.sourceSystem,
|
package/dist/inngest.js
CHANGED
|
@@ -28,6 +28,8 @@ export async function decidioGateStep(args) {
|
|
|
28
28
|
export function createInngestResumeBridge(opts) {
|
|
29
29
|
const secret = opts.webhookSecret ?? "";
|
|
30
30
|
const eventName = opts.eventName ?? DECIDIO_RESUME_EVENT;
|
|
31
|
+
if (!secret && opts.allowUnsigned)
|
|
32
|
+
console.warn("[decidio] inngest resume bridge: allowUnsigned=true and no webhookSecret — resume signals are NOT authenticated; anyone who can reach this endpoint can emit a resume event. Development only.");
|
|
31
33
|
return (req, res) => {
|
|
32
34
|
if (req.method !== "POST") {
|
|
33
35
|
res.statusCode = 405;
|
package/dist/resume.d.ts
CHANGED
|
@@ -450,7 +450,10 @@ export declare class DecidioSuspendedError extends Error {
|
|
|
450
450
|
/** `indeterminate` (0.2.0): the handler was INVOKED and then threw. Whether the downstream
|
|
451
451
|
* effect committed is not knowable from here, so the action is never retried automatically —
|
|
452
452
|
* it waits for reconcile(). Callers that switch exhaustively on this union must handle it. */
|
|
453
|
-
|
|
453
|
+
/** `already_resolved` (0.3.5): this resume signal LOST the transition race to an earlier, final answer
|
|
454
|
+
* (a denial, or an execution that already ran) — `reason` names which. It used to read `unknown`,
|
|
455
|
+
* which is the word for "not ours"; safety was right and the diagnosis was not (prodsec V033-06). */
|
|
456
|
+
export type ResumeStatus = "executed" | "rejected" | "blocked" | "no_handler" | "unknown" | "indeterminate" | "already_resolved";
|
|
454
457
|
/** The states a durable action can actually be in — every one of them nameable, including the
|
|
455
458
|
* one nobody likes. */
|
|
456
459
|
export type RecoveryState =
|
|
@@ -472,7 +475,9 @@ export type RecoveryState =
|
|
|
472
475
|
* and must never be retried automatically. */
|
|
473
476
|
| "indeterminate"
|
|
474
477
|
/** Executed, result captured, report not yet accepted by Decidio. Safe to `reconfirm`. */
|
|
475
|
-
| "executed_unreported"
|
|
478
|
+
| "executed_unreported"
|
|
479
|
+
/** a parked row whose payload is gone: cannot execute, not awaiting anyone (0.3.5) */
|
|
480
|
+
| "orphaned_state";
|
|
476
481
|
export interface RecoveryItem {
|
|
477
482
|
decisionId: string;
|
|
478
483
|
action?: string;
|
package/dist/resume.js
CHANGED
|
@@ -1037,7 +1037,7 @@ export function createDecidioResume(opts) {
|
|
|
1037
1037
|
refuseOnce(decisionId, claim.state === "discarded"
|
|
1038
1038
|
? `[decidio] REFUSING to execute ${decisionId}: it was DENIED (${(await ctrl.store.getExecution(decisionId))?.verdict ?? "rejected"}) and that answer is final. Nothing ran. A denial cannot be reconciled or retried — if this action should now proceed, it needs a NEW request and a new approval.`
|
|
1039
1039
|
: `[decidio] REFUSING to execute ${decisionId}: its durable state is ${claim.state} — invocation already began, so re-running it could duplicate a committed effect. Inspect it with guard.resume.recover(); settle it with guard.resume.reconcile("${decisionId}", …).`, claim.state === "discarded" ? " (This prints once. The decision is closed — no action is needed.)" : undefined);
|
|
1040
|
-
return finish({ status: claim.state === "indeterminate" ? "indeterminate" : "
|
|
1040
|
+
return finish({ status: claim.state === "indeterminate" ? "indeterminate" : "already_resolved", decisionId, action: pending.action, reason: claim.state === "discarded" ? `this decision was already DENIED (${(await ctrl.store.getExecution(decisionId))?.verdict ?? "rejected"}); that answer is final and this signal lost the race to it` : `this decision already ran (durable state ${claim.state}); this signal lost the race to it` });
|
|
1041
1041
|
}
|
|
1042
1042
|
return { status: "unknown", decisionId };
|
|
1043
1043
|
}
|
|
@@ -1269,7 +1269,12 @@ export function createDecidioResume(opts) {
|
|
|
1269
1269
|
guidance: `"${a}" was claimed at ${r.at} by attempt ${r.attemptId ?? "(unknown)"} which never reached the invocation boundary — the handler provably did not run, and nothing will retry it on its own. Release it: guard.resume.reconcile("${r.decisionId}", { outcome: "not_committed", reason: "claimed but never invoked" }).` });
|
|
1270
1270
|
break;
|
|
1271
1271
|
case "parked":
|
|
1272
|
-
|
|
1272
|
+
if (!(await ctrl.store.get(r.decisionId))) {
|
|
1273
|
+
items.push({ decisionId: r.decisionId, action: r.action, state: "orphaned_state", guidance: `"${a}" is recorded as parked but its payload is gone, so nothing can execute it — an orphaned row (usually a legacy denial whose state was never written), not work awaiting a human. Nothing will run. It needs no action; remove its revision files if you want the listing clean.` });
|
|
1274
|
+
}
|
|
1275
|
+
else {
|
|
1276
|
+
items.push({ decisionId: r.decisionId, action: r.action, state: "parked", guidance: `"${a}" is parked awaiting a human decision. Nothing has executed.` });
|
|
1277
|
+
}
|
|
1273
1278
|
break;
|
|
1274
1279
|
case "discarded":
|
|
1275
1280
|
if (r.executionDespiteDenial) {
|
package/dist/signing.d.ts
CHANGED
|
@@ -74,6 +74,10 @@ export declare function assertJsonSafe(value: unknown, path?: string): void;
|
|
|
74
74
|
* rule is about the WIRE, not which credential (the interactive sign-in enforced this for
|
|
75
75
|
* session tokens while agent-token requests sailed through). localhost/127.0.0.1/[::1] stay
|
|
76
76
|
* allowed for local dev; https always allowed. Throws before anything is sent. */
|
|
77
|
+
/** The URL as it may be ECHOED in a refusal: userinfo masked. The SDK treats URL userinfo as a credential
|
|
78
|
+
* (it is refused as one), so no refusal may print it - every branch of assertSafeTransport echoes through
|
|
79
|
+
* this, not the raw string (invariant-guard, 0.3.5). */
|
|
80
|
+
export declare function redactUrl(u: string): string;
|
|
77
81
|
export declare function assertSafeTransport(url: string, hasCredential: boolean): void;
|
|
78
82
|
/** The ONE answer to "does this request carry a credential worth protecting?" (A6, 0.1.12).
|
|
79
83
|
* A signed identity proof is a credential too: sending it over remote plaintext disclosed
|
package/dist/signing.js
CHANGED
|
@@ -106,7 +106,17 @@ export function assertJsonSafe(value, path = "args") {
|
|
|
106
106
|
};
|
|
107
107
|
walk(value, path);
|
|
108
108
|
}
|
|
109
|
+
export function redactUrl(u) { return u.replace(/\/\/[^/@]*@/, "//***@"); }
|
|
109
110
|
export function assertSafeTransport(url, hasCredential) {
|
|
111
|
+
if (typeof url !== "string") {
|
|
112
|
+
throw new Error(`[decidio] the gate URL is ${url === null ? "null" : typeof url}, not a string, so there is no host to send to. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
|
|
113
|
+
}
|
|
114
|
+
for (const ch of url) {
|
|
115
|
+
const cp = ch.codePointAt(0);
|
|
116
|
+
if (cp <= 0x20 || (cp >= 0x7f && cp <= 0x9f) || cp === 0xfeff) {
|
|
117
|
+
throw new Error(`[decidio] the gate URL ${JSON.stringify(redactUrl(url))} contains whitespace or a control character (U+${cp.toString(16).toUpperCase().padStart(4, "0")}) - a validator and a transport can read such a spelling differently, so it is refused as written. Remove it. Nothing was sent.`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
110
120
|
let u;
|
|
111
121
|
try {
|
|
112
122
|
u = new URL(url);
|
|
@@ -114,9 +124,9 @@ export function assertSafeTransport(url, hasCredential) {
|
|
|
114
124
|
catch {
|
|
115
125
|
const spelled = url.replace(/[\t\r\n]/g, "").replace(/^[\s\u0000-\u001f\u0085\ufeff]+/, "");
|
|
116
126
|
if (/^http:/i.test(spelled)) {
|
|
117
|
-
throw new Error(`[decidio] refusing to ${hasCredential ? "send credentials" : "send a request"} over plaintext http to ${JSON.stringify(url)} — that is not a complete absolute URL, so where the request would actually connect is undefined (the path is appended to it, which can turn it into a remote host). Set a full https URL, or http://localhost for local dev. Nothing was sent.`);
|
|
127
|
+
throw new Error(`[decidio] refusing to ${hasCredential ? "send credentials" : "send a request"} over plaintext http to ${JSON.stringify(redactUrl(url))} — that is not a complete absolute URL, so where the request would actually connect is undefined (the path is appended to it, which can turn it into a remote host). Set a full https URL, or http://localhost for local dev. Nothing was sent.`);
|
|
118
128
|
}
|
|
119
|
-
throw new Error(`[decidio] the gate URL ${JSON.stringify(url)} is not an absolute URL, so there is no host to send to — where a request would actually connect depends entirely on whichever transport resolves it. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
|
|
129
|
+
throw new Error(`[decidio] the gate URL ${JSON.stringify(redactUrl(url))} is not an absolute URL, so there is no host to send to — where a request would actually connect depends entirely on whichever transport resolves it. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
|
|
120
130
|
}
|
|
121
131
|
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
|
122
132
|
throw new Error(`[decidio] the gate URL uses the ${u.protocol.replace(":", "")} scheme, which is not a Decidio endpoint — use https (or http://localhost for local dev). Nothing was sent.`);
|
|
@@ -125,14 +135,14 @@ export function assertSafeTransport(url, hasCredential) {
|
|
|
125
135
|
throw new Error(`[decidio] the gate URL carries credentials in the URL itself (${u.protocol}//user:***@${u.host}). Decidio authenticates with a bearer token; remove the user:password from the URL. Nothing was sent.`);
|
|
126
136
|
}
|
|
127
137
|
if (u.hostname === "") {
|
|
128
|
-
throw new Error(`[decidio] the gate URL ${JSON.stringify(url)} has no host — there is nowhere for the request to go. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
|
|
138
|
+
throw new Error(`[decidio] the gate URL ${JSON.stringify(redactUrl(url))} has no host — there is nowhere for the request to go. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
|
|
129
139
|
}
|
|
130
140
|
{
|
|
131
141
|
const spelled = url.replace(/[\t\r\n]/g, "").replace(/^[\s\u0000-\u001f\u0085\ufeff]+/, "").toLowerCase();
|
|
132
142
|
const origin = `${u.protocol}//${u.host}`.toLowerCase();
|
|
133
143
|
const next = spelled.charAt(origin.length);
|
|
134
144
|
if (!spelled.startsWith(origin) || (next !== "" && next !== "/" && next !== "?" && next !== "#" && next !== ":")) {
|
|
135
|
-
throw new Error(`[decidio] the gate URL ${JSON.stringify(url)} is not written in canonical form — this parser reads it as ${origin}, but the raw spelling is what a transport receives, and a parser that normalizes differently can read it as another host entirely. Write it as ${origin}. Nothing was sent.`);
|
|
145
|
+
throw new Error(`[decidio] the gate URL ${JSON.stringify(redactUrl(url))} is not written in canonical form — this parser reads it as ${origin}, but the raw spelling is what a transport receives, and a parser that normalizes differently can read it as another host entirely. Write it as ${origin}. Nothing was sent.`);
|
|
136
146
|
}
|
|
137
147
|
}
|
|
138
148
|
if (u.protocol !== "http:")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decidio/sdk",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
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",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"prepublishOnly": "npm run typecheck && npm run build && node ../publish-boundary/check.mjs --only @decidio/sdk"
|
|
83
83
|
},
|
|
84
84
|
"dependencies": {
|
|
85
|
-
"@decidio/verify": "0.3.
|
|
85
|
+
"@decidio/verify": "0.3.5"
|
|
86
86
|
},
|
|
87
87
|
"author": "OmniTwin Technologies Inc.",
|
|
88
88
|
"bugs": {
|