@tinycloud/courier 0.2.0-beta.33 → 0.2.0-beta.35

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 --head-sha <40-lowercase-hex> --base master --summary "Merge PR 12" --why "…"
53
53
  tc courier orders bob
54
54
  ```
55
55
 
@@ -165,9 +165,146 @@ 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
+ For `merge` only, the request also requires `headSha` and `base`, supplied as
195
+ `--head-sha <sha>` and `--base <branch>`. Both are inside the hashed request and
196
+ belong on the owner's approval card. Read the PR **at request time** with
197
+ `gh pr view N --repo owner/repo --json headRefOid,baseRefName` (read-only), then
198
+ pass `headRefOid` as `--head-sha` and `baseRefName` as `--base`. `headSha` must
199
+ be exactly 40 lowercase hex characters. `base` preserves case: branch names
200
+ are case-sensitive, with no trimming or lowercasing. It must be 1–100 ASCII
201
+ characters from `[A-Za-z0-9._/-]`, with no leading `-`, dot-leading or empty
202
+ path component, `..`, `.lock` component suffix, trailing dot or reserved `HEAD`.
203
+ This excludes spaces, controls, Unicode, backslash, `@{`, leading/trailing `/`
204
+ and `//`. Other verbs must not carry either field, on write or read.
205
+
206
+ An old merge request without either pin is refused with
207
+ `merge request lacks headSha/base: re-request`; file a new request and obtain
208
+ a new approval. The broker (separate PR) will merge only the approved commit
209
+ using `--match-head-commit=<approved sha>` and refuse a different base. A push
210
+ or retarget after approval therefore needs a new request and approval.
211
+
212
+ One rule set applies when `courier request` writes, when `courier approval`
213
+ reads, and when the view shows the card. A value that breaks it is **refused,
214
+ never repaired or shortened**:
215
+
216
+ - text must already be Unicode NFC; it is never normalized or repaired. Non-ASCII
217
+ characters are allowed only from the frozen **Unicode 15.0.0** categories
218
+ Lu, Ll, Lt, Lo and Nd, excluding Default_Ignorable. Modifier letters (Lm),
219
+ other numbers (Nl/No), all combining marks, punctuation, symbols, controls,
220
+ invisible characters, fillers and unassigned/newer characters are refused;
221
+ - U+0020 is the only space. `summary` additionally allows exactly these ASCII
222
+ punctuation characters: `. , : ; - _ / # ( ) ? !` and apostrophe U+0027.
223
+ Non-ASCII apostrophes, quotes and brackets are refused. `action`, `reason`,
224
+ `risk` and approval notes allow all printable ASCII punctuation and LF (`\n`),
225
+ never a tab or CR;
226
+ - `target`, `summary`: 1 to 200 UTF-16 units, trimmed, one line; `action`,
227
+ `reason`, `risk`: 1 to 2000, a single `\n` allowed, never a tab or CR;
228
+ - `target` is printable ASCII (`[\x20-\x7E]`) for every verb, in the one
229
+ canonical spelling for its verb (`courier request --help` prints these forms):
230
+
231
+ | verb | canonical target |
232
+ |---|---|
233
+ | `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) |
234
+ | `publish` | `npm:<name>@<version>`, `release:owner/repo@<tag>`, `changeset:<root package name>` or `script:<package>:<script>` |
235
+ | `deploy` | `wrangler:<worker>[:<env>]`, `pages:<project>`, `phala:<name>` or `script:<package>:<script>` |
236
+ | `other` | `workflow:owner/repo:<workflow file or name>`, `secrets:<subcommand and key>`, or `<kind>:<text>` |
237
+ | `spend` | `spend:<what and how much>` |
238
+
239
+ The CLI and view share `src/text-ranges.ts` and
240
+ `test/vectors/text-verdicts.json`; decisions use only that frozen table and an
241
+ NFC check, never runtime Unicode property tables. The version is pinned to
242
+ Unicode 15.0.0 (Node 20.0.0). The vector ranges cover all 1,114,112 code points
243
+ for `summary`, `multiline` and `target`, plus full-string verdict/reason samples.
244
+ `textVerdict(value: unknown, field: 'summary' | 'multiline' | 'target')` returns
245
+ `{ allowed: boolean, reason: null | 'not-string' | 'empty' | 'too-long' |
246
+ 'edge-whitespace' | 'not-nfc' | 'disallowed-code-point' }`. Limits/trim rules are
247
+ included; `multiline` is the action/reason/risk/note policy (an optional empty
248
+ approval note is handled by the caller). Target shapes remain verb-specific.
249
+ The pinned UCD source hashes and regeneration instructions are in
250
+ `scripts/generate-text-table.mjs`. The independent test walks every code point
251
+ using runtime Unicode categories: it fails if the table permits more, and
252
+ reports newer runtime additions without permitting them.
253
+
254
+ - `threadId`, `taskId` and the request id are ULIDs (26 upper-case Crockford
255
+ base32 characters, no I L O U), and the peer handle is 1 to 64 of
256
+ `[A-Za-z0-9_-]`, on write and on read (the view only shows cards for ULIDs);
257
+ - the key set of `me/requests/<id>.json` is closed: `id`, `peer`, `threadId`,
258
+ `taskId`, `verb`, `target`, `summary`, `requestedBy` (`{owner, agent}`),
259
+ `createdAt`, `expiresAt`, `status` (`"pending"`) and the optional free text
260
+ `action`, `reason`, `risk` (written by the CLI, well-formed when present).
261
+ For `merge` only, `headSha` and `base` are also required keys.
262
+ Anything else makes the request unreviewable in the view and refused by
263
+ `courier approval`, even if the hash matches;
264
+ - the whole request must hash: `canonicalJson` / `requestHash` throw on a
265
+ non-finite number (a parsed `1e999`) and on nesting deeper than 64 levels
266
+ (top-level object = depth 1), so such a request is never written and
267
+ `courier approval` fails closed on it.
268
+
269
+ `test/vectors/request-fields.json` is the contract: every row says whether a
270
+ value is accepted, and the canonical-JSON rows give the expected output (or
271
+ "must throw"). Copy it into any other implementation.
272
+
273
+ `courier approval <requestId> --verb <v> --target <t> [--head-sha <sha>] [--base <branch>] [--peek]` is what a
274
+ woken agent runs before a gated action. It reads `me/requests/<id>.json` and
275
+ `me/approvals/<id>.json` and exits 0 only if the request has valid structured
276
+ fields, the approval names that request (`requestId` equals the id), carries
277
+ `requestHash` equal to the SHA-256 of the request's canonical JSON
278
+ (`canonicalJson` / `requestHash`, exported, mirrored by the view; a parsed
279
+ `"__proto__"` key counts as an ordinary key), the request contains no hidden
280
+ text anywhere (rules above, applied to every string and key), says `approved`, was decided in
281
+ `[createdAt, expiresAt)` of the request (a decision dated up to 5 minutes ahead
282
+ of this machine's clock is tolerated; keep the clocks of the agent's machine
283
+ and the owner's browser in step) and within the last 24 hours, and the given
284
+ `--verb` and (canonicalised) `--target` equal the request's. Both are required
285
+ when consuming; `--peek` (for guards) may omit them.
286
+ For a merge, valid stored `headSha` and `base` are always required. Optional
287
+ `--head-sha` and `--base` each require an exact match, including branch case;
288
+ they cannot match a non-merge request.
289
+
290
+ `courier request` and `courier approval` (peek and consume) print the full
291
+ `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.
292
+ Merge approval output (peek and consume) also prints `headSha` and `base`
293
+ next to `requestHash`.
294
+
295
+ It then consumes the approval with a create-only marker at
296
+ `me/state/approvals-used/<id>.json`, so a second run exits `approval-used`;
297
+ `--peek` checks the marker too (a used approval fails the peek) but does not
298
+ write it. A denial exits `approval-denied`. The courier key cannot write
299
+ `me/approvals/`, so only the owner's view session can produce a passing
300
+ approval. Residual risk: the courier grant can delete under `me/state/`
301
+ (spec §3), so a compromised agent could delete the used marker and replay an
302
+ approval within its 24 hours; a delete-proof record is tracked separately.
303
+
304
+ The watcher wakes the thread's agent as soon as a new approval (approved or
305
+ denied) for one of its requests appears, without the debounce. Approvals older
306
+ than 24 hours, already used, malformed, or without a request at start-up wake
307
+ nobody.
171
308
 
