@decidio/sdk 0.3.3 → 0.4.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 +92 -0
- package/README.md +80 -20
- package/dist/adapters/_gate.js +2 -1
- package/dist/adapters/openai.d.ts +1 -1
- package/dist/adapters/temporal.d.ts +5 -2
- package/dist/adapters/temporal.js +4 -3
- package/dist/cli.js +314 -23
- package/dist/errors.js +5 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +9 -6
- package/dist/inngest.d.ts +2 -0
- package/dist/inngest.js +2 -0
- package/dist/resume.d.ts +9 -2
- package/dist/resume.js +12 -2
- package/dist/signing.d.ts +8 -0
- package/dist/signing.js +17 -4
- package/package.json +3 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Changelog — @decidio/sdk
|
|
2
|
+
|
|
3
|
+
Release notes for the client SDK. The Python package `decidio` ships the same changes under the same
|
|
4
|
+
version (the two are conformance-locked twins). Each entry names what changed, what a reader must do
|
|
5
|
+
on upgrade, and what proved it.
|
|
6
|
+
|
|
7
|
+
## 0.4.0 — the scope statement
|
|
8
|
+
|
|
9
|
+
- **README: "What 0.4 does not yet claim" and "Compatibility and support."** The stability promise is
|
|
10
|
+
stated: what is stable, what is Beta (the engine adapters), what is deprecated and kept until 1.0
|
|
11
|
+
(`protectBestEffort`, `gateInterruptions`), and the five things reviewers keep finding that only
|
|
12
|
+
product decisions close (trust-on-first-use issuer DID, the unshipped schema, the unshipped
|
|
13
|
+
conformance suites, provenance and the repository link). 1.0.0 follows user-acceptance testing.
|
|
14
|
+
- **The tour fails closed on an unpinned check.** If the API omits the issuer DID, the tour used to warn
|
|
15
|
+
and still report success; it now fails and cleans up.
|
|
16
|
+
- **The tour's preflight names everything it writes**, including `.decidio-token-journal` and
|
|
17
|
+
`.decidio-pending/`.
|
|
18
|
+
- **`--` ends option parsing on all three verifier doors**, so a receipt saved under a dash-leading
|
|
19
|
+
name can be verified.
|
|
20
|
+
- **The token recovery journal treats corruption as uncertainty, never absence** (prodsec retest of
|
|
21
|
+
0.3.5). Every row is parsed and schema-checked before any mint; a row that cannot be trusted refuses
|
|
22
|
+
`init` and `quickstart`, turns `doctor` red and makes `quickstart cleanup` exit non-zero - the bytes are
|
|
23
|
+
left as found. Appends are framed (a delimiter first if the previous row lost its newline, a schema
|
|
24
|
+
version, a write loop before the fsync), so a torn row stays discoverable.
|
|
25
|
+
- **A resume signal that finds the decision `invoking` answers `indeterminate`**, not `already_resolved`
|
|
26
|
+
with "already ran": the action may still be running, may have crashed before any effect, or may have
|
|
27
|
+
finished without reporting. `already_resolved` is reserved for the final states.
|
|
28
|
+
- **Temporal (Beta): a custom `confirm` may return the gate's confirm outcome** (validated with
|
|
29
|
+
`readConfirmResponse`) and it becomes the step's confirmation instead of being discarded.
|
|
30
|
+
- **Transport: every refusal redacts URL userinfo**, including the opaque `scheme:user:secret@` spelling,
|
|
31
|
+
and Unicode space and format characters (NBSP, ideographic space, zero-width space, …) are refused in
|
|
32
|
+
both runtimes from one explicit list. Four cases joined the shared transport corpus.
|
|
33
|
+
- **This file ships** in the tarball (and in the Python sdist), as a deliberate publish-boundary line.
|
|
34
|
+
|
|
35
|
+
## 0.3.5 — the retest residuals
|
|
36
|
+
|
|
37
|
+
- **Transport:** any control character or whitespace anywhere in the URL is refused before anything is
|
|
38
|
+
sent; refusals echo a redacted spelling (URL userinfo is a credential).
|
|
39
|
+
- **Credential lifecycle:** the quickstart inspects a previous `.env.quickstart` before minting; secret
|
|
40
|
+
files are written temp + fsync + rename; **the token recovery journal** (`.decidio-token-journal`,
|
|
41
|
+
owner-only, append-only) records every minted token id before the credential file exists and retires
|
|
42
|
+
it on a durable write or any proven revoke. Credential creation fails closed if the journal cannot be
|
|
43
|
+
written. `quickstart cleanup` sweeps it; `doctor` lists outstanding entries.
|
|
44
|
+
- **Verifiers (all three doors):** the `verificationMethod` must be the issuer's exact `did:key` method
|
|
45
|
+
identifier, and `created` must be a real calendar date-time. Correctly signed receipts naming a
|
|
46
|
+
nonexistent method fragment or an impossible time are now INVALID.
|
|
47
|
+
- **Adapter gate:** the same describe validation as the core (a blank action or negative amount is
|
|
48
|
+
refused before signing or posting).
|
|
49
|
+
- **Outcomes:** a resume signal that lost the race to a final answer is `already_resolved`; a parked row
|
|
50
|
+
whose payload is gone is `orphaned_state`.
|
|
51
|
+
- Upgrade: no action. Custom stores are unaffected.
|
|
52
|
+
|
|
53
|
+
## 0.3.4 — first-run consistency
|
|
54
|
+
|
|
55
|
+
- Tour and README pointers, routing-by-default vs the universal-backstop block named as two states, the
|
|
56
|
+
stale "unsafe values park and fail on resume" paragraph corrected, cleanup discloses the durable
|
|
57
|
+
store, help never loads `.env` (and only when usage will actually print), adapter headers current.
|
|
58
|
+
- Upgrade: no action.
|
|
59
|
+
|
|
60
|
+
## 0.3.2 / 0.3.3 — the transition itself is atomic (COORDINATED UPGRADE)
|
|
61
|
+
|
|
62
|
+
- **The shipped file stores are an append-only revision chain** (`<key>.rev.<N>`), published with a
|
|
63
|
+
hard link that refuses an existing name: exactly one writer wins each transition, and a loser
|
|
64
|
+
re-validates against what actually happened. This closed the pre-0.3.2 race in which a denial could
|
|
65
|
+
be written and returned and the denied action still run.
|
|
66
|
+
- **Upgrade — read this if a directory is shared by more than one worker:** the first 0.3.2+ store
|
|
67
|
+
migrates the older marker layout one way. **Stop every older worker that shares the directory before
|
|
68
|
+
the first 0.3.2+ process starts**, then upgrade them together. An older worker writing into an
|
|
69
|
+
upgraded directory leaves a marker beside the chain, and that decision reads as unreadable by design.
|
|
70
|
+
Nothing deletes a revision; each decision keeps a handful of small files (see the README's sizing
|
|
71
|
+
envelope).
|
|
72
|
+
- 0.3.3: one snapshot of `describe()`'s answer for signing and posting; the shared id grammar at every
|
|
73
|
+
exported resume door; a compensating revoke when a minted token's credential file fails to write;
|
|
74
|
+
`reconfirm()` on a reported decision tells the truth about confirmation.
|
|
75
|
+
|
|
76
|
+
## 0.3.1
|
|
77
|
+
|
|
78
|
+
- The verifier says what it proves (canonical signed claims, not exact bytes); proof options
|
|
79
|
+
(`proofPurpose`, `verificationMethod`, `created`) are part of the claim on every door; duplicate
|
|
80
|
+
`--issuer` flags are refused everywhere; orphaned token ids survive every replacement.
|
|
81
|
+
|
|
82
|
+
## 0.3.0
|
|
83
|
+
|
|
84
|
+
- A claim that gates nothing: `reportRecorded` and `executionConfirmed` are distinct, honest booleans.
|
|
85
|
+
A denial is final on every path. The decision-id grammar is defined server-side and mirrored here.
|
|
86
|
+
- Upgrade for custom stores: `markDiscarded` is required (a hard construction failure without it).
|
|
87
|
+
|
|
88
|
+
## 0.2.x
|
|
89
|
+
|
|
90
|
+
- The execution state machine (`parked → claimed_preinvoke → invoking → executed_unreported |
|
|
91
|
+
indeterminate → reported`), `reconcile()`, `ExecutionContext` with a stable idempotency key, and the
|
|
92
|
+
composite return (`{ value, decisionId, confirmation }`) with `protectBestEffort` keeping the old shape.
|
package/README.md
CHANGED
|
@@ -57,13 +57,17 @@ npm install @decidio/sdk
|
|
|
57
57
|
npx @decidio/sdk init --unique my-agent # --unique appends a random suffix: collision-proof
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
+
> Just ran `npx @decidio/sdk quickstart` in this directory and kept its agent? Its `.env` already
|
|
61
|
+
> works — skip this step and continue at step 3.
|
|
62
|
+
|
|
60
63
|
Every CLI command talks to the hosted sandbox by default and announces its target host on
|
|
61
64
|
stderr each run — set `DECIDIO_API_URL` (env or `.env`) to point at another Decidio instead.
|
|
62
65
|
|
|
63
66
|
`init` signs you into your workspace (email + password, or paste the sign-in link from your
|
|
64
67
|
workspace email), generates the agent's Ed25519 keypair **locally** (only the public `did:key`
|
|
65
68
|
is sent), registers the agent, mints its **floor-limited API token** (limited to the agent runtime — request authorization, poll status, report signed confirmation; it cannot administer the workspace or approve anything — and your workspace session never touches the agent's disk), and writes `.env`.
|
|
66
|
-
Every later command reads `.env` from this directory automatically
|
|
69
|
+
Every later command that talks to Decidio reads `.env` from this directory automatically (`verify` is
|
|
70
|
+
offline and never does). Re-running init for an
|
|
67
71
|
agent that already has a token REPLACES it — the server keeps one active token per agent, so
|
|
68
72
|
the previous one is revoked and init says so.
|
|
69
73
|
|
|
@@ -230,7 +234,11 @@ Real approvals take minutes to days, and nobody keeps a terminal open for them.
|
|
|
230
234
|
`mode: "blocking"` (durable is the default) and a routed action **suspends** instead of
|
|
231
235
|
waiting: it parks its call arguments agent-side (`.decidio-pending/` by default — Decidio
|
|
232
236
|
stores none of your downstream payload) and throws `DecidioSuspendedError`. The process may
|
|
233
|
-
exit.
|
|
237
|
+
exit. **The parked payload is plaintext on the agent's disk:** the full call arguments and their
|
|
238
|
+
signature sit in `.decidio-pending/` until the decision resolves — owner-only permissions where the
|
|
239
|
+
OS supports it, gitignored by `init`, but not encrypted. Treat that directory as you treat the `.env`
|
|
240
|
+
beside it; a custom `PendingStore` can encrypt at rest. Two ways the parked action resumes when a
|
|
241
|
+
human decides:
|
|
234
242
|
|
|
235
243
|
**The worker (self-serve — start here).** No inbound URL, no shared secrets, and it resumes work
|
|
236
244
|
the current process never started. Here is a complete `agent.mjs` you can run:
|
|
@@ -421,11 +429,12 @@ the store you configured, so two workers sharing one Redis or Postgres store can
|
|
|
421
429
|
other's work. (Through 0.1.x this was a separate local-filesystem journal, which meant two
|
|
422
430
|
workers kept two disjoint journals and neither could. `reportDir` is accepted and ignored now.)
|
|
423
431
|
|
|
424
|
-
**Parked arguments must be JSON-serializable
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
432
|
+
**Parked arguments must be JSON-serializable — and that is checked BEFORE routing.** Values that
|
|
433
|
+
do not survive JSON with their meaning intact — `Date`, `Map`, `undefined`, `NaN`, class instances,
|
|
434
|
+
functions — are refused with `DecidioUnsafeArgumentsError` before any decision exists and before any
|
|
435
|
+
network call (since 0.1.12): nothing is parked, nothing is routed. The parked payload is also
|
|
436
|
+
signed, so a file edited on disk fails the integrity check on resume (a refusal, never a wrong
|
|
437
|
+
execution). Pass plain data; rehydrate inside your function.
|
|
429
438
|
|
|
430
439
|
**Payload integrity.** When a routed action is parked, the SDK signs its arguments (bound to the
|
|
431
440
|
decision id) with your agent key; on resume it **refuses to execute** if the parked payload was
|
|
@@ -451,7 +460,7 @@ for engines you don't. Each adapter's shipped `.d.ts` header documents its wirin
|
|
|
451
460
|
|
|
452
461
|
|
|
453
462
|
|
|
454
|
-
## The durable store is a revision chain (0.3.2)
|
|
463
|
+
## The durable store is a revision chain (since 0.3.2)
|
|
455
464
|
|
|
456
465
|
`FilePendingStore` keeps each decision's execution record as an **append-only revision chain** —
|
|
457
466
|
`<id>.rev.1`, `<id>.rev.2`, … — and the head is the highest number. Every state change reads the
|
|
@@ -484,11 +493,19 @@ the store refuses to pick one.
|
|
|
484
493
|
|
|
485
494
|
**Custom stores.** The `PendingStore` contract has said since 0.2.0 that every method must be atomic
|
|
486
495
|
against concurrent callers. The shipped file store did not honor that until now; a custom store must.
|
|
487
|
-
The deterministic two-process schedules that proved the defect live in
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
paused while the approver publishes four revisions past it. A store that cannot pass
|
|
491
|
-
atomic, whatever its methods say.
|
|
496
|
+
The deterministic two-process schedules that proved the defect live in the Decidio source
|
|
497
|
+
repository's conformance suite (not part of this package): a denial landing while an invoker is
|
|
498
|
+
paused between validating and publishing; two conflicting settlements from one head; and a denier
|
|
499
|
+
paused while the approver publishes four revisions past it. A store that cannot pass those schedules
|
|
500
|
+
is not atomic, whatever its methods say.
|
|
501
|
+
|
|
502
|
+
**Sizing envelope.** Nothing deletes a revision, so each decision leaves a handful of small files (one
|
|
503
|
+
per transition, typically four to six) and a head lookup lists the directory. That is comfortable for
|
|
504
|
+
tens of thousands of decisions on a local disk; beyond that, or for any deployment that shares a store
|
|
505
|
+
across hosts, use a transactional custom `PendingStore`. The tested envelope is a **single host on a local
|
|
506
|
+
filesystem with hard links** — network, FUSE and multi-host volumes are outside it, and their link
|
|
507
|
+
semantics have not been exercised. Never compact the directory while a worker runs; if compaction is
|
|
508
|
+
ever wanted it is an offline job with every writer stopped.
|
|
492
509
|
|
|
493
510
|
## Reading a receipt
|
|
494
511
|
|
|
@@ -510,8 +527,9 @@ request was compared to and did not clear, not a limit that was applied to it. R
|
|
|
510
527
|
`requested_amount` as the ask, the other two as ceilings.
|
|
511
528
|
|
|
512
529
|
**`firedRules` is what the policy engine actually matched — for any verdict.** It is not an
|
|
513
|
-
auto-approve indicator: a **blocked
|
|
514
|
-
non-empty does not mean "approved" and empty does
|
|
530
|
+
auto-approve indicator: a request **blocked by the universal backstop** (no active policy governs
|
|
531
|
+
agents at all) carries that backstop rule here, so non-empty does not mean "approved" and empty does
|
|
532
|
+
not mean "blocked". The field that states the
|
|
515
533
|
disposition is `authority.result`; `firedRules` tells you which named, versioned rules produced it.
|
|
516
534
|
|
|
517
535
|
**`payloadSchemaUri` is a `urn:`, not a URL.** It is content-addressed —
|
|
@@ -541,8 +559,10 @@ verdict, reason?)` is now REQUIRED — it writes the tombstone that makes a deni
|
|
|
541
559
|
permanently unclaimable, and a store that cannot record a denial cannot uphold the guarantee, so
|
|
542
560
|
this is refused at construction rather than deep inside a resolve. `delete` also takes an optional
|
|
543
561
|
owning attempt (`delete(id, attemptId?)`) so a superseded attempt cannot clean up the live one's
|
|
544
|
-
payload, and `settle` takes `workerStopped`. The shipped stores show the shape
|
|
545
|
-
|
|
562
|
+
payload, and `settle` takes `workerStopped`. The shipped stores show the shape; the ownership
|
|
563
|
+
corpus that pins these rules (claim fencing, terminal denial, advance-only transitions, settle
|
|
564
|
+
preconditions) lives in the Decidio source repository and is not part of this package — the rules
|
|
565
|
+
themselves are the ones stated above.
|
|
546
566
|
|
|
547
567
|
**`recover()` now throws instead of returning `[]`** when the ledger cannot be read
|
|
548
568
|
(`DecidioRecoveryUnavailableError`). `[]` and "I cannot tell you what you owe" are the same value
|
|
@@ -581,8 +601,9 @@ that fits every other confirm error.
|
|
|
581
601
|
|
|
582
602
|
- **Engine adapters are BETA.** They share the core's gate semantics — the verdict allow-list,
|
|
583
603
|
the canonical argument model, the transport rule and evidence handling are ONE implementation,
|
|
584
|
-
and a conformance matrix runs the same malformed-verdict, unsafe-argument
|
|
585
|
-
battery against every door. What is *not* yet
|
|
604
|
+
and a conformance matrix runs the same malformed-verdict, unsafe-argument, plaintext-transport and
|
|
605
|
+
malformed-description (blank action, negative amount) battery against every door. What is *not* yet
|
|
606
|
+
verified is the engine integration itself: those
|
|
586
607
|
paths are exercised against structural mocks, not live LangGraph/Temporal/Inngest/OpenAI
|
|
587
608
|
runtimes. Use the core path for production until that changes. Each adapter is bound through its
|
|
588
609
|
own `@decidio/sdk/<engine>` subpath (they need the engine handle at call time); no engine at all?
|
|
@@ -631,7 +652,7 @@ manager for production.
|
|
|
631
652
|
|
|
632
653
|
## Errors
|
|
633
654
|
|
|
634
|
-
- `DecidioBlockedError` — policy blocked the action
|
|
655
|
+
- `DecidioBlockedError` — policy blocked the action: a named block rule, or the universal backstop that fires when NO active policy governs agents at all (fail closed, no human involved). Distinct from routing-by-default — with an active policy and no matching auto-approve rule, a request is not blocked, it is ROUTED to a person.
|
|
635
656
|
- `DecidioRejectedError` — a human rejected the routed action.
|
|
636
657
|
- `DecidioSuspendedError` — (durable mode) the action was routed and is parked for async approval; the process may exit and resume later. Not a failure.
|
|
637
658
|
- `DecidioTimeoutError` — (blocking mode only) no human decided within `pollTimeoutMs` (default 10 min); the decision stays open in Decidio, nothing executed locally.
|
|
@@ -657,6 +678,45 @@ Every error raised after a decision exists carries the `decisionId`, so a suspen
|
|
|
657
678
|
timed-out action is always findable in
|
|
658
679
|
your queue.
|
|
659
680
|
|
|
681
|
+
## What 0.4 does not yet claim
|
|
682
|
+
|
|
683
|
+
The retest that graded 0.3.3 *A- for the core and the shipped stores* and the first-run reviews of
|
|
684
|
+
0.3.3 and 0.3.5 agree on what is left, and none of it is a defect a patch release closes. Stated here so
|
|
685
|
+
the next reader grades a promise, not a moving target:
|
|
686
|
+
|
|
687
|
+
- **Engine adapters are Beta and outside the stability promise.** They pass the same conformance
|
|
688
|
+
battery as the core against structural mocks; no live LangGraph/Temporal/Inngest/OpenAI restart suite
|
|
689
|
+
exists yet. Use the core path for production until it does.
|
|
690
|
+
- **The issuer DID is trust on first use.** The tour and `receipt` print your workspace's issuer DID
|
|
691
|
+
from the same authenticated response that carries the receipt. Pinned verification is only as strong
|
|
692
|
+
as where you keep that DID: record it through a channel you trust independently (your app's
|
|
693
|
+
record-verify panel today; a published well-known location is on the roadmap).
|
|
694
|
+
- **Verification is cryptographic, not schema validation.** `VALID` means the issuer's key signed these
|
|
695
|
+
canonical claims; the sealed-payload schema the receipt names by hash is not shipped, so a
|
|
696
|
+
structurally odd but correctly signed document is not refused on shape. Shipping the schema is on
|
|
697
|
+
the roadmap.
|
|
698
|
+
- **The conformance suites are not in the package.** The deterministic two-process schedules that
|
|
699
|
+
prove the durable store, and the corpus every door is held to, live in the Decidio source repository.
|
|
700
|
+
A customer cannot rerun them from this tarball; a public conformance kit is on the roadmap.
|
|
701
|
+
- **No provenance attestation, no repository link.** The source repository is private; npm refuses
|
|
702
|
+
provenance for private repositories, and a link that 404s is worse than none. Registry signatures and
|
|
703
|
+
lockfile integrity are what you can check today.
|
|
704
|
+
|
|
705
|
+
## Compatibility and support
|
|
706
|
+
|
|
707
|
+
- **Stable in 0.4:** `guard.protect` / `withApproval` and their options; the durable resume
|
|
708
|
+
controller (`createDecidioResume`: `park`, `resolve`, `reconcile`, `reconfirm`, `recover`, the worker
|
|
709
|
+
and both webhook handlers); the `PendingStore` contract and both shipped stores, including the on-disk
|
|
710
|
+
revision-chain layout; `EvidenceOutcome` (a root export) and `ResumeStatus` / `RecoveryState` (on the
|
|
711
|
+
`@decidio/sdk/resume` subpath), additive growth only; the error classes; the CLI's commands, flags and exit codes; the receipt verifier's verdict and
|
|
712
|
+
reasons. Changes to any of these announce themselves in the changelog first.
|
|
713
|
+
- **Beta:** the engine adapters (above).
|
|
714
|
+
- **Deprecated, kept until 1.0:** `protectBestEffort` (the value-only shape) and the old adapter
|
|
715
|
+
name `gateInterruptions`. They will be removed in 1.0.0, not before.
|
|
716
|
+
- **Versioning:** 0.x minor releases may still carry deliberate breaking cleanups, announced in the
|
|
717
|
+
changelog with the upgrade step. 1.0.0 follows user-acceptance testing of this release and freezes the
|
|
718
|
+
surface above.
|
|
719
|
+
|
|
660
720
|
## Notes
|
|
661
721
|
|
|
662
722
|
- **Framework-agnostic:** it wraps at the tool-call boundary, so it works with any agent runtime (LangGraph, OpenAI Agents SDK, CrewAI, a raw tool loop) and any action (Salesforce, M365, SAP, a DB write).
|
package/dist/adapters/_gate.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, carriesCredential, canonicalize, readConfirmResponse, buildConfirmBody, decisionIdProblem } from "../index.js";
|
|
2
|
+
import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, carriesCredential, canonicalize, assertDescribed, readConfirmResponse, buildConfirmBody, decisionIdProblem } from "../index.js";
|
|
3
3
|
export const AUTHORIZE_VERDICTS = ["proceed", "route", "block"];
|
|
4
4
|
export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
|
|
5
5
|
const a = auth;
|
|
@@ -15,6 +15,7 @@ export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
|
|
|
15
15
|
}
|
|
16
16
|
export async function gateAuthorize(config, ctx) {
|
|
17
17
|
ctx = canonicalize(ctx, "describe(...)");
|
|
18
|
+
assertDescribed(ctx);
|
|
18
19
|
assertSafeTransport(config.apiUrl, carriesCredential(config));
|
|
19
20
|
const f = config.fetchImpl ?? fetch;
|
|
20
21
|
const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
|
|
@@ -15,7 +15,7 @@ interface RunStateLike {
|
|
|
15
15
|
* park the serialized state agent-side on `route`, resume on Decidio's approval.
|
|
16
16
|
*
|
|
17
17
|
* const state = result.state; // or RunState from the run
|
|
18
|
-
* const { resolved, pending } = await
|
|
18
|
+
* const { resolved, pending } = await authorizeInterruptions(config, state, describe);
|
|
19
19
|
* if (pending.length) { await store.put(decisionId, state.toString()); return; } // suspend
|
|
20
20
|
* result = await run(agent, state); // all resolved → resume
|
|
21
21
|
* // on Decidio approval webhook: state = await RunState.fromString(agent, parked);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type GuardConfig, type ApprovalContext, type ProtectedExecution } from "../index.js";
|
|
1
|
+
import { type GuardConfig, type ApprovalContext, type ProtectedExecution, type EvidenceOutcome } from "../index.js";
|
|
2
2
|
import { type GateAuth } from "./_gate.js";
|
|
3
3
|
/**
|
|
4
4
|
* @decidio/sdk/temporal — durable suspend via Temporal's native `condition()` + a
|
|
@@ -23,7 +23,10 @@ export declare function installDecidioSignal(): Map<string, {
|
|
|
23
23
|
}>;
|
|
24
24
|
export interface DecidioGateOpts<R = unknown> {
|
|
25
25
|
authorize?: () => Promise<GateAuth>;
|
|
26
|
-
|
|
26
|
+
/** Reports through YOUR activity. Return the gate's confirm response body (validated with
|
|
27
|
+
* `readConfirmResponse`) and it becomes the outcome; return nothing and the outcome says the SDK could
|
|
28
|
+
* not read back what Decidio recorded (prodsec V035-03: a returned outcome used to be discarded). */
|
|
29
|
+
confirm?: (decisionId: string, result: unknown) => Promise<EvidenceOutcome | void>;
|
|
27
30
|
/** Map your action's return value to the evidence Decidio records — the same option the core
|
|
28
31
|
* path takes. Every adapter's stderr note named it and no adapter accepted one, so on this
|
|
29
32
|
* path the advice could not be followed. */
|
|
@@ -25,12 +25,13 @@ export async function decidioGate(config, ctx, run, verdicts, opts = {}) {
|
|
|
25
25
|
if (opts.confirm) {
|
|
26
26
|
if (opts.evidence)
|
|
27
27
|
console.warn("[decidio] temporal: `evidence` is ignored when you supply `confirm` — your callback owns the report, so map the result inside it. Remove one of the two to make the intent clear.");
|
|
28
|
-
await opts.confirm(auth.decisionId, result);
|
|
29
|
-
|
|
28
|
+
const returned = await opts.confirm(auth.decisionId, result);
|
|
29
|
+
const usable = !!returned && typeof returned === "object" && typeof returned.reportRecorded === "boolean";
|
|
30
|
+
confirmation = usable ? returned : {
|
|
30
31
|
reportRecorded: false, executionConfirmed: false, confirmationRetryable: false, reportJournalled: false,
|
|
31
32
|
diagnostic: "your own `confirm` callback reported this execution, so this SDK cannot read back what Decidio recorded. Check the record, or return the gate's response body from your activity and validate it with readConfirmResponse.",
|
|
32
33
|
};
|
|
33
|
-
if (!confirmWarned) {
|
|
34
|
+
if (!usable && !confirmWarned) {
|
|
34
35
|
confirmWarned = true;
|
|
35
36
|
console.warn(`[decidio] temporal: a custom \`confirm\` reports through your own activity, so this SDK cannot read back the evidence tier Decidio recorded. Check the record (or return the gate's response body from your activity and validate it with readConfirmResponse) if you need to know whether the execution was CONFIRMED, not merely reported.`);
|
|
36
37
|
}
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync } from "node:fs";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, openSync, writeSync, fsyncSync, closeSync, renameSync } from "node:fs";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
5
|
import { execFileSync } from "node:child_process";
|
|
@@ -38,7 +38,10 @@ function readEnvFile() {
|
|
|
38
38
|
}
|
|
39
39
|
return out;
|
|
40
40
|
}
|
|
41
|
-
const
|
|
41
|
+
const wantsHelp = (s) => s === "help" || s === "--help" || s === "-h";
|
|
42
|
+
const ARGV_TAIL = process.argv.slice(2);
|
|
43
|
+
const OFFLINE_CMD = ["verify", "--version", "-v", "-V", "version", undefined].includes(ARGV_TAIL[0])
|
|
44
|
+
|| wantsHelp(ARGV_TAIL[0]) || wantsHelp(ARGV_TAIL[1]);
|
|
42
45
|
if (!OFFLINE_CMD) {
|
|
43
46
|
readEnvFile();
|
|
44
47
|
if (envUnreadable) {
|
|
@@ -109,7 +112,30 @@ async function api(path, init = {}, bearer) {
|
|
|
109
112
|
return { status: res.status, body };
|
|
110
113
|
}
|
|
111
114
|
function writeSecretFile(path, body) {
|
|
112
|
-
|
|
115
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
116
|
+
const fd = openSync(tmp, "w", 0o600);
|
|
117
|
+
try {
|
|
118
|
+
writeSync(fd, body);
|
|
119
|
+
fsyncSync(fd);
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
closeSync(fd);
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
chmodSync(tmp, 0o600);
|
|
126
|
+
}
|
|
127
|
+
catch { }
|
|
128
|
+
hardenFile(tmp);
|
|
129
|
+
try {
|
|
130
|
+
renameSync(tmp, path);
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
try {
|
|
134
|
+
rmSync(tmp, { force: true });
|
|
135
|
+
}
|
|
136
|
+
catch { }
|
|
137
|
+
throw e;
|
|
138
|
+
}
|
|
113
139
|
try {
|
|
114
140
|
chmodSync(path, 0o600);
|
|
115
141
|
}
|
|
@@ -123,14 +149,137 @@ async function revokeFreshMint(jti, bearer, writeError, target) {
|
|
|
123
149
|
err(`✘ could not write ${target} (${writeError?.message ?? writeError}). The token minted a moment ago (jti ${jti}) is LIVE until revoked - revoking it now.`);
|
|
124
150
|
const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, bearer);
|
|
125
151
|
if (rv.status >= 200 && rv.status < 300) {
|
|
152
|
+
journalRetire(jti, "revoked");
|
|
126
153
|
err(` ✔ the token minted a moment ago (jti ${jti}) was REVOKED again, so nothing live was left behind. Fix the cause above and re-run - it will mint a fresh one.`);
|
|
127
154
|
return true;
|
|
128
155
|
}
|
|
156
|
+
let known = false;
|
|
157
|
+
try {
|
|
158
|
+
known = journalOutstanding().some((j) => j.jti === jti);
|
|
159
|
+
}
|
|
160
|
+
catch { }
|
|
161
|
+
if (known)
|
|
162
|
+
err(` Its id is kept in ${JOURNAL_PATH} (owner-only, append-only) until it is revoked - \`quickstart cleanup\` retries it, and \`doctor\` names it.`);
|
|
163
|
+
else
|
|
164
|
+
err(` Its id is NOT recorded anywhere but this terminal (the journal write is what failed) - write it down now and revoke it by hand.`);
|
|
129
165
|
err(` ⚠ the token minted a moment ago (jti ${jti}) is LIVE and could NOT be revoked (${rv.status}). Revoke it by hand, or re-run init - the server keeps one active token per agent, so a fresh mint supersedes it:`);
|
|
130
166
|
err(` npx @decidio/sdk tokens revoke ${jti}`);
|
|
131
167
|
err(` npx @decidio/sdk tokens list --mine`);
|
|
132
168
|
return false;
|
|
133
169
|
}
|
|
170
|
+
const JOURNAL_PATH = ".decidio-token-journal";
|
|
171
|
+
const JOURNAL_SCHEMA = 1;
|
|
172
|
+
const JOURNAL_EVENTS = new Set(["probe", "minted", "retired"]);
|
|
173
|
+
function journalAppend(entry) {
|
|
174
|
+
const fresh = !existsSync(JOURNAL_PATH);
|
|
175
|
+
const row = Buffer.from(JSON.stringify({ v: JOURNAL_SCHEMA, ...entry, at: new Date().toISOString() }) + "\n", "utf8");
|
|
176
|
+
const fd = openSync(JOURNAL_PATH, "a", 0o600);
|
|
177
|
+
try {
|
|
178
|
+
if (!fresh) {
|
|
179
|
+
const st = readFileSync(JOURNAL_PATH);
|
|
180
|
+
if (st.length && st[st.length - 1] !== 0x0a) {
|
|
181
|
+
let w = 0;
|
|
182
|
+
const nl = Buffer.from("\n");
|
|
183
|
+
while (w < nl.length)
|
|
184
|
+
w += writeSync(fd, nl, w);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
let written = 0;
|
|
188
|
+
while (written < row.length)
|
|
189
|
+
written += writeSync(fd, row, written);
|
|
190
|
+
fsyncSync(fd);
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
closeSync(fd);
|
|
194
|
+
}
|
|
195
|
+
if (fresh) {
|
|
196
|
+
try {
|
|
197
|
+
chmodSync(JOURNAL_PATH, 0o600);
|
|
198
|
+
}
|
|
199
|
+
catch { }
|
|
200
|
+
hardenFile(JOURNAL_PATH);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function journalRowProblem(e) {
|
|
204
|
+
if (!e || typeof e !== "object" || Array.isArray(e))
|
|
205
|
+
return "not a JSON object";
|
|
206
|
+
if (e.v !== undefined && e.v !== JOURNAL_SCHEMA)
|
|
207
|
+
return `schema version ${JSON.stringify(e.v)} is not ${JOURNAL_SCHEMA}`;
|
|
208
|
+
if (typeof e.event !== "string" || !JOURNAL_EVENTS.has(e.event))
|
|
209
|
+
return `unknown event ${JSON.stringify(e.event ?? null)}`;
|
|
210
|
+
if (e.event !== "probe" && (typeof e.jti !== "string" || !e.jti.trim()))
|
|
211
|
+
return `${e.event} row without a string jti`;
|
|
212
|
+
if (e.event === "retired" && e.how !== "persisted" && e.how !== "revoked")
|
|
213
|
+
return `retired row with how=${JSON.stringify(e.how ?? null)}`;
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
class JournalCorruptError extends Error {
|
|
217
|
+
line;
|
|
218
|
+
why;
|
|
219
|
+
constructor(line, why) {
|
|
220
|
+
super(`${JOURNAL_PATH} line ${line}: ${why}`);
|
|
221
|
+
this.line = line;
|
|
222
|
+
this.why = why;
|
|
223
|
+
this.name = "JournalCorruptError";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function journalRows() {
|
|
227
|
+
if (!existsSync(JOURNAL_PATH))
|
|
228
|
+
return [];
|
|
229
|
+
const rows = [];
|
|
230
|
+
const lines = readFileSync(JOURNAL_PATH, "utf8").split(/\r?\n/);
|
|
231
|
+
for (let i = 0; i < lines.length; i++) {
|
|
232
|
+
const line = lines[i];
|
|
233
|
+
if (!line.trim())
|
|
234
|
+
continue;
|
|
235
|
+
let e;
|
|
236
|
+
try {
|
|
237
|
+
e = JSON.parse(line);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
throw new JournalCorruptError(i + 1, "not valid JSON (a torn or hand-edited row)");
|
|
241
|
+
}
|
|
242
|
+
const why = journalRowProblem(e);
|
|
243
|
+
if (why)
|
|
244
|
+
throw new JournalCorruptError(i + 1, why);
|
|
245
|
+
rows.push(e);
|
|
246
|
+
}
|
|
247
|
+
return rows;
|
|
248
|
+
}
|
|
249
|
+
const REFUSE_MINT_CORRUPT_JOURNAL = (e) => `✘ the token recovery journal cannot be trusted (${e.message}) - it may hold the id of a token that is still live. Refusing to mint on top of uncertain history. Inspect the file, revoke anything it names (npx @decidio/sdk tokens list --mine), and repair or move it aside; every byte is left as found. Nothing was minted.`;
|
|
250
|
+
function journalProbe() {
|
|
251
|
+
try {
|
|
252
|
+
journalRows();
|
|
253
|
+
}
|
|
254
|
+
catch (e) {
|
|
255
|
+
return `CORRUPT: ${e?.message ?? e}`;
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
journalAppend({ event: "probe" });
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
catch (e) {
|
|
262
|
+
return String(e?.message ?? e);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function journalMinted(jti, agentId, label) { journalAppend({ event: "minted", jti, agentId, label, apiUrl: API }); }
|
|
266
|
+
function journalRetire(jti, how) { try {
|
|
267
|
+
journalAppend({ event: "retired", jti, how });
|
|
268
|
+
}
|
|
269
|
+
catch { } }
|
|
270
|
+
function journalOutstanding() {
|
|
271
|
+
const minted = new Map();
|
|
272
|
+
for (const e of journalRows()) {
|
|
273
|
+
if (e.event === "minted")
|
|
274
|
+
minted.set(e.jti, { jti: e.jti, agentId: e.agentId, label: e.label, apiUrl: e.apiUrl, at: e.at });
|
|
275
|
+
if (e.event === "retired")
|
|
276
|
+
minted.delete(e.jti);
|
|
277
|
+
}
|
|
278
|
+
return [...minted.values()];
|
|
279
|
+
}
|
|
280
|
+
const REFUSE_MINT_NO_JOURNAL = (why) => why.startsWith("CORRUPT: ")
|
|
281
|
+
? REFUSE_MINT_CORRUPT_JOURNAL(new Error(why.slice("CORRUPT: ".length)))
|
|
282
|
+
: `✘ could not write the token recovery journal (${JOURNAL_PATH}: ${why}) - refusing to mint a credential this directory cannot record a revocation handle for. Fix the directory's permissions or free space and re-run. Nothing was minted.`;
|
|
134
283
|
function upsertEnv(updates) {
|
|
135
284
|
const remaining = { ...updates };
|
|
136
285
|
const updatedKeys = new Set();
|
|
@@ -180,7 +329,7 @@ function assertNotTracked(path) {
|
|
|
180
329
|
return false;
|
|
181
330
|
}
|
|
182
331
|
function ensureGitignore() {
|
|
183
|
-
const entries = [".env", ".env.quickstart", ".env.*.bak", ".decidio-pending/"];
|
|
332
|
+
const entries = [".env", ".env.quickstart", ".env.*.bak", ".env.*.tmp", ".decidio-pending/", ".decidio-token-journal"];
|
|
184
333
|
const existing = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
|
|
185
334
|
const missing = entries.filter((e) => !existing.split(/\r?\n/).some((l) => l.trim() === e || l.trim() === e.replace(/\/$/, "")));
|
|
186
335
|
if (!missing.length)
|
|
@@ -319,16 +468,33 @@ async function cmdInit(agentId, opts = {}) {
|
|
|
319
468
|
let agentToken, workspaceId, tokenJti;
|
|
320
469
|
const mintBearer = session ?? TOKEN();
|
|
321
470
|
if (mintBearer) {
|
|
471
|
+
ensureGitignore();
|
|
472
|
+
const noJournal = journalProbe();
|
|
473
|
+
if (noJournal) {
|
|
474
|
+
err(REFUSE_MINT_NO_JOURNAL(noJournal));
|
|
475
|
+
return 1;
|
|
476
|
+
}
|
|
322
477
|
const expiresInDays = opts.expiresDays ?? 90;
|
|
323
478
|
const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "sdk-cli", expiresInDays }) }, mintBearer);
|
|
324
479
|
if (mint.status >= 200 && mint.status < 300 && mint.body?.token) {
|
|
325
480
|
agentToken = mint.body.token;
|
|
326
481
|
workspaceId = mint.body.workspaceId;
|
|
327
482
|
tokenJti = mint.body.jti;
|
|
483
|
+
if (tokenJti) {
|
|
484
|
+
try {
|
|
485
|
+
journalMinted(tokenJti, agentId, "sdk-cli");
|
|
486
|
+
}
|
|
487
|
+
catch (e) {
|
|
488
|
+
await revokeFreshMint(tokenJti, mintBearer, e, JOURNAL_PATH);
|
|
489
|
+
return 1;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
328
492
|
log(`✔ minted the agent's API token — floor-limited: it can request authorization, poll status and report confirmation; it cannot administer the workspace or approve anything${mint.body.expiresAt ? ` (expires ${mint.body.expiresAt})` : ` (${expiresInDays} days)`}`);
|
|
329
493
|
const superseded = Array.isArray(mint.body.supersededJtis) ? mint.body.supersededJtis : [];
|
|
330
|
-
for (const s of superseded)
|
|
494
|
+
for (const s of superseded) {
|
|
495
|
+
journalRetire(s, "revoked");
|
|
331
496
|
log(` • this replaces the agent's previous token (jti ${s}) — now REVOKED: the server keeps one active token per agent. Anything still using it will 401.`);
|
|
497
|
+
}
|
|
332
498
|
}
|
|
333
499
|
else if (session) {
|
|
334
500
|
err(`✘ could not mint the agent's API token (${mint.status}): ${JSON.stringify(mint.body)}`);
|
|
@@ -356,6 +522,8 @@ async function cmdInit(agentId, opts = {}) {
|
|
|
356
522
|
try {
|
|
357
523
|
prepareEnvForWrite();
|
|
358
524
|
upsertEnv(envOut);
|
|
525
|
+
if (tokenJti)
|
|
526
|
+
journalRetire(tokenJti, "persisted");
|
|
359
527
|
}
|
|
360
528
|
catch (e) {
|
|
361
529
|
if (agentToken && tokenJti)
|
|
@@ -404,6 +572,17 @@ async function cmdDoctor() {
|
|
|
404
572
|
let warned = 0;
|
|
405
573
|
const line = (good, label) => { log(`${good ? "✔" : "✘"} ${label}`); ok = ok && good; };
|
|
406
574
|
const warn = (label) => { warned++; log(`⚠ ${label}`); };
|
|
575
|
+
let journalItems = [];
|
|
576
|
+
try {
|
|
577
|
+
journalItems = journalOutstanding();
|
|
578
|
+
}
|
|
579
|
+
catch (e) {
|
|
580
|
+
line(false, `token recovery journal: ${e?.message ?? e} — it may hold the id of a live token, and this directory cannot mint until it is repaired or moved aside (every byte is left as found).`);
|
|
581
|
+
}
|
|
582
|
+
for (const j of journalItems) {
|
|
583
|
+
const where = j.apiUrl && j.apiUrl !== API ? `DECIDIO_API_URL=${j.apiUrl} ` : "";
|
|
584
|
+
warn(`token recovery journal: jti ${j.jti} (${j.label ?? "token"}, agent ${j.agentId ?? "?"}, minted ${j.at ?? "?"} against ${j.apiUrl ?? API}) has no recorded revocation in this directory's journal — it may still be live. Revoke it, or confirm it is gone: ${where}npx @decidio/sdk quickstart cleanup (or: ${where}npx @decidio/sdk tokens revoke ${j.jti})`);
|
|
585
|
+
}
|
|
407
586
|
log(`✔ API URL: ${API}${(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL) ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
|
|
408
587
|
line(!!(process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID), `agent id: ${process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID || "(unset — run: npx @decidio/sdk init <agent-name>)"}`);
|
|
409
588
|
const secret = process.env.DECIDIO_WEBHOOK_SECRET || env.DECIDIO_WEBHOOK_SECRET;
|
|
@@ -422,7 +601,7 @@ async function cmdDoctor() {
|
|
|
422
601
|
else if (derived !== storedDid)
|
|
423
602
|
line(false, `signing key: DOES NOT MATCH this agent's DID — the key here derives ${derived}, but .env registers ${storedDid}. Signed requests will not be recognised as this agent (auto-approve and application_confirmed evidence will both fail). Restore the original key, or re-run \`npx @decidio/sdk init\` to register a fresh one.`);
|
|
424
603
|
else
|
|
425
|
-
log("✔ signing key: set and MATCHES the registered DID — the agent can prove its identity, making it ELIGIBLE for policy auto-approve. Whether a rule matches is your workspace policy's decision; with no matching rule every request routes to a human (
|
|
604
|
+
log("✔ signing key: set and MATCHES the registered DID — the agent can prove its identity, making it ELIGIBLE for policy auto-approve. Whether a rule matches is your workspace policy's decision; with no matching rule every request routes to a human (routing-by-default - a block is different: a named rule, or the universal backstop when no active policy governs agents at all).");
|
|
426
605
|
}
|
|
427
606
|
else
|
|
428
607
|
warn("signing key: MISSING (DECIDIO_AGENT_KEY/DID) — the agent can't prove identity, so EVERY request routes to a human. Run `npx @decidio/sdk init` to generate one.");
|
|
@@ -641,6 +820,10 @@ function cmdVerify(argsIn) {
|
|
|
641
820
|
const files = [];
|
|
642
821
|
for (let i = 0; i < argsIn.length; i++) {
|
|
643
822
|
const a = argsIn[i];
|
|
823
|
+
if (a === "--") {
|
|
824
|
+
files.push(...argsIn.slice(i + 1));
|
|
825
|
+
break;
|
|
826
|
+
}
|
|
644
827
|
if (a === "--issuer") {
|
|
645
828
|
const v = argsIn[i + 1];
|
|
646
829
|
if (v === undefined || v === "" || v.startsWith("-")) {
|
|
@@ -696,8 +879,10 @@ async function cmdQuickstart(opts = {}) {
|
|
|
696
879
|
log(` Target: ${API}${process.env.DECIDIO_API_URL ? "" : " (the hosted synthetic sandbox — set DECIDIO_API_URL to use your own)"}`);
|
|
697
880
|
log("");
|
|
698
881
|
log(" This tour will: register one synthetic agent · mint a 7-DAY floor-limited token ·");
|
|
699
|
-
log(" write .env (or .env.quickstart)
|
|
700
|
-
log("
|
|
882
|
+
log(" write .env (or .env.quickstart), .decidio-token-journal (revocation ids, no secrets) and .gitignore");
|
|
883
|
+
log(" entries (incl. .decidio-pending/, the durable store your real agent will use) · save the receipt");
|
|
884
|
+
log(" as decidio-receipt-<id>.json here (public; it stays) · create one synthetic decision and its");
|
|
885
|
+
log(" sealed record (records are permanent by design). Cleanup offer at the end.");
|
|
701
886
|
log("");
|
|
702
887
|
const session = await acquireSession("The quickstart");
|
|
703
888
|
if (!session)
|
|
@@ -729,11 +914,35 @@ async function cmdQuickstart(opts = {}) {
|
|
|
729
914
|
err(` Nothing was written to this directory.`);
|
|
730
915
|
return 1;
|
|
731
916
|
}
|
|
917
|
+
let prevTourFile = null;
|
|
918
|
+
if (envFile !== ENV_PATH && existsSync(envFile)) {
|
|
919
|
+
try {
|
|
920
|
+
prevTourFile = readFileSync(envFile, "utf8");
|
|
921
|
+
}
|
|
922
|
+
catch (e) {
|
|
923
|
+
err(`✘ ${envFile} exists but could not be read (${e?.message ?? e}) - a previous tour's credentials may be in it, and this tour would have to replace it. Refusing to mint a new token on top of a file this command cannot read: fix its permissions or move it aside, then re-run. Nothing was minted.`);
|
|
924
|
+
return 1;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
const noJournal = journalProbe();
|
|
928
|
+
if (noJournal) {
|
|
929
|
+
err(REFUSE_MINT_NO_JOURNAL(noJournal));
|
|
930
|
+
return 1;
|
|
931
|
+
}
|
|
732
932
|
const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "quickstart", expiresInDays: 7 }) }, session);
|
|
733
933
|
if (!(mint.status >= 200 && mint.status < 300) || !mint.body?.token) {
|
|
734
934
|
err(`✘ token mint failed (${mint.status}): ${JSON.stringify(mint.body)}`);
|
|
735
935
|
return 1;
|
|
736
936
|
}
|
|
937
|
+
if (mint.body.jti) {
|
|
938
|
+
try {
|
|
939
|
+
journalMinted(mint.body.jti, agentId, "quickstart");
|
|
940
|
+
}
|
|
941
|
+
catch (e) {
|
|
942
|
+
await revokeFreshMint(mint.body.jti, session, e, JOURNAL_PATH);
|
|
943
|
+
return 1;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
737
946
|
const envOut = {
|
|
738
947
|
DECIDIO_API_URL: API, DECIDIO_AGENT_ID: agentId, DECIDIO_AGENT_DID: reg.body?.did ?? did,
|
|
739
948
|
DECIDIO_AGENT_KEY: privateKeyBase64, DECIDIO_API_TOKEN: mint.body.token,
|
|
@@ -741,8 +950,8 @@ async function cmdQuickstart(opts = {}) {
|
|
|
741
950
|
...(mint.body.jti ? { DECIDIO_TOKEN_JTI: mint.body.jti } : {}),
|
|
742
951
|
};
|
|
743
952
|
if (envFile !== ENV_PATH) {
|
|
744
|
-
if (
|
|
745
|
-
const prev =
|
|
953
|
+
if (prevTourFile !== null) {
|
|
954
|
+
const prev = prevTourFile;
|
|
746
955
|
const prevId = prev.match(/^DECIDIO_AGENT_ID=(.+)$/m)?.[1];
|
|
747
956
|
const prevJti = prev.match(/^DECIDIO_TOKEN_JTI=(.+)$/m)?.[1];
|
|
748
957
|
const carried = new Set((prev.match(/^DECIDIO_ORPHANED_TOKEN_JTI=(.+)$/m)?.[1] ?? "").split(",").map((s) => s.trim()).filter(Boolean));
|
|
@@ -767,6 +976,8 @@ async function cmdQuickstart(opts = {}) {
|
|
|
767
976
|
}
|
|
768
977
|
try {
|
|
769
978
|
writeSecretFile(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
|
|
979
|
+
if (mint.body.jti)
|
|
980
|
+
journalRetire(mint.body.jti, "persisted");
|
|
770
981
|
}
|
|
771
982
|
catch (e) {
|
|
772
983
|
if (mint.body?.jti)
|
|
@@ -780,6 +991,8 @@ async function cmdQuickstart(opts = {}) {
|
|
|
780
991
|
else {
|
|
781
992
|
try {
|
|
782
993
|
upsertEnv(envOut);
|
|
994
|
+
if (mint.body.jti)
|
|
995
|
+
journalRetire(mint.body.jti, "persisted");
|
|
783
996
|
}
|
|
784
997
|
catch (e) {
|
|
785
998
|
if (mint.body?.jti)
|
|
@@ -832,7 +1045,9 @@ async function cmdQuickstart(opts = {}) {
|
|
|
832
1045
|
return failTour(1);
|
|
833
1046
|
}
|
|
834
1047
|
log(` ✔ $86,000 payInvoice ROUTED to a human (decision ${decisionId}) — this agent has no auto-approve rule,`);
|
|
835
|
-
log(` so
|
|
1048
|
+
log(` so the policy ROUTES it to a person (routing-by-default). That is distinct from a BLOCK, which no human can`);
|
|
1049
|
+
log(` override: a block is a named rule, or the universal backstop when no active policy governs agents at all.`);
|
|
1050
|
+
log(` Nothing executes until someone decides.`);
|
|
836
1051
|
log("");
|
|
837
1052
|
const interactive = process.stdin.isTTY;
|
|
838
1053
|
if (interactive) {
|
|
@@ -892,11 +1107,17 @@ async function cmdQuickstart(opts = {}) {
|
|
|
892
1107
|
const issuer = rec.body?.issuer;
|
|
893
1108
|
const v = verifyReceipt(rec.body.credential, issuer);
|
|
894
1109
|
log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? `, PINNED to your workspace issuer ${issuer}` : ""})`);
|
|
1110
|
+
if (!v.valid) {
|
|
1111
|
+
err(` ✘ the receipt did not verify (${v.reason ?? "no reason given"}) — the tour did not complete.`);
|
|
1112
|
+
return failTour(1);
|
|
1113
|
+
}
|
|
895
1114
|
if (!issuer) {
|
|
896
1115
|
err(` ⚠ the server did not return an issuer DID, so this check ran UNPINNED — it proves the`);
|
|
897
1116
|
err(` receipt is internally consistent, NOT who issued it (a self-signed forgery also passes).`);
|
|
898
1117
|
err(` Get your workspace's issuer DID from the app's record-verify panel, then re-check:`);
|
|
899
1118
|
err(` npx @decidio/sdk verify --issuer <did:key:...> ${fname}`);
|
|
1119
|
+
err(` ✘ the tour does not count an UNPINNED check as success.`);
|
|
1120
|
+
return failTour(1);
|
|
900
1121
|
}
|
|
901
1122
|
if (issuer)
|
|
902
1123
|
log(` Keep that DID: it is your trust anchor — npx @decidio/sdk verify --issuer ${issuer} <receipt.json>`);
|
|
@@ -913,7 +1134,10 @@ async function cmdQuickstart(opts = {}) {
|
|
|
913
1134
|
log(` const doIt = guard.protect(doItRaw, (a) => ({ action: "yourAction", amount: a.amount, scope: "YourScope" }));`);
|
|
914
1135
|
log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
|
|
915
1136
|
log("");
|
|
916
|
-
log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")}
|
|
1137
|
+
log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")}`);
|
|
1138
|
+
log(`Next: the README section "Production mode: durable async resume" (the worker; the engine adapters follow it).`);
|
|
1139
|
+
log(`The README's seven-step quickstart is this same blocking flow in your own code - if you keep this agent, its .env`);
|
|
1140
|
+
log(`already works here, so skip that flow's init step.`);
|
|
917
1141
|
log(`Built by OmniTwin — decidioai.com · Putting this in front of real approvals? decidioai.com/#early-access`);
|
|
918
1142
|
let doCleanup = false;
|
|
919
1143
|
if (interactive) {
|
|
@@ -932,14 +1156,55 @@ async function cmdQuickstart(opts = {}) {
|
|
|
932
1156
|
async function quickstartCleanup(session, envFile) {
|
|
933
1157
|
const env = (() => {
|
|
934
1158
|
const out = {};
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
const
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1159
|
+
try {
|
|
1160
|
+
if (existsSync(envFile))
|
|
1161
|
+
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
|
1162
|
+
const m = line.match(/^\s*([A-Z_]+)=(.*)$/);
|
|
1163
|
+
if (m)
|
|
1164
|
+
out[m[1]] = m[2];
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
catch (e) {
|
|
1168
|
+
err(` ⚠ ${envFile} could not be read (${e?.message ?? e}) - sweeping the token recovery journal instead.`);
|
|
1169
|
+
}
|
|
941
1170
|
return out;
|
|
942
1171
|
})();
|
|
1172
|
+
let journalRevoked = 0;
|
|
1173
|
+
const journalStuck = [];
|
|
1174
|
+
let outstanding = [];
|
|
1175
|
+
try {
|
|
1176
|
+
outstanding = journalOutstanding();
|
|
1177
|
+
}
|
|
1178
|
+
catch (e) {
|
|
1179
|
+
err(`✘ the token recovery journal cannot be trusted (${e?.message ?? e}). It may hold the id of a live token, so this cleanup cannot claim nothing is owed. Every byte is left as found: inspect ${JOURNAL_PATH}, revoke anything it names (npx @decidio/sdk tokens list --mine), then repair or move it aside and re-run.`);
|
|
1180
|
+
return 1;
|
|
1181
|
+
}
|
|
1182
|
+
for (const j of outstanding) {
|
|
1183
|
+
if (j.apiUrl && j.apiUrl !== API) {
|
|
1184
|
+
journalStuck.push(j.jti);
|
|
1185
|
+
err(` ⚠ journalled token ${j.jti} was minted against ${j.apiUrl}, not ${API} — revoke it there: DECIDIO_API_URL=${j.apiUrl} npx @decidio/sdk tokens revoke ${j.jti}`);
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(j.jti)}`, { method: "DELETE" }, session);
|
|
1189
|
+
if (rv.status >= 200 && rv.status < 300) {
|
|
1190
|
+
journalRetire(j.jti, "revoked");
|
|
1191
|
+
journalRevoked++;
|
|
1192
|
+
if (rv.body?.changed === false)
|
|
1193
|
+
log(` • journalled token ${j.jti} was already revoked — retiring its journal entry.`);
|
|
1194
|
+
else
|
|
1195
|
+
log(` ✔ journalled token revoked (jti ${j.jti}${j.label ? `, ${j.label}` : ""}) — its credential file never landed, so this was its only handle`);
|
|
1196
|
+
}
|
|
1197
|
+
else {
|
|
1198
|
+
journalStuck.push(j.jti);
|
|
1199
|
+
err(` ⚠ journalled token ${j.jti} could NOT be revoked (${rv.status}) — it is still live; its id stays in ${JOURNAL_PATH}.`);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
if (journalStuck.length) {
|
|
1203
|
+
err(`✘ ${journalStuck.length} journalled token(s) are STILL LIVE and could not be revoked. Nothing was deleted; retry, or revoke by hand:`);
|
|
1204
|
+
for (const jti of journalStuck)
|
|
1205
|
+
err(` npx @decidio/sdk tokens revoke ${jti}`);
|
|
1206
|
+
return 1;
|
|
1207
|
+
}
|
|
943
1208
|
const orphans = (env.DECIDIO_ORPHANED_TOKEN_JTI ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
944
1209
|
const stillOrphaned = [];
|
|
945
1210
|
for (const jti of orphans) {
|
|
@@ -970,6 +1235,9 @@ async function quickstartCleanup(session, envFile) {
|
|
|
970
1235
|
return 1;
|
|
971
1236
|
}
|
|
972
1237
|
}
|
|
1238
|
+
else if (journalRevoked > 0 && !existsSync(envFile) || (journalRevoked > 0 && Object.keys(env).length === 0)) {
|
|
1239
|
+
log(` • no credential file to clean: the journal sweep revoked ${journalRevoked} token(s) this directory owed.`);
|
|
1240
|
+
}
|
|
973
1241
|
else {
|
|
974
1242
|
err(`✘ no DECIDIO_TOKEN_JTI in ${envFile} (older tour) — this tour's token is STILL LIVE and this`);
|
|
975
1243
|
err(` command cannot revoke it. Nothing was deleted. Find and revoke it, then re-run cleanup:`);
|
|
@@ -978,9 +1246,18 @@ async function quickstartCleanup(session, envFile) {
|
|
|
978
1246
|
err(` (it also expires on its own within 7 days)`);
|
|
979
1247
|
return 1;
|
|
980
1248
|
}
|
|
1249
|
+
let envRaw = null;
|
|
981
1250
|
if (existsSync(envFile)) {
|
|
1251
|
+
try {
|
|
1252
|
+
envRaw = readFileSync(envFile, "utf8");
|
|
1253
|
+
}
|
|
1254
|
+
catch (e) {
|
|
1255
|
+
err(` ⚠ ${envFile} could not be read (${e?.message ?? e}) — left as it is; remove it by hand once it is readable.`);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
if (envRaw !== null) {
|
|
982
1259
|
const TOUR_KEYS = new Set(["DECIDIO_API_URL", "DECIDIO_AGENT_ID", "DECIDIO_AGENT_DID", "DECIDIO_AGENT_KEY", "DECIDIO_API_TOKEN", "DECIDIO_WORKSPACE_ID", "DECIDIO_TOKEN_JTI", "DECIDIO_ORPHANED_TOKEN_JTI"]);
|
|
983
|
-
const kept =
|
|
1260
|
+
const kept = envRaw.split(/\r?\n/).filter((line) => {
|
|
984
1261
|
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=/);
|
|
985
1262
|
return m ? !TOUR_KEYS.has(m[1]) : line.trim() !== "";
|
|
986
1263
|
});
|
|
@@ -1004,6 +1281,12 @@ async function quickstartCleanup(session, envFile) {
|
|
|
1004
1281
|
}
|
|
1005
1282
|
log(` • What remains, by design: the sealed decision record (records are permanent — that is the product)`);
|
|
1006
1283
|
log(` and the agent's registration row (inert without key or token; server-side deregistration is on the roadmap).`);
|
|
1284
|
+
if (existsSync(".decidio-pending")) {
|
|
1285
|
+
log(` • Also kept: .decidio-pending/ — this agent's own execution ledger: whatever this agent parked (call arguments,`);
|
|
1286
|
+
log(` execution evidence; never credentials, by design). Gitignored; owner-only where the OS supports it. If nothing is`);
|
|
1287
|
+
log(` pending and you do not want the history, remove it:`);
|
|
1288
|
+
log(` ${process.platform === "win32" ? "Remove-Item -Recurse .decidio-pending" : "rm -r .decidio-pending"}`);
|
|
1289
|
+
}
|
|
1007
1290
|
return 0;
|
|
1008
1291
|
}
|
|
1009
1292
|
async function cmdTokens(sub, jti, mineOnly = false) {
|
|
@@ -1036,10 +1319,12 @@ async function cmdTokens(sub, jti, mineOnly = false) {
|
|
|
1036
1319
|
const rev = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, bearer);
|
|
1037
1320
|
if (rev.status >= 200 && rev.status < 300) {
|
|
1038
1321
|
if (rev.body?.changed === false) {
|
|
1322
|
+
journalRetire(jti, "revoked");
|
|
1039
1323
|
log(`• ${jti} was already revoked — nothing changed.`);
|
|
1040
1324
|
log(` Revocation is permanent; there is no re-revoke. See it: npx @decidio/sdk tokens list`);
|
|
1041
1325
|
return 1;
|
|
1042
1326
|
}
|
|
1327
|
+
journalRetire(jti, "revoked");
|
|
1043
1328
|
log(`✔ revoked ${jti} — the token is dead now (its agent stays registered; mint a new one with init).`);
|
|
1044
1329
|
return 0;
|
|
1045
1330
|
}
|
|
@@ -1151,7 +1436,6 @@ Docs: the README that shipped with this package — node_modules/@decidio/sdk/RE
|
|
|
1151
1436
|
(quickstart, durable resume, workers, adapters).`;
|
|
1152
1437
|
async function main(argv) {
|
|
1153
1438
|
const [cmd, a, b, ...rest] = argv.slice(2);
|
|
1154
|
-
const wantsHelp = (s) => s === "help" || s === "--help" || s === "-h";
|
|
1155
1439
|
if (cmd === "--version" || cmd === "-v" || cmd === "-V" || cmd === "version") {
|
|
1156
1440
|
log(VERSION);
|
|
1157
1441
|
return 0;
|
|
@@ -1230,9 +1514,15 @@ async function main(argv) {
|
|
|
1230
1514
|
case "quickstart": {
|
|
1231
1515
|
if (argsIn.includes("cleanup")) {
|
|
1232
1516
|
const envFile = existsSync(".env.quickstart") ? ".env.quickstart"
|
|
1233
|
-
: (readEnvFile().DECIDIO_AGENT_ID ?? "").startsWith("quickstart-") ? ENV_PATH
|
|
1517
|
+
: (readEnvFile().DECIDIO_AGENT_ID ?? "").startsWith("quickstart-") ? ENV_PATH
|
|
1518
|
+
: (() => { try {
|
|
1519
|
+
return journalOutstanding().length ? ".env.quickstart" : undefined;
|
|
1520
|
+
}
|
|
1521
|
+
catch {
|
|
1522
|
+
return ".env.quickstart";
|
|
1523
|
+
} })();
|
|
1234
1524
|
if (!envFile) {
|
|
1235
|
-
log("Nothing to clean up here: no .env.quickstart,
|
|
1525
|
+
log("Nothing to clean up here: no .env.quickstart, .env doesn't belong to a quickstart agent, and the token recovery journal owes nothing.");
|
|
1236
1526
|
return 0;
|
|
1237
1527
|
}
|
|
1238
1528
|
const session = await acquireSession("Cleaning the quickstart up");
|
|
@@ -1251,7 +1541,8 @@ async function main(argv) {
|
|
|
1251
1541
|
err("✘ --expires-days needs a positive whole number of days (e.g. --expires-days 30).");
|
|
1252
1542
|
return 2;
|
|
1253
1543
|
}
|
|
1254
|
-
const
|
|
1544
|
+
const ddIdx = argsIn.indexOf("--");
|
|
1545
|
+
const nameArg = ddIdx >= 0 ? argsIn[ddIdx + 1] : argsIn.find((x, i) => !x.startsWith("-") && !(expIdx >= 0 && i === expIdx + 1));
|
|
1255
1546
|
let name = nameArg ?? process.env.DECIDIO_AGENT_ID ?? "my-agent";
|
|
1256
1547
|
if (unique)
|
|
1257
1548
|
name = `${name}-${randomBytes(2).toString("hex")}`;
|
package/dist/errors.js
CHANGED
|
@@ -26,7 +26,11 @@ export class DecidioHttpError extends Error {
|
|
|
26
26
|
(credential
|
|
27
27
|
? ` — the credential was rejected. The agent token may have expired or been revoked (re-running \`init\` for the same agent revokes the previous one). Mint a fresh one: npx @decidio/sdk init <agent-name>, then restart this process.`
|
|
28
28
|
: retryable
|
|
29
|
-
?
|
|
29
|
+
? (/\/agent\/confirm/.test(endpoint)
|
|
30
|
+
? ` — this looks transient. Your action ALREADY RAN; do NOT re-run it — only its report failed to land, and the door's own note says whether a report was kept to re-send.`
|
|
31
|
+
: /\/agent\/status/.test(endpoint)
|
|
32
|
+
? ` — this looks transient; polling again is safe (a status read changes nothing).`
|
|
33
|
+
: ` — this looks transient; retrying is safe (a retry creates a NEW request and can never double-execute).`)
|
|
30
34
|
: ` — the server refused the request itself, so retrying it unchanged will fail the same way. Fix the request (or the decision it names) first.`) +
|
|
31
35
|
sideEffectNote(endpoint, typeof body?.code === "string" ? body.code : undefined));
|
|
32
36
|
this.status = status;
|
package/dist/index.d.ts
CHANGED
|
@@ -103,6 +103,11 @@ export declare class DecidioNetworkError extends Error {
|
|
|
103
103
|
code: string | undefined;
|
|
104
104
|
constructor(endpoint: string, op: string, code: string | undefined, cause: unknown);
|
|
105
105
|
}
|
|
106
|
+
/** THE ONE DESCRIBE VALIDATION (prodsec V033-05). The core wrapper refused a blank `action` and a
|
|
107
|
+
* negative / non-finite `amount` before routing; the adapter gate canonicalized and posted them, so
|
|
108
|
+
* every engine door reached the mock authorization path with a request the core would have refused.
|
|
109
|
+
* Exported so `adapters/_gate.ts` calls the same function rather than a second copy that can drift. */
|
|
110
|
+
export declare function assertDescribed(ctx: ApprovalContext): void;
|
|
106
111
|
/**
|
|
107
112
|
* Wrap an async action behind the Decidio gate. `describe` extracts the approval
|
|
108
113
|
* context (action, amount, scope) from the call arguments. Returns a function with
|
package/dist/index.js
CHANGED
|
@@ -78,6 +78,14 @@ async function reach(f, url, op, init) {
|
|
|
78
78
|
throw new DecidioNetworkError(url, op, e?.cause?.code ?? e?.code, e);
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
+
export function assertDescribed(ctx) {
|
|
82
|
+
if (typeof ctx?.action !== "string" || ctx.action.trim().length === 0) {
|
|
83
|
+
throw new Error(`[decidio] describe(...) must return an \`action\` string naming what is being asked (got ${JSON.stringify(ctx?.action ?? null)}). Nothing was routed or executed.`);
|
|
84
|
+
}
|
|
85
|
+
if (typeof ctx.amount !== "number" || !Number.isFinite(ctx.amount) || ctx.amount < 0) {
|
|
86
|
+
throw new Error(`[decidio] describe(...) must return a finite, non-negative \`amount\` — the policy decides on it, so a request without one is not a request Decidio can rule on (got ${JSON.stringify(ctx.amount ?? null)}). Use 0 for an action with no monetary value. Nothing was routed or executed.`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
81
89
|
async function post(cfg, path, body) {
|
|
82
90
|
const f = cfg.fetchImpl ?? fetch;
|
|
83
91
|
assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
|
|
@@ -142,12 +150,7 @@ export function withApproval(fn, describe, config, opts) {
|
|
|
142
150
|
const requesterIdentity = (config.agentKey && config.agentDid)
|
|
143
151
|
? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
|
|
144
152
|
: undefined;
|
|
145
|
-
|
|
146
|
-
throw new Error(`[decidio] describe(...) must return an \`action\` string naming what is being asked (got ${JSON.stringify(ctx?.action ?? null)}). Nothing was routed or executed.`);
|
|
147
|
-
}
|
|
148
|
-
if (typeof ctx.amount !== "number" || !Number.isFinite(ctx.amount) || ctx.amount < 0) {
|
|
149
|
-
throw new Error(`[decidio] describe(...) must return a finite, non-negative \`amount\` — the policy decides on it, so a request without one is not a request Decidio can rule on (got ${JSON.stringify(ctx.amount ?? null)}). Use 0 for an action with no monetary value. Nothing was routed or executed.`);
|
|
150
|
-
}
|
|
153
|
+
assertDescribed(ctx);
|
|
151
154
|
const auth = await post(config, "/agent/authorize", {
|
|
152
155
|
requester: config.agentId, action: ctx.action, amount: ctx.amount,
|
|
153
156
|
scope: ctx.scope, sourceSystem: config.sourceSystem,
|
package/dist/inngest.d.ts
CHANGED
|
@@ -35,6 +35,8 @@ export type GateStepOutcome<R> = {
|
|
|
35
35
|
status: "executed" | "rejected" | "blocked";
|
|
36
36
|
decisionId: string;
|
|
37
37
|
value?: R;
|
|
38
|
+
/** Always present when `status` is "executed" (the step confirms before it returns); optional in the type
|
|
39
|
+
* because the other statuses carry none. */
|
|
38
40
|
confirmation?: EvidenceOutcome;
|
|
39
41
|
};
|
|
40
42
|
/**
|
package/dist/inngest.js
CHANGED
|
@@ -28,6 +28,8 @@ export async function decidioGateStep(args) {
|
|
|
28
28
|
export function createInngestResumeBridge(opts) {
|
|
29
29
|
const secret = opts.webhookSecret ?? "";
|
|
30
30
|
const eventName = opts.eventName ?? DECIDIO_RESUME_EVENT;
|
|
31
|
+
if (!secret && opts.allowUnsigned)
|
|
32
|
+
console.warn("[decidio] inngest resume bridge: allowUnsigned=true and no webhookSecret — resume signals are NOT authenticated; anyone who can reach this endpoint can emit a resume event. Development only.");
|
|
31
33
|
return (req, res) => {
|
|
32
34
|
if (req.method !== "POST") {
|
|
33
35
|
res.statusCode = 405;
|
package/dist/resume.d.ts
CHANGED
|
@@ -450,7 +450,12 @@ export declare class DecidioSuspendedError extends Error {
|
|
|
450
450
|
/** `indeterminate` (0.2.0): the handler was INVOKED and then threw. Whether the downstream
|
|
451
451
|
* effect committed is not knowable from here, so the action is never retried automatically —
|
|
452
452
|
* it waits for reconcile(). Callers that switch exhaustively on this union must handle it. */
|
|
453
|
-
|
|
453
|
+
/** `already_resolved` (0.3.5): this resume signal LOST the transition race to an earlier, FINAL answer
|
|
454
|
+
* (a denial, an execution that already ran, or a report already recorded) — `reason` names which. It used
|
|
455
|
+
* to read `unknown`, the word for "not ours" (prodsec V033-06). An `invoking` row is NOT final and answers
|
|
456
|
+
* `indeterminate` (prodsec V035-02). Both members are additive: an exhaustive switch over this union
|
|
457
|
+
* gains a case per release, which the changelog announces. */
|
|
458
|
+
export type ResumeStatus = "executed" | "rejected" | "blocked" | "no_handler" | "unknown" | "indeterminate" | "already_resolved";
|
|
454
459
|
/** The states a durable action can actually be in — every one of them nameable, including the
|
|
455
460
|
* one nobody likes. */
|
|
456
461
|
export type RecoveryState =
|
|
@@ -472,7 +477,9 @@ export type RecoveryState =
|
|
|
472
477
|
* and must never be retried automatically. */
|
|
473
478
|
| "indeterminate"
|
|
474
479
|
/** Executed, result captured, report not yet accepted by Decidio. Safe to `reconfirm`. */
|
|
475
|
-
| "executed_unreported"
|
|
480
|
+
| "executed_unreported"
|
|
481
|
+
/** a parked row whose payload is gone: cannot execute, not awaiting anyone (0.3.5) */
|
|
482
|
+
| "orphaned_state";
|
|
476
483
|
export interface RecoveryItem {
|
|
477
484
|
decisionId: string;
|
|
478
485
|
action?: string;
|
package/dist/resume.js
CHANGED
|
@@ -1037,7 +1037,12 @@ export function createDecidioResume(opts) {
|
|
|
1037
1037
|
refuseOnce(decisionId, claim.state === "discarded"
|
|
1038
1038
|
? `[decidio] REFUSING to execute ${decisionId}: it was DENIED (${(await ctrl.store.getExecution(decisionId))?.verdict ?? "rejected"}) and that answer is final. Nothing ran. A denial cannot be reconciled or retried — if this action should now proceed, it needs a NEW request and a new approval.`
|
|
1039
1039
|
: `[decidio] REFUSING to execute ${decisionId}: its durable state is ${claim.state} — invocation already began, so re-running it could duplicate a committed effect. Inspect it with guard.resume.recover(); settle it with guard.resume.reconcile("${decisionId}", …).`, claim.state === "discarded" ? " (This prints once. The decision is closed — no action is needed.)" : undefined);
|
|
1040
|
-
|
|
1040
|
+
if (claim.state === "invoking" || claim.state === "indeterminate") {
|
|
1041
|
+
return finish({ status: "indeterminate", decisionId, action: pending.action, reason: claim.state === "invoking"
|
|
1042
|
+
? `this decision is in durable state invoking: its action may still be running, may have crashed before any effect, or may have finished without reporting - it may or may not have run. Nothing will retry it; check the worker and the downstream system, then settle it with reconcile("${decisionId}", …).`
|
|
1043
|
+
: claim.reason });
|
|
1044
|
+
}
|
|
1045
|
+
return finish({ status: "already_resolved", decisionId, action: pending.action, reason: claim.state === "discarded" ? `this decision was already DENIED (${(await ctrl.store.getExecution(decisionId))?.verdict ?? "rejected"}); that answer is final and this signal lost the race to it` : `this decision already ran (durable state ${claim.state}); this signal lost the race to it` });
|
|
1041
1046
|
}
|
|
1042
1047
|
return { status: "unknown", decisionId };
|
|
1043
1048
|
}
|
|
@@ -1269,7 +1274,12 @@ export function createDecidioResume(opts) {
|
|
|
1269
1274
|
guidance: `"${a}" was claimed at ${r.at} by attempt ${r.attemptId ?? "(unknown)"} which never reached the invocation boundary — the handler provably did not run, and nothing will retry it on its own. Release it: guard.resume.reconcile("${r.decisionId}", { outcome: "not_committed", reason: "claimed but never invoked" }).` });
|
|
1270
1275
|
break;
|
|
1271
1276
|
case "parked":
|
|
1272
|
-
|
|
1277
|
+
if (!(await ctrl.store.get(r.decisionId))) {
|
|
1278
|
+
items.push({ decisionId: r.decisionId, action: r.action, state: "orphaned_state", guidance: `"${a}" is recorded as parked but its payload is gone, so nothing can execute it — an orphaned row (usually a legacy denial whose state was never written), not work awaiting a human. Nothing will run. It needs no action; remove its revision files if you want the listing clean.` });
|
|
1279
|
+
}
|
|
1280
|
+
else {
|
|
1281
|
+
items.push({ decisionId: r.decisionId, action: r.action, state: "parked", guidance: `"${a}" is parked awaiting a human decision. Nothing has executed.` });
|
|
1282
|
+
}
|
|
1273
1283
|
break;
|
|
1274
1284
|
case "discarded":
|
|
1275
1285
|
if (r.executionDespiteDenial) {
|
package/dist/signing.d.ts
CHANGED
|
@@ -74,6 +74,14 @@ export declare function assertJsonSafe(value: unknown, path?: string): void;
|
|
|
74
74
|
* rule is about the WIRE, not which credential (the interactive sign-in enforced this for
|
|
75
75
|
* session tokens while agent-token requests sailed through). localhost/127.0.0.1/[::1] stay
|
|
76
76
|
* allowed for local dev; https always allowed. Throws before anything is sent. */
|
|
77
|
+
/** The URL as it may be ECHOED in a refusal: userinfo masked. The SDK treats URL userinfo as a credential
|
|
78
|
+
* (it is refused as one), so no refusal may print it - every branch of assertSafeTransport echoes through
|
|
79
|
+
* this, not the raw string (invariant-guard, 0.3.5). */
|
|
80
|
+
export declare function redactUrl(u: string): string;
|
|
81
|
+
/** Unicode SPACE and FORMAT characters a URL must never contain: the Zs/Zl/Zp separators (NBSP, ideographic
|
|
82
|
+
* space, …) and the zero-width formats. Spelled as an explicit list in BOTH runtimes so the answers cannot
|
|
83
|
+
* drift with a library's notion of whitespace (Python's urlsplit strips a leading NBSP; WHATWG does not). */
|
|
84
|
+
export declare const URL_UNICODE_SPACE: RegExp;
|
|
77
85
|
export declare function assertSafeTransport(url: string, hasCredential: boolean): void;
|
|
78
86
|
/** The ONE answer to "does this request carry a credential worth protecting?" (A6, 0.1.12).
|
|
79
87
|
* A signed identity proof is a credential too: sending it over remote plaintext disclosed
|
package/dist/signing.js
CHANGED
|
@@ -106,7 +106,20 @@ export function assertJsonSafe(value, path = "args") {
|
|
|
106
106
|
};
|
|
107
107
|
walk(value, path);
|
|
108
108
|
}
|
|
109
|
+
export function redactUrl(u) {
|
|
110
|
+
return u.replace(/\/\/[^/?#]*@/, "//***@").replace(/^([^/?#@:]*:)(?!\/\/)[\/\\]*[^/?#]*@/, "$1***@");
|
|
111
|
+
}
|
|
112
|
+
export const URL_UNICODE_SPACE = /[\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\u200b-\u200d\u2060]/;
|
|
109
113
|
export function assertSafeTransport(url, hasCredential) {
|
|
114
|
+
if (typeof url !== "string") {
|
|
115
|
+
throw new Error(`[decidio] the gate URL is ${url === null ? "null" : typeof url}, not a string, so there is no host to send to. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
|
|
116
|
+
}
|
|
117
|
+
for (const ch of url) {
|
|
118
|
+
const cp = ch.codePointAt(0);
|
|
119
|
+
if (cp <= 0x20 || (cp >= 0x7f && cp <= 0x9f) || cp === 0xfeff || URL_UNICODE_SPACE.test(ch)) {
|
|
120
|
+
throw new Error(`[decidio] the gate URL ${JSON.stringify(redactUrl(url))} contains whitespace or a control character (U+${cp.toString(16).toUpperCase().padStart(4, "0")}) - a validator and a transport can read such a spelling differently, so it is refused as written. Remove it. Nothing was sent.`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
110
123
|
let u;
|
|
111
124
|
try {
|
|
112
125
|
u = new URL(url);
|
|
@@ -114,9 +127,9 @@ export function assertSafeTransport(url, hasCredential) {
|
|
|
114
127
|
catch {
|
|
115
128
|
const spelled = url.replace(/[\t\r\n]/g, "").replace(/^[\s\u0000-\u001f\u0085\ufeff]+/, "");
|
|
116
129
|
if (/^http:/i.test(spelled)) {
|
|
117
|
-
throw new Error(`[decidio] refusing to ${hasCredential ? "send credentials" : "send a request"} over plaintext http to ${JSON.stringify(url)} — that is not a complete absolute URL, so where the request would actually connect is undefined (the path is appended to it, which can turn it into a remote host). Set a full https URL, or http://localhost for local dev. Nothing was sent.`);
|
|
130
|
+
throw new Error(`[decidio] refusing to ${hasCredential ? "send credentials" : "send a request"} over plaintext http to ${JSON.stringify(redactUrl(url))} — that is not a complete absolute URL, so where the request would actually connect is undefined (the path is appended to it, which can turn it into a remote host). Set a full https URL, or http://localhost for local dev. Nothing was sent.`);
|
|
118
131
|
}
|
|
119
|
-
throw new Error(`[decidio] the gate URL ${JSON.stringify(url)} is not an absolute URL, so there is no host to send to — where a request would actually connect depends entirely on whichever transport resolves it. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
|
|
132
|
+
throw new Error(`[decidio] the gate URL ${JSON.stringify(redactUrl(url))} is not an absolute URL, so there is no host to send to — where a request would actually connect depends entirely on whichever transport resolves it. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
|
|
120
133
|
}
|
|
121
134
|
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
|
122
135
|
throw new Error(`[decidio] the gate URL uses the ${u.protocol.replace(":", "")} scheme, which is not a Decidio endpoint — use https (or http://localhost for local dev). Nothing was sent.`);
|
|
@@ -125,14 +138,14 @@ export function assertSafeTransport(url, hasCredential) {
|
|
|
125
138
|
throw new Error(`[decidio] the gate URL carries credentials in the URL itself (${u.protocol}//user:***@${u.host}). Decidio authenticates with a bearer token; remove the user:password from the URL. Nothing was sent.`);
|
|
126
139
|
}
|
|
127
140
|
if (u.hostname === "") {
|
|
128
|
-
throw new Error(`[decidio] the gate URL ${JSON.stringify(url)} has no host — there is nowhere for the request to go. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
|
|
141
|
+
throw new Error(`[decidio] the gate URL ${JSON.stringify(redactUrl(url))} has no host — there is nowhere for the request to go. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
|
|
129
142
|
}
|
|
130
143
|
{
|
|
131
144
|
const spelled = url.replace(/[\t\r\n]/g, "").replace(/^[\s\u0000-\u001f\u0085\ufeff]+/, "").toLowerCase();
|
|
132
145
|
const origin = `${u.protocol}//${u.host}`.toLowerCase();
|
|
133
146
|
const next = spelled.charAt(origin.length);
|
|
134
147
|
if (!spelled.startsWith(origin) || (next !== "" && next !== "/" && next !== "?" && next !== "#" && next !== ":")) {
|
|
135
|
-
throw new Error(`[decidio] the gate URL ${JSON.stringify(url)} is not written in canonical form — this parser reads it as ${origin}, but the raw spelling is what a transport receives, and a parser that normalizes differently can read it as another host entirely. Write it as ${origin}. Nothing was sent.`);
|
|
148
|
+
throw new Error(`[decidio] the gate URL ${JSON.stringify(redactUrl(url))} is not written in canonical form — this parser reads it as ${origin}, but the raw spelling is what a transport receives, and a parser that normalizes differently can read it as another host entirely. Write it as ${origin}. Nothing was sent.`);
|
|
136
149
|
}
|
|
137
150
|
}
|
|
138
151
|
if (u.protocol !== "http:")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decidio/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "One-line approval gate for AI-agent actions. Wrap a risky tool call with guard.protect(); Decidio's policy engine decides proceed | route | block, a human approves routed actions, and every outcome is sealed as a verifiable Authority Receipt.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"dist",
|
|
41
41
|
"openapi.yaml",
|
|
42
42
|
"README.md",
|
|
43
|
+
"CHANGELOG.md",
|
|
43
44
|
"LICENSE"
|
|
44
45
|
],
|
|
45
46
|
"engines": {
|
|
@@ -82,7 +83,7 @@
|
|
|
82
83
|
"prepublishOnly": "npm run typecheck && npm run build && node ../publish-boundary/check.mjs --only @decidio/sdk"
|
|
83
84
|
},
|
|
84
85
|
"dependencies": {
|
|
85
|
-
"@decidio/verify": "0.
|
|
86
|
+
"@decidio/verify": "0.4.0"
|
|
86
87
|
},
|
|
87
88
|
"author": "OmniTwin Technologies Inc.",
|
|
88
89
|
"bugs": {
|