@dopamint-fun/open-sdk 0.1.0-dev.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/dist/acceptance.d.ts +24 -0
- package/dist/acceptance.js +48 -0
- package/dist/agentHttp.d.ts +52 -0
- package/dist/agentHttp.js +139 -0
- package/dist/bytes.d.ts +40 -0
- package/dist/bytes.js +166 -0
- package/dist/channel.d.ts +38 -0
- package/dist/channel.js +86 -0
- package/dist/claim.d.ts +39 -0
- package/dist/claim.js +91 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1006 -0
- package/dist/crypto.d.ts +3 -0
- package/dist/crypto.js +20 -0
- package/dist/identity.d.ts +32 -0
- package/dist/identity.js +100 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +22 -0
- package/dist/keypair.d.ts +25 -0
- package/dist/keypair.js +69 -0
- package/dist/offer.d.ts +36 -0
- package/dist/offer.js +138 -0
- package/dist/registration.d.ts +53 -0
- package/dist/registration.js +104 -0
- package/dist/seatAuth.d.ts +30 -0
- package/dist/seatAuth.js +285 -0
- package/dist/session.d.ts +260 -0
- package/dist/session.js +840 -0
- package/dist/sessionCodec.d.ts +139 -0
- package/dist/sessionCodec.js +373 -0
- package/dist/sessionWire.d.ts +80 -0
- package/dist/sessionWire.js +118 -0
- package/dist/settlement.d.ts +56 -0
- package/dist/settlement.js +117 -0
- package/dist/texas.d.ts +52 -0
- package/dist/texas.js +217 -0
- package/dist/tour.d.ts +101 -0
- package/dist/tour.js +129 -0
- package/package.json +35 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* The thin CLI over the library - the pieces an agent harness shells out to.
|
|
3
|
+
*
|
|
4
|
+
* keygen and address manage the key file; register performs the one HTTP call
|
|
5
|
+
* an agent must make before it can be seated; play drives the Participant
|
|
6
|
+
* Session to a terminal disposition; consent recomputes the settlement digest
|
|
7
|
+
* and signs it. sign remains for harnesses that still assemble HTTP themselves.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { parseArgs } from "node:util";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { dirname, join, resolve } from "node:path";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
import { fromHex, toHex } from "./bytes.js";
|
|
15
|
+
import { nameAgent } from "./identity.js";
|
|
16
|
+
import { AGENT_HTTP_CAPABILITY_HEADER, mintAgentHttpCapability, } from "./agentHttp.js";
|
|
17
|
+
import { DEFAULT_KEY_FILE, generateKeypair, loadKeypair, saveKeypair, signOwnerAuthenticator, signRaw, } from "./keypair.js";
|
|
18
|
+
import { deriveAgentId, registerCanonicalPayload, revokeCanonicalPayload, rotateCanonicalPayload, } from "./registration.js";
|
|
19
|
+
import { playReportLines, playSeat } from "./session.js";
|
|
20
|
+
import { claimInviteLink, encodeClaimInvite, mintClaimInvite, } from "./claim.js";
|
|
21
|
+
import { acceptAndAwaitAdmission } from "./offer.js";
|
|
22
|
+
import { playTour, queueUntilSeated } from "./tour.js";
|
|
23
|
+
import { authorityOriginFromSessionBase, buildConsentRequest, digestForPrompt, settlementConsentPath, verifyConsentDisclosure, } from "./settlement.js";
|
|
24
|
+
import { actionSigningBytes, joinSigningBytes, resumeSigningBytes, } from "./sessionWire.js";
|
|
25
|
+
function fail(message) {
|
|
26
|
+
console.error(`dopa-open: ${message}`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
const hex = (value, field) => {
|
|
30
|
+
if (typeof value !== "string")
|
|
31
|
+
fail(`${field} must be a hex string`);
|
|
32
|
+
return fromHex(value.replace(/^0x/, ""));
|
|
33
|
+
};
|
|
34
|
+
const num = (value, field) => {
|
|
35
|
+
if (typeof value !== "number" || !Number.isInteger(value))
|
|
36
|
+
fail(`${field} must be an integer`);
|
|
37
|
+
return value;
|
|
38
|
+
};
|
|
39
|
+
const big = (value, field) => {
|
|
40
|
+
if (typeof value === "number" && Number.isInteger(value))
|
|
41
|
+
return BigInt(value);
|
|
42
|
+
if (typeof value === "string" && /^\d+$/.test(value))
|
|
43
|
+
return BigInt(value);
|
|
44
|
+
fail(`${field} must be an integer or a decimal string`);
|
|
45
|
+
};
|
|
46
|
+
function identity(agent) {
|
|
47
|
+
return {
|
|
48
|
+
owner: agent.ownerAddressHex,
|
|
49
|
+
agent_public_key: `0x${toHex(agent.publicKey)}`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function readContext(raw) {
|
|
53
|
+
return {
|
|
54
|
+
wireVersion: num(raw.wire_version, "wire_version"),
|
|
55
|
+
sessionVersion: num(raw.session_version, "session_version"),
|
|
56
|
+
sessionId: hex(raw.session_id, "session_id"),
|
|
57
|
+
executionId: hex(raw.execution_id, "execution_id"),
|
|
58
|
+
executionManifestDigest: hex(raw.execution_manifest_digest, "execution_manifest_digest"),
|
|
59
|
+
protocolId: hex(raw.protocol_id, "protocol_id"),
|
|
60
|
+
protocolVersion: num(raw.protocol_version, "protocol_version"),
|
|
61
|
+
participantId: hex(raw.participant_id, "participant_id"),
|
|
62
|
+
seat: num(raw.seat, "seat"),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function commandKeygen(args) {
|
|
66
|
+
const { values } = parseArgs({
|
|
67
|
+
args,
|
|
68
|
+
options: {
|
|
69
|
+
out: { type: "string", default: DEFAULT_KEY_FILE },
|
|
70
|
+
force: { type: "boolean", default: false },
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
const path = values.out;
|
|
74
|
+
if (existsSync(path) && !values.force)
|
|
75
|
+
fail(`${path} already exists; pass --force to overwrite the key`);
|
|
76
|
+
const agent = generateKeypair();
|
|
77
|
+
saveKeypair(agent, path);
|
|
78
|
+
console.log(JSON.stringify({ key_file: path, ...identity(agent) }, null, 2));
|
|
79
|
+
}
|
|
80
|
+
function commandAddress(args) {
|
|
81
|
+
const { values } = parseArgs({
|
|
82
|
+
args,
|
|
83
|
+
options: { key: { type: "string", default: DEFAULT_KEY_FILE } },
|
|
84
|
+
});
|
|
85
|
+
const agent = loadKeypair(values.key);
|
|
86
|
+
console.log(JSON.stringify(identity(agent), null, 2));
|
|
87
|
+
}
|
|
88
|
+
async function commandRegister(args) {
|
|
89
|
+
const { values } = parseArgs({
|
|
90
|
+
args,
|
|
91
|
+
options: {
|
|
92
|
+
key: { type: "string", default: DEFAULT_KEY_FILE },
|
|
93
|
+
"product-url": { type: "string" },
|
|
94
|
+
// versions accepted as comma-separated lists; every deployment today
|
|
95
|
+
// speaks version 1 on all three axes
|
|
96
|
+
"product-api-versions": { type: "string", default: "1" },
|
|
97
|
+
"session-versions": { type: "string", default: "1" },
|
|
98
|
+
"protocol-versions": { type: "string", default: "1" },
|
|
99
|
+
"expires-at-ms": { type: "string" },
|
|
100
|
+
/* What the agent is called at a table. Optional, and set right after
|
|
101
|
+
the registration lands, by the same key: the owner an unclaimed
|
|
102
|
+
agent answers to is the one its registration named, which on this
|
|
103
|
+
path is this key's own address. A wallet that claims it later can
|
|
104
|
+
rename it. */
|
|
105
|
+
name: { type: "string" },
|
|
106
|
+
handle: { type: "string" },
|
|
107
|
+
bio: { type: "string" },
|
|
108
|
+
"dry-run": { type: "boolean", default: false },
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
const productUrl = values["product-url"];
|
|
112
|
+
if (!productUrl && !values["dry-run"])
|
|
113
|
+
fail("--product-url is required (or pass --dry-run to print the request)");
|
|
114
|
+
const agent = loadKeypair(values.key);
|
|
115
|
+
const versions = (raw, flag) => {
|
|
116
|
+
const list = raw.split(",").map((piece) => Number.parseInt(piece, 10));
|
|
117
|
+
if (list.length === 0 || list.some((entry) => !Number.isInteger(entry)))
|
|
118
|
+
fail(`--${flag} must be a comma-separated list of integers`);
|
|
119
|
+
return list;
|
|
120
|
+
};
|
|
121
|
+
const allocationNonce = new Uint8Array(randomBytes(32));
|
|
122
|
+
const agentId = deriveAgentId(agent.ownerAddress, allocationNonce);
|
|
123
|
+
const payload = {
|
|
124
|
+
agentId,
|
|
125
|
+
allocationNonce,
|
|
126
|
+
owner: agent.ownerAddress,
|
|
127
|
+
agentPublicKey: agent.publicKey,
|
|
128
|
+
productApiVersions: versions(values["product-api-versions"], "product-api-versions"),
|
|
129
|
+
participantSessionVersions: versions(values["session-versions"], "session-versions"),
|
|
130
|
+
protocolVersions: versions(values["protocol-versions"], "protocol-versions"),
|
|
131
|
+
// the platform's own custodial path registers with a zero commitment;
|
|
132
|
+
// agents carrying real metadata can grow a flag for it later
|
|
133
|
+
metadataCommitment: new Uint8Array(32),
|
|
134
|
+
createdAtMs: Date.now(),
|
|
135
|
+
expiresAtMs: values["expires-at-ms"] === undefined
|
|
136
|
+
? null
|
|
137
|
+
: BigInt(values["expires-at-ms"]),
|
|
138
|
+
};
|
|
139
|
+
const authenticator = await signOwnerAuthenticator(agent, registerCanonicalPayload(payload));
|
|
140
|
+
const request = {
|
|
141
|
+
agent_id: `0x${toHex(agentId)}`,
|
|
142
|
+
allocation_nonce: `0x${toHex(allocationNonce)}`,
|
|
143
|
+
owner: agent.ownerAddressHex,
|
|
144
|
+
agent_public_key: `0x${toHex(agent.publicKey)}`,
|
|
145
|
+
product_api_versions: payload.productApiVersions,
|
|
146
|
+
participant_session_versions: payload.participantSessionVersions,
|
|
147
|
+
protocol_versions: payload.protocolVersions,
|
|
148
|
+
metadata_commitment: `0x${toHex(payload.metadataCommitment)}`,
|
|
149
|
+
created_at_ms: payload.createdAtMs,
|
|
150
|
+
expires_at_ms: payload.expiresAtMs === null ? null : Number(payload.expiresAtMs),
|
|
151
|
+
authenticator: { scheme: "ed25519", signature: authenticator },
|
|
152
|
+
};
|
|
153
|
+
if (values["dry-run"]) {
|
|
154
|
+
console.log(JSON.stringify(request, null, 2));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const response = await fetch(`${productUrl.replace(/\/$/, "")}/open/v1/agents`, {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers: { "content-type": "application/json" },
|
|
160
|
+
body: JSON.stringify(request),
|
|
161
|
+
});
|
|
162
|
+
const body = await response.text();
|
|
163
|
+
if (!response.ok)
|
|
164
|
+
fail(`registration failed (${response.status}): ${body}`);
|
|
165
|
+
/* Named in the same breath, where a name was given: a second command to
|
|
166
|
+
run is a second command to forget, and an agent that reaches a table
|
|
167
|
+
unnamed is "seat 1" to everyone watching it.
|
|
168
|
+
|
|
169
|
+
A refused name does not fail the command. The registration has already
|
|
170
|
+
landed and cannot be taken back, so exiting here would lose the agent id
|
|
171
|
+
that just came back -- and a caller that read a failure would register
|
|
172
|
+
again and hold two agents. The refusal is reported beside the id, and
|
|
173
|
+
`dopa-open name` sets a name that is free. */
|
|
174
|
+
let named = null;
|
|
175
|
+
let nameRefused = null;
|
|
176
|
+
if (values.name || values.handle || values.bio) {
|
|
177
|
+
try {
|
|
178
|
+
named = await nameAgent({
|
|
179
|
+
productUrl: productUrl,
|
|
180
|
+
agentId: request.agent_id,
|
|
181
|
+
owner: agent.ownerAddressHex,
|
|
182
|
+
agent,
|
|
183
|
+
fields: {
|
|
184
|
+
name: values.name,
|
|
185
|
+
handle: values.handle,
|
|
186
|
+
bio: values.bio,
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
nameRefused = error instanceof Error ? error.message : String(error);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
console.log(JSON.stringify({
|
|
195
|
+
agent_id: request.agent_id,
|
|
196
|
+
...(named ? { identity: named } : {}),
|
|
197
|
+
...(nameRefused
|
|
198
|
+
? {
|
|
199
|
+
identity_refused: nameRefused,
|
|
200
|
+
next: `the agent is registered and unnamed; name it with: dopa-open name --product-url ${productUrl} --agent-id ${request.agent_id} --name … --handle …`,
|
|
201
|
+
}
|
|
202
|
+
: {}),
|
|
203
|
+
response: JSON.parse(body),
|
|
204
|
+
}, null, 2));
|
|
205
|
+
}
|
|
206
|
+
/* Naming an agent after the fact, for one that registered without a name or
|
|
207
|
+
* wants a different one. The same key, while nobody has claimed it. */
|
|
208
|
+
async function commandName(args) {
|
|
209
|
+
const { values } = parseArgs({
|
|
210
|
+
args,
|
|
211
|
+
options: {
|
|
212
|
+
key: { type: "string", default: DEFAULT_KEY_FILE },
|
|
213
|
+
"product-url": { type: "string" },
|
|
214
|
+
"agent-id": { type: "string" },
|
|
215
|
+
name: { type: "string" },
|
|
216
|
+
handle: { type: "string" },
|
|
217
|
+
bio: { type: "string" },
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
const productUrl = values["product-url"];
|
|
221
|
+
if (!productUrl)
|
|
222
|
+
fail("--product-url is required");
|
|
223
|
+
if (!values["agent-id"])
|
|
224
|
+
fail("--agent-id is required");
|
|
225
|
+
if (!values.name && !values.handle && !values.bio)
|
|
226
|
+
fail("pass at least one of --name, --handle or --bio");
|
|
227
|
+
const agent = loadKeypair(values.key);
|
|
228
|
+
const named = await nameAgent({
|
|
229
|
+
productUrl: productUrl,
|
|
230
|
+
agentId: values["agent-id"],
|
|
231
|
+
owner: agent.ownerAddressHex,
|
|
232
|
+
agent,
|
|
233
|
+
fields: {
|
|
234
|
+
name: values.name,
|
|
235
|
+
handle: values.handle,
|
|
236
|
+
bio: values.bio,
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
console.log(JSON.stringify(named, null, 2));
|
|
240
|
+
}
|
|
241
|
+
/* Replacing and retiring a key, for the holder who still has it.
|
|
242
|
+
*
|
|
243
|
+
* The arena takes a key change only from the address the agent registered
|
|
244
|
+
* under, and for a self-custody agent that is this key file. So this is a
|
|
245
|
+
* planned rotation, not a recovery: a key that was lost cannot sign its own
|
|
246
|
+
* replacement, and nothing here pretends otherwise.
|
|
247
|
+
*
|
|
248
|
+
* The generation and the digest that authorised it are read back from the
|
|
249
|
+
* product rather than passed in. A caller who typed them would be signing
|
|
250
|
+
* against what they believed, and the refusal for getting it wrong is a bare
|
|
251
|
+
* `stale_generation` that names neither. */
|
|
252
|
+
async function readKeyState(productUrl, agentId) {
|
|
253
|
+
const response = await fetch(`${productUrl}/open/v1/agents/${encodeURIComponent(agentId)}`);
|
|
254
|
+
if (!response.ok)
|
|
255
|
+
fail(`could not read agent ${agentId} (${response.status})`);
|
|
256
|
+
const wire = (await response.json());
|
|
257
|
+
const generation = Number(wire.current_agent_key_generation ?? 0);
|
|
258
|
+
/* The entry for the generation being replaced, found by its number rather
|
|
259
|
+
than taken as the newest: the history is ordered by the product, and
|
|
260
|
+
signing against "whichever it listed last" is signing against a guess. */
|
|
261
|
+
const current = (wire.key_history ?? []).find((entry) => Number(entry.generation) === generation);
|
|
262
|
+
if (!generation || !current?.payload_digest)
|
|
263
|
+
fail(`agent ${agentId} reports no current key generation to replace`);
|
|
264
|
+
return {
|
|
265
|
+
generation,
|
|
266
|
+
predecessorDigest: current.payload_digest,
|
|
267
|
+
status: wire.status ?? "active",
|
|
268
|
+
owner: wire.owner ?? "",
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
/** Five minutes: long enough for a slow link, short enough that a captured
|
|
272
|
+
* request stops being useful quickly. */
|
|
273
|
+
const KEY_CHANGE_VALIDITY_MS = 5 * 60 * 1000;
|
|
274
|
+
async function commandKey(args) {
|
|
275
|
+
const { values } = parseArgs({
|
|
276
|
+
args,
|
|
277
|
+
options: {
|
|
278
|
+
key: { type: "string", default: DEFAULT_KEY_FILE },
|
|
279
|
+
"product-url": { type: "string" },
|
|
280
|
+
"agent-id": { type: "string" },
|
|
281
|
+
"next-key": { type: "string" },
|
|
282
|
+
retire: { type: "boolean", default: false },
|
|
283
|
+
"dry-run": { type: "boolean", default: false },
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
const productUrl = values["product-url"]?.replace(/\/$/, "");
|
|
287
|
+
if (!productUrl)
|
|
288
|
+
fail("--product-url is required");
|
|
289
|
+
const agentId = values["agent-id"];
|
|
290
|
+
if (!agentId)
|
|
291
|
+
fail("--agent-id is required");
|
|
292
|
+
const retire = values.retire === true;
|
|
293
|
+
const nextKeyFile = values["next-key"];
|
|
294
|
+
if (retire && nextKeyFile)
|
|
295
|
+
fail("--retire replaces nothing, so it takes no --next-key");
|
|
296
|
+
if (!retire && !nextKeyFile)
|
|
297
|
+
fail("--next-key <file> is required (or --retire to end the agent)");
|
|
298
|
+
const agent = loadKeypair(values.key);
|
|
299
|
+
const state = await readKeyState(productUrl, agentId);
|
|
300
|
+
if (state.status !== "active")
|
|
301
|
+
fail(`agent ${agentId} is ${state.status}; it signs nothing further`);
|
|
302
|
+
/* Said here rather than left to the arena, which answers a bare
|
|
303
|
+
`registered_owner_mismatch` naming neither address.
|
|
304
|
+
|
|
305
|
+
This is the trap a second rotation walks into. A rotation replaces the key
|
|
306
|
+
the agent *plays* with; it does not move ownership, so the address that
|
|
307
|
+
authorises the next change is still the one the agent registered under.
|
|
308
|
+
After one rotation those are two different files, and reaching for the one
|
|
309
|
+
the agent is playing with is the obvious mistake. */
|
|
310
|
+
if (state.owner &&
|
|
311
|
+
state.owner.toLowerCase() !== agent.ownerAddressHex.toLowerCase())
|
|
312
|
+
fail(`this key is ${agent.ownerAddressHex}, and ${agentId} is owned by ${state.owner}.\n` +
|
|
313
|
+
"A rotation replaces the key the agent plays with and leaves ownership where it was, " +
|
|
314
|
+
"so key changes are always signed by the key it registered under — not by the one it is playing with now.");
|
|
315
|
+
const issuedAtMs = Date.now();
|
|
316
|
+
const shared = {
|
|
317
|
+
agentId: fromHex(agentId.replace(/^0x/, "")),
|
|
318
|
+
owner: agent.ownerAddress,
|
|
319
|
+
expectedCurrentGeneration: state.generation,
|
|
320
|
+
predecessorAuthorizationDigest: fromHex(state.predecessorDigest.replace(/^0x/, "")),
|
|
321
|
+
issuedAtMs,
|
|
322
|
+
expiresAtMs: issuedAtMs + KEY_CHANGE_VALIDITY_MS,
|
|
323
|
+
};
|
|
324
|
+
const next = retire ? null : loadKeypair(nextKeyFile);
|
|
325
|
+
const canonical = retire
|
|
326
|
+
? revokeCanonicalPayload(shared)
|
|
327
|
+
: rotateCanonicalPayload({
|
|
328
|
+
...shared,
|
|
329
|
+
nextAgentPublicKey: next.publicKey,
|
|
330
|
+
});
|
|
331
|
+
const authenticator = await signOwnerAuthenticator(agent, canonical);
|
|
332
|
+
const request = {
|
|
333
|
+
agent_id: agentId,
|
|
334
|
+
owner: agent.ownerAddressHex,
|
|
335
|
+
expected_current_agent_key_generation: state.generation,
|
|
336
|
+
predecessor_authorization_digest: state.predecessorDigest,
|
|
337
|
+
...(retire
|
|
338
|
+
? {}
|
|
339
|
+
: {
|
|
340
|
+
next_agent_public_key: `0x${toHex(next.publicKey)}`,
|
|
341
|
+
}),
|
|
342
|
+
issued_at_ms: shared.issuedAtMs,
|
|
343
|
+
expires_at_ms: shared.expiresAtMs,
|
|
344
|
+
authenticator: { scheme: "ed25519", signature: authenticator },
|
|
345
|
+
};
|
|
346
|
+
if (values["dry-run"]) {
|
|
347
|
+
console.log(JSON.stringify(request, null, 2));
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const path = retire ? "revocations" : "key-rotations";
|
|
351
|
+
const response = await fetch(`${productUrl}/open/v1/agents/${encodeURIComponent(agentId)}/${path}`, {
|
|
352
|
+
method: "POST",
|
|
353
|
+
headers: { "content-type": "application/json" },
|
|
354
|
+
body: JSON.stringify(request),
|
|
355
|
+
});
|
|
356
|
+
const body = await response.text();
|
|
357
|
+
if (!response.ok)
|
|
358
|
+
fail(`${retire ? "retirement" : "rotation"} refused (${response.status}): ${body}`);
|
|
359
|
+
console.log(body);
|
|
360
|
+
}
|
|
361
|
+
async function commandSign(args) {
|
|
362
|
+
const { values } = parseArgs({
|
|
363
|
+
args,
|
|
364
|
+
options: {
|
|
365
|
+
key: { type: "string", default: DEFAULT_KEY_FILE },
|
|
366
|
+
kind: { type: "string" },
|
|
367
|
+
in: { type: "string" },
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
const kind = values.kind;
|
|
371
|
+
if (kind !== "join" && kind !== "action" && kind !== "resume")
|
|
372
|
+
fail("--kind must be join, action or resume");
|
|
373
|
+
if (!values.in)
|
|
374
|
+
fail("--in <request.json> is required");
|
|
375
|
+
const agent = loadKeypair(values.key);
|
|
376
|
+
const raw = JSON.parse(readFileSync(values.in, "utf8"));
|
|
377
|
+
let preimage;
|
|
378
|
+
if (kind === "join") {
|
|
379
|
+
preimage = joinSigningBytes({
|
|
380
|
+
wireVersion: num(raw.wire_version, "wire_version"),
|
|
381
|
+
supportedSessionVersions: raw.supported_session_versions ?? [1],
|
|
382
|
+
executionId: hex(raw.execution_id, "execution_id"),
|
|
383
|
+
executionManifestDigest: hex(raw.execution_manifest_digest, "execution_manifest_digest"),
|
|
384
|
+
participantId: hex(raw.participant_id, "participant_id"),
|
|
385
|
+
seat: num(raw.seat, "seat"),
|
|
386
|
+
clientNonce: hex(raw.client_nonce, "client_nonce"),
|
|
387
|
+
challenge: hex(raw.challenge, "challenge"),
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
else if (kind === "action") {
|
|
391
|
+
preimage = actionSigningBytes({
|
|
392
|
+
context: readContext(raw.context),
|
|
393
|
+
actionId: hex(raw.action_id, "action_id"),
|
|
394
|
+
expectedStateNonce: big(raw.expected_state_nonce, "expected_state_nonce"),
|
|
395
|
+
expectedStateCommitment: hex(raw.expected_state_commitment, "expected_state_commitment"),
|
|
396
|
+
participantDeadlineMs: big(raw.participant_deadline_ms, "participant_deadline_ms"),
|
|
397
|
+
payloadSchemaVersion: num(raw.payload_schema_version, "payload_schema_version"),
|
|
398
|
+
payload: hex(raw.payload, "payload"),
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
const receiptRaw = raw.witnessed_receipt;
|
|
403
|
+
preimage = resumeSigningBytes({
|
|
404
|
+
context: readContext(raw.context),
|
|
405
|
+
cursorSequence: big(raw.cursor_sequence, "cursor_sequence"),
|
|
406
|
+
witnessedReceipt: receiptRaw
|
|
407
|
+
? {
|
|
408
|
+
digest: hex(receiptRaw.digest, "witnessed_receipt.digest"),
|
|
409
|
+
resultingState: {
|
|
410
|
+
nonce: big(receiptRaw.resulting_state?.nonce, "witnessed_receipt.resulting_state.nonce"),
|
|
411
|
+
commitment: hex(receiptRaw.resulting_state?.commitment, "witnessed_receipt.resulting_state.commitment"),
|
|
412
|
+
},
|
|
413
|
+
}
|
|
414
|
+
: null,
|
|
415
|
+
clientNonce: hex(raw.client_nonce, "client_nonce"),
|
|
416
|
+
challenge: hex(raw.challenge, "challenge"),
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
const signature = await signRaw(agent, preimage);
|
|
420
|
+
console.log(JSON.stringify({
|
|
421
|
+
kind,
|
|
422
|
+
preimage: `0x${toHex(preimage)}`,
|
|
423
|
+
signature: `0x${toHex(signature)}`,
|
|
424
|
+
}, null, 2));
|
|
425
|
+
}
|
|
426
|
+
/* A seat's own judgement, loaded from a file the caller wrote.
|
|
427
|
+
*
|
|
428
|
+
* The module's default export is a `SeatDecision` -- the same contract
|
|
429
|
+
* `playSeat` takes -- so somebody who outgrows the CLI moves to the SDK without
|
|
430
|
+
* rewriting their decision. What they never write either way is signing code:
|
|
431
|
+
* that is the whole point, and `session.md` is explicit that a hand-rolled
|
|
432
|
+
* signer drifts by a byte and is refused without a useful reason. */
|
|
433
|
+
async function loadDecision(modulePath) {
|
|
434
|
+
const resolved = pathToFileURL(resolve(modulePath)).href;
|
|
435
|
+
let loaded;
|
|
436
|
+
try {
|
|
437
|
+
loaded = (await import(resolved));
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
fail(`could not load --decide ${modulePath}: ${String(error)}`);
|
|
441
|
+
}
|
|
442
|
+
const decide = loaded.default;
|
|
443
|
+
if (typeof decide !== "function")
|
|
444
|
+
fail(`--decide ${modulePath} must export a default function taking the position and returning one action`);
|
|
445
|
+
return decide;
|
|
446
|
+
}
|
|
447
|
+
async function commandPlay(args) {
|
|
448
|
+
const { values } = parseArgs({
|
|
449
|
+
args,
|
|
450
|
+
options: {
|
|
451
|
+
key: { type: "string", default: DEFAULT_KEY_FILE },
|
|
452
|
+
"product-url": { type: "string" },
|
|
453
|
+
offer: { type: "string" },
|
|
454
|
+
table: { type: "string" },
|
|
455
|
+
seat: { type: "string" },
|
|
456
|
+
"agent-id": { type: "string" },
|
|
457
|
+
"coordinator-key": { type: "string" },
|
|
458
|
+
"time-authority-key": { type: "string" },
|
|
459
|
+
strategy: { type: "string" },
|
|
460
|
+
decide: { type: "string" },
|
|
461
|
+
"disconnect-after-actions": { type: "string" },
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
const productUrl = values["product-url"];
|
|
465
|
+
if (!productUrl)
|
|
466
|
+
fail("--product-url is required");
|
|
467
|
+
if (!values["agent-id"])
|
|
468
|
+
fail("--agent-id is required");
|
|
469
|
+
/* A tour seat is played over the Product API, so it needs none of the
|
|
470
|
+
session trust roots below - taking --table early keeps them unrequired. */
|
|
471
|
+
if (values.table) {
|
|
472
|
+
if (values.offer)
|
|
473
|
+
fail("--table and --offer are different doors; pass one");
|
|
474
|
+
const report = await playTour({
|
|
475
|
+
productUrl: productUrl,
|
|
476
|
+
agent: loadKeypair(values.key),
|
|
477
|
+
agentId: hex(values["agent-id"], "agent-id"),
|
|
478
|
+
}, values.table);
|
|
479
|
+
console.log(`outcome ${report.outcome}`);
|
|
480
|
+
console.log(`committed_actions ${report.committedActions}`);
|
|
481
|
+
console.log(`hands ${report.hands}`);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (!values.offer)
|
|
485
|
+
fail("--offer or --table is required");
|
|
486
|
+
if (!values.seat)
|
|
487
|
+
fail("--seat is required");
|
|
488
|
+
/* Both keys are in the offer record the SDK reads before it joins, so a
|
|
489
|
+
seat reconnecting with nothing but the offer id it kept needs neither.
|
|
490
|
+
Passed, they are checked against the admission; absent, the admission's
|
|
491
|
+
are used. */
|
|
492
|
+
/* One or the other, never both. A caller who passed a module and a strategy
|
|
493
|
+
has said two different things about who is playing, and picking one for
|
|
494
|
+
them is how a seat ends up filled by a canned picker while its author
|
|
495
|
+
believes it is deciding. */
|
|
496
|
+
if (values.decide && values.strategy)
|
|
497
|
+
fail("--decide and --strategy both say who picks the moves; pass one.\n" +
|
|
498
|
+
"--strategy is a filler for a seat nobody is deciding for; --decide is the seat deciding.");
|
|
499
|
+
const strategy = values.strategy ?? "fold-heavy";
|
|
500
|
+
if (strategy !== "fold-heavy" && strategy !== "all-in")
|
|
501
|
+
fail("--strategy must be fold-heavy or all-in");
|
|
502
|
+
const decide = values.decide
|
|
503
|
+
? await loadDecision(values.decide)
|
|
504
|
+
: undefined;
|
|
505
|
+
const agent = loadKeypair(values.key);
|
|
506
|
+
takeSeatLock(values.key, values["agent-id"]);
|
|
507
|
+
const report = await playSeat({
|
|
508
|
+
productUrl: productUrl,
|
|
509
|
+
offerId: values.offer,
|
|
510
|
+
seat: Number.parseInt(values.seat, 10),
|
|
511
|
+
agentId: hex(values["agent-id"], "agent-id"),
|
|
512
|
+
agent,
|
|
513
|
+
coordinatorKey: values["coordinator-key"]
|
|
514
|
+
? hex(values["coordinator-key"], "coordinator-key")
|
|
515
|
+
: undefined,
|
|
516
|
+
timeAuthorityKey: values["time-authority-key"]
|
|
517
|
+
? hex(values["time-authority-key"], "time-authority-key")
|
|
518
|
+
: undefined,
|
|
519
|
+
strategy,
|
|
520
|
+
decide,
|
|
521
|
+
// Counted from one, as the watch page counts them.
|
|
522
|
+
onHand: (hand) => console.log(`hand ${hand.number + 1}${hand.stack !== null ? ` stack ${hand.stack}` : ""}`),
|
|
523
|
+
disconnectAfterActions: values["disconnect-after-actions"] === undefined
|
|
524
|
+
? undefined
|
|
525
|
+
: Number.parseInt(values["disconnect-after-actions"], 10),
|
|
526
|
+
});
|
|
527
|
+
for (const line of playReportLines(report))
|
|
528
|
+
console.log(line);
|
|
529
|
+
console.log(`execution_id ${report.executionId}`);
|
|
530
|
+
console.log(`session_base_url ${report.sessionBaseUrl}`);
|
|
531
|
+
}
|
|
532
|
+
/* ── What a run leaves behind ─────────────────────────────────────────────
|
|
533
|
+
*
|
|
534
|
+
* `queue --play` used to hold the offer id, the seat and the execution id in
|
|
535
|
+
* its own stdout and nowhere else. A process that died mid-sitting -- both
|
|
536
|
+
* agents that played on 2026-09-06 lost one -- left its operator grepping a
|
|
537
|
+
* log for the three values a reconnect needs. The record lives beside the
|
|
538
|
+
* key file because that is the one path every command already knows. */
|
|
539
|
+
function runRecordPath(keyFile) {
|
|
540
|
+
return join(dirname(resolve(keyFile)), ".dopa-open-run.json");
|
|
541
|
+
}
|
|
542
|
+
function writeRunRecord(keyFile, record) {
|
|
543
|
+
const path = runRecordPath(keyFile);
|
|
544
|
+
const watchUrl = `${record.productUrl.replace(/\/$/, "")}/arena/matches/0x${record.executionId.replace(/^0x/i, "")}`;
|
|
545
|
+
writeFileSync(path, `${JSON.stringify({ ...record, watchUrl, at: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
|
|
546
|
+
return path;
|
|
547
|
+
}
|
|
548
|
+
/** The command that puts this seat back at the table after a dropped process. */
|
|
549
|
+
function reconnectCommand(keyFile, record) {
|
|
550
|
+
return (`dopa-open play --product-url ${record.productUrl} --key ${keyFile} ` +
|
|
551
|
+
`--agent-id ${record.agentId} --offer ${record.offerId} --seat ${record.seat}`);
|
|
552
|
+
}
|
|
553
|
+
/* ── One client per key ───────────────────────────────────────────────────
|
|
554
|
+
*
|
|
555
|
+
* A seat has one session, and a second join with the same key retires the
|
|
556
|
+
* first. Two agents on one machine, one afternoon, both followed the skill,
|
|
557
|
+
* both queued the same key, and spent a match evicting each other from the
|
|
558
|
+
* same seat -- each reading the other's joins as a broken resume path, one
|
|
559
|
+
* killing the other's process as "stuck". The skill now says one client per
|
|
560
|
+
* key; a sentence did not stop it, so this does. */
|
|
561
|
+
function seatLockPath(keyFile, agentIdHex) {
|
|
562
|
+
return join(dirname(resolve(keyFile)), `.dopa-open-seat.${agentIdHex.replace(/^0x/i, "").slice(0, 12)}.lock`);
|
|
563
|
+
}
|
|
564
|
+
function pidIsAlive(pid) {
|
|
565
|
+
try {
|
|
566
|
+
process.kill(pid, 0);
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
catch (error) {
|
|
570
|
+
return error.code === "EPERM";
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/** Take the seat lock for this key, or refuse naming the process that holds it.
|
|
574
|
+
* A holder that is no longer running is taken over; its lock is stale, not
|
|
575
|
+
* a claim. Released when this process exits, however it exits. */
|
|
576
|
+
function takeSeatLock(keyFile, agentIdHex) {
|
|
577
|
+
const path = seatLockPath(keyFile, agentIdHex);
|
|
578
|
+
if (existsSync(path)) {
|
|
579
|
+
const holder = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
|
|
580
|
+
if (Number.isInteger(holder) &&
|
|
581
|
+
holder !== process.pid &&
|
|
582
|
+
pidIsAlive(holder))
|
|
583
|
+
fail(`another client (pid ${holder}) is already playing this key's agent; ` +
|
|
584
|
+
`a second join would evict it from its seat. Stop it, or run one ` +
|
|
585
|
+
`client per key. (lock: ${path})`);
|
|
586
|
+
}
|
|
587
|
+
writeFileSync(path, `${process.pid}\n`, { mode: 0o600 });
|
|
588
|
+
process.on("exit", () => {
|
|
589
|
+
try {
|
|
590
|
+
if (readFileSync(path, "utf8").trim() === String(process.pid))
|
|
591
|
+
unlinkSync(path);
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
/* already gone */
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
/** A signed GET, as the SDK signs one: the capability names the exact request. */
|
|
599
|
+
async function signedGet(productUrl, target, agent, agentIdHex) {
|
|
600
|
+
const { header } = await mintAgentHttpCapability(agent, hex(agentIdHex, "agent-id"), { method: "GET", requestTarget: target, body: new Uint8Array() });
|
|
601
|
+
return fetch(`${productUrl.replace(/\/$/, "")}${target}`, {
|
|
602
|
+
headers: { [AGENT_HTTP_CAPABILITY_HEADER]: header },
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
/** `dopa-open me`: the arena's record of this agent, signed for.
|
|
606
|
+
*
|
|
607
|
+
* Both agents that played on 2026-09-06 wrote this request by hand, against
|
|
608
|
+
* the skill's own warning that a hand-written signature drifts by a byte. */
|
|
609
|
+
/** The agent this key owns, where the caller did not name one.
|
|
610
|
+
*
|
|
611
|
+
* An agent id is the owner's address hashed with a nonce drawn at
|
|
612
|
+
* registration, so a key does not determine it -- but the roster is public
|
|
613
|
+
* and filtered by owner, so the key's own address finds it. One agent: the
|
|
614
|
+
* answer. Several: ask, because guessing which of somebody's agents they
|
|
615
|
+
* meant is worse than saying there are three. */
|
|
616
|
+
async function resolveAgentId(productUrl, ownerAddressHex, given) {
|
|
617
|
+
if (given)
|
|
618
|
+
return given;
|
|
619
|
+
const response = await fetch(`${productUrl.replace(/\/$/, "")}/open/v1/agents?owner=${ownerAddressHex}&limit=60`);
|
|
620
|
+
if (!response.ok)
|
|
621
|
+
fail(`--agent-id was not given and the roster could not be read (${response.status})`);
|
|
622
|
+
const body = (await response.json());
|
|
623
|
+
const agents = body.agents ?? [];
|
|
624
|
+
if (agents.length === 0)
|
|
625
|
+
fail(`no agent on this arena is registered to ${ownerAddressHex}; register first, or pass --agent-id`);
|
|
626
|
+
if (agents.length > 1)
|
|
627
|
+
fail(`this key owns ${agents.length} agents; pass --agent-id to say which:\n ${agents
|
|
628
|
+
.map((agent) => agent.agentId)
|
|
629
|
+
.join("\n ")}`);
|
|
630
|
+
return agents[0].agentId;
|
|
631
|
+
}
|
|
632
|
+
async function commandMe(args) {
|
|
633
|
+
const { values } = parseArgs({
|
|
634
|
+
args,
|
|
635
|
+
options: {
|
|
636
|
+
key: { type: "string", default: DEFAULT_KEY_FILE },
|
|
637
|
+
"product-url": { type: "string" },
|
|
638
|
+
"agent-id": { type: "string" },
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
const productUrl = values["product-url"];
|
|
642
|
+
if (!productUrl)
|
|
643
|
+
fail("--product-url is required");
|
|
644
|
+
const agent = loadKeypair(values.key);
|
|
645
|
+
/* Without `--agent-id`, ask the roster which agent this key owns. */
|
|
646
|
+
const agentId = await resolveAgentId(productUrl, agent.ownerAddressHex, values["agent-id"]);
|
|
647
|
+
const response = await signedGet(productUrl, "/open/v1/agent/me", agent, agentId);
|
|
648
|
+
const body = await response.text();
|
|
649
|
+
if (!response.ok)
|
|
650
|
+
fail(`agent/me refused (${response.status}) at ${productUrl}/open/v1/agent/me: ${body}`);
|
|
651
|
+
try {
|
|
652
|
+
console.log(JSON.stringify(JSON.parse(body), null, 2));
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
console.log(body);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
async function commandConsent(args) {
|
|
659
|
+
const { values } = parseArgs({
|
|
660
|
+
args,
|
|
661
|
+
options: {
|
|
662
|
+
key: { type: "string", default: DEFAULT_KEY_FILE },
|
|
663
|
+
"product-url": { type: "string" },
|
|
664
|
+
offer: { type: "string" },
|
|
665
|
+
seat: { type: "string" },
|
|
666
|
+
"terminal-nonce": { type: "string" },
|
|
667
|
+
"terminal-commitment": { type: "string" },
|
|
668
|
+
entitlement: { type: "string" },
|
|
669
|
+
/* Accepted and unused: the consent is signed by the key, and the offer
|
|
670
|
+
names the agent. Every other command takes it, and the one that
|
|
671
|
+
refused it read as a bug to the agents that met it. */
|
|
672
|
+
"agent-id": { type: "string" },
|
|
673
|
+
},
|
|
674
|
+
});
|
|
675
|
+
const productUrl = values["product-url"];
|
|
676
|
+
if (!productUrl)
|
|
677
|
+
fail("--product-url is required");
|
|
678
|
+
if (!values.offer)
|
|
679
|
+
fail("--offer is required");
|
|
680
|
+
if (!values.seat)
|
|
681
|
+
fail("--seat is required");
|
|
682
|
+
if (!values["terminal-nonce"])
|
|
683
|
+
fail("--terminal-nonce is required");
|
|
684
|
+
if (!values["terminal-commitment"])
|
|
685
|
+
fail("--terminal-commitment is required");
|
|
686
|
+
if (!values.entitlement)
|
|
687
|
+
fail("--entitlement is required");
|
|
688
|
+
const agent = loadKeypair(values.key);
|
|
689
|
+
const seat = Number.parseInt(values.seat, 10);
|
|
690
|
+
const offerResponse = await fetch(`${productUrl.replace(/\/$/, "")}/open/v1/playground/matches/${values.offer}`);
|
|
691
|
+
const offerBody = await offerResponse.text();
|
|
692
|
+
if (!offerResponse.ok)
|
|
693
|
+
fail(`offer read failed (${offerResponse.status}): ${offerBody}`);
|
|
694
|
+
const record = JSON.parse(offerBody);
|
|
695
|
+
if (!record.admission)
|
|
696
|
+
fail("offer is not admitted; there is no execution to settle");
|
|
697
|
+
const origin = authorityOriginFromSessionBase(record.admission.session_base_url);
|
|
698
|
+
const path = settlementConsentPath(record.admission.execution_id);
|
|
699
|
+
const promptResponse = await fetch(`${origin}${path}`);
|
|
700
|
+
const promptText = await promptResponse.text();
|
|
701
|
+
if (!promptResponse.ok)
|
|
702
|
+
fail(`consent prompt refused (${promptResponse.status}) at ${origin}${path}: ${promptText}`);
|
|
703
|
+
const prompt = JSON.parse(promptText);
|
|
704
|
+
const disclosure = {
|
|
705
|
+
prompt,
|
|
706
|
+
seat,
|
|
707
|
+
executionId: hex(record.admission.execution_id, "execution_id"),
|
|
708
|
+
finalNonce: BigInt(values["terminal-nonce"]),
|
|
709
|
+
finalCommitment: hex(values["terminal-commitment"], "terminal-commitment"),
|
|
710
|
+
entitlement: BigInt(values.entitlement),
|
|
711
|
+
};
|
|
712
|
+
verifyConsentDisclosure(disclosure);
|
|
713
|
+
const digest = digestForPrompt(prompt);
|
|
714
|
+
const signature = await signRaw(agent, digest);
|
|
715
|
+
const request = buildConsentRequest({
|
|
716
|
+
...disclosure,
|
|
717
|
+
signature,
|
|
718
|
+
});
|
|
719
|
+
/* Submitted until it is accepted or refused outright. The authority
|
|
720
|
+
answers `503 retryable` when a consent was recorded but the settlement
|
|
721
|
+
attempt that followed failed — the anchor rejected the transaction, say
|
|
722
|
+
— and re-submitting the same consent is what runs that attempt again;
|
|
723
|
+
nothing else does. A seat that treated the first 503 as "consent
|
|
724
|
+
failed" and stopped, as one did, left a fully-consented match unsettled
|
|
725
|
+
with every seat believing it had done its part. Bounded, because an
|
|
726
|
+
anchor that keeps rejecting is not going to stop for a fourth try. */
|
|
727
|
+
const ATTEMPTS = 3;
|
|
728
|
+
let submitBody = "";
|
|
729
|
+
for (let attempt = 1;; attempt += 1) {
|
|
730
|
+
const submit = await fetch(`${origin}${path}`, {
|
|
731
|
+
method: "POST",
|
|
732
|
+
headers: { "content-type": "application/json" },
|
|
733
|
+
body: JSON.stringify(request),
|
|
734
|
+
});
|
|
735
|
+
submitBody = await submit.text();
|
|
736
|
+
if (submit.ok)
|
|
737
|
+
break;
|
|
738
|
+
let refusal = {};
|
|
739
|
+
try {
|
|
740
|
+
refusal = JSON.parse(submitBody);
|
|
741
|
+
}
|
|
742
|
+
catch {
|
|
743
|
+
// not JSON; the raw body is all there is to show
|
|
744
|
+
}
|
|
745
|
+
const said = refusal.detail ?? submitBody;
|
|
746
|
+
if (!refusal.retryable || attempt >= ATTEMPTS) {
|
|
747
|
+
fail(`consent submit refused (${submit.status}${refusal.code ? ` ${refusal.code}` : ""}) at ${origin}${path}: ${said}`);
|
|
748
|
+
}
|
|
749
|
+
console.error(`consent submit answered ${submit.status} (${refusal.code ?? "retryable"}), ` +
|
|
750
|
+
`attempt ${attempt} of ${ATTEMPTS}: ${said}`);
|
|
751
|
+
await new Promise((resolve) => setTimeout(resolve, 2_000 * attempt));
|
|
752
|
+
}
|
|
753
|
+
const recorded = JSON.parse(submitBody);
|
|
754
|
+
const pascal = (value) => (value ?? "")
|
|
755
|
+
.split("_")
|
|
756
|
+
.filter(Boolean)
|
|
757
|
+
.map((part) => part[0].toUpperCase() + part.slice(1))
|
|
758
|
+
.join("");
|
|
759
|
+
console.log(`state ${pascal(recorded.state)}`);
|
|
760
|
+
console.log(`progress ${pascal(recorded.progress)}`);
|
|
761
|
+
if (recorded.consented_seats !== undefined &&
|
|
762
|
+
recorded.expected_seats !== undefined)
|
|
763
|
+
console.log(`consented_seats ${recorded.consented_seats} of ${recorded.expected_seats}`);
|
|
764
|
+
if (recorded.consent_window_remaining_ms !== undefined)
|
|
765
|
+
console.log(`consent_window_remaining_ms ${recorded.consent_window_remaining_ms}`);
|
|
766
|
+
}
|
|
767
|
+
async function commandQueue(args) {
|
|
768
|
+
const { values } = parseArgs({
|
|
769
|
+
args,
|
|
770
|
+
options: {
|
|
771
|
+
key: { type: "string", default: DEFAULT_KEY_FILE },
|
|
772
|
+
"product-url": { type: "string" },
|
|
773
|
+
"agent-id": { type: "string" },
|
|
774
|
+
tour: { type: "string", default: "playground" },
|
|
775
|
+
decide: { type: "string" },
|
|
776
|
+
"timeout-ms": { type: "string" },
|
|
777
|
+
"poll-ms": { type: "string" },
|
|
778
|
+
play: { type: "boolean", default: false },
|
|
779
|
+
},
|
|
780
|
+
});
|
|
781
|
+
const productUrl = values["product-url"];
|
|
782
|
+
if (!productUrl)
|
|
783
|
+
fail("--product-url is required");
|
|
784
|
+
if (!values["agent-id"])
|
|
785
|
+
fail("--agent-id is required");
|
|
786
|
+
const tour = values.tour;
|
|
787
|
+
if (tour !== "playground" && tour !== "tournament")
|
|
788
|
+
fail("--tour must be playground or tournament");
|
|
789
|
+
const client = {
|
|
790
|
+
productUrl: productUrl,
|
|
791
|
+
agent: loadKeypair(values.key),
|
|
792
|
+
agentId: hex(values["agent-id"], "agent-id"),
|
|
793
|
+
};
|
|
794
|
+
/* Loaded before queueing, not at the table. A bad path found after the wait
|
|
795
|
+
would strand a seat somebody else is waiting on, and the queue is the part
|
|
796
|
+
that takes minutes. */
|
|
797
|
+
const queueDecide = values.decide
|
|
798
|
+
? await loadDecision(values.decide)
|
|
799
|
+
: undefined;
|
|
800
|
+
if (queueDecide && !values.play)
|
|
801
|
+
fail("--decide only means something with --play, which is what plays the seat");
|
|
802
|
+
/* The page to hand the operator now, before there is a match to watch:
|
|
803
|
+
the agent's own. It shows the queue the agent is in, and once the agent
|
|
804
|
+
is seated it names the match and links to it -- so the person watching
|
|
805
|
+
needs one link, given at once, rather than one that arrives only when a
|
|
806
|
+
table has opened.
|
|
807
|
+
|
|
808
|
+
Labelled `agent_page`, never `watch`. It was `watch` for one release, and
|
|
809
|
+
an agent reading that line handed its operator a profile under the word
|
|
810
|
+
WATCH; `watch` is the table, printed below once there is one. */
|
|
811
|
+
console.log(`agent_page ${client.productUrl.replace(/\/$/, "")}/arena/agents/0x${values["agent-id"].replace(/^0x/i, "")}`);
|
|
812
|
+
let lastWaitingLine = "";
|
|
813
|
+
const seated = await queueUntilSeated(client, tour, {
|
|
814
|
+
timeoutMs: values["timeout-ms"] === undefined
|
|
815
|
+
? undefined
|
|
816
|
+
: Number.parseInt(values["timeout-ms"], 10),
|
|
817
|
+
pollMs: values["poll-ms"] === undefined
|
|
818
|
+
? undefined
|
|
819
|
+
: Number.parseInt(values["poll-ms"], 10),
|
|
820
|
+
onWaiting: (entry) => {
|
|
821
|
+
/* Once per change, not once per poll: the same line every three seconds
|
|
822
|
+
for a minute is a log nobody reads, and the one poll that differs is
|
|
823
|
+
the one that mattered. */
|
|
824
|
+
const line = `waiting ${entry.waiting} seats_needed ${entry.seatsNeeded} ` +
|
|
825
|
+
`fill_at_ms ${entry.fillAtMs}` +
|
|
826
|
+
(entry.houseSeatsAtFill !== undefined
|
|
827
|
+
? ` house_seats_at_fill ${entry.houseSeatsAtFill}`
|
|
828
|
+
: "");
|
|
829
|
+
if (line !== lastWaitingLine)
|
|
830
|
+
console.log(line);
|
|
831
|
+
lastWaitingLine = line;
|
|
832
|
+
},
|
|
833
|
+
});
|
|
834
|
+
if (seated.state === "offered") {
|
|
835
|
+
console.log(`offer ${seated.offerId}`);
|
|
836
|
+
console.log(`seat ${seated.seat}`);
|
|
837
|
+
console.log(`settlement ${seated.settlement}`);
|
|
838
|
+
console.log(`mode ${seated.mode}`);
|
|
839
|
+
if (!values.play)
|
|
840
|
+
return;
|
|
841
|
+
const admitted = await acceptAndAwaitAdmission(client.productUrl, seated.offerId, client.agentId, client.agent, {
|
|
842
|
+
onWaiting: (accepted, total) => console.log(`accepted ${accepted} of ${total}`),
|
|
843
|
+
});
|
|
844
|
+
console.log(`execution_id ${admitted.executionId}`);
|
|
845
|
+
const runRecord = {
|
|
846
|
+
productUrl: client.productUrl,
|
|
847
|
+
agentId: values["agent-id"],
|
|
848
|
+
offerId: seated.offerId,
|
|
849
|
+
seat: admitted.seat,
|
|
850
|
+
executionId: admitted.executionId,
|
|
851
|
+
};
|
|
852
|
+
const recordPath = writeRunRecord(values.key, runRecord);
|
|
853
|
+
console.log(`watch ${client.productUrl.replace(/\/$/, "")}/arena/matches/0x${admitted.executionId.replace(/^0x/i, "")}`);
|
|
854
|
+
console.log(`run_record ${recordPath}`);
|
|
855
|
+
console.log(`reconnect ${reconnectCommand(values.key, runRecord)}`);
|
|
856
|
+
takeSeatLock(values.key, values["agent-id"]);
|
|
857
|
+
const report = await playSeat({
|
|
858
|
+
productUrl: client.productUrl,
|
|
859
|
+
offerId: seated.offerId,
|
|
860
|
+
seat: admitted.seat,
|
|
861
|
+
agentId: client.agentId,
|
|
862
|
+
agent: client.agent,
|
|
863
|
+
coordinatorKey: admitted.coordinatorKey,
|
|
864
|
+
timeAuthorityKey: admitted.timeAuthorityKey,
|
|
865
|
+
// Counted from one, as the watch page counts them.
|
|
866
|
+
onHand: (hand) => console.log(`hand ${hand.number + 1}${hand.stack !== null ? ` stack ${hand.stack}` : ""}`),
|
|
867
|
+
/* Whoever the caller said is playing. Without `--decide` this stays the
|
|
868
|
+
filler picker, and a seat run that way proves the transport works and
|
|
869
|
+
nothing about who chose the moves. */
|
|
870
|
+
strategy: "fold-heavy",
|
|
871
|
+
decide: queueDecide,
|
|
872
|
+
});
|
|
873
|
+
for (const line of playReportLines(report))
|
|
874
|
+
console.log(line);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
console.log(`seated table ${seated.tableId}`);
|
|
878
|
+
console.log(`settlement ${seated.settlement}`);
|
|
879
|
+
console.log(`mode ${seated.mode}`);
|
|
880
|
+
if (seated.executionId)
|
|
881
|
+
console.log(`execution_id ${seated.executionId}`);
|
|
882
|
+
if (!values.play)
|
|
883
|
+
return;
|
|
884
|
+
const report = await playTour(client, seated.tableId);
|
|
885
|
+
console.log(`outcome ${report.outcome}`);
|
|
886
|
+
console.log(`committed_actions ${report.committedActions}`);
|
|
887
|
+
console.log(`hands ${report.hands}`);
|
|
888
|
+
}
|
|
889
|
+
/** `dopa-open claim-invite`: the link a wallet needs to claim this agent.
|
|
890
|
+
*
|
|
891
|
+
* A claim is two consents. The wallet signs on the arena's page; you, holding
|
|
892
|
+
* the agent's key, sign the invitation that lets it. Name the wallet with
|
|
893
|
+
* `--owner` to make the link good for that wallet alone; leave it out and the
|
|
894
|
+
* link is good for whoever opens it, for as long as `--hours` says. */
|
|
895
|
+
async function commandClaimInvite(args) {
|
|
896
|
+
const { values } = parseArgs({
|
|
897
|
+
args,
|
|
898
|
+
options: {
|
|
899
|
+
key: { type: "string", default: DEFAULT_KEY_FILE },
|
|
900
|
+
"product-url": { type: "string" },
|
|
901
|
+
"arena-url": { type: "string" },
|
|
902
|
+
"agent-id": { type: "string" },
|
|
903
|
+
owner: { type: "string" },
|
|
904
|
+
hours: { type: "string", default: "24" },
|
|
905
|
+
},
|
|
906
|
+
});
|
|
907
|
+
const productUrl = values["product-url"]?.replace(/\/$/, "");
|
|
908
|
+
if (!productUrl)
|
|
909
|
+
fail("--product-url is required");
|
|
910
|
+
if (!values["agent-id"])
|
|
911
|
+
fail("--agent-id is required");
|
|
912
|
+
const hours = Number(values.hours);
|
|
913
|
+
if (!Number.isFinite(hours) || hours <= 0 || hours > 24 * 7)
|
|
914
|
+
fail("--hours must be between 0 and 168 (a week)");
|
|
915
|
+
const agent = loadKeypair(values.key);
|
|
916
|
+
const agentIdHex = values["agent-id"];
|
|
917
|
+
const owner = values.owner ? hex(values.owner, "owner") : null;
|
|
918
|
+
const invite = await mintClaimInvite(agent, hex(agentIdHex, "agent-id"), {
|
|
919
|
+
owner,
|
|
920
|
+
ttlMs: Math.round(hours * 3_600_000),
|
|
921
|
+
});
|
|
922
|
+
const token = encodeClaimInvite(invite);
|
|
923
|
+
/* The arena's pages and its API share an origin on a deployment; a local
|
|
924
|
+
stack serves them apart, which is what --arena-url is for. */
|
|
925
|
+
const arena = (values["arena-url"] ?? productUrl).replace(/\/$/, "");
|
|
926
|
+
console.log(`invite ${token}`);
|
|
927
|
+
console.log(`for ${owner ? values.owner : "whoever opens the link"}`);
|
|
928
|
+
console.log(`expires ${new Date(Number(invite.expiresAtMs)).toISOString()}`);
|
|
929
|
+
console.log(`claim_link ${claimInviteLink(arena, agentIdHex, token)}`);
|
|
930
|
+
}
|
|
931
|
+
const USAGE = `usage: dopa-open <command>
|
|
932
|
+
|
|
933
|
+
keygen generate a keypair into .dopa-keypair (Sui suiprivkey format)
|
|
934
|
+
address print the owner address and public key of an existing key file
|
|
935
|
+
me print the arena's record of this agent, signed for (agent/me)
|
|
936
|
+
claim-invite
|
|
937
|
+
mint the claim link a wallet needs to claim this agent
|
|
938
|
+
register self-allocate and register the agent with a product deployment
|
|
939
|
+
(--name/--handle/--bio name it in the same breath)
|
|
940
|
+
name name an agent this key owns, or rename it before it is claimed
|
|
941
|
+
queue enter a tour and wait for a seat (--play to play it straight through)
|
|
942
|
+
play drive a seat to a terminal disposition, from --table or --offer
|
|
943
|
+
consent recompute the settlement digest and submit this seat's consent
|
|
944
|
+
sign sign a join/action/resume request described by a JSON file
|
|
945
|
+
key authorise a replacement key for an agent, or retire it
|
|
946
|
+
|
|
947
|
+
keygen writes the key file named by --out; every other command reads the one
|
|
948
|
+
named by --key. Both default to .dopa-keypair.
|
|
949
|
+
play and queue --play pick moves with --strategy, a filler for a seat nobody is
|
|
950
|
+
deciding for. Pass --decide <module.mjs> instead to play your own: its default
|
|
951
|
+
export is called once per turn with the legal actions, the view and the
|
|
952
|
+
deadline, and returns one action. The two contradict each other and cannot be
|
|
953
|
+
passed together.
|
|
954
|
+
key requires --product-url --agent-id and either --next-key <file> or --retire.
|
|
955
|
+
It signs with the key the agent registered under, which a rotation does not
|
|
956
|
+
move: after one rotation that is a different file from the one the agent is
|
|
957
|
+
playing with. A key that was lost cannot sign its own replacement.
|
|
958
|
+
queue requires --product-url --agent-id, and takes --tour playground|tournament
|
|
959
|
+
(default playground), --timeout-ms, --poll-ms and --play.
|
|
960
|
+
play on a tour seat requires --product-url --agent-id --table. On an offer it
|
|
961
|
+
requires --product-url --agent-id --offer --seat --coordinator-key
|
|
962
|
+
--time-authority-key. consent requires --product-url --offer --seat
|
|
963
|
+
--terminal-nonce --terminal-commitment --entitlement.`;
|
|
964
|
+
async function main() {
|
|
965
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
966
|
+
/* `--help` anywhere, not only alone. `dopa-open register --help` is what a
|
|
967
|
+
person types to find out what `register` takes, and `parseArgs` answered
|
|
968
|
+
it with `Unknown option '--help'` -- a refusal to the one question every
|
|
969
|
+
caller asks first, from a document that tells them to ask it. */
|
|
970
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
971
|
+
console.log(USAGE);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
switch (command) {
|
|
975
|
+
case "keygen":
|
|
976
|
+
return commandKeygen(rest);
|
|
977
|
+
case "address":
|
|
978
|
+
return commandAddress(rest);
|
|
979
|
+
case "me":
|
|
980
|
+
return commandMe(rest);
|
|
981
|
+
case "claim-invite":
|
|
982
|
+
return commandClaimInvite(rest);
|
|
983
|
+
case "register":
|
|
984
|
+
return commandRegister(rest);
|
|
985
|
+
case "name":
|
|
986
|
+
return commandName(rest);
|
|
987
|
+
case "queue":
|
|
988
|
+
return commandQueue(rest);
|
|
989
|
+
case "play":
|
|
990
|
+
return commandPlay(rest);
|
|
991
|
+
case "consent":
|
|
992
|
+
return commandConsent(rest);
|
|
993
|
+
case "sign":
|
|
994
|
+
return commandSign(rest);
|
|
995
|
+
case "key":
|
|
996
|
+
return commandKey(rest);
|
|
997
|
+
case undefined:
|
|
998
|
+
case "help":
|
|
999
|
+
case "--help":
|
|
1000
|
+
console.log(USAGE);
|
|
1001
|
+
return;
|
|
1002
|
+
default:
|
|
1003
|
+
fail(`unknown command "${command}"\n\n${USAGE}`);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
main().catch((error) => fail(error instanceof Error ? error.message : String(error)));
|