172
309
  ### Running it as a service
173
310
 
@@ -0,0 +1,53 @@
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
+ /** Merge-only pins from the hashed request. Branch names preserve case. */
23
+ headSha?: string;
24
+ base?: string;
25
+ peer: string;
26
+ threadId: string;
27
+ taskId: string;
28
+ }
29
+ /**
30
+ * Spec §7: an approval covers one request and expires after 24 hours. Throws a CourierError with a stable code
31
+ * unless `approval` is a current, matching decision on `request` (denials are reported, not thrown).
32
+ */
33
+ export declare function verifyApproval(id: string, request: unknown, approval: unknown, options?: {
34
+ now?: number;
35
+ verb?: string;
36
+ target?: string;
37
+ headSha?: string;
38
+ base?: string;
39
+ }): VerifiedApproval;
40
+ /**
41
+ * True if the approval was used (the marker under `me/state/approvals-used/`). Note the courier key can delete under
42
+ * `me/state/` (spec §3); a delete-proof record is being designed separately (G3), so this is the one place to change.
43
+ */
44
+ export declare function approvalIsUsed(kv: KV, id: string): Promise<boolean>;
45
+ /** Marks an approval used. The marker is create-only, so of two racing callers exactly one wins. */
46
+ export declare function consumeApproval(kv: KV, id: string, at?: Date): Promise<void>;
47
+ export declare const approvalUsage: () => string;
48
+ /**
49
+ * `courier approval <id> --verb V --target T`: exit 0 and consume the approval only if the owner approved this exact
50
+ * request (its structured verb and target equal yours), within 24 hours, and nobody has used it. `--peek` verifies
51
+ * without consuming (for guards) and fails the same way for a used approval. A denial exits non-zero.
52
+ */
53
+ export declare function cmdApproval(args: ParsedArgs): Promise<void>;
@@ -0,0 +1,189 @@
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 invalid structured fields (verb, target, summary or merge headSha/base); 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
+ for (const key of ['headSha', 'base']) {
101
+ if (options[key] !== undefined && options[key] !== fields[key]) {
102
+ fail('approval-mismatch', `The approval's ${key} does not match the supplied ${key}.`);
103
+ }
104
+ }
105
+ return {
106
+ requestId: id,
107
+ requestHash: hash,
108
+ ...(fields.verb === 'merge' ? { headSha: fields.headSha, base: fields.base } : {}),
109
+ decision: approval.decision,
110
+ note: typeof approval.note === 'string' ? approval.note : '',
111
+ decidedAt: approval.decidedAt,
112
+ verb: fields.verb,
113
+ target: fields.target,
114
+ summary: fields.summary,
115
+ peer: request.peer,
116
+ threadId: request.threadId,
117
+ taskId: request.taskId,
118
+ };
119
+ }
120
+ /**
121
+ * True if the approval was used (the marker under `me/state/approvals-used/`). Note the courier key can delete under
122
+ * `me/state/` (spec §3); a delete-proof record is being designed separately (G3), so this is the one place to change.
123
+ */
124
+ export async function approvalIsUsed(kv, id) {
125
+ return Boolean(await getJson(kv, k.approvalUsed(id)));
126
+ }
127
+ /** Marks an approval used. The marker is create-only, so of two racing callers exactly one wins. */
128
+ export async function consumeApproval(kv, id, at = new Date()) {
129
+ try {
130
+ await putCreate(kv, k.approvalUsed(id), { requestId: id, usedAt: at.toISOString() });
131
+ }
132
+ catch (error) {
133
+ if (error instanceof CourierError && error.code === 'conflict')
134
+ fail('approval-used', `Approval ${id} was already used; an approval covers one action.`);
135
+ throw error;
136
+ }
137
+ }
138
+ const SAFE = /^[0-9A-Za-z_-]{1,64}$/;
139
+ export const approvalUsage = () => `Usage: ${cmd('approval <id> --verb <v> --target <t> [--head-sha <sha>] [--base <branch>] [--peek]')} (--verb and --target may be omitted with --peek)`;
140
+ /**
141
+ * `courier approval <id> --verb V --target T`: exit 0 and consume the approval only if the owner approved this exact
142
+ * request (its structured verb and target equal yours), within 24 hours, and nobody has used it. `--peek` verifies
143
+ * without consuming (for guards) and fails the same way for a used approval. A denial exits non-zero.
144
+ */
145
+ export async function cmdApproval(args) {
146
+ const id = args.positional[0] ?? fail('usage', approvalUsage());
147
+ if (!SAFE.test(id))
148
+ fail('usage', `"${id}" is not a request id. ${approvalUsage()}`);
149
+ if (args.has('action')) {
150
+ fail('usage', `--action no longer exists: an approval is bound to what you are about to do by --verb and --target. ${approvalUsage()}\n${targetHelp()}`);
151
+ }
152
+ const flag = (name) => {
153
+ if (!args.has(name))
154
+ return undefined;
155
+ const value = args.get(name);
156
+ if (value === 'true' || value === '')
157
+ fail('usage', `--${name} needs a value.`);
158
+ return value;
159
+ };
160
+ const verb = flag('verb');
161
+ const rawTarget = flag('target');
162
+ const headSha = flag('head-sha');
163
+ const base = flag('base');
164
+ if ((verb === undefined) !== (rawTarget === undefined))
165
+ fail('usage', `--verb and --target go together. ${approvalUsage()}`);
166
+ if (verb === undefined && !args.has('peek'))
167
+ fail('usage', `Say what you are about to do, so the approval is bound to it. ${approvalUsage()}\n${targetHelp()}`);
168
+ const target = verb !== undefined ? canonicalTarget(verb, rawTarget) : undefined;
169
+ const node = await courierNode(loadProfile());
170
+ const request = await getJson(node.kv, k.request(id));
171
+ if (!request)
172
+ fail('approval-mismatch', `No request ${id} in me/requests/.`);
173
+ const approval = await getJson(node.kv, k.approval(id));
174
+ if (!approval)
175
+ fail('approval-missing', `No approval for request ${id} yet. Your owner decides in the view or in your chat; stop and wait.`);
176
+ const verified = verifyApproval(id, request.value, approval.value, { verb, target, headSha, base });
177
+ if (verified.decision === 'denied') {
178
+ await writeOut(`${JSON.stringify({ ok: false, code: 'approval-denied', ...verified }, null, 2)}\n`);
179
+ throw new CourierError('approval-denied', `Your owner denied request ${id}${verified.note ? `: ${verified.note}` : ''}. Do not take the action.`);
180
+ }
181
+ if (await approvalIsUsed(node.kv, id)) {
182
+ await writeOut(`${JSON.stringify({ ok: false, code: 'approval-used', requestId: id }, null, 2)}\n`);
183
+ throw new CourierError('approval-used', `Approval ${id} was already used; an approval covers one action.`);
184
+ }
185
+ if (!args.has('peek'))
186
+ await consumeApproval(node.kv, id);
187
+ await writeOut(`${JSON.stringify({ ok: true, consumed: !args.has('peek'), ...verified }, null, 2)}\n`);
188
+ }
189
+ //# 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() {
@@ -594,24 +597,33 @@ export async function cmdTask(args) {
594
597
  const taskUsage = () => `Usage: ${cmd('task <taskId|new> --peer <p> --thread <id> --state <s> [--note …] [--artifact f]… [--ask a1]')}`;
595
598
  // ---------- request ----------
596
599
  export async function cmdRequest(args) {
597
- const { profile, node } = await ctx();
598
600
  const threadId = args.positional[0] ?? fail('usage', requestUsage());
599
601
  const taskId = requireFlag(args, 'task', requestUsage());
600
- const action = requireFlag(args, 'action', requestUsage());
601
602
  const why = requireFlag(args, 'why', requestUsage());
602
603
  const peerHandle = requireFlag(args, 'peer', requestUsage());
604
+ const flag = (name) => (args.get(name) === 'true' ? undefined : args.get(name));
605
+ const pinFlag = (name) => args.has(name) ? requireFlag(args, name, requestUsage()) : undefined;
606
+ requestIds({ threadId, taskId });
607
+ const fields = requestFields({ verb: flag('verb'), target: flag('target'), summary: flag('summary'), headSha: pinFlag('head-sha'), base: pinFlag('base') });
608
+ const { profile, node } = await ctx();
603
609
  getPeer(profile, peerHandle);
604
610
  const risk = args.get('risk');
605
611
  const id = ulid();
606
612
  const request = {
607
- id, peer: peerHandle, threadId, taskId, action, reason: why,
608
- risk: risk ?? 'unspecified',
613
+ id, peer: peerHandle, threadId, taskId, ...fields,
614
+ // Free text for the owner to read; nothing matches against it (the structured fields above do that).
615
+ ...requestText({ action: flag('action'), reason: why, risk }, fields.summary),
609
616
  requestedBy: { owner: profile.ownerDid, agent: profile.courierDid },
610
617
  createdAt: new Date().toISOString(),
611
618
  expiresAt: new Date(Date.now() + 24 * 3600 * 1000).toISOString(),
612
619
  status: 'pending',
613
620
  };
614
- await putCreate(node.kv, k.request(id), request);
621
+ const hash = requestHash(request); // refuses (throws) a request that could never be hashed or approved, before anything is written
622
+ const problem = requestShapeProblem(request);
623
+ if (problem)
624
+ fail('usage', `The request is malformed: ${problem}.`);
625
+ // The task comes first: the view names <peer> on the card only if `to/<peer>/threads/<thread>/task/<task>.json`
626
+ // exists in this space, so the request is not written unless that file does (created here if the agent has not yet).
615
627
  const taskKey = k.task(peerHandle, threadId, taskId);
616
628
  const existing = await getJson(node.kv, taskKey);
617
629
  const task = authRequiredTask(existing?.value, threadId, taskId);
@@ -619,9 +631,15 @@ export async function cmdRequest(args) {
619
631
  await putMatch(node.kv, taskKey, task, existing.etag);
620
632
  else
621
633
  await putCreate(node.kv, taskKey, task);
622
- printJson({ ok: true, requestId: id, taskId, state: 'TASK_STATE_AUTH_REQUIRED' });
634
+ if (!(await getJson(node.kv, taskKey)))
635
+ fail('task-missing', `The task ${taskId} is not in ${taskKey}; no request was written.`);
636
+ await putCreate(node.kv, k.request(id), request);
637
+ // `requestHash` is what the view's card shows as its fingerprint (first 8 hex digits) and `courier approval` prints in full.
638
+ printJson({ ok: true, requestId: id, requestHash: hash, taskId, state: 'TASK_STATE_AUTH_REQUIRED' });
623
639
  }
624
- const requestUsage = () => `Usage: ${cmd('request <threadId> --peer <p> --task <id> --action "…" --why "…" [--risk "…"]')}`;
640
+ const requestUsage = () => `Usage: ${cmd('request <threadId> --peer <p> --task <id> --verb merge|deploy|publish|spend|other --target <what> --summary "…" --why "…" [--risk "…"] [--action "…"]')}\n${targetHelp()}\nMerge requires --head-sha <40 lowercase hex> and --base <branch> (refused for other verbs).\nRead the PR at request time: gh pr view N --repo owner/repo --json headRefOid,baseRefName\nPass headRefOid as --head-sha and baseRefName as --base; branch case is preserved.\nThe broker (separate PR) will merge only the approved commit and refuse a different base.`;
641
+ /** `courier request --help`: the usage line and every target form. */
642
+ export const requestHelp = () => `${requestUsage()}\n`;
625
643
  // ---------- orders ----------
626
644
  export async function cmdOrders(args) {
627
645
  const { profile, node } = await ctx();