@decidio/sdk 0.11.0 → 0.12.0
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/CHANGELOG.md +63 -1
- package/README.md +18 -9
- package/dist/adapters/_gate.d.ts +18 -1
- package/dist/adapters/_gate.js +34 -27
- package/dist/adapters/langgraph.js +1 -1
- package/dist/adapters/openai.d.ts +11 -4
- package/dist/adapters/openai.js +12 -4
- package/dist/adapters/temporal.d.ts +29 -2
- package/dist/adapters/temporal.js +42 -6
- package/dist/cli.js +131 -32
- package/dist/demo-gate.js +14 -0
- package/dist/errors.js +3 -1
- package/dist/guard.d.ts +5 -0
- package/dist/guard.js +3 -2
- package/dist/index.d.ts +39 -7
- package/dist/index.js +103 -20
- package/dist/inngest.d.ts +5 -0
- package/dist/inngest.js +2 -2
- package/dist/resume.d.ts +52 -2
- package/dist/resume.js +74 -5
- package/dist/signing.d.ts +8 -0
- package/dist/signing.js +6 -0
- package/openapi.yaml +158 -2
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { DecidioSuspendedError, checkApproval, claimApproval, newClaimKey, RETRY_DELAYS_MS } from "./resume.js";
|
|
3
|
+
import { signBoundProof, signConfirm, assertSafeTransport, carriesCredential, bindingMaterial, requestCommitment, invocationRequestKey, ownRequestKey } from "./signing.js";
|
|
3
4
|
import { canonicalize, canonicalizeArgs } from "./canonical.js";
|
|
4
5
|
import { readConfirmResponse, buildConfirmBody } from "./evidence.js";
|
|
5
|
-
export { DecidioSuspendedError, DecidioDeniedError, DecidioRecoveryUnavailableError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded, checkApproval } from "./resume.js";
|
|
6
|
+
export { DecidioSuspendedError, DecidioDeniedError, DecidioRecoveryUnavailableError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded, checkApproval, claimApproval, newClaimKey } from "./resume.js";
|
|
6
7
|
export { guard, Guard } from "./guard.js";
|
|
7
8
|
export { DecidioHttpError, decisionIdProblem } from "./errors.js";
|
|
8
9
|
import { DecidioHttpError, decisionIdProblem } from "./errors.js";
|
|
9
|
-
export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport, carriesCredential, bindingMaterial, boundScope, requestCommitment, idempotencyKey, committedRequestJson } from "./signing.js";
|
|
10
|
+
export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport, carriesCredential, bindingMaterial, boundScope, requestCommitment, idempotencyKey, invocationRequestKey, ownRequestKey, committedRequestJson } from "./signing.js";
|
|
10
11
|
export { canonicalize, canonicalizeArgs, canonicalJson, DecidioUnsafeArgumentsError } from "./canonical.js";
|
|
11
12
|
export { readConfirmResponse, buildConfirmBody, describeTierGap, EVIDENCE_TIERS } from "./evidence.js";
|
|
12
13
|
export class DecidioBlockedError extends Error {
|
|
@@ -66,10 +67,12 @@ export function coreRequest(ctx, args) {
|
|
|
66
67
|
export class DecidioTimeoutError extends Error {
|
|
67
68
|
decisionId;
|
|
68
69
|
withdrawn;
|
|
69
|
-
|
|
70
|
+
shared;
|
|
71
|
+
constructor(decisionId, withdrawn = false, shared = false) {
|
|
70
72
|
super(`Timed out waiting for human approval (${decisionId})`);
|
|
71
73
|
this.decisionId = decisionId;
|
|
72
74
|
this.withdrawn = withdrawn;
|
|
75
|
+
this.shared = shared;
|
|
73
76
|
this.name = "DecidioTimeoutError";
|
|
74
77
|
}
|
|
75
78
|
}
|
|
@@ -78,12 +81,14 @@ export class DecidioRateLimitError extends Error {
|
|
|
78
81
|
limit;
|
|
79
82
|
remaining;
|
|
80
83
|
resetAt;
|
|
81
|
-
|
|
84
|
+
code;
|
|
85
|
+
constructor(limit, remaining, resetAt, serverMessage, code) {
|
|
82
86
|
super(`[decidio] governed-action rate limit reached${limit != null ? ` (${limit}/hour on this workspace)` : ""}${resetAt ? ` — resets ${resetAt}` : ""}. ` +
|
|
83
87
|
`${serverMessage ? serverMessage + " " : ""}Nothing was routed or executed; retry after the reset.`);
|
|
84
88
|
this.limit = limit;
|
|
85
89
|
this.remaining = remaining;
|
|
86
90
|
this.resetAt = resetAt;
|
|
91
|
+
this.code = code;
|
|
87
92
|
this.name = "DecidioRateLimitError";
|
|
88
93
|
}
|
|
89
94
|
}
|
|
@@ -97,8 +102,9 @@ export class DecidioNetworkError extends Error {
|
|
|
97
102
|
? `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). `
|
|
98
103
|
: "") +
|
|
99
104
|
`Nothing was executed locally and no verdict was received — if a request reached Decidio it is ` +
|
|
100
|
-
`visible in your queue.
|
|
101
|
-
|
|
105
|
+
`visible in your queue. ` +
|
|
106
|
+
(/\/agent\/authorize/.test(op) ? `This SDK already retried it with the same request key. ` : "") +
|
|
107
|
+
`Check the network/URL (DECIDIO_API_URL) and retry; retrying is safe — it can never double-execute.`);
|
|
102
108
|
this.endpoint = endpoint;
|
|
103
109
|
this.op = op;
|
|
104
110
|
this.code = code;
|
|
@@ -132,7 +138,7 @@ async function post(cfg, path, body) {
|
|
|
132
138
|
});
|
|
133
139
|
if (res.status === 429) {
|
|
134
140
|
const b = await res.json().catch(() => null);
|
|
135
|
-
throw new DecidioRateLimitError(b?.limit, b?.remaining, b?.resetAt, b?.message);
|
|
141
|
+
throw new DecidioRateLimitError(b?.limit, b?.remaining, b?.resetAt, b?.message, typeof b?.code === "string" ? b.code : undefined);
|
|
136
142
|
}
|
|
137
143
|
if (!res.ok) {
|
|
138
144
|
const raw = await res.text().catch(() => "");
|
|
@@ -145,6 +151,56 @@ async function post(cfg, path, body) {
|
|
|
145
151
|
}
|
|
146
152
|
return res.json();
|
|
147
153
|
}
|
|
154
|
+
function retriesSafely(e) {
|
|
155
|
+
if (e instanceof DecidioNetworkError)
|
|
156
|
+
return true;
|
|
157
|
+
if (e instanceof DecidioRateLimitError)
|
|
158
|
+
return e.code !== "AGENT_RATE";
|
|
159
|
+
if (e instanceof DecidioHttpError)
|
|
160
|
+
return (e.status >= 500 && e.status !== 501) || (e.status === 429 && e.body?.code !== "AGENT_RATE");
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
export async function sendAuthorize(config, first, opts = {}) {
|
|
164
|
+
const mayRetry = !!first.idempotencyKey || !first.requestCommitment;
|
|
165
|
+
let body = first;
|
|
166
|
+
const once = async () => {
|
|
167
|
+
if ((opts.rateLimit ?? "typed") === "typed")
|
|
168
|
+
return post(config, "/agent/authorize", body);
|
|
169
|
+
const f = config.fetchImpl ?? fetch;
|
|
170
|
+
assertSafeTransport(config.apiUrl, carriesCredential(config));
|
|
171
|
+
const res = await reach(f, config.apiUrl.replace(/\/$/, "") + "/agent/authorize", "POST /agent/authorize", {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: { "content-type": "application/json", ...(config.apiToken ? { authorization: `Bearer ${config.apiToken}` } : {}) },
|
|
174
|
+
body: JSON.stringify(body),
|
|
175
|
+
});
|
|
176
|
+
if (!res.ok) {
|
|
177
|
+
const raw = await res.text().catch(() => "");
|
|
178
|
+
let parsed = raw;
|
|
179
|
+
try {
|
|
180
|
+
parsed = raw ? JSON.parse(raw) : undefined;
|
|
181
|
+
}
|
|
182
|
+
catch { }
|
|
183
|
+
throw new DecidioHttpError(res.status, "/agent/authorize", parsed);
|
|
184
|
+
}
|
|
185
|
+
return res.json();
|
|
186
|
+
};
|
|
187
|
+
for (let attempt = 0;; attempt++) {
|
|
188
|
+
try {
|
|
189
|
+
return await once();
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
if (!mayRetry || attempt >= RETRY_DELAYS_MS.length || !retriesSafely(e))
|
|
193
|
+
throw e;
|
|
194
|
+
await sleep(RETRY_DELAYS_MS[attempt]);
|
|
195
|
+
const fresh = first.requesterIdentity && opts.resign ? opts.resign() : undefined;
|
|
196
|
+
if (fresh)
|
|
197
|
+
body = { ...first, requesterIdentity: fresh };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
export function claimRefusal(claim) {
|
|
202
|
+
return new DecidioBlockedError(claim.ok ? "the approval was collected" : claim.reason, claim.decisionId);
|
|
203
|
+
}
|
|
148
204
|
async function get(cfg, path) {
|
|
149
205
|
const f = cfg.fetchImpl ?? fetch;
|
|
150
206
|
assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
|
|
@@ -190,13 +246,30 @@ export function withApproval(fn, describe, config, opts) {
|
|
|
190
246
|
const material = bindingMaterial(config);
|
|
191
247
|
const committed = coreRequest(ctx, args);
|
|
192
248
|
const sentCommitment = material ? requestCommitment(material, committed) : undefined;
|
|
193
|
-
|
|
249
|
+
let requestKey;
|
|
250
|
+
if (opts?.requestKey) {
|
|
251
|
+
const own = opts.requestKey(...args);
|
|
252
|
+
if (typeof own !== "string" || own.length === 0)
|
|
253
|
+
throw new Error(`[decidio] requestKey(...) must return a non-empty string naming this call (got ${JSON.stringify(own ?? null)}). Nothing was routed or executed.`);
|
|
254
|
+
if (!material)
|
|
255
|
+
throw new Error("[decidio] requestKey needs this agent's key material (DECIDIO_AGENT_KEY + DECIDIO_AGENT_DID, or DECIDIO_API_TOKEN): a key rides only beside the request commitment that material makes, and Decidio refuses one alone. Nothing was routed or executed.");
|
|
256
|
+
requestKey = ownRequestKey(material, own);
|
|
257
|
+
}
|
|
258
|
+
else if (material && sentCommitment) {
|
|
259
|
+
requestKey = invocationRequestKey(material, config.invocationId ? String(config.invocationId()) : randomUUID(), sentCommitment);
|
|
260
|
+
}
|
|
261
|
+
const auth = await sendAuthorize(config, {
|
|
194
262
|
requester: config.agentId, action: ctx.action, amount: ctx.amount,
|
|
195
263
|
scope: ctx.scope, sourceSystem: config.sourceSystem,
|
|
196
264
|
...(ctx.context ? { context: ctx.context } : {}),
|
|
197
265
|
...(requesterIdentity ? { requesterIdentity } : {}),
|
|
198
266
|
...(resumeUrl ? { resumeUrl } : {}),
|
|
199
267
|
...(sentCommitment ? { requestCommitment: sentCommitment } : {}),
|
|
268
|
+
...(requestKey ? { idempotencyKey: requestKey } : {}),
|
|
269
|
+
}, {
|
|
270
|
+
resign: () => (config.agentKey && config.agentDid)
|
|
271
|
+
? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
|
|
272
|
+
: undefined,
|
|
200
273
|
});
|
|
201
274
|
if (auth.decision !== "proceed" && auth.decision !== "route" && auth.decision !== "block")
|
|
202
275
|
throw new DecidioBlockedError(`unrecognized gate verdict: ${String(auth.decision)}`, auth.decisionId);
|
|
@@ -238,13 +311,18 @@ export function withApproval(fn, describe, config, opts) {
|
|
|
238
311
|
if (Date.now() > deadline) {
|
|
239
312
|
let withdrawn = false;
|
|
240
313
|
let lateState = null;
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
314
|
+
let shared = auth.idempotentReplay === true || auth.openTwin === true;
|
|
315
|
+
if (!shared) {
|
|
316
|
+
try {
|
|
317
|
+
await post(config, `/agent/withdraw/${encodeURIComponent(auth.decisionId)}`, { reason: "timeout" });
|
|
318
|
+
withdrawn = true;
|
|
319
|
+
}
|
|
320
|
+
catch (e) {
|
|
321
|
+
if (e instanceof DecidioHttpError && e.status === 409 && typeof e.body?.state === "string")
|
|
322
|
+
lateState = e.body.state;
|
|
323
|
+
else if (e instanceof DecidioHttpError && e.status === 409 && e.body?.code === "SHARED_REQUEST")
|
|
324
|
+
shared = true;
|
|
325
|
+
}
|
|
248
326
|
}
|
|
249
327
|
if (lateState === "approved" || lateState === "auto_approved")
|
|
250
328
|
break;
|
|
@@ -252,14 +330,19 @@ export function withApproval(fn, describe, config, opts) {
|
|
|
252
330
|
throw new DecidioRejectedError("rejected by approver", auth.decisionId);
|
|
253
331
|
if (lateState === "blocked")
|
|
254
332
|
throw new DecidioBlockedError("blocked", auth.decisionId);
|
|
255
|
-
console.warn(`[decidio] blocking wait hit its ${Math.round(pollTimeout / 1000)}s limit — decision ${auth.decisionId} ${withdrawn ? "was withdrawn in Decidio (the queue no longer shows it; a later approval does nothing)" : "is still open in Decidio (the withdraw call failed), 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.`);
|
|
256
|
-
throw new DecidioTimeoutError(auth.decisionId, withdrawn);
|
|
333
|
+
console.warn(`[decidio] blocking wait hit its ${Math.round(pollTimeout / 1000)}s limit — decision ${auth.decisionId} ${withdrawn ? "was withdrawn in Decidio (the queue no longer shows it; a later approval does nothing)" : shared ? "is shared with another caller (or an earlier try of this one), so it was left open for a person to decide - this process stopped waiting" : "is still open in Decidio (the withdraw call failed), 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.`);
|
|
334
|
+
throw new DecidioTimeoutError(auth.decisionId, withdrawn, shared);
|
|
257
335
|
}
|
|
258
336
|
await sleep(pollInterval);
|
|
259
337
|
}
|
|
260
338
|
const check = await checkApproval(config, auth.decisionId, committed, { sent: !!sentCommitment, ...(approvedStatus ? { status: approvedStatus } : {}) });
|
|
261
339
|
if (!check.ok)
|
|
262
340
|
throw approvalRefusal(check);
|
|
341
|
+
if (check.state === "approved") {
|
|
342
|
+
const claimed = await claimApproval(config, auth.decisionId, newClaimKey());
|
|
343
|
+
if (!claimed.ok)
|
|
344
|
+
throw claimRefusal(claimed);
|
|
345
|
+
}
|
|
263
346
|
}
|
|
264
347
|
const result = opts?.withContext
|
|
265
348
|
? await fn({ decisionId: auth.decisionId, attemptId: null, idempotencyKey: auth.decisionId }, ...args)
|
|
@@ -299,6 +382,6 @@ export function withApproval(fn, describe, config, opts) {
|
|
|
299
382
|
}
|
|
300
383
|
if (evidenceProblem)
|
|
301
384
|
confirmation.evidenceError = evidenceProblem;
|
|
302
|
-
return { value: result, decisionId: auth.decisionId, confirmation };
|
|
385
|
+
return { value: result, decisionId: auth.decisionId, confirmation, ...(requestKey ? { requestKey } : {}) };
|
|
303
386
|
};
|
|
304
387
|
}
|
package/dist/inngest.d.ts
CHANGED
|
@@ -39,6 +39,7 @@ export type GateStepOutcome<R> =
|
|
|
39
39
|
decisionId: string;
|
|
40
40
|
value: R;
|
|
41
41
|
confirmation: EvidenceOutcome;
|
|
42
|
+
requestKey?: string;
|
|
42
43
|
}
|
|
43
44
|
/** The action never ran — a human said no, or a rule blocked it — so there is nothing to confirm. */
|
|
44
45
|
| {
|
|
@@ -70,6 +71,10 @@ export declare function decidioGateStep<R>(args: {
|
|
|
70
71
|
* path takes. This door's own confirm chokepoint tells callers to use it; until 0.2.0 no
|
|
71
72
|
* adapter door accepted one, so the advice was unfollowable everywhere it was printed. */
|
|
72
73
|
evidence?: (result: R) => unknown;
|
|
74
|
+
/** Inngest's id for THIS function run - the handler's `runId` (0.12.0, 11f). The request key is keyed on it, so Inngest
|
|
75
|
+
* retrying the authorize step is the same request while it waits. Without it the key is
|
|
76
|
+
* over a fresh id for the call (a retried step may then ask again). */
|
|
77
|
+
runId?: string;
|
|
73
78
|
}): Promise<GateStepOutcome<R>>;
|
|
74
79
|
/**
|
|
75
80
|
* Decidio → Inngest bridge. Mount as an HTTP endpoint and register it as the agent's
|
package/dist/inngest.js
CHANGED
|
@@ -4,7 +4,7 @@ import { approvalRefusal } from "./index.js";
|
|
|
4
4
|
export const DECIDIO_RESUME_EVENT = "decidio/decision.resolved";
|
|
5
5
|
export async function decidioGateStep(args) {
|
|
6
6
|
const { step, config, ctx } = args;
|
|
7
|
-
const auth = await step.run("decidio-authorize", async () => gateAuthorize(config, ctx));
|
|
7
|
+
const auth = await step.run("decidio-authorize", async () => gateAuthorize(config, ctx, typeof args.runId === "string" && args.runId ? { naturalId: { engine: "inngest", runId: args.runId, step: "decidio-authorize" } } : {}));
|
|
8
8
|
assertAuthorizeVerdict(auth, auth?.decisionId);
|
|
9
9
|
if (auth.decision === "block")
|
|
10
10
|
return { status: "blocked", decisionId: auth.decisionId };
|
|
@@ -31,7 +31,7 @@ export async function decidioGateStep(args) {
|
|
|
31
31
|
}
|
|
32
32
|
const result = await step.run("agent-action", args.run);
|
|
33
33
|
const confirmation = await step.run("decidio-confirm", async () => gateConfirm(config, auth.decisionId, result, args.evidence));
|
|
34
|
-
return { status: "executed", decisionId: auth.decisionId, value: result, confirmation };
|
|
34
|
+
return { status: "executed", decisionId: auth.decisionId, value: result, confirmation, ...(typeof auth.requestKey === "string" ? { requestKey: auth.requestKey } : {}) };
|
|
35
35
|
}
|
|
36
36
|
export function createInngestResumeBridge(opts) {
|
|
37
37
|
const secret = opts.webhookSecret ?? "";
|
package/dist/resume.d.ts
CHANGED
|
@@ -38,6 +38,13 @@ export interface PendingAction {
|
|
|
38
38
|
* compared with the commitment Decidio returns. Absent on entries parked before 0.7.0 (and by
|
|
39
39
|
* an agent with no key material), which are checked on Decidio's state alone. */
|
|
40
40
|
requestCommitment?: string;
|
|
41
|
+
/** THE RUN'S CLAIM KEY (0.12.0, founder call 24): made at random when the action is parked, and the key this parked
|
|
42
|
+
* action collects a person's approval with (POST /agent/claim) right before it runs. It lives HERE, with the parked
|
|
43
|
+
* action, so every resume of it - a worker that restarts, a signal after a lost answer, a claim an operator released -
|
|
44
|
+
* is the same run to Decidio (`again: true`), while another process that parked its own copy of the request holds its
|
|
45
|
+
* own key and is told the approval was used. Covered by the payload signature. Absent on entries parked before 0.12.0:
|
|
46
|
+
* those claim with a fresh key each time (a lost answer then costs a new approval, never a second run). */
|
|
47
|
+
claimKey?: string;
|
|
41
48
|
}
|
|
42
49
|
/** Where suspended actions live between request and approval. The default is a
|
|
43
50
|
* file store; swap in Redis/Postgres/your queue for production. AGENT-SIDE only —
|
|
@@ -636,7 +643,11 @@ export interface ResumeController extends ResumeConfig {
|
|
|
636
643
|
* /agent/status + the request commitment — `checkApproval`). Decidio still pending → `not_approved`;
|
|
637
644
|
* Decidio says rejected/blocked → recorded as that denial; an approval that cannot be tied to these
|
|
638
645
|
* arguments → `request_unverified`. A transport failure during that check throws (nothing ran). A
|
|
639
|
-
* `rejected`/`blocked` verdict is applied as given — it can only ever prevent execution.
|
|
646
|
+
* `rejected`/`blocked` verdict is applied as given — it can only ever prevent execution.
|
|
647
|
+
*
|
|
648
|
+
* A PERSON'S approval is then COLLECTED ONCE (0.12.0, `claimApproval`) with the claim key parked with the action:
|
|
649
|
+
* another run already used it, or it expired → `blocked` with the reason (recorded like a block; nothing ran); a 404
|
|
650
|
+
* or a claim that could not be sent → throws (nothing ran, the entry stays parked for the next signal). */
|
|
640
651
|
resolve(input: {
|
|
641
652
|
decisionId: string;
|
|
642
653
|
verdict: "approved" | "rejected" | "blocked";
|
|
@@ -704,7 +715,9 @@ export declare function assertConfirmRecorded(body: any, decisionId: string, sig
|
|
|
704
715
|
* authorized with, or null for a decision whose request was never bound. `spent` and `grantExpiresAt` (0.8.0,
|
|
705
716
|
* mini-approvals): the grant is ONE-TIME - `spent` once an execution was recorded on it, `grantExpiresAt` when
|
|
706
717
|
* it lapses (the seal's time plus the gate's TTL) for a granted decision, null otherwise. A gate that predates
|
|
707
|
-
* them answers neither.
|
|
718
|
+
* them answers neither. `handedBackAt` (when a replay handed the grant to a retry) and `claimed` (0.12.0: whether a
|
|
719
|
+
* run has collected a person's approval - never by whom) are informational: reading the status claims NOTHING, and
|
|
720
|
+
* this SDK decides a claim only by claiming (claimApproval). */
|
|
708
721
|
export interface DecisionStatus {
|
|
709
722
|
state: string;
|
|
710
723
|
decisionId?: string;
|
|
@@ -713,6 +726,8 @@ export interface DecisionStatus {
|
|
|
713
726
|
requestCommitment?: string | null;
|
|
714
727
|
spent?: boolean;
|
|
715
728
|
grantExpiresAt?: string | null;
|
|
729
|
+
handedBackAt?: string | null;
|
|
730
|
+
claimed?: boolean;
|
|
716
731
|
}
|
|
717
732
|
type StatusConfig = {
|
|
718
733
|
apiUrl: string;
|
|
@@ -764,6 +779,41 @@ export declare function checkApproval(cfg: StatusConfig, decisionId: string, req
|
|
|
764
779
|
sent?: boolean;
|
|
765
780
|
status?: DecisionStatus;
|
|
766
781
|
}): Promise<ApprovalCheck>;
|
|
782
|
+
/** How long the SDK waits before each retry of a call it may safely repeat (11f): the authorize POST (with the same
|
|
783
|
+
* body, so the same request key) and the claim (with the same claim key). Bounded: two retries, then the failure is the
|
|
784
|
+
* caller's to see. Python twin: RETRY_DELAYS. */
|
|
785
|
+
export declare const RETRY_DELAYS_MS: readonly number[];
|
|
786
|
+
/** The grammar the gate takes for a request key and a claim key alike. */
|
|
787
|
+
export declare const REQUEST_KEY_RE: RegExp;
|
|
788
|
+
/** A claim key: RANDOM PER RUN (two workers with one key would be one run to Decidio, and both would run). The durable
|
|
789
|
+
* path makes one when it parks an action and keeps it there; the blocking wait and the OpenAI resume make one per call. */
|
|
790
|
+
export declare function newClaimKey(): string;
|
|
791
|
+
/** Why a claim did not let this run go ahead. already_used / expired / not_claimable are Decidio's FINAL answers (409):
|
|
792
|
+
* this approval will never run here. not_found (404: no such decision for this credential, or a gate that predates
|
|
793
|
+
* claims), refused (any other 4xx), unreachable (a network failure or a 5xx after the retries) and unrecognized (an
|
|
794
|
+
* answer this SDK does not understand) may change - nothing ran, and a later try may succeed. */
|
|
795
|
+
export type ClaimCode = "already_used" | "expired" | "not_claimable" | "not_found" | "refused" | "unreachable" | "unrecognized";
|
|
796
|
+
/** The outcome of one claim. Plain JSON on purpose, like ApprovalCheck: an engine that must run HTTP in an activity
|
|
797
|
+
* (Temporal) returns it across its serialization boundary. */
|
|
798
|
+
export type ClaimOutcome = {
|
|
799
|
+
ok: true;
|
|
800
|
+
decisionId: string;
|
|
801
|
+
again: boolean;
|
|
802
|
+
} | {
|
|
803
|
+
ok: false;
|
|
804
|
+
decisionId: string;
|
|
805
|
+
code: ClaimCode;
|
|
806
|
+
final: boolean;
|
|
807
|
+
reason: string;
|
|
808
|
+
status?: number;
|
|
809
|
+
};
|
|
810
|
+
/** COLLECT A PERSON'S APPROVAL FOR THIS RUN, ONCE (POST /agent/claim/:decisionId) - the step every door that RUNS on a
|
|
811
|
+
* person's approval takes right before it runs: the blocking wait, the durable resume, Temporal and the OpenAI resume. The
|
|
812
|
+
* first claim wins; the same key claiming again is the same run retrying (`again: true`); any other key is told the
|
|
813
|
+
* approval was already used. Reading the status claims nothing, so nothing that only watches ever calls this. An
|
|
814
|
+
* auto-approval is never claimed (it is never shared). Retried with the SAME key on a network failure or a 5xx; every
|
|
815
|
+
* answer other than a 200 that says `claimed: true` means DO NOT RUN. Never throws for an answer - a refusal is a value. */
|
|
816
|
+
export declare function claimApproval(cfg: StatusConfig, decisionId: string, claimKey: string): Promise<ClaimOutcome>;
|
|
767
817
|
/** The webhook handlers' refusal when no webhook secret is configured - with the remedy. */
|
|
768
818
|
/** The one remedy every webhook-secret refusal names (#4B review W3): the dedicated webhook secret - NEVER the
|
|
769
819
|
* server's API_AUTH_TOKEN, the admin bearer, which a refusal that said "the value your deployment signs
|
package/dist/resume.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHmac, timingSafeEqual, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify, randomUUID } from "node:crypto";
|
|
1
|
+
import { createHmac, timingSafeEqual, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify, randomUUID, randomBytes } from "node:crypto";
|
|
2
2
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, renameSync, openSync, closeSync, fsyncSync, linkSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { userInfo } from "node:os";
|
|
@@ -7,7 +7,7 @@ import { signConfirm, canonicalize as jcs, assertSafeTransport, carriesCredentia
|
|
|
7
7
|
import { canonicalize as toCanonical, DecidioUnsafeArgumentsError } from "./canonical.js";
|
|
8
8
|
import { readConfirmResponse, buildConfirmBody, tierConfirms, tombstoneTier } from "./evidence.js";
|
|
9
9
|
import { DecidioHttpError, decisionIdProblem } from "./errors.js";
|
|
10
|
-
const argsBindingBytes = (p) => Buffer.from(jcs({ decisionId: p.decisionId, action: p.action, amount: p.amount, ...(p.scope !== undefined ? { scope: p.scope } : {}), args: p.args, ...(p.requestCommitment !== undefined ? { requestCommitment: p.requestCommitment } : {}) }), "utf8");
|
|
10
|
+
const argsBindingBytes = (p) => Buffer.from(jcs({ decisionId: p.decisionId, action: p.action, amount: p.amount, ...(p.scope !== undefined ? { scope: p.scope } : {}), args: p.args, ...(p.requestCommitment !== undefined ? { requestCommitment: p.requestCommitment } : {}), ...(p.claimKey !== undefined ? { claimKey: p.claimKey } : {}) }), "utf8");
|
|
11
11
|
function signArgs(p, agentKey, webhookSecret) {
|
|
12
12
|
const bytes = argsBindingBytes(p);
|
|
13
13
|
if (agentKey) {
|
|
@@ -882,6 +882,55 @@ export async function checkApproval(cfg, decisionId, request, opts = {}) {
|
|
|
882
882
|
}
|
|
883
883
|
return { ok: true, decisionId, state };
|
|
884
884
|
}
|
|
885
|
+
export const RETRY_DELAYS_MS = [300, 1_000];
|
|
886
|
+
export const REQUEST_KEY_RE = /^[A-Za-z0-9._:-]{16,128}$/;
|
|
887
|
+
export function newClaimKey() {
|
|
888
|
+
return `run_${randomBytes(18).toString("base64url")}`;
|
|
889
|
+
}
|
|
890
|
+
export async function claimApproval(cfg, decisionId, claimKey) {
|
|
891
|
+
const no = (code, reason, status) => ({ ok: false, decisionId, code, final: code === "already_used" || code === "expired" || code === "not_claimable", reason, ...(status !== undefined ? { status } : {}) });
|
|
892
|
+
if (typeof claimKey !== "string" || !REQUEST_KEY_RE.test(claimKey))
|
|
893
|
+
return no("refused", "this run's claim key is not one Decidio accepts (16-128 letters, digits, dot, colon, underscore or dash). Nothing ran.");
|
|
894
|
+
const f = cfg.fetchImpl ?? fetch;
|
|
895
|
+
assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
|
|
896
|
+
const url = cfg.apiUrl.replace(/\/$/, "") + "/agent/claim/" + encodeURIComponent(decisionId);
|
|
897
|
+
let last = "";
|
|
898
|
+
for (let attempt = 0;; attempt++) {
|
|
899
|
+
let res;
|
|
900
|
+
try {
|
|
901
|
+
res = await f(url, { method: "POST", headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) }, body: JSON.stringify({ claimKey }) });
|
|
902
|
+
}
|
|
903
|
+
catch (e) {
|
|
904
|
+
last = `could not reach Decidio: ${e?.cause?.code ?? e?.code ?? e?.message ?? e}`;
|
|
905
|
+
}
|
|
906
|
+
if (res) {
|
|
907
|
+
const body = await res.json().catch(() => null);
|
|
908
|
+
if (res.status === 200) {
|
|
909
|
+
if (body && body.ok === true && body.claimed === true)
|
|
910
|
+
return { ok: true, decisionId, again: body.again === true };
|
|
911
|
+
return no("unrecognized", "Decidio answered the claim with HTTP 200 but did not say this run collected the approval, and an answer this SDK does not understand is never permission. Nothing ran.", 200);
|
|
912
|
+
}
|
|
913
|
+
if (res.status === 409) {
|
|
914
|
+
const code = body?.code;
|
|
915
|
+
if (code === "ALREADY_USED")
|
|
916
|
+
return no("already_used", "this approval was already used by another run. Ask again for a new approval. Nothing ran.", 409);
|
|
917
|
+
if (code === "EXPIRED")
|
|
918
|
+
return no("expired", "this approval has expired. Ask again for a new approval. Nothing ran.", 409);
|
|
919
|
+
if (code === "NOT_CLAIMABLE")
|
|
920
|
+
return no("not_claimable", "Decidio says this approval can't be collected by a run (only a person's approval is, and a deploy approval belongs to its deploy job). Nothing ran.", 409);
|
|
921
|
+
return no("unrecognized", `Decidio refused the claim with a code this SDK does not know (${JSON.stringify(code ?? null)}). Nothing ran.`, 409);
|
|
922
|
+
}
|
|
923
|
+
if (res.status === 404)
|
|
924
|
+
return no("not_found", "Decidio did not let this agent collect the approval (HTTP 404: it does not know this decision for this credential, or it predates approval claims). Nothing ran.", 404);
|
|
925
|
+
if (!(res.status === 429 || (res.status >= 500 && res.status !== 501)))
|
|
926
|
+
return no("refused", `Decidio refused to let this run collect the approval (HTTP ${res.status}). Nothing ran.`, res.status);
|
|
927
|
+
last = `HTTP ${res.status}`;
|
|
928
|
+
}
|
|
929
|
+
if (attempt >= RETRY_DELAYS_MS.length)
|
|
930
|
+
return no("unreachable", `could not collect the approval from Decidio after ${attempt + 1} tries (${last}). Nothing ran.`);
|
|
931
|
+
await sleep(RETRY_DELAYS_MS[attempt]);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
885
934
|
const V2_STORE_METHODS = [
|
|
886
935
|
"put", "get", "delete", "list",
|
|
887
936
|
"claim", "markInvoking", "recordExecuted", "markIndeterminate", "markReported",
|
|
@@ -1125,6 +1174,25 @@ export function createDecidioResume(opts) {
|
|
|
1125
1174
|
refuseOnce(decisionId, `[decidio] REFUSING to execute ${decisionId}: ${check.reason}. Nothing ran; the parked entry is untouched for inspection. If the request really changed, reject this decision and re-run the action to request a fresh approval.`);
|
|
1126
1175
|
return finish({ status: "request_unverified", decisionId, action: pending.action, reason: check.reason });
|
|
1127
1176
|
}
|
|
1177
|
+
if (check.state === "approved") {
|
|
1178
|
+
let claimed;
|
|
1179
|
+
try {
|
|
1180
|
+
claimed = await claimApproval(ctrl, decisionId, pending.claimKey ?? newClaimKey());
|
|
1181
|
+
}
|
|
1182
|
+
catch (e) {
|
|
1183
|
+
await ctrl.store.releasePreinvoke(decisionId, attemptId).catch(() => { });
|
|
1184
|
+
throw e;
|
|
1185
|
+
}
|
|
1186
|
+
if (!claimed.ok) {
|
|
1187
|
+
if (claimed.final) {
|
|
1188
|
+
await ctrl.store.markDiscarded(decisionId, "blocked", claimed.reason);
|
|
1189
|
+
await ctrl.store.delete(decisionId);
|
|
1190
|
+
return finish({ status: "blocked", decisionId, action: pending.action, reason: claimed.reason });
|
|
1191
|
+
}
|
|
1192
|
+
await ctrl.store.releasePreinvoke(decisionId, attemptId).catch(() => { });
|
|
1193
|
+
throw new Error(`[decidio] ${decisionId}: ${claimed.reason} The entry stays parked; the next signal tries again with the same claim key.`);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1128
1196
|
try {
|
|
1129
1197
|
await ctrl.store.markInvoking(decisionId, attemptId);
|
|
1130
1198
|
}
|
|
@@ -1194,10 +1262,11 @@ export function createDecidioResume(opts) {
|
|
|
1194
1262
|
catch (e) {
|
|
1195
1263
|
throw new DecidioUnsafeArgumentsError(`${e?.message ?? e} NOT parked and NOT executable — but decision ${p.decisionId} may already be routed; reject it in your queue: npx @decidio/sdk approvals reject ${p.decisionId}`, p.decisionId);
|
|
1196
1264
|
}
|
|
1197
|
-
const
|
|
1265
|
+
const parked = { ...p, claimKey: p.claimKey ?? newClaimKey() };
|
|
1266
|
+
const argsSig = signArgs(parked, ctrl.agentKey, integrityKey());
|
|
1198
1267
|
if (!argsSig)
|
|
1199
1268
|
console.warn(`[decidio] parking ${p.decisionId} UNBOUND — no agent key or webhook secret to sign the payload with, so a modified pending file could not be detected. Run \`npx @decidio/sdk init\` to configure keys.`);
|
|
1200
|
-
await ctrl.store.put({ ...
|
|
1269
|
+
await ctrl.store.put({ ...parked, createdAt: new Date().toISOString(), ...(argsSig ? { argsSig } : {}) });
|
|
1201
1270
|
},
|
|
1202
1271
|
async resolve(input) { return resolveCore(input); },
|
|
1203
1272
|
webhookHandler() {
|
|
@@ -1478,7 +1547,7 @@ export function createDecidioResume(opts) {
|
|
|
1478
1547
|
else if (outcome.status === "rejected")
|
|
1479
1548
|
console.error(`[decidio] resumed ${p.decisionId}: REJECTED — the parked "${p.action}" was discarded, nothing executed.`);
|
|
1480
1549
|
else if (outcome.status === "blocked")
|
|
1481
|
-
console.error(`[decidio] resumed ${p.decisionId}: BLOCKED by policy — the parked "${p.action}" was discarded, nothing executed.`);
|
|
1550
|
+
console.error(`[decidio] resumed ${p.decisionId}: BLOCKED ${outcome.reason ? `(${outcome.reason})` : "by policy"} — the parked "${p.action}" was discarded, nothing executed.`);
|
|
1482
1551
|
workerOpts?.onResolved?.(outcome);
|
|
1483
1552
|
}
|
|
1484
1553
|
catch (e) {
|
package/dist/signing.d.ts
CHANGED
|
@@ -126,6 +126,14 @@ export declare function requestCommitment(material: BindingMaterial, request: Co
|
|
|
126
126
|
/** lowercase-hex idempotency key over an execution identity (e.g. LangGraph's thread + task +
|
|
127
127
|
* commitment), keyed so the host's thread ids never reach Decidio in the clear. */
|
|
128
128
|
export declare function idempotencyKey(material: BindingMaterial, identity: Record<string, JsonValue>): string;
|
|
129
|
+
/** THE REQUEST KEY A CALL SENDS BY DEFAULT (11f, founder 2026-10-30): an idempotency key over a per-call random id and
|
|
130
|
+
* the request's commitment, so the SDK's own retry of one call is the same request (the gate answers the decision it
|
|
131
|
+
* already made) while two calls stay two requests. Python twin: invocation_request_key. */
|
|
132
|
+
export declare function invocationRequestKey(material: BindingMaterial, invocationId: string, commitment: string): string;
|
|
133
|
+
/** A CALLER'S OWN REQUEST KEY (11f), e.g. an order id: keyed like every other identity, so the caller's id never reaches
|
|
134
|
+
* Decidio in the clear. Deliberately NOT over the commitment: the same key for a changed request is the gate's 409
|
|
135
|
+
* IDEMPOTENCY_CONFLICT, never a silent second request. Python twin: own_request_key. */
|
|
136
|
+
export declare function ownRequestKey(material: BindingMaterial, key: string): string;
|
|
129
137
|
/** The Claude Code hook's request scope: `cmd-` + 16 hex of a KEYED digest over the command text and its subject
|
|
130
138
|
* (chat approvals follow-up, invariant-guard 2026-10-15). The text may carry a credential the display redacts; an
|
|
131
139
|
* unkeyed digest sealed beside the redacted text would be an offline oracle for a weak password. Keyed under its own
|
package/dist/signing.js
CHANGED
|
@@ -198,6 +198,12 @@ export function requestCommitment(material, request) {
|
|
|
198
198
|
export function idempotencyKey(material, identity) {
|
|
199
199
|
return createHmac("sha256", bindingSubkey(material, "decidio.idempotency-key.v1")).update(canonicalJson(identity, "idempotency identity"), "utf8").digest("hex");
|
|
200
200
|
}
|
|
201
|
+
export function invocationRequestKey(material, invocationId, commitment) {
|
|
202
|
+
return idempotencyKey(material, { invocation: invocationId, commitment });
|
|
203
|
+
}
|
|
204
|
+
export function ownRequestKey(material, key) {
|
|
205
|
+
return idempotencyKey(material, { requestKey: key });
|
|
206
|
+
}
|
|
201
207
|
export function boundScope(material, text) {
|
|
202
208
|
return "cmd-" + createHmac("sha256", bindingSubkey(material, "decidio.hook-scope.v1")).update(text, "utf8").digest("hex").slice(0, 16);
|
|
203
209
|
}
|