@tinycloud/courier 0.2.0-beta.33 → 0.2.0-beta.34
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 +117 -4
- package/dist/approval.d.ts +48 -0
- package/dist/approval.js +181 -0
- package/dist/approval.js.map +1 -0
- package/dist/canonical.d.ts +14 -0
- package/dist/canonical.js +39 -0
- package/dist/canonical.js.map +1 -0
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +23 -6
- package/dist/commands.js.map +1 -1
- package/dist/guard/target.d.ts +13 -0
- package/dist/guard/target.js +101 -0
- package/dist/guard/target.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/keys.d.ts +2 -0
- package/dist/keys.js +2 -0
- package/dist/keys.js.map +1 -1
- package/dist/main.js +17 -3
- package/dist/main.js.map +1 -1
- package/dist/request.d.ts +54 -0
- package/dist/request.js +116 -0
- package/dist/request.js.map +1 -0
- package/dist/service.js +7 -0
- package/dist/service.js.map +1 -1
- package/dist/text-ranges.d.ts +2 -0
- package/dist/text-ranges.js +692 -0
- package/dist/text-ranges.js.map +1 -0
- package/dist/text.d.ts +28 -0
- package/dist/text.js +115 -0
- package/dist/text.js.map +1 -0
- package/dist/wake.d.ts +7 -0
- package/dist/wake.js +43 -9
- package/dist/wake.js.map +1 -1
- package/dist/watch-cmd.js +8 -2
- package/dist/watch-cmd.js.map +1 -1
- package/dist/watch.d.ts +15 -3
- package/dist/watch.js +110 -20
- package/dist/watch.js.map +1 -1
- package/package.json +1 -1
- package/skills/courier/SKILL.md +45 -4
package/README.md
CHANGED
|
@@ -49,7 +49,7 @@ tc courier pair accept --file a.json
|
|
|
49
49
|
tc courier send bob --new "Subject" --ask "…" --file handoff.md
|
|
50
50
|
tc courier inbox && tc courier read <threadId>
|
|
51
51
|
tc courier task new --peer bob --thread <id> --state working --ask a1
|
|
52
|
-
tc courier request <threadId> --peer bob --task <id> --
|
|
52
|
+
tc courier request <threadId> --peer bob --task <id> --verb merge --target owner/repo#12 --summary "Merge PR 12" --why "…"
|
|
53
53
|
tc courier orders bob
|
|
54
54
|
```
|
|
55
55
|
|
|
@@ -165,9 +165,122 @@ process group is killed (a shell's background jobs, pipelines and subshells
|
|
|
165
165
|
included) and the wake counts as failed. A failed wake stays pending, releases
|
|
166
166
|
its claim and retries after 60 s. A timeout that hits after the agent already
|
|
167
167
|
received the prompt therefore wakes the thread again: wakes are at-least-once.
|
|
168
|
-
A
|
|
169
|
-
|
|
170
|
-
|
|
168
|
+
A child that calls `setsid` or `setpgid` leaves the group and survives the kill
|
|
169
|
+
(the group kill reaches everything the shell started, not a program that
|
|
170
|
+
deliberately detaches). A command that exits normally but leaves a background
|
|
171
|
+
job running is not waited for, and the job is left alone. When the watcher stops (SIGINT, SIGTERM, a state
|
|
172
|
+
conflict) it kills the runs still in flight at once, without waiting for the
|
|
173
|
+
wake to end.
|
|
174
|
+
|
|
175
|
+
If the watcher itself is killed (SIGKILL, power loss), its detached runs are not
|
|
176
|
+
reaped by the watcher. `systemd --user` units set `KillMode=control-group`, so
|
|
177
|
+
the whole cgroup goes down with the service; launchd tracks only the job's own
|
|
178
|
+
process group (`AbandonProcessGroup` is false, but agents run in their own
|
|
179
|
+
groups), so on macOS an agent may outlive a crashed watcher. The restarted
|
|
180
|
+
watcher takes over the dead one's claim and wakes the thread again (at-least-once):
|
|
181
|
+
that thread can briefly have two agent runs.
|
|
182
|
+
|
|
183
|
+
### Approvals: request fields, text rules and `courier approval`
|
|
184
|
+
|
|
185
|
+
`courier request` writes structured fields into `me/requests/<id>.json`:
|
|
186
|
+
`verb` (`merge`, `deploy`, `publish`, `spend` or `other`), `target` and
|
|
187
|
+
`summary`. They are part of the request the hash covers; the view renders
|
|
188
|
+
"Approving lets your agent <verb> <target> for <peer>: “<summary>”" (your own agent
|
|
189
|
+
acts; the peer is who asked) from them; the guard
|
|
190
|
+
matches the command it is about to allow to `verb` and `target` only. `action`
|
|
191
|
+
(default: the summary), `reason` (`--why`) and `risk` are free text for the
|
|
192
|
+
owner and are never matched.
|
|
193
|
+
|
|
194
|
+
One rule set applies when `courier request` writes, when `courier approval`
|
|
195
|
+
reads, and when the view shows the card. A value that breaks it is **refused,
|
|
196
|
+
never repaired or shortened**:
|
|
197
|
+
|
|
198
|
+
- text must already be Unicode NFC; it is never normalized or repaired. Non-ASCII
|
|
199
|
+
characters are allowed only from the frozen **Unicode 15.0.0** categories
|
|
200
|
+
Lu, Ll, Lt, Lo and Nd, excluding Default_Ignorable. Modifier letters (Lm),
|
|
201
|
+
other numbers (Nl/No), all combining marks, punctuation, symbols, controls,
|
|
202
|
+
invisible characters, fillers and unassigned/newer characters are refused;
|
|
203
|
+
- U+0020 is the only space. `summary` additionally allows exactly these ASCII
|
|
204
|
+
punctuation characters: `. , : ; - _ / # ( ) ? !` and apostrophe U+0027.
|
|
205
|
+
Non-ASCII apostrophes, quotes and brackets are refused. `action`, `reason`,
|
|
206
|
+
`risk` and approval notes allow all printable ASCII punctuation and LF (`\n`),
|
|
207
|
+
never a tab or CR;
|
|
208
|
+
- `target`, `summary`: 1 to 200 UTF-16 units, trimmed, one line; `action`,
|
|
209
|
+
`reason`, `risk`: 1 to 2000, a single `\n` allowed, never a tab or CR;
|
|
210
|
+
- `target` is printable ASCII (`[\x20-\x7E]`) for every verb, in the one
|
|
211
|
+
canonical spelling for its verb (`courier request --help` prints these forms):
|
|
212
|
+
|
|
213
|
+
| verb | canonical target |
|
|
214
|
+
|---|---|
|
|
215
|
+
| `merge` | `owner/repo#<PR number>` (a GitHub PR URL is reduced to it), or `owner/repo@<branch>` for a direct push; repo lower-cased; the PR number is exactly the digits written, 1 to 9 of them, no leading zeros (nothing is ever rounded through a float) |
|
|
216
|
+
| `publish` | `npm:<name>@<version>`, `release:owner/repo@<tag>`, `changeset:<root package name>` or `script:<package>:<script>` |
|
|
217
|
+
| `deploy` | `wrangler:<worker>[:<env>]`, `pages:<project>`, `phala:<name>` or `script:<package>:<script>` |
|
|
218
|
+
| `other` | `workflow:owner/repo:<workflow file or name>`, `secrets:<subcommand and key>`, or `<kind>:<text>` |
|
|
219
|
+
| `spend` | `spend:<what and how much>` |
|
|
220
|
+
|
|
221
|
+
The CLI and view share `src/text-ranges.ts` and
|
|
222
|
+
`test/vectors/text-verdicts.json`; decisions use only that frozen table and an
|
|
223
|
+
NFC check, never runtime Unicode property tables. The version is pinned to
|
|
224
|
+
Unicode 15.0.0 (Node 20.0.0). The vector ranges cover all 1,114,112 code points
|
|
225
|
+
for `summary`, `multiline` and `target`, plus full-string verdict/reason samples.
|
|
226
|
+
`textVerdict(value: unknown, field: 'summary' | 'multiline' | 'target')` returns
|
|
227
|
+
`{ allowed: boolean, reason: null | 'not-string' | 'empty' | 'too-long' |
|
|
228
|
+
'edge-whitespace' | 'not-nfc' | 'disallowed-code-point' }`. Limits/trim rules are
|
|
229
|
+
included; `multiline` is the action/reason/risk/note policy (an optional empty
|
|
230
|
+
approval note is handled by the caller). Target shapes remain verb-specific.
|
|
231
|
+
The pinned UCD source hashes and regeneration instructions are in
|
|
232
|
+
`scripts/generate-text-table.mjs`. The independent test walks every code point
|
|
233
|
+
using runtime Unicode categories: it fails if the table permits more, and
|
|
234
|
+
reports newer runtime additions without permitting them.
|
|
235
|
+
|
|
236
|
+
- `threadId`, `taskId` and the request id are ULIDs (26 upper-case Crockford
|
|
237
|
+
base32 characters, no I L O U), and the peer handle is 1 to 64 of
|
|
238
|
+
`[A-Za-z0-9_-]`, on write and on read (the view only shows cards for ULIDs);
|
|
239
|
+
- the key set of `me/requests/<id>.json` is closed: `id`, `peer`, `threadId`,
|
|
240
|
+
`taskId`, `verb`, `target`, `summary`, `requestedBy` (`{owner, agent}`),
|
|
241
|
+
`createdAt`, `expiresAt`, `status` (`"pending"`) and the optional free text
|
|
242
|
+
`action`, `reason`, `risk` (written by the CLI, well-formed when present).
|
|
243
|
+
Anything else makes the request unreviewable in the view and refused by
|
|
244
|
+
`courier approval`, even if the hash matches;
|
|
245
|
+
- the whole request must hash: `canonicalJson` / `requestHash` throw on a
|
|
246
|
+
non-finite number (a parsed `1e999`) and on nesting deeper than 64 levels
|
|
247
|
+
(top-level object = depth 1), so such a request is never written and
|
|
248
|
+
`courier approval` fails closed on it.
|
|
249
|
+
|
|
250
|
+
`test/vectors/request-fields.json` is the contract: every row says whether a
|
|
251
|
+
value is accepted, and the canonical-JSON rows give the expected output (or
|
|
252
|
+
"must throw"). Copy it into any other implementation.
|
|
253
|
+
|
|
254
|
+
`courier approval <requestId> --verb <v> --target <t> [--peek]` is what a
|
|
255
|
+
woken agent runs before a gated action. It reads `me/requests/<id>.json` and
|
|
256
|
+
`me/approvals/<id>.json` and exits 0 only if the request has valid structured
|
|
257
|
+
fields, the approval names that request (`requestId` equals the id), carries
|
|
258
|
+
`requestHash` equal to the SHA-256 of the request's canonical JSON
|
|
259
|
+
(`canonicalJson` / `requestHash`, exported, mirrored by the view; a parsed
|
|
260
|
+
`"__proto__"` key counts as an ordinary key), the request contains no hidden
|
|
261
|
+
text anywhere (rules above, applied to every string and key), says `approved`, was decided in
|
|
262
|
+
`[createdAt, expiresAt)` of the request (a decision dated up to 5 minutes ahead
|
|
263
|
+
of this machine's clock is tolerated; keep the clocks of the agent's machine
|
|
264
|
+
and the owner's browser in step) and within the last 24 hours, and the given
|
|
265
|
+
`--verb` and (canonicalised) `--target` equal the request's. Both are required
|
|
266
|
+
when consuming; `--peek` (for guards) may omit them.
|
|
267
|
+
|
|
268
|
+
`courier request` and `courier approval` (peek and consume) print the full
|
|
269
|
+
`requestHash`; the view's card shows its first 8 hex digits as a fingerprint so the owner can match the card to the agent's request.
|
|
270
|
+
|
|
271
|
+
It then consumes the approval with a create-only marker at
|
|
272
|
+
`me/state/approvals-used/<id>.json`, so a second run exits `approval-used`;
|
|
273
|
+
`--peek` checks the marker too (a used approval fails the peek) but does not
|
|
274
|
+
write it. A denial exits `approval-denied`. The courier key cannot write
|
|
275
|
+
`me/approvals/`, so only the owner's view session can produce a passing
|
|
276
|
+
approval. Residual risk: the courier grant can delete under `me/state/`
|
|
277
|
+
(spec §3), so a compromised agent could delete the used marker and replay an
|
|
278
|
+
approval within its 24 hours; a delete-proof record is tracked separately.
|
|
279
|
+
|
|
280
|
+
The watcher wakes the thread's agent as soon as a new approval (approved or
|
|
281
|
+
denied) for one of its requests appears, without the debounce. Approvals older
|
|
282
|
+
than 24 hours, already used, malformed, or without a request at start-up wake
|
|
283
|
+
nobody.
|
|
171
284
|
|
|
172
285
|
### Running it as a service
|
|
173
286
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { KV } from './inbox.js';
|
|
2
|
+
import type { ParsedArgs } from './args.js';
|
|
3
|
+
/** An approval's stated decision on a request, as the view writes `me/approvals/<id>.json`. */
|
|
4
|
+
export interface ApprovalRecord {
|
|
5
|
+
requestId: string;
|
|
6
|
+
requestHash: string;
|
|
7
|
+
decision: 'approved' | 'denied';
|
|
8
|
+
note: string;
|
|
9
|
+
decidedAt: string;
|
|
10
|
+
}
|
|
11
|
+
export interface VerifiedApproval {
|
|
12
|
+
requestId: string;
|
|
13
|
+
/** Lowercase hex SHA-256 of the request's canonical JSON; the view shows its first 8 digits as the card's fingerprint. */
|
|
14
|
+
requestHash: string;
|
|
15
|
+
decision: 'approved' | 'denied';
|
|
16
|
+
note: string;
|
|
17
|
+
decidedAt: string;
|
|
18
|
+
/** What was approved: matched against the command by `verb` and `target`, never by free text. */
|
|
19
|
+
verb: string;
|
|
20
|
+
target: string;
|
|
21
|
+
summary: string;
|
|
22
|
+
peer: string;
|
|
23
|
+
threadId: string;
|
|
24
|
+
taskId: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Spec §7: an approval covers one request and expires after 24 hours. Throws a CourierError with a stable code
|
|
28
|
+
* unless `approval` is a current, matching decision on `request` (denials are reported, not thrown).
|
|
29
|
+
*/
|
|
30
|
+
export declare function verifyApproval(id: string, request: unknown, approval: unknown, options?: {
|
|
31
|
+
now?: number;
|
|
32
|
+
verb?: string;
|
|
33
|
+
target?: string;
|
|
34
|
+
}): VerifiedApproval;
|
|
35
|
+
/**
|
|
36
|
+
* True if the approval was used (the marker under `me/state/approvals-used/`). Note the courier key can delete under
|
|
37
|
+
* `me/state/` (spec §3); a delete-proof record is being designed separately (G3), so this is the one place to change.
|
|
38
|
+
*/
|
|
39
|
+
export declare function approvalIsUsed(kv: KV, id: string): Promise<boolean>;
|
|
40
|
+
/** Marks an approval used. The marker is create-only, so of two racing callers exactly one wins. */
|
|
41
|
+
export declare function consumeApproval(kv: KV, id: string, at?: Date): Promise<void>;
|
|
42
|
+
export declare const approvalUsage: () => string;
|
|
43
|
+
/**
|
|
44
|
+
* `courier approval <id> --verb V --target T`: exit 0 and consume the approval only if the owner approved this exact
|
|
45
|
+
* request (its structured verb and target equal yours), within 24 hours, and nobody has used it. `--peek` verifies
|
|
46
|
+
* without consuming (for guards) and fails the same way for a used approval. A denial exits non-zero.
|
|
47
|
+
*/
|
|
48
|
+
export declare function cmdApproval(args: ParsedArgs): Promise<void>;
|
package/dist/approval.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { CourierError } from './errors.js';
|
|
2
|
+
import { k } from './keys.js';
|
|
3
|
+
import { isRecord } from './a2a.js';
|
|
4
|
+
import { requestHash } from './canonical.js';
|
|
5
|
+
import { requestShapeProblem, storedRequestFields } from './request.js';
|
|
6
|
+
import { allStrings, hiddenTextProblem, MAX_ACTION, MAX_REASON, MAX_RISK, plainLineProblem, plainTextProblem } from './text.js';
|
|
7
|
+
import { canonicalTarget, targetHelp } from './guard/target.js';
|
|
8
|
+
import { getJson, putCreate } from './store.js';
|
|
9
|
+
import { cmd } from './program.js';
|
|
10
|
+
import { courierNode } from './node.js';
|
|
11
|
+
import { loadProfile } from './profile.js';
|
|
12
|
+
import { writeOut } from './inbox.js';
|
|
13
|
+
const DAY_MS = 24 * 3600 * 1000;
|
|
14
|
+
const MAX_NOTE = 2000;
|
|
15
|
+
/** The view stamps `decidedAt` with the browser's clock; a decision dated slightly ahead of this machine's clock is tolerated. */
|
|
16
|
+
const FUTURE_SKEW_MS = 5 * 60 * 1000;
|
|
17
|
+
function fail(code, message) {
|
|
18
|
+
throw new CourierError(code, message);
|
|
19
|
+
}
|
|
20
|
+
function ms(value, what) {
|
|
21
|
+
const parsed = typeof value === 'string' ? Date.parse(value) : NaN;
|
|
22
|
+
if (!Number.isFinite(parsed))
|
|
23
|
+
fail('approval-mismatch', `${what} is not a valid timestamp.`);
|
|
24
|
+
return parsed;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Spec §7: an approval covers one request and expires after 24 hours. Throws a CourierError with a stable code
|
|
28
|
+
* unless `approval` is a current, matching decision on `request` (denials are reported, not thrown).
|
|
29
|
+
*/
|
|
30
|
+
export function verifyApproval(id, request, approval, options = {}) {
|
|
31
|
+
const now = options.now ?? Date.now();
|
|
32
|
+
if (!isRecord(request) || request.id !== id)
|
|
33
|
+
fail('approval-mismatch', `me/requests/${id}.json is missing or its id is not "${id}".`);
|
|
34
|
+
if (!isRecord(approval))
|
|
35
|
+
fail('approval-missing', `No approval for request ${id} yet.`);
|
|
36
|
+
if (approval.requestId !== id)
|
|
37
|
+
fail('approval-mismatch', `Approval ${id}.json names request "${String(approval.requestId)}", not "${id}".`);
|
|
38
|
+
if (approval.decision !== 'approved' && approval.decision !== 'denied') {
|
|
39
|
+
fail('approval-mismatch', `Approval decision must be "approved" or "denied" (got ${JSON.stringify(approval.decision)}).`);
|
|
40
|
+
}
|
|
41
|
+
// Fail closed on a request that cannot be hashed (non-finite number, absurd nesting) or hides text from the reader.
|
|
42
|
+
let hash;
|
|
43
|
+
try {
|
|
44
|
+
hash = requestHash(request);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
fail('approval-mismatch', `The request cannot be verified: ${error.message}`);
|
|
48
|
+
}
|
|
49
|
+
for (const text of allStrings(request)) {
|
|
50
|
+
const problem = hiddenTextProblem(text, true);
|
|
51
|
+
if (problem)
|
|
52
|
+
fail('approval-mismatch', `The request contains text a person could not read as written (${problem}); it cannot be approved. File a new \`courier request\`.`);
|
|
53
|
+
}
|
|
54
|
+
if (approval.requestHash !== hash) {
|
|
55
|
+
fail('approval-mismatch', `Approval ${id} was not made for this request as stored (hash mismatch): the request changed after the decision.`);
|
|
56
|
+
}
|
|
57
|
+
// Everything of the approval that is echoed to the agent is swept like the request: no hidden text.
|
|
58
|
+
const noteProblem = approval.note === undefined || approval.note === '' ? undefined : plainTextProblem(approval.note, MAX_NOTE);
|
|
59
|
+
if (typeof approval.note !== 'string' && approval.note !== undefined)
|
|
60
|
+
fail('approval-mismatch', 'The approval note is not text.');
|
|
61
|
+
if (noteProblem)
|
|
62
|
+
fail('approval-mismatch', `The approval note is refused: ${noteProblem}.`);
|
|
63
|
+
const stampProblem = plainLineProblem(approval.decidedAt, 40);
|
|
64
|
+
if (stampProblem)
|
|
65
|
+
fail('approval-mismatch', `decidedAt is refused: ${stampProblem}.`);
|
|
66
|
+
const decidedAt = ms(approval.decidedAt, 'decidedAt');
|
|
67
|
+
const created = ms(request.createdAt, 'request createdAt');
|
|
68
|
+
const expires = ms(request.expiresAt, 'request expiresAt');
|
|
69
|
+
// Spec §7: the decision lies in [createdAt, expiresAt). Both timestamps come from different clocks; that is why the
|
|
70
|
+
// README asks for them to agree.
|
|
71
|
+
if (decidedAt < created)
|
|
72
|
+
fail('approval-mismatch', 'The decision is dated before the request existed.');
|
|
73
|
+
if (decidedAt > now + FUTURE_SKEW_MS)
|
|
74
|
+
fail('approval-mismatch', 'The decision is dated in the future.');
|
|
75
|
+
if (decidedAt >= expires)
|
|
76
|
+
fail('approval-expired', 'The decision came after the request expired.');
|
|
77
|
+
if (now - decidedAt >= DAY_MS)
|
|
78
|
+
fail('approval-expired', 'The approval is older than 24 hours; ask your owner again.');
|
|
79
|
+
if (typeof request.peer !== 'string' || typeof request.threadId !== 'string' || typeof request.taskId !== 'string') {
|
|
80
|
+
fail('approval-mismatch', 'The request is missing peer, threadId or taskId.');
|
|
81
|
+
}
|
|
82
|
+
const shape = requestShapeProblem(request);
|
|
83
|
+
if (shape)
|
|
84
|
+
fail('approval-mismatch', `The request is not in the form \`courier request\` writes (${shape}); it cannot be approved. File a new \`courier request\`.`);
|
|
85
|
+
// The free text is only displayed, but the owner read it: whatever is present must be well-formed text, never empty.
|
|
86
|
+
for (const [name, max] of [['action', MAX_ACTION], ['reason', MAX_REASON], ['risk', MAX_RISK]]) {
|
|
87
|
+
if (name in request && plainTextProblem(request[name], max))
|
|
88
|
+
fail('approval-mismatch', `The request's ${name} is empty, too long or not plain text; it cannot be approved. File a new \`courier request\`.`);
|
|
89
|
+
}
|
|
90
|
+
const fields = storedRequestFields(request);
|
|
91
|
+
if (!fields) {
|
|
92
|
+
fail('approval-mismatch', 'The request has no valid structured verb, target and summary (an older request, or a hand-written one); it cannot be approved. File a new `courier request`.');
|
|
93
|
+
}
|
|
94
|
+
if (options.verb !== undefined && options.verb !== fields.verb) {
|
|
95
|
+
fail('approval-mismatch', `The approval is for "${fields.verb}", not "${options.verb}".`);
|
|
96
|
+
}
|
|
97
|
+
if (options.target !== undefined && options.target !== fields.target) {
|
|
98
|
+
fail('approval-mismatch', `The approval is for target ${JSON.stringify(fields.target)}, not ${JSON.stringify(options.target)}.`);
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
requestId: id,
|
|
102
|
+
requestHash: hash,
|
|
103
|
+
decision: approval.decision,
|
|
104
|
+
note: typeof approval.note === 'string' ? approval.note : '',
|
|
105
|
+
decidedAt: approval.decidedAt,
|
|
106
|
+
verb: fields.verb,
|
|
107
|
+
target: fields.target,
|
|
108
|
+
summary: fields.summary,
|
|
109
|
+
peer: request.peer,
|
|
110
|
+
threadId: request.threadId,
|
|
111
|
+
taskId: request.taskId,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* True if the approval was used (the marker under `me/state/approvals-used/`). Note the courier key can delete under
|
|
116
|
+
* `me/state/` (spec §3); a delete-proof record is being designed separately (G3), so this is the one place to change.
|
|
117
|
+
*/
|
|
118
|
+
export async function approvalIsUsed(kv, id) {
|
|
119
|
+
return Boolean(await getJson(kv, k.approvalUsed(id)));
|
|
120
|
+
}
|
|
121
|
+
/** Marks an approval used. The marker is create-only, so of two racing callers exactly one wins. */
|
|
122
|
+
export async function consumeApproval(kv, id, at = new Date()) {
|
|
123
|
+
try {
|
|
124
|
+
await putCreate(kv, k.approvalUsed(id), { requestId: id, usedAt: at.toISOString() });
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (error instanceof CourierError && error.code === 'conflict')
|
|
128
|
+
fail('approval-used', `Approval ${id} was already used; an approval covers one action.`);
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const SAFE = /^[0-9A-Za-z_-]{1,64}$/;
|
|
133
|
+
export const approvalUsage = () => `Usage: ${cmd('approval <id> --verb <v> --target <t> [--peek]')} (--verb and --target may be omitted with --peek)`;
|
|
134
|
+
/**
|
|
135
|
+
* `courier approval <id> --verb V --target T`: exit 0 and consume the approval only if the owner approved this exact
|
|
136
|
+
* request (its structured verb and target equal yours), within 24 hours, and nobody has used it. `--peek` verifies
|
|
137
|
+
* without consuming (for guards) and fails the same way for a used approval. A denial exits non-zero.
|
|
138
|
+
*/
|
|
139
|
+
export async function cmdApproval(args) {
|
|
140
|
+
const id = args.positional[0] ?? fail('usage', approvalUsage());
|
|
141
|
+
if (!SAFE.test(id))
|
|
142
|
+
fail('usage', `"${id}" is not a request id. ${approvalUsage()}`);
|
|
143
|
+
if (args.has('action')) {
|
|
144
|
+
fail('usage', `--action no longer exists: an approval is bound to what you are about to do by --verb and --target. ${approvalUsage()}\n${targetHelp()}`);
|
|
145
|
+
}
|
|
146
|
+
const flag = (name) => {
|
|
147
|
+
if (!args.has(name))
|
|
148
|
+
return undefined;
|
|
149
|
+
const value = args.get(name);
|
|
150
|
+
if (value === 'true' || value === '')
|
|
151
|
+
fail('usage', `--${name} needs a value.`);
|
|
152
|
+
return value;
|
|
153
|
+
};
|
|
154
|
+
const verb = flag('verb');
|
|
155
|
+
const rawTarget = flag('target');
|
|
156
|
+
if ((verb === undefined) !== (rawTarget === undefined))
|
|
157
|
+
fail('usage', `--verb and --target go together. ${approvalUsage()}`);
|
|
158
|
+
if (verb === undefined && !args.has('peek'))
|
|
159
|
+
fail('usage', `Say what you are about to do, so the approval is bound to it. ${approvalUsage()}\n${targetHelp()}`);
|
|
160
|
+
const target = verb !== undefined ? canonicalTarget(verb, rawTarget) : undefined;
|
|
161
|
+
const node = await courierNode(loadProfile());
|
|
162
|
+
const request = await getJson(node.kv, k.request(id));
|
|
163
|
+
if (!request)
|
|
164
|
+
fail('approval-mismatch', `No request ${id} in me/requests/.`);
|
|
165
|
+
const approval = await getJson(node.kv, k.approval(id));
|
|
166
|
+
if (!approval)
|
|
167
|
+
fail('approval-missing', `No approval for request ${id} yet. Your owner decides in the view or in your chat; stop and wait.`);
|
|
168
|
+
const verified = verifyApproval(id, request.value, approval.value, { verb, target });
|
|
169
|
+
if (verified.decision === 'denied') {
|
|
170
|
+
await writeOut(`${JSON.stringify({ ok: false, code: 'approval-denied', ...verified }, null, 2)}\n`);
|
|
171
|
+
throw new CourierError('approval-denied', `Your owner denied request ${id}${verified.note ? `: ${verified.note}` : ''}. Do not take the action.`);
|
|
172
|
+
}
|
|
173
|
+
if (await approvalIsUsed(node.kv, id)) {
|
|
174
|
+
await writeOut(`${JSON.stringify({ ok: false, code: 'approval-used', requestId: id }, null, 2)}\n`);
|
|
175
|
+
throw new CourierError('approval-used', `Approval ${id} was already used; an approval covers one action.`);
|
|
176
|
+
}
|
|
177
|
+
if (!args.has('peek'))
|
|
178
|
+
await consumeApproval(node.kv, id);
|
|
179
|
+
await writeOut(`${JSON.stringify({ ok: true, consumed: !args.has('peek'), ...verified }, null, 2)}\n`);
|
|
180
|
+
}
|
|
181
|
+
//# sourceMappingURL=approval.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"approval.js","sourceRoot":"","sources":["../src/approval.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Deepest nesting `canonicalJson` accepts: a top-level object or array is depth 1. Shared with the view. */
|
|
2
|
+
export declare const MAX_CANONICAL_DEPTH = 64;
|
|
3
|
+
/**
|
|
4
|
+
* Canonical JSON for hashing: object keys sorted recursively in UTF-16 code-unit order (integer-like keys too: `{"10":1,"9":2}`
|
|
5
|
+
* is `{"10":1,"9":2}`), no whitespace, arrays in order, values as `JSON.stringify` writes them. The `/courier` view mirrors this function: an
|
|
6
|
+
* approval's `requestHash` is `requestHash(<parsed me/requests/<id>.json>)`, so both sides must agree byte for byte.
|
|
7
|
+
*
|
|
8
|
+
* Throws `CourierError('canonical-invalid')` for a non-finite number (a parsed `1e999` is Infinity, which
|
|
9
|
+
* `JSON.stringify` would silently turn into `null`) and for nesting deeper than {@link MAX_CANONICAL_DEPTH}: a
|
|
10
|
+
* request like that is never written and never approved.
|
|
11
|
+
*/
|
|
12
|
+
export declare function canonicalJson(value: unknown): string;
|
|
13
|
+
/** Lowercase hex SHA-256 of the UTF-8 bytes of {@link canonicalJson}. Throws like it. */
|
|
14
|
+
export declare function requestHash(request: unknown): string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { CourierError } from './errors.js';
|
|
3
|
+
/** Deepest nesting `canonicalJson` accepts: a top-level object or array is depth 1. Shared with the view. */
|
|
4
|
+
export const MAX_CANONICAL_DEPTH = 64;
|
|
5
|
+
/**
|
|
6
|
+
* Canonical JSON for hashing: object keys sorted recursively in UTF-16 code-unit order (integer-like keys too: `{"10":1,"9":2}`
|
|
7
|
+
* is `{"10":1,"9":2}`), no whitespace, arrays in order, values as `JSON.stringify` writes them. The `/courier` view mirrors this function: an
|
|
8
|
+
* approval's `requestHash` is `requestHash(<parsed me/requests/<id>.json>)`, so both sides must agree byte for byte.
|
|
9
|
+
*
|
|
10
|
+
* Throws `CourierError('canonical-invalid')` for a non-finite number (a parsed `1e999` is Infinity, which
|
|
11
|
+
* `JSON.stringify` would silently turn into `null`) and for nesting deeper than {@link MAX_CANONICAL_DEPTH}: a
|
|
12
|
+
* request like that is never written and never approved.
|
|
13
|
+
*/
|
|
14
|
+
export function canonicalJson(value) {
|
|
15
|
+
return canonicalize(value, 1);
|
|
16
|
+
}
|
|
17
|
+
/** Built as a string from the sorted entries (never by rebuilding an object, whose integer-like keys JS would reorder numerically). */
|
|
18
|
+
function canonicalize(value, depth) {
|
|
19
|
+
if (typeof value === 'number') {
|
|
20
|
+
if (!Number.isFinite(value))
|
|
21
|
+
throw new CourierError('canonical-invalid', 'Not hashable: a number is not finite (for example 1e999).');
|
|
22
|
+
return JSON.stringify(value);
|
|
23
|
+
}
|
|
24
|
+
if (value === null || typeof value !== 'object')
|
|
25
|
+
return JSON.stringify(value) ?? 'null';
|
|
26
|
+
if (depth > MAX_CANONICAL_DEPTH)
|
|
27
|
+
throw new CourierError('canonical-invalid', `Not hashable: nested deeper than ${MAX_CANONICAL_DEPTH} levels.`);
|
|
28
|
+
if (Array.isArray(value))
|
|
29
|
+
return `[${value.map((item) => canonicalize(item, depth + 1)).join(',')}]`;
|
|
30
|
+
const source = value;
|
|
31
|
+
// Default sort order is UTF-16 code-unit order, as specified. A parsed "__proto__" key is an own key and is kept.
|
|
32
|
+
const entries = Object.keys(source).sort().flatMap((key) => (source[key] === undefined ? [] : [`${JSON.stringify(key)}:${canonicalize(source[key], depth + 1)}`]));
|
|
33
|
+
return `{${entries.join(',')}}`;
|
|
34
|
+
}
|
|
35
|
+
/** Lowercase hex SHA-256 of the UTF-8 bytes of {@link canonicalJson}. Throws like it. */
|
|
36
|
+
export function requestHash(request) {
|
|
37
|
+
return createHash('sha256').update(canonicalJson(request), 'utf8').digest('hex');
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=canonical.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canonical.js","sourceRoot":"","sources":["../src/canonical.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,6GAA6G;AAC7G,MAAM,CAAC,MAAM,mBAAmB,GAAG,EAAE,CAAC;AAEtC;;;;;;;;GAQG;AACH,MAAM,UAAU,aAAa,CAAC,KAAc;IAC1C,OAAO,YAAY,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;AAChC,CAAC;AAED,uIAAuI;AACvI,SAAS,YAAY,CAAC,KAAc,EAAE,KAAa;IACjD,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC9B,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC;YAAE,MAAM,IAAI,YAAY,CAAC,mBAAmB,EAAE,2DAA2D,CAAC,CAAC;QACtI,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;IAC/B,CAAC;IACD,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,MAAM,CAAC;IACxF,IAAI,KAAK,GAAG,mBAAmB;QAAE,MAAM,IAAI,YAAY,CAAC,mBAAmB,EAAE,oCAAoC,mBAAmB,UAAU,CAAC,CAAC;IAChJ,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC;IACrG,MAAM,MAAM,GAAG,KAAgC,CAAC;IAChD,kHAAkH;IAClH,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,YAAY,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,KAAK,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IACnK,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC;AAClC,CAAC;AAED,yFAAyF;AACzF,MAAM,UAAU,WAAW,CAAC,OAAgB;IAC1C,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AACnF,CAAC"}
|
package/dist/commands.d.ts
CHANGED
|
@@ -17,4 +17,6 @@ export declare function cmdRead(args: ParsedArgs): Promise<void>;
|
|
|
17
17
|
export declare function renderThreadText(threadId: string, entries: readonly ThreadEntry[]): string;
|
|
18
18
|
export declare function cmdTask(args: ParsedArgs): Promise<void>;
|
|
19
19
|
export declare function cmdRequest(args: ParsedArgs): Promise<void>;
|
|
20
|
+
/** `courier request --help`: the usage line and every target form. */
|
|
21
|
+
export declare const requestHelp: () => string;
|
|
20
22
|
export declare function cmdOrders(args: ParsedArgs): Promise<void>;
|
package/dist/commands.js
CHANGED
|
@@ -14,6 +14,9 @@ import { authRequiredTask, buildMessage, buildTask, COUNTERPARTY_NOTICE, isRecor
|
|
|
14
14
|
import { commitReceipts, collectInbound, renderInboxText, writeOut } from './inbox.js';
|
|
15
15
|
import { getJson, listAll, putCreate, putMatch, del } from './store.js';
|
|
16
16
|
import { DEFAULT_ORDERS } from './orders.js';
|
|
17
|
+
import { requestFields, requestIds, requestShapeProblem, requestText } from './request.js';
|
|
18
|
+
import { requestHash } from './canonical.js';
|
|
19
|
+
import { targetHelp } from './guard/target.js';
|
|
17
20
|
import { requireFlag, oneOf } from './args.js';
|
|
18
21
|
import { assertPeerDelegation, bareDid, durationMs, AGENT_GRANT_MAX_MS } from './authority.js';
|
|
19
22
|
async function ctx() {
|
|
@@ -597,21 +600,29 @@ export async function cmdRequest(args) {
|
|
|
597
600
|
const { profile, node } = await ctx();
|
|
598
601
|
const threadId = args.positional[0] ?? fail('usage', requestUsage());
|
|
599
602
|
const taskId = requireFlag(args, 'task', requestUsage());
|
|
600
|
-
const action = requireFlag(args, 'action', requestUsage());
|
|
601
603
|
const why = requireFlag(args, 'why', requestUsage());
|
|
602
604
|
const peerHandle = requireFlag(args, 'peer', requestUsage());
|
|
603
605
|
getPeer(profile, peerHandle);
|
|
606
|
+
const flag = (name) => (args.get(name) === 'true' ? undefined : args.get(name));
|
|
607
|
+
requestIds({ threadId, taskId });
|
|
608
|
+
const fields = requestFields({ verb: flag('verb'), target: flag('target'), summary: flag('summary') });
|
|
604
609
|
const risk = args.get('risk');
|
|
605
610
|
const id = ulid();
|
|
606
611
|
const request = {
|
|
607
|
-
id, peer: peerHandle, threadId, taskId,
|
|
608
|
-
|
|
612
|
+
id, peer: peerHandle, threadId, taskId, ...fields,
|
|
613
|
+
// Free text for the owner to read; nothing matches against it (the structured fields above do that).
|
|
614
|
+
...requestText({ action: flag('action'), reason: why, risk }, fields.summary),
|
|
609
615
|
requestedBy: { owner: profile.ownerDid, agent: profile.courierDid },
|
|
610
616
|
createdAt: new Date().toISOString(),
|
|
611
617
|
expiresAt: new Date(Date.now() + 24 * 3600 * 1000).toISOString(),
|
|
612
618
|
status: 'pending',
|
|
613
619
|
};
|
|
614
|
-
|
|
620
|
+
const hash = requestHash(request); // refuses (throws) a request that could never be hashed or approved, before anything is written
|
|
621
|
+
const problem = requestShapeProblem(request);
|
|
622
|
+
if (problem)
|
|
623
|
+
fail('usage', `The request is malformed: ${problem}.`);
|
|
624
|
+
// The task comes first: the view names <peer> on the card only if `to/<peer>/threads/<thread>/task/<task>.json`
|
|
625
|
+
// exists in this space, so the request is not written unless that file does (created here if the agent has not yet).
|
|
615
626
|
const taskKey = k.task(peerHandle, threadId, taskId);
|
|
616
627
|
const existing = await getJson(node.kv, taskKey);
|
|
617
628
|
const task = authRequiredTask(existing?.value, threadId, taskId);
|
|
@@ -619,9 +630,15 @@ export async function cmdRequest(args) {
|
|
|
619
630
|
await putMatch(node.kv, taskKey, task, existing.etag);
|
|
620
631
|
else
|
|
621
632
|
await putCreate(node.kv, taskKey, task);
|
|
622
|
-
|
|
633
|
+
if (!(await getJson(node.kv, taskKey)))
|
|
634
|
+
fail('task-missing', `The task ${taskId} is not in ${taskKey}; no request was written.`);
|
|
635
|
+
await putCreate(node.kv, k.request(id), request);
|
|
636
|
+
// `requestHash` is what the view's card shows as its fingerprint (first 8 hex digits) and `courier approval` prints in full.
|
|
637
|
+
printJson({ ok: true, requestId: id, requestHash: hash, taskId, state: 'TASK_STATE_AUTH_REQUIRED' });
|
|
623
638
|
}
|
|
624
|
-
const requestUsage = () => `Usage: ${cmd('request <threadId> --peer <p> --task <id> --
|
|
639
|
+
const requestUsage = () => `Usage: ${cmd('request <threadId> --peer <p> --task <id> --verb merge|deploy|publish|spend|other --target <what> --summary "…" --why "…" [--risk "…"] [--action "…"]')}\n${targetHelp()}`;
|
|
640
|
+
/** `courier request --help`: the usage line and every target form. */
|
|
641
|
+
export const requestHelp = () => `${requestUsage()}\n`;
|
|
625
642
|
// ---------- orders ----------
|
|
626
643
|
export async function cmdOrders(args) {
|
|
627
644
|
const { profile, node } = await ctx();
|