@tinycloud/courier 0.0.0-stage → 0.1.0-beta.1
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 +135 -2
- package/dist/a2a.d.ts +134 -0
- package/dist/a2a.js +213 -0
- package/dist/a2a.js.map +1 -0
- package/dist/args.d.ts +11 -0
- package/dist/args.js +53 -0
- package/dist/args.js.map +1 -0
- package/dist/authority.d.ts +33 -0
- package/dist/authority.js +99 -0
- package/dist/authority.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +6 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands.d.ts +20 -0
- package/dist/commands.js +638 -0
- package/dist/commands.js.map +1 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.js +35 -0
- package/dist/errors.js.map +1 -0
- package/dist/grant.d.ts +55 -0
- package/dist/grant.js +356 -0
- package/dist/grant.js.map +1 -0
- package/dist/inbox.d.ts +53 -0
- package/dist/inbox.js +120 -0
- package/dist/inbox.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +33 -0
- package/dist/keys.js +57 -0
- package/dist/keys.js.map +1 -0
- package/dist/main.d.ts +9 -0
- package/dist/main.js +90 -0
- package/dist/main.js.map +1 -0
- package/dist/node.d.ts +12 -0
- package/dist/node.js +85 -0
- package/dist/node.js.map +1 -0
- package/dist/orders.d.ts +2 -0
- package/dist/orders.js +26 -0
- package/dist/orders.js.map +1 -0
- package/dist/pairing.d.ts +60 -0
- package/dist/pairing.js +88 -0
- package/dist/pairing.js.map +1 -0
- package/dist/profile.d.ts +67 -0
- package/dist/profile.js +104 -0
- package/dist/profile.js.map +1 -0
- package/dist/program.d.ts +4 -0
- package/dist/program.js +13 -0
- package/dist/program.js.map +1 -0
- package/dist/store.d.ts +14 -0
- package/dist/store.js +97 -0
- package/dist/store.js.map +1 -0
- package/package.json +47 -4
- package/skills/courier/SKILL.md +164 -0
- package/skills/courier/release.json +16 -0
package/dist/commands.js
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { ulid } from 'ulid';
|
|
5
|
+
import { NodeWasmBindings, activateValidatedRuntimeDelegation, deserializeDelegation, serializeDelegation } from '@tinycloud/node-sdk';
|
|
6
|
+
import { cmd } from './program.js';
|
|
7
|
+
import { CourierError, errInfo } from './errors.js';
|
|
8
|
+
import { AGENT_GRANT, AGENT_GRANT_EXPIRY, COURIER_SPACE, DEFAULT_HOST, EXT, k, peerReadPermissions, PREFIX } from './keys.js';
|
|
9
|
+
import { loadProfile, saveProfile, getPeer, profileDir, profileExists, loadKey, saveKey, } from './profile.js';
|
|
10
|
+
import { courierNode, peerKv } from './node.js';
|
|
11
|
+
import { courierGrantRequest, grantFromInput, installGrant, openKeyDelegateUrl, receiveGrant, resumeGrant } from './grant.js';
|
|
12
|
+
import { artifactA, artifactWithDelegation, parseArtifact, peerRecordBody, PEER_DELEGATION_EXPIRY, validateArtifactIdentity, } from './pairing.js';
|
|
13
|
+
import { authRequiredTask, buildMessage, buildTask, COUNTERPARTY_NOTICE, isRecord, messageView, oneLine, safeText, messageRole, parseTaskState, taskView, INTENTS, } from './a2a.js';
|
|
14
|
+
import { commitReceipts, collectInbound, renderInboxText, writeOut } from './inbox.js';
|
|
15
|
+
import { getJson, listAll, putCreate, putMatch, del } from './store.js';
|
|
16
|
+
import { DEFAULT_ORDERS } from './orders.js';
|
|
17
|
+
import { requireFlag, oneOf } from './args.js';
|
|
18
|
+
import { assertPeerDelegation, bareDid, durationMs, AGENT_GRANT_MAX_MS } from './authority.js';
|
|
19
|
+
async function ctx() {
|
|
20
|
+
const profile = loadProfile();
|
|
21
|
+
const node = await courierNode(profile);
|
|
22
|
+
return { profile, node };
|
|
23
|
+
}
|
|
24
|
+
function newAgentKey() {
|
|
25
|
+
const manager = new NodeWasmBindings().createSessionManager();
|
|
26
|
+
const id = manager.createSessionKey('courier');
|
|
27
|
+
const jwk = manager.jwk(id);
|
|
28
|
+
if (!jwk)
|
|
29
|
+
throw new CourierError('key-generation', 'SDK failed to generate the Courier agent key.');
|
|
30
|
+
return { v: 1, jwk: JSON.parse(jwk), did: manager.getDID(id), createdAt: new Date().toISOString() };
|
|
31
|
+
}
|
|
32
|
+
function printJson(v) {
|
|
33
|
+
process.stdout.write(JSON.stringify(v, null, 2) + '\n');
|
|
34
|
+
}
|
|
35
|
+
function fail(code, message) {
|
|
36
|
+
throw new CourierError(code, message);
|
|
37
|
+
}
|
|
38
|
+
// ---------- init ----------
|
|
39
|
+
/** A value flag's value, or undefined when absent. parseArgs records a value-less flag as 'true'. */
|
|
40
|
+
function initValue(args, name) {
|
|
41
|
+
if (!args.has(name))
|
|
42
|
+
return undefined;
|
|
43
|
+
const value = args.get(name);
|
|
44
|
+
if (value === 'true' || value === '')
|
|
45
|
+
fail('usage', `--${name} needs a value.`);
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
function initUrl(args, name) {
|
|
49
|
+
if (!args.has(name))
|
|
50
|
+
return undefined;
|
|
51
|
+
const value = args.get(name);
|
|
52
|
+
let url;
|
|
53
|
+
try {
|
|
54
|
+
url = new URL(value);
|
|
55
|
+
}
|
|
56
|
+
catch { /* reported below */ }
|
|
57
|
+
if (!url || (url.protocol !== 'https:' && url.protocol !== 'http:'))
|
|
58
|
+
fail('usage', `--${name} needs a URL.`);
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
export async function cmdInit(args) {
|
|
62
|
+
if (args.has('key') || args.has('key-file') || args.has('force')) {
|
|
63
|
+
fail('usage', `Owner private-key flags are not supported. Use \`${cmd('init --request')}\` and approve the restricted grant in OpenKey.`);
|
|
64
|
+
}
|
|
65
|
+
const hostFlag = initUrl(args, 'host');
|
|
66
|
+
const openkeyHostFlag = initUrl(args, 'openkey-host');
|
|
67
|
+
const apiHostFlag = initUrl(args, 'openkey-api-host');
|
|
68
|
+
const labelFlag = initValue(args, 'label');
|
|
69
|
+
const handleFlag = initValue(args, 'handle');
|
|
70
|
+
const expiry = args.get('expiry') ?? AGENT_GRANT_EXPIRY;
|
|
71
|
+
const expiryMs = durationMs(expiry);
|
|
72
|
+
if (!expiryMs || expiryMs > AGENT_GRANT_MAX_MS)
|
|
73
|
+
fail('usage', `--expiry must be a duration such as 7d, at most ${AGENT_GRANT_EXPIRY}.`);
|
|
74
|
+
if (args.has('resume') && (args.has('request') || args.has('grant-file') || args.has('paste') || args.has('rotate-key'))) {
|
|
75
|
+
fail('usage', `\`${cmd('init --resume')}\` cannot be combined with --request, --grant-file, --paste or --rotate-key.`);
|
|
76
|
+
}
|
|
77
|
+
const grantFile = args.has('grant-file')
|
|
78
|
+
? requireFlag(args, 'grant-file', `Usage: ${cmd('init [--request | --grant-file <file|->]')}`)
|
|
79
|
+
: undefined;
|
|
80
|
+
// Read the grant before touching the profile, so a bad path or empty stdin cannot cost the key.
|
|
81
|
+
// --request returns before the grant is used, so never block on stdin for it.
|
|
82
|
+
const grantRaw = grantFile === undefined || args.has('request')
|
|
83
|
+
? undefined
|
|
84
|
+
: grantFile === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(grantFile, 'utf8');
|
|
85
|
+
if (args.has('resume')) {
|
|
86
|
+
if (!profileExists())
|
|
87
|
+
fail('no-grant', `No Courier profile to resume. Run \`${cmd('init')}\` and approve a grant first.`);
|
|
88
|
+
const profile = loadProfile();
|
|
89
|
+
if (profile.status !== 'awaiting-space')
|
|
90
|
+
fail('usage', `\`${cmd('init --resume')}\` requires a profile in awaiting-space state.`);
|
|
91
|
+
await resumeGrant(profile, loadKey());
|
|
92
|
+
await finishInit(profile, profile.host);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const rotate = args.has('rotate-key');
|
|
96
|
+
const prior = profileExists() && !rotate ? loadProfile() : undefined;
|
|
97
|
+
const key = prior ? loadKey() : newAgentKey();
|
|
98
|
+
const host = hostFlag ?? prior?.host ?? process.env.COURIER_HOST ?? DEFAULT_HOST;
|
|
99
|
+
const openkeyHost = openkeyHostFlag ?? prior?.openkeyHost ?? process.env.COURIER_OPENKEY_HOST ?? 'https://openkey.so';
|
|
100
|
+
const profile = prior ?? {
|
|
101
|
+
version: 3,
|
|
102
|
+
host,
|
|
103
|
+
openkeyHost,
|
|
104
|
+
label: labelFlag ?? `courier · ${os.hostname()}`,
|
|
105
|
+
...(handleFlag ? { selfHandle: handleFlag } : {}),
|
|
106
|
+
courierDid: bareDid(key.did),
|
|
107
|
+
status: 'awaiting-grant',
|
|
108
|
+
peers: {},
|
|
109
|
+
};
|
|
110
|
+
profile.host = host;
|
|
111
|
+
profile.openkeyHost = openkeyHost;
|
|
112
|
+
profile.label = labelFlag ?? profile.label;
|
|
113
|
+
if (handleFlag)
|
|
114
|
+
profile.selfHandle = handleFlag;
|
|
115
|
+
if (rotate) {
|
|
116
|
+
profile.peers = {};
|
|
117
|
+
profile.ownerDid = undefined;
|
|
118
|
+
profile.spaceId = undefined;
|
|
119
|
+
profile.grantExpiresAt = undefined;
|
|
120
|
+
profile.status = 'awaiting-grant';
|
|
121
|
+
try {
|
|
122
|
+
fs.unlinkSync(path.join(profileDir(), 'grant.json'));
|
|
123
|
+
}
|
|
124
|
+
catch { /* no prior grant */ }
|
|
125
|
+
}
|
|
126
|
+
profile.courierDid = bareDid(key.did);
|
|
127
|
+
saveKey(key);
|
|
128
|
+
saveProfile(profile);
|
|
129
|
+
const request = courierGrantRequest(profile, key, expiry, { openkeyApiHost: apiHostFlag });
|
|
130
|
+
if (rotate)
|
|
131
|
+
console.error('Agent key rotated. Existing peer pairings must be re-established; the old grant remains valid until expiry.');
|
|
132
|
+
if (args.has('request')) {
|
|
133
|
+
const openkeyUrl = openKeyDelegateUrl(request);
|
|
134
|
+
printJson({ did: key.did, courierDid: profile.courierDid, publicJwk: Object.fromEntries(Object.entries(key.jwk).filter(([name]) => !['d', 'p', 'q', 'dp', 'dq', 'qi', 'oth', 'k'].includes(name))), host, space: COURIER_SPACE, permissions: AGENT_GRANT, expiry, openkeyUrl });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
let payload;
|
|
138
|
+
if (grantRaw !== undefined) {
|
|
139
|
+
payload = await grantFromInput(grantRaw, key, { apiHost: request.openkeyApiHost });
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
payload = await receiveGrant(request, { paste: args.has('paste') });
|
|
143
|
+
}
|
|
144
|
+
await installGrant(profile, key, payload);
|
|
145
|
+
await finishInit(profile, host);
|
|
146
|
+
}
|
|
147
|
+
async function finishInit(profile, host) {
|
|
148
|
+
const active = await ctx();
|
|
149
|
+
for (const peer of Object.values(active.profile.peers)) {
|
|
150
|
+
if (peer.status === 'paired')
|
|
151
|
+
await renewOutbound(active, peer, true);
|
|
152
|
+
}
|
|
153
|
+
printJson({ ok: true, profileDir: profileDir(), ownerDid: profile.ownerDid, courierDid: profile.courierDid, spaceId: profile.spaceId, host, grantExpiresAt: profile.grantExpiresAt });
|
|
154
|
+
}
|
|
155
|
+
export function cmdManifest() {
|
|
156
|
+
printJson({ space: COURIER_SPACE, expiry: AGENT_GRANT_EXPIRY, permissions: AGENT_GRANT });
|
|
157
|
+
}
|
|
158
|
+
// ---------- pair ----------
|
|
159
|
+
export async function cmdPair(args) {
|
|
160
|
+
const sub = args.positional[0];
|
|
161
|
+
if (sub === 'start')
|
|
162
|
+
return pairStart(args);
|
|
163
|
+
if (sub === 'accept')
|
|
164
|
+
return pairAccept(args);
|
|
165
|
+
if (sub === 'renew')
|
|
166
|
+
return pairRenew(args);
|
|
167
|
+
fail('usage', `Usage: ${cmd('pair start <peer> | pair accept <artifact-json|--file f|--stdin> | pair renew [peer] [--force]')}`);
|
|
168
|
+
}
|
|
169
|
+
async function pairStart(args) {
|
|
170
|
+
const { profile } = await ctx();
|
|
171
|
+
const peerHandle = args.positional[1] ?? fail('usage', `Usage: ${cmd('pair start <peer> [--call-me <handle>]')}`);
|
|
172
|
+
const callMe = args.get('call-me') ?? profile.selfHandle ?? peerHandle;
|
|
173
|
+
if (profile.peers[peerHandle]?.status === 'paired')
|
|
174
|
+
fail('usage', `Already paired with ${peerHandle}.`);
|
|
175
|
+
profile.peers[peerHandle] = {
|
|
176
|
+
handle: peerHandle,
|
|
177
|
+
ownerDid: '',
|
|
178
|
+
courierDid: '',
|
|
179
|
+
spaceId: '',
|
|
180
|
+
outbound: [],
|
|
181
|
+
theirHandleForMe: peerHandle,
|
|
182
|
+
status: 'pending-A',
|
|
183
|
+
pairedAt: new Date().toISOString(),
|
|
184
|
+
};
|
|
185
|
+
saveProfile(profile);
|
|
186
|
+
printJson(artifactA(profile, peerHandle, callMe));
|
|
187
|
+
}
|
|
188
|
+
function readArtifactArg(args) {
|
|
189
|
+
if (args.has('stdin'))
|
|
190
|
+
return fs.readFileSync(0, 'utf8');
|
|
191
|
+
const file = args.get('file');
|
|
192
|
+
if (file)
|
|
193
|
+
return fs.readFileSync(file, 'utf8');
|
|
194
|
+
const pos = args.positional.slice(1).join(' ');
|
|
195
|
+
if (!pos)
|
|
196
|
+
fail('usage', `Usage: ${cmd('pair accept <artifact-json> | --file f | --stdin')}`);
|
|
197
|
+
if (!pos.startsWith('{') && fs.existsSync(pos))
|
|
198
|
+
return fs.readFileSync(pos, 'utf8');
|
|
199
|
+
return pos;
|
|
200
|
+
}
|
|
201
|
+
async function validatePeerDelegation(node, profile, peer, serialized) {
|
|
202
|
+
const delegation = deserializeDelegation(serialized);
|
|
203
|
+
const ownerAddress = peer.ownerDid.split(':').at(-1)?.toLowerCase();
|
|
204
|
+
if (delegation.spaceId !== peer.spaceId || delegation.ownerAddress.toLowerCase() !== ownerAddress) {
|
|
205
|
+
fail('bad-artifact', 'Peer delegation space or owner does not match the pairing artifact.');
|
|
206
|
+
}
|
|
207
|
+
if (!peer.theirHandleForMe)
|
|
208
|
+
fail('bad-artifact', 'Pairing artifact is missing the peer mailbox handle.');
|
|
209
|
+
const active = await activateValidatedRuntimeDelegation(node, delegation, { host: profile.host });
|
|
210
|
+
assertPeerDelegation(active, {
|
|
211
|
+
issuer: peer.courierDid,
|
|
212
|
+
audience: profile.courierDid,
|
|
213
|
+
spaceId: peer.spaceId,
|
|
214
|
+
mailbox: peer.theirHandleForMe,
|
|
215
|
+
});
|
|
216
|
+
const expiry = delegation.expiry instanceof Date ? delegation.expiry : new Date(delegation.expiry);
|
|
217
|
+
return { delegation, cid: delegationCid(delegation), expiresAt: expiry.toISOString() };
|
|
218
|
+
}
|
|
219
|
+
async function pairAccept(args) {
|
|
220
|
+
const { profile, node } = await ctx();
|
|
221
|
+
const art = parseArtifact(readArtifactArg(args));
|
|
222
|
+
validateArtifactIdentity(art);
|
|
223
|
+
if (art.from.owner === profile.ownerDid || art.from.courier === profile.courierDid)
|
|
224
|
+
fail('bad-artifact', 'Refusing to pair with this agent identity.');
|
|
225
|
+
if (art.step === 'A') {
|
|
226
|
+
const handle = art.callMe;
|
|
227
|
+
const outbound = await node.delegateTo(art.from.courier, peerReadPermissions(handle, profile.spaceId), { expiry: PEER_DELEGATION_EXPIRY });
|
|
228
|
+
const peer = {
|
|
229
|
+
handle, ownerDid: art.from.owner, courierDid: art.from.courier, label: art.from.label,
|
|
230
|
+
spaceId: art.spaceId, outbound: [], theirHandleForMe: art.peer, status: 'awaiting-C', pairedAt: new Date().toISOString(),
|
|
231
|
+
};
|
|
232
|
+
recordOutbound(peer, outbound.delegation);
|
|
233
|
+
profile.peers[handle] = peer;
|
|
234
|
+
saveProfile(profile);
|
|
235
|
+
await writePeerRecord(node, peer);
|
|
236
|
+
printJson(artifactWithDelegation('B', profile, outbound.delegation, handle, art.peer));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (art.step === 'B') {
|
|
240
|
+
const handle = args.get('as') ?? art.callMe;
|
|
241
|
+
const peer = findPendingPeer(profile, handle);
|
|
242
|
+
if (art.from.owner === profile.ownerDid)
|
|
243
|
+
fail('bad-artifact', 'Refusing to pair with our own owner identity.');
|
|
244
|
+
const candidate = {
|
|
245
|
+
...peer,
|
|
246
|
+
ownerDid: art.from.owner,
|
|
247
|
+
courierDid: art.from.courier,
|
|
248
|
+
label: art.from.label,
|
|
249
|
+
spaceId: art.spaceId,
|
|
250
|
+
theirHandleForMe: art.peer,
|
|
251
|
+
};
|
|
252
|
+
const incoming = await validatePeerDelegation(node, profile, candidate, art.delegation);
|
|
253
|
+
Object.assign(peer, candidate);
|
|
254
|
+
peer.inbound = { delegation: serializeDelegation(incoming.delegation), cid: incoming.cid, expiresAt: incoming.expiresAt };
|
|
255
|
+
const outgoing = await node.delegateTo(peer.courierDid, peerReadPermissions(peer.handle, profile.spaceId), { expiry: PEER_DELEGATION_EXPIRY });
|
|
256
|
+
recordOutbound(peer, outgoing.delegation);
|
|
257
|
+
peer.status = 'paired';
|
|
258
|
+
saveProfile(profile);
|
|
259
|
+
await writePeerRecord(node, peer);
|
|
260
|
+
printJson(artifactWithDelegation('C', profile, outgoing.delegation, peer.handle, art.peer));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const peer = profile.peers[art.callMe] ?? findPeerByCourier(profile, art.from.courier);
|
|
264
|
+
if (!peer)
|
|
265
|
+
fail('unknown-peer', `No pending pairing for "${art.callMe}". Did you run \`pair accept\` on artifact A?`);
|
|
266
|
+
if (art.from.courier !== peer.courierDid || art.spaceId !== peer.spaceId || art.from.owner !== peer.ownerDid) {
|
|
267
|
+
fail('bad-artifact', `Artifact C does not match the pending pairing with ${peer.handle}.`);
|
|
268
|
+
}
|
|
269
|
+
const candidate = { ...peer, theirHandleForMe: art.peer };
|
|
270
|
+
const incoming = await validatePeerDelegation(node, profile, candidate, art.delegation);
|
|
271
|
+
peer.theirHandleForMe = candidate.theirHandleForMe;
|
|
272
|
+
peer.inbound = { delegation: serializeDelegation(incoming.delegation), cid: incoming.cid, expiresAt: incoming.expiresAt };
|
|
273
|
+
peer.status = 'paired';
|
|
274
|
+
saveProfile(profile);
|
|
275
|
+
await writePeerRecord(node, peer);
|
|
276
|
+
printJson({ ok: true, paired: peer.handle, peerDid: peer.courierDid, peerSpace: peer.spaceId, delegationExpiresAt: peer.inbound.expiresAt });
|
|
277
|
+
}
|
|
278
|
+
function recordOutbound(peer, delegation) {
|
|
279
|
+
const expiresAt = delegation.expiry instanceof Date ? delegation.expiry.toISOString() : new Date(delegation.expiry).toISOString();
|
|
280
|
+
peer.outbound.push({ cid: delegationCid(delegation), issuedAt: new Date().toISOString(), expiresAt });
|
|
281
|
+
}
|
|
282
|
+
async function pairRenew(args) {
|
|
283
|
+
const active = await ctx();
|
|
284
|
+
const requested = args.positional[1];
|
|
285
|
+
const peers = requested ? [getPeer(active.profile, requested)] : Object.values(active.profile.peers).filter((peer) => peer.status === 'paired');
|
|
286
|
+
for (const peer of peers) {
|
|
287
|
+
if (peer.status !== 'paired')
|
|
288
|
+
fail('not-paired', `Cannot renew ${peer.handle}: pairing is not complete.`);
|
|
289
|
+
await renewOutbound(active, peer, args.has('force'));
|
|
290
|
+
await installInbound(active, peer);
|
|
291
|
+
}
|
|
292
|
+
printJson({ ok: true, renewed: peers.map((peer) => peer.handle) });
|
|
293
|
+
}
|
|
294
|
+
async function renewOutbound(active, peer, force) {
|
|
295
|
+
const last = peer.outbound.at(-1);
|
|
296
|
+
if (!force && last && Date.now() - Date.parse(last.issuedAt) < 20 * 3600 * 1000)
|
|
297
|
+
return;
|
|
298
|
+
const { profile, node } = active;
|
|
299
|
+
const result = await node.delegateTo(peer.courierDid, peerReadPermissions(peer.handle, profile.spaceId), { expiry: PEER_DELEGATION_EXPIRY });
|
|
300
|
+
const issuedAt = new Date().toISOString();
|
|
301
|
+
const serialized = serializeDelegation(result.delegation);
|
|
302
|
+
const existing = await getJson(node.kv, k.peerDelegation(peer.handle));
|
|
303
|
+
const body = { v: 0, delegation: serialized, issuedAt };
|
|
304
|
+
if (existing?.etag)
|
|
305
|
+
await putMatch(node.kv, k.peerDelegation(peer.handle), body, existing.etag);
|
|
306
|
+
else
|
|
307
|
+
await putCreate(node.kv, k.peerDelegation(peer.handle), body);
|
|
308
|
+
peer.outbound.push({
|
|
309
|
+
cid: delegationCid(result.delegation),
|
|
310
|
+
issuedAt,
|
|
311
|
+
expiresAt: result.delegation.expiry instanceof Date ? result.delegation.expiry.toISOString() : new Date(result.delegation.expiry).toISOString(),
|
|
312
|
+
});
|
|
313
|
+
saveProfile(profile);
|
|
314
|
+
}
|
|
315
|
+
async function installInbound(active, peer) {
|
|
316
|
+
if (!peer.inbound || !peer.theirHandleForMe)
|
|
317
|
+
return;
|
|
318
|
+
const kv = await peerKv(active.node, active.profile, peer);
|
|
319
|
+
const update = await getJson(kv, k.peerDelegation(peer.theirHandleForMe));
|
|
320
|
+
if (!update?.value.delegation)
|
|
321
|
+
return;
|
|
322
|
+
const incoming = await validatePeerDelegation(active.node, active.profile, peer, update.value.delegation);
|
|
323
|
+
if (Date.parse(incoming.expiresAt) <= Date.parse(peer.inbound.expiresAt))
|
|
324
|
+
return;
|
|
325
|
+
peer.inbound = { delegation: serializeDelegation(incoming.delegation), cid: incoming.cid, expiresAt: incoming.expiresAt };
|
|
326
|
+
saveProfile(active.profile);
|
|
327
|
+
}
|
|
328
|
+
function findPendingPeer(profile, handle) {
|
|
329
|
+
const p = profile.peers[handle];
|
|
330
|
+
if (p && p.status === 'pending-A')
|
|
331
|
+
return p;
|
|
332
|
+
const pending = Object.values(profile.peers).filter((x) => x.status === 'pending-A');
|
|
333
|
+
if (pending.length === 1)
|
|
334
|
+
return pending[0];
|
|
335
|
+
fail('unknown-peer', `No pending pairing started with \`${cmd('pair start')}\` (looked for "${handle}").`);
|
|
336
|
+
}
|
|
337
|
+
function findPeerByCourier(profile, courierDid) {
|
|
338
|
+
return Object.values(profile.peers).find((x) => x.courierDid === courierDid);
|
|
339
|
+
}
|
|
340
|
+
function delegationCid(d) {
|
|
341
|
+
return String(d.cid ?? '');
|
|
342
|
+
}
|
|
343
|
+
async function writePeerRecord(node, peer) {
|
|
344
|
+
// Best-effort publication of pairing state in-space (spec §4); the profile
|
|
345
|
+
// remains authoritative for the CLI.
|
|
346
|
+
const kv = node.kv;
|
|
347
|
+
try {
|
|
348
|
+
await putCreate(kv, k.peerRecord(peer.handle), peerRecordBody(peer));
|
|
349
|
+
}
|
|
350
|
+
catch (e) {
|
|
351
|
+
const info = errInfo(e);
|
|
352
|
+
if (info.code === 'conflict') {
|
|
353
|
+
const cur = await getJson(kv, k.peerRecord(peer.handle));
|
|
354
|
+
if (cur?.etag)
|
|
355
|
+
await putMatch(kv, k.peerRecord(peer.handle), peerRecordBody(peer), cur.etag);
|
|
356
|
+
else
|
|
357
|
+
console.error(`warning: could not update me/peers/${peer.handle}.json`);
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
console.error(`warning: could not write me/peers/${peer.handle}.json: ${info.message}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// ---------- revoke ----------
|
|
365
|
+
export async function cmdRevoke(args) {
|
|
366
|
+
const { profile, node } = await ctx();
|
|
367
|
+
const handle = args.positional[0] ?? fail('usage', `Usage: ${cmd('revoke <peer>')}`);
|
|
368
|
+
const peer = getPeer(profile, handle);
|
|
369
|
+
delete profile.peers[handle];
|
|
370
|
+
saveProfile(profile);
|
|
371
|
+
try {
|
|
372
|
+
await del(node.kv, k.peerRecord(handle));
|
|
373
|
+
}
|
|
374
|
+
catch { /* non-fatal */ }
|
|
375
|
+
console.log(`Revoked ${handle} (${peer.courierDid}).\n` +
|
|
376
|
+
`Note: TC-721 prevents immediate revocation; access ends when the current outbound delegation expires (${peer.outbound.at(-1)?.expiresAt ?? 'unknown'}). ` +
|
|
377
|
+
`Rotating the agent key does not invalidate the old grant; it remains valid until its expiry.`);
|
|
378
|
+
}
|
|
379
|
+
// ---------- send ----------
|
|
380
|
+
export async function cmdSend(args) {
|
|
381
|
+
const { profile, node } = await ctx();
|
|
382
|
+
const peerHandle = args.positional[0] ?? fail('usage', sendUsage());
|
|
383
|
+
const peer = getPeer(profile, peerHandle);
|
|
384
|
+
const which = oneOf(args, ['new', 'thread']);
|
|
385
|
+
const threadId = which === 'new' ? ulid() : which === 'thread' ? requireFlag(args, 'thread', sendUsage()) : fail('usage', sendUsage());
|
|
386
|
+
const subject = args.get('new');
|
|
387
|
+
const intentRaw = args.get('intent') ?? 'request';
|
|
388
|
+
if (!INTENTS.includes(intentRaw))
|
|
389
|
+
fail('usage', `--intent must be one of: ${INTENTS.join(', ')}`);
|
|
390
|
+
const text = readTextArg(args, '--text or --file is required (or pipe the body on stdin)');
|
|
391
|
+
if (text.length > 256 * 1024)
|
|
392
|
+
fail('too-large', 'Message body exceeds the 256 KB inline limit (§4) — put it under files/ instead.');
|
|
393
|
+
const from = { owner: profile.ownerDid, agent: profile.courierDid, label: profile.label };
|
|
394
|
+
const asks = args.all('ask').map((title, i) => ({ id: `a${i + 1}`, title }));
|
|
395
|
+
const constraints = args.all('constraint');
|
|
396
|
+
const inReplyTo = args.get('reply-to') ?? null;
|
|
397
|
+
const referenceTaskIds = args.all('task-ref');
|
|
398
|
+
const startedByMe = which === 'new' || Boolean(await getJson(node.kv, k.threadMeta(peerHandle, threadId)));
|
|
399
|
+
let refsOwnTask = false;
|
|
400
|
+
let refsAnyTask = false;
|
|
401
|
+
for (const taskId of referenceTaskIds) {
|
|
402
|
+
if (await getJson(node.kv, k.task(peerHandle, threadId, taskId))) {
|
|
403
|
+
refsOwnTask = true;
|
|
404
|
+
refsAnyTask = true;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (peer.inbound && peer.theirHandleForMe) {
|
|
408
|
+
const peerSpace = await peerKv(node, profile, peer);
|
|
409
|
+
const key = `${PREFIX}/to/${peer.theirHandleForMe}/threads/${threadId}/task/${taskId}.json`;
|
|
410
|
+
if (await getJson(peerSpace, key))
|
|
411
|
+
refsAnyTask = true;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const msg = buildMessage({
|
|
415
|
+
threadId, text, from, intent: intentRaw, asks, constraints, inReplyTo, referenceTaskIds,
|
|
416
|
+
role: messageRole({ startedByMe, refsOwnTask, refsAnyTask }),
|
|
417
|
+
});
|
|
418
|
+
if (which === 'new') {
|
|
419
|
+
const meta = {
|
|
420
|
+
threadId, subject: subject ?? '(no subject)',
|
|
421
|
+
startedBy: { owner: profile.ownerDid, courier: profile.courierDid, label: profile.label },
|
|
422
|
+
createdAt: new Date().toISOString(),
|
|
423
|
+
};
|
|
424
|
+
await putCreate(node.kv, k.threadMeta(peerHandle, threadId), meta);
|
|
425
|
+
}
|
|
426
|
+
await putCreate(node.kv, k.msg(peerHandle, threadId, msg.messageId), msg);
|
|
427
|
+
printJson({ ok: true, threadId, messageId: msg.messageId, peer: peerHandle, intent: intentRaw, role: msg.role });
|
|
428
|
+
}
|
|
429
|
+
const sendUsage = () => `Usage: ${cmd('send <peer> (--new <subject> | --thread <id>) [--text t | --file f | stdin] [--intent i] [--ask "…"]… [--reply-to id]')}`;
|
|
430
|
+
function readTextArg(args, usage) {
|
|
431
|
+
const t = args.get('text');
|
|
432
|
+
if (t !== undefined && t !== 'true')
|
|
433
|
+
return t;
|
|
434
|
+
const file = args.get('file');
|
|
435
|
+
if (file)
|
|
436
|
+
return fs.readFileSync(file, 'utf8');
|
|
437
|
+
if (args.has('stdin'))
|
|
438
|
+
return fs.readFileSync(0, 'utf8');
|
|
439
|
+
if (!process.stdin.isTTY)
|
|
440
|
+
return fs.readFileSync(0, 'utf8');
|
|
441
|
+
fail('usage', usage);
|
|
442
|
+
}
|
|
443
|
+
// ---------- inbox ----------
|
|
444
|
+
export async function cmdInbox(args) {
|
|
445
|
+
const { profile, node } = await ctx();
|
|
446
|
+
const collection = await collectInbound({
|
|
447
|
+
selfKv: node.kv,
|
|
448
|
+
peers: Object.values(profile.peers),
|
|
449
|
+
kvFor: (peer) => peerKv(node, profile, peer),
|
|
450
|
+
});
|
|
451
|
+
const output = {
|
|
452
|
+
notice: COUNTERPARTY_NOTICE,
|
|
453
|
+
items: collection.items.map((item) => ({ authority: 'counterparty', ...item })),
|
|
454
|
+
errors: collection.errors,
|
|
455
|
+
};
|
|
456
|
+
await writeOut(args.has('json') ? JSON.stringify(output, null, 2) + '\n' : renderInboxText(collection.items));
|
|
457
|
+
for (const error of collection.errors)
|
|
458
|
+
console.error(`warning: inbox ${error.peer}: ${error.message}`);
|
|
459
|
+
if (!args.has('peek'))
|
|
460
|
+
await commitReceipts(node.kv, collection.pending);
|
|
461
|
+
}
|
|
462
|
+
export async function cmdRead(args) {
|
|
463
|
+
const { profile, node } = await ctx();
|
|
464
|
+
const threadId = args.positional[0] ?? fail('usage', `Usage: ${cmd('read <threadId> [--peer p] [--json]')}`);
|
|
465
|
+
const entries = [];
|
|
466
|
+
const peersToScan = args.get('peer')
|
|
467
|
+
? [getPeer(profile, args.get('peer'))]
|
|
468
|
+
: Object.values(profile.peers).filter((peer) => peer.status === 'paired' || peer.status === 'awaiting-C');
|
|
469
|
+
for (const peer of peersToScan) {
|
|
470
|
+
const mine = await listAll(node.kv, `${k.threadRoot(peer.handle, threadId)}/`);
|
|
471
|
+
for (const key of mine) {
|
|
472
|
+
const got = await getJson(node.kv, key);
|
|
473
|
+
if (!got)
|
|
474
|
+
continue;
|
|
475
|
+
if (key.endsWith('/meta.json'))
|
|
476
|
+
entries.push({ kind: 'meta', id: threadId, side: 'me', authority: 'self', body: got.value });
|
|
477
|
+
else if (/\/msg\/[^/]+\.json$/.test(key)) {
|
|
478
|
+
const invalid = messageView(got.value).invalid;
|
|
479
|
+
entries.push({ kind: 'msg', id: msgIdFromKey(key), side: 'me', authority: 'self', body: got.value, ...(invalid.length ? { invalid } : {}) });
|
|
480
|
+
}
|
|
481
|
+
else if (/\/task\/[^/]+\.json$/.test(key)) {
|
|
482
|
+
const invalid = taskView(got.value).invalid;
|
|
483
|
+
entries.push({ kind: 'task', id: taskIdFromKey(key), side: 'me', authority: 'self', body: got.value, ...(invalid.length ? { invalid } : {}) });
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (peer.inbound && peer.theirHandleForMe) {
|
|
487
|
+
const peerSpace = await peerKv(node, profile, peer);
|
|
488
|
+
const theirs = await listAll(peerSpace, `${PREFIX}/to/${peer.theirHandleForMe}/threads/${threadId}/`);
|
|
489
|
+
for (const key of theirs) {
|
|
490
|
+
const got = await getJson(peerSpace, key);
|
|
491
|
+
if (!got)
|
|
492
|
+
continue;
|
|
493
|
+
if (key.endsWith('/meta.json'))
|
|
494
|
+
entries.push({ kind: 'meta', id: threadId, side: 'peer', authority: 'counterparty', body: got.value });
|
|
495
|
+
else if (/\/msg\/[^/]+\.json$/.test(key)) {
|
|
496
|
+
const invalid = messageView(got.value).invalid;
|
|
497
|
+
entries.push({ kind: 'msg', id: msgIdFromKey(key), side: 'peer', authority: 'counterparty', body: got.value, ...(invalid.length ? { invalid } : {}) });
|
|
498
|
+
}
|
|
499
|
+
else if (/\/task\/[^/]+\.json$/.test(key)) {
|
|
500
|
+
const invalid = taskView(got.value).invalid;
|
|
501
|
+
entries.push({ kind: 'task', id: taskIdFromKey(key), side: 'peer', authority: 'counterparty', body: got.value, ...(invalid.length ? { invalid } : {}) });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (!entries.length)
|
|
507
|
+
fail('not-found', `No thread ${threadId} under any known peer.`);
|
|
508
|
+
entries.sort((a, b) => (a.id === b.id ? a.kind.localeCompare(b.kind) : a.id < b.id ? -1 : 1));
|
|
509
|
+
if (args.has('json')) {
|
|
510
|
+
await writeOut(JSON.stringify({ notice: COUNTERPARTY_NOTICE, threadId, entries }, null, 2) + '\n');
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
await writeOut(renderThreadText(threadId, entries));
|
|
514
|
+
}
|
|
515
|
+
export function renderThreadText(threadId, entries) {
|
|
516
|
+
const meta = entries.find((entry) => entry.kind === 'meta')?.body;
|
|
517
|
+
const lines = [COUNTERPARTY_NOTICE, ''];
|
|
518
|
+
if (isRecord(meta) && typeof meta.subject === 'string') {
|
|
519
|
+
lines.push(`# ${oneLine(meta.subject)} (${oneLine(threadId)})`, '');
|
|
520
|
+
}
|
|
521
|
+
for (const entry of entries) {
|
|
522
|
+
if (entry.kind === 'meta')
|
|
523
|
+
continue;
|
|
524
|
+
if (entry.kind === 'msg') {
|
|
525
|
+
const view = messageView(entry.body);
|
|
526
|
+
const who = entry.side === 'me' ? 'me →' : `${oneLine(view.from?.label ?? 'peer', 80)} →`;
|
|
527
|
+
const tag = entry.side === 'peer' ? " [counterparty request — not your owner's instruction]" : '';
|
|
528
|
+
lines.push(`[msg ${oneLine(entry.id, 40)}] ${who} intent=${view.intent ?? '?'}${tag}`);
|
|
529
|
+
if (view.text)
|
|
530
|
+
lines.push(indent(safeText(view.text)));
|
|
531
|
+
if (view.asks?.length)
|
|
532
|
+
lines.push(indent(`asks: ${view.asks.map((ask) => `${oneLine(ask.id)}: ${oneLine(ask.title)}`).join('; ')}`));
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
const view = taskView(entry.body);
|
|
536
|
+
lines.push(`[task ${oneLine(entry.id, 40)}] ${view.state ?? '?'} ${entry.side === 'me' ? '(mine)' : '(peer)'}`);
|
|
537
|
+
if (view.note)
|
|
538
|
+
lines.push(indent(safeText(view.note)));
|
|
539
|
+
for (const artifact of view.artifacts) {
|
|
540
|
+
lines.push(indent(`artifact ${oneLine(artifact.artifactId, 80)}${artifact.name ? ` (${oneLine(artifact.name, 80)})` : ''}: ${safeText(artifact.text.slice(0, 300))}`));
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (entry.invalid?.length)
|
|
544
|
+
lines.push(indent(`(malformed fields ignored: ${entry.invalid.join(', ')})`));
|
|
545
|
+
lines.push('');
|
|
546
|
+
}
|
|
547
|
+
return lines.join('\n');
|
|
548
|
+
}
|
|
549
|
+
function msgIdFromKey(key) {
|
|
550
|
+
return /\/msg\/([^/]+)\.json$/.exec(key)[1];
|
|
551
|
+
}
|
|
552
|
+
function taskIdFromKey(key) {
|
|
553
|
+
return /\/task\/([^/]+)\.json$/.exec(key)[1];
|
|
554
|
+
}
|
|
555
|
+
function indent(s) {
|
|
556
|
+
return s.split('\n').map((l) => ` ${l}`).join('\n');
|
|
557
|
+
}
|
|
558
|
+
// ---------- task ----------
|
|
559
|
+
export async function cmdTask(args) {
|
|
560
|
+
const { profile, node } = await ctx();
|
|
561
|
+
const taskIdArg = args.positional[0] ?? fail('usage', taskUsage());
|
|
562
|
+
const peerHandle = requireFlag(args, 'peer', taskUsage());
|
|
563
|
+
getPeer(profile, peerHandle);
|
|
564
|
+
const threadId = requireFlag(args, 'thread', taskUsage());
|
|
565
|
+
const state = parseTaskState(requireFlag(args, 'state', taskUsage()));
|
|
566
|
+
const note = args.get('note');
|
|
567
|
+
const taskId = taskIdArg === 'new' ? ulid() : taskIdArg;
|
|
568
|
+
const key = k.task(peerHandle, threadId, taskId);
|
|
569
|
+
const artifacts = args.all('artifact').map((f) => {
|
|
570
|
+
const name = f.split('/').pop() ?? 'artifact';
|
|
571
|
+
return { artifactId: name.replace(/\W+/g, '-'), name, parts: [{ text: fs.readFileSync(f, 'utf8'), mediaType: 'text/markdown' }] };
|
|
572
|
+
});
|
|
573
|
+
const existing = await getJson(node.kv, key);
|
|
574
|
+
const priorExt = (existing?.value.metadata?.[EXT] ?? {});
|
|
575
|
+
const task = buildTask({
|
|
576
|
+
threadId, taskId, state, note,
|
|
577
|
+
artifacts: artifacts.length ? [...(existing?.value.artifacts ?? []), ...artifacts] : existing?.value.artifacts,
|
|
578
|
+
ask: args.get('ask') ?? (typeof priorExt.ask === 'string' ? priorExt.ask : undefined),
|
|
579
|
+
requestMessageId: args.get('request-message') ?? (typeof priorExt.requestMessageId === 'string' ? priorExt.requestMessageId : undefined),
|
|
580
|
+
role: 'ROLE_AGENT',
|
|
581
|
+
});
|
|
582
|
+
// Preserve immutable provenance fields on update.
|
|
583
|
+
if (existing) {
|
|
584
|
+
task.id = existing.value.id;
|
|
585
|
+
task.contextId = existing.value.contextId;
|
|
586
|
+
task.metadata[EXT] = { ...priorExt, ...task.metadata[EXT] };
|
|
587
|
+
await putMatch(node.kv, key, task, existing.etag ?? fail('conflict', `task ${taskId} exists without an etag`));
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
await putCreate(node.kv, key, task);
|
|
591
|
+
}
|
|
592
|
+
printJson({ ok: true, taskId, threadId, state, updated: Boolean(existing) });
|
|
593
|
+
}
|
|
594
|
+
const taskUsage = () => `Usage: ${cmd('task <taskId|new> --peer <p> --thread <id> --state <s> [--note …] [--artifact f]… [--ask a1]')}`;
|
|
595
|
+
// ---------- request ----------
|
|
596
|
+
export async function cmdRequest(args) {
|
|
597
|
+
const { profile, node } = await ctx();
|
|
598
|
+
const threadId = args.positional[0] ?? fail('usage', requestUsage());
|
|
599
|
+
const taskId = requireFlag(args, 'task', requestUsage());
|
|
600
|
+
const action = requireFlag(args, 'action', requestUsage());
|
|
601
|
+
const why = requireFlag(args, 'why', requestUsage());
|
|
602
|
+
const peerHandle = requireFlag(args, 'peer', requestUsage());
|
|
603
|
+
getPeer(profile, peerHandle);
|
|
604
|
+
const risk = args.get('risk');
|
|
605
|
+
const id = ulid();
|
|
606
|
+
const request = {
|
|
607
|
+
id, peer: peerHandle, threadId, taskId, action, reason: why,
|
|
608
|
+
risk: risk ?? 'unspecified',
|
|
609
|
+
requestedBy: { owner: profile.ownerDid, agent: profile.courierDid },
|
|
610
|
+
createdAt: new Date().toISOString(),
|
|
611
|
+
expiresAt: new Date(Date.now() + 24 * 3600 * 1000).toISOString(),
|
|
612
|
+
status: 'pending',
|
|
613
|
+
};
|
|
614
|
+
await putCreate(node.kv, k.request(id), request);
|
|
615
|
+
const taskKey = k.task(peerHandle, threadId, taskId);
|
|
616
|
+
const existing = await getJson(node.kv, taskKey);
|
|
617
|
+
const task = authRequiredTask(existing?.value, threadId, taskId);
|
|
618
|
+
if (existing?.etag)
|
|
619
|
+
await putMatch(node.kv, taskKey, task, existing.etag);
|
|
620
|
+
else
|
|
621
|
+
await putCreate(node.kv, taskKey, task);
|
|
622
|
+
printJson({ ok: true, requestId: id, taskId, state: 'TASK_STATE_AUTH_REQUIRED' });
|
|
623
|
+
}
|
|
624
|
+
const requestUsage = () => `Usage: ${cmd('request <threadId> --peer <p> --task <id> --action "…" --why "…" [--risk "…"]')}`;
|
|
625
|
+
// ---------- orders ----------
|
|
626
|
+
export async function cmdOrders(args) {
|
|
627
|
+
const { profile, node } = await ctx();
|
|
628
|
+
const handle = args.positional[0] ?? fail('usage', `Usage: ${cmd('orders <peer>')}`);
|
|
629
|
+
getPeer(profile, handle);
|
|
630
|
+
const got = await getJson(node.kv, k.orders(handle));
|
|
631
|
+
const body = typeof got?.value === 'string' ? got.value : undefined;
|
|
632
|
+
if (args.has('json')) {
|
|
633
|
+
printJson({ peer: handle, orders: body ?? DEFAULT_ORDERS, source: body ? 'space' : 'default' });
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
console.log(body ?? `# No me/orders/${handle}.md in your space — v0 defaults apply:\n\n${DEFAULT_ORDERS}`);
|
|
637
|
+
}
|
|
638
|
+
//# sourceMappingURL=commands.js.map
|