@decidio/sdk 0.1.8 → 0.1.9
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 +34 -12
- package/dist/adapters/_gate.d.ts +2 -0
- package/dist/adapters/_gate.js +25 -2
- package/dist/adapters/langgraph.d.ts +13 -0
- package/dist/adapters/langgraph.js +14 -0
- package/dist/adapters/openai.d.ts +24 -0
- package/dist/adapters/openai.js +4 -0
- package/dist/adapters/temporal.d.ts +16 -0
- package/dist/adapters/temporal.js +17 -1
- package/dist/cli.js +372 -169
- package/dist/guard.d.ts +29 -0
- package/dist/guard.js +25 -0
- package/dist/index.d.ts +48 -2
- package/dist/index.js +103 -7
- package/dist/inngest.d.ts +20 -0
- package/dist/inngest.js +26 -0
- package/dist/resume.d.ts +89 -0
- package/dist/resume.js +157 -18
- package/dist/signing.d.ts +20 -0
- package/dist/signing.js +73 -0
- package/openapi.yaml +1 -1
- package/package.json +8 -2
package/dist/guard.d.ts
CHANGED
|
@@ -8,25 +8,53 @@ export interface GuardFacadeConfig {
|
|
|
8
8
|
resumeUrl?: string;
|
|
9
9
|
webhookSecret?: string;
|
|
10
10
|
pendingDir?: string;
|
|
11
|
+
/** Local signing key (DECIDIO_AGENT_KEY) + public did (DECIDIO_AGENT_DID), from `init`.
|
|
12
|
+
* When present the gate authorizes against a PROVEN identity, not a claimed label. */
|
|
11
13
|
agentKey?: string;
|
|
12
14
|
agentDid?: string;
|
|
15
|
+
/** The workspace this agent acts in (DECIDIO_WORKSPACE_ID). Bound into the signed proof (D-B3.6)
|
|
16
|
+
* so a captured proof can't be replayed in another tenant. */
|
|
13
17
|
workspaceId?: string;
|
|
18
|
+
/** Base URL of the Decidio app (DECIDIO_APP_URL) — used to deep-link a routed decision. */
|
|
14
19
|
appUrl?: string;
|
|
15
20
|
fetchImpl?: typeof fetch;
|
|
16
21
|
}
|
|
17
22
|
export interface ProtectOptions {
|
|
23
|
+
/** null/"generic" → the core gate; engine adapters (langgraph/openai/temporal/inngest/mcp)
|
|
24
|
+
* live in @decidio/sdk/adapters and take the engine handle. */
|
|
18
25
|
adapter?: "generic" | "langgraph" | "openai" | "temporal" | "inngest" | "mcp";
|
|
26
|
+
/** "durable" (default) suspends for async approval; "blocking" polls (short approvals only). */
|
|
19
27
|
mode?: "durable" | "blocking";
|
|
28
|
+
/** The action name this wrapper produces, declared UP FRONT so the resume registry is populated
|
|
29
|
+
* at WRAP time rather than on first call. Required for the cold-worker pattern: after a
|
|
30
|
+
* restart, `guard.worker()` can only re-execute actions registered in THIS process, and
|
|
31
|
+
* `describe` (which names the action) cannot run without the original call arguments. Declare
|
|
32
|
+
* it and a restarted process resumes work it never originated. */
|
|
20
33
|
action?: string;
|
|
34
|
+
/** Blocking-mode poll ceiling in ms (default 600000 = 10 min). Documented since 0.1.0 but only
|
|
35
|
+
* reachable via the low-level withApproval config until now (round-2 blind catch: passing it
|
|
36
|
+
* here was SILENTLY ignored and CI pipelines blocked the full 10 minutes instead of failing
|
|
37
|
+
* fast — and the Python twin rejected the same call loudly, a twin divergence). */
|
|
38
|
+
pollTimeoutMs?: number;
|
|
39
|
+
/** Blocking-mode poll interval in ms (default 3000). */
|
|
40
|
+
pollIntervalMs?: number;
|
|
21
41
|
}
|
|
22
42
|
export declare class Guard {
|
|
23
43
|
config: GuardFacadeConfig;
|
|
24
44
|
resume?: ResumeController;
|
|
25
45
|
constructor(config: GuardFacadeConfig, resume?: ResumeController);
|
|
26
46
|
static fromEnv(): Guard;
|
|
47
|
+
/** Register a raw fn under its action name so a COLD process (one that never made the original
|
|
48
|
+
* call) can resume it. Call this at startup beside `guard.worker()` — or pass
|
|
49
|
+
* `{ action }` to `protect(...)`, which does it for you at wrap time. */
|
|
27
50
|
register(action: string, fn: (...a: any[]) => Promise<any>): void;
|
|
51
|
+
/** Mount on a node/Express/Fastify route: `app.post("/decidio/resume", guard.resumeHandler())`. */
|
|
28
52
|
resumeHandler(): import("http").RequestListener;
|
|
53
|
+
/** Mount on a Next.js/Hono/CF/Bun route: `export const POST = (req) => guard.resumeFetchHandler()(req)`. */
|
|
29
54
|
resumeFetchHandler(): (req: Request) => Promise<Response>;
|
|
55
|
+
/** Durable resume with NOTHING to host: a background watcher polls Decidio outward and finishes
|
|
56
|
+
* any parked approval — so a suspended action survives a process restart. One line at startup.
|
|
57
|
+
* Production may swap this for the webhook (resumeUrl); the suspend/resume shape is identical. */
|
|
30
58
|
worker(opts?: {
|
|
31
59
|
intervalMs?: number;
|
|
32
60
|
timeoutMs?: number;
|
|
@@ -37,4 +65,5 @@ export declare class Guard {
|
|
|
37
65
|
private ensureResume;
|
|
38
66
|
protect<A extends any[], R>(fn: (...a: A) => Promise<R>, describe: (...a: A) => ApprovalContext, opts?: ProtectOptions): (...a: A) => Promise<R>;
|
|
39
67
|
}
|
|
68
|
+
/** Preconfigured singleton — `import { guard } from "@decidio/sdk"`. */
|
|
40
69
|
export declare const guard: Guard;
|
package/dist/guard.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
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.
|
|
1
5
|
import { withApproval } from "./index.js";
|
|
2
6
|
import { createDecidioResume, FilePendingStore } from "./resume.js";
|
|
3
7
|
export class Guard {
|
|
@@ -22,9 +26,17 @@ export class Guard {
|
|
|
22
26
|
appUrl: process.env.DECIDIO_APP_URL,
|
|
23
27
|
});
|
|
24
28
|
}
|
|
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. */
|
|
25
32
|
register(action, fn) { this.ensureResume().register(action, fn); }
|
|
33
|
+
/** Mount on a node/Express/Fastify route: `app.post("/decidio/resume", guard.resumeHandler())`. */
|
|
26
34
|
resumeHandler() { return this.ensureResume().webhookHandler(); }
|
|
35
|
+
/** Mount on a Next.js/Hono/CF/Bun route: `export const POST = (req) => guard.resumeFetchHandler()(req)`. */
|
|
27
36
|
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. */
|
|
28
40
|
worker(opts) {
|
|
29
41
|
return this.ensureResume().worker(opts);
|
|
30
42
|
}
|
|
@@ -34,6 +46,8 @@ export class Guard {
|
|
|
34
46
|
this.resume = createDecidioResume({
|
|
35
47
|
apiUrl: c.apiUrl, apiToken: c.apiToken, webhookSecret: c.webhookSecret,
|
|
36
48
|
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).
|
|
37
51
|
agentId: c.agentId, agentKey: c.agentKey, agentDid: c.agentDid, workspaceId: c.workspaceId,
|
|
38
52
|
fetchImpl: c.fetchImpl,
|
|
39
53
|
});
|
|
@@ -42,22 +56,33 @@ export class Guard {
|
|
|
42
56
|
}
|
|
43
57
|
protect(fn, describe, opts) {
|
|
44
58
|
if (opts?.adapter && opts.adapter !== "generic") {
|
|
59
|
+
// Engine adapters are bound explicitly via @decidio/sdk/adapters (they need the
|
|
60
|
+
// engine handle at call time). Surfaced as a clear error until wired in this build.
|
|
45
61
|
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.`);
|
|
46
62
|
}
|
|
47
63
|
const c = this.config;
|
|
48
64
|
const base = {
|
|
49
65
|
apiUrl: c.apiUrl, agentId: c.agentId, apiToken: c.apiToken,
|
|
50
66
|
sourceSystem: c.sourceSystem, agentKey: c.agentKey, agentDid: c.agentDid, workspaceId: c.workspaceId, appUrl: c.appUrl, fetchImpl: c.fetchImpl,
|
|
67
|
+
pollTimeoutMs: opts?.pollTimeoutMs, pollIntervalMs: opts?.pollIntervalMs,
|
|
51
68
|
};
|
|
52
69
|
if ((opts?.mode ?? "durable") === "blocking") {
|
|
53
70
|
console.warn("[decidio] WARNING: blocking wait — only safe for short, watched approvals. " +
|
|
54
71
|
"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.
|
|
55
74
|
return withApproval(fn, describe, base);
|
|
56
75
|
}
|
|
76
|
+
// durable: refuse-don't-downgrade — always wire a resume transport, never silently block.
|
|
57
77
|
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).
|
|
58
82
|
if (opts?.action)
|
|
59
83
|
resume.register(opts.action, fn);
|
|
60
84
|
return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume });
|
|
61
85
|
}
|
|
62
86
|
}
|
|
87
|
+
/** Preconfigured singleton — `import { guard } from "@decidio/sdk"`. */
|
|
63
88
|
export const guard = Guard.fromEnv();
|
package/dist/index.d.ts
CHANGED
|
@@ -1,27 +1,54 @@
|
|
|
1
1
|
import type { ResumeController } from "./resume.js";
|
|
2
|
-
export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume } from "./resume.js";
|
|
2
|
+
export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
|
|
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 } from "./signing.js";
|
|
6
|
+
export { generateKeypair, signBoundProof, signConfirm, canonicalize, assertJsonSafe } 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". */
|
|
9
10
|
apiUrl: string;
|
|
11
|
+
/** The agent identity registered with Decidio (onboarding wizard), e.g. "salesforce/SalesforceBot". */
|
|
10
12
|
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. */
|
|
11
15
|
apiToken?: string;
|
|
16
|
+
/** Source system label sealed onto the receipt (e.g. "salesforce"). */
|
|
12
17
|
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. */
|
|
13
22
|
agentKey?: string;
|
|
23
|
+
/** The agent's PUBLIC did:key (the one registered with Decidio). Pairs with `agentKey`. */
|
|
14
24
|
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. */
|
|
15
28
|
workspaceId?: string;
|
|
29
|
+
/** Poll cadence + ceiling while a routed action awaits human approval. Used ONLY
|
|
30
|
+
* by the blocking transport (no `resume` configured). */
|
|
16
31
|
pollIntervalMs?: number;
|
|
17
32
|
pollTimeoutMs?: number;
|
|
33
|
+
/** Optional async notifier so a long human wait surfaces in your logs/UI. */
|
|
18
34
|
onPending?: (info: {
|
|
19
35
|
decisionId: string;
|
|
20
36
|
receiptId: string;
|
|
21
37
|
url: string | null;
|
|
22
38
|
}) => 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. */
|
|
23
43
|
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. */
|
|
24
48
|
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. */
|
|
25
52
|
resumeUrl?: string;
|
|
26
53
|
fetchImpl?: typeof fetch;
|
|
27
54
|
}
|
|
@@ -29,6 +56,9 @@ export interface ApprovalContext {
|
|
|
29
56
|
action: string;
|
|
30
57
|
amount: number;
|
|
31
58
|
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. */
|
|
32
62
|
context?: Record<string, string | number>;
|
|
33
63
|
}
|
|
34
64
|
export declare class DecidioBlockedError extends Error {
|
|
@@ -45,6 +75,15 @@ export declare class DecidioTimeoutError extends Error {
|
|
|
45
75
|
decisionId: string;
|
|
46
76
|
constructor(decisionId: string);
|
|
47
77
|
}
|
|
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
|
+
/** The workspace's governed-action allowance is exhausted (HTTP 429, code AGENT_RATE). The
|
|
83
|
+
* server says exactly how much and until when — relayed here instead of discarded (round-2
|
|
84
|
+
* blind catch: this surfaced as a bare `Error` with no decisionId, the tour misdiagnosed it as
|
|
85
|
+
* a connectivity failure, and doctor then corroborated the wrong suspect). Nothing was routed,
|
|
86
|
+
* recorded, or executed; retry after `resetAt`. */
|
|
48
87
|
export declare class DecidioRateLimitError extends Error {
|
|
49
88
|
limit: number | undefined;
|
|
50
89
|
remaining: number | undefined;
|
|
@@ -57,5 +96,12 @@ export declare class DecidioNetworkError extends Error {
|
|
|
57
96
|
code: string | undefined;
|
|
58
97
|
constructor(endpoint: string, op: string, code: string | undefined, cause: unknown);
|
|
59
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Wrap an async action behind the Decidio gate. `describe` extracts the approval
|
|
101
|
+
* context (action, amount, scope) from the call arguments. Returns a function with
|
|
102
|
+
* the SAME signature as `fn` — drop-in.
|
|
103
|
+
*/
|
|
104
|
+
/** `<appUrl>/d/<decisionId>` — the deep link that opens this decision directly, on the web app or
|
|
105
|
+
* the phone build. Null when DECIDIO_APP_URL is not configured, never a half-built URL. */
|
|
60
106
|
export declare function decisionUrl(appUrl: string | undefined, decisionId: string): string | null;
|
|
61
107
|
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,8 +1,37 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
+
import { DecidioSuspendedError, assertConfirmRecorded } from "./resume.js";
|
|
31
|
+
import { signBoundProof, signConfirm, assertJsonSafe } from "./signing.js";
|
|
32
|
+
export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
|
|
4
33
|
export { guard, Guard } from "./guard.js";
|
|
5
|
-
export { generateKeypair, signBoundProof, signConfirm, canonicalize } from "./signing.js";
|
|
34
|
+
export { generateKeypair, signBoundProof, signConfirm, canonicalize, assertJsonSafe } from "./signing.js";
|
|
6
35
|
export class DecidioBlockedError extends Error {
|
|
7
36
|
reason;
|
|
8
37
|
decisionId;
|
|
@@ -32,6 +61,15 @@ export class DecidioTimeoutError extends Error {
|
|
|
32
61
|
}
|
|
33
62
|
}
|
|
34
63
|
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`. */
|
|
35
73
|
export class DecidioRateLimitError extends Error {
|
|
36
74
|
limit;
|
|
37
75
|
remaining;
|
|
@@ -50,6 +88,9 @@ export class DecidioNetworkError extends Error {
|
|
|
50
88
|
op;
|
|
51
89
|
code;
|
|
52
90
|
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.
|
|
53
94
|
super(`[decidio] could not reach ${endpoint} during ${op}${code ? ` (${code})` : ""}. ` +
|
|
54
95
|
(/localhost|127\.0\.0\.1/.test(endpoint) && code === "ECONNREFUSED"
|
|
55
96
|
? `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). `
|
|
@@ -96,6 +137,13 @@ async function get(cfg, path) {
|
|
|
96
137
|
throw new Error(`decidio ${path} ${res.status}`);
|
|
97
138
|
return res.json();
|
|
98
139
|
}
|
|
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. */
|
|
99
147
|
export function decisionUrl(appUrl, decisionId) {
|
|
100
148
|
if (!appUrl)
|
|
101
149
|
return null;
|
|
@@ -107,9 +155,16 @@ export function withApproval(fn, describe, config) {
|
|
|
107
155
|
const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
|
|
108
156
|
return async (...args) => {
|
|
109
157
|
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.
|
|
110
162
|
const requesterIdentity = (config.agentKey && config.agentDid)
|
|
111
163
|
? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
|
|
112
164
|
: undefined;
|
|
165
|
+
// The described request must ALSO be JSON-safe (prodsec F-01): a NaN amount would reach the
|
|
166
|
+
// human as null — they would approve something other than what the caller described.
|
|
167
|
+
assertJsonSafe(ctx, "describe(...)");
|
|
113
168
|
const auth = await post(config, "/agent/authorize", {
|
|
114
169
|
requester: config.agentId, action: ctx.action, amount: ctx.amount,
|
|
115
170
|
scope: ctx.scope, sourceSystem: config.sourceSystem,
|
|
@@ -117,28 +172,52 @@ export function withApproval(fn, describe, config) {
|
|
|
117
172
|
...(requesterIdentity ? { requesterIdentity } : {}),
|
|
118
173
|
...(resumeUrl ? { resumeUrl } : {}),
|
|
119
174
|
});
|
|
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.
|
|
120
178
|
if (auth.decision !== "proceed" && auth.decision !== "route" && auth.decision !== "block")
|
|
121
179
|
throw new DecidioBlockedError(`unrecognized gate verdict: ${String(auth.decision)}`, auth.decisionId);
|
|
122
180
|
if (auth.decision === "block")
|
|
123
181
|
throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
|
|
182
|
+
// 2. routed → either SUSPEND for async resume (durable) or BLOCK-poll (interactive)
|
|
124
183
|
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.
|
|
125
189
|
const approvalUrl = decisionUrl(config.appUrl, auth.decisionId);
|
|
126
190
|
if (config.onPending)
|
|
127
191
|
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).
|
|
128
195
|
else
|
|
129
|
-
console.
|
|
196
|
+
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.
|
|
130
201
|
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.)
|
|
131
205
|
config.resume.agentId ??= config.agentId;
|
|
132
206
|
config.resume.agentKey ??= config.agentKey;
|
|
133
207
|
config.resume.agentDid ??= config.agentDid;
|
|
134
208
|
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).
|
|
135
211
|
await config.resume.park({
|
|
136
212
|
decisionId: auth.decisionId, receiptId: auth.receiptId,
|
|
137
213
|
action: ctx.action, amount: ctx.amount, scope: ctx.scope, args,
|
|
138
214
|
});
|
|
139
215
|
throw new DecidioSuspendedError(auth.decisionId, ctx.action);
|
|
140
216
|
}
|
|
217
|
+
// Interactive transport (no resume configured): block until decided. Only
|
|
218
|
+
// appropriate for short, watched approvals (demos, synchronous tools).
|
|
141
219
|
const deadline = Date.now() + pollTimeout;
|
|
220
|
+
// eslint-disable-next-line no-constant-condition
|
|
142
221
|
while (true) {
|
|
143
222
|
const st = await get(config, `/agent/status/${encodeURIComponent(auth.decisionId)}`);
|
|
144
223
|
if (st.state === "approved" || st.state === "auto_approved")
|
|
@@ -148,25 +227,42 @@ export function withApproval(fn, describe, config) {
|
|
|
148
227
|
if (st.state === "blocked")
|
|
149
228
|
throw new DecidioBlockedError(st.reason ?? "blocked", auth.decisionId);
|
|
150
229
|
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.
|
|
151
233
|
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.`);
|
|
152
234
|
throw new DecidioTimeoutError(auth.decisionId);
|
|
153
235
|
}
|
|
154
236
|
await sleep(pollInterval);
|
|
155
237
|
}
|
|
156
238
|
}
|
|
239
|
+
// 3. authorized inline (proceed, or interactive route just approved) → run the
|
|
240
|
+
// REAL action and capture the source response (listener)
|
|
157
241
|
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.
|
|
158
245
|
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.
|
|
159
249
|
const confirmIdentity = signConfirm(auth.decisionId, config);
|
|
160
|
-
await post(config, "/agent/confirm", {
|
|
250
|
+
const confirmBody = await post(config, "/agent/confirm", {
|
|
161
251
|
decisionId: auth.decisionId,
|
|
162
252
|
capturedResponse: result ?? null,
|
|
163
|
-
source: "wrapper",
|
|
253
|
+
source: "wrapper", // the SDK captured the genuine response, not an agent self-report
|
|
164
254
|
...(confirmIdentity ? { requesterIdentity: confirmIdentity } : {}),
|
|
165
255
|
});
|
|
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
|
+
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.
|
|
166
261
|
if (!confirmIdentity)
|
|
167
262
|
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.`);
|
|
168
263
|
}
|
|
169
264
|
catch (e) {
|
|
265
|
+
// surface but don't throw — the action already happened
|
|
170
266
|
console.warn(`[decidio] confirmation post failed (action succeeded): ${e.message}`);
|
|
171
267
|
}
|
|
172
268
|
return result;
|
package/dist/inngest.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
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`. */
|
|
4
6
|
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. */
|
|
5
9
|
export interface InngestStepLike {
|
|
6
10
|
run<T>(id: string, fn: () => Promise<T>): Promise<T>;
|
|
7
11
|
waitForEvent(id: string, opts: {
|
|
@@ -25,6 +29,16 @@ export type GateStepOutcome<R> = {
|
|
|
25
29
|
decisionId: string;
|
|
26
30
|
result?: R;
|
|
27
31
|
};
|
|
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
|
+
*/
|
|
28
42
|
export declare function decidioGateStep<R>(args: {
|
|
29
43
|
step: InngestStepLike;
|
|
30
44
|
config: GuardConfig;
|
|
@@ -32,6 +46,12 @@ export declare function decidioGateStep<R>(args: {
|
|
|
32
46
|
run: () => Promise<R>;
|
|
33
47
|
timeout?: string;
|
|
34
48
|
}): 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
|
+
*/
|
|
35
55
|
export declare function createInngestResumeBridge(opts: {
|
|
36
56
|
inngest: InngestClientLike;
|
|
37
57
|
webhookSecret?: string;
|
package/dist/inngest.js
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
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`. */
|
|
3
5
|
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
|
+
*/
|
|
4
16
|
export async function decidioGateStep(args) {
|
|
5
17
|
const { step, config, ctx } = args;
|
|
6
18
|
const base = config.apiUrl.replace(/\/$/, "");
|
|
7
19
|
const f = config.fetchImpl ?? fetch;
|
|
8
20
|
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)
|
|
9
22
|
const auth = await step.run("decidio-authorize", async () => {
|
|
23
|
+
// Sign the bound proof so an Inngest-path agent can prove its identity + auto-approve.
|
|
10
24
|
const requesterIdentity = (config.agentKey && config.agentDid)
|
|
11
25
|
? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
|
|
12
26
|
: undefined;
|
|
@@ -26,6 +40,9 @@ export async function decidioGateStep(args) {
|
|
|
26
40
|
if (auth.decision === "block")
|
|
27
41
|
return { status: "blocked", decisionId: auth.decisionId };
|
|
28
42
|
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.
|
|
29
46
|
const ev = await step.waitForEvent("decidio-await-approval", {
|
|
30
47
|
event: DECIDIO_RESUME_EVENT,
|
|
31
48
|
timeout: args.timeout ?? "30d",
|
|
@@ -39,6 +56,8 @@ export async function decidioGateStep(args) {
|
|
|
39
56
|
if (verdict !== "approved" && verdict !== "auto_approved")
|
|
40
57
|
return { status: "blocked", decisionId: auth.decisionId };
|
|
41
58
|
}
|
|
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).
|
|
42
61
|
const result = await step.run("agent-action", args.run);
|
|
43
62
|
await step.run("decidio-confirm", async () => {
|
|
44
63
|
const requesterIdentity = signConfirm(auth.decisionId, config);
|
|
@@ -50,6 +69,12 @@ export async function decidioGateStep(args) {
|
|
|
50
69
|
});
|
|
51
70
|
return { status: "executed", decisionId: auth.decisionId, result };
|
|
52
71
|
}
|
|
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
|
+
*/
|
|
53
78
|
export function createInngestResumeBridge(opts) {
|
|
54
79
|
const secret = opts.webhookSecret ?? "";
|
|
55
80
|
const eventName = opts.eventName ?? DECIDIO_RESUME_EVENT;
|
|
@@ -85,4 +110,5 @@ export function createInngestResumeBridge(opts) {
|
|
|
85
110
|
});
|
|
86
111
|
};
|
|
87
112
|
}
|
|
113
|
+
// Re-export the signature check so a host can verify the webhook out-of-band if needed.
|
|
88
114
|
export { verifyResumeSignature };
|