@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 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> --action "…" --why "…"
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 command that exits normally but leaves a background job running is not
169
- waited for, and the job is left alone. When the watcher stops (SIGTERM, a state
170
- conflict) it kills the runs still in flight.
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>;
@@ -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"}
@@ -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, action, reason: why,
608
- risk: risk ?? 'unspecified',
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
- await putCreate(node.kv, k.request(id), request);
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
- printJson({ ok: true, requestId: id, taskId, state: 'TASK_STATE_AUTH_REQUIRED' });
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> --action "…" --why "…" [--risk "…"]')}`;
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();