@decidio/sdk 0.1.9 → 0.1.10
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 +9 -4
- package/dist/adapters/_gate.js +9 -20
- package/dist/adapters/langgraph.js +0 -14
- package/dist/adapters/openai.js +0 -4
- package/dist/adapters/temporal.js +1 -17
- package/dist/cli.js +32 -254
- package/dist/guard.d.ts +9 -7
- package/dist/guard.js +12 -26
- package/dist/index.d.ts +7 -9
- package/dist/index.js +17 -98
- package/dist/inngest.js +0 -26
- package/dist/resume.d.ts +11 -12
- package/dist/resume.js +31 -118
- package/dist/signing.d.ts +16 -6
- package/dist/signing.js +23 -40
- package/openapi.yaml +1 -1
- package/package.json +3 -9
package/dist/guard.d.ts
CHANGED
|
@@ -20,9 +20,9 @@ export interface GuardFacadeConfig {
|
|
|
20
20
|
fetchImpl?: typeof fetch;
|
|
21
21
|
}
|
|
22
22
|
export interface ProtectOptions {
|
|
23
|
-
/** null/"generic" → the core gate; engine adapters (langgraph/openai/temporal/inngest
|
|
24
|
-
* live in @decidio/sdk
|
|
25
|
-
adapter?: "generic" | "langgraph" | "openai" | "temporal" | "inngest"
|
|
23
|
+
/** null/"generic" → the core gate; engine adapters (langgraph/openai/temporal/inngest)
|
|
24
|
+
* live in @decidio/sdk subpaths and take the engine handle. */
|
|
25
|
+
adapter?: "generic" | "langgraph" | "openai" | "temporal" | "inngest";
|
|
26
26
|
/** "durable" (default) suspends for async approval; "blocking" polls (short approvals only). */
|
|
27
27
|
mode?: "durable" | "blocking";
|
|
28
28
|
/** The action name this wrapper produces, declared UP FRONT so the resume registry is populated
|
|
@@ -31,10 +31,9 @@ export interface ProtectOptions {
|
|
|
31
31
|
* `describe` (which names the action) cannot run without the original call arguments. Declare
|
|
32
32
|
* it and a restarted process resumes work it never originated. */
|
|
33
33
|
action?: string;
|
|
34
|
-
/** Blocking-mode poll ceiling in ms (default 600000 = 10 min).
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* fast — and the Python twin rejected the same call loudly, a twin divergence). */
|
|
34
|
+
/** Blocking-mode poll ceiling in ms (default 600000 = 10 min). Set it here so a CI pipeline
|
|
35
|
+
* fails fast instead of blocking the full 10-minute default while a routed action waits.
|
|
36
|
+
* Accepted identically by the Python SDK (poll_timeout, seconds). */
|
|
38
37
|
pollTimeoutMs?: number;
|
|
39
38
|
/** Blocking-mode poll interval in ms (default 3000). */
|
|
40
39
|
pollIntervalMs?: number;
|
|
@@ -63,6 +62,9 @@ export declare class Guard {
|
|
|
63
62
|
stop: () => void;
|
|
64
63
|
};
|
|
65
64
|
private ensureResume;
|
|
65
|
+
/** Throws when the facade has no gate URL — at USE time, not import time, so `import { guard }`
|
|
66
|
+
* stays safe in envless contexts. */
|
|
67
|
+
private assertConfigured;
|
|
66
68
|
protect<A extends any[], R>(fn: (...a: A) => Promise<R>, describe: (...a: A) => ApprovalContext, opts?: ProtectOptions): (...a: A) => Promise<R>;
|
|
67
69
|
}
|
|
68
70
|
/** Preconfigured singleton — `import { guard } from "@decidio/sdk"`. */
|
package/dist/guard.js
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// The Guard facade — `guard.protect(fn, describe)`, the one agnostic line (TS mirror
|
|
2
|
-
// of decidio-guard Python). A thin layer over the verified primitives: durable mode
|
|
3
|
-
// builds a resume controller and suspends; blocking mode polls (opt-in, loud). The
|
|
4
|
-
// agent executes its own action; Decidio gates + records + signals — never executes.
|
|
5
1
|
import { withApproval } from "./index.js";
|
|
6
2
|
import { createDecidioResume, FilePendingStore } from "./resume.js";
|
|
7
3
|
export class Guard {
|
|
@@ -13,7 +9,7 @@ export class Guard {
|
|
|
13
9
|
}
|
|
14
10
|
static fromEnv() {
|
|
15
11
|
return new Guard({
|
|
16
|
-
apiUrl: process.env.DECIDIO_API_URL ?? "
|
|
12
|
+
apiUrl: process.env.DECIDIO_API_URL ?? "",
|
|
17
13
|
agentId: process.env.DECIDIO_AGENT_ID ?? "agent",
|
|
18
14
|
apiToken: process.env.DECIDIO_API_TOKEN,
|
|
19
15
|
sourceSystem: process.env.DECIDIO_SOURCE_SYSTEM,
|
|
@@ -26,39 +22,37 @@ export class Guard {
|
|
|
26
22
|
appUrl: process.env.DECIDIO_APP_URL,
|
|
27
23
|
});
|
|
28
24
|
}
|
|
29
|
-
/** Register a raw fn under its action name so a COLD process (one that never made the original
|
|
30
|
-
* call) can resume it. Call this at startup beside `guard.worker()` — or pass
|
|
31
|
-
* `{ action }` to `protect(...)`, which does it for you at wrap time. */
|
|
32
25
|
register(action, fn) { this.ensureResume().register(action, fn); }
|
|
33
|
-
/** Mount on a node/Express/Fastify route: `app.post("/decidio/resume", guard.resumeHandler())`. */
|
|
34
26
|
resumeHandler() { return this.ensureResume().webhookHandler(); }
|
|
35
|
-
/** Mount on a Next.js/Hono/CF/Bun route: `export const POST = (req) => guard.resumeFetchHandler()(req)`. */
|
|
36
27
|
resumeFetchHandler() { return this.ensureResume().fetchHandler(); }
|
|
37
|
-
/** Durable resume with NOTHING to host: a background watcher polls Decidio outward and finishes
|
|
38
|
-
* any parked approval — so a suspended action survives a process restart. One line at startup.
|
|
39
|
-
* Production may swap this for the webhook (resumeUrl); the suspend/resume shape is identical. */
|
|
40
28
|
worker(opts) {
|
|
41
29
|
return this.ensureResume().worker(opts);
|
|
42
30
|
}
|
|
43
31
|
ensureResume() {
|
|
32
|
+
this.assertConfigured();
|
|
44
33
|
if (!this.resume) {
|
|
45
34
|
const c = this.config;
|
|
46
35
|
this.resume = createDecidioResume({
|
|
47
36
|
apiUrl: c.apiUrl, apiToken: c.apiToken, webhookSecret: c.webhookSecret,
|
|
48
37
|
resumeUrl: c.resumeUrl, store: new FilePendingStore(c.pendingDir ?? ".decidio-pending"),
|
|
49
|
-
// Carry the signing identity so the durable/cold confirm on resume is signed too (incl. the
|
|
50
|
-
// workspace binding — D-B3.6 — so a cold-resume confirm proof is tenant-scoped as well).
|
|
51
38
|
agentId: c.agentId, agentKey: c.agentKey, agentDid: c.agentDid, workspaceId: c.workspaceId,
|
|
52
39
|
fetchImpl: c.fetchImpl,
|
|
53
40
|
});
|
|
54
41
|
}
|
|
55
42
|
return this.resume;
|
|
56
43
|
}
|
|
44
|
+
assertConfigured() {
|
|
45
|
+
if (!this.config.apiUrl)
|
|
46
|
+
throw new Error("[decidio] no gate URL configured — set DECIDIO_API_URL (the hosted sandbox is https://decidio-api.onrender.com) or run `npx @decidio/sdk init`, then start your process with `node --env-file=.env`. Nothing was routed or executed.");
|
|
47
|
+
}
|
|
57
48
|
protect(fn, describe, opts) {
|
|
49
|
+
this.assertConfigured();
|
|
58
50
|
if (opts?.adapter && opts.adapter !== "generic") {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
51
|
+
const REAL = ["langgraph", "openai", "temporal", "inngest"];
|
|
52
|
+
if (REAL.includes(opts.adapter)) {
|
|
53
|
+
throw new Error(`[decidio] adapter "${opts.adapter}" must be bound via the @decidio/sdk/${opts.adapter} subpath (it needs the engine handle). See that adapter's exports.`);
|
|
54
|
+
}
|
|
55
|
+
throw new Error(`[decidio] unknown adapter "${opts.adapter}" — the available engine adapters are ${REAL.map((a) => `"${a}"`).join(", ")} (each via its @decidio/sdk/<name> subpath), or omit the option for the core gate.`);
|
|
62
56
|
}
|
|
63
57
|
const c = this.config;
|
|
64
58
|
const base = {
|
|
@@ -69,20 +63,12 @@ export class Guard {
|
|
|
69
63
|
if ((opts?.mode ?? "durable") === "blocking") {
|
|
70
64
|
console.warn("[decidio] WARNING: blocking wait — only safe for short, watched approvals. " +
|
|
71
65
|
"Omit mode:\"blocking\" for durable async resume on real (minutes-to-days) approvals.");
|
|
72
|
-
// No resumeUrl on the blocking path — the server must not fire a webhook to an
|
|
73
|
-
// endpoint a blocking agent isn't serving; the poll is the only resume channel.
|
|
74
66
|
return withApproval(fn, describe, base);
|
|
75
67
|
}
|
|
76
|
-
// durable: refuse-don't-downgrade — always wire a resume transport, never silently block.
|
|
77
68
|
const resume = this.ensureResume();
|
|
78
|
-
// WRAP-TIME registration when the action is declared: the call-time registration inside
|
|
79
|
-
// withApproval only helps the process that made the original call, so a restarted worker
|
|
80
|
-
// found an empty registry and answered no_handler — the documented restart-survival pattern
|
|
81
|
-
// could not actually work without this (or an explicit guard.register at startup).
|
|
82
69
|
if (opts?.action)
|
|
83
70
|
resume.register(opts.action, fn);
|
|
84
71
|
return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume });
|
|
85
72
|
}
|
|
86
73
|
}
|
|
87
|
-
/** Preconfigured singleton — `import { guard } from "@decidio/sdk"`. */
|
|
88
74
|
export const guard = Guard.fromEnv();
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDeci
|
|
|
3
3
|
export type { PendingAction, PendingStore, ResumeController, ResumeOutcome } from "./resume.js";
|
|
4
4
|
export { guard, Guard } from "./guard.js";
|
|
5
5
|
export type { GuardFacadeConfig, ProtectOptions } from "./guard.js";
|
|
6
|
-
export { generateKeypair, signBoundProof, signConfirm, canonicalize, assertJsonSafe } from "./signing.js";
|
|
6
|
+
export { generateKeypair, signBoundProof, signConfirm, canonicalize, assertJsonSafe, assertSafeTransport, DecidioUnsafeArgumentsError } from "./signing.js";
|
|
7
7
|
export type { RequesterIdentity } from "./signing.js";
|
|
8
8
|
export interface GuardConfig {
|
|
9
9
|
/** Base URL of the Decidio gate, e.g. "https://api.decidio.io". */
|
|
@@ -75,15 +75,13 @@ export declare class DecidioTimeoutError extends Error {
|
|
|
75
75
|
decisionId: string;
|
|
76
76
|
constructor(decisionId: string);
|
|
77
77
|
}
|
|
78
|
-
/** A transport failure with GUIDANCE instead of a raw
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* whether anything could have executed. */
|
|
78
|
+
/** A transport failure with GUIDANCE instead of a raw fetch stack. Carries the endpoint, the
|
|
79
|
+
* operation, the underlying code, and the safety state a caller cares about most: whether
|
|
80
|
+
* anything could have executed. */
|
|
82
81
|
/** The workspace's governed-action allowance is exhausted (HTTP 429, code AGENT_RATE). The
|
|
83
|
-
* server says exactly how much and until when —
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* recorded, or executed; retry after `resetAt`. */
|
|
82
|
+
* server says exactly how much and until when — `limit`, `remaining` and `resetAt` relay its
|
|
83
|
+
* answer verbatim so automation never has to parse a message. Nothing was routed, recorded,
|
|
84
|
+
* or executed; retry after `resetAt`. */
|
|
87
85
|
export declare class DecidioRateLimitError extends Error {
|
|
88
86
|
limit: number | undefined;
|
|
89
87
|
remaining: number | undefined;
|
package/dist/index.js
CHANGED
|
@@ -1,37 +1,8 @@
|
|
|
1
|
-
// @decidio/sdk — the one-line approval gate for AI-agent actions.
|
|
2
|
-
//
|
|
3
|
-
// import { guard } from "@decidio/sdk";
|
|
4
|
-
// const createOpportunity = guard.protect(createOpportunityRaw, (a) => ({
|
|
5
|
-
// action: "createOpportunity", amount: a.amount, scope: "Opportunity",
|
|
6
|
-
// }));
|
|
7
|
-
//
|
|
8
|
-
// NOTE: `guard` is the preconfigured SINGLETON (a Guard instance, see guard.ts). It is NOT a
|
|
9
|
-
// GuardConfig, so it cannot be passed as withApproval's third argument — this comment used to
|
|
10
|
-
// show exactly that, and the error propagated into the product's own copy-paste snippets.
|
|
11
|
-
// `withApproval(fn, describe, config)` is the lower-level export and takes a config OBJECT.
|
|
12
|
-
//
|
|
13
|
-
// On call, the wrapper asks Decidio's gate to authorize the action. Decidio's
|
|
14
|
-
// policy engine (Cedar / computeChain) returns proceed | route | block:
|
|
15
|
-
// • proceed → the wrapped fn runs immediately (auto-approved under policy)
|
|
16
|
-
// • route → the wrapper WAITS for a human to approve in Decidio's queue, then
|
|
17
|
-
// runs the fn (or throws if rejected)
|
|
18
|
-
// • block → the wrapper throws; the fn never runs
|
|
19
|
-
// The wrapper is a LISTENER: it captures the real source-system response the wrapped
|
|
20
|
-
// fn returns and reports it to Decidio, which seals an `application_confirmed`
|
|
21
|
-
// Authority Receipt (Decidio minimizes + tokenizes before sealing — the immutable
|
|
22
|
-
// seal never stores raw payloads). The agent executes its own action; Decidio
|
|
23
|
-
// authorizes, records, and (optionally) independently verifies it. Decidio holds NO
|
|
24
|
-
// write credentials for your system — only the authority decision and the record.
|
|
25
|
-
//
|
|
26
|
-
// Framework-agnostic by design: it wraps at the tool-call boundary, so it works with
|
|
27
|
-
// any agent runtime (LangGraph, OpenAI Agents SDK, CrewAI, a raw tool loop) and any
|
|
28
|
-
// action (Salesforce, M365, SAP, a DB write) — the wrapper doesn't care what the fn
|
|
29
|
-
// does, only that it returns the source response.
|
|
30
1
|
import { DecidioSuspendedError, assertConfirmRecorded } from "./resume.js";
|
|
31
|
-
import { signBoundProof, signConfirm, assertJsonSafe } from "./signing.js";
|
|
2
|
+
import { signBoundProof, signConfirm, assertJsonSafe, assertSafeTransport, DecidioUnsafeArgumentsError } from "./signing.js";
|
|
32
3
|
export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
|
|
33
4
|
export { guard, Guard } from "./guard.js";
|
|
34
|
-
export { generateKeypair, signBoundProof, signConfirm, canonicalize, assertJsonSafe } from "./signing.js";
|
|
5
|
+
export { generateKeypair, signBoundProof, signConfirm, canonicalize, assertJsonSafe, assertSafeTransport, DecidioUnsafeArgumentsError } from "./signing.js";
|
|
35
6
|
export class DecidioBlockedError extends Error {
|
|
36
7
|
reason;
|
|
37
8
|
decisionId;
|
|
@@ -61,15 +32,6 @@ export class DecidioTimeoutError extends Error {
|
|
|
61
32
|
}
|
|
62
33
|
}
|
|
63
34
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
64
|
-
/** A transport failure with GUIDANCE instead of a raw undici stack (round-4 Codex catch: the CLI
|
|
65
|
-
* wrapped network errors kindly but the runtime leaked `TypeError: fetch failed`). Carries the
|
|
66
|
-
* endpoint, the operation, the underlying code, and the safety state a caller cares about most:
|
|
67
|
-
* whether anything could have executed. */
|
|
68
|
-
/** The workspace's governed-action allowance is exhausted (HTTP 429, code AGENT_RATE). The
|
|
69
|
-
* server says exactly how much and until when — relayed here instead of discarded (round-2
|
|
70
|
-
* blind catch: this surfaced as a bare `Error` with no decisionId, the tour misdiagnosed it as
|
|
71
|
-
* a connectivity failure, and doctor then corroborated the wrong suspect). Nothing was routed,
|
|
72
|
-
* recorded, or executed; retry after `resetAt`. */
|
|
73
35
|
export class DecidioRateLimitError extends Error {
|
|
74
36
|
limit;
|
|
75
37
|
remaining;
|
|
@@ -88,9 +50,6 @@ export class DecidioNetworkError extends Error {
|
|
|
88
50
|
op;
|
|
89
51
|
code;
|
|
90
52
|
constructor(endpoint, op, code, cause) {
|
|
91
|
-
// A refused localhost connection almost always means MISSING CONFIG, not a broken network
|
|
92
|
-
// (blind-review catch: forgetting `--env-file .env` produced advice about network debugging).
|
|
93
|
-
// Name the likely cause before the generic one.
|
|
94
53
|
super(`[decidio] could not reach ${endpoint} during ${op}${code ? ` (${code})` : ""}. ` +
|
|
95
54
|
(/localhost|127\.0\.0\.1/.test(endpoint) && code === "ECONNREFUSED"
|
|
96
55
|
? `A refused localhost connection usually means the SDK never saw your config: if your settings live in .env, run node with --env-file=.env (or export DECIDIO_API_URL — the hosted sandbox is https://decidio-api.onrender.com). `
|
|
@@ -115,6 +74,7 @@ async function reach(f, url, op, init) {
|
|
|
115
74
|
}
|
|
116
75
|
async function post(cfg, path, body) {
|
|
117
76
|
const f = cfg.fetchImpl ?? fetch;
|
|
77
|
+
assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
|
|
118
78
|
const res = await reach(f, cfg.apiUrl.replace(/\/$/, "") + path, `POST ${path}`, {
|
|
119
79
|
method: "POST",
|
|
120
80
|
headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
@@ -130,6 +90,7 @@ async function post(cfg, path, body) {
|
|
|
130
90
|
}
|
|
131
91
|
async function get(cfg, path) {
|
|
132
92
|
const f = cfg.fetchImpl ?? fetch;
|
|
93
|
+
assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
|
|
133
94
|
const res = await reach(f, cfg.apiUrl.replace(/\/$/, "") + path, `GET ${path}`, {
|
|
134
95
|
headers: { ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
135
96
|
});
|
|
@@ -137,13 +98,6 @@ async function get(cfg, path) {
|
|
|
137
98
|
throw new Error(`decidio ${path} ${res.status}`);
|
|
138
99
|
return res.json();
|
|
139
100
|
}
|
|
140
|
-
/**
|
|
141
|
-
* Wrap an async action behind the Decidio gate. `describe` extracts the approval
|
|
142
|
-
* context (action, amount, scope) from the call arguments. Returns a function with
|
|
143
|
-
* the SAME signature as `fn` — drop-in.
|
|
144
|
-
*/
|
|
145
|
-
/** `<appUrl>/d/<decisionId>` — the deep link that opens this decision directly, on the web app or
|
|
146
|
-
* the phone build. Null when DECIDIO_APP_URL is not configured, never a half-built URL. */
|
|
147
101
|
export function decisionUrl(appUrl, decisionId) {
|
|
148
102
|
if (!appUrl)
|
|
149
103
|
return null;
|
|
@@ -154,17 +108,22 @@ export function withApproval(fn, describe, config) {
|
|
|
154
108
|
const pollTimeout = config.pollTimeoutMs ?? 600_000;
|
|
155
109
|
const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
|
|
156
110
|
return async (...args) => {
|
|
111
|
+
try {
|
|
112
|
+
assertJsonSafe(args, "args");
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
throw new DecidioUnsafeArgumentsError(`${e?.message ?? e} The decision was NOT routed; nothing reached Decidio.`);
|
|
116
|
+
}
|
|
157
117
|
const ctx = describe(...args);
|
|
158
|
-
// 1. ask the gate (carry the resume URL so a routed decision knows where to call back).
|
|
159
|
-
// When a signing key is configured, sign a request-bound identity proof so the floor
|
|
160
|
-
// can PROVE this agent rather than trust the `requester` label (registered agents must
|
|
161
|
-
// sign to auto-approve). Unsigned still works for generic/non-agent callers.
|
|
162
118
|
const requesterIdentity = (config.agentKey && config.agentDid)
|
|
163
119
|
? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
|
|
164
120
|
: undefined;
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
121
|
+
try {
|
|
122
|
+
assertJsonSafe(ctx, "describe(...)");
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
throw new DecidioUnsafeArgumentsError(`${e?.message ?? e} The decision was NOT routed; nothing reached Decidio.`);
|
|
126
|
+
}
|
|
168
127
|
const auth = await post(config, "/agent/authorize", {
|
|
169
128
|
requester: config.agentId, action: ctx.action, amount: ctx.amount,
|
|
170
129
|
scope: ctx.scope, sourceSystem: config.sourceSystem,
|
|
@@ -172,52 +131,28 @@ export function withApproval(fn, describe, config) {
|
|
|
172
131
|
...(requesterIdentity ? { requesterIdentity } : {}),
|
|
173
132
|
...(resumeUrl ? { resumeUrl } : {}),
|
|
174
133
|
});
|
|
175
|
-
// Allow-list, not deny-list: an unrecognised verdict means this SDK is older than the
|
|
176
|
-
// server, and the safe reading of an answer you do not understand is NO. Without this, the
|
|
177
|
-
// day a fourth verdict ships every deployed SDK falls through and executes it.
|
|
178
134
|
if (auth.decision !== "proceed" && auth.decision !== "route" && auth.decision !== "block")
|
|
179
135
|
throw new DecidioBlockedError(`unrecognized gate verdict: ${String(auth.decision)}`, auth.decisionId);
|
|
180
136
|
if (auth.decision === "block")
|
|
181
137
|
throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
|
|
182
|
-
// 2. routed → either SUSPEND for async resume (durable) or BLOCK-poll (interactive)
|
|
183
138
|
if (auth.decision === "route") {
|
|
184
|
-
// ALWAYS hand back the decision id — an agent that suspends and says only "waiting" leaves
|
|
185
|
-
// the operator hunting through a queue, and an agent that says NOTHING (the pre-0.1.2 bug:
|
|
186
|
-
// the print was gated on appUrl, which init never writes) leaves them staring at a silent
|
|
187
|
-
// terminal. The Codex acceptance run failed exactly this claim; the id line is unconditional
|
|
188
|
-
// now, with the deep link when appUrl is configured and the CLI command when not.
|
|
189
139
|
const approvalUrl = decisionUrl(config.appUrl, auth.decisionId);
|
|
190
140
|
if (config.onPending)
|
|
191
141
|
config.onPending({ decisionId: auth.decisionId, receiptId: auth.receiptId, url: approvalUrl });
|
|
192
|
-
// stderr, not stdout: this is SDK diagnostics inside a USER'S process — their stdout
|
|
193
|
-
// belongs to their application output (round-3 blind catch: piping the agent's stdout
|
|
194
|
-
// captured this status line interleaved with app output, while the warning went to stderr).
|
|
195
142
|
else
|
|
196
143
|
console.error(`[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}`}`);
|
|
197
|
-
// Durable transport: park the args agent-side, register the raw fn so a (cold)
|
|
198
|
-
// resume can replay it, and SUSPEND. The process may exit now; the controller's
|
|
199
|
-
// webhook handler or poll worker re-executes THIS fn when a human approves.
|
|
200
|
-
// Decidio never executes — it only signals.
|
|
201
144
|
if (config.resume) {
|
|
202
|
-
// Propagate the agent's signing identity to the resume controller so the eventual
|
|
203
|
-
// (possibly cold) confirm on resume is SIGNED too — earning application_confirmed on
|
|
204
|
-
// the durable path, not just inline. (No-op if the controller already has its own.)
|
|
205
145
|
config.resume.agentId ??= config.agentId;
|
|
206
146
|
config.resume.agentKey ??= config.agentKey;
|
|
207
147
|
config.resume.agentDid ??= config.agentDid;
|
|
208
148
|
config.resume.register(ctx.action, fn);
|
|
209
|
-
// park() (not store.put) so the payload is SIGNED — resolve() refuses a pending file
|
|
210
|
-
// whose args no longer match what was routed (round-6 acceptance catch).
|
|
211
149
|
await config.resume.park({
|
|
212
150
|
decisionId: auth.decisionId, receiptId: auth.receiptId,
|
|
213
151
|
action: ctx.action, amount: ctx.amount, scope: ctx.scope, args,
|
|
214
152
|
});
|
|
215
153
|
throw new DecidioSuspendedError(auth.decisionId, ctx.action);
|
|
216
154
|
}
|
|
217
|
-
// Interactive transport (no resume configured): block until decided. Only
|
|
218
|
-
// appropriate for short, watched approvals (demos, synchronous tools).
|
|
219
155
|
const deadline = Date.now() + pollTimeout;
|
|
220
|
-
// eslint-disable-next-line no-constant-condition
|
|
221
156
|
while (true) {
|
|
222
157
|
const st = await get(config, `/agent/status/${encodeURIComponent(auth.decisionId)}`);
|
|
223
158
|
if (st.state === "approved" || st.state === "auto_approved")
|
|
@@ -227,42 +162,26 @@ export function withApproval(fn, describe, config) {
|
|
|
227
162
|
if (st.state === "blocked")
|
|
228
163
|
throw new DecidioBlockedError(st.reason ?? "blocked", auth.decisionId);
|
|
229
164
|
if (Date.now() > deadline) {
|
|
230
|
-
// The founder-visible failure of blocking: the wait has a ceiling, and past it the
|
|
231
|
-
// work is LOST — the decision stays open in Decidio but nothing is parked to resume.
|
|
232
|
-
// Say so, say the limit, and say the durable way out.
|
|
233
165
|
console.warn(`[decidio] blocking wait hit its ${Math.round(pollTimeout / 1000)}s limit — decision ${auth.decisionId} is still open in Decidio, but this process stopped waiting and nothing was executed. Re-run to request again, or use durable resume (guard.worker()) so an approval is never lost.`);
|
|
234
166
|
throw new DecidioTimeoutError(auth.decisionId);
|
|
235
167
|
}
|
|
236
168
|
await sleep(pollInterval);
|
|
237
169
|
}
|
|
238
170
|
}
|
|
239
|
-
// 3. authorized inline (proceed, or interactive route just approved) → run the
|
|
240
|
-
// REAL action and capture the source response (listener)
|
|
241
171
|
const result = await fn(...args);
|
|
242
|
-
// 4. report the captured outcome → Decidio seals application_confirmed
|
|
243
|
-
// (server minimizes + tokenizes the response before sealing). Best-effort:
|
|
244
|
-
// a confirmation failure must not undo a successful business action.
|
|
245
172
|
try {
|
|
246
|
-
// Sign the confirmation too (bound to {action:"confirm", scope:decisionId}) so the gate
|
|
247
|
-
// can PROVE this is the receipt's original requester before granting the wrapper tier
|
|
248
|
-
// (application_confirmed). Without a key the report is capped at agent_asserted server-side.
|
|
249
173
|
const confirmIdentity = signConfirm(auth.decisionId, config);
|
|
250
174
|
const confirmBody = await post(config, "/agent/confirm", {
|
|
251
175
|
decisionId: auth.decisionId,
|
|
252
176
|
capturedResponse: result ?? null,
|
|
253
|
-
source: "wrapper",
|
|
177
|
+
source: "wrapper",
|
|
254
178
|
...(confirmIdentity ? { requesterIdentity: confirmIdentity } : {}),
|
|
255
179
|
});
|
|
256
|
-
// The ONE confirm-body rule, on this door too (guard equivalent-path catch: transport
|
|
257
|
-
// success is not confirmation — a tierless 2xx must not read as recorded evidence).
|
|
258
180
|
assertConfirmRecorded(confirmBody, auth.decisionId, !!confirmIdentity);
|
|
259
|
-
// An UNSIGNED confirm is server-capped at the weaker agent_asserted tier. Say so at the
|
|
260
|
-
// moment it happens — a silent evidence downgrade was round 3's acceptance FAIL.
|
|
261
181
|
if (!confirmIdentity)
|
|
262
182
|
console.warn(`[decidio] execution evidence for ${auth.decisionId} recorded as agent_asserted — no signing key configured, so the report can't be proven to come from this agent. Set DECIDIO_AGENT_KEY/DID (npx @decidio/sdk init writes them) to earn application_confirmed.`);
|
|
263
183
|
}
|
|
264
184
|
catch (e) {
|
|
265
|
-
// surface but don't throw — the action already happened
|
|
266
185
|
console.warn(`[decidio] confirmation post failed (action succeeded): ${e.message}`);
|
|
267
186
|
}
|
|
268
187
|
return result;
|
package/dist/inngest.js
CHANGED
|
@@ -1,26 +1,12 @@
|
|
|
1
1
|
import { verifyResumeSignature } from "./resume.js";
|
|
2
2
|
import { signBoundProof, signConfirm } from "./signing.js";
|
|
3
|
-
/** The Inngest event Decidio's verdict is translated into. The waiting function
|
|
4
|
-
* correlates on `data.decisionId`. */
|
|
5
3
|
export const DECIDIO_RESUME_EVENT = "decidio/decision.resolved";
|
|
6
|
-
/**
|
|
7
|
-
* Gate an action INSIDE an Inngest function, with durable suspend on routing.
|
|
8
|
-
* • proceed → run the action (as a step) + confirm.
|
|
9
|
-
* • route → `step.waitForEvent` durably suspends until Decidio's resume event for
|
|
10
|
-
* THIS decision arrives; approved → run + confirm; rejected → status.
|
|
11
|
-
* • block → return blocked (the action never runs).
|
|
12
|
-
* The action runs in YOUR process with YOUR credentials — Decidio only signals.
|
|
13
|
-
* `config.resumeUrl` must point at the bridge endpoint (createInngestResumeBridge),
|
|
14
|
-
* so Decidio's webhook is translated into the event this step waits on.
|
|
15
|
-
*/
|
|
16
4
|
export async function decidioGateStep(args) {
|
|
17
5
|
const { step, config, ctx } = args;
|
|
18
6
|
const base = config.apiUrl.replace(/\/$/, "");
|
|
19
7
|
const f = config.fetchImpl ?? fetch;
|
|
20
8
|
const authHeaders = { "content-type": "application/json", ...(config.apiToken ? { authorization: `Bearer ${config.apiToken}` } : {}) };
|
|
21
|
-
// 1. authorize (memoized as a step, so a replay doesn't re-ask the gate)
|
|
22
9
|
const auth = await step.run("decidio-authorize", async () => {
|
|
23
|
-
// Sign the bound proof so an Inngest-path agent can prove its identity + auto-approve.
|
|
24
10
|
const requesterIdentity = (config.agentKey && config.agentDid)
|
|
25
11
|
? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
|
|
26
12
|
: undefined;
|
|
@@ -40,9 +26,6 @@ export async function decidioGateStep(args) {
|
|
|
40
26
|
if (auth.decision === "block")
|
|
41
27
|
return { status: "blocked", decisionId: auth.decisionId };
|
|
42
28
|
if (auth.decision === "route") {
|
|
43
|
-
// 2. durably SUSPEND until the resume event for THIS decision arrives. Zero
|
|
44
|
-
// compute while waiting; the worker/process may be recycled — Inngest resumes
|
|
45
|
-
// from this checkpoint. `if` correlates the awaited event to this decisionId.
|
|
46
29
|
const ev = await step.waitForEvent("decidio-await-approval", {
|
|
47
30
|
event: DECIDIO_RESUME_EVENT,
|
|
48
31
|
timeout: args.timeout ?? "30d",
|
|
@@ -56,8 +39,6 @@ export async function decidioGateStep(args) {
|
|
|
56
39
|
if (verdict !== "approved" && verdict !== "auto_approved")
|
|
57
40
|
return { status: "blocked", decisionId: auth.decisionId };
|
|
58
41
|
}
|
|
59
|
-
// 3. authorized → perform the agent's OWN action (a step), then confirm the captured
|
|
60
|
-
// response so Decidio seals application_confirmed (never executes it itself).
|
|
61
42
|
const result = await step.run("agent-action", args.run);
|
|
62
43
|
await step.run("decidio-confirm", async () => {
|
|
63
44
|
const requesterIdentity = signConfirm(auth.decisionId, config);
|
|
@@ -69,12 +50,6 @@ export async function decidioGateStep(args) {
|
|
|
69
50
|
});
|
|
70
51
|
return { status: "executed", decisionId: auth.decisionId, result };
|
|
71
52
|
}
|
|
72
|
-
/**
|
|
73
|
-
* Decidio → Inngest bridge. Mount as an HTTP endpoint and register it as the agent's
|
|
74
|
-
* `resumeUrl`. It verifies Decidio's signed resume webhook (same HMAC as the core
|
|
75
|
-
* handler) and emits `DECIDIO_RESUME_EVENT` into Inngest, waking the suspended
|
|
76
|
-
* `decidioGateStep`. Fails CLOSED without a configured secret (unless allowUnsigned).
|
|
77
|
-
*/
|
|
78
53
|
export function createInngestResumeBridge(opts) {
|
|
79
54
|
const secret = opts.webhookSecret ?? "";
|
|
80
55
|
const eventName = opts.eventName ?? DECIDIO_RESUME_EVENT;
|
|
@@ -110,5 +85,4 @@ export function createInngestResumeBridge(opts) {
|
|
|
110
85
|
});
|
|
111
86
|
};
|
|
112
87
|
}
|
|
113
|
-
// Re-export the signature check so a host can verify the webhook out-of-band if needed.
|
|
114
88
|
export { verifyResumeSignature };
|
package/dist/resume.d.ts
CHANGED
|
@@ -9,8 +9,8 @@ export interface PendingAction {
|
|
|
9
9
|
scope?: string;
|
|
10
10
|
args: unknown[];
|
|
11
11
|
createdAt: string;
|
|
12
|
-
/** Integrity binding over {decisionId, action, amount, scope, args}
|
|
13
|
-
*
|
|
12
|
+
/** Integrity binding over {decisionId, action, amount, scope, args} — without it the parked
|
|
13
|
+
* payload would be mutable between routing and execution. ed25519 = detached
|
|
14
14
|
* signature by the agent's key; hmac = keyed by the webhook secret. Verified before ANY
|
|
15
15
|
* re-execution; a mismatch REFUSES and leaves the file for inspection. When the controller
|
|
16
16
|
* holds an agent key, a MISSING signature is also refused (a stripped signature is
|
|
@@ -87,8 +87,8 @@ export interface ResumeOutcome {
|
|
|
87
87
|
result?: unknown;
|
|
88
88
|
reason?: string;
|
|
89
89
|
/** executed only: did the execution-evidence report reach Decidio AND come back with a recorded
|
|
90
|
-
* tier? `status: "executed"` describes the LOCAL run; this describes the record
|
|
91
|
-
*
|
|
90
|
+
* tier? `status: "executed"` describes the LOCAL run; this describes the record — a lost
|
|
91
|
+
* confirmation must never read as recorded evidence. */
|
|
92
92
|
confirmed?: boolean;
|
|
93
93
|
/** executed only: the evidence tier the server says it recorded (e.g. application_confirmed). */
|
|
94
94
|
evidenceTier?: string;
|
|
@@ -114,9 +114,9 @@ export interface ResumeConfig {
|
|
|
114
114
|
* with no secret and this unset, the webhook handler rejects every call (fail
|
|
115
115
|
* closed) so a misconfigured agent can't be driven by forged callbacks. Setting it
|
|
116
116
|
* true REMOVES webhook authenticity — anyone who can reach the endpoint can signal
|
|
117
|
-
* work — and the controller says so loudly at startup (
|
|
118
|
-
*
|
|
119
|
-
*
|
|
117
|
+
* work — and the controller says so loudly at startup (an absolute fail-closed
|
|
118
|
+
* guarantee must disclose its opt-out). Only behind a trusted network boundary
|
|
119
|
+
* (e.g. mTLS / private mesh), never on an internet-reachable endpoint. */
|
|
120
120
|
allowUnsigned?: boolean;
|
|
121
121
|
/** The callback URL Decidio should POST the verdict to (webhook transport). Sent
|
|
122
122
|
* on /agent/authorize so the decision carries it; omit when using the poll worker. */
|
|
@@ -163,11 +163,10 @@ declare function sign(secret: string, body: string): string;
|
|
|
163
163
|
/** Verify Decidio's `x-decidio-signature` over the raw webhook body (HMAC-SHA256).
|
|
164
164
|
* Exported so engine adapters (e.g. the Inngest bridge) reuse the exact check. */
|
|
165
165
|
export declare function verifyResumeSignature(secret: string, body: string, header: string | undefined): boolean;
|
|
166
|
-
/** The ONE confirm-body rule,
|
|
167
|
-
* adapter gate
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* silent. Returns the recorded tier or throws. */
|
|
166
|
+
/** The ONE confirm-body rule, shared by every confirm door (resume, inline withApproval,
|
|
167
|
+
* adapter gate). Transport success is not confirmation: a 2xx whose body carries no recorded
|
|
168
|
+
* tier is a FAILED confirm; a SIGNED confirm coming back below application_confirmed is
|
|
169
|
+
* disclosed, never silent. Returns the recorded tier or throws. */
|
|
171
170
|
export declare function assertConfirmRecorded(body: any, decisionId: string, signed: boolean): string;
|
|
172
171
|
/**
|
|
173
172
|
* Build the resume controller. Spread it into your GuardConfig as `resume`, then
|