@decidio/sdk 0.1.5 → 0.1.7
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 +57 -11
- package/dist/adapters/_gate.d.ts +0 -2
- package/dist/adapters/_gate.js +0 -15
- package/dist/adapters/langgraph.d.ts +0 -3
- package/dist/adapters/langgraph.js +0 -10
- package/dist/adapters/openai.d.ts +0 -4
- package/dist/adapters/openai.js +0 -20
- package/dist/adapters/temporal.d.ts +0 -1
- package/dist/adapters/temporal.js +1 -13
- package/dist/cli.js +149 -138
- package/dist/guard.d.ts +2 -14
- package/dist/guard.js +6 -19
- package/dist/index.d.ts +0 -41
- package/dist/index.js +3 -81
- package/dist/inngest.d.ts +0 -20
- package/dist/inngest.js +0 -26
- package/dist/resume.d.ts +5 -61
- package/dist/resume.js +107 -73
- package/dist/signing.d.ts +0 -12
- package/dist/signing.js +0 -25
- package/openapi.yaml +1 -1
- package/package.json +5 -1
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 {
|
|
@@ -26,13 +22,9 @@ export class Guard {
|
|
|
26
22
|
appUrl: process.env.DECIDIO_APP_URL,
|
|
27
23
|
});
|
|
28
24
|
}
|
|
29
|
-
|
|
25
|
+
register(action, fn) { this.ensureResume().register(action, fn); }
|
|
30
26
|
resumeHandler() { return this.ensureResume().webhookHandler(); }
|
|
31
|
-
/** Mount on a Next.js/Hono/CF/Bun route: `export const POST = (req) => guard.resumeFetchHandler()(req)`. */
|
|
32
27
|
resumeFetchHandler() { return this.ensureResume().fetchHandler(); }
|
|
33
|
-
/** Durable resume with NOTHING to host: a background watcher polls Decidio outward and finishes
|
|
34
|
-
* any parked approval — so a suspended action survives a process restart. One line at startup.
|
|
35
|
-
* Production may swap this for the webhook (resumeUrl); the suspend/resume shape is identical. */
|
|
36
28
|
worker(opts) {
|
|
37
29
|
return this.ensureResume().worker(opts);
|
|
38
30
|
}
|
|
@@ -42,8 +34,6 @@ export class Guard {
|
|
|
42
34
|
this.resume = createDecidioResume({
|
|
43
35
|
apiUrl: c.apiUrl, apiToken: c.apiToken, webhookSecret: c.webhookSecret,
|
|
44
36
|
resumeUrl: c.resumeUrl, store: new FilePendingStore(c.pendingDir ?? ".decidio-pending"),
|
|
45
|
-
// Carry the signing identity so the durable/cold confirm on resume is signed too (incl. the
|
|
46
|
-
// workspace binding — D-B3.6 — so a cold-resume confirm proof is tenant-scoped as well).
|
|
47
37
|
agentId: c.agentId, agentKey: c.agentKey, agentDid: c.agentDid, workspaceId: c.workspaceId,
|
|
48
38
|
fetchImpl: c.fetchImpl,
|
|
49
39
|
});
|
|
@@ -52,9 +42,7 @@ export class Guard {
|
|
|
52
42
|
}
|
|
53
43
|
protect(fn, describe, opts) {
|
|
54
44
|
if (opts?.adapter && opts.adapter !== "generic") {
|
|
55
|
-
|
|
56
|
-
// engine handle at call time). Surfaced as a clear error until wired in this build.
|
|
57
|
-
throw new Error(`[decidio] adapter "${opts.adapter}" must be bound via @decidio/sdk/adapters/${opts.adapter} (it needs the engine handle).`);
|
|
45
|
+
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.`);
|
|
58
46
|
}
|
|
59
47
|
const c = this.config;
|
|
60
48
|
const base = {
|
|
@@ -64,13 +52,12 @@ export class Guard {
|
|
|
64
52
|
if ((opts?.mode ?? "durable") === "blocking") {
|
|
65
53
|
console.warn("[decidio] WARNING: blocking wait — only safe for short, watched approvals. " +
|
|
66
54
|
"Omit mode:\"blocking\" for durable async resume on real (minutes-to-days) approvals.");
|
|
67
|
-
// No resumeUrl on the blocking path — the server must not fire a webhook to an
|
|
68
|
-
// endpoint a blocking agent isn't serving; the poll is the only resume channel.
|
|
69
55
|
return withApproval(fn, describe, base);
|
|
70
56
|
}
|
|
71
|
-
|
|
72
|
-
|
|
57
|
+
const resume = this.ensureResume();
|
|
58
|
+
if (opts?.action)
|
|
59
|
+
resume.register(opts.action, fn);
|
|
60
|
+
return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume });
|
|
73
61
|
}
|
|
74
62
|
}
|
|
75
|
-
/** Preconfigured singleton — `import { guard } from "@decidio/sdk"`. */
|
|
76
63
|
export const guard = Guard.fromEnv();
|
package/dist/index.d.ts
CHANGED
|
@@ -6,49 +6,22 @@ export type { GuardFacadeConfig, ProtectOptions } from "./guard.js";
|
|
|
6
6
|
export { generateKeypair, signBoundProof, signConfirm, canonicalize } from "./signing.js";
|
|
7
7
|
export type { RequesterIdentity } from "./signing.js";
|
|
8
8
|
export interface GuardConfig {
|
|
9
|
-
/** Base URL of the Decidio gate, e.g. "https://api.decidio.io". */
|
|
10
9
|
apiUrl: string;
|
|
11
|
-
/** The agent identity registered with Decidio (onboarding wizard), e.g. "salesforce/SalesforceBot". */
|
|
12
10
|
agentId: string;
|
|
13
|
-
/** Bearer token for the REST floor (a trusted, already-authenticated internal
|
|
14
|
-
* caller). Lives ONLY in the agent host's environment — never in client code. */
|
|
15
11
|
apiToken?: string;
|
|
16
|
-
/** Source system label sealed onto the receipt (e.g. "salesforce"). */
|
|
17
12
|
sourceSystem?: string;
|
|
18
|
-
/** The agent's PRIVATE signing key (PKCS8 base64), generated locally by `init` and held
|
|
19
|
-
* ONLY in the agent host's env as DECIDIO_AGENT_KEY. When set (with `agentDid`), the SDK
|
|
20
|
-
* signs a request-bound identity proof on each authorize so the floor PROVES the requester
|
|
21
|
-
* instead of trusting the label — required for a registered agent to auto-approve. */
|
|
22
13
|
agentKey?: string;
|
|
23
|
-
/** The agent's PUBLIC did:key (the one registered with Decidio). Pairs with `agentKey`. */
|
|
24
14
|
agentDid?: string;
|
|
25
|
-
/** The workspace this agent acts in (DECIDIO_WORKSPACE_ID, from the owner's agent-token mint).
|
|
26
|
-
* Bound into the signed proof (D-B3.6) so a captured proof can't be replayed in another tenant.
|
|
27
|
-
* For a tester's per-workspace agent token this is the tester's workspace id. */
|
|
28
15
|
workspaceId?: string;
|
|
29
|
-
/** Poll cadence + ceiling while a routed action awaits human approval. Used ONLY
|
|
30
|
-
* by the blocking transport (no `resume` configured). */
|
|
31
16
|
pollIntervalMs?: number;
|
|
32
17
|
pollTimeoutMs?: number;
|
|
33
|
-
/** Optional async notifier so a long human wait surfaces in your logs/UI. */
|
|
34
18
|
onPending?: (info: {
|
|
35
19
|
decisionId: string;
|
|
36
20
|
receiptId: string;
|
|
37
21
|
url: string | null;
|
|
38
22
|
}) => void;
|
|
39
|
-
/** Base URL of the Decidio app (DECIDIO_APP_URL). When set, a routed action can say WHERE to
|
|
40
|
-
* approve it — `<appUrl>/d/<decisionId>` opens that decision directly, on web or on the phone
|
|
41
|
-
* build. This link used to be built only inside our own example, so a developer who pasted the
|
|
42
|
-
* env block set DECIDIO_APP_URL and nothing ever read it. */
|
|
43
23
|
appUrl?: string;
|
|
44
|
-
/** Durable async resume. When set, a routed action SUSPENDS (parks its args in the
|
|
45
|
-
* store, throws DecidioSuspendedError) instead of blocking — the process may exit
|
|
46
|
-
* and resume later via the controller's webhook handler or poll worker. This is
|
|
47
|
-
* the production transport for real (minutes-to-days) human approvals. */
|
|
48
24
|
resume?: ResumeController;
|
|
49
|
-
/** Callback URL Decidio POSTs the verdict to on approval (webhook transport).
|
|
50
|
-
* Sent on /agent/authorize so the decision carries it. Falls back to
|
|
51
|
-
* resume.resumeUrl. Omit when using the poll worker. */
|
|
52
25
|
resumeUrl?: string;
|
|
53
26
|
fetchImpl?: typeof fetch;
|
|
54
27
|
}
|
|
@@ -56,9 +29,6 @@ export interface ApprovalContext {
|
|
|
56
29
|
action: string;
|
|
57
30
|
amount: number;
|
|
58
31
|
scope?: string;
|
|
59
|
-
/** Optional human-facing DETAILS of the action (e.g. { Account, "Lead Source", Stage }) shown to
|
|
60
|
-
* the approver as "what you're approving". Display-only — the policy decides on `amount`/`scope`,
|
|
61
|
-
* never these. Residual-safe tokenized server-side before persistence. */
|
|
62
32
|
context?: Record<string, string | number>;
|
|
63
33
|
}
|
|
64
34
|
export declare class DecidioBlockedError extends Error {
|
|
@@ -75,22 +45,11 @@ export declare class DecidioTimeoutError extends Error {
|
|
|
75
45
|
decisionId: string;
|
|
76
46
|
constructor(decisionId: string);
|
|
77
47
|
}
|
|
78
|
-
/** A transport failure with GUIDANCE instead of a raw undici stack (round-4 Codex catch: the CLI
|
|
79
|
-
* wrapped network errors kindly but the runtime leaked `TypeError: fetch failed`). Carries the
|
|
80
|
-
* endpoint, the operation, the underlying code, and the safety state a caller cares about most:
|
|
81
|
-
* whether anything could have executed. */
|
|
82
48
|
export declare class DecidioNetworkError extends Error {
|
|
83
49
|
endpoint: string;
|
|
84
50
|
op: string;
|
|
85
51
|
code: string | undefined;
|
|
86
52
|
constructor(endpoint: string, op: string, code: string | undefined, cause: unknown);
|
|
87
53
|
}
|
|
88
|
-
/**
|
|
89
|
-
* Wrap an async action behind the Decidio gate. `describe` extracts the approval
|
|
90
|
-
* context (action, amount, scope) from the call arguments. Returns a function with
|
|
91
|
-
* the SAME signature as `fn` — drop-in.
|
|
92
|
-
*/
|
|
93
|
-
/** `<appUrl>/d/<decisionId>` — the deep link that opens this decision directly, on the web app or
|
|
94
|
-
* the phone build. Null when DECIDIO_APP_URL is not configured, never a half-built URL. */
|
|
95
54
|
export declare function decisionUrl(appUrl: string | undefined, decisionId: string): string | null;
|
|
96
55
|
export declare function withApproval<A extends any[], R>(fn: (...args: A) => Promise<R>, describe: (...args: A) => ApprovalContext, config: GuardConfig): (...args: A) => Promise<R>;
|
package/dist/index.js
CHANGED
|
@@ -1,32 +1,3 @@
|
|
|
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 } from "./resume.js";
|
|
31
2
|
import { signBoundProof, signConfirm } from "./signing.js";
|
|
32
3
|
export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume } from "./resume.js";
|
|
@@ -61,10 +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
35
|
export class DecidioNetworkError extends Error {
|
|
69
36
|
endpoint;
|
|
70
37
|
op;
|
|
@@ -109,13 +76,6 @@ async function get(cfg, path) {
|
|
|
109
76
|
throw new Error(`decidio ${path} ${res.status}`);
|
|
110
77
|
return res.json();
|
|
111
78
|
}
|
|
112
|
-
/**
|
|
113
|
-
* Wrap an async action behind the Decidio gate. `describe` extracts the approval
|
|
114
|
-
* context (action, amount, scope) from the call arguments. Returns a function with
|
|
115
|
-
* the SAME signature as `fn` — drop-in.
|
|
116
|
-
*/
|
|
117
|
-
/** `<appUrl>/d/<decisionId>` — the deep link that opens this decision directly, on the web app or
|
|
118
|
-
* the phone build. Null when DECIDIO_APP_URL is not configured, never a half-built URL. */
|
|
119
79
|
export function decisionUrl(appUrl, decisionId) {
|
|
120
80
|
if (!appUrl)
|
|
121
81
|
return null;
|
|
@@ -127,10 +87,6 @@ export function withApproval(fn, describe, config) {
|
|
|
127
87
|
const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
|
|
128
88
|
return async (...args) => {
|
|
129
89
|
const ctx = describe(...args);
|
|
130
|
-
// 1. ask the gate (carry the resume URL so a routed decision knows where to call back).
|
|
131
|
-
// When a signing key is configured, sign a request-bound identity proof so the floor
|
|
132
|
-
// can PROVE this agent rather than trust the `requester` label (registered agents must
|
|
133
|
-
// sign to auto-approve). Unsigned still works for generic/non-agent callers.
|
|
134
90
|
const requesterIdentity = (config.agentKey && config.agentDid)
|
|
135
91
|
? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
|
|
136
92
|
: undefined;
|
|
@@ -141,48 +97,28 @@ export function withApproval(fn, describe, config) {
|
|
|
141
97
|
...(requesterIdentity ? { requesterIdentity } : {}),
|
|
142
98
|
...(resumeUrl ? { resumeUrl } : {}),
|
|
143
99
|
});
|
|
144
|
-
// Allow-list, not deny-list: an unrecognised verdict means this SDK is older than the
|
|
145
|
-
// server, and the safe reading of an answer you do not understand is NO. Without this, the
|
|
146
|
-
// day a fourth verdict ships every deployed SDK falls through and executes it.
|
|
147
100
|
if (auth.decision !== "proceed" && auth.decision !== "route" && auth.decision !== "block")
|
|
148
101
|
throw new DecidioBlockedError(`unrecognized gate verdict: ${String(auth.decision)}`, auth.decisionId);
|
|
149
102
|
if (auth.decision === "block")
|
|
150
103
|
throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
|
|
151
|
-
// 2. routed → either SUSPEND for async resume (durable) or BLOCK-poll (interactive)
|
|
152
104
|
if (auth.decision === "route") {
|
|
153
|
-
// ALWAYS hand back the decision id — an agent that suspends and says only "waiting" leaves
|
|
154
|
-
// the operator hunting through a queue, and an agent that says NOTHING (the pre-0.1.2 bug:
|
|
155
|
-
// the print was gated on appUrl, which init never writes) leaves them staring at a silent
|
|
156
|
-
// terminal. The Codex acceptance run failed exactly this claim; the id line is unconditional
|
|
157
|
-
// now, with the deep link when appUrl is configured and the CLI command when not.
|
|
158
105
|
const approvalUrl = decisionUrl(config.appUrl, auth.decisionId);
|
|
159
106
|
if (config.onPending)
|
|
160
107
|
config.onPending({ decisionId: auth.decisionId, receiptId: auth.receiptId, url: approvalUrl });
|
|
161
108
|
else
|
|
162
109
|
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}`}`);
|
|
163
|
-
// Durable transport: park the args agent-side, register the raw fn so a (cold)
|
|
164
|
-
// resume can replay it, and SUSPEND. The process may exit now; the controller's
|
|
165
|
-
// webhook handler or poll worker re-executes THIS fn when a human approves.
|
|
166
|
-
// Decidio never executes — it only signals.
|
|
167
110
|
if (config.resume) {
|
|
168
|
-
// Propagate the agent's signing identity to the resume controller so the eventual
|
|
169
|
-
// (possibly cold) confirm on resume is SIGNED too — earning application_confirmed on
|
|
170
|
-
// the durable path, not just inline. (No-op if the controller already has its own.)
|
|
171
111
|
config.resume.agentId ??= config.agentId;
|
|
172
112
|
config.resume.agentKey ??= config.agentKey;
|
|
173
113
|
config.resume.agentDid ??= config.agentDid;
|
|
174
114
|
config.resume.register(ctx.action, fn);
|
|
175
|
-
await config.resume.
|
|
115
|
+
await config.resume.park({
|
|
176
116
|
decisionId: auth.decisionId, receiptId: auth.receiptId,
|
|
177
|
-
action: ctx.action, amount: ctx.amount, scope: ctx.scope,
|
|
178
|
-
args, createdAt: new Date().toISOString(),
|
|
117
|
+
action: ctx.action, amount: ctx.amount, scope: ctx.scope, args,
|
|
179
118
|
});
|
|
180
119
|
throw new DecidioSuspendedError(auth.decisionId, ctx.action);
|
|
181
120
|
}
|
|
182
|
-
// Interactive transport (no resume configured): block until decided. Only
|
|
183
|
-
// appropriate for short, watched approvals (demos, synchronous tools).
|
|
184
121
|
const deadline = Date.now() + pollTimeout;
|
|
185
|
-
// eslint-disable-next-line no-constant-condition
|
|
186
122
|
while (true) {
|
|
187
123
|
const st = await get(config, `/agent/status/${encodeURIComponent(auth.decisionId)}`);
|
|
188
124
|
if (st.state === "approved" || st.state === "auto_approved")
|
|
@@ -192,39 +128,25 @@ export function withApproval(fn, describe, config) {
|
|
|
192
128
|
if (st.state === "blocked")
|
|
193
129
|
throw new DecidioBlockedError(st.reason ?? "blocked", auth.decisionId);
|
|
194
130
|
if (Date.now() > deadline) {
|
|
195
|
-
// The founder-visible failure of blocking: the wait has a ceiling, and past it the
|
|
196
|
-
// work is LOST — the decision stays open in Decidio but nothing is parked to resume.
|
|
197
|
-
// Say so, say the limit, and say the durable way out.
|
|
198
131
|
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.`);
|
|
199
132
|
throw new DecidioTimeoutError(auth.decisionId);
|
|
200
133
|
}
|
|
201
134
|
await sleep(pollInterval);
|
|
202
135
|
}
|
|
203
136
|
}
|
|
204
|
-
// 3. authorized inline (proceed, or interactive route just approved) → run the
|
|
205
|
-
// REAL action and capture the source response (listener)
|
|
206
137
|
const result = await fn(...args);
|
|
207
|
-
// 4. report the captured outcome → Decidio seals application_confirmed
|
|
208
|
-
// (server minimizes + tokenizes the response before sealing). Best-effort:
|
|
209
|
-
// a confirmation failure must not undo a successful business action.
|
|
210
138
|
try {
|
|
211
|
-
// Sign the confirmation too (bound to {action:"confirm", scope:decisionId}) so the gate
|
|
212
|
-
// can PROVE this is the receipt's original requester before granting the wrapper tier
|
|
213
|
-
// (application_confirmed). Without a key the report is capped at agent_asserted server-side.
|
|
214
139
|
const confirmIdentity = signConfirm(auth.decisionId, config);
|
|
215
140
|
await post(config, "/agent/confirm", {
|
|
216
141
|
decisionId: auth.decisionId,
|
|
217
142
|
capturedResponse: result ?? null,
|
|
218
|
-
source: "wrapper",
|
|
143
|
+
source: "wrapper",
|
|
219
144
|
...(confirmIdentity ? { requesterIdentity: confirmIdentity } : {}),
|
|
220
145
|
});
|
|
221
|
-
// An UNSIGNED confirm is server-capped at the weaker agent_asserted tier. Say so at the
|
|
222
|
-
// moment it happens — a silent evidence downgrade was round 3's acceptance FAIL.
|
|
223
146
|
if (!confirmIdentity)
|
|
224
147
|
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.`);
|
|
225
148
|
}
|
|
226
149
|
catch (e) {
|
|
227
|
-
// surface but don't throw — the action already happened
|
|
228
150
|
console.warn(`[decidio] confirmation post failed (action succeeded): ${e.message}`);
|
|
229
151
|
}
|
|
230
152
|
return result;
|
package/dist/inngest.d.ts
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import type { RequestListener } from "node:http";
|
|
2
2
|
import { verifyResumeSignature } from "./resume.js";
|
|
3
3
|
import type { GuardConfig, ApprovalContext } from "./index.js";
|
|
4
|
-
/** The Inngest event Decidio's verdict is translated into. The waiting function
|
|
5
|
-
* correlates on `data.decisionId`. */
|
|
6
4
|
export declare const DECIDIO_RESUME_EVENT = "decidio/decision.resolved";
|
|
7
|
-
/** The minimal slices of the Inngest step + client APIs we use, typed structurally so
|
|
8
|
-
* this file doesn't force an `inngest` type dependency on the core build. */
|
|
9
5
|
export interface InngestStepLike {
|
|
10
6
|
run<T>(id: string, fn: () => Promise<T>): Promise<T>;
|
|
11
7
|
waitForEvent(id: string, opts: {
|
|
@@ -29,16 +25,6 @@ export type GateStepOutcome<R> = {
|
|
|
29
25
|
decisionId: string;
|
|
30
26
|
result?: R;
|
|
31
27
|
};
|
|
32
|
-
/**
|
|
33
|
-
* Gate an action INSIDE an Inngest function, with durable suspend on routing.
|
|
34
|
-
* • proceed → run the action (as a step) + confirm.
|
|
35
|
-
* • route → `step.waitForEvent` durably suspends until Decidio's resume event for
|
|
36
|
-
* THIS decision arrives; approved → run + confirm; rejected → status.
|
|
37
|
-
* • block → return blocked (the action never runs).
|
|
38
|
-
* The action runs in YOUR process with YOUR credentials — Decidio only signals.
|
|
39
|
-
* `config.resumeUrl` must point at the bridge endpoint (createInngestResumeBridge),
|
|
40
|
-
* so Decidio's webhook is translated into the event this step waits on.
|
|
41
|
-
*/
|
|
42
28
|
export declare function decidioGateStep<R>(args: {
|
|
43
29
|
step: InngestStepLike;
|
|
44
30
|
config: GuardConfig;
|
|
@@ -46,12 +32,6 @@ export declare function decidioGateStep<R>(args: {
|
|
|
46
32
|
run: () => Promise<R>;
|
|
47
33
|
timeout?: string;
|
|
48
34
|
}): Promise<GateStepOutcome<R>>;
|
|
49
|
-
/**
|
|
50
|
-
* Decidio → Inngest bridge. Mount as an HTTP endpoint and register it as the agent's
|
|
51
|
-
* `resumeUrl`. It verifies Decidio's signed resume webhook (same HMAC as the core
|
|
52
|
-
* handler) and emits `DECIDIO_RESUME_EVENT` into Inngest, waking the suspended
|
|
53
|
-
* `decidioGateStep`. Fails CLOSED without a configured secret (unless allowUnsigned).
|
|
54
|
-
*/
|
|
55
35
|
export declare function createInngestResumeBridge(opts: {
|
|
56
36
|
inngest: InngestClientLike;
|
|
57
37
|
webhookSecret?: string;
|
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
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import type { RequestListener } from "node:http";
|
|
2
|
-
/** A routed action parked on the agent side, awaiting human approval. The `args`
|
|
3
|
-
* are the original call arguments, replayed verbatim when the action resumes. */
|
|
4
2
|
export interface PendingAction {
|
|
5
3
|
decisionId: string;
|
|
6
4
|
receiptId?: string;
|
|
@@ -9,22 +7,17 @@ export interface PendingAction {
|
|
|
9
7
|
scope?: string;
|
|
10
8
|
args: unknown[];
|
|
11
9
|
createdAt: string;
|
|
10
|
+
argsSig?: {
|
|
11
|
+
kind: "ed25519" | "hmac";
|
|
12
|
+
sig: string;
|
|
13
|
+
};
|
|
12
14
|
}
|
|
13
|
-
/** Where suspended actions live between request and approval. The default is a
|
|
14
|
-
* file store; swap in Redis/Postgres/your queue for production. AGENT-SIDE only —
|
|
15
|
-
* Decidio never sees this. */
|
|
16
15
|
export interface PendingStore {
|
|
17
16
|
put(p: PendingAction): Promise<void>;
|
|
18
17
|
get(decisionId: string): Promise<PendingAction | null>;
|
|
19
18
|
delete(decisionId: string): Promise<void>;
|
|
20
19
|
list(): Promise<PendingAction[]>;
|
|
21
|
-
/** Atomically take ownership of a parked action for execution. Returns true if THIS
|
|
22
|
-
* caller claimed it, false if it's already claimed/gone — the cross-process single-use
|
|
23
|
-
* guarantee (a production store backs this with rename/SETNX/SELECT-FOR-UPDATE). A
|
|
24
|
-
* claimed entry is excluded from list(). Optional: if absent, the controller falls
|
|
25
|
-
* back to an in-process guard (same-process only). */
|
|
26
20
|
claim?(decisionId: string): Promise<boolean>;
|
|
27
|
-
/** Undo a claim so a failed execution is retryable. */
|
|
28
21
|
release?(decisionId: string): Promise<void>;
|
|
29
22
|
}
|
|
30
23
|
export declare class MemoryPendingStore implements PendingStore {
|
|
@@ -37,11 +30,6 @@ export declare class MemoryPendingStore implements PendingStore {
|
|
|
37
30
|
claim(id: string): Promise<boolean>;
|
|
38
31
|
release(id: string): Promise<void>;
|
|
39
32
|
}
|
|
40
|
-
/** File-backed store so a COLD process (the original requester long gone) can still
|
|
41
|
-
* resume — the proof that nothing is "kept alive". One JSON file per decision. A claim
|
|
42
|
-
* renames `<id>.json` → `<id>.claimed` atomically (same-volume rename), so two processes
|
|
43
|
-
* sharing the dir can't both execute. (A stale `.claimed` from a crash mid-execute needs
|
|
44
|
-
* TTL recovery — tracked in the backlog.) */
|
|
45
33
|
export declare class FilePendingStore implements PendingStore {
|
|
46
34
|
private dir;
|
|
47
35
|
constructor(dir: string);
|
|
@@ -55,10 +43,6 @@ export declare class FilePendingStore implements PendingStore {
|
|
|
55
43
|
claim(id: string): Promise<boolean>;
|
|
56
44
|
release(id: string): Promise<void>;
|
|
57
45
|
}
|
|
58
|
-
/** Thrown by a `withApproval`-wrapped call when the action is routed and configured
|
|
59
|
-
* for async resume: the action is now SUSPENDED (parked in the store), not failed.
|
|
60
|
-
* The caller can stop here — the process may even exit; the resume transport will
|
|
61
|
-
* complete it later. */
|
|
62
46
|
export declare class DecidioSuspendedError extends Error {
|
|
63
47
|
decisionId: string;
|
|
64
48
|
action: string;
|
|
@@ -72,58 +56,30 @@ export interface ResumeOutcome {
|
|
|
72
56
|
result?: unknown;
|
|
73
57
|
reason?: string;
|
|
74
58
|
}
|
|
75
|
-
/** Minimal config the controller needs to confirm outcomes + poll status. Shared
|
|
76
|
-
* with GuardConfig (apiUrl/apiToken). */
|
|
77
59
|
export interface ResumeConfig {
|
|
78
60
|
apiUrl: string;
|
|
79
61
|
apiToken?: string;
|
|
80
|
-
/** Agent identity for SIGNING the confirm call on resume (so the durable path can earn the
|
|
81
|
-
* application_confirmed tier, not just the inline path). Default from DECIDIO_AGENT_ID/
|
|
82
|
-
* _KEY/_DID env when unset — a cold resume process that has the env signs automatically. */
|
|
83
62
|
agentId?: string;
|
|
84
63
|
agentKey?: string;
|
|
85
64
|
agentDid?: string;
|
|
86
|
-
/** The workspace the agent acts in (DECIDIO_WORKSPACE_ID) — bound into the cold-resume confirm
|
|
87
|
-
* proof (D-B3.6) so it's tenant-scoped too. Default from env when unset. */
|
|
88
65
|
workspaceId?: string;
|
|
89
|
-
/** Shared secret Decidio signs the resume webhook with (HMAC-SHA256). Defaults to
|
|
90
|
-
* the API token if unset. Verified before any re-execution. */
|
|
91
66
|
webhookSecret?: string;
|
|
92
|
-
/** Escape hatch to accept UNSIGNED resume webhooks (no secret). Off by default:
|
|
93
|
-
* with no secret and this unset, the webhook handler rejects every call (fail
|
|
94
|
-
* closed) so a misconfigured agent can't be driven by forged callbacks. Only set
|
|
95
|
-
* true behind a trusted network boundary (e.g. mTLS / private mesh). */
|
|
96
67
|
allowUnsigned?: boolean;
|
|
97
|
-
/** The callback URL Decidio should POST the verdict to (webhook transport). Sent
|
|
98
|
-
* on /agent/authorize so the decision carries it; omit when using the poll worker. */
|
|
99
68
|
resumeUrl?: string;
|
|
100
|
-
/** Fired after every terminal resolution (executed / rejected / blocked / no_handler),
|
|
101
|
-
* via either transport. Use it to log, close a listener, or exit a CLI. */
|
|
102
69
|
onResolved?: (o: ResumeOutcome) => void;
|
|
103
70
|
fetchImpl?: typeof fetch;
|
|
104
71
|
}
|
|
105
|
-
/** Re-execute the agent's own fn on approval, confirm the captured outcome to
|
|
106
|
-
* Decidio, and clear the parked entry. Bound to a store + a registry of raw fns. */
|
|
107
72
|
export interface ResumeController extends ResumeConfig {
|
|
108
73
|
store: PendingStore;
|
|
109
|
-
/** Register a RAW fn under its action name so a (possibly cold) resume can replay
|
|
110
|
-
* it. `withApproval` auto-registers on the hot path; a cold worker/service should
|
|
111
|
-
* register at startup so it can resume actions it never originated. */
|
|
112
74
|
register(action: string, fn: (...args: any[]) => Promise<any>): void;
|
|
113
|
-
|
|
114
|
-
* a second call after success finds nothing parked → "unknown" (no double-write). */
|
|
75
|
+
park(p: Omit<PendingAction, "createdAt" | "argsSig">): Promise<void>;
|
|
115
76
|
resolve(input: {
|
|
116
77
|
decisionId: string;
|
|
117
78
|
verdict: "approved" | "rejected" | "blocked";
|
|
118
79
|
reason?: string;
|
|
119
80
|
}): Promise<ResumeOutcome>;
|
|
120
|
-
/** node:http listener for Decidio's signed approval webhook (Express/Fastify/node). */
|
|
121
81
|
webhookHandler(): RequestListener;
|
|
122
|
-
/** Web-Fetch handler (Request → Response) for Next.js route handlers, Hono,
|
|
123
|
-
* Cloudflare Workers, Bun, Deno. Same fail-closed signature check. */
|
|
124
82
|
fetchHandler(): (req: Request) => Promise<Response>;
|
|
125
|
-
/** Poll-based resume for hosts with no inbound URL (CLI/laptop). A long-lived
|
|
126
|
-
* watcher — this is a durable WORKER, not the request path blocking. */
|
|
127
83
|
worker(opts?: {
|
|
128
84
|
intervalMs?: number;
|
|
129
85
|
timeoutMs?: number;
|
|
@@ -133,19 +89,7 @@ export interface ResumeController extends ResumeConfig {
|
|
|
133
89
|
};
|
|
134
90
|
}
|
|
135
91
|
declare function sign(secret: string, body: string): string;
|
|
136
|
-
/** Verify Decidio's `x-decidio-signature` over the raw webhook body (HMAC-SHA256).
|
|
137
|
-
* Exported so engine adapters (e.g. the Inngest bridge) reuse the exact check. */
|
|
138
92
|
export declare function verifyResumeSignature(secret: string, body: string, header: string | undefined): boolean;
|
|
139
|
-
/**
|
|
140
|
-
* Build the resume controller. Spread it into your GuardConfig as `resume`, then
|
|
141
|
-
* either mount `webhookHandler()` (production) or run `worker()` (CLI/laptop).
|
|
142
|
-
*
|
|
143
|
-
* const resume = createDecidioResume({ apiUrl, apiToken, store: new FilePendingStore(".decidio-pending"), resumeUrl });
|
|
144
|
-
* const guard = { apiUrl, agentId, apiToken, resume };
|
|
145
|
-
* const createOpportunity = withApproval(createOpportunityRaw, describe, guard);
|
|
146
|
-
* // production: http.createServer(resume.webhookHandler()).listen(4100)
|
|
147
|
-
* // or CLI: resume.worker()
|
|
148
|
-
*/
|
|
149
93
|
export declare function createDecidioResume(opts: ResumeConfig & {
|
|
150
94
|
store: PendingStore;
|
|
151
95
|
}): ResumeController;
|