totemlayer 0.1.1 → 0.1.3
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 +113 -3
- package/bin/collaboration-companion.mjs +328 -0
- package/bin/collaboration-listener.mjs +425 -0
- package/bin/local-mcp.mjs +97 -0
- package/bin/managed-collaboration.mjs +94 -0
- package/bin/mcp-install.mjs +155 -0
- package/bin/mcp.mjs +777 -0
- package/bin/network-runtime.mjs +685 -0
- package/bin/network.mjs +323 -0
- package/bin/presenter-mcp.mjs +238 -0
- package/bin/repository.d.mts +27 -0
- package/bin/repository.mjs +135 -0
- package/bin/sdk/errors.js +54 -0
- package/bin/sdk/index.js +307 -0
- package/bin/sdk/transport.js +197 -0
- package/bin/sdk/types.js +1 -0
- package/bin/totem.mjs +579 -152
- package/package.json +10 -2
- package/scripts/prepare.mjs +15 -0
package/bin/totem.mjs
CHANGED
|
@@ -4,9 +4,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
4
4
|
import { createReadStream } from "node:fs";
|
|
5
5
|
import { stripVTControlCharacters } from "node:util";
|
|
6
6
|
|
|
7
|
-
const
|
|
8
|
-
"Request a quote for 50,000 Industrial Sensors (SKU-1001) from SupplyCo using Acme Procurement Agent.";
|
|
9
|
-
const CLI_VERSION = "0.1.1";
|
|
7
|
+
const CLI_VERSION = "0.1.3";
|
|
10
8
|
const DEFAULT_BASE_URL = "https://totemlayer.com";
|
|
11
9
|
const API_KEY_PATTERN = /^at_(?:live|test)_[A-Za-z0-9_-]{20,}$/;
|
|
12
10
|
const ACTION_PATTERN = /^[a-z0-9][a-z0-9._:-]*$/;
|
|
@@ -16,25 +14,45 @@ const TERMINAL_UNSAFE_PATTERN =
|
|
|
16
14
|
/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
|
|
17
15
|
const MAX_JSON_INPUT_BYTES = 128 * 1_024;
|
|
18
16
|
const MAX_REQUEST_FILE_BYTES = 16 * 1_024 * 1_024;
|
|
17
|
+
const ISO_CURRENCY_CODES = new Set(Intl.supportedValuesOf("currency"));
|
|
19
18
|
const BRAND_ART = [
|
|
20
19
|
" ╭╮",
|
|
21
20
|
" ╭───╯╰────╮ ╺┳╸┏━┓╺┳╸┏━╸┏┳┓",
|
|
22
21
|
" ╰────╮╭────╯ ┃ ┃ ┃ ┃ ┣╸ ┃┃┃",
|
|
23
22
|
" ╰╯ ╹ ┗━┛ ╹ ┗━╸╹ ╹",
|
|
24
23
|
];
|
|
25
|
-
const
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"CHALLENGE_ALREADY_PRESENTED",
|
|
24
|
+
const HANDSHAKE_REJECTION_CODES = new Set([
|
|
25
|
+
"AGENT_AUTHORITY_REVOKED",
|
|
26
|
+
"BEACON_VERIFICATION_FAILED",
|
|
29
27
|
"CHALLENGE_ALREADY_CONSUMED",
|
|
28
|
+
"CHALLENGE_ALREADY_PRESENTED",
|
|
30
29
|
"CHALLENGE_EXPIRED",
|
|
30
|
+
"CHALLENGE_NOT_FOUND",
|
|
31
|
+
"CHALLENGE_NOT_PENDING",
|
|
32
|
+
"CHALLENGE_NOT_PRESENTED",
|
|
33
|
+
"CONTACT_RELATIONSHIP_CHANGED",
|
|
34
|
+
"CONTACT_RELATIONSHIP_REQUIRED",
|
|
35
|
+
"CREDENTIAL_NOT_PRESENTABLE",
|
|
36
|
+
"ENVIRONMENT_MISMATCH",
|
|
37
|
+
"ENVIRONMENT_UNCLASSIFIED",
|
|
38
|
+
"IDEMPOTENCY_KEY_REUSED",
|
|
39
|
+
"INVALID_PRESENTATION_SIGNATURE",
|
|
31
40
|
"PRESENTATION_EXPIRED",
|
|
41
|
+
"PRESENTATION_CLAIMS_MISMATCH",
|
|
32
42
|
"PRESENTATION_MISMATCH",
|
|
33
43
|
"PRESENTER_AGENT_INACTIVE",
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
|
|
44
|
+
"TRANSACTION_NOT_AUTHORIZED",
|
|
45
|
+
"VERIFIER_AGENT_INACTIVE",
|
|
46
|
+
]);
|
|
47
|
+
const ACTION_CHECK_SUITE_STATUSES = new Set(["QUEUED", "RUNNING", "COMPLETED", "STALE"]);
|
|
48
|
+
const ACTION_CHECK_SUITE_CONCLUSIONS = new Set([
|
|
49
|
+
"ALLOW",
|
|
50
|
+
"ALLOW_WITH_WARNINGS",
|
|
51
|
+
"REQUIRES_APPROVAL",
|
|
52
|
+
"BLOCK",
|
|
53
|
+
"ERROR",
|
|
37
54
|
]);
|
|
55
|
+
const ACTION_EXECUTION_OUTCOMES = new Set(["SUCCEEDED", "FAILED", "UNKNOWN", "COMPENSATED"]);
|
|
38
56
|
const ACTION_CHECK_POLICY_CODES = new Set([
|
|
39
57
|
"ACTION_APPROVAL_REQUIRED",
|
|
40
58
|
"ACTION_APPROVAL_STALE",
|
|
@@ -71,25 +89,144 @@ function styled(value, code, stream = process.stdout) {
|
|
|
71
89
|
return supportsColor(stream) ? `\u001b[${code}m${value}\u001b[0m` : value;
|
|
72
90
|
}
|
|
73
91
|
|
|
92
|
+
function interactiveWidth(stream = process.stdout) {
|
|
93
|
+
const columns = Number(stream.columns);
|
|
94
|
+
if (!stream.isTTY || !Number.isInteger(columns) || columns < 48) return undefined;
|
|
95
|
+
return Math.min(columns, 120);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function wrappedTerminalLines(value, prefixLength, stream = process.stdout) {
|
|
99
|
+
const width = interactiveWidth(stream);
|
|
100
|
+
if (!width) return [value];
|
|
101
|
+
const available = Math.max(24, width - prefixLength - 1);
|
|
102
|
+
const words = value.split(" ");
|
|
103
|
+
const lines = [];
|
|
104
|
+
let line = "";
|
|
105
|
+
for (const word of words) {
|
|
106
|
+
if (!line) {
|
|
107
|
+
line = word;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (line.length + word.length + 1 <= available) {
|
|
111
|
+
line += ` ${word}`;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
lines.push(line);
|
|
115
|
+
line = word;
|
|
116
|
+
}
|
|
117
|
+
if (line) lines.push(line);
|
|
118
|
+
return lines.length > 0 ? lines : [""];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function writeHumanLine(
|
|
122
|
+
label,
|
|
123
|
+
value,
|
|
124
|
+
{ stream = process.stdout, labelCode = "2", valueCode, suffix = ":" } = {},
|
|
125
|
+
) {
|
|
126
|
+
const safeLabel = terminalText(label, "Result");
|
|
127
|
+
const safeValue = terminalText(value, "unknown");
|
|
128
|
+
const prefix = `${safeLabel}${suffix} `;
|
|
129
|
+
const continuation = " ".repeat(prefix.length);
|
|
130
|
+
for (const [index, line] of wrappedTerminalLines(safeValue, prefix.length, stream).entries()) {
|
|
131
|
+
const renderedValue = valueCode ? styled(line, valueCode, stream) : line;
|
|
132
|
+
stream.write(
|
|
133
|
+
index === 0
|
|
134
|
+
? `${styled(`${safeLabel}${suffix}`, labelCode, stream)} ${renderedValue}\n`
|
|
135
|
+
: `${continuation}${renderedValue}\n`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function wrappedHelpLines(line, width) {
|
|
141
|
+
if (!width || line.length <= width) return [line];
|
|
142
|
+
|
|
143
|
+
const optionMatch = line.match(
|
|
144
|
+
/^(\s{2}(?:--\S+(?:\s+<[^>]+>)?|-[hv],\s+--\S+|decision\.json|Install once|Confirm installation|Run one-off)\s+)(\S.*)$/,
|
|
145
|
+
);
|
|
146
|
+
const firstPrefix = optionMatch?.[1] ?? line.match(/^\s*/)?.[0] ?? "";
|
|
147
|
+
let continuationPrefix = optionMatch ? " ".repeat(firstPrefix.length) : firstPrefix;
|
|
148
|
+
const fallbackPrefix = firstPrefix ? " " : "";
|
|
149
|
+
const words = (optionMatch?.[2] ?? line.slice(firstPrefix.length)).split(" ");
|
|
150
|
+
const output = [];
|
|
151
|
+
let prefix = firstPrefix;
|
|
152
|
+
let current = prefix;
|
|
153
|
+
|
|
154
|
+
for (const word of words) {
|
|
155
|
+
if (!word) continue;
|
|
156
|
+
const separator = current.length > prefix.length ? " " : "";
|
|
157
|
+
if (`${current}${separator}${word}`.length <= width) {
|
|
158
|
+
current += `${separator}${word}`;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (current.length > prefix.length) output.push(current);
|
|
162
|
+
if (continuationPrefix.length + word.length > width) continuationPrefix = fallbackPrefix;
|
|
163
|
+
prefix = continuationPrefix;
|
|
164
|
+
current = `${prefix}${word}`;
|
|
165
|
+
}
|
|
166
|
+
if (current.length > prefix.length) output.push(current);
|
|
167
|
+
return output.length > 0 ? output : [line];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function writeHelpBody(body) {
|
|
171
|
+
const headings = new Set([
|
|
172
|
+
"Start here",
|
|
173
|
+
"Authorization handshake",
|
|
174
|
+
"Action Checks · local or self-hosted",
|
|
175
|
+
"Procurement demonstration",
|
|
176
|
+
"Shared options:",
|
|
177
|
+
"Challenge options:",
|
|
178
|
+
"Verification binding:",
|
|
179
|
+
"Presentation input:",
|
|
180
|
+
"Action Checks input (local/self-hosted only):",
|
|
181
|
+
"Authentication:",
|
|
182
|
+
]);
|
|
183
|
+
const width = interactiveWidth();
|
|
184
|
+
const output = body
|
|
185
|
+
.split("\n")
|
|
186
|
+
.flatMap((line) => wrappedHelpLines(line, width))
|
|
187
|
+
.map((line) => (headings.has(line) ? styled(line, "1;38;2;58;153;200") : line))
|
|
188
|
+
.join("\n");
|
|
189
|
+
process.stdout.write(output);
|
|
190
|
+
}
|
|
191
|
+
|
|
74
192
|
function writeBrand() {
|
|
75
193
|
const art = BRAND_ART.map((line, index) =>
|
|
76
194
|
styled(line, index < 2 ? "38;2;58;153;200" : "38;2;21;37;131"),
|
|
77
195
|
).join("\n");
|
|
78
196
|
process.stdout.write(`${art}\n`);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
)
|
|
197
|
+
const title = `TotemLayer CLI ${CLI_VERSION} · cross-company agent authorization`;
|
|
198
|
+
const titleLines = wrappedTerminalLines(title, 0);
|
|
199
|
+
for (const [index, line] of titleLines.entries()) {
|
|
200
|
+
const versionPrefix = `TotemLayer CLI ${CLI_VERSION}`;
|
|
201
|
+
process.stdout.write(
|
|
202
|
+
`${index === 0 && line.startsWith(versionPrefix) ? styled(versionPrefix, "1") + line.slice(versionPrefix.length) : line}\n`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
process.stdout.write("\n");
|
|
82
206
|
}
|
|
83
207
|
|
|
84
|
-
function writeHumanResult(header, fields, nextStep, tone = "success") {
|
|
208
|
+
function writeHumanResult(header, fields, nextStep, tone = "success", secrets = []) {
|
|
85
209
|
const toneCode = tone === "danger" ? "38;2;255;114;114" : "38;2;58;153;200";
|
|
86
|
-
process.stdout.write(`${styled(header, `1;${toneCode}`)}\n`);
|
|
87
|
-
|
|
210
|
+
process.stdout.write(`${styled(redactCliSecrets(header, secrets), `1;${toneCode}`)}\n`);
|
|
211
|
+
const dividerLength = Math.min(interactiveWidth() ?? 40, 56);
|
|
212
|
+
process.stdout.write(`${styled("─".repeat(dividerLength), "2")}\n`);
|
|
88
213
|
for (const [label, value] of fields) {
|
|
89
214
|
if (value === undefined || value === null || value === "") continue;
|
|
90
|
-
|
|
215
|
+
writeHumanLine(label, redactCliSecrets(value, secrets), {
|
|
216
|
+
valueCode: ["Status", "Decision", "Run", "Agent authority"].includes(label)
|
|
217
|
+
? `1;${toneCode}`
|
|
218
|
+
: undefined,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
if (nextStep) {
|
|
222
|
+
process.stdout.write("\n");
|
|
223
|
+
writeHumanLine("Next →", nextStep, { labelCode: `1;${toneCode}`, suffix: "" });
|
|
91
224
|
}
|
|
92
|
-
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function writeHumanArtifact(payload, secrets) {
|
|
228
|
+
process.stdout.write(`\n${styled("Artifact JSON · save securely", "1")}\n`);
|
|
229
|
+
writeJson(redactJsonSecrets(payload, secrets));
|
|
93
230
|
}
|
|
94
231
|
|
|
95
232
|
function compactDigest(value) {
|
|
@@ -99,76 +236,131 @@ function compactDigest(value) {
|
|
|
99
236
|
|
|
100
237
|
function help() {
|
|
101
238
|
writeBrand();
|
|
102
|
-
|
|
239
|
+
writeHelpBody(`Start here
|
|
103
240
|
Install once npm install --global totemlayer
|
|
104
241
|
Confirm installation totem --version
|
|
105
242
|
Run one-off npx --yes totemlayer --help
|
|
106
243
|
|
|
244
|
+
Agent messaging and local runtime
|
|
245
|
+
totem network --help
|
|
246
|
+
totem network run --provider ollama --model qwen3.5:9b
|
|
247
|
+
|
|
248
|
+
Connect an AI app through MCP
|
|
249
|
+
totem mcp
|
|
250
|
+
|
|
107
251
|
Authorization handshake
|
|
108
|
-
totem challenge
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
252
|
+
totem challenge \\
|
|
253
|
+
--presenter-agent <agent-id> \\
|
|
254
|
+
--action <action> \\
|
|
255
|
+
(--request-digest <sha256> | \\
|
|
256
|
+
--request-file <path>) \\
|
|
257
|
+
[--amount <number> --currency <ISO-4217>] \\
|
|
258
|
+
--json
|
|
259
|
+
totem present \\
|
|
260
|
+
--credential-id <credential-id> \\
|
|
261
|
+
[--challenge-file <path>] \\
|
|
262
|
+
--json
|
|
263
|
+
totem verify \\
|
|
264
|
+
--challenge-file <receiver-challenge.json> \\
|
|
265
|
+
(--request-digest <sha256> | \\
|
|
266
|
+
--request-file <path>) \\
|
|
267
|
+
[--presentation-file <path>] \\
|
|
268
|
+
--json
|
|
113
269
|
|
|
114
270
|
Action Checks · local or self-hosted
|
|
115
271
|
totem checks run --file <action.json>
|
|
116
272
|
totem checks view <suite-id>
|
|
117
|
-
totem checks approve <suite-id>
|
|
118
|
-
|
|
119
|
-
totem checks
|
|
120
|
-
|
|
273
|
+
totem checks approve <suite-id> \\
|
|
274
|
+
[--file <decision.json>]
|
|
275
|
+
totem checks reject <suite-id> \\
|
|
276
|
+
[--file <decision.json>]
|
|
277
|
+
totem checks grant <suite-id> \\
|
|
278
|
+
--file <action.json>
|
|
279
|
+
totem checks execute <suite-id> \\
|
|
280
|
+
--file <execution.json>
|
|
121
281
|
|
|
122
282
|
Procurement demonstration
|
|
123
|
-
totem rfq
|
|
283
|
+
totem rfq \\
|
|
284
|
+
"Request a quote for 50,000 Industrial" \\
|
|
285
|
+
"Sensors (SKU-1001) from SupplyCo using" \\
|
|
286
|
+
"Acme Procurement Agent."
|
|
287
|
+
Sandbox/self-hosted demo only.
|
|
124
288
|
|
|
125
289
|
Shared options:
|
|
126
|
-
--base-url <url> Totem URL
|
|
127
|
-
|
|
290
|
+
--base-url <url> Totem URL; default:
|
|
291
|
+
TOTEM_URL or
|
|
292
|
+
${DEFAULT_BASE_URL}
|
|
293
|
+
--json Print complete machine-readable
|
|
294
|
+
JSON
|
|
128
295
|
-h, --help Show this help
|
|
129
296
|
-v, --version Print the CLI version
|
|
130
297
|
|
|
131
298
|
Challenge options:
|
|
132
|
-
--presenter-agent <id> Exact
|
|
133
|
-
--action <action> Namespaced action
|
|
134
|
-
|
|
135
|
-
--request-
|
|
136
|
-
--
|
|
137
|
-
|
|
138
|
-
--
|
|
299
|
+
--presenter-agent <id> Exact expected presenter agent
|
|
300
|
+
--action <action> Namespaced action; for example,
|
|
301
|
+
inventory.read
|
|
302
|
+
--request-digest <sha256> SHA-256 of the exact request
|
|
303
|
+
--request-file <path> Hash this file locally; contents
|
|
304
|
+
are never uploaded
|
|
305
|
+
--amount <number> Optional amount; requires
|
|
306
|
+
--currency
|
|
307
|
+
--currency <code> ISO currency; requires --amount
|
|
308
|
+
--idempotency-key <key> Reuse for an exact retry; a
|
|
309
|
+
random key is used by default
|
|
139
310
|
|
|
140
311
|
Verification binding:
|
|
141
|
-
--challenge-file <path> Receiver's original challenge;
|
|
142
|
-
|
|
143
|
-
--request-
|
|
312
|
+
--challenge-file <path> Receiver's original challenge;
|
|
313
|
+
never trust a returned copy
|
|
314
|
+
--request-digest <sha256> Require evidence for this exact
|
|
315
|
+
request digest
|
|
316
|
+
--request-file <path> Hash the exact local request
|
|
317
|
+
before accepting approval
|
|
144
318
|
|
|
145
319
|
Presentation input:
|
|
146
|
-
present reads challenge JSON from stdin unless
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
320
|
+
present reads challenge JSON from stdin unless
|
|
321
|
+
you supply --challenge-file.
|
|
322
|
+
verify reads presentation JSON from stdin unless
|
|
323
|
+
you supply --presentation-file. It compares that proof
|
|
324
|
+
with the receiver-owned --challenge-file before use.
|
|
325
|
+
Raw keys, nonces, credentials, and presentation tokens are
|
|
326
|
+
never accepted as arguments.
|
|
150
327
|
|
|
151
328
|
Action Checks input (local/self-hosted only):
|
|
152
|
-
--file <path> run/grant: exact action JSON
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
329
|
+
--file <path> run/grant: exact action JSON
|
|
330
|
+
execute: JSON containing
|
|
331
|
+
"action" and "grant"
|
|
332
|
+
decision.json Optional decision body; example:
|
|
333
|
+
{"confirmation":"...","reason":"..."}
|
|
334
|
+
Never put confirmation, grant,
|
|
335
|
+
session cookie, or action
|
|
336
|
+
payload on the command line
|
|
337
|
+
Action Checks always emit structured JSON.
|
|
338
|
+
Blocked, rejected, expired, stale, or replayed actions
|
|
339
|
+
exit 2. Invalid input, network, authentication, and
|
|
340
|
+
operational failures exit 1.
|
|
157
341
|
|
|
158
342
|
Authentication:
|
|
159
343
|
challenge and verify use TOTEM_API_KEY.
|
|
160
|
-
present uses TOTEM_AGENT_API_KEY bound to the
|
|
161
|
-
checks run/view/grant/execute use TOTEM_API_KEY with an
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
344
|
+
present uses TOTEM_AGENT_API_KEY bound to the presenter.
|
|
345
|
+
checks run/view/grant/execute use TOTEM_API_KEY with an
|
|
346
|
+
Action Checks scope.
|
|
347
|
+
checks approve/reject never use an API key. They require
|
|
348
|
+
an authenticated human browser session, supplied only
|
|
349
|
+
through TOTEM_SESSION_COOKIE as a Cookie header value.
|
|
350
|
+
Clear it after the command. Without it, use /checks.
|
|
351
|
+
Keys are sent only in the Authorization header and are
|
|
352
|
+
never printed.
|
|
353
|
+
|
|
354
|
+
Production supports challenge, present, and verify.
|
|
355
|
+
Action Checks and RFQ require a sandbox or self-hosted Totem
|
|
356
|
+
deployment with their local demonstrations enabled.
|
|
357
|
+
|
|
358
|
+
Human-readable commands show the decision, evidence, and
|
|
359
|
+
safest next step. Successful human challenge and present
|
|
360
|
+
commands also print their complete short-lived artifact.
|
|
361
|
+
Save it securely: the next participant needs its nonce or
|
|
362
|
+
presentation token. Add --json when another program needs
|
|
363
|
+
the complete machine-readable response.
|
|
172
364
|
`);
|
|
173
365
|
}
|
|
174
366
|
|
|
@@ -176,10 +368,14 @@ function fail(message) {
|
|
|
176
368
|
process.stderr.write(
|
|
177
369
|
`${styled("Totem: command failed", "1;38;2;255;114;114", process.stderr)}\n`,
|
|
178
370
|
);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
);
|
|
371
|
+
writeHumanLine("Reason", terminalText(message, "The Totem command failed."), {
|
|
372
|
+
stream: process.stderr,
|
|
373
|
+
labelCode: "1;38;2;255;114;114",
|
|
374
|
+
});
|
|
375
|
+
writeHumanLine("Next", "Fix the reason above, or run `totem --help` for a complete example.", {
|
|
376
|
+
stream: process.stderr,
|
|
377
|
+
labelCode: "1;38;2;58;153;200",
|
|
378
|
+
});
|
|
183
379
|
process.exitCode = 1;
|
|
184
380
|
}
|
|
185
381
|
|
|
@@ -201,8 +397,23 @@ function failUnknownOutcome(message, idempotencyKey, json, machineStdout = false
|
|
|
201
397
|
fail(`${message} The outcome is unknown; retry with --idempotency-key "${idempotencyKey}".`);
|
|
202
398
|
}
|
|
203
399
|
|
|
400
|
+
function failHandshakeUnknownOutcome(operation, message, json) {
|
|
401
|
+
const guidance =
|
|
402
|
+
operation === "presentation"
|
|
403
|
+
? "Do not retry this challenge blindly; ask the receiver to reconcile it or create a fresh challenge."
|
|
404
|
+
: "Do not act on the request; reconcile the original challenge before retrying or create a fresh handshake.";
|
|
405
|
+
const safeMessage = `${message} The ${operation} outcome is unknown. ${guidance}`;
|
|
406
|
+
if (json) {
|
|
407
|
+
writeJson({ error: { message: safeMessage, outcome: "unknown", operation } });
|
|
408
|
+
process.exitCode = 1;
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
fail(safeMessage);
|
|
412
|
+
}
|
|
413
|
+
|
|
204
414
|
function parseArguments(values) {
|
|
205
415
|
const options = { json: false, positionals: [] };
|
|
416
|
+
let parsingOptions = true;
|
|
206
417
|
const valuedOptions = new Set([
|
|
207
418
|
"--base-url",
|
|
208
419
|
"--idempotency-key",
|
|
@@ -219,7 +430,14 @@ function parseArguments(values) {
|
|
|
219
430
|
]);
|
|
220
431
|
for (let index = 0; index < values.length; index += 1) {
|
|
221
432
|
const value = values[index];
|
|
222
|
-
if (value === "--")
|
|
433
|
+
if (parsingOptions && value === "--") {
|
|
434
|
+
parsingOptions = false;
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
if (!parsingOptions) {
|
|
438
|
+
options.positionals.push(value);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
223
441
|
if (value === "--json") {
|
|
224
442
|
options.json = true;
|
|
225
443
|
continue;
|
|
@@ -235,7 +453,9 @@ function parseArguments(values) {
|
|
|
235
453
|
if (valuedOptions.has(value)) {
|
|
236
454
|
const next = values[index + 1];
|
|
237
455
|
if (!next || next.startsWith("--")) throw new Error(`${value} requires a value.`);
|
|
238
|
-
|
|
456
|
+
const key = value.slice(2).replaceAll("-", "_");
|
|
457
|
+
if (options[key] !== undefined) throw new Error(`${value} may be supplied only once.`);
|
|
458
|
+
options[key] = next;
|
|
239
459
|
index += 1;
|
|
240
460
|
continue;
|
|
241
461
|
}
|
|
@@ -247,8 +467,13 @@ function parseArguments(values) {
|
|
|
247
467
|
|
|
248
468
|
function safeBaseUrl(value) {
|
|
249
469
|
const url = new URL(value);
|
|
250
|
-
if (
|
|
251
|
-
|
|
470
|
+
if (
|
|
471
|
+
!["http:", "https:"].includes(url.protocol) ||
|
|
472
|
+
url.username ||
|
|
473
|
+
url.password ||
|
|
474
|
+
(url.pathname !== "/" && url.pathname !== "")
|
|
475
|
+
) {
|
|
476
|
+
throw new Error("The Totem URL must be an HTTP(S) origin without credentials or a path.");
|
|
252
477
|
}
|
|
253
478
|
const local = ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
|
|
254
479
|
if (url.protocol !== "https:" && !local) {
|
|
@@ -260,6 +485,21 @@ function safeBaseUrl(value) {
|
|
|
260
485
|
return url;
|
|
261
486
|
}
|
|
262
487
|
|
|
488
|
+
function validIdempotencyKey(value, flag = "--idempotency-key") {
|
|
489
|
+
if (
|
|
490
|
+
typeof value !== "string" ||
|
|
491
|
+
value !== value.trim() ||
|
|
492
|
+
value.length < 8 ||
|
|
493
|
+
value.length > 128 ||
|
|
494
|
+
!SAFE_IDENTIFIER_PATTERN.test(value) ||
|
|
495
|
+
/^at_(?:live|test)_/i.test(value) ||
|
|
496
|
+
value.split(".").length === 3
|
|
497
|
+
) {
|
|
498
|
+
throw new Error(`${flag} must contain 8 to 128 safe identifier characters.`);
|
|
499
|
+
}
|
|
500
|
+
return value;
|
|
501
|
+
}
|
|
502
|
+
|
|
263
503
|
function requireLocalActionChecks(baseUrl) {
|
|
264
504
|
if (baseUrl.origin === DEFAULT_BASE_URL) {
|
|
265
505
|
throw new Error(
|
|
@@ -326,13 +566,13 @@ function actionCheckSessionCookie() {
|
|
|
326
566
|
return value;
|
|
327
567
|
}
|
|
328
568
|
|
|
329
|
-
function errorFrom(payload, fallback) {
|
|
330
|
-
if (!payload || typeof payload !== "object") return fallback;
|
|
569
|
+
function errorFrom(payload, fallback, secrets = []) {
|
|
570
|
+
if (!payload || typeof payload !== "object") return redactCliSecrets(fallback, secrets);
|
|
331
571
|
const error = payload.error;
|
|
332
|
-
if (!error || typeof error !== "object") return fallback;
|
|
572
|
+
if (!error || typeof error !== "object") return redactCliSecrets(fallback, secrets);
|
|
333
573
|
const code = typeof error.code === "string" ? error.code : "REQUEST_FAILED";
|
|
334
574
|
const message = typeof error.message === "string" ? error.message : fallback;
|
|
335
|
-
return `${code}: ${message}
|
|
575
|
+
return redactCliSecrets(`${code}: ${message}`, secrets);
|
|
336
576
|
}
|
|
337
577
|
|
|
338
578
|
function redactCliSecrets(value, secrets) {
|
|
@@ -344,6 +584,30 @@ function redactCliSecrets(value, secrets) {
|
|
|
344
584
|
return redacted;
|
|
345
585
|
}
|
|
346
586
|
|
|
587
|
+
function redactJsonSecrets(value, secrets) {
|
|
588
|
+
if (typeof value === "string") {
|
|
589
|
+
let redacted = value;
|
|
590
|
+
for (const secret of secrets) {
|
|
591
|
+
if (typeof secret === "string" && secret.length >= 8) {
|
|
592
|
+
redacted = redacted.replaceAll(secret, "[REDACTED]");
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return redacted;
|
|
596
|
+
}
|
|
597
|
+
if (Array.isArray(value)) {
|
|
598
|
+
return value.map((item) => redactJsonSecrets(item, secrets));
|
|
599
|
+
}
|
|
600
|
+
if (record(value)) {
|
|
601
|
+
return Object.fromEntries(
|
|
602
|
+
Object.entries(value).map(([key, item]) => [
|
|
603
|
+
redactJsonSecrets(key, secrets),
|
|
604
|
+
redactJsonSecrets(item, secrets),
|
|
605
|
+
]),
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
return value;
|
|
609
|
+
}
|
|
610
|
+
|
|
347
611
|
function writeActionCheckFailure(payload, fallback, status, secrets = []) {
|
|
348
612
|
const error = record(record(payload)?.error);
|
|
349
613
|
const code = redactCliSecrets(
|
|
@@ -387,10 +651,33 @@ function terminalText(value, fallback = "unknown") {
|
|
|
387
651
|
return sanitized.slice(0, 2_000) || fallback;
|
|
388
652
|
}
|
|
389
653
|
|
|
654
|
+
function organizationProofText(organization) {
|
|
655
|
+
if (organization?.verified !== true) return organization?.domain;
|
|
656
|
+
if (organization.accountType === "PERSONAL") return "Owner email · verified";
|
|
657
|
+
return `${terminalText(organization.domain, "verified domain")} · verified`;
|
|
658
|
+
}
|
|
659
|
+
|
|
390
660
|
function record(value) {
|
|
391
661
|
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
392
662
|
}
|
|
393
663
|
|
|
664
|
+
function validActionCheckSuite(suite, expectedId) {
|
|
665
|
+
if (
|
|
666
|
+
!suite ||
|
|
667
|
+
typeof suite.id !== "string" ||
|
|
668
|
+
suite.id.length > 200 ||
|
|
669
|
+
!SAFE_IDENTIFIER_PATTERN.test(suite.id) ||
|
|
670
|
+
(expectedId && suite.id !== expectedId) ||
|
|
671
|
+
!ACTION_CHECK_SUITE_STATUSES.has(suite.status)
|
|
672
|
+
) {
|
|
673
|
+
return false;
|
|
674
|
+
}
|
|
675
|
+
if (suite.conclusion === null || suite.conclusion === undefined) {
|
|
676
|
+
return suite.status !== "COMPLETED";
|
|
677
|
+
}
|
|
678
|
+
return ACTION_CHECK_SUITE_CONCLUSIONS.has(suite.conclusion);
|
|
679
|
+
}
|
|
680
|
+
|
|
394
681
|
function requiredOption(options, key, flag, pattern = SAFE_IDENTIFIER_PATTERN) {
|
|
395
682
|
const value = options[key]?.trim();
|
|
396
683
|
if (!value || value.length > 200 || (pattern && !pattern.test(value))) {
|
|
@@ -535,6 +822,7 @@ function receiverChallenge(input) {
|
|
|
535
822
|
challenge.nonce.length < 32 ||
|
|
536
823
|
challenge.nonce.length > 256 ||
|
|
537
824
|
!/^[A-Za-z0-9_-]+$/.test(challenge.nonce) ||
|
|
825
|
+
!["SANDBOX", "PRODUCTION"].includes(challenge.environment) ||
|
|
538
826
|
typeof challenge.requestedAction !== "string" ||
|
|
539
827
|
!ACTION_PATTERN.test(challenge.requestedAction) ||
|
|
540
828
|
typeof challenge.requestDigest !== "string" ||
|
|
@@ -568,6 +856,7 @@ function receiverChallenge(input) {
|
|
|
568
856
|
issuerOrganizationId: challenge.issuerOrganizationId,
|
|
569
857
|
verifierAgentId: challenge.verifierAgentId,
|
|
570
858
|
presenterAgentId: challenge.presenterAgentId,
|
|
859
|
+
environment: challenge.environment,
|
|
571
860
|
requestedAction: challenge.requestedAction,
|
|
572
861
|
requestDigest: challenge.requestDigest.toLowerCase(),
|
|
573
862
|
...(hasAmount
|
|
@@ -617,6 +906,7 @@ function boundPresentationClaims(token, challenge, credentialId, expiresAt) {
|
|
|
617
906
|
claims.credentialId !== credentialId ||
|
|
618
907
|
claims.agentId !== challenge.presenterAgentId ||
|
|
619
908
|
claims.organizationId !== challenge.issuerOrganizationId ||
|
|
909
|
+
claims.environment !== challenge.environment ||
|
|
620
910
|
claims.requestedAction !== challenge.requestedAction ||
|
|
621
911
|
claims.requestDigest !== challenge.requestDigest ||
|
|
622
912
|
claims.transactionAmount !== expectedAmount ||
|
|
@@ -688,25 +978,21 @@ async function runChallenge(options, baseUrl) {
|
|
|
688
978
|
let transactionAmount;
|
|
689
979
|
let currency;
|
|
690
980
|
if (hasAmount) {
|
|
691
|
-
|
|
692
|
-
if (
|
|
981
|
+
const rawAmount = options.amount.trim();
|
|
982
|
+
if (!/^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(rawAmount)) {
|
|
983
|
+
throw new Error("--amount must be a decimal number from 0 through 1000000000000.");
|
|
984
|
+
}
|
|
985
|
+
transactionAmount = Number(rawAmount);
|
|
986
|
+
if (!Number.isFinite(transactionAmount) || transactionAmount > 1e12) {
|
|
693
987
|
throw new Error("--amount must be a finite number from 0 through 1000000000000.");
|
|
694
988
|
}
|
|
695
989
|
currency = options.currency.trim().toUpperCase();
|
|
696
|
-
if (
|
|
697
|
-
throw new Error("--currency must be a three-letter ISO currency code.");
|
|
990
|
+
if (!ISO_CURRENCY_CODES.has(currency)) {
|
|
991
|
+
throw new Error("--currency must be a recognized three-letter ISO 4217 currency code.");
|
|
698
992
|
}
|
|
699
993
|
}
|
|
700
994
|
|
|
701
|
-
const idempotencyKey = options.idempotency_key ?? `cli:${randomUUID()}
|
|
702
|
-
if (
|
|
703
|
-
typeof idempotencyKey !== "string" ||
|
|
704
|
-
idempotencyKey.length < 8 ||
|
|
705
|
-
idempotencyKey.length > 128 ||
|
|
706
|
-
!SAFE_IDENTIFIER_PATTERN.test(idempotencyKey)
|
|
707
|
-
) {
|
|
708
|
-
throw new Error("--idempotency-key must contain 8 to 128 safe identifier characters.");
|
|
709
|
-
}
|
|
995
|
+
const idempotencyKey = validIdempotencyKey(options.idempotency_key ?? `cli:${randomUUID()}`);
|
|
710
996
|
if (!options.json) process.stderr.write(`Totem retry key: ${idempotencyKey}\n`);
|
|
711
997
|
|
|
712
998
|
let result;
|
|
@@ -728,15 +1014,39 @@ async function runChallenge(options, baseUrl) {
|
|
|
728
1014
|
failUnknownOutcome(message, idempotencyKey, options.json, true);
|
|
729
1015
|
return;
|
|
730
1016
|
}
|
|
731
|
-
const challenge = record(result.payload?.challenge);
|
|
732
1017
|
if (!result.response.ok) {
|
|
1018
|
+
const apiError = record(result.payload?.error);
|
|
1019
|
+
if (typeof apiError?.code === "string" && HANDSHAKE_REJECTION_CODES.has(apiError.code)) {
|
|
1020
|
+
if (options.json) writeJson(redactJsonSecrets(result.payload, [key]));
|
|
1021
|
+
else {
|
|
1022
|
+
writeHumanResult(
|
|
1023
|
+
"Totem challenge · REJECTED",
|
|
1024
|
+
[
|
|
1025
|
+
["Status", "Challenge was not created"],
|
|
1026
|
+
["Presenter", presenterAgentId],
|
|
1027
|
+
["Action", requestedAction],
|
|
1028
|
+
["Reason", apiError.code],
|
|
1029
|
+
],
|
|
1030
|
+
"Resolve the policy state before creating a new challenge; this request is not authorized.",
|
|
1031
|
+
"danger",
|
|
1032
|
+
[key],
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
process.exitCode = 2;
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
733
1038
|
failCommand(
|
|
734
|
-
errorFrom(result.payload, `Challenge creation returned HTTP ${result.response.status}
|
|
1039
|
+
errorFrom(result.payload, `Challenge creation returned HTTP ${result.response.status}.`, [
|
|
1040
|
+
key,
|
|
1041
|
+
]),
|
|
735
1042
|
options.json,
|
|
736
1043
|
);
|
|
737
1044
|
return;
|
|
738
1045
|
}
|
|
739
|
-
|
|
1046
|
+
let challenge;
|
|
1047
|
+
try {
|
|
1048
|
+
challenge = receiverChallenge(result.payload);
|
|
1049
|
+
} catch {
|
|
740
1050
|
failUnknownOutcome(
|
|
741
1051
|
"Totem returned an invalid challenge response.",
|
|
742
1052
|
idempotencyKey,
|
|
@@ -745,7 +1055,28 @@ async function runChallenge(options, baseUrl) {
|
|
|
745
1055
|
);
|
|
746
1056
|
return;
|
|
747
1057
|
}
|
|
748
|
-
|
|
1058
|
+
const responseHasTransaction = challenge.transactionAmount !== undefined;
|
|
1059
|
+
const replayed = result.payload?.replayed;
|
|
1060
|
+
if (
|
|
1061
|
+
typeof replayed !== "boolean" ||
|
|
1062
|
+
result.response.status !== (replayed ? 200 : 201) ||
|
|
1063
|
+
challenge.presenterAgentId !== presenterAgentId ||
|
|
1064
|
+
challenge.requestedAction !== requestedAction ||
|
|
1065
|
+
challenge.requestDigest !== requestDigest ||
|
|
1066
|
+
Date.parse(challenge.expiresAt) <= Date.now() ||
|
|
1067
|
+
responseHasTransaction !== (transactionAmount !== undefined) ||
|
|
1068
|
+
(responseHasTransaction &&
|
|
1069
|
+
(challenge.transactionAmount !== transactionAmount || challenge.currency !== currency))
|
|
1070
|
+
) {
|
|
1071
|
+
failUnknownOutcome(
|
|
1072
|
+
"Totem returned a challenge that does not match the exact request.",
|
|
1073
|
+
idempotencyKey,
|
|
1074
|
+
options.json,
|
|
1075
|
+
true,
|
|
1076
|
+
);
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
if (options.json) writeJson(redactJsonSecrets(result.payload, [key]));
|
|
749
1080
|
else {
|
|
750
1081
|
writeHumanResult(
|
|
751
1082
|
`Totem challenge · ${terminalText(challenge.id)}`,
|
|
@@ -764,8 +1095,11 @@ async function runChallenge(options, baseUrl) {
|
|
|
764
1095
|
["Expires", challenge.expiresAt],
|
|
765
1096
|
["Protection", "Recipient-bound · request-bound · single-use"],
|
|
766
1097
|
],
|
|
767
|
-
"
|
|
1098
|
+
"Save the complete JSON artifact below as the receiver-owned challenge, then send a copy to the presenting agent.",
|
|
1099
|
+
"success",
|
|
1100
|
+
[key],
|
|
768
1101
|
);
|
|
1102
|
+
writeHumanArtifact(result.payload, [key]);
|
|
769
1103
|
}
|
|
770
1104
|
}
|
|
771
1105
|
|
|
@@ -780,6 +1114,7 @@ async function runPresent(options, baseUrl) {
|
|
|
780
1114
|
const nonce = challenge.nonce;
|
|
781
1115
|
if (
|
|
782
1116
|
typeof challengeId !== "string" ||
|
|
1117
|
+
challengeId.length > 200 ||
|
|
783
1118
|
!SAFE_IDENTIFIER_PATTERN.test(challengeId) ||
|
|
784
1119
|
typeof nonce !== "string" ||
|
|
785
1120
|
nonce.length < 32 ||
|
|
@@ -789,17 +1124,27 @@ async function runPresent(options, baseUrl) {
|
|
|
789
1124
|
throw new Error("Challenge JSON is missing a valid challenge id or nonce.");
|
|
790
1125
|
}
|
|
791
1126
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
1127
|
+
let result;
|
|
1128
|
+
try {
|
|
1129
|
+
result = await requestJson({
|
|
1130
|
+
baseUrl,
|
|
1131
|
+
path: "/api/v1/presentations",
|
|
1132
|
+
key,
|
|
1133
|
+
body: { credentialId, challengeId, nonce },
|
|
1134
|
+
});
|
|
1135
|
+
} catch (error) {
|
|
1136
|
+
failHandshakeUnknownOutcome(
|
|
1137
|
+
"presentation",
|
|
1138
|
+
error instanceof Error ? error.message : "Could not create the presentation.",
|
|
1139
|
+
options.json,
|
|
1140
|
+
);
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
798
1143
|
const presentation = record(result.payload?.presentation);
|
|
799
1144
|
if (!result.response.ok) {
|
|
800
1145
|
const apiError = record(result.payload?.error);
|
|
801
|
-
if (typeof apiError?.code === "string" &&
|
|
802
|
-
if (options.json) writeJson(result.payload);
|
|
1146
|
+
if (typeof apiError?.code === "string" && HANDSHAKE_REJECTION_CODES.has(apiError.code)) {
|
|
1147
|
+
if (options.json) writeJson(redactJsonSecrets(result.payload, [key]));
|
|
803
1148
|
else {
|
|
804
1149
|
writeHumanResult(
|
|
805
1150
|
"Totem presentation · REJECTED",
|
|
@@ -810,28 +1155,41 @@ async function runPresent(options, baseUrl) {
|
|
|
810
1155
|
],
|
|
811
1156
|
"Return to the receiver for a fresh challenge before trying again.",
|
|
812
1157
|
"danger",
|
|
1158
|
+
[key],
|
|
813
1159
|
);
|
|
814
1160
|
}
|
|
815
1161
|
process.exitCode = 2;
|
|
816
1162
|
return;
|
|
817
1163
|
}
|
|
818
1164
|
failCommand(
|
|
819
|
-
errorFrom(result.payload, `Presentation creation returned HTTP ${result.response.status}
|
|
1165
|
+
errorFrom(result.payload, `Presentation creation returned HTTP ${result.response.status}.`, [
|
|
1166
|
+
key,
|
|
1167
|
+
]),
|
|
820
1168
|
options.json,
|
|
821
1169
|
);
|
|
822
1170
|
return;
|
|
823
1171
|
}
|
|
824
1172
|
if (
|
|
825
1173
|
!presentation ||
|
|
826
|
-
|
|
827
|
-
|
|
1174
|
+
result.response.status !== 201 ||
|
|
1175
|
+
presentation.challengeId !== challengeId ||
|
|
1176
|
+
presentation.credentialId !== credentialId ||
|
|
828
1177
|
typeof presentation.token !== "string" ||
|
|
829
|
-
|
|
1178
|
+
presentation.token.length < 20 ||
|
|
1179
|
+
presentation.token.length > 100_000 ||
|
|
1180
|
+
presentation.token.split(".").length !== 3 ||
|
|
1181
|
+
!presentation.token.split(".").every((part) => /^[A-Za-z0-9_-]+$/.test(part)) ||
|
|
1182
|
+
!validIsoTimestamp(presentation.expiresAt) ||
|
|
1183
|
+
Date.parse(presentation.expiresAt) <= Date.now()
|
|
830
1184
|
) {
|
|
831
|
-
|
|
1185
|
+
failHandshakeUnknownOutcome(
|
|
1186
|
+
"presentation",
|
|
1187
|
+
"Totem returned an invalid or mismatched presentation response.",
|
|
1188
|
+
options.json,
|
|
1189
|
+
);
|
|
832
1190
|
return;
|
|
833
1191
|
}
|
|
834
|
-
if (options.json) writeJson(result.payload);
|
|
1192
|
+
if (options.json) writeJson(redactJsonSecrets(result.payload, [key]));
|
|
835
1193
|
else {
|
|
836
1194
|
writeHumanResult(
|
|
837
1195
|
`Totem presentation · ${terminalText(presentation.challengeId)}`,
|
|
@@ -844,8 +1202,11 @@ async function runPresent(options, baseUrl) {
|
|
|
844
1202
|
["Expires", presentation.expiresAt],
|
|
845
1203
|
["Protection", "Challenge-bound · recipient-bound · one-minute proof"],
|
|
846
1204
|
],
|
|
847
|
-
"
|
|
1205
|
+
"Save the complete JSON artifact below and return it to the verifier before the proof expires.",
|
|
1206
|
+
"success",
|
|
1207
|
+
[key],
|
|
848
1208
|
);
|
|
1209
|
+
writeHumanArtifact(result.payload, [key]);
|
|
849
1210
|
}
|
|
850
1211
|
}
|
|
851
1212
|
|
|
@@ -887,17 +1248,27 @@ async function runVerify(options, baseUrl) {
|
|
|
887
1248
|
}
|
|
888
1249
|
const claims = boundPresentationClaims(token, challenge, credentialId, presentation.expiresAt);
|
|
889
1250
|
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1251
|
+
let result;
|
|
1252
|
+
try {
|
|
1253
|
+
result = await requestJson({
|
|
1254
|
+
baseUrl,
|
|
1255
|
+
path: "/api/v1/presentations/verify",
|
|
1256
|
+
key,
|
|
1257
|
+
body: { challengeId: challenge.id, presentation: token },
|
|
1258
|
+
});
|
|
1259
|
+
} catch (error) {
|
|
1260
|
+
failHandshakeUnknownOutcome(
|
|
1261
|
+
"verification",
|
|
1262
|
+
error instanceof Error ? error.message : "Could not verify the presentation.",
|
|
1263
|
+
options.json,
|
|
1264
|
+
);
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
896
1267
|
const payload = record(result.payload);
|
|
897
1268
|
if (!payload || typeof payload.verified !== "boolean" || typeof payload.result !== "string") {
|
|
898
1269
|
const apiError = record(payload?.error);
|
|
899
|
-
if (typeof apiError?.code === "string" &&
|
|
900
|
-
if (options.json) writeJson(payload);
|
|
1270
|
+
if (typeof apiError?.code === "string" && HANDSHAKE_REJECTION_CODES.has(apiError.code)) {
|
|
1271
|
+
if (options.json) writeJson(redactJsonSecrets(payload, [key, token]));
|
|
901
1272
|
else {
|
|
902
1273
|
writeHumanResult(
|
|
903
1274
|
"Totem verification · REJECTED",
|
|
@@ -909,18 +1280,22 @@ async function runVerify(options, baseUrl) {
|
|
|
909
1280
|
],
|
|
910
1281
|
"Create a fresh challenge and verify the exact request again before taking action.",
|
|
911
1282
|
"danger",
|
|
1283
|
+
[key, token],
|
|
912
1284
|
);
|
|
913
1285
|
}
|
|
914
1286
|
process.exitCode = 2;
|
|
915
1287
|
return;
|
|
916
1288
|
}
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
),
|
|
922
|
-
options.json,
|
|
1289
|
+
const message = errorFrom(
|
|
1290
|
+
result.payload,
|
|
1291
|
+
`Presentation verification returned HTTP ${result.response.status}.`,
|
|
1292
|
+
[key, token],
|
|
923
1293
|
);
|
|
1294
|
+
if (result.response.status >= 500 || result.response.ok) {
|
|
1295
|
+
failHandshakeUnknownOutcome("verification", message, options.json);
|
|
1296
|
+
} else {
|
|
1297
|
+
failCommand(message, options.json);
|
|
1298
|
+
}
|
|
924
1299
|
return;
|
|
925
1300
|
}
|
|
926
1301
|
|
|
@@ -972,7 +1347,7 @@ async function runVerify(options, baseUrl) {
|
|
|
972
1347
|
}
|
|
973
1348
|
}
|
|
974
1349
|
|
|
975
|
-
if (options.json) writeJson(payload);
|
|
1350
|
+
if (options.json) writeJson(redactJsonSecrets(payload, [key, token]));
|
|
976
1351
|
else {
|
|
977
1352
|
const approvedResult = payload.verified && payload.result === "APPROVED" && result.response.ok;
|
|
978
1353
|
writeHumanResult(
|
|
@@ -982,12 +1357,7 @@ async function runVerify(options, baseUrl) {
|
|
|
982
1357
|
["Agent", payload.agent?.name ?? payload.agent?.id],
|
|
983
1358
|
["Agent ID", payload.agent?.id],
|
|
984
1359
|
["Organization", payload.organization?.name ?? payload.organization?.id],
|
|
985
|
-
[
|
|
986
|
-
"Organization proof",
|
|
987
|
-
payload.organization?.verified
|
|
988
|
-
? `${terminalText(payload.organization.domain, "verified domain")} · verified`
|
|
989
|
-
: payload.organization?.domain,
|
|
990
|
-
],
|
|
1360
|
+
["Organization proof", organizationProofText(payload.organization)],
|
|
991
1361
|
["Action", payload.authorization?.requestedAction],
|
|
992
1362
|
[
|
|
993
1363
|
"Authority",
|
|
@@ -1011,6 +1381,7 @@ async function runVerify(options, baseUrl) {
|
|
|
1011
1381
|
? "Continue only with the unchanged request bytes shown by this approval."
|
|
1012
1382
|
: "Do not process the request; resolve the reason and create a fresh proof.",
|
|
1013
1383
|
approvedResult ? "success" : "danger",
|
|
1384
|
+
[key, token],
|
|
1014
1385
|
);
|
|
1015
1386
|
}
|
|
1016
1387
|
if (payload.verified && payload.result === "APPROVED" && result.response.ok) return;
|
|
@@ -1127,14 +1498,14 @@ async function runChecks(options, baseUrl) {
|
|
|
1127
1498
|
});
|
|
1128
1499
|
if (!payload) return;
|
|
1129
1500
|
const suite = record(payload.suite);
|
|
1130
|
-
if (!suite
|
|
1501
|
+
if (!validActionCheckSuite(suite)) {
|
|
1131
1502
|
writeActionCheckOperationalError("Totem returned an invalid action check suite.");
|
|
1132
1503
|
return;
|
|
1133
1504
|
}
|
|
1134
|
-
writeJson({ suite });
|
|
1505
|
+
writeJson(redactJsonSecrets({ suite }, [key]));
|
|
1135
1506
|
if (suite.status === "STALE" || ["BLOCK", "REQUIRES_APPROVAL"].includes(suite.conclusion)) {
|
|
1136
1507
|
process.exitCode = 2;
|
|
1137
|
-
} else if (suite.conclusion === "ERROR") {
|
|
1508
|
+
} else if (suite.status !== "COMPLETED" || suite.conclusion === "ERROR") {
|
|
1138
1509
|
process.exitCode = 1;
|
|
1139
1510
|
}
|
|
1140
1511
|
return;
|
|
@@ -1153,11 +1524,11 @@ async function runChecks(options, baseUrl) {
|
|
|
1153
1524
|
});
|
|
1154
1525
|
if (!payload) return;
|
|
1155
1526
|
const suite = record(payload.suite);
|
|
1156
|
-
if (!suite
|
|
1527
|
+
if (!validActionCheckSuite(suite, suiteId)) {
|
|
1157
1528
|
writeActionCheckOperationalError("Totem returned an invalid action check suite.");
|
|
1158
1529
|
return;
|
|
1159
1530
|
}
|
|
1160
|
-
writeJson({ suite });
|
|
1531
|
+
writeJson(redactJsonSecrets({ suite }, [key]));
|
|
1161
1532
|
return;
|
|
1162
1533
|
}
|
|
1163
1534
|
|
|
@@ -1175,11 +1546,11 @@ async function runChecks(options, baseUrl) {
|
|
|
1175
1546
|
});
|
|
1176
1547
|
if (!payload) return;
|
|
1177
1548
|
const suite = record(payload.suite);
|
|
1178
|
-
if (!suite
|
|
1549
|
+
if (!validActionCheckSuite(suite, suiteId)) {
|
|
1179
1550
|
writeActionCheckOperationalError("Totem returned an invalid action check decision response.");
|
|
1180
1551
|
return;
|
|
1181
1552
|
}
|
|
1182
|
-
writeJson({ suite });
|
|
1553
|
+
writeJson(redactJsonSecrets({ suite }, [cookie]));
|
|
1183
1554
|
if (decision === "REJECT") process.exitCode = 2;
|
|
1184
1555
|
return;
|
|
1185
1556
|
}
|
|
@@ -1203,13 +1574,17 @@ async function runChecks(options, baseUrl) {
|
|
|
1203
1574
|
typeof grant.token !== "string" ||
|
|
1204
1575
|
grant.token.length < 32 ||
|
|
1205
1576
|
grant.token.length > 512 ||
|
|
1206
|
-
|
|
1207
|
-
!suite
|
|
1577
|
+
!validIsoTimestamp(grant.expiresAt) ||
|
|
1578
|
+
!validActionCheckSuite(suite, suiteId)
|
|
1208
1579
|
) {
|
|
1209
1580
|
writeActionCheckOperationalError("Totem returned an invalid execution grant response.");
|
|
1210
1581
|
return;
|
|
1211
1582
|
}
|
|
1212
|
-
writeJson(
|
|
1583
|
+
writeJson(
|
|
1584
|
+
redactJsonSecrets({ grant: { token: grant.token, expiresAt: grant.expiresAt, suite } }, [
|
|
1585
|
+
key,
|
|
1586
|
+
]),
|
|
1587
|
+
);
|
|
1213
1588
|
return;
|
|
1214
1589
|
}
|
|
1215
1590
|
|
|
@@ -1233,19 +1608,27 @@ async function runChecks(options, baseUrl) {
|
|
|
1233
1608
|
const suite = record(payload.suite);
|
|
1234
1609
|
const receipt = record(payload.receipt);
|
|
1235
1610
|
if (
|
|
1236
|
-
!suite ||
|
|
1611
|
+
!validActionCheckSuite(suite, suiteId) ||
|
|
1237
1612
|
!receipt ||
|
|
1238
1613
|
typeof receipt.id !== "string" ||
|
|
1239
|
-
|
|
1614
|
+
receipt.id.length > 200 ||
|
|
1615
|
+
!SAFE_IDENTIFIER_PATTERN.test(receipt.id) ||
|
|
1616
|
+
!ACTION_EXECUTION_OUTCOMES.has(receipt.outcome) ||
|
|
1617
|
+
(typeof receipt.suiteId === "string" && receipt.suiteId !== suiteId)
|
|
1240
1618
|
) {
|
|
1241
1619
|
writeActionCheckOperationalError("Totem returned an invalid execution receipt response.");
|
|
1242
1620
|
return;
|
|
1243
1621
|
}
|
|
1244
|
-
writeJson(
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1622
|
+
writeJson(
|
|
1623
|
+
redactJsonSecrets(
|
|
1624
|
+
{
|
|
1625
|
+
suite,
|
|
1626
|
+
receipt,
|
|
1627
|
+
...(typeof payload.receiptId === "string" ? { receiptId: payload.receiptId } : {}),
|
|
1628
|
+
},
|
|
1629
|
+
[key, grant],
|
|
1630
|
+
),
|
|
1631
|
+
);
|
|
1249
1632
|
if (["FAILED", "UNKNOWN", "COMPENSATED"].includes(receipt.outcome)) process.exitCode = 1;
|
|
1250
1633
|
return;
|
|
1251
1634
|
}
|
|
@@ -1264,8 +1647,11 @@ async function runRfq(options, baseUrl) {
|
|
|
1264
1647
|
}
|
|
1265
1648
|
const requestParts = options.positionals.slice(1);
|
|
1266
1649
|
if (requestParts.length === 0) throw new Error('Provide an RFQ request after the "rfq" command.');
|
|
1267
|
-
const idempotencyKey = options.idempotency_key ?? `cli:${randomUUID()}`;
|
|
1268
1650
|
const prompt = requestParts.join(" ").trim();
|
|
1651
|
+
if (prompt.length < 4 || prompt.length > 2_000) {
|
|
1652
|
+
throw new Error("The RFQ request must contain 4 to 2000 characters.");
|
|
1653
|
+
}
|
|
1654
|
+
const idempotencyKey = validIdempotencyKey(options.idempotency_key ?? `cli:${randomUUID()}`);
|
|
1269
1655
|
process.stderr.write(`Totem retry key: ${idempotencyKey}\n`);
|
|
1270
1656
|
|
|
1271
1657
|
let result;
|
|
@@ -1285,6 +1671,7 @@ async function runRfq(options, baseUrl) {
|
|
|
1285
1671
|
: "Could not reach Totem.",
|
|
1286
1672
|
idempotencyKey,
|
|
1287
1673
|
options.json,
|
|
1674
|
+
options.json,
|
|
1288
1675
|
);
|
|
1289
1676
|
return;
|
|
1290
1677
|
}
|
|
@@ -1292,18 +1679,28 @@ async function runRfq(options, baseUrl) {
|
|
|
1292
1679
|
const message = errorFrom(
|
|
1293
1680
|
result.payload,
|
|
1294
1681
|
`The RFQ endpoint returned HTTP ${result.response.status}.`,
|
|
1682
|
+
[key],
|
|
1295
1683
|
);
|
|
1296
|
-
if (result.response.status >= 500)
|
|
1297
|
-
|
|
1684
|
+
if (result.response.status >= 500) {
|
|
1685
|
+
failUnknownOutcome(message, idempotencyKey, options.json, options.json);
|
|
1686
|
+
} else {
|
|
1687
|
+
failCommand(message, options.json);
|
|
1688
|
+
}
|
|
1298
1689
|
return;
|
|
1299
1690
|
}
|
|
1300
1691
|
const run = record(result.payload?.run);
|
|
1301
1692
|
if (!run) {
|
|
1302
|
-
failUnknownOutcome(
|
|
1693
|
+
failUnknownOutcome(
|
|
1694
|
+
"Totem returned an invalid success response.",
|
|
1695
|
+
idempotencyKey,
|
|
1696
|
+
options.json,
|
|
1697
|
+
options.json,
|
|
1698
|
+
);
|
|
1303
1699
|
return;
|
|
1304
1700
|
}
|
|
1305
1701
|
if (options.json) {
|
|
1306
|
-
writeJson(result.payload);
|
|
1702
|
+
writeJson(redactJsonSecrets(result.payload, [key]));
|
|
1703
|
+
if (run.status !== "SUCCEEDED") process.exitCode = 1;
|
|
1307
1704
|
return;
|
|
1308
1705
|
}
|
|
1309
1706
|
|
|
@@ -1338,12 +1735,43 @@ async function runRfq(options, baseUrl) {
|
|
|
1338
1735
|
? "Review the supplier response and retain the run ID as the audit reference."
|
|
1339
1736
|
: "Open the Totem operator run for the bounded trace before retrying.",
|
|
1340
1737
|
succeeded ? "success" : "danger",
|
|
1738
|
+
[key],
|
|
1341
1739
|
);
|
|
1342
1740
|
if (run.status !== "SUCCEEDED") process.exitCode = 1;
|
|
1343
1741
|
}
|
|
1344
1742
|
|
|
1345
1743
|
async function main() {
|
|
1744
|
+
if (process.argv[2] === "mcp") {
|
|
1745
|
+
if (process.argv[3] === "install" || process.argv[3] === "--help" || process.argv[3] === "-h") {
|
|
1746
|
+
const { installTotemMcp } = await import("./mcp-install.mjs");
|
|
1747
|
+
try {
|
|
1748
|
+
process.stdout.write(
|
|
1749
|
+
await installTotemMcp(process.argv[3] === "install" ? process.argv.slice(4) : ["--help"]),
|
|
1750
|
+
);
|
|
1751
|
+
} catch (error) {
|
|
1752
|
+
process.stderr.write(
|
|
1753
|
+
`${error instanceof Error ? error.message : "Could not install the Totem MCP server."}\n`,
|
|
1754
|
+
);
|
|
1755
|
+
process.exitCode = 1;
|
|
1756
|
+
}
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
if (process.argv.length > 3) {
|
|
1760
|
+
process.stderr.write("Unknown Totem MCP option. Run `totem mcp install --help`.\n");
|
|
1761
|
+
process.exitCode = 1;
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
const { mcpMain } = await import("./local-mcp.mjs");
|
|
1765
|
+
await mcpMain();
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
if (process.argv[2] === "network") {
|
|
1769
|
+
const { networkMain } = await import("./network.mjs");
|
|
1770
|
+
await networkMain(process.argv.slice(3));
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1346
1773
|
let options;
|
|
1774
|
+
const jsonRequested = process.argv.slice(2).includes("--json");
|
|
1347
1775
|
try {
|
|
1348
1776
|
options = parseArguments(process.argv.slice(2));
|
|
1349
1777
|
const command = options.positionals[0];
|
|
@@ -1368,8 +1796,7 @@ async function main() {
|
|
|
1368
1796
|
const message = error instanceof Error ? error.message : "The Totem command failed.";
|
|
1369
1797
|
const command = options?.positionals?.[0] ?? process.argv[2];
|
|
1370
1798
|
if (command === "checks") writeActionCheckOperationalError(message);
|
|
1371
|
-
else
|
|
1372
|
-
else fail(message);
|
|
1799
|
+
else failCommand(message, options?.json ?? jsonRequested);
|
|
1373
1800
|
}
|
|
1374
1801
|
}
|
|
1375
1802
|
|