@decidio/sdk 0.1.10 → 0.1.11
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 +125 -25
- package/dist/adapters/_gate.d.ts +25 -1
- package/dist/adapters/_gate.js +61 -7
- package/dist/adapters/langgraph.js +2 -1
- package/dist/adapters/openai.js +2 -1
- package/dist/adapters/temporal.js +17 -3
- package/dist/canonical.d.ts +18 -0
- package/dist/canonical.js +104 -0
- package/dist/cli.js +114 -18
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +18 -0
- package/dist/evidence.d.ts +41 -0
- package/dist/evidence.js +57 -0
- package/dist/guard.d.ts +21 -2
- package/dist/guard.js +16 -9
- package/dist/index.d.ts +13 -2
- package/dist/index.js +43 -26
- package/dist/inngest.js +6 -30
- package/dist/resume.d.ts +74 -16
- package/dist/resume.js +197 -47
- package/dist/signing.d.ts +56 -0
- package/dist/signing.js +84 -1
- package/openapi.yaml +47 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import { DecidioSuspendedError
|
|
2
|
-
import { signBoundProof, signConfirm,
|
|
1
|
+
import { DecidioSuspendedError } from "./resume.js";
|
|
2
|
+
import { signBoundProof, signConfirm, assertSafeTransport } from "./signing.js";
|
|
3
|
+
import { canonicalize, canonicalizeArgs } from "./canonical.js";
|
|
4
|
+
import { readConfirmResponse, buildConfirmBody } from "./evidence.js";
|
|
3
5
|
export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
|
|
4
6
|
export { guard, Guard } from "./guard.js";
|
|
5
|
-
export {
|
|
7
|
+
export { DecidioHttpError } from "./errors.js";
|
|
8
|
+
import { DecidioHttpError } from "./errors.js";
|
|
9
|
+
export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport } from "./signing.js";
|
|
10
|
+
export { canonicalize, canonicalizeArgs, canonicalJson, DecidioUnsafeArgumentsError } from "./canonical.js";
|
|
11
|
+
export { readConfirmResponse, buildConfirmBody, describeTierGap, EVIDENCE_TIERS } from "./evidence.js";
|
|
6
12
|
export class DecidioBlockedError extends Error {
|
|
7
13
|
reason;
|
|
8
14
|
decisionId;
|
|
@@ -84,8 +90,15 @@ async function post(cfg, path, body) {
|
|
|
84
90
|
const b = await res.json().catch(() => null);
|
|
85
91
|
throw new DecidioRateLimitError(b?.limit, b?.remaining, b?.resetAt, b?.message);
|
|
86
92
|
}
|
|
87
|
-
if (!res.ok)
|
|
88
|
-
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
const raw = await res.text().catch(() => "");
|
|
95
|
+
let parsed = raw;
|
|
96
|
+
try {
|
|
97
|
+
parsed = raw ? JSON.parse(raw) : undefined;
|
|
98
|
+
}
|
|
99
|
+
catch { }
|
|
100
|
+
throw new DecidioHttpError(res.status, path, parsed);
|
|
101
|
+
}
|
|
89
102
|
return res.json();
|
|
90
103
|
}
|
|
91
104
|
async function get(cfg, path) {
|
|
@@ -95,7 +108,7 @@ async function get(cfg, path) {
|
|
|
95
108
|
headers: { ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
96
109
|
});
|
|
97
110
|
if (!res.ok)
|
|
98
|
-
throw new
|
|
111
|
+
throw new DecidioHttpError(res.status, path);
|
|
99
112
|
return res.json();
|
|
100
113
|
}
|
|
101
114
|
export function decisionUrl(appUrl, decisionId) {
|
|
@@ -103,27 +116,32 @@ export function decisionUrl(appUrl, decisionId) {
|
|
|
103
116
|
return null;
|
|
104
117
|
return `${appUrl.replace(/\/$/, "")}/d/${encodeURIComponent(decisionId)}`;
|
|
105
118
|
}
|
|
106
|
-
export function withApproval(fn, describe, config) {
|
|
119
|
+
export function withApproval(fn, describe, config, opts) {
|
|
107
120
|
const pollInterval = config.pollIntervalMs ?? 2_000;
|
|
108
121
|
const pollTimeout = config.pollTimeoutMs ?? 600_000;
|
|
109
122
|
const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
|
|
110
|
-
return async (...
|
|
123
|
+
return async (...rawArgs) => {
|
|
124
|
+
const args = canonicalizeArgs(rawArgs);
|
|
125
|
+
let ctx;
|
|
111
126
|
try {
|
|
112
|
-
|
|
127
|
+
ctx = describe(...args);
|
|
113
128
|
}
|
|
114
129
|
catch (e) {
|
|
115
|
-
|
|
130
|
+
if (e instanceof TypeError && /read only|not extensible|object is not extensible|frozen/i.test(e.message ?? "")) {
|
|
131
|
+
throw new TypeError(`[decidio] your describe() callback tried to MODIFY the call arguments (${e.message}). ` +
|
|
132
|
+
`Those arguments are the approved value: they are what a human sees, what the signature covers, ` +
|
|
133
|
+
`what is parked for a durable resume, and what your function ultimately runs on — so changing them ` +
|
|
134
|
+
`here would mean the action that executes is not the action that was approved. Read from the ` +
|
|
135
|
+
`arguments and RETURN the context instead: (inv) => ({ action: "pay", amount: inv.amount }). ` +
|
|
136
|
+
`If you need a modified value at execution time, compute it inside your function. ` +
|
|
137
|
+
`Nothing was routed or executed.`);
|
|
138
|
+
}
|
|
139
|
+
throw e;
|
|
116
140
|
}
|
|
117
|
-
const ctx = describe(...args);
|
|
118
141
|
const requesterIdentity = (config.agentKey && config.agentDid)
|
|
119
142
|
? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
|
|
120
143
|
: undefined;
|
|
121
|
-
|
|
122
|
-
assertJsonSafe(ctx, "describe(...)");
|
|
123
|
-
}
|
|
124
|
-
catch (e) {
|
|
125
|
-
throw new DecidioUnsafeArgumentsError(`${e?.message ?? e} The decision was NOT routed; nothing reached Decidio.`);
|
|
126
|
-
}
|
|
144
|
+
canonicalize(ctx, "describe(...)");
|
|
127
145
|
const auth = await post(config, "/agent/authorize", {
|
|
128
146
|
requester: config.agentId, action: ctx.action, amount: ctx.amount,
|
|
129
147
|
scope: ctx.scope, sourceSystem: config.sourceSystem,
|
|
@@ -171,15 +189,14 @@ export function withApproval(fn, describe, config) {
|
|
|
171
189
|
const result = await fn(...args);
|
|
172
190
|
try {
|
|
173
191
|
const confirmIdentity = signConfirm(auth.decisionId, config);
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
console.warn(`[decidio] execution evidence for ${auth.decisionId} recorded as agent_asserted — no signing key configured, so the report can't be proven to come from this agent. Set DECIDIO_AGENT_KEY/DID (npx @decidio/sdk init writes them) to earn application_confirmed.`);
|
|
192
|
+
const evidenceOf = (opts?.evidence ?? ((r) => r));
|
|
193
|
+
const reported = canonicalize(evidenceOf(result) ?? null, "evidence(result)");
|
|
194
|
+
const confirmBody = await post(config, "/agent/confirm", buildConfirmBody(config, auth.decisionId, reported, confirmIdentity));
|
|
195
|
+
const ev = readConfirmResponse(confirmBody, { signed: !!confirmIdentity, capturedResponse: reported });
|
|
196
|
+
if (!ev.reportRecorded)
|
|
197
|
+
console.warn(`[decidio] ${auth.decisionId}: ${ev.diagnostic}`);
|
|
198
|
+
else if (!ev.executionConfirmed)
|
|
199
|
+
console.warn(`[decidio] execution evidence for ${auth.decisionId} recorded as ${ev.evidenceTier} — ${ev.diagnostic}`);
|
|
183
200
|
}
|
|
184
201
|
catch (e) {
|
|
185
202
|
console.warn(`[decidio] confirmation post failed (action succeeded): ${e.message}`);
|
package/dist/inngest.js
CHANGED
|
@@ -1,28 +1,10 @@
|
|
|
1
1
|
import { verifyResumeSignature } from "./resume.js";
|
|
2
|
-
import {
|
|
2
|
+
import { gateAuthorize, gateConfirm, assertAuthorizeVerdict, verdictFrom, checkEngineVersion } from "./adapters/_gate.js";
|
|
3
3
|
export const DECIDIO_RESUME_EVENT = "decidio/decision.resolved";
|
|
4
4
|
export async function decidioGateStep(args) {
|
|
5
5
|
const { step, config, ctx } = args;
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
const authHeaders = { "content-type": "application/json", ...(config.apiToken ? { authorization: `Bearer ${config.apiToken}` } : {}) };
|
|
9
|
-
const auth = await step.run("decidio-authorize", async () => {
|
|
10
|
-
const requesterIdentity = (config.agentKey && config.agentDid)
|
|
11
|
-
? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
|
|
12
|
-
: undefined;
|
|
13
|
-
const res = await f(base + "/agent/authorize", {
|
|
14
|
-
method: "POST", headers: authHeaders,
|
|
15
|
-
body: JSON.stringify({
|
|
16
|
-
requester: config.agentId, action: ctx.action, amount: ctx.amount, scope: ctx.scope,
|
|
17
|
-
sourceSystem: config.sourceSystem,
|
|
18
|
-
...(requesterIdentity ? { requesterIdentity } : {}),
|
|
19
|
-
...(config.resumeUrl ? { resumeUrl: config.resumeUrl } : {}),
|
|
20
|
-
}),
|
|
21
|
-
});
|
|
22
|
-
if (!res.ok)
|
|
23
|
-
throw new Error(`decidio authorize ${res.status}`);
|
|
24
|
-
return res.json();
|
|
25
|
-
});
|
|
6
|
+
const auth = await step.run("decidio-authorize", async () => gateAuthorize(config, ctx));
|
|
7
|
+
assertAuthorizeVerdict(auth, auth?.decisionId);
|
|
26
8
|
if (auth.decision === "block")
|
|
27
9
|
return { status: "blocked", decisionId: auth.decisionId };
|
|
28
10
|
if (auth.decision === "route") {
|
|
@@ -33,21 +15,14 @@ export async function decidioGateStep(args) {
|
|
|
33
15
|
});
|
|
34
16
|
if (!ev)
|
|
35
17
|
throw new Error(`decidio approval timed out (${auth.decisionId})`);
|
|
36
|
-
const verdict = ev.data
|
|
18
|
+
const verdict = verdictFrom(ev.data);
|
|
37
19
|
if (verdict === "rejected")
|
|
38
20
|
return { status: "rejected", decisionId: auth.decisionId };
|
|
39
21
|
if (verdict !== "approved" && verdict !== "auto_approved")
|
|
40
22
|
return { status: "blocked", decisionId: auth.decisionId };
|
|
41
23
|
}
|
|
42
24
|
const result = await step.run("agent-action", args.run);
|
|
43
|
-
await step.run("decidio-confirm", async () => {
|
|
44
|
-
const requesterIdentity = signConfirm(auth.decisionId, config);
|
|
45
|
-
await f(base + "/agent/confirm", {
|
|
46
|
-
method: "POST", headers: authHeaders,
|
|
47
|
-
body: JSON.stringify({ decisionId: auth.decisionId, capturedResponse: result ?? null, source: "wrapper", ...(requesterIdentity ? { requesterIdentity } : {}) }),
|
|
48
|
-
});
|
|
49
|
-
return true;
|
|
50
|
-
});
|
|
25
|
+
await step.run("decidio-confirm", async () => { await gateConfirm(config, auth.decisionId, result); return true; });
|
|
51
26
|
return { status: "executed", decisionId: auth.decisionId, result };
|
|
52
27
|
}
|
|
53
28
|
export function createInngestResumeBridge(opts) {
|
|
@@ -86,3 +61,4 @@ export function createInngestResumeBridge(opts) {
|
|
|
86
61
|
};
|
|
87
62
|
}
|
|
88
63
|
export { verifyResumeSignature };
|
|
64
|
+
checkEngineVersion("inngest");
|
package/dist/resume.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { RequestListener } from "node:http";
|
|
2
|
+
import { type EvidenceOutcome, type EvidenceTier } from "./evidence.js";
|
|
2
3
|
/** A routed action parked on the agent side, awaiting human approval. The `args`
|
|
3
4
|
* are the original call arguments, replayed verbatim when the action resumes. */
|
|
4
5
|
export interface PendingAction {
|
|
@@ -41,6 +42,9 @@ export interface PendingStore {
|
|
|
41
42
|
claim?(decisionId: string): Promise<boolean>;
|
|
42
43
|
/** Undo a claim so a failed execution is retryable. */
|
|
43
44
|
release?(decisionId: string): Promise<void>;
|
|
45
|
+
/** Entries currently CLAIMED (excluded from list()). Needed for honest recovery reporting —
|
|
46
|
+
* a claimed entry with no journalled result is the indeterminate case. */
|
|
47
|
+
listClaimed?(): Promise<PendingAction[]>;
|
|
44
48
|
}
|
|
45
49
|
export declare class MemoryPendingStore implements PendingStore {
|
|
46
50
|
private m;
|
|
@@ -51,7 +55,13 @@ export declare class MemoryPendingStore implements PendingStore {
|
|
|
51
55
|
list(): Promise<PendingAction[]>;
|
|
52
56
|
claim(id: string): Promise<boolean>;
|
|
53
57
|
release(id: string): Promise<void>;
|
|
58
|
+
listClaimed(): Promise<PendingAction[]>;
|
|
54
59
|
}
|
|
60
|
+
/** Restrict a directory to its owner. On Windows POSIX modes are a no-op, so strip inheritance
|
|
61
|
+
* and grant only the effective user. A FAILURE IS SAID, never swallowed: these files hold the
|
|
62
|
+
* arguments of actions awaiting approval, and "silently kept the directory's inherited
|
|
63
|
+
* permissions" is exactly the outcome an operator must not discover from an auditor. */
|
|
64
|
+
export declare function hardenDir(dir: string): void;
|
|
55
65
|
/** File-backed store so a COLD process (the original requester long gone) can still
|
|
56
66
|
* resume — the proof that nothing is "kept alive". One JSON file per decision. A claim
|
|
57
67
|
* renames `<id>.json` → `<id>.claimed` atomically (same-volume rename), so two processes
|
|
@@ -69,6 +79,9 @@ export declare class FilePendingStore implements PendingStore {
|
|
|
69
79
|
list(): Promise<PendingAction[]>;
|
|
70
80
|
claim(id: string): Promise<boolean>;
|
|
71
81
|
release(id: string): Promise<void>;
|
|
82
|
+
/** Claimed entries (`.claimed` files) — invisible to list() by design, but a stranded one is
|
|
83
|
+
* exactly what recovery must surface. */
|
|
84
|
+
listClaimed(): Promise<PendingAction[]>;
|
|
72
85
|
}
|
|
73
86
|
/** Thrown by a `withApproval`-wrapped call when the action is routed and configured
|
|
74
87
|
* for async resume: the action is now SUSPENDED (parked in the store), not failed.
|
|
@@ -80,18 +93,57 @@ export declare class DecidioSuspendedError extends Error {
|
|
|
80
93
|
constructor(decisionId: string, action: string);
|
|
81
94
|
}
|
|
82
95
|
export type ResumeStatus = "executed" | "rejected" | "blocked" | "no_handler" | "unknown";
|
|
96
|
+
/** A durable note that an action ALREADY RAN and what must still be reported. It lives OUTSIDE
|
|
97
|
+
* the pending store on purpose: a journal entry carries no handler binding, so replaying it can
|
|
98
|
+
* only ever re-send a report — it can never re-run the business action. That is what makes
|
|
99
|
+
* recovery safe on stores with and without claim(). */
|
|
100
|
+
export interface ExecutionJournalEntry {
|
|
101
|
+
decisionId: string;
|
|
102
|
+
action: string;
|
|
103
|
+
/** The canonical value that was reported — the durable FACT, journalled before the first
|
|
104
|
+
* attempt. Deliberately NOT the signed request: both proofs on a confirm are short-lived (the
|
|
105
|
+
* identity proof lives 120s, the execution report 5 minutes), so a replayed envelope is
|
|
106
|
+
* refused by the server for any outage longer than that — which is every outage worth
|
|
107
|
+
* recovering from. `reconfirm` re-signs at SEND time over these same bytes, so the report
|
|
108
|
+
* stays replay-safe (it still cannot re-run anything: no handler is journalled) while staying
|
|
109
|
+
* usable a week later. */
|
|
110
|
+
reported: unknown;
|
|
111
|
+
at: string;
|
|
112
|
+
}
|
|
113
|
+
/** The states a durable action can actually be in — every one of them nameable, including the
|
|
114
|
+
* one nobody likes. */
|
|
115
|
+
export type RecoveryState =
|
|
116
|
+
/** Parked, waiting on a human. Nothing has executed. */
|
|
117
|
+
"parked"
|
|
118
|
+
/** Claimed for execution and NOT journalled: the process died somewhere between claiming and
|
|
119
|
+
* recording the result. The action MAY OR MAY NOT have run — this cannot be resolved locally
|
|
120
|
+
* and must never be retried automatically. */
|
|
121
|
+
| "indeterminate"
|
|
122
|
+
/** Executed, result captured, report not yet accepted by Decidio. Safe to `reconfirm`. */
|
|
123
|
+
| "executed_unreported";
|
|
124
|
+
export interface RecoveryItem {
|
|
125
|
+
decisionId: string;
|
|
126
|
+
action?: string;
|
|
127
|
+
state: RecoveryState;
|
|
128
|
+
/** What a human should do about it, in words. */
|
|
129
|
+
guidance: string;
|
|
130
|
+
}
|
|
83
131
|
export interface ResumeOutcome {
|
|
84
132
|
status: ResumeStatus;
|
|
85
133
|
decisionId: string;
|
|
86
134
|
action?: string;
|
|
87
135
|
result?: unknown;
|
|
88
136
|
reason?: string;
|
|
89
|
-
/** executed only:
|
|
90
|
-
*
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
137
|
+
/** executed only: Decidio ACCEPTED the execution report and named the tier it recorded.
|
|
138
|
+
* `status: "executed"` describes the LOCAL run; this describes the record. */
|
|
139
|
+
reportRecorded?: boolean;
|
|
140
|
+
/** executed only: the recorded tier attests a COMMITTED execution. Deliberately a different
|
|
141
|
+
* question — and a different name — from `reportRecorded`. */
|
|
142
|
+
executionConfirmed?: boolean;
|
|
143
|
+
/** executed only: the evidence tier Decidio recorded, verbatim. */
|
|
144
|
+
evidenceTier?: EvidenceTier;
|
|
145
|
+
/** executed only: why the evidence landed where it did, when that is worth explaining. */
|
|
146
|
+
diagnostic?: string;
|
|
95
147
|
}
|
|
96
148
|
/** Minimal config the controller needs to confirm outcomes + poll status. Shared
|
|
97
149
|
* with GuardConfig (apiUrl/apiToken). */
|
|
@@ -110,6 +162,12 @@ export interface ResumeConfig {
|
|
|
110
162
|
/** Shared secret Decidio signs the resume webhook with (HMAC-SHA256). Defaults to
|
|
111
163
|
* the API token if unset. Verified before any re-execution. */
|
|
112
164
|
webhookSecret?: string;
|
|
165
|
+
/** Where the execution journal lives (default `<pending dir>/reports`). Separate from the
|
|
166
|
+
* pending store by design: journal entries can only re-send a report, never re-run an action. */
|
|
167
|
+
reportDir?: string;
|
|
168
|
+
/** Map the action's natural return value to the evidence Decidio records — same option, same
|
|
169
|
+
* meaning as `protect`'s, so a durable resume reports exactly what the inline path would. */
|
|
170
|
+
evidence?: (result: any) => unknown;
|
|
113
171
|
/** Escape hatch to accept UNSIGNED resume webhooks (no secret). Off by default:
|
|
114
172
|
* with no secret and this unset, the webhook handler rejects every call (fail
|
|
115
173
|
* closed) so a misconfigured agent can't be driven by forged callbacks. Setting it
|
|
@@ -158,6 +216,16 @@ export interface ResumeController extends ResumeConfig {
|
|
|
158
216
|
}): {
|
|
159
217
|
stop: () => void;
|
|
160
218
|
};
|
|
219
|
+
/** Re-send a journalled execution report, IDEMPOTENTLY. Replays the exact request built before
|
|
220
|
+
* the first attempt — it can never re-run the business action, because the journal holds no
|
|
221
|
+
* handler binding. Returns the evidence outcome; a decision with nothing journalled returns
|
|
222
|
+
* `reportRecorded: false` with guidance rather than pretending. */
|
|
223
|
+
reconfirm(decisionId: string): Promise<EvidenceOutcome>;
|
|
224
|
+
/** Every durable item that needs a human's attention, with its state named — including the one
|
|
225
|
+
* state that CANNOT be resolved automatically: `indeterminate`, where a process died between
|
|
226
|
+
* claiming an action and journalling its result, so whether the action ran is unknown locally.
|
|
227
|
+
* This never executes or retries anything; it reports. */
|
|
228
|
+
recover(): Promise<RecoveryItem[]>;
|
|
161
229
|
}
|
|
162
230
|
declare function sign(secret: string, body: string): string;
|
|
163
231
|
/** Verify Decidio's `x-decidio-signature` over the raw webhook body (HMAC-SHA256).
|
|
@@ -168,16 +236,6 @@ export declare function verifyResumeSignature(secret: string, body: string, head
|
|
|
168
236
|
* tier is a FAILED confirm; a SIGNED confirm coming back below application_confirmed is
|
|
169
237
|
* disclosed, never silent. Returns the recorded tier or throws. */
|
|
170
238
|
export declare function assertConfirmRecorded(body: any, decisionId: string, signed: boolean): string;
|
|
171
|
-
/**
|
|
172
|
-
* Build the resume controller. Spread it into your GuardConfig as `resume`, then
|
|
173
|
-
* either mount `webhookHandler()` (production) or run `worker()` (CLI/laptop).
|
|
174
|
-
*
|
|
175
|
-
* const resume = createDecidioResume({ apiUrl, apiToken, store: new FilePendingStore(".decidio-pending"), resumeUrl });
|
|
176
|
-
* const guard = { apiUrl, agentId, apiToken, resume };
|
|
177
|
-
* const createOpportunity = withApproval(createOpportunityRaw, describe, guard);
|
|
178
|
-
* // production: http.createServer(resume.webhookHandler()).listen(4100)
|
|
179
|
-
* // or CLI: resume.worker()
|
|
180
|
-
*/
|
|
181
239
|
export declare function createDecidioResume(opts: ResumeConfig & {
|
|
182
240
|
store: PendingStore;
|
|
183
241
|
}): ResumeController;
|
package/dist/resume.js
CHANGED
|
@@ -3,8 +3,11 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { userInfo } from "node:os";
|
|
5
5
|
import { execFileSync } from "node:child_process";
|
|
6
|
-
import { signConfirm, canonicalize
|
|
7
|
-
|
|
6
|
+
import { signConfirm, canonicalize as jcs, assertSafeTransport } from "./signing.js";
|
|
7
|
+
import { canonicalize as toCanonical, DecidioUnsafeArgumentsError } from "./canonical.js";
|
|
8
|
+
import { readConfirmResponse, buildConfirmBody } from "./evidence.js";
|
|
9
|
+
import { DecidioHttpError } 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 }), "utf8");
|
|
8
11
|
function signArgs(p, agentKey, webhookSecret) {
|
|
9
12
|
const bytes = argsBindingBytes(p);
|
|
10
13
|
if (agentKey) {
|
|
@@ -49,24 +52,32 @@ export class MemoryPendingStore {
|
|
|
49
52
|
async claim(id) { if (this.claimed.has(id) || !this.m.has(id))
|
|
50
53
|
return false; this.claimed.add(id); return true; }
|
|
51
54
|
async release(id) { this.claimed.delete(id); }
|
|
55
|
+
async listClaimed() { return [...this.claimed].map((id) => this.m.get(id)).filter(Boolean); }
|
|
56
|
+
}
|
|
57
|
+
export function hardenDir(dir) {
|
|
58
|
+
if (process.platform !== "win32")
|
|
59
|
+
return;
|
|
60
|
+
try {
|
|
61
|
+
const user = (() => { try {
|
|
62
|
+
return userInfo().username;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return process.env.USERNAME ?? process.env.USER ?? "";
|
|
66
|
+
} })();
|
|
67
|
+
if (!user)
|
|
68
|
+
throw new Error("could not determine the current user");
|
|
69
|
+
execFileSync("icacls", [dir, "/inheritance:r", "/grant:r", `${user}:(OI)(CI)F`], { stdio: "ignore" });
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
console.warn(`[decidio] WARNING: could not restrict ${dir} to owner-only (${e?.message ?? e}). It keeps the directory's inherited permissions, so other accounts on this machine may be able to read parked action arguments. Move the pending store to a protected location, or set DECIDIO_PENDING_DIR to one.`);
|
|
73
|
+
}
|
|
52
74
|
}
|
|
53
75
|
export class FilePendingStore {
|
|
54
76
|
dir;
|
|
55
77
|
constructor(dir) {
|
|
56
78
|
this.dir = dir;
|
|
57
79
|
mkdirSync(dir, { recursive: true });
|
|
58
|
-
|
|
59
|
-
try {
|
|
60
|
-
const user = (() => { try {
|
|
61
|
-
return userInfo().username;
|
|
62
|
-
}
|
|
63
|
-
catch {
|
|
64
|
-
return process.env.USERNAME ?? process.env.USER ?? "";
|
|
65
|
-
} })();
|
|
66
|
-
execFileSync("icacls", [dir, "/inheritance:r", "/grant:r", `${user}:(OI)(CI)F`], { stdio: "ignore" });
|
|
67
|
-
}
|
|
68
|
-
catch { }
|
|
69
|
-
}
|
|
80
|
+
hardenDir(dir);
|
|
70
81
|
}
|
|
71
82
|
key(id) { return id.replace(/[^\w.-]/g, "_"); }
|
|
72
83
|
path(id) { return join(this.dir, `${this.key(id)}.json`); }
|
|
@@ -110,6 +121,20 @@ export class FilePendingStore {
|
|
|
110
121
|
}
|
|
111
122
|
catch { }
|
|
112
123
|
}
|
|
124
|
+
async listClaimed() {
|
|
125
|
+
if (!existsSync(this.dir))
|
|
126
|
+
return [];
|
|
127
|
+
const out = [];
|
|
128
|
+
for (const name of readdirSync(this.dir)) {
|
|
129
|
+
if (!name.endsWith(".claimed"))
|
|
130
|
+
continue;
|
|
131
|
+
try {
|
|
132
|
+
out.push(JSON.parse(readFileSync(join(this.dir, name), "utf8")));
|
|
133
|
+
}
|
|
134
|
+
catch { }
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
113
138
|
}
|
|
114
139
|
export class DecidioSuspendedError extends Error {
|
|
115
140
|
decisionId;
|
|
@@ -141,21 +166,31 @@ export function assertConfirmRecorded(body, decisionId, signed) {
|
|
|
141
166
|
}
|
|
142
167
|
return body.confirmation;
|
|
143
168
|
}
|
|
144
|
-
|
|
145
|
-
const f = cfg.fetchImpl ?? fetch;
|
|
146
|
-
assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
|
|
169
|
+
function buildResumeConfirmRequest(cfg, decisionId, canonicalResult) {
|
|
147
170
|
const requesterIdentity = signConfirm(decisionId, cfg);
|
|
148
171
|
if (!requesterIdentity)
|
|
149
|
-
console.warn(`[decidio] execution evidence for ${decisionId} recorded as
|
|
172
|
+
console.warn(`[decidio] execution evidence for ${decisionId} will be recorded as an unproven agent assertion — no signing key configured. Set DECIDIO_AGENT_KEY/DID to earn a confirming tier.`);
|
|
173
|
+
return buildConfirmBody({ agentId: cfg.agentId ?? "agent", agentKey: cfg.agentKey, agentDid: cfg.agentDid, workspaceId: cfg.workspaceId }, decisionId, canonicalResult, requesterIdentity);
|
|
174
|
+
}
|
|
175
|
+
async function postConfirm(cfg, decisionId, body) {
|
|
176
|
+
const f = cfg.fetchImpl ?? fetch;
|
|
177
|
+
assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
|
|
150
178
|
const r = await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
|
|
151
179
|
method: "POST",
|
|
152
180
|
headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
153
|
-
body: JSON.stringify(
|
|
181
|
+
body: JSON.stringify(body),
|
|
154
182
|
});
|
|
155
|
-
if (!r.ok)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
183
|
+
if (!r.ok) {
|
|
184
|
+
const raw = await r.text().catch(() => "");
|
|
185
|
+
let parsed = raw;
|
|
186
|
+
try {
|
|
187
|
+
parsed = raw ? JSON.parse(raw) : undefined;
|
|
188
|
+
}
|
|
189
|
+
catch { }
|
|
190
|
+
throw new DecidioHttpError(r.status, "/agent/confirm", parsed);
|
|
191
|
+
}
|
|
192
|
+
const parsed = await r.json().catch(() => null);
|
|
193
|
+
return readConfirmResponse(parsed, { signed: !!body.requesterIdentity, capturedResponse: body.capturedResponse, journalled: true });
|
|
159
194
|
}
|
|
160
195
|
async function statusOf(cfg, decisionId) {
|
|
161
196
|
const f = cfg.fetchImpl ?? fetch;
|
|
@@ -164,15 +199,106 @@ async function statusOf(cfg, decisionId) {
|
|
|
164
199
|
headers: { ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
165
200
|
});
|
|
166
201
|
if (!r.ok)
|
|
167
|
-
throw new
|
|
202
|
+
throw new DecidioHttpError(r.status, `/agent/status/${decisionId}`);
|
|
168
203
|
return r.json();
|
|
169
204
|
}
|
|
205
|
+
class ReportJournal {
|
|
206
|
+
dir;
|
|
207
|
+
constructor(dir) {
|
|
208
|
+
this.dir = dir;
|
|
209
|
+
try {
|
|
210
|
+
mkdirSync(dir, { recursive: true });
|
|
211
|
+
hardenDir(dir);
|
|
212
|
+
}
|
|
213
|
+
catch { }
|
|
214
|
+
}
|
|
215
|
+
p(id) { return join(this.dir, `${id.replace(/[^\w.-]/g, "_")}.json`); }
|
|
216
|
+
write(e) { try {
|
|
217
|
+
writeFileSync(this.p(e.decisionId), JSON.stringify(e, null, 2), { mode: 0o600 });
|
|
218
|
+
}
|
|
219
|
+
catch { } }
|
|
220
|
+
read(id) { try {
|
|
221
|
+
return existsSync(this.p(id)) ? JSON.parse(readFileSync(this.p(id), "utf8")) : null;
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return null;
|
|
225
|
+
} }
|
|
226
|
+
clear(id) { try {
|
|
227
|
+
if (existsSync(this.p(id)))
|
|
228
|
+
rmSync(this.p(id));
|
|
229
|
+
}
|
|
230
|
+
catch { } }
|
|
231
|
+
list() {
|
|
232
|
+
try {
|
|
233
|
+
if (!existsSync(this.dir))
|
|
234
|
+
return [];
|
|
235
|
+
return readdirSync(this.dir).filter((f) => f.endsWith(".json")).map((f) => { try {
|
|
236
|
+
return JSON.parse(readFileSync(join(this.dir, f), "utf8"));
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return null;
|
|
240
|
+
} }).filter(Boolean);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
170
247
|
export function createDecidioResume(opts) {
|
|
171
248
|
const registry = new Map();
|
|
172
249
|
const inFlight = new Set();
|
|
173
250
|
const secret = opts.webhookSecret ?? opts.apiToken ?? "";
|
|
174
251
|
if (opts.allowUnsigned)
|
|
175
252
|
console.warn("[decidio] WARNING: allowUnsigned is ON — this controller will accept UNSIGNED resume webhooks. Anyone who can reach the endpoint can signal work (parked-payload integrity still gates execution). Use this only behind a trusted network boundary, never on an internet-reachable endpoint.");
|
|
253
|
+
const journal = new ReportJournal(opts.reportDir ?? join(process.env.DECIDIO_PENDING_DIR ?? ".decidio-pending", "reports"));
|
|
254
|
+
async function reportAfterExecution(pending, decisionId, result) {
|
|
255
|
+
const base = { status: "executed", decisionId, action: pending.action, result };
|
|
256
|
+
let reported;
|
|
257
|
+
let haveReported = false;
|
|
258
|
+
let buildProblem;
|
|
259
|
+
try {
|
|
260
|
+
reported = toCanonical((ctrl.evidence ? ctrl.evidence(result) : result) ?? null, "evidence(result)");
|
|
261
|
+
haveReported = true;
|
|
262
|
+
}
|
|
263
|
+
catch (e) {
|
|
264
|
+
buildProblem = `${e?.message ?? e}`;
|
|
265
|
+
reported = null;
|
|
266
|
+
haveReported = true;
|
|
267
|
+
}
|
|
268
|
+
if (haveReported)
|
|
269
|
+
journal.write({ decisionId, action: pending.action, reported, at: new Date().toISOString() });
|
|
270
|
+
let confirmRequest;
|
|
271
|
+
try {
|
|
272
|
+
confirmRequest = buildResumeConfirmRequest(ctrl, decisionId, reported);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
confirmRequest = undefined;
|
|
276
|
+
}
|
|
277
|
+
if (!confirmRequest) {
|
|
278
|
+
await ctrl.store.delete(decisionId).catch(() => { });
|
|
279
|
+
const diagnostic = `"${pending.action}" EXECUTED, but no execution report could be built (${buildProblem ?? "unknown"}). Nothing will re-run it; record the outcome from your own system.`;
|
|
280
|
+
console.warn(`[decidio] ${decisionId}: ${diagnostic}`);
|
|
281
|
+
return { ...base, reportRecorded: false, executionConfirmed: false, diagnostic };
|
|
282
|
+
}
|
|
283
|
+
await ctrl.store.delete(decisionId).catch(() => { });
|
|
284
|
+
let ev;
|
|
285
|
+
try {
|
|
286
|
+
ev = await postConfirm(ctrl, decisionId, confirmRequest);
|
|
287
|
+
}
|
|
288
|
+
catch (e) {
|
|
289
|
+
ev = { reportRecorded: false, executionConfirmed: false, diagnostic: `the execution report did not reach Decidio (${e?.message ?? e}). Your action DID run and the report is journalled - re-send it with reconfirm("${decisionId}").` };
|
|
290
|
+
}
|
|
291
|
+
if (ev.reportRecorded)
|
|
292
|
+
journal.clear(decisionId);
|
|
293
|
+
const diagnostic = buildProblem
|
|
294
|
+
? `${ev.diagnostic ? ev.diagnostic + " " : ""}Also: the action's return value was not reportable (${buildProblem}), so an empty response was reported - map it with the \`evidence\` option to earn a confirming tier.`
|
|
295
|
+
: ev.diagnostic;
|
|
296
|
+
if (!ev.reportRecorded)
|
|
297
|
+
console.warn(`[decidio] ${decisionId}: ${diagnostic}`);
|
|
298
|
+
else if (!ev.executionConfirmed)
|
|
299
|
+
console.warn(`[decidio] execution evidence for ${decisionId} recorded as ${ev.evidenceTier} - ${diagnostic}`);
|
|
300
|
+
return { ...base, reportRecorded: ev.reportRecorded, executionConfirmed: ev.executionConfirmed, ...(ev.evidenceTier ? { evidenceTier: ev.evidenceTier } : {}), ...(diagnostic ? { diagnostic } : {}) };
|
|
301
|
+
}
|
|
176
302
|
const refusalWarned = new Set();
|
|
177
303
|
const refuseOnce = (decisionId, msg) => {
|
|
178
304
|
if (refusalWarned.has(decisionId))
|
|
@@ -196,7 +322,7 @@ export function createDecidioResume(opts) {
|
|
|
196
322
|
},
|
|
197
323
|
async park(p) {
|
|
198
324
|
try {
|
|
199
|
-
|
|
325
|
+
toCanonical(p.args, "args");
|
|
200
326
|
}
|
|
201
327
|
catch (e) {
|
|
202
328
|
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);
|
|
@@ -257,25 +383,7 @@ export function createDecidioResume(opts) {
|
|
|
257
383
|
return { status: "unknown", decisionId };
|
|
258
384
|
try {
|
|
259
385
|
const result = await fn(...pending.args);
|
|
260
|
-
|
|
261
|
-
let tier;
|
|
262
|
-
try {
|
|
263
|
-
tier = await confirm(ctrl, decisionId, result);
|
|
264
|
-
}
|
|
265
|
-
catch (e) {
|
|
266
|
-
confirmed = false;
|
|
267
|
-
console.warn(`[decidio] resume confirm failed (your action DID succeed): ${e.message}`);
|
|
268
|
-
}
|
|
269
|
-
const retainedIsSafe = typeof ctrl.store.claim === "function";
|
|
270
|
-
if (confirmed || !retainedIsSafe)
|
|
271
|
-
await ctrl.store.delete(decisionId);
|
|
272
|
-
if (!confirmed) {
|
|
273
|
-
if (retainedIsSafe)
|
|
274
|
-
console.warn(` The parked entry for ${decisionId} is kept as local evidence that it executed — it stays CLAIMED, so it cannot re-run. Retry the confirmation, then delete it.`);
|
|
275
|
-
else
|
|
276
|
-
console.warn(` This store provides no claim() primitive, so the entry was DELETED to guarantee the action cannot execute twice. ${decisionId} executed, but its confirmation never reached Decidio — re-confirm it from your own records.`);
|
|
277
|
-
}
|
|
278
|
-
return finish({ status: "executed", decisionId, action: pending.action, result, confirmed, ...(tier ? { evidenceTier: tier } : {}) });
|
|
386
|
+
return finish(await reportAfterExecution(pending, decisionId, result));
|
|
279
387
|
}
|
|
280
388
|
catch (e) {
|
|
281
389
|
if (ctrl.store.release)
|
|
@@ -342,6 +450,48 @@ export function createDecidioResume(opts) {
|
|
|
342
450
|
}
|
|
343
451
|
};
|
|
344
452
|
},
|
|
453
|
+
async reconfirm(decisionId) {
|
|
454
|
+
const entry = journal.read(decisionId);
|
|
455
|
+
if (!entry) {
|
|
456
|
+
return {
|
|
457
|
+
reportRecorded: false, executionConfirmed: false,
|
|
458
|
+
diagnostic: `nothing is journalled for ${decisionId}: either its report was already recorded (nothing to do) or this process never executed it. Check the record in Decidio — do NOT re-run the action to find out.`,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
let request;
|
|
462
|
+
try {
|
|
463
|
+
request = buildResumeConfirmRequest(ctrl, decisionId, entry.reported);
|
|
464
|
+
}
|
|
465
|
+
catch (e) {
|
|
466
|
+
return { reportRecorded: false, executionConfirmed: false, diagnostic: `the journalled report for ${decisionId} could not be re-signed (${e?.message ?? e}). The entry is kept; fix the agent's signing config (DECIDIO_AGENT_KEY/DID) and retry.` };
|
|
467
|
+
}
|
|
468
|
+
const ev = await postConfirm(ctrl, decisionId, request);
|
|
469
|
+
if (ev.reportRecorded)
|
|
470
|
+
journal.clear(decisionId);
|
|
471
|
+
return ev;
|
|
472
|
+
},
|
|
473
|
+
async recover() {
|
|
474
|
+
const items = [];
|
|
475
|
+
for (const e of journal.list()) {
|
|
476
|
+
items.push({
|
|
477
|
+
decisionId: e.decisionId, action: e.action, state: "executed_unreported",
|
|
478
|
+
guidance: `"${e.action}" executed at ${e.at} and its execution report has not been recorded. Re-send it safely: guard.resume.reconfirm("${e.decisionId}") — this replays the saved report and cannot re-run the action.`,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
const claimed = ctrl.store.listClaimed ? await ctrl.store.listClaimed().catch(() => []) : [];
|
|
482
|
+
for (const p of claimed) {
|
|
483
|
+
if (journal.read(p.decisionId))
|
|
484
|
+
continue;
|
|
485
|
+
items.push({
|
|
486
|
+
decisionId: p.decisionId, action: p.action, state: "indeterminate",
|
|
487
|
+
guidance: `"${p.action}" was CLAIMED for execution but no result was ever recorded: a process died mid-flight, so it is unknown locally whether the action ran. Reconcile in the downstream system (or with an idempotency key) before deciding. The SDK will NOT retry it automatically, and neither should a script.`,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
for (const p of await ctrl.store.list().catch(() => [])) {
|
|
491
|
+
items.push({ decisionId: p.decisionId, action: p.action, state: "parked", guidance: `"${p.action}" is parked awaiting a human decision. Nothing has executed.` });
|
|
492
|
+
}
|
|
493
|
+
return items;
|
|
494
|
+
},
|
|
345
495
|
worker(workerOpts) {
|
|
346
496
|
const interval = workerOpts?.intervalMs ?? 2_500;
|
|
347
497
|
const deadline = workerOpts?.timeoutMs ? Date.now() + workerOpts.timeoutMs : Infinity;
|
|
@@ -364,10 +514,10 @@ export function createDecidioResume(opts) {
|
|
|
364
514
|
continue;
|
|
365
515
|
try {
|
|
366
516
|
const outcome = await ctrl.resolve({ decisionId: p.decisionId, verdict, reason: st.reason });
|
|
367
|
-
if (outcome.status === "executed" && outcome.
|
|
368
|
-
console.error(`[decidio] resumed ${p.decisionId}: approved → "${p.action}" executed
|
|
517
|
+
if (outcome.status === "executed" && outcome.reportRecorded)
|
|
518
|
+
console.error(`[decidio] resumed ${p.decisionId}: approved → "${p.action}" executed; evidence recorded as ${outcome.evidenceTier}${outcome.executionConfirmed ? "" : " (execution NOT confirmed — see the warning above)"}.`);
|
|
369
519
|
else if (outcome.status === "executed")
|
|
370
|
-
console.error(`[decidio] resumed ${p.decisionId}: approved → "${p.action}" executed, but its execution
|
|
520
|
+
console.error(`[decidio] resumed ${p.decisionId}: approved → "${p.action}" executed, but its execution report did NOT reach Decidio — recover with: guard.resume.reconfirm("${p.decisionId}")`);
|
|
371
521
|
else if (outcome.status === "rejected")
|
|
372
522
|
console.error(`[decidio] resumed ${p.decisionId}: REJECTED — the parked "${p.action}" was discarded, nothing executed.`);
|
|
373
523
|
else if (outcome.status === "blocked")
|