@agentcomms/resend 0.12.1 → 0.12.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/dist/cli.mjs +64 -19
- package/dist/index.d.mts +28 -4
- package/dist/index.mjs +2 -2
- package/dist/{send-W8SmlF7p.mjs → send-BedjeO_M.mjs} +849 -339
- package/dist/{server-ElSFoKeD.mjs → server-Bq2OYONI.mjs} +3 -2
- package/dist/{stdio-entry-h_wAVp9z.mjs → stdio-entry-QIUMjquj.mjs} +1 -1
- package/package.json +3 -3
|
@@ -4,15 +4,15 @@ import path, { basename, delimiter, dirname, extname, isAbsolute, join, relative
|
|
|
4
4
|
import { domainToASCII, fileURLToPath, pathToFileURL } from "node:url";
|
|
5
5
|
import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
|
|
6
6
|
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
7
|
+
import "node:readline/promises";
|
|
8
|
+
import { styleText } from "node:util";
|
|
7
9
|
import { homedir, platform, userInfo } from "node:os";
|
|
8
10
|
import { constants as constants$1, realpathSync } from "node:fs";
|
|
9
11
|
import { execFile, spawn } from "node:child_process";
|
|
10
|
-
import "node:readline/promises";
|
|
11
|
-
import { styleText } from "node:util";
|
|
12
12
|
//#endregion
|
|
13
13
|
//#region src/version.ts
|
|
14
14
|
/** The package version, read from package.json at build time. */
|
|
15
|
-
const VERSION$1 = "0.12.
|
|
15
|
+
const VERSION$1 = "0.12.3";
|
|
16
16
|
//#endregion
|
|
17
17
|
//#region src/api/routes.ts
|
|
18
18
|
/**
|
|
@@ -187,7 +187,7 @@ function refusedBecause(method, pathname) {
|
|
|
187
187
|
return null;
|
|
188
188
|
}
|
|
189
189
|
//#endregion
|
|
190
|
-
//#region ../core/dist/update-check-
|
|
190
|
+
//#region ../core/dist/update-check-Dq-D4bAD.mjs
|
|
191
191
|
/**
|
|
192
192
|
* Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
|
|
193
193
|
* optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
|
|
@@ -564,9 +564,15 @@ async function takeOverStale(lockPath, staleMs) {
|
|
|
564
564
|
async function withFileLock(lockPath, fn, options = {}) {
|
|
565
565
|
const timeoutMs = options.timeoutMs ?? 5e3;
|
|
566
566
|
const staleMs = options.staleMs ?? 3e4;
|
|
567
|
-
const
|
|
567
|
+
const started = Date.now();
|
|
568
|
+
let deadline = started + timeoutMs;
|
|
569
|
+
/** `maxWaitMs`'s end: fixed when the wait begins, whatever the lock does. */
|
|
570
|
+
const limit = options.maxWaitMs === void 0 ? Number.POSITIVE_INFINITY : started + options.maxWaitMs;
|
|
568
571
|
const token = randomBytes(12).toString("hex");
|
|
569
572
|
let lastCode = "EEXIST";
|
|
573
|
+
/** The token of the holder this caller saw last, and how many times it has seen the lock change hands. */
|
|
574
|
+
let holder;
|
|
575
|
+
let handOvers = 0;
|
|
570
576
|
await ensurePrivateDir(dirname(lockPath));
|
|
571
577
|
for (;;) try {
|
|
572
578
|
const handle = await open(lockPath, "wx", 384);
|
|
@@ -581,10 +587,20 @@ async function withFileLock(lockPath, fn, options = {}) {
|
|
|
581
587
|
const code = error.code;
|
|
582
588
|
if (!CONTENDED.has(code ?? "")) throw error;
|
|
583
589
|
lastCode = code ?? lastCode;
|
|
584
|
-
if (code === "EEXIST"
|
|
585
|
-
await
|
|
586
|
-
|
|
590
|
+
if (code === "EEXIST") {
|
|
591
|
+
const body = await readLock(lockPath);
|
|
592
|
+
if (await isStale(lockPath, body, staleMs)) {
|
|
593
|
+
await takeOverStale(lockPath, staleMs);
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
const seen = typeof body?.token === "string" ? body.token : void 0;
|
|
597
|
+
if (seen !== void 0 && seen !== holder) {
|
|
598
|
+
if (holder !== void 0) handOvers += 1;
|
|
599
|
+
holder = seen;
|
|
600
|
+
if (options.timeoutPerHolder) deadline = Date.now() + timeoutMs;
|
|
601
|
+
}
|
|
587
602
|
}
|
|
603
|
+
if (Date.now() > limit) throw new CommsError("LOCK_TIMEOUT", `gave up waiting for ${lockPath}: the wait hit its overall limit of ${(limit - started) / 1e3} s`, { hint: (handOvers > 0 ? `It changed hands ${handOvers} time(s) while this waited, never to this process: busy rather than stuck. Retry in a moment.` : "Retry in a moment. If it persists and no other process is running, delete the lock file.") + (lastCode === "EEXIST" ? "" : ` (last error: ${lastCode})`) });
|
|
588
604
|
if (Date.now() > deadline) throw new CommsError("LOCK_TIMEOUT", `another agent-communications process is holding ${lockPath}`, { hint: `Retry in a moment. If it persists and no other process is running, delete the lock file.` + (lastCode === "EEXIST" ? "" : ` (last error: ${lastCode})`) });
|
|
589
605
|
await setTimeout$1(25 + Math.floor(Math.random() * 50));
|
|
590
606
|
}
|
|
@@ -1195,6 +1211,253 @@ function connectMailboxCommand() {
|
|
|
1195
1211
|
const mail = accountChannels().find((manifest) => manifest.accounts?.map === "inboxes");
|
|
1196
1212
|
return mail ? `${mail.binary} inbox add` : void 0;
|
|
1197
1213
|
}
|
|
1214
|
+
/** Crockford-style alphabet without I, L, O and U, so ids and challenges survive being read aloud or retyped. */
|
|
1215
|
+
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
1216
|
+
function randomString(length) {
|
|
1217
|
+
const bytes = randomBytes(length);
|
|
1218
|
+
let out = "";
|
|
1219
|
+
for (let i = 0; i < length; i += 1) out += ALPHABET[(bytes[i] ?? 0) & 31];
|
|
1220
|
+
return out;
|
|
1221
|
+
}
|
|
1222
|
+
/** `ap_` + 26 characters: 130 random bits. Validated before an id ever names a file. */
|
|
1223
|
+
const APPROVAL_ID_PATTERN = /^ap_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
1224
|
+
const PLAN_TOKEN_PATTERN = /^pl_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
1225
|
+
function newApprovalId() {
|
|
1226
|
+
return `ap_${randomString(26)}`;
|
|
1227
|
+
}
|
|
1228
|
+
function newPlanToken() {
|
|
1229
|
+
return `pl_${randomString(26)}`;
|
|
1230
|
+
}
|
|
1231
|
+
const CHALLENGE_LETTERS = "ABCDEFGHJKMNPQRSTVWXYZ";
|
|
1232
|
+
/** A short challenge a human types back to approve: letters only, no look-alikes. */
|
|
1233
|
+
function newChallenge(length = 4) {
|
|
1234
|
+
let out = "";
|
|
1235
|
+
for (let i = 0; i < length; i += 1) out += CHALLENGE_LETTERS[randomInt(22)];
|
|
1236
|
+
return out;
|
|
1237
|
+
}
|
|
1238
|
+
/** Challenges are stored only as hashes, so no listing or tool result can reveal one. */
|
|
1239
|
+
function hashChallenge(challenge) {
|
|
1240
|
+
return createHash("sha256").update(challenge.trim().toUpperCase()).digest("hex");
|
|
1241
|
+
}
|
|
1242
|
+
/** Case-insensitive, constant-time comparison of an answer with a stored challenge hash. */
|
|
1243
|
+
function challengeMatches(answer, storedHash) {
|
|
1244
|
+
const a = Buffer.from(hashChallenge(answer), "hex");
|
|
1245
|
+
const b = Buffer.from(storedHash, "hex");
|
|
1246
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
1247
|
+
}
|
|
1248
|
+
function okEnvelope(data) {
|
|
1249
|
+
return {
|
|
1250
|
+
ok: true,
|
|
1251
|
+
schemaVersion: 1,
|
|
1252
|
+
data
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
function errorEnvelope(error) {
|
|
1256
|
+
const body = {
|
|
1257
|
+
code: error.code,
|
|
1258
|
+
message: error.message
|
|
1259
|
+
};
|
|
1260
|
+
if (error.hint !== void 0) body.hint = error.hint;
|
|
1261
|
+
if (error.details !== void 0) body.details = error.details;
|
|
1262
|
+
return {
|
|
1263
|
+
ok: false,
|
|
1264
|
+
schemaVersion: 1,
|
|
1265
|
+
error: body
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
const defaultStreams = {
|
|
1269
|
+
stdout: process.stdout,
|
|
1270
|
+
stderr: process.stderr,
|
|
1271
|
+
stdin: process.stdin
|
|
1272
|
+
};
|
|
1273
|
+
function colorEnabled(env, stream, flag) {
|
|
1274
|
+
if (flag === false) return false;
|
|
1275
|
+
if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
|
|
1276
|
+
if (env.TERM === "dumb") return false;
|
|
1277
|
+
if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
|
|
1278
|
+
return Boolean(stream.isTTY);
|
|
1279
|
+
}
|
|
1280
|
+
/** True when a human could answer a prompt: both ends are terminals, no --json/--no-input, not CI. */
|
|
1281
|
+
function canPrompt(env, streams, options) {
|
|
1282
|
+
if (options.json || options.noInput) return false;
|
|
1283
|
+
if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
|
|
1284
|
+
return Boolean(streams.stdin?.isTTY && streams.stdout.isTTY);
|
|
1285
|
+
}
|
|
1286
|
+
/** Environment variables well-known coding agents set. Used only as a speed bump, never as a security boundary. */
|
|
1287
|
+
const AGENT_MARKERS = [
|
|
1288
|
+
"CLAUDECODE",
|
|
1289
|
+
"CLAUDE_CODE_ENTRYPOINT",
|
|
1290
|
+
"CODEX_SANDBOX",
|
|
1291
|
+
"CODEX_HOME",
|
|
1292
|
+
"CURSOR_AGENT",
|
|
1293
|
+
"GEMINI_CLI",
|
|
1294
|
+
"AGENT_COMMS_AGENT"
|
|
1295
|
+
];
|
|
1296
|
+
function agentMarker(env) {
|
|
1297
|
+
for (const name of AGENT_MARKERS) if (env[name] !== void 0 && env[name] !== "") return name;
|
|
1298
|
+
return null;
|
|
1299
|
+
}
|
|
1300
|
+
function paint(color, format, text) {
|
|
1301
|
+
return color ? styleText(format, text, { validateStream: false }) : text;
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* A command line for a person to copy and run, each word quoted only where the shell it is pasted into would need it.
|
|
1305
|
+
* Every command this package prints to be run — a change to run again with its approval, a folder to take out, an
|
|
1306
|
+
* entry to register again or remove — is quoted here, so no printer quotes for a shell of its own.
|
|
1307
|
+
*
|
|
1308
|
+
* Everywhere but Windows that shell is a POSIX one, and a word goes in single quotes, inside which nothing is special
|
|
1309
|
+
* but the quote itself. Windows has two shells, and neither reads single quotes that way: cmd.exe does not take them
|
|
1310
|
+
* as quotes at all, so `'C:\Profiles\First Last\outgoing'` reached the command as two words, quote marks and all, and
|
|
1311
|
+
* PowerShell does, but escapes a quote inside them by doubling it rather than as `'\''`. A command printed on Windows
|
|
1312
|
+
* has to be safe in both, because nothing says which one it will be pasted into (CUE-306) — and safe is not enough:
|
|
1313
|
+
* the program has to receive the same words from either. Three readers stand between the line and the program. cmd.exe
|
|
1314
|
+
* hands the program the line as it is, and the program's own parser (the C runtime's, Node's) splits it. PowerShell
|
|
1315
|
+
* reads the line itself, a double-quoted word with its backslashes as plain characters, and then writes a new command
|
|
1316
|
+
* line for the program: Windows PowerShell 5.1 the old way ("Legacy" in about_Parsing, "Passing arguments to native
|
|
1317
|
+
* applications") — a word quoted only when it holds whitespace, as it is, and an empty word dropped — and PowerShell
|
|
1318
|
+
* 7.3 and later its own way ("Standard") — quoted when it must be, with every backslash before a quote doubled. For a
|
|
1319
|
+
* `.cmd` script, which is how npm installs `agentcomms`, `claude` and `codex` on Windows, PowerShell 7 goes back to the
|
|
1320
|
+
* old way, and cmd.exe then reads that new line. So on Windows:
|
|
1321
|
+
*
|
|
1322
|
+
* - A word of letters, digits and `_ + = : . / \`, with `@` and `-` anywhere but first, is left as it is: no reader
|
|
1323
|
+
* does anything with it, and a backslash is an ordinary character to all of them, at the end too. A first `@` is
|
|
1324
|
+
* splatting to PowerShell, and a `,` its array operator — two words — so a word with either is quoted. So is a word
|
|
1325
|
+
* that starts with a digit, which PowerShell may read as a number (`1kb`, `0x10`), and one that starts with `-` but
|
|
1326
|
+
* is not a plain option, which PowerShell may read as a parameter of its own and split (`-name.x`).
|
|
1327
|
+
* - A word that double quotes bring through all three readers whole goes in double quotes. Inside them cmd.exe reads
|
|
1328
|
+
* `& | < > ^ ( )` and spaces as ordinary characters, and PowerShell reads everything as ordinary but `$`, the
|
|
1329
|
+
* backtick and a double quote. What is left special in one or the other is kept out: a double quote, which ends the
|
|
1330
|
+
* quoting in both — and PowerShell takes the curly ones, `“ ” „`, for one too; `$` and the backtick, PowerShell's
|
|
1331
|
+
* expansion and escape; `%`, which cmd.exe expands as `%NAME%` before it looks at quotes at all; `!`, which it
|
|
1332
|
+
* expands as `!NAME!` inside quotes too wherever delayed expansion is on, and which then makes a `^` inside quotes an
|
|
1333
|
+
* escape; and any control or formatting character — a line break ends the command in cmd.exe even inside quotes, a
|
|
1334
|
+
* tab pasted into cmd.exe can complete a file name, and a right-to-left override shows a line other than the one
|
|
1335
|
+
* that runs. Three more cannot come through: an empty word, which Windows PowerShell drops; a word ending in a
|
|
1336
|
+
* backslash, which the C runtime reads with the closing quote as `\"` — and doubled for it, PowerShell 7 passes both
|
|
1337
|
+
* backslashes on where cmd.exe and Windows PowerShell pass one; and a word with `& | < > ^ ( )` but no whitespace,
|
|
1338
|
+
* which PowerShell passes to a `.cmd` script unquoted, for cmd.exe to run `&whoami` from or to split at `|`.
|
|
1339
|
+
* - With any other word in it, no line is printed at all (`line` is null), and the printers show the command's words
|
|
1340
|
+
* as JSON instead, saying it has to be typed. Not a line with the word left out: a placeholder in its place was
|
|
1341
|
+
* still a command that ran — `claude mcp remove NAME` removed whatever entry was called `NAME`, and an install
|
|
1342
|
+
* hint's `--force` replaced it — and before that, a word quoted for PowerShell's single quotes, which cmd.exe reads
|
|
1343
|
+
* as characters, ran `whoami` from a server named `$x&whoami&`. See `commandAsJson` for why the JSON runs nothing.
|
|
1344
|
+
*
|
|
1345
|
+
* Everywhere else there is always a line: single quotes make any word safe.
|
|
1346
|
+
*/
|
|
1347
|
+
function shellCommand(words, platform = process.platform) {
|
|
1348
|
+
if (platform !== "win32") return {
|
|
1349
|
+
words,
|
|
1350
|
+
line: words.map(posixShellWord).join(" "),
|
|
1351
|
+
platform
|
|
1352
|
+
};
|
|
1353
|
+
const printed = words.map(windowsShellWord);
|
|
1354
|
+
return {
|
|
1355
|
+
words,
|
|
1356
|
+
line: printed.includes(null) ? null : printed.join(" "),
|
|
1357
|
+
platform
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
/** The same command with more words at its end — the approval it is to be run again with — for the same shell. */
|
|
1361
|
+
function withWords(command, ...more) {
|
|
1362
|
+
return shellCommand([...command.words, ...more], command.platform);
|
|
1363
|
+
}
|
|
1364
|
+
function posixShellWord(word) {
|
|
1365
|
+
return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* The word as cmd.exe, Windows PowerShell and PowerShell 7 all hand it to a program, or null when no printing of it
|
|
1369
|
+
* does (see `shellCommand`). Bare: a plain option, or ordinary characters that do not start like a number.
|
|
1370
|
+
*/
|
|
1371
|
+
function windowsShellWord(word) {
|
|
1372
|
+
if (/^(?:--?[A-Za-z][A-Za-z0-9-]*|(?!\+?\.?\d)[\w+=:./\\][\w@+=:./\\-]*)$/.test(word)) return word;
|
|
1373
|
+
if (word === "" || word.endsWith("\\")) return null;
|
|
1374
|
+
if (/["$`%!\u201C-\u201E]|[\p{C}\p{Zl}\p{Zp}]/u.test(word)) return null;
|
|
1375
|
+
if (/[&|<>^()]/.test(word) && !/\s/.test(word)) return null;
|
|
1376
|
+
return `"${word}"`;
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* The words of a command as a JSON array, for a command that cannot be printed as a line: something to read, and to
|
|
1380
|
+
* parse, and nothing to run.
|
|
1381
|
+
*
|
|
1382
|
+
* Pasted into either Windows shell by mistake, it runs nothing. PowerShell refuses it before running anything: a `[`
|
|
1383
|
+
* opens a type name, and a quoted string is none. cmd.exe looks for a program called `["agentcomms"` or the like, and
|
|
1384
|
+
* finds none — and nothing in the line is anything else to it: every character it acts on, inside quotes or out (`%`,
|
|
1385
|
+
* `!`), is written as a `\u` escape, and so is every double quote inside a word, so the quotes cmd.exe sees are the
|
|
1386
|
+
* JSON's own, in pairs, and `&`, `|`, `<`, `>`, `^` and the parentheses are only ever inside them, where it reads them
|
|
1387
|
+
* as characters. `$` and the backtick are escaped too, so that PowerShell would expand nothing even if it read on, and
|
|
1388
|
+
* so is everything outside printable ASCII — a line break, a curly quote, a right-to-left override. A backslash is
|
|
1389
|
+
* doubled, as JSON has it.
|
|
1390
|
+
*/
|
|
1391
|
+
function commandAsJson(words) {
|
|
1392
|
+
const word = (text) => {
|
|
1393
|
+
let json = "";
|
|
1394
|
+
for (let index = 0; index < text.length; index++) {
|
|
1395
|
+
const code = text.charCodeAt(index);
|
|
1396
|
+
const char = text[index];
|
|
1397
|
+
if (char === "\\") json += "\\\\";
|
|
1398
|
+
else if (code >= 32 && code <= 126 && !"\"%!$`".includes(char)) json += char;
|
|
1399
|
+
else json += `\\u${code.toString(16).padStart(4, "0")}`;
|
|
1400
|
+
}
|
|
1401
|
+
return `"${json}"`;
|
|
1402
|
+
};
|
|
1403
|
+
return `[${words.map(word).join(",")}]`;
|
|
1404
|
+
}
|
|
1405
|
+
/** What is said beside a command shown as JSON. */
|
|
1406
|
+
const TO_TYPE = "the command's words, written as JSON: one of them cannot be quoted the same way for cmd.exe and for PowerShell, so type the command yourself, with that word quoted for the shell you use";
|
|
1407
|
+
/** A command in backticks, for a sentence — as its words in JSON, saying it has to be typed, when it has no line. */
|
|
1408
|
+
function inlineCommand(command) {
|
|
1409
|
+
return command.line === null ? `\`${commandAsJson(command.words)}\` (${TO_TYPE})` : `\`${command.line}\``;
|
|
1410
|
+
}
|
|
1411
|
+
/** A command as text of its own — a list's line, a field's value — or its words in JSON, saying it has to be typed. */
|
|
1412
|
+
function commandText(command) {
|
|
1413
|
+
return command.line === null ? `${commandAsJson(command.words)} (${TO_TYPE})` : command.line;
|
|
1414
|
+
}
|
|
1415
|
+
/** Writes a successful result: the envelope with --json, otherwise the human rendering. */
|
|
1416
|
+
function writeResult(data, options, human, streams = defaultStreams) {
|
|
1417
|
+
if (options.json) {
|
|
1418
|
+
streams.stdout.write(`${JSON.stringify(okEnvelope(data))}\n`);
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
const text = human(data);
|
|
1422
|
+
if (text) streams.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
1423
|
+
}
|
|
1424
|
+
/** Writes an error and returns the exit code to use. */
|
|
1425
|
+
function writeError(error, options, streams = defaultStreams) {
|
|
1426
|
+
const commsError = toCommsError(error);
|
|
1427
|
+
if (options.json) streams.stdout.write(`${JSON.stringify(errorEnvelope(commsError))}\n`);
|
|
1428
|
+
else {
|
|
1429
|
+
streams.stderr.write(`${paint(options.color, "red", "error")}: ${commsError.message}\n`);
|
|
1430
|
+
if (commsError.hint) streams.stderr.write(`${paint(options.color, "dim", "hint")}: ${commsError.hint}\n`);
|
|
1431
|
+
}
|
|
1432
|
+
return commsError.exitCode;
|
|
1433
|
+
}
|
|
1434
|
+
/** Runs a command body and exits with the documented code. Unexpected errors keep only their message. */
|
|
1435
|
+
async function runCommand(options, body, streams = defaultStreams) {
|
|
1436
|
+
try {
|
|
1437
|
+
await body();
|
|
1438
|
+
return EXIT_CODES.OK;
|
|
1439
|
+
} catch (error) {
|
|
1440
|
+
return writeError(error, options, streams);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* The first two steps of `requirePerson` — an agent is refused, then anything without a terminal — for a command
|
|
1445
|
+
* whose code is not its own to make up.
|
|
1446
|
+
*
|
|
1447
|
+
* `agentcomms approve` asks for a code the approval store issued and will check, so it cannot use `askChallenge`;
|
|
1448
|
+
* but it refuses in exactly the same order and words, because it is the same gate.
|
|
1449
|
+
*/
|
|
1450
|
+
function refuseUnlessPerson(env, streams, gate) {
|
|
1451
|
+
const marker = agentMarker(env);
|
|
1452
|
+
if (marker) throw new CommsError("LOOSENING_REFUSED", gate.refusedToAgent, {
|
|
1453
|
+
hint: `Ask the user to run \`${gate.command}\` in their own terminal.`,
|
|
1454
|
+
details: { marker }
|
|
1455
|
+
});
|
|
1456
|
+
const prompting = {};
|
|
1457
|
+
if (gate.json !== void 0) prompting.json = gate.json;
|
|
1458
|
+
if (gate.noInput !== void 0) prompting.noInput = gate.noInput;
|
|
1459
|
+
if (!canPrompt(env, streams, prompting)) throw new CommsError("LOOSENING_REFUSED", gate.refusedWithoutTerminal, { hint: `Run \`${gate.command}\` directly in a terminal.` });
|
|
1460
|
+
}
|
|
1198
1461
|
/**
|
|
1199
1462
|
* One table of dangerous characters, shared by the inbound sanitiser (which strips them from sender-controlled text)
|
|
1200
1463
|
* and the preview renderer (which makes them visible), so the two can never disagree.
|
|
@@ -1855,13 +2118,19 @@ function committedSecretsStore(config) {
|
|
|
1855
2118
|
* A different backend asked for is refused, not taken: one backend holds everything here, and changing it has to move
|
|
1856
2119
|
* what is already stored, which is what `agentcomms secrets migrate` does and nothing else does.
|
|
1857
2120
|
*/
|
|
1858
|
-
function secretsStoreFor(config, requested) {
|
|
2121
|
+
function secretsStoreFor(config, requested, platform = process.platform) {
|
|
1859
2122
|
const committed = committedSecretsStore(config);
|
|
1860
2123
|
if (committed === null) return {
|
|
1861
2124
|
store: requested ?? "keychain",
|
|
1862
2125
|
choosing: true
|
|
1863
2126
|
};
|
|
1864
|
-
if (requested !== void 0 && requested !== committed) throw new CommsError("CONFIG", `this configuration already keeps its secrets in the ${committed} store`, { hint: `Everything here uses one store, and changing it moves what is already stored. To change it, run
|
|
2127
|
+
if (requested !== void 0 && requested !== committed) throw new CommsError("CONFIG", `this configuration already keeps its secrets in the ${committed} store`, { hint: `Everything here uses one store, and changing it moves what is already stored. To change it, run ${inlineCommand(shellCommand([
|
|
2128
|
+
"agentcomms",
|
|
2129
|
+
"secrets",
|
|
2130
|
+
"migrate",
|
|
2131
|
+
"--to",
|
|
2132
|
+
requested
|
|
2133
|
+
], platform))}, then run this again.` });
|
|
1865
2134
|
return {
|
|
1866
2135
|
store: committed,
|
|
1867
2136
|
choosing: false
|
|
@@ -2404,40 +2673,6 @@ function canonicalLoosening(loosening) {
|
|
|
2404
2673
|
function sameLoosening(a, b) {
|
|
2405
2674
|
return canonicalLoosening(a) === canonicalLoosening(b);
|
|
2406
2675
|
}
|
|
2407
|
-
/** Crockford-style alphabet without I, L, O and U, so ids and challenges survive being read aloud or retyped. */
|
|
2408
|
-
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
2409
|
-
function randomString(length) {
|
|
2410
|
-
const bytes = randomBytes(length);
|
|
2411
|
-
let out = "";
|
|
2412
|
-
for (let i = 0; i < length; i += 1) out += ALPHABET[(bytes[i] ?? 0) & 31];
|
|
2413
|
-
return out;
|
|
2414
|
-
}
|
|
2415
|
-
/** `ap_` + 26 characters: 130 random bits. Validated before an id ever names a file. */
|
|
2416
|
-
const APPROVAL_ID_PATTERN = /^ap_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
2417
|
-
const PLAN_TOKEN_PATTERN = /^pl_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
2418
|
-
function newApprovalId() {
|
|
2419
|
-
return `ap_${randomString(26)}`;
|
|
2420
|
-
}
|
|
2421
|
-
function newPlanToken() {
|
|
2422
|
-
return `pl_${randomString(26)}`;
|
|
2423
|
-
}
|
|
2424
|
-
const CHALLENGE_LETTERS = "ABCDEFGHJKMNPQRSTVWXYZ";
|
|
2425
|
-
/** A short challenge a human types back to approve: letters only, no look-alikes. */
|
|
2426
|
-
function newChallenge(length = 4) {
|
|
2427
|
-
let out = "";
|
|
2428
|
-
for (let i = 0; i < length; i += 1) out += CHALLENGE_LETTERS[randomInt(22)];
|
|
2429
|
-
return out;
|
|
2430
|
-
}
|
|
2431
|
-
/** Challenges are stored only as hashes, so no listing or tool result can reveal one. */
|
|
2432
|
-
function hashChallenge(challenge) {
|
|
2433
|
-
return createHash("sha256").update(challenge.trim().toUpperCase()).digest("hex");
|
|
2434
|
-
}
|
|
2435
|
-
/** Case-insensitive, constant-time comparison of an answer with a stored challenge hash. */
|
|
2436
|
-
function challengeMatches(answer, storedHash) {
|
|
2437
|
-
const a = Buffer.from(hashChallenge(answer), "hex");
|
|
2438
|
-
const b = Buffer.from(storedHash, "hex");
|
|
2439
|
-
return a.length === b.length && timingSafeEqual(a, b);
|
|
2440
|
-
}
|
|
2441
2676
|
/**
|
|
2442
2677
|
* The digest a download's question is bound to: the account, the download, the request, the files it listed and the
|
|
2443
2678
|
* names it showed them under.
|
|
@@ -2588,6 +2823,32 @@ function refusalFor(record) {
|
|
|
2588
2823
|
const kind = approvalKind(record);
|
|
2589
2824
|
return kind === "change" ? refuseChange : kind === "download" ? refuseDownload : refuse$1;
|
|
2590
2825
|
}
|
|
2826
|
+
/**
|
|
2827
|
+
* A claim whose caller was cancelled before it changed anything (`ClaimOptions.signal`).
|
|
2828
|
+
*
|
|
2829
|
+
* `USAGE` and `cancelled:`, the words every cancellation in this repository is reported in, with `details.reason`
|
|
2830
|
+
* saying so for a caller that branches on it — and in the record's own kind: a question is not an approval, and
|
|
2831
|
+
* nothing is sent by answering one. The record is as it was, and the hint says so.
|
|
2832
|
+
*/
|
|
2833
|
+
function cancelledClaim(record) {
|
|
2834
|
+
const kind = approvalKind(record);
|
|
2835
|
+
if (kind === "download") return new CommsError("USAGE", "cancelled: nothing was saved", {
|
|
2836
|
+
hint: "The question was not used: the same call, made again with the same answer, can still use it until it expires.",
|
|
2837
|
+
details: {
|
|
2838
|
+
choiceId: record.approvalId,
|
|
2839
|
+
state: record.state,
|
|
2840
|
+
reason: "cancelled"
|
|
2841
|
+
}
|
|
2842
|
+
});
|
|
2843
|
+
return new CommsError("USAGE", `cancelled: ${kind === "change" ? "nothing was changed" : "nothing was sent"}`, {
|
|
2844
|
+
hint: "The approval was not used: the same call, made again, can still use it until it expires.",
|
|
2845
|
+
details: {
|
|
2846
|
+
approvalId: record.approvalId,
|
|
2847
|
+
state: record.state,
|
|
2848
|
+
reason: "cancelled"
|
|
2849
|
+
}
|
|
2850
|
+
});
|
|
2851
|
+
}
|
|
2591
2852
|
/** The record as it may be shown to anyone, agents included: never the challenge hash. */
|
|
2592
2853
|
function publicView(record) {
|
|
2593
2854
|
const { challengeHash: _hidden, ...rest } = record;
|
|
@@ -2705,19 +2966,23 @@ var ApprovalStore = class {
|
|
|
2705
2966
|
* Nothing written, because the caller made a mistake about an approval that may be perfectly good: voiding a post
|
|
2706
2967
|
* somebody is about to approve, because an agent passed its id to a change, would punish the wrong party.
|
|
2707
2968
|
*/
|
|
2708
|
-
#requireKind(record, kind) {
|
|
2969
|
+
#requireKind(record, kind, platform) {
|
|
2709
2970
|
const actual = approvalKind(record);
|
|
2710
2971
|
if (actual === kind) return;
|
|
2711
2972
|
const id = record.approvalId;
|
|
2712
2973
|
if (actual === "download") throw (kind === "change" ? refuseChange : refuse$1)("USAGE", `${id} is a question about where to save files, not ${kind === "send" ? "a send" : "a configuration change"}`, record, DOWNLOAD_ANSWER_HINT);
|
|
2713
2974
|
if (kind === "download") throw refuseDownload("USAGE", `${id} is ${actual === "change" ? "a configuration change" : "a send"}, not a question about where to save files`, record, "Pass the choice id the download’s question came with, and the person’s answer beside it.");
|
|
2714
|
-
throw kind === "send" ? refuse$1("USAGE", `approval ${id} is for a configuration change, not a send`, record, `A person approves it with
|
|
2975
|
+
throw kind === "send" ? refuse$1("USAGE", `approval ${id} is for a configuration change, not a send`, record, `A person approves it with ${inlineCommand(shellCommand([
|
|
2976
|
+
"agentcomms",
|
|
2977
|
+
"approve",
|
|
2978
|
+
id
|
|
2979
|
+
], platform))} — or ${channelApproveCommands()}, whichever is installed — and it permits only the change it was prepared for.`) : refuseChange("USAGE", `approval ${id} is for a send, not a configuration change`, record, `It is approved with the command that prepared it — ${channelApproveCommands({ sending: true })} — and permits only that send.`);
|
|
2715
2980
|
}
|
|
2716
2981
|
/** Issues a new challenge to show a human; only its hash is kept. */
|
|
2717
|
-
async issueChallenge(approvalId, kind = "send") {
|
|
2982
|
+
async issueChallenge(approvalId, kind = "send", platform = process.platform) {
|
|
2718
2983
|
const challenge = newChallenge();
|
|
2719
2984
|
await this.#transition(approvalId, (current) => {
|
|
2720
|
-
this.#requireKind(current, kind);
|
|
2985
|
+
this.#requireKind(current, kind, platform);
|
|
2721
2986
|
if (current.state !== "pending") throw this.#stateError(current);
|
|
2722
2987
|
return {
|
|
2723
2988
|
...current,
|
|
@@ -2734,10 +2999,10 @@ var ApprovalStore = class {
|
|
|
2734
2999
|
* A change is approved the same way, with `kind: 'change'` and its digest standing in for the draft (see
|
|
2735
3000
|
* `createChange`), so a person's typed code means one thing whichever kind of approval it is typed for.
|
|
2736
3001
|
*/
|
|
2737
|
-
async approve(approvalId, via, live, answer, kind = "send") {
|
|
3002
|
+
async approve(approvalId, via, live, answer, kind = "send", platform = process.platform) {
|
|
2738
3003
|
let failure = null;
|
|
2739
3004
|
const result = await this.#transition(approvalId, (current) => {
|
|
2740
|
-
this.#requireKind(current, kind);
|
|
3005
|
+
this.#requireKind(current, kind, platform);
|
|
2741
3006
|
if (current.state !== "pending") throw this.#stateError(current);
|
|
2742
3007
|
if (!current.challengeHash) throw refusalFor(current)("APPROVAL_REQUIRED", "no challenge was issued for this approval", current);
|
|
2743
3008
|
if (live.draftMessageId !== current.draftMessageId || live.digest !== current.digest) {
|
|
@@ -2794,8 +3059,9 @@ var ApprovalStore = class {
|
|
|
2794
3059
|
async claimForSend(approvalId, live, options = {}) {
|
|
2795
3060
|
let failure = null;
|
|
2796
3061
|
const result = await this.#transition(approvalId, (current) => {
|
|
2797
|
-
this.#requireKind(current, "send");
|
|
3062
|
+
this.#requireKind(current, "send", options.platform ?? process.platform);
|
|
2798
3063
|
if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
|
|
3064
|
+
if (options.signal?.aborted) throw cancelledClaim(current);
|
|
2799
3065
|
const voidWith = (code, reason) => {
|
|
2800
3066
|
failure = {
|
|
2801
3067
|
code,
|
|
@@ -2904,8 +3170,9 @@ var ApprovalStore = class {
|
|
|
2904
3170
|
const digest = changeDigest(live.change);
|
|
2905
3171
|
let failure = null;
|
|
2906
3172
|
const result = await this.#transition(approvalId, (current) => {
|
|
2907
|
-
this.#requireKind(current, "change");
|
|
3173
|
+
this.#requireKind(current, "change", options.platform ?? process.platform);
|
|
2908
3174
|
if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
|
|
3175
|
+
if (options.signal?.aborted) throw cancelledClaim(current);
|
|
2909
3176
|
const voidWith = (reason) => {
|
|
2910
3177
|
failure = {
|
|
2911
3178
|
code: "APPROVAL_VOID",
|
|
@@ -2919,7 +3186,11 @@ var ApprovalStore = class {
|
|
|
2919
3186
|
};
|
|
2920
3187
|
if (digest !== current.digest) return voidWith(current.change ? changeDrift(current.change, live.change) : "the change is not the one that was approved");
|
|
2921
3188
|
if (stricterPolicy(live.policy, current.requiredPolicy) !== "chat") {
|
|
2922
|
-
if (current.state !== "approved") throw refuseChange("APPROVAL_PENDING", "this change needs a person to approve it at a terminal first", current, options.pendingHint ?? `Ask the user to run
|
|
3189
|
+
if (current.state !== "approved") throw refuseChange("APPROVAL_PENDING", "this change needs a person to approve it at a terminal first", current, options.pendingHint ?? `Ask the user to run ${inlineCommand(shellCommand([
|
|
3190
|
+
"agentcomms",
|
|
3191
|
+
"approve",
|
|
3192
|
+
approvalId
|
|
3193
|
+
], options.platform ?? process.platform))} in their own terminal, then try again with the same approval.`);
|
|
2923
3194
|
if (current.approvedVia !== "terminal") return voidWith("the change policy is confirm, and this was not approved at a terminal");
|
|
2924
3195
|
}
|
|
2925
3196
|
return {
|
|
@@ -2996,13 +3267,13 @@ var ApprovalStore = class {
|
|
|
2996
3267
|
*
|
|
2997
3268
|
* Only a pending question can be answered, and only once: a second answer is refused, not taken over the first.
|
|
2998
3269
|
*/
|
|
2999
|
-
async answerDownload(approvalId, via, answer) {
|
|
3270
|
+
async answerDownload(approvalId, via, answer, platform = process.platform) {
|
|
3000
3271
|
const recorded = answer.choice === "other" ? {
|
|
3001
3272
|
choice: "other",
|
|
3002
3273
|
folder: answer.folder
|
|
3003
3274
|
} : { choice: answer.choice };
|
|
3004
3275
|
return await this.#transition(approvalId, (current) => {
|
|
3005
|
-
this.#requireKind(current, "download");
|
|
3276
|
+
this.#requireKind(current, "download", platform);
|
|
3006
3277
|
if (current.state === "approved") throw refuseDownload("APPROVAL_VOID", "the question was answered already, and it is answered once", current);
|
|
3007
3278
|
if (current.state !== "pending") throw this.#stateError(current);
|
|
3008
3279
|
if (!current.download || downloadDigest(current.download) !== current.digest) throw refuseDownload("APPROVAL_VOID", "the question does not describe the download it is bound to", current);
|
|
@@ -3033,13 +3304,17 @@ var ApprovalStore = class {
|
|
|
3033
3304
|
* gave it in the conversation. Under `confirm`, only a question the person answered at a terminal or in a trusted
|
|
3034
3305
|
* form can be claimed; a pending one is refused, and left as it is, so they can still answer it. One used, voided or
|
|
3035
3306
|
* expired is refused as an approval in that state is.
|
|
3307
|
+
*
|
|
3308
|
+
* `options.signal` is the download's cancellation, asked under the lock as a send's is (`ClaimOptions.signal`): a
|
|
3309
|
+
* download cancelled while this waited saves nothing and leaves the question open, the answer still unused.
|
|
3036
3310
|
*/
|
|
3037
3311
|
async claimForDownload(approvalId, live, options = {}) {
|
|
3038
3312
|
const digest = downloadDigest(live);
|
|
3039
3313
|
let failure = null;
|
|
3040
3314
|
const result = await this.#transition(approvalId, (current) => {
|
|
3041
|
-
this.#requireKind(current, "download");
|
|
3315
|
+
this.#requireKind(current, "download", options.platform ?? process.platform);
|
|
3042
3316
|
if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
|
|
3317
|
+
if (options.signal?.aborted) throw cancelledClaim(current);
|
|
3043
3318
|
const voidWith = (reason) => {
|
|
3044
3319
|
failure = {
|
|
3045
3320
|
code: "APPROVAL_VOID",
|
|
@@ -4374,13 +4649,13 @@ function snippetFor(client, name, entry) {
|
|
|
4374
4649
|
* Project-scoped entries are not looked at: every write here targets user scope, so they are not what would be
|
|
4375
4650
|
* replaced.
|
|
4376
4651
|
*/
|
|
4377
|
-
function claimName(product, options, name, existing) {
|
|
4652
|
+
function claimName(product, options, name, existing, platform) {
|
|
4378
4653
|
const taken = existing.filter((server) => server.client === options.client && server.name === name && server.scope !== "project");
|
|
4379
4654
|
const foreign = taken.find((server) => !isProductServer(server, product));
|
|
4380
4655
|
if (foreign) {
|
|
4381
4656
|
const what = foreign.packageName ?? (foreign.url ? displayUrl(foreign.url) : void 0) ?? (foreign.command || "something else");
|
|
4382
4657
|
const other = name === product.binary ? `${product.binary}-${product.defaultServerName}` : product.binary;
|
|
4383
|
-
throw new CommsError("CONFIG", `${options.client} already has an MCP server called "${name}", and it is not this one (it runs ${what})`, { hint: `Register this one under another name:
|
|
4658
|
+
throw new CommsError("CONFIG", `${options.client} already has an MCP server called "${name}", and it is not this one (it runs ${what})`, { hint: `Register this one under another name: ${inlineCommand(installCommand$1(product, options, other, [], platform))}. --force does not replace a server this did not install.` });
|
|
4384
4659
|
}
|
|
4385
4660
|
if (taken.length > 0 && !options.force) {
|
|
4386
4661
|
for (const pin of PIN_OPTIONS) {
|
|
@@ -4388,14 +4663,14 @@ function claimName(product, options, name, existing) {
|
|
|
4388
4663
|
const served = taken.map((server) => product.narrowingOf(server.args)[pin]).find(Boolean);
|
|
4389
4664
|
if (!wanted || !served || served === wanted) continue;
|
|
4390
4665
|
const second = `${product.defaultServerName}-${wanted.split("/")[0]}`;
|
|
4391
|
-
throw new CommsError("CONFIG", `${options.client} already has this server registered as "${name}"`, { hint: `That entry serves ${served}; to serve ${wanted} as well, register a second entry under its own name:
|
|
4666
|
+
throw new CommsError("CONFIG", `${options.client} already has this server registered as "${name}"`, { hint: `That entry serves ${served}; to serve ${wanted} as well, register a second entry under its own name: ${inlineCommand(installCommand$1(product, options, second === name ? `${name}-2` : second, [], platform))}.` });
|
|
4392
4667
|
}
|
|
4393
4668
|
const npx = taken.some((server) => server.args.some((argument) => argument.startsWith(`${product.npxPackage}@`)));
|
|
4394
4669
|
const again = {
|
|
4395
4670
|
...keepNarrowing(product, options, taken).options,
|
|
4396
4671
|
launcher: options.launcher ?? (npx ? "npx" : void 0)
|
|
4397
4672
|
};
|
|
4398
|
-
throw new CommsError("CONFIG", `${options.client} already has this server registered as "${name}"`, { hint: `Pass --force to replace it — that is how an upgrade reaches a client:
|
|
4673
|
+
throw new CommsError("CONFIG", `${options.client} already has this server registered as "${name}"`, { hint: `Pass --force to replace it — that is how an upgrade reaches a client: ${inlineCommand(installCommand$1(product, again, name, ["--force"], platform))}.` });
|
|
4399
4674
|
}
|
|
4400
4675
|
return taken;
|
|
4401
4676
|
}
|
|
@@ -4438,8 +4713,11 @@ function keepNarrowing(product, options, previous) {
|
|
|
4438
4713
|
* workspace on the machine; for Gmail it dropped `--inbox` and `--read-only` the same way. Doctor's repair already
|
|
4439
4714
|
* rebuilds these flags for exactly that reason. The product's `serverArgs` are the install command's own flags for
|
|
4440
4715
|
* the pin and the narrowing, so they are repeated as they are.
|
|
4716
|
+
*
|
|
4717
|
+
* A pin kept from the entry being replaced was read from the client's file, which may hold anything, so on Windows the
|
|
4718
|
+
* command can come back with no line to paste; every hint gives it with `inlineCommand`, which shows it as words then.
|
|
4441
4719
|
*/
|
|
4442
|
-
function installCommand$1(product, options, name, extra
|
|
4720
|
+
function installCommand$1(product, options, name, extra, platform) {
|
|
4443
4721
|
const words = [
|
|
4444
4722
|
product.binary,
|
|
4445
4723
|
"mcp",
|
|
@@ -4451,7 +4729,7 @@ function installCommand$1(product, options, name, extra = []) {
|
|
|
4451
4729
|
words.push(...product.serverArgs(options));
|
|
4452
4730
|
if (options.launcher && options.launcher !== "managed") words.push("--launcher", options.launcher);
|
|
4453
4731
|
words.push(...extra);
|
|
4454
|
-
return words
|
|
4732
|
+
return shellCommand(words, platform);
|
|
4455
4733
|
}
|
|
4456
4734
|
/**
|
|
4457
4735
|
* A command's exit status and what it printed, kept here and never shown: codex prints an entry's env. Started as
|
|
@@ -4494,8 +4772,13 @@ function capture$1(env, command, args) {
|
|
|
4494
4772
|
* or a failure that is not "no such server", stops the install: not knowing is not the same as nothing there.
|
|
4495
4773
|
* Nothing codex prints is repeated, because its answer carries the entry's env.
|
|
4496
4774
|
*/
|
|
4497
|
-
async function codexRegistration(env, binary, name, path) {
|
|
4498
|
-
const unknown = () => new CommsError("CONFIG", `codex would not say what it has registered as "${name}", so nothing was written`, { hint: `Look with
|
|
4775
|
+
async function codexRegistration(env, binary, name, path, platform) {
|
|
4776
|
+
const unknown = () => new CommsError("CONFIG", `codex would not say what it has registered as "${name}", so nothing was written`, { hint: `Look with ${inlineCommand(shellCommand([
|
|
4777
|
+
"codex",
|
|
4778
|
+
"mcp",
|
|
4779
|
+
"get",
|
|
4780
|
+
name
|
|
4781
|
+
], platform))}. If it is not an older copy of this server, choose another --name; \`--print\` shows the entry to add by hand.` });
|
|
4499
4782
|
let answer;
|
|
4500
4783
|
try {
|
|
4501
4784
|
answer = await capture$1(env, binary, [
|
|
@@ -4598,10 +4881,10 @@ async function preflightInstall(context, product, options) {
|
|
|
4598
4881
|
const blind = scan.unreadable.find((file) => file.path === own);
|
|
4599
4882
|
if (blind) throw new CommsError("CONFIG", `${blind.path} could not be read (${blind.reason}), so what is already registered there cannot be checked; nothing was written`, { hint: "Fix the file, or add the entry by hand: `--print` shows it without writing anything." });
|
|
4600
4883
|
}
|
|
4601
|
-
let previous = writes ? claimName(product, options, name, existing) : [];
|
|
4884
|
+
let previous = writes ? claimName(product, options, name, existing, context.platform) : [];
|
|
4602
4885
|
if (writes && binary && options.client === "codex") {
|
|
4603
|
-
const reported = await codexRegistration(context.env, binary, name, configPath ?? "codex");
|
|
4604
|
-
if (reported) previous = claimName(product, options, name, [reported]);
|
|
4886
|
+
const reported = await codexRegistration(context.env, binary, name, configPath ?? "codex", context.platform);
|
|
4887
|
+
if (reported) previous = claimName(product, options, name, [reported], context.platform);
|
|
4605
4888
|
}
|
|
4606
4889
|
const { options: effective, kept } = keepNarrowing(product, options, previous);
|
|
4607
4890
|
const node = await resolveNode(context.env);
|
|
@@ -4726,9 +5009,21 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
4726
5009
|
};
|
|
4727
5010
|
const existing = scan.servers;
|
|
4728
5011
|
const { cliName, binary, own, configPath, writes } = target;
|
|
4729
|
-
const warnings = (product.warnAbout?.(existing.filter((server) => server.client === options.client)) ?? []).filter(Boolean);
|
|
5012
|
+
const warnings = (product.warnAbout?.(existing.filter((server) => server.client === options.client), context.platform) ?? []).filter(Boolean);
|
|
4730
5013
|
if (kept.length > 0) {
|
|
4731
|
-
const removal = options.client === "claude-code" ?
|
|
5014
|
+
const removal = options.client === "claude-code" ? inlineCommand(shellCommand([
|
|
5015
|
+
"claude",
|
|
5016
|
+
"mcp",
|
|
5017
|
+
"remove",
|
|
5018
|
+
name,
|
|
5019
|
+
"--scope",
|
|
5020
|
+
"user"
|
|
5021
|
+
], context.platform)) : options.client === "codex" ? inlineCommand(shellCommand([
|
|
5022
|
+
"codex",
|
|
5023
|
+
"mcp",
|
|
5024
|
+
"remove",
|
|
5025
|
+
name
|
|
5026
|
+
], context.platform)) : `delete "${name}" from ${configPath}`;
|
|
4732
5027
|
warnings.push(`Kept ${kept.join(" ")} from the "${name}" entry this replaced, because this install did not say otherwise. To register it wider on purpose, remove that entry first (${removal}), then install without them.`);
|
|
4733
5028
|
}
|
|
4734
5029
|
if (!writes) {
|
|
@@ -4823,7 +5118,7 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
4823
5118
|
restored = false;
|
|
4824
5119
|
}
|
|
4825
5120
|
throw new CommsError("CONFIG", restored ? `could not register "${name}"; the previous entry was put back` : `could not register "${name}", and the previous entry could not be put back either — ${cliName} now has no server called "${name}"`, {
|
|
4826
|
-
hint: restored ? "Check the client is not running, then try again." : `Re-register it with
|
|
5121
|
+
hint: restored ? "Check the client is not running, then try again." : `Re-register it with ${inlineCommand(installCommand$1(product, effective, name, [], context.platform))}. The old entry is in ${backupPath}.`,
|
|
4827
5122
|
cause: error
|
|
4828
5123
|
});
|
|
4829
5124
|
}
|
|
@@ -4832,7 +5127,17 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
4832
5127
|
} catch (error) {
|
|
4833
5128
|
const message = error instanceof CommsError ? error.message : String(error);
|
|
4834
5129
|
if (/already exists/i.test(message)) throw new CommsError("CONFIG", `${cliName} already has an MCP server called "${name}", somewhere this could not read`, {
|
|
4835
|
-
hint: `Look at it with
|
|
5130
|
+
hint: `Look at it with ${inlineCommand(shellCommand([
|
|
5131
|
+
cliName,
|
|
5132
|
+
"mcp",
|
|
5133
|
+
"get",
|
|
5134
|
+
name
|
|
5135
|
+
], context.platform))}. If it is an older ${product.binary}, remove it with ${inlineCommand(shellCommand([
|
|
5136
|
+
cliName,
|
|
5137
|
+
"mcp",
|
|
5138
|
+
"remove",
|
|
5139
|
+
name
|
|
5140
|
+
], context.platform))} and run this again; if not, choose another --name.`,
|
|
4836
5141
|
cause: error
|
|
4837
5142
|
});
|
|
4838
5143
|
throw error;
|
|
@@ -5168,19 +5473,32 @@ function rivalPatterns(packages) {
|
|
|
5168
5473
|
function escapeRegExp$2(text) {
|
|
5169
5474
|
return text.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
|
|
5170
5475
|
}
|
|
5171
|
-
|
|
5476
|
+
/**
|
|
5477
|
+
* The command that removes a registered server, in its client's own terms — the name as the client's file has it,
|
|
5478
|
+
* which may be anything, so on Windows it can have no line to paste, and is shown as its words, to be typed
|
|
5479
|
+
* (`shellCommand`). Never as a line with the name left out: that removed whatever entry had the stand-in's name.
|
|
5480
|
+
*/
|
|
5481
|
+
function removalCommand(client, name, platform) {
|
|
5482
|
+
return commandText(shellCommand([
|
|
5483
|
+
client,
|
|
5484
|
+
"mcp",
|
|
5485
|
+
"remove",
|
|
5486
|
+
name
|
|
5487
|
+
], platform));
|
|
5488
|
+
}
|
|
5489
|
+
function gmailRemoval({ client, name: server, path, scope }, platform) {
|
|
5172
5490
|
if (scope === "project") return `remove "${server}" from the project entry in ${path} by hand`;
|
|
5173
5491
|
switch (client) {
|
|
5174
|
-
case "claude-code": return
|
|
5175
|
-
case "codex": return
|
|
5492
|
+
case "claude-code": return removalCommand("claude", server, platform);
|
|
5493
|
+
case "codex": return removalCommand("codex", server, platform);
|
|
5176
5494
|
default: return `remove the "${server}" entry from ${path}, then restart ${client}`;
|
|
5177
5495
|
}
|
|
5178
5496
|
}
|
|
5179
5497
|
/**
|
|
5180
5498
|
* Registered servers that are one of `packages` — another server for the same service whose send tools no approval
|
|
5181
|
-
* step gates — each with why it matters and how to remove it.
|
|
5499
|
+
* step gates — each with why it matters and how to remove it. `platform` is the shell the removal is quoted for.
|
|
5182
5500
|
*/
|
|
5183
|
-
function findRivalPackageServers(servers, packages) {
|
|
5501
|
+
function findRivalPackageServers(servers, packages, platform = process.platform) {
|
|
5184
5502
|
const patterns = rivalPatterns(packages);
|
|
5185
5503
|
const findings = [];
|
|
5186
5504
|
for (const server of servers) {
|
|
@@ -5191,14 +5509,14 @@ function findRivalPackageServers(servers, packages) {
|
|
|
5191
5509
|
...server,
|
|
5192
5510
|
packageName: known.name,
|
|
5193
5511
|
reason: `${known.name} exposes send tools that no approval step gates`,
|
|
5194
|
-
removal: gmailRemoval(server)
|
|
5512
|
+
removal: gmailRemoval(server, platform)
|
|
5195
5513
|
});
|
|
5196
5514
|
}
|
|
5197
5515
|
return findings;
|
|
5198
5516
|
}
|
|
5199
5517
|
/** What registering a server says about the rival packages its manifest names: one line each. */
|
|
5200
|
-
function rivalPackageWarnings(servers, packages) {
|
|
5201
|
-
return findRivalPackageServers(servers, packages).map((finding) => `${finding.packageName} is registered with ${finding.client} as "${finding.name}": ${finding.reason}. Remove it: ${finding.removal}`);
|
|
5518
|
+
function rivalPackageWarnings(servers, packages, platform = process.platform) {
|
|
5519
|
+
return findRivalPackageServers(servers, packages, platform).map((finding) => `${finding.packageName} is registered with ${finding.client} as "${finding.name}": ${finding.reason}. Remove it: ${finding.removal}`);
|
|
5202
5520
|
}
|
|
5203
5521
|
/**
|
|
5204
5522
|
* Registered servers that name the service's `word` anywhere — their name, command, arguments or URL — and are not
|
|
@@ -5284,7 +5602,7 @@ function serverFactsOf({ packageName, manifest }) {
|
|
|
5284
5602
|
if (rivals === void 0) return facts;
|
|
5285
5603
|
return {
|
|
5286
5604
|
...facts,
|
|
5287
|
-
warnAbout: (servers) => [...rivals.packages ? rivalPackageWarnings(servers, rivals.packages) : [], ...rivals.word !== void 0 && rivals.can !== void 0 ? rivalWordWarnings(servers, rivals.word, rivals.can, facts) : []]
|
|
5605
|
+
warnAbout: (servers, platform) => [...rivals.packages ? rivalPackageWarnings(servers, rivals.packages, platform) : [], ...rivals.word !== void 0 && rivals.can !== void 0 ? rivalWordWarnings(servers, rivals.word, rivals.can, facts) : []]
|
|
5288
5606
|
};
|
|
5289
5607
|
}
|
|
5290
5608
|
const CHANNEL_SERVERS = Object.freeze(Object.fromEntries(CHANNEL_SNAPSHOT.map((entry) => [entry.manifest.channel, serverFactsOf(entry)])));
|
|
@@ -5317,107 +5635,6 @@ function requireChannelManifest(channel) {
|
|
|
5317
5635
|
if (manifest === void 0) throw notAChannel(channel);
|
|
5318
5636
|
return manifest;
|
|
5319
5637
|
}
|
|
5320
|
-
function okEnvelope(data) {
|
|
5321
|
-
return {
|
|
5322
|
-
ok: true,
|
|
5323
|
-
schemaVersion: 1,
|
|
5324
|
-
data
|
|
5325
|
-
};
|
|
5326
|
-
}
|
|
5327
|
-
function errorEnvelope(error) {
|
|
5328
|
-
const body = {
|
|
5329
|
-
code: error.code,
|
|
5330
|
-
message: error.message
|
|
5331
|
-
};
|
|
5332
|
-
if (error.hint !== void 0) body.hint = error.hint;
|
|
5333
|
-
if (error.details !== void 0) body.details = error.details;
|
|
5334
|
-
return {
|
|
5335
|
-
ok: false,
|
|
5336
|
-
schemaVersion: 1,
|
|
5337
|
-
error: body
|
|
5338
|
-
};
|
|
5339
|
-
}
|
|
5340
|
-
const defaultStreams = {
|
|
5341
|
-
stdout: process.stdout,
|
|
5342
|
-
stderr: process.stderr,
|
|
5343
|
-
stdin: process.stdin
|
|
5344
|
-
};
|
|
5345
|
-
function colorEnabled(env, stream, flag) {
|
|
5346
|
-
if (flag === false) return false;
|
|
5347
|
-
if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
|
|
5348
|
-
if (env.TERM === "dumb") return false;
|
|
5349
|
-
if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
|
|
5350
|
-
return Boolean(stream.isTTY);
|
|
5351
|
-
}
|
|
5352
|
-
/** True when a human could answer a prompt: both ends are terminals, no --json/--no-input, not CI. */
|
|
5353
|
-
function canPrompt(env, streams, options) {
|
|
5354
|
-
if (options.json || options.noInput) return false;
|
|
5355
|
-
if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
|
|
5356
|
-
return Boolean(streams.stdin?.isTTY && streams.stdout.isTTY);
|
|
5357
|
-
}
|
|
5358
|
-
/** Environment variables well-known coding agents set. Used only as a speed bump, never as a security boundary. */
|
|
5359
|
-
const AGENT_MARKERS = [
|
|
5360
|
-
"CLAUDECODE",
|
|
5361
|
-
"CLAUDE_CODE_ENTRYPOINT",
|
|
5362
|
-
"CODEX_SANDBOX",
|
|
5363
|
-
"CODEX_HOME",
|
|
5364
|
-
"CURSOR_AGENT",
|
|
5365
|
-
"GEMINI_CLI",
|
|
5366
|
-
"AGENT_COMMS_AGENT"
|
|
5367
|
-
];
|
|
5368
|
-
function agentMarker(env) {
|
|
5369
|
-
for (const name of AGENT_MARKERS) if (env[name] !== void 0 && env[name] !== "") return name;
|
|
5370
|
-
return null;
|
|
5371
|
-
}
|
|
5372
|
-
function paint(color, format, text) {
|
|
5373
|
-
return color ? styleText(format, text, { validateStream: false }) : text;
|
|
5374
|
-
}
|
|
5375
|
-
/** Writes a successful result: the envelope with --json, otherwise the human rendering. */
|
|
5376
|
-
function writeResult(data, options, human, streams = defaultStreams) {
|
|
5377
|
-
if (options.json) {
|
|
5378
|
-
streams.stdout.write(`${JSON.stringify(okEnvelope(data))}\n`);
|
|
5379
|
-
return;
|
|
5380
|
-
}
|
|
5381
|
-
const text = human(data);
|
|
5382
|
-
if (text) streams.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
5383
|
-
}
|
|
5384
|
-
/** Writes an error and returns the exit code to use. */
|
|
5385
|
-
function writeError(error, options, streams = defaultStreams) {
|
|
5386
|
-
const commsError = toCommsError(error);
|
|
5387
|
-
if (options.json) streams.stdout.write(`${JSON.stringify(errorEnvelope(commsError))}\n`);
|
|
5388
|
-
else {
|
|
5389
|
-
streams.stderr.write(`${paint(options.color, "red", "error")}: ${commsError.message}\n`);
|
|
5390
|
-
if (commsError.hint) streams.stderr.write(`${paint(options.color, "dim", "hint")}: ${commsError.hint}\n`);
|
|
5391
|
-
}
|
|
5392
|
-
return commsError.exitCode;
|
|
5393
|
-
}
|
|
5394
|
-
/** Runs a command body and exits with the documented code. Unexpected errors keep only their message. */
|
|
5395
|
-
async function runCommand(options, body, streams = defaultStreams) {
|
|
5396
|
-
try {
|
|
5397
|
-
await body();
|
|
5398
|
-
return EXIT_CODES.OK;
|
|
5399
|
-
} catch (error) {
|
|
5400
|
-
return writeError(error, options, streams);
|
|
5401
|
-
}
|
|
5402
|
-
}
|
|
5403
|
-
/**
|
|
5404
|
-
* The first two steps of `requirePerson` — an agent is refused, then anything without a terminal — for a command
|
|
5405
|
-
* whose code is not its own to make up.
|
|
5406
|
-
*
|
|
5407
|
-
* `agentcomms approve` asks for a code the approval store issued and will check, so it cannot use `askChallenge`;
|
|
5408
|
-
* but it refuses in exactly the same order and words, because it is the same gate.
|
|
5409
|
-
*/
|
|
5410
|
-
function refuseUnlessPerson(env, streams, gate) {
|
|
5411
|
-
const marker = agentMarker(env);
|
|
5412
|
-
if (marker) throw new CommsError("LOOSENING_REFUSED", gate.refusedToAgent, {
|
|
5413
|
-
hint: `Ask the user to run \`${gate.command}\` in their own terminal.`,
|
|
5414
|
-
details: { marker }
|
|
5415
|
-
});
|
|
5416
|
-
const prompting = {};
|
|
5417
|
-
if (gate.json !== void 0) prompting.json = gate.json;
|
|
5418
|
-
if (gate.noInput !== void 0) prompting.noInput = gate.noInput;
|
|
5419
|
-
if (!canPrompt(env, streams, prompting)) throw new CommsError("LOOSENING_REFUSED", gate.refusedWithoutTerminal, { hint: `Run \`${gate.command}\` directly in a terminal.` });
|
|
5420
|
-
}
|
|
5421
5638
|
/**
|
|
5422
5639
|
* One renderer for every surface a send preview is shown on — the chat, an elicitation form, a terminal. Text the
|
|
5423
5640
|
* draft's author controls (body, subject, display names, file names, link text) must not be able to change how the
|
|
@@ -5583,9 +5800,9 @@ function renderUpdate(result, color) {
|
|
|
5583
5800
|
if (result.next) lines.push("", result.next);
|
|
5584
5801
|
return lines.join("\n");
|
|
5585
5802
|
}
|
|
5586
|
-
/** The
|
|
5587
|
-
function
|
|
5588
|
-
return
|
|
5803
|
+
/** The channel's fixed approval-command prefix with this generated id, rendered by the common shell rule. */
|
|
5804
|
+
function changeApprovalCommand(approveCommand, approvalId, platform = process.platform) {
|
|
5805
|
+
return shellCommand([...(approveCommand ?? "agentcomms approve").trim().split(/\s+/), approvalId], platform);
|
|
5589
5806
|
}
|
|
5590
5807
|
/**
|
|
5591
5808
|
* The change policy that governs a change, as `config` stands: the strictest of the policies over everything the change
|
|
@@ -5696,7 +5913,7 @@ async function prepareChange(core, request, options) {
|
|
|
5696
5913
|
change: binding
|
|
5697
5914
|
}),
|
|
5698
5915
|
expiresAt: record.expiresAt,
|
|
5699
|
-
next: nextStep(record.approvalId, policy,
|
|
5916
|
+
next: nextStep(record.approvalId, policy, options)
|
|
5700
5917
|
};
|
|
5701
5918
|
} catch (error) {
|
|
5702
5919
|
await auditRefusal(core, "change.prepare", error, {
|
|
@@ -5727,7 +5944,10 @@ async function claimChange(core, approvalId, expect, options) {
|
|
|
5727
5944
|
const record = await core.approvals.claimForChange(approvalId, {
|
|
5728
5945
|
change: binding,
|
|
5729
5946
|
policy
|
|
5730
|
-
}, {
|
|
5947
|
+
}, {
|
|
5948
|
+
pendingHint: `Ask the user to run ${inlineCommand(changeApprovalCommand(options.approveCommand, approvalId, options.platform))} in their own terminal, then try again with the same approval.`,
|
|
5949
|
+
platform: options.platform
|
|
5950
|
+
});
|
|
5731
5951
|
const decided = stricterPolicy(policy, record.requiredPolicy) === "chat" ? "chat" : "confirm";
|
|
5732
5952
|
await auditChange(core, {
|
|
5733
5953
|
operation: "change.claim",
|
|
@@ -5775,7 +5995,7 @@ async function beginChangeApproval(core, approvalId, options) {
|
|
|
5775
5995
|
let record;
|
|
5776
5996
|
try {
|
|
5777
5997
|
record = await changeRecord(core, approvalId);
|
|
5778
|
-
const challenge = await core.approvals.issueChallenge(approvalId, "change");
|
|
5998
|
+
const challenge = await core.approvals.issueChallenge(approvalId, "change", options.platform);
|
|
5779
5999
|
return {
|
|
5780
6000
|
approvalId,
|
|
5781
6001
|
preview: renderChangePreview(record),
|
|
@@ -5800,7 +6020,7 @@ async function finishChangeApproval(core, approvalId, answer, options) {
|
|
|
5800
6020
|
const approved = await core.approvals.approve(approvalId, "terminal", {
|
|
5801
6021
|
draftMessageId: digest,
|
|
5802
6022
|
digest
|
|
5803
|
-
}, answer, "change");
|
|
6023
|
+
}, answer, "change", options.platform);
|
|
5804
6024
|
await auditChange(core, {
|
|
5805
6025
|
operation: "change.approve",
|
|
5806
6026
|
outcome: "ok",
|
|
@@ -5856,8 +6076,8 @@ async function revokeChange(core, approvalId, reason, options) {
|
|
|
5856
6076
|
function changePolicyOf$1(record) {
|
|
5857
6077
|
return record.requiredPolicy === "chat" ? "chat" : "confirm";
|
|
5858
6078
|
}
|
|
5859
|
-
function nextStep(approvalId, policy,
|
|
5860
|
-
return policy === "chat" ? `Show this preview to the user and ask. If they say yes, claim approval ${approvalId} and apply the change; if not, revoke it.` : `The change policy is confirm: ask the user to run
|
|
6079
|
+
function nextStep(approvalId, policy, options) {
|
|
6080
|
+
return policy === "chat" ? `Show this preview to the user and ask. If they say yes, claim approval ${approvalId} and apply the change; if not, revoke it.` : `The change policy is confirm: ask the user to run ${inlineCommand(changeApprovalCommand(options.approveCommand, approvalId, options.platform))} in their own terminal and type the code it shows. Then claim approval ${approvalId} and apply the change.`;
|
|
5861
6081
|
}
|
|
5862
6082
|
/**
|
|
5863
6083
|
* One line per step of a change approval: who asked, from which surface, under which policy, and how it ended.
|
|
@@ -5978,21 +6198,25 @@ function renderChangePreview(record) {
|
|
|
5978
6198
|
* it on the second call, with its id.
|
|
5979
6199
|
*
|
|
5980
6200
|
* A change that loosens no setting and does nothing irreversible is applied directly: asking a person to agree to
|
|
5981
|
-
* something that needs no agreement teaches them to agree without reading. It refuses an approval id
|
|
5982
|
-
*
|
|
5983
|
-
* call claiming an approval through (design 2026-09-28 §2),
|
|
5984
|
-
* approval the store holds — one an agent had just had prepared for
|
|
5985
|
-
* or a cancellation made where the call itself was stopped.
|
|
6201
|
+
* something that needs no agreement teaches them to agree without reading. It refuses an approval id — in the change's
|
|
6202
|
+
* own words when it has them (`refuseApproval`) — as a report refuses one (`refuseApprovalWithoutChange`), rather than
|
|
6203
|
+
* applying and dropping it: the update check's stop lets a call claiming an approval through (design 2026-09-28 §2),
|
|
6204
|
+
* and one that is then never claimed would carry any approval the store holds — one an agent had just had prepared for
|
|
6205
|
+
* "not now", say — past the stop, to a tightening or a cancellation made where the call itself was stopped.
|
|
5986
6206
|
*/
|
|
5987
6207
|
async function gatedChange(core, change, options) {
|
|
5988
6208
|
const request = await change.plan(await core.config.load());
|
|
5989
6209
|
const loosens = classifyChange(request.before, request.after).loosened.length > 0;
|
|
5990
6210
|
const acts = (request.effects ?? []).length > 0;
|
|
5991
6211
|
if (!loosens && !acts) {
|
|
5992
|
-
if (options.approvalId)
|
|
5993
|
-
|
|
5994
|
-
|
|
5995
|
-
|
|
6212
|
+
if (options.approvalId) {
|
|
6213
|
+
const refusal = await change.refuseApproval?.(options.approvalId);
|
|
6214
|
+
if (refusal !== void 0) throw refusal;
|
|
6215
|
+
throw new CommsError("USAGE", "nothing was changed: as things stand this change needs no approval, so it takes none", {
|
|
6216
|
+
hint: "Make the same call again without the approval: it applies at once and asks nobody. An approval is spent only on the change it was prepared for.",
|
|
6217
|
+
details: { approvalId: options.approvalId }
|
|
6218
|
+
});
|
|
6219
|
+
}
|
|
5996
6220
|
return {
|
|
5997
6221
|
status: "applied",
|
|
5998
6222
|
result: await change.apply(void 0, request)
|
|
@@ -6002,12 +6226,14 @@ async function gatedChange(core, change, options) {
|
|
|
6002
6226
|
status: "approval-required",
|
|
6003
6227
|
prepared: await prepareChange(core, request, {
|
|
6004
6228
|
surface: options.surface,
|
|
6005
|
-
approveCommand: options.approveCommand
|
|
6229
|
+
approveCommand: options.approveCommand,
|
|
6230
|
+
platform: options.platform
|
|
6006
6231
|
})
|
|
6007
6232
|
};
|
|
6008
6233
|
const consent = await claimChange(core, options.approvalId, request, {
|
|
6009
6234
|
surface: options.surface,
|
|
6010
|
-
approveCommand: options.approveCommand
|
|
6235
|
+
approveCommand: options.approveCommand,
|
|
6236
|
+
platform: options.platform
|
|
6011
6237
|
});
|
|
6012
6238
|
return {
|
|
6013
6239
|
status: "applied",
|
|
@@ -6069,14 +6295,17 @@ async function gatedChangeAtTerminal(core, change, options) {
|
|
|
6069
6295
|
const first = await gatedChange(core, change, {
|
|
6070
6296
|
surface: "cli",
|
|
6071
6297
|
approvalId: options.approvalId,
|
|
6072
|
-
approveCommand
|
|
6298
|
+
approveCommand,
|
|
6299
|
+
platform: options.output.platform
|
|
6073
6300
|
});
|
|
6074
6301
|
if (first.status === "applied") return first.result;
|
|
6075
6302
|
const { prepared } = first;
|
|
6076
6303
|
if (!(agentMarker(options.env) === null && canPrompt(options.env, streams, { json: options.output.json === true }))) {
|
|
6077
6304
|
if (options.output.json !== true) streams.stdout.write(`${prepared.preview}\n\n`);
|
|
6305
|
+
const carrying = [options.approvalFlag ?? "--approval", prepared.approvalId];
|
|
6306
|
+
const { command } = options;
|
|
6078
6307
|
throw new CommsError("APPROVAL_PENDING", `this change needs approval first: ${prepared.summary}`, {
|
|
6079
|
-
hint: approvalHint(prepared, `${
|
|
6308
|
+
hint: approvalHint(prepared, typeof command === "string" ? `${command} ${carrying.join(" ")}` : withWords(command, ...carrying), approveCommand, options.output.platform),
|
|
6080
6309
|
details: {
|
|
6081
6310
|
approvalId: prepared.approvalId,
|
|
6082
6311
|
policy: prepared.policy,
|
|
@@ -6097,7 +6326,8 @@ async function gatedChangeAtTerminal(core, change, options) {
|
|
|
6097
6326
|
const second = await gatedChange(core, change, {
|
|
6098
6327
|
surface: "cli",
|
|
6099
6328
|
approvalId: prepared.approvalId,
|
|
6100
|
-
approveCommand
|
|
6329
|
+
approveCommand,
|
|
6330
|
+
platform: options.output.platform
|
|
6101
6331
|
});
|
|
6102
6332
|
if (second.status !== "applied") throw new CommsError("UNEXPECTED", "the approved change asked for approval again");
|
|
6103
6333
|
return second.result;
|
|
@@ -6109,8 +6339,9 @@ async function gatedChangeAtTerminal(core, change, options) {
|
|
|
6109
6339
|
* Shared with a command that reports a waiting change rather than failing on it — `agent-gmail setup`, which stops at
|
|
6110
6340
|
* its registration step and says why in its report — so the two say it in the same words.
|
|
6111
6341
|
*/
|
|
6112
|
-
function approvalHint(prepared, rerun, approveCommand) {
|
|
6113
|
-
|
|
6342
|
+
function approvalHint(prepared, rerun, approveCommand, platform = typeof rerun === "string" ? process.platform : rerun.platform) {
|
|
6343
|
+
const run = typeof rerun === "string" ? `\`${rerun}\`` : inlineCommand(rerun);
|
|
6344
|
+
return prepared.policy === "confirm" ? `Show the person the preview. They run ${inlineCommand(changeApprovalCommand(approveCommand, prepared.approvalId, platform))}; then run ${run}.` : `Show the person the preview. Once they say yes, run ${run}.`;
|
|
6114
6345
|
}
|
|
6115
6346
|
/**
|
|
6116
6347
|
* Approves a configuration change at a terminal, the way a person approves a send: read the preview, type the code.
|
|
@@ -6130,7 +6361,11 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
|
|
|
6130
6361
|
refuseUnlessPerson(env, streams, {
|
|
6131
6362
|
refusedToAgent: "only a person can approve a change, not an agent",
|
|
6132
6363
|
refusedWithoutTerminal: "approving a change needs an interactive terminal",
|
|
6133
|
-
command:
|
|
6364
|
+
command: commandText(shellCommand([
|
|
6365
|
+
"agentcomms",
|
|
6366
|
+
"approve",
|
|
6367
|
+
approvalId
|
|
6368
|
+
], output.platform)),
|
|
6134
6369
|
color: output.color,
|
|
6135
6370
|
json: output.json
|
|
6136
6371
|
});
|
|
@@ -6792,7 +7027,7 @@ async function npmInstallGlobal(env, spec) {
|
|
|
6792
7027
|
}
|
|
6793
7028
|
}
|
|
6794
7029
|
/** The package version, read from package.json at build time. */
|
|
6795
|
-
const VERSION = "0.12.
|
|
7030
|
+
const VERSION = "0.12.3";
|
|
6796
7031
|
/**
|
|
6797
7032
|
* Registering, pruning and listing the MCP servers of every channel, from the core.
|
|
6798
7033
|
*
|
|
@@ -7050,7 +7285,7 @@ function serverPruneChange(core, env, request, own) {
|
|
|
7050
7285
|
};
|
|
7051
7286
|
const { packageName, version } = ownProduct(request.channel, own) ?? {
|
|
7052
7287
|
packageName: channelServer(request.channel).packageName,
|
|
7053
|
-
version: "0.12.
|
|
7288
|
+
version: "0.12.3"
|
|
7054
7289
|
};
|
|
7055
7290
|
const product = {
|
|
7056
7291
|
packageName,
|
|
@@ -7396,8 +7631,13 @@ async function latestReleases(names, latestVersion) {
|
|
|
7396
7631
|
if (first?.problem) throw new CommsError("PROVIDER_UNAVAILABLE", `${first.problem}${problems.length > 1 ? ` (and ${problems.length - 1} more)` : ""}`, { hint: "Nothing was checked or changed. Try again once the registry can be reached; `npm view <package> version` shows what npm itself reads." });
|
|
7397
7632
|
return Object.fromEntries(answers.map((answer) => [answer.name, answer.version ?? ""]));
|
|
7398
7633
|
}
|
|
7399
|
-
/**
|
|
7400
|
-
|
|
7634
|
+
/**
|
|
7635
|
+
* How to register an entry again by hand: its channel's own `mcp install`, with every flag that decides its reach.
|
|
7636
|
+
*
|
|
7637
|
+
* The entry's name and pins are read from the client's file, which may hold anything, so on Windows the command may
|
|
7638
|
+
* have no line to paste: the reasons below give it with `inlineCommand`, which shows it as words then.
|
|
7639
|
+
*/
|
|
7640
|
+
function installCommand(item, platform) {
|
|
7401
7641
|
const facts = channelServer(item.channel);
|
|
7402
7642
|
const words = [
|
|
7403
7643
|
facts.binary,
|
|
@@ -7408,7 +7648,7 @@ function installCommand(item) {
|
|
|
7408
7648
|
];
|
|
7409
7649
|
if (item.name !== facts.defaultServerName) words.push("--name", item.name);
|
|
7410
7650
|
words.push(...item.narrowing, "--force");
|
|
7411
|
-
return words
|
|
7651
|
+
return shellCommand(words, platform);
|
|
7412
7652
|
}
|
|
7413
7653
|
/**
|
|
7414
7654
|
* Why a registration that is behind cannot be registered again from here, or undefined when it can.
|
|
@@ -7419,13 +7659,13 @@ function installCommand(item) {
|
|
|
7419
7659
|
* been renamed or removed: registering it again would register a server that is refused when it starts.
|
|
7420
7660
|
*/
|
|
7421
7661
|
async function whyNotUpdatable(context, config, item, narrowing) {
|
|
7422
|
-
if (item.launcher !== "managed" && item.launcher !== "npx") return `it was not written by \`mcp install\`, so how it starts cannot be carried over; register it again with
|
|
7662
|
+
if (item.launcher !== "managed" && item.launcher !== "npx") return `it was not written by \`mcp install\`, so how it starts cannot be carried over; register it again with ${inlineCommand(installCommand(item, context.platform))}`;
|
|
7423
7663
|
if (item.scope !== "user") return `it is registered for one project (in ${item.path}), and \`mcp install\` registers at user scope only; register it again from that project with ${item.client}'s own command`;
|
|
7424
7664
|
const target = await installTarget(context, {
|
|
7425
7665
|
client: item.client,
|
|
7426
7666
|
apply: true
|
|
7427
7667
|
});
|
|
7428
|
-
if (!target.writes) return target.cliName ? `\`${target.cliName}\` is not ${clientCliSearch(context.env)}, so the entry cannot be replaced from here; run
|
|
7668
|
+
if (!target.writes) return target.cliName ? `\`${target.cliName}\` is not ${clientCliSearch(context.env)}, so the entry cannot be replaced from here; run ${inlineCommand(installCommand(item, context.platform))} where it is` : `this environment names no ${item.client} configuration to write to`;
|
|
7429
7669
|
const own = pinOption(item.channel);
|
|
7430
7670
|
const pin = own === void 0 || own === "readOnly" ? void 0 : narrowing[own];
|
|
7431
7671
|
if (pin !== void 0) {
|
|
@@ -7469,7 +7709,8 @@ async function runtimesNeeded(dataDir, latest, managed) {
|
|
|
7469
7709
|
async function inspect(core, env, deps) {
|
|
7470
7710
|
const context = {
|
|
7471
7711
|
env,
|
|
7472
|
-
core
|
|
7712
|
+
core,
|
|
7713
|
+
platform: deps.platform
|
|
7473
7714
|
};
|
|
7474
7715
|
const scan = await scanRegisteredServers(env);
|
|
7475
7716
|
const unreadable = [...scan.unreadable];
|
|
@@ -7778,7 +8019,7 @@ async function planRegistrations(context, candidates, request) {
|
|
|
7778
8019
|
}
|
|
7779
8020
|
const keeps = narrowingArgs(item.channel, preflight.effective);
|
|
7780
8021
|
if (JSON.stringify(keeps) !== JSON.stringify(item.narrowing)) {
|
|
7781
|
-
leave(item, `registering it again would not keep exactly ${item.narrowing.length > 0 ? item.narrowing.join(" ") : "no pin"}: ${item.client} itself has the entry as ${keeps.length > 0 ? keeps.join(" ") : "not pinned"}. Register it again yourself with the pins it should have:
|
|
8022
|
+
leave(item, `registering it again would not keep exactly ${item.narrowing.length > 0 ? item.narrowing.join(" ") : "no pin"}: ${item.client} itself has the entry as ${keeps.length > 0 ? keeps.join(" ") : "not pinned"}. Register it again yourself with the pins it should have: ${inlineCommand(installCommand(item, context.platform))}`);
|
|
7782
8023
|
continue;
|
|
7783
8024
|
}
|
|
7784
8025
|
steps.push({
|
|
@@ -7803,7 +8044,8 @@ async function planRegistrations(context, candidates, request) {
|
|
|
7803
8044
|
function updateChange(core, env, request = {}, deps = {}) {
|
|
7804
8045
|
const context = {
|
|
7805
8046
|
env,
|
|
7806
|
-
core
|
|
8047
|
+
core,
|
|
8048
|
+
platform: deps.platform
|
|
7807
8049
|
};
|
|
7808
8050
|
let planned = null;
|
|
7809
8051
|
return {
|
|
@@ -7837,9 +8079,69 @@ function updateChange(core, env, request = {}, deps = {}) {
|
|
|
7837
8079
|
const result = await applyUpdate(context, planned, deps);
|
|
7838
8080
|
await recordFound(core, env, planned.report, deps, result);
|
|
7839
8081
|
return result;
|
|
8082
|
+
},
|
|
8083
|
+
refuseApproval: (approvalId) => {
|
|
8084
|
+
if (planned === null) throw new CommsError("UNEXPECTED", "the update was refused before it was planned");
|
|
8085
|
+
return nothingToApply(core, approvalId, planned);
|
|
7840
8086
|
}
|
|
7841
8087
|
};
|
|
7842
8088
|
}
|
|
8089
|
+
/**
|
|
8090
|
+
* The refusal for an approval handed to an update with no step left in it (CUE-303).
|
|
8091
|
+
*
|
|
8092
|
+
* An update needs an approval only for its steps, so one with none takes no approval, and the general refusal said to
|
|
8093
|
+
* call again without it, as "it applies at once". An approval is prepared for an update while something is behind,
|
|
8094
|
+
* and it is usually claimed minutes later; meanwhile another session, or the person at a terminal, may have applied
|
|
8095
|
+
* the update. Then nothing is behind, calling again applies nothing, and "applies at once" read as an update about to
|
|
8096
|
+
* happen. So this says what is so: there is nothing to apply — nothing behind at all, or nothing that can be updated
|
|
8097
|
+
* from here — and the approval was not used, with what had become of it.
|
|
8098
|
+
*
|
|
8099
|
+
* The store is asked as it is. Its `get` answers null for an approval it does not have, and throws for anything else:
|
|
8100
|
+
* an id that is not one, a record it cannot read or parse. Every one of those was caught and said as "not used" with
|
|
8101
|
+
* nothing more, so a corrupt approval file or a permissions problem read as an approval that was simply not there.
|
|
8102
|
+
* Now a record that cannot be read goes up as itself, as it would from any other call that reads an approval, and an
|
|
8103
|
+
* id that is not one is said to be that.
|
|
8104
|
+
*/
|
|
8105
|
+
async function nothingToApply(core, approvalId, planned) {
|
|
8106
|
+
if (!APPROVAL_ID_PATTERN.test(approvalId)) return new CommsError("USAGE", `nothing was changed: "${approvalId}" is not an approval id`, {
|
|
8107
|
+
hint: "An approval id is the `approvalId` a call that needs approval returns, and nothing here needs one: a check — comms_update with `check`, or `agentcomms update --check` at a terminal — shows what is behind.",
|
|
8108
|
+
details: { approvalId }
|
|
8109
|
+
});
|
|
8110
|
+
const record = await core.approvals.get(approvalId);
|
|
8111
|
+
const unused = `so there is nothing to apply; the approval ${approvalId} was not used (${whatBecameOf(record)})`;
|
|
8112
|
+
const details = {
|
|
8113
|
+
approvalId,
|
|
8114
|
+
...record ? { state: record.state } : {}
|
|
8115
|
+
};
|
|
8116
|
+
if (planned.nothingBehind) return new CommsError("USAGE", `nothing was changed: nothing is behind the latest release, ${unused}`, {
|
|
8117
|
+
hint: "Nothing needs doing: a check — comms_update with `check`, or `agentcomms update --check` at a terminal — shows everything here up to date. A release published later is another update, with a preview and an approval of its own.",
|
|
8118
|
+
details
|
|
8119
|
+
});
|
|
8120
|
+
return new CommsError("USAGE", `nothing was changed: what is behind cannot be updated from here, ${unused}`, {
|
|
8121
|
+
hint: "Each is left for a person: a check — comms_update with `check`, or `agentcomms update --check` at a terminal — lists them, with why and what to run. The same call without the approval reports them too, and changes nothing.",
|
|
8122
|
+
details
|
|
8123
|
+
});
|
|
8124
|
+
}
|
|
8125
|
+
/**
|
|
8126
|
+
* What had become of an approval, for `nothingToApply`: none at all, or each state an approval can be in, by name.
|
|
8127
|
+
*
|
|
8128
|
+
* A record, so that a state added to the store is a type error here rather than a refusal that says nothing. The
|
|
8129
|
+
* send states are here too: any approval's id can be handed to an update, a send's among them, and this reads the
|
|
8130
|
+
* record before anything looks at its kind.
|
|
8131
|
+
*/
|
|
8132
|
+
function whatBecameOf(record) {
|
|
8133
|
+
if (record === null) return "there is no approval by that id";
|
|
8134
|
+
return {
|
|
8135
|
+
pending: `it was still waiting to be approved, and lapses at ${record.expiresAt}`,
|
|
8136
|
+
approved: `it had been approved, and lapses unspent at ${record.expiresAt}`,
|
|
8137
|
+
sending: "it is an approval to send, and that send is under way",
|
|
8138
|
+
used: "it had been spent already",
|
|
8139
|
+
failed: "it had been spent on a send that failed",
|
|
8140
|
+
unknown: "it had been spent on a send whose outcome was never recorded",
|
|
8141
|
+
expired: `it had expired at ${record.expiresAt}`,
|
|
8142
|
+
revoked: "it had been revoked"
|
|
8143
|
+
}[record.state] ?? `it is ${JSON.stringify(record.state)}, a state this version does not know`;
|
|
8144
|
+
}
|
|
7843
8145
|
/** The product a runtime of `packageName` is installed as: the channel whose package it is. */
|
|
7844
8146
|
function runtimeProduct(packageName, version) {
|
|
7845
8147
|
const channel = CHANNELS.find((each) => channelServer(each).packageName === packageName);
|
|
@@ -8660,7 +8962,7 @@ function terminalUpdateHooks(core, env, options) {
|
|
|
8660
8962
|
};
|
|
8661
8963
|
}
|
|
8662
8964
|
//#endregion
|
|
8663
|
-
//#region ../core/dist/tool-arguments-
|
|
8965
|
+
//#region ../core/dist/tool-arguments-BMgRZ6DZ.mjs
|
|
8664
8966
|
const APPLIED = Symbol.for("agentcomms.strictToolArguments");
|
|
8665
8967
|
const TARGET = "draft-2020-12";
|
|
8666
8968
|
/**
|
|
@@ -19865,7 +20167,7 @@ function keyPermissionOf(account) {
|
|
|
19865
20167
|
* wrong. Core's schema reads any mode word so that one odd account cannot make the whole file unreadable; acting on
|
|
19866
20168
|
* one is refused here, as Slack refuses its own.
|
|
19867
20169
|
*/
|
|
19868
|
-
function checkedAccount(name, account) {
|
|
20170
|
+
function checkedAccount(name, account, platform = process.platform) {
|
|
19869
20171
|
const problem = (() => {
|
|
19870
20172
|
if (account.platform !== "resend") return `it is a ${account.platform} account`;
|
|
19871
20173
|
if (!MODES.includes(String(account.mode))) return `its mode is "${String(account.mode)}", and a Resend account is in read or send mode`;
|
|
@@ -19875,57 +20177,67 @@ function checkedAccount(name, account) {
|
|
|
19875
20177
|
if (lock !== void 0 && (typeof lock !== "string" || !DOMAIN.test(lock))) return "its domainLock is not a domain";
|
|
19876
20178
|
return null;
|
|
19877
20179
|
})();
|
|
19878
|
-
if (problem !== null) throw new CommsError("CONFIG", `"${name}" is not a Resend account this release can act on: ${problem}`, { hint: `Remove it with
|
|
20180
|
+
if (problem !== null) throw new CommsError("CONFIG", `"${name}" is not a Resend account this release can act on: ${problem}`, { hint: `Remove it with ${inlineCommand(shellCommand([
|
|
20181
|
+
"agent-resend",
|
|
20182
|
+
"account",
|
|
20183
|
+
"remove",
|
|
20184
|
+
name
|
|
20185
|
+
], platform))} and add it again, or fix it in the configuration file.` });
|
|
19879
20186
|
return account;
|
|
19880
20187
|
}
|
|
19881
20188
|
function notFound(name) {
|
|
19882
20189
|
return () => new CommsError("NOT_FOUND", `there is no Resend account called "${name}"`, { hint: "List them with `agent-resend account list`; a person adds one with `agent-resend account add <org/resend>`." });
|
|
19883
20190
|
}
|
|
19884
20191
|
/** Every Resend account in `config`, checked, by name. */
|
|
19885
|
-
function resendAccounts(config) {
|
|
20192
|
+
function resendAccounts(config, platform = process.platform) {
|
|
19886
20193
|
return Object.entries(config.accounts).filter(([, account]) => account.platform === PLATFORM).map(([name, account]) => ({
|
|
19887
20194
|
name,
|
|
19888
|
-
account: checkedAccount(name, account)
|
|
20195
|
+
account: checkedAccount(name, account, platform)
|
|
19889
20196
|
})).sort((a, b) => a.name < b.name ? -1 : 1);
|
|
19890
20197
|
}
|
|
19891
20198
|
/**
|
|
19892
20199
|
* The account by name, or a refusal that says what to do — through core's `resolveName`, so a name that was replaced
|
|
19893
20200
|
* is refused with the one it has now rather than reported as unknown.
|
|
19894
20201
|
*/
|
|
19895
|
-
function requireAccount(config, name) {
|
|
20202
|
+
function requireAccount(config, name, platform = process.platform) {
|
|
19896
20203
|
const { alias, account } = resolveName(config, "account", name, notFound(name));
|
|
19897
20204
|
if (account.platform !== "resend") throw notFound(name)();
|
|
19898
20205
|
return {
|
|
19899
20206
|
name: alias,
|
|
19900
|
-
account: checkedAccount(alias, account)
|
|
20207
|
+
account: checkedAccount(alias, account, platform)
|
|
19901
20208
|
};
|
|
19902
20209
|
}
|
|
19903
20210
|
/** The account under exactly this name, or null: no former names, no refusal. */
|
|
19904
|
-
function lookupAccount(config, name) {
|
|
20211
|
+
function lookupAccount(config, name, platform = process.platform) {
|
|
19905
20212
|
const account = lookupName(config, "account", name);
|
|
19906
20213
|
if (!account || account.platform !== "resend") return null;
|
|
19907
20214
|
return {
|
|
19908
20215
|
name,
|
|
19909
|
-
account: checkedAccount(name, account)
|
|
20216
|
+
account: checkedAccount(name, account, platform)
|
|
19910
20217
|
};
|
|
19911
20218
|
}
|
|
19912
20219
|
/** The account by its immutable id, under whatever name it has now. */
|
|
19913
|
-
function accountById(config, id) {
|
|
20220
|
+
function accountById(config, id, platform = process.platform) {
|
|
19914
20221
|
const found = findById(config, "account", id);
|
|
19915
20222
|
if (!found || found.account.platform !== "resend") return null;
|
|
19916
20223
|
return {
|
|
19917
20224
|
name: found.alias,
|
|
19918
|
-
account: checkedAccount(found.alias, found.account)
|
|
20225
|
+
account: checkedAccount(found.alias, found.account, platform)
|
|
19919
20226
|
};
|
|
19920
20227
|
}
|
|
19921
20228
|
/**
|
|
19922
20229
|
* Refuses a name a new account cannot take — core's rule, for either config version: the grammar and the platform,
|
|
19923
20230
|
* free in both maps, and never a former name — with this package's own hint for one already connected.
|
|
19924
20231
|
*/
|
|
19925
|
-
function checkNewName(config, name) {
|
|
20232
|
+
function checkNewName(config, name, platform = process.platform) {
|
|
19926
20233
|
const check = nameAvailable(config, "account", name, PLATFORM);
|
|
19927
20234
|
if (check.ok) return;
|
|
19928
|
-
if (lookupName(config, "account", name) || lookupName(config, "inbox", name)) throw new CommsError("USAGE", `there is already an account called "${name}"`, { hint: `Choose another name, or remove that one first with
|
|
20235
|
+
if (lookupName(config, "account", name) || lookupName(config, "inbox", name)) throw new CommsError("USAGE", `there is already an account called "${name}"`, { hint: `Choose another name, or remove that one first with ${inlineCommand(shellCommand([
|
|
20236
|
+
"agent-resend",
|
|
20237
|
+
"account",
|
|
20238
|
+
"remove",
|
|
20239
|
+
name
|
|
20240
|
+
], platform))}.` });
|
|
19929
20241
|
throw check.error;
|
|
19930
20242
|
}
|
|
19931
20243
|
/** `config` with `account` under `name`. */
|
|
@@ -19961,20 +20273,22 @@ function changePolicyIn(config, account) {
|
|
|
19961
20273
|
*/
|
|
19962
20274
|
var AccountStore = class {
|
|
19963
20275
|
#load;
|
|
19964
|
-
|
|
20276
|
+
#platform;
|
|
20277
|
+
constructor(load, platform = process.platform) {
|
|
19965
20278
|
this.#load = load;
|
|
20279
|
+
this.#platform = platform;
|
|
19966
20280
|
}
|
|
19967
20281
|
async list() {
|
|
19968
|
-
return resendAccounts(await this.#load());
|
|
20282
|
+
return resendAccounts(await this.#load(), this.#platform);
|
|
19969
20283
|
}
|
|
19970
20284
|
async find(name) {
|
|
19971
|
-
return lookupAccount(await this.#load(), name);
|
|
20285
|
+
return lookupAccount(await this.#load(), name, this.#platform);
|
|
19972
20286
|
}
|
|
19973
20287
|
async findById(id) {
|
|
19974
|
-
return accountById(await this.#load(), id);
|
|
20288
|
+
return accountById(await this.#load(), id, this.#platform);
|
|
19975
20289
|
}
|
|
19976
20290
|
async require(name) {
|
|
19977
|
-
return requireAccount(await this.#load(), name);
|
|
20291
|
+
return requireAccount(await this.#load(), name, this.#platform);
|
|
19978
20292
|
}
|
|
19979
20293
|
};
|
|
19980
20294
|
var Throttle = class {
|
|
@@ -19982,12 +20296,38 @@ var Throttle = class {
|
|
|
19982
20296
|
#now;
|
|
19983
20297
|
#sleep;
|
|
19984
20298
|
#interval;
|
|
20299
|
+
/**
|
|
20300
|
+
* Five seconds **for each holder** of the lock, not for the whole line of callers ahead.
|
|
20301
|
+
*
|
|
20302
|
+
* Every Resend request on this machine takes this one lock, and an agent asking six accounts at once puts six
|
|
20303
|
+
* callers on it at the same moment. With the timeout counted from when each began to wait, the sixth had to see
|
|
20304
|
+
* five holders through inside one five-second budget; on a heavily loaded machine (a load of 97 on ten cores, in
|
|
20305
|
+
* the pre-push run that showed it) the read and the fsynced write under the lock slow down with everything else,
|
|
20306
|
+
* and it gave up with "another process is holding" while the lock was being handed on exactly as it should be. A
|
|
20307
|
+
* person with several accounts and a busy machine would have had requests refused for no reason but the queue.
|
|
20308
|
+
* Counted per holder, a caller gives up only once one holder has kept the lock for five seconds: the stuck lock
|
|
20309
|
+
* the timeout is there for.
|
|
20310
|
+
*
|
|
20311
|
+
* Each request takes the lock once, to reserve its slot, and does its waiting outside it, so the line is only the
|
|
20312
|
+
* requests in flight. But the lock is polled, not queued: whoever looks first after a release takes it, and a
|
|
20313
|
+
* request that keeps looking at the wrong moment watches later ones go ahead of it, for as long as later ones keep
|
|
20314
|
+
* arriving — and several servers each paging through sent mail keep them arriving. So the wait also has an end
|
|
20315
|
+
* that no hand-over moves, `LOCK_MAX_WAIT_MS`, and past it the request is refused as a busy lock rather than left
|
|
20316
|
+
* hanging. `after` waits the same way, because a stop it gave up on recording is a 429 that every other account
|
|
20317
|
+
* carries on into.
|
|
20318
|
+
*/
|
|
20319
|
+
#lock;
|
|
19985
20320
|
/** Takes no account on purpose: see above. Every throttle on a state directory is the same one. */
|
|
19986
20321
|
constructor(stateDir, options = {}) {
|
|
19987
20322
|
this.path = join(stateDir, "resend", "throttle.json");
|
|
19988
20323
|
this.#now = options.now ?? Date.now;
|
|
19989
20324
|
this.#sleep = options.sleep ?? ((ms) => setTimeout$1(ms).then(() => void 0));
|
|
19990
20325
|
this.#interval = options.intervalMs ?? 500;
|
|
20326
|
+
this.#lock = {
|
|
20327
|
+
timeoutPerHolder: true,
|
|
20328
|
+
maxWaitMs: options.lockMaxWaitMs ?? 3e4,
|
|
20329
|
+
...options.lockTimeoutMs === void 0 ? {} : { timeoutMs: options.lockTimeoutMs }
|
|
20330
|
+
};
|
|
19991
20331
|
}
|
|
19992
20332
|
async #read() {
|
|
19993
20333
|
try {
|
|
@@ -20004,6 +20344,10 @@ var Throttle = class {
|
|
|
20004
20344
|
throw error;
|
|
20005
20345
|
}
|
|
20006
20346
|
}
|
|
20347
|
+
/** Runs `fn` holding the machine's one throttle lock, waiting for it as `#lock` says. */
|
|
20348
|
+
#locked(fn) {
|
|
20349
|
+
return withFileLock(`${this.path}.lock`, fn, this.#lock);
|
|
20350
|
+
}
|
|
20007
20351
|
/** When Resend will next be asked anything from this machine, or null when it is not holding off. */
|
|
20008
20352
|
async blockedUntil() {
|
|
20009
20353
|
const state = await this.#read();
|
|
@@ -20031,7 +20375,7 @@ var Throttle = class {
|
|
|
20031
20375
|
* later.
|
|
20032
20376
|
*/
|
|
20033
20377
|
async before() {
|
|
20034
|
-
const wait = await
|
|
20378
|
+
const wait = await this.#locked(async () => {
|
|
20035
20379
|
const state = await this.#read();
|
|
20036
20380
|
this.#refuseIfHeld(state);
|
|
20037
20381
|
const now = this.#now();
|
|
@@ -20062,7 +20406,7 @@ var Throttle = class {
|
|
|
20062
20406
|
else if (seconds("ratelimit-remaining") === 0) stopFor = seconds("ratelimit-reset") ?? 1;
|
|
20063
20407
|
if (stopFor === null) return null;
|
|
20064
20408
|
const until = this.#now() + Math.max(1, stopFor) * 1e3;
|
|
20065
|
-
await
|
|
20409
|
+
await this.#locked(async () => {
|
|
20066
20410
|
const state = await this.#read();
|
|
20067
20411
|
await writeFileAtomic(this.path, JSON.stringify({
|
|
20068
20412
|
...state,
|
|
@@ -20079,6 +20423,7 @@ var ResendContext = class {
|
|
|
20079
20423
|
core;
|
|
20080
20424
|
env;
|
|
20081
20425
|
now;
|
|
20426
|
+
platform;
|
|
20082
20427
|
surface;
|
|
20083
20428
|
accounts;
|
|
20084
20429
|
#fetch;
|
|
@@ -20088,8 +20433,9 @@ var ResendContext = class {
|
|
|
20088
20433
|
this.env = options.env ?? process.env;
|
|
20089
20434
|
this.core = options.core ?? openCore({ env: this.env });
|
|
20090
20435
|
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
20436
|
+
this.platform = options.platform ?? process.platform;
|
|
20091
20437
|
this.surface = options.surface ?? "cli";
|
|
20092
|
-
this.accounts = new AccountStore(() => this.core.config.load());
|
|
20438
|
+
this.accounts = new AccountStore(() => this.core.config.load(), this.platform);
|
|
20093
20439
|
this.#fetch = options.fetch;
|
|
20094
20440
|
this.#throttle = new Throttle(this.core.paths.stateDir, options.throttle);
|
|
20095
20441
|
}
|
|
@@ -20138,7 +20484,12 @@ var ResendContext = class {
|
|
|
20138
20484
|
/** A transport for a connected account: its key from the secret store, the machine's throttle, and any open permit. */
|
|
20139
20485
|
async transport(named, permit) {
|
|
20140
20486
|
const key = await (await this.secrets()).get(named.account.secretRef);
|
|
20141
|
-
if (key === null || key.trim() === "") throw new CommsError("AUTH_REQUIRED", `the key for "${named.name}" is not in the secret store`, { hint: `A person removes the account and adds it again:
|
|
20487
|
+
if (key === null || key.trim() === "") throw new CommsError("AUTH_REQUIRED", `the key for "${named.name}" is not in the secret store`, { hint: `A person removes the account and adds it again: ${inlineCommand(shellCommand([
|
|
20488
|
+
"agent-resend",
|
|
20489
|
+
"account",
|
|
20490
|
+
"add",
|
|
20491
|
+
named.name
|
|
20492
|
+
], this.platform))}.` });
|
|
20142
20493
|
return {
|
|
20143
20494
|
fetch: this.#fetch,
|
|
20144
20495
|
key,
|
|
@@ -20286,15 +20637,17 @@ function codeFor(status, name) {
|
|
|
20286
20637
|
message: "Resend refused the request"
|
|
20287
20638
|
};
|
|
20288
20639
|
}
|
|
20289
|
-
|
|
20290
|
-
|
|
20291
|
-
|
|
20292
|
-
|
|
20293
|
-
|
|
20294
|
-
|
|
20295
|
-
|
|
20296
|
-
|
|
20297
|
-
|
|
20640
|
+
const REFUSED_BEFORE_ACTING = /* @__PURE__ */ new Set([
|
|
20641
|
+
400,
|
|
20642
|
+
401,
|
|
20643
|
+
403,
|
|
20644
|
+
404,
|
|
20645
|
+
422,
|
|
20646
|
+
429
|
|
20647
|
+
]);
|
|
20648
|
+
/** Whether a failed write can have taken effect. */
|
|
20649
|
+
function writeOutcomeOf(status) {
|
|
20650
|
+
return status !== void 0 && REFUSED_BEFORE_ACTING.has(status) ? "not-sent" : "unknown";
|
|
20298
20651
|
}
|
|
20299
20652
|
/** One API request. Resolves to the parsed JSON body of a 2xx, or throws a `CommsError` whose text holds no key. */
|
|
20300
20653
|
async function resendRequest(transport, method, path, options = {}) {
|
|
@@ -20367,7 +20720,7 @@ async function resendRequest(transport, method, path, options = {}) {
|
|
|
20367
20720
|
resendError: name,
|
|
20368
20721
|
stage: "http",
|
|
20369
20722
|
...stopFor !== null ? { retryAfterSeconds: stopFor } : {},
|
|
20370
|
-
...writing ? { outcome:
|
|
20723
|
+
...writing ? { outcome: writeOutcomeOf(response.status) } : {}
|
|
20371
20724
|
}
|
|
20372
20725
|
});
|
|
20373
20726
|
}
|
|
@@ -20713,11 +21066,11 @@ function viewOf(named, config) {
|
|
|
20713
21066
|
}
|
|
20714
21067
|
async function listAccounts(context) {
|
|
20715
21068
|
const config = await context.config();
|
|
20716
|
-
return { accounts: resendAccounts(config).map((named) => viewOf(named, config)) };
|
|
21069
|
+
return { accounts: resendAccounts(config, context.platform).map((named) => viewOf(named, config)) };
|
|
20717
21070
|
}
|
|
20718
21071
|
async function showAccount(context, name) {
|
|
20719
21072
|
const config = await context.config();
|
|
20720
|
-
return viewOf(requireAccount(config, name), config);
|
|
21073
|
+
return viewOf(requireAccount(config, name, context.platform), config);
|
|
20721
21074
|
}
|
|
20722
21075
|
/** A mode word checked by the operation, so both surfaces refuse it in the same words. */
|
|
20723
21076
|
function modeOf(raw) {
|
|
@@ -20736,8 +21089,8 @@ function changePolicyOf(raw) {
|
|
|
20736
21089
|
throw new CommsError("USAGE", `"${String(raw)}" is not a change policy: use chat or confirm`);
|
|
20737
21090
|
}
|
|
20738
21091
|
/** Refuses a key that is already connected, under any name: one key, one account. */
|
|
20739
|
-
function refuseConnectedKey(config, fingerprint) {
|
|
20740
|
-
const same = resendAccounts(config).find((named) => named.account.workspace === fingerprint);
|
|
21092
|
+
function refuseConnectedKey(config, fingerprint, platform) {
|
|
21093
|
+
const same = resendAccounts(config, platform).find((named) => named.account.workspace === fingerprint);
|
|
20741
21094
|
if (same) throw new CommsError("USAGE", `this key is already connected as "${same.name}"`, { hint: "One key, one account. Change that one with `agent-resend account policy`." });
|
|
20742
21095
|
}
|
|
20743
21096
|
/**
|
|
@@ -20772,8 +21125,8 @@ function addAccountChange(context, request, inspection) {
|
|
|
20772
21125
|
...domainLock ? { domainLock } : {}
|
|
20773
21126
|
};
|
|
20774
21127
|
const connect = (config) => {
|
|
20775
|
-
checkNewName(config, request.name);
|
|
20776
|
-
refuseConnectedKey(config, fingerprint);
|
|
21128
|
+
checkNewName(config, request.name, context.platform);
|
|
21129
|
+
refuseConnectedKey(config, fingerprint, context.platform);
|
|
20777
21130
|
return withAccount(config, request.name, account);
|
|
20778
21131
|
};
|
|
20779
21132
|
return {
|
|
@@ -20789,7 +21142,7 @@ function addAccountChange(context, request, inspection) {
|
|
|
20789
21142
|
};
|
|
20790
21143
|
},
|
|
20791
21144
|
apply: async (consent) => {
|
|
20792
|
-
const { store, choosing } = secretsStoreFor(await context.config(), void 0);
|
|
21145
|
+
const { store, choosing } = secretsStoreFor(await context.config(), void 0, context.platform);
|
|
20793
21146
|
const secrets = await context.core.secrets(store);
|
|
20794
21147
|
await secrets.set(account.secretRef, request.key.trim());
|
|
20795
21148
|
let written;
|
|
@@ -20830,7 +21183,7 @@ function addAccountChange(context, request, inspection) {
|
|
|
20830
21183
|
function removeAccountChange(context, name) {
|
|
20831
21184
|
return {
|
|
20832
21185
|
plan: (config) => {
|
|
20833
|
-
const found = requireAccount(config, name);
|
|
21186
|
+
const found = requireAccount(config, name, context.platform);
|
|
20834
21187
|
return {
|
|
20835
21188
|
account: found.name,
|
|
20836
21189
|
before: config,
|
|
@@ -20840,15 +21193,25 @@ function removeAccountChange(context, name) {
|
|
|
20840
21193
|
};
|
|
20841
21194
|
},
|
|
20842
21195
|
apply: async (_consent, request) => {
|
|
20843
|
-
const approved = requireAccount(request.before, name).account;
|
|
21196
|
+
const approved = requireAccount(request.before, name, context.platform).account;
|
|
20844
21197
|
const removed = await withCredentialsLock(context.core.paths.configDir, async () => {
|
|
20845
|
-
const found = requireAccount(await context.config(), name);
|
|
20846
|
-
if (found.account.id !== approved.id) throw new CommsError("CONFIG", `"${name}" changed after its removal was approved, so nothing was removed`, { hint: `Look at it with
|
|
21198
|
+
const found = requireAccount(await context.config(), name, context.platform);
|
|
21199
|
+
if (found.account.id !== approved.id) throw new CommsError("CONFIG", `"${name}" changed after its removal was approved, so nothing was removed`, { hint: `Look at it with ${inlineCommand(shellCommand([
|
|
21200
|
+
"agent-resend",
|
|
21201
|
+
"account",
|
|
21202
|
+
"show",
|
|
21203
|
+
name
|
|
21204
|
+
], context.platform))}, and remove it again if you still want it gone.` });
|
|
20847
21205
|
const secrets = await context.secrets();
|
|
20848
21206
|
await secrets.delete(found.account.secretRef).catch(() => false);
|
|
20849
21207
|
await context.core.config.update((current) => {
|
|
20850
|
-
if (secretsStoreOf(current) !== secrets.kind) throw new CommsError("TRANSIENT", `the secret store changed while "${name}" was being removed`, { hint: `Run
|
|
20851
|
-
|
|
21208
|
+
if (secretsStoreOf(current) !== secrets.kind) throw new CommsError("TRANSIENT", `the secret store changed while "${name}" was being removed`, { hint: `Run ${inlineCommand(shellCommand([
|
|
21209
|
+
"agent-resend",
|
|
21210
|
+
"account",
|
|
21211
|
+
"remove",
|
|
21212
|
+
name
|
|
21213
|
+
], context.platform))} again.` });
|
|
21214
|
+
const held = accountById(current, found.account.id, context.platform);
|
|
20852
21215
|
if (!held) return current;
|
|
20853
21216
|
return withoutAccount(current, held.name);
|
|
20854
21217
|
});
|
|
@@ -20900,7 +21263,7 @@ function reportOf(named, config) {
|
|
|
20900
21263
|
}
|
|
20901
21264
|
async function policyReport(context, name) {
|
|
20902
21265
|
const config = await context.config();
|
|
20903
|
-
return reportOf(requireAccount(config, name), config);
|
|
21266
|
+
return reportOf(requireAccount(config, name, context.platform), config);
|
|
20904
21267
|
}
|
|
20905
21268
|
/**
|
|
20906
21269
|
* Why a policy report refuses the approval it is handed, in the words of the surface it came from, as core's
|
|
@@ -20942,7 +21305,7 @@ function policyChange(context, name, wanted) {
|
|
|
20942
21305
|
].filter(Boolean).join(", ");
|
|
20943
21306
|
return {
|
|
20944
21307
|
plan: (config) => {
|
|
20945
|
-
const found = checked(requireAccount(config, name));
|
|
21308
|
+
const found = checked(requireAccount(config, name, context.platform));
|
|
20946
21309
|
return {
|
|
20947
21310
|
account: found.name,
|
|
20948
21311
|
before: config,
|
|
@@ -20951,10 +21314,10 @@ function policyChange(context, name, wanted) {
|
|
|
20951
21314
|
};
|
|
20952
21315
|
},
|
|
20953
21316
|
apply: async (consent, request) => {
|
|
20954
|
-
const approved = requireAccount(request.before, name).account;
|
|
21317
|
+
const approved = requireAccount(request.before, name, context.platform).account;
|
|
20955
21318
|
let written;
|
|
20956
21319
|
const config = await context.core.config.update((current) => {
|
|
20957
|
-
const held = accountById(current, approved.id);
|
|
21320
|
+
const held = accountById(current, approved.id, context.platform);
|
|
20958
21321
|
if (!held) throw new CommsError("CONFIG", `"${name}" changed while its policy was being set, so nothing was set`, { hint: "Check it with `agent-resend account list`, then set the policy again." });
|
|
20959
21322
|
written = {
|
|
20960
21323
|
name: held.name,
|
|
@@ -21001,7 +21364,17 @@ async function checkAccount(context, named, offline) {
|
|
|
21001
21364
|
name: "key stored",
|
|
21002
21365
|
ok: false,
|
|
21003
21366
|
detail: "the key is missing from the secret store",
|
|
21004
|
-
fix:
|
|
21367
|
+
fix: `${commandText(shellCommand([
|
|
21368
|
+
"agent-resend",
|
|
21369
|
+
"account",
|
|
21370
|
+
"remove",
|
|
21371
|
+
named.name
|
|
21372
|
+
], context.platform))}, then ${commandText(shellCommand([
|
|
21373
|
+
"agent-resend",
|
|
21374
|
+
"account",
|
|
21375
|
+
"add",
|
|
21376
|
+
named.name
|
|
21377
|
+
], context.platform))}`
|
|
21005
21378
|
});
|
|
21006
21379
|
} catch (error) {
|
|
21007
21380
|
checks.push({
|
|
@@ -22048,8 +22421,15 @@ async function ownRecord(context, named, approvalId) {
|
|
|
22048
22421
|
if (!record || approvalKind(record) !== "send" || record.inboxId !== named.account.id) throw new CommsError("NOT_FOUND", `there is no send approval ${approvalId} for "${named.name}"`, { hint: "Approvals last ten minutes. Prepare the send again." });
|
|
22049
22422
|
return record;
|
|
22050
22423
|
}
|
|
22051
|
-
function requireSendMode(named) {
|
|
22052
|
-
if (named.account.mode !== "send") throw new CommsError("SCOPE_MISSING", `"${named.name}" is in read mode, so it sends nothing`, { hint: `A person can allow sending with
|
|
22424
|
+
function requireSendMode(named, platform) {
|
|
22425
|
+
if (named.account.mode !== "send") throw new CommsError("SCOPE_MISSING", `"${named.name}" is in read mode, so it sends nothing`, { hint: `A person can allow sending with ${inlineCommand(shellCommand([
|
|
22426
|
+
"agent-resend",
|
|
22427
|
+
"account",
|
|
22428
|
+
"policy",
|
|
22429
|
+
named.name,
|
|
22430
|
+
"--mode",
|
|
22431
|
+
"send"
|
|
22432
|
+
], platform))}, which is a change they approve.` });
|
|
22053
22433
|
}
|
|
22054
22434
|
/**
|
|
22055
22435
|
* Whether the From domain may send, checked before an approval exists.
|
|
@@ -22116,8 +22496,12 @@ function expectationOf(message) {
|
|
|
22116
22496
|
subject: message.subject
|
|
22117
22497
|
};
|
|
22118
22498
|
}
|
|
22119
|
-
function describePolicy(effective, approvalId, flags) {
|
|
22120
|
-
if (effective === "confirm") return `Policy: confirm${flags.length > 0 ? ` (${flags.join(", ")})` : ""} — a person runs
|
|
22499
|
+
function describePolicy(effective, approvalId, flags, platform) {
|
|
22500
|
+
if (effective === "confirm") return `Policy: confirm${flags.length > 0 ? ` (${flags.join(", ")})` : ""} — a person runs ${inlineCommand(shellCommand([
|
|
22501
|
+
"agent-resend",
|
|
22502
|
+
"approve",
|
|
22503
|
+
approvalId
|
|
22504
|
+
], platform))} at their own terminal before this can go.`;
|
|
22121
22505
|
return "Policy: chat — send only after the user approves this exact preview.";
|
|
22122
22506
|
}
|
|
22123
22507
|
function previewOf(options) {
|
|
@@ -22155,7 +22539,7 @@ function previewOf(options) {
|
|
|
22155
22539
|
thread: message.inReplyTo ? `reply to ${message.inReplyTo}${message.references.length > 1 ? ` (${message.references.length} references)` : ""}` : void 0,
|
|
22156
22540
|
links: options.built.links,
|
|
22157
22541
|
warnings,
|
|
22158
|
-
policy: describePolicy(options.effective, options.approvalId, options.flags)
|
|
22542
|
+
policy: describePolicy(options.effective, options.approvalId, options.flags, options.platform)
|
|
22159
22543
|
});
|
|
22160
22544
|
}
|
|
22161
22545
|
async function attachPolicy(context) {
|
|
@@ -22173,10 +22557,17 @@ async function attachPolicy(context) {
|
|
|
22173
22557
|
*/
|
|
22174
22558
|
async function prepareSend(context, name, input) {
|
|
22175
22559
|
const named = await context.accounts.require(name);
|
|
22176
|
-
requireSendMode(named);
|
|
22560
|
+
requireSendMode(named, context.platform);
|
|
22177
22561
|
const config = await context.config();
|
|
22178
22562
|
const livePolicy = named.account.sendPolicy ?? config.defaults.sendPolicy;
|
|
22179
|
-
if (livePolicy === "never") throw new CommsError("POLICY_NEVER", `sending from ${name} is turned off (policy: never)`, { hint: `A person can change it with
|
|
22563
|
+
if (livePolicy === "never") throw new CommsError("POLICY_NEVER", `sending from ${name} is turned off (policy: never)`, { hint: `A person can change it with ${inlineCommand(shellCommand([
|
|
22564
|
+
"agent-resend",
|
|
22565
|
+
"account",
|
|
22566
|
+
"policy",
|
|
22567
|
+
name,
|
|
22568
|
+
"--send",
|
|
22569
|
+
"confirm"
|
|
22570
|
+
], context.platform))}.` });
|
|
22180
22571
|
const built = await buildMessage(input, {
|
|
22181
22572
|
now: context.now(),
|
|
22182
22573
|
attach: await attachPolicy(context)
|
|
@@ -22228,7 +22619,8 @@ async function prepareSend(context, name, input) {
|
|
|
22228
22619
|
study,
|
|
22229
22620
|
domainNote,
|
|
22230
22621
|
effective,
|
|
22231
|
-
flags: study.flags
|
|
22622
|
+
flags: study.flags,
|
|
22623
|
+
platform: context.platform
|
|
22232
22624
|
}),
|
|
22233
22625
|
policy: livePolicy,
|
|
22234
22626
|
effectivePolicy: effective,
|
|
@@ -22236,7 +22628,11 @@ async function prepareSend(context, name, input) {
|
|
|
22236
22628
|
reach,
|
|
22237
22629
|
expect: record.expect,
|
|
22238
22630
|
expiresAt: record.expiresAt,
|
|
22239
|
-
nextStep: effective === "confirm" ? `Show the preview to the user, then have them run
|
|
22631
|
+
nextStep: effective === "confirm" ? `Show the preview to the user, then have them run ${inlineCommand(shellCommand([
|
|
22632
|
+
"agent-resend",
|
|
22633
|
+
"approve",
|
|
22634
|
+
record.approvalId
|
|
22635
|
+
], context.platform))} in their own terminal. You cannot approve this yourself. Then execute it with the same approval id and the recipients and subject shown.` : "Show the preview to the user verbatim and wait for an explicit yes. Then execute it with the same approval id and the recipients and subject shown above."
|
|
22240
22636
|
};
|
|
22241
22637
|
}
|
|
22242
22638
|
/** The stored message and its attachments' bytes, checked again: a file changed since the preview voids the send. */
|
|
@@ -22299,13 +22695,64 @@ function describeState(state) {
|
|
|
22299
22695
|
switch (state) {
|
|
22300
22696
|
case "used": return "this approval has already been used — the email was sent once, and is not sent again";
|
|
22301
22697
|
case "sending": return "this approval is being sent by another process right now";
|
|
22302
|
-
case "failed": return "the send under this approval
|
|
22698
|
+
case "failed": return "the send under this approval was refused; nothing was sent";
|
|
22303
22699
|
case "unknown": return "a process stopped mid-send under this approval; whether the email went is not known";
|
|
22304
22700
|
case "expired": return "this approval has expired";
|
|
22305
22701
|
case "revoked": return "this approval was cancelled or voided";
|
|
22306
22702
|
default: return `this approval is ${state}`;
|
|
22307
22703
|
}
|
|
22308
22704
|
}
|
|
22705
|
+
function messageOf(error) {
|
|
22706
|
+
return error instanceof Error ? error.message : String(error);
|
|
22707
|
+
}
|
|
22708
|
+
/** Keeps the failure that stopped the send visible, adding only what could not be settled afterwards. */
|
|
22709
|
+
function noSendError(error, unrecorded) {
|
|
22710
|
+
const original = error instanceof CommsError ? error : new CommsError("UNEXPECTED", messageOf(error), { cause: error });
|
|
22711
|
+
const hint = [original.hint, ...unrecorded].filter((part) => part !== void 0);
|
|
22712
|
+
return new CommsError(original.code, original.message, {
|
|
22713
|
+
...hint.length === 0 ? {} : { hint: hint.join(" ") },
|
|
22714
|
+
...original.details === void 0 ? {} : { details: original.details },
|
|
22715
|
+
cause: error
|
|
22716
|
+
});
|
|
22717
|
+
}
|
|
22718
|
+
/** Settles a failure known to have happened before Resend sent anything, without one failed write skipping another. */
|
|
22719
|
+
async function recordNoSend(context, records, options, error) {
|
|
22720
|
+
const unrecorded = [];
|
|
22721
|
+
const said = messageOf(error);
|
|
22722
|
+
try {
|
|
22723
|
+
await records.release(options.accountId, options.approvalId);
|
|
22724
|
+
} catch (failure) {
|
|
22725
|
+
unrecorded.push(`the capacity slot could not be released (${messageOf(failure)})`);
|
|
22726
|
+
}
|
|
22727
|
+
try {
|
|
22728
|
+
await context.core.approvals.complete(options.approvalId, { error: said });
|
|
22729
|
+
} catch (failure) {
|
|
22730
|
+
unrecorded.push(`the approval could not be marked failed (${messageOf(failure)})`);
|
|
22731
|
+
}
|
|
22732
|
+
try {
|
|
22733
|
+
await records.record(options.accountId, {
|
|
22734
|
+
approvalId: options.approvalId,
|
|
22735
|
+
event: "failed",
|
|
22736
|
+
error: said.slice(0, 300)
|
|
22737
|
+
});
|
|
22738
|
+
} catch (failure) {
|
|
22739
|
+
unrecorded.push(`the send record could not record the failure (${messageOf(failure)})`);
|
|
22740
|
+
}
|
|
22741
|
+
try {
|
|
22742
|
+
await context.core.audit.append({
|
|
22743
|
+
inboxId: options.accountId,
|
|
22744
|
+
alias: options.name,
|
|
22745
|
+
operation: "resend.send.execute",
|
|
22746
|
+
outcome: "failed",
|
|
22747
|
+
surface: context.surface,
|
|
22748
|
+
approvalId: options.approvalId,
|
|
22749
|
+
reason: [said.slice(0, 200), ...unrecorded].join("; ")
|
|
22750
|
+
});
|
|
22751
|
+
} catch (failure) {
|
|
22752
|
+
unrecorded.push(`the audit log could not record the failure (${messageOf(failure)})`);
|
|
22753
|
+
}
|
|
22754
|
+
return noSendError(error, unrecorded);
|
|
22755
|
+
}
|
|
22309
22756
|
/**
|
|
22310
22757
|
* Step three, and the only place mail leaves.
|
|
22311
22758
|
*
|
|
@@ -22314,10 +22761,17 @@ function describeState(state) {
|
|
|
22314
22761
|
*/
|
|
22315
22762
|
async function executeSend(context, name, options) {
|
|
22316
22763
|
const named = await context.accounts.require(name);
|
|
22317
|
-
requireSendMode(named);
|
|
22764
|
+
requireSendMode(named, context.platform);
|
|
22318
22765
|
const known = await ownRecord(context, named, options.approvalId);
|
|
22319
22766
|
if (known.state !== "pending" && known.state !== "approved") throw new CommsError("APPROVAL_VOID", `nothing was sent: ${describeState(known.state)}`, {
|
|
22320
|
-
hint: known.state === "
|
|
22767
|
+
hint: known.state === "unknown" ? `Check what happened with ${inlineCommand(shellCommand([
|
|
22768
|
+
"agent-resend",
|
|
22769
|
+
"send",
|
|
22770
|
+
"status",
|
|
22771
|
+
options.approvalId,
|
|
22772
|
+
"--account",
|
|
22773
|
+
name
|
|
22774
|
+
], context.platform))} before anything else.` : "Prepare the send again if it should still go.",
|
|
22321
22775
|
details: {
|
|
22322
22776
|
approvalId: options.approvalId,
|
|
22323
22777
|
state: known.state
|
|
@@ -22340,89 +22794,143 @@ async function executeSend(context, name, options) {
|
|
|
22340
22794
|
inboxSub: named.account.userId,
|
|
22341
22795
|
policy: livePolicy,
|
|
22342
22796
|
expect: options.expect
|
|
22343
|
-
}, {
|
|
22797
|
+
}, {
|
|
22798
|
+
pendingHint: `Ask the user to run ${inlineCommand(shellCommand([
|
|
22799
|
+
"agent-resend",
|
|
22800
|
+
"approve",
|
|
22801
|
+
options.approvalId
|
|
22802
|
+
], context.platform))} in their own terminal, then execute it again with the same approval. You cannot approve it yourself.`,
|
|
22803
|
+
platform: context.platform
|
|
22804
|
+
});
|
|
22344
22805
|
const records = new SendRecords(context.core.paths.stateDir, context.now);
|
|
22345
22806
|
const message = prepared.message;
|
|
22346
22807
|
const recipients = uniqueRecipients(message);
|
|
22808
|
+
const bookkeeping = {
|
|
22809
|
+
name,
|
|
22810
|
+
accountId: named.account.id,
|
|
22811
|
+
approvalId: options.approvalId
|
|
22812
|
+
};
|
|
22347
22813
|
try {
|
|
22348
22814
|
await records.reserve(named.account.id, options.approvalId, config.defaults.sendCaps);
|
|
22349
22815
|
} catch (error) {
|
|
22350
|
-
await context
|
|
22351
|
-
|
|
22816
|
+
throw await recordNoSend(context, records, bookkeeping, error);
|
|
22817
|
+
}
|
|
22818
|
+
try {
|
|
22819
|
+
await records.record(named.account.id, {
|
|
22820
|
+
approvalId: options.approvalId,
|
|
22821
|
+
event: "attempt",
|
|
22822
|
+
recipients,
|
|
22823
|
+
subject: message.subject,
|
|
22824
|
+
scheduledAt: message.scheduledAt
|
|
22825
|
+
});
|
|
22826
|
+
await context.core.audit.append({
|
|
22827
|
+
inboxId: named.account.id,
|
|
22828
|
+
alias: name,
|
|
22829
|
+
operation: "resend.send.execute",
|
|
22830
|
+
outcome: "started",
|
|
22831
|
+
surface: context.surface,
|
|
22832
|
+
approvalId: options.approvalId,
|
|
22833
|
+
recipients: recipients.map(canonicalAddress)
|
|
22834
|
+
}, { durable: true });
|
|
22835
|
+
} catch (error) {
|
|
22836
|
+
throw await recordNoSend(context, records, bookkeeping, error);
|
|
22352
22837
|
}
|
|
22353
|
-
await records.record(named.account.id, {
|
|
22354
|
-
approvalId: options.approvalId,
|
|
22355
|
-
event: "attempt",
|
|
22356
|
-
recipients,
|
|
22357
|
-
subject: message.subject,
|
|
22358
|
-
scheduledAt: message.scheduledAt
|
|
22359
|
-
});
|
|
22360
|
-
await context.core.audit.append({
|
|
22361
|
-
inboxId: named.account.id,
|
|
22362
|
-
alias: name,
|
|
22363
|
-
operation: "resend.send.execute",
|
|
22364
|
-
outcome: "started",
|
|
22365
|
-
surface: context.surface,
|
|
22366
|
-
approvalId: options.approvalId,
|
|
22367
|
-
recipients: recipients.map(canonicalAddress)
|
|
22368
|
-
}, { durable: true });
|
|
22369
22838
|
let resendId;
|
|
22839
|
+
let requestIssued = false;
|
|
22840
|
+
const innerFetch = transport.fetch ?? fetch;
|
|
22841
|
+
const trackedTransport = {
|
|
22842
|
+
...transport,
|
|
22843
|
+
fetch: async (...args) => {
|
|
22844
|
+
requestIssued = true;
|
|
22845
|
+
return innerFetch(...args);
|
|
22846
|
+
}
|
|
22847
|
+
};
|
|
22370
22848
|
try {
|
|
22371
|
-
const response = await spendOn(permit, options.approvalId, "emails.send", () => resendRequest(
|
|
22849
|
+
const response = await spendOn(permit, options.approvalId, "emails.send", () => resendRequest(trackedTransport, "POST", "/emails", {
|
|
22372
22850
|
body: payloadOf(message, options.approvalId, contents),
|
|
22373
22851
|
idempotencyKey: options.approvalId
|
|
22374
22852
|
}));
|
|
22375
22853
|
if (typeof response.id !== "string" || response.id === "") throw new CommsError("PROVIDER_UNAVAILABLE", "Resend accepted the send but returned no email id", { details: { outcome: "unknown" } });
|
|
22376
22854
|
resendId = response.id;
|
|
22377
22855
|
} catch (error) {
|
|
22856
|
+
if (!requestIssued) throw await recordNoSend(context, records, bookkeeping, error);
|
|
22378
22857
|
const outcome = error instanceof CommsError && error.details?.outcome === "not-sent" ? "not-sent" : error instanceof CommsError && error.code === "SEND_REFUSED" ? "not-sent" : "unknown";
|
|
22379
22858
|
const said = error instanceof Error ? error.message : String(error);
|
|
22380
|
-
|
|
22381
|
-
|
|
22859
|
+
if (outcome === "not-sent") throw await recordNoSend(context, records, bookkeeping, error);
|
|
22860
|
+
const unrecorded = [];
|
|
22861
|
+
try {
|
|
22862
|
+
await records.record(named.account.id, {
|
|
22863
|
+
approvalId: options.approvalId,
|
|
22864
|
+
event: "unknown",
|
|
22865
|
+
error: said.slice(0, 300)
|
|
22866
|
+
});
|
|
22867
|
+
} catch (failure) {
|
|
22868
|
+
unrecorded.push(`the send record could not record this (${failure instanceof Error ? failure.message : String(failure)})`);
|
|
22869
|
+
}
|
|
22870
|
+
try {
|
|
22871
|
+
await context.core.audit.append({
|
|
22872
|
+
inboxId: named.account.id,
|
|
22873
|
+
alias: name,
|
|
22874
|
+
operation: "resend.send.execute",
|
|
22875
|
+
outcome: "failed",
|
|
22876
|
+
surface: context.surface,
|
|
22877
|
+
approvalId: options.approvalId,
|
|
22878
|
+
reason: `outcome unknown: ${said.slice(0, 200)}`
|
|
22879
|
+
});
|
|
22880
|
+
} catch (failure) {
|
|
22881
|
+
unrecorded.push(`the audit log could not record this (${failure instanceof Error ? failure.message : String(failure)})`);
|
|
22882
|
+
}
|
|
22883
|
+
throw new CommsError("TRANSIENT", `whether the email was sent is not known: ${said}`, {
|
|
22884
|
+
hint: [`Do not send it again. Check the Resend dashboard or ask the recipient, and check with ${inlineCommand(shellCommand([
|
|
22885
|
+
"agent-resend",
|
|
22886
|
+
"send",
|
|
22887
|
+
"status",
|
|
22888
|
+
options.approvalId,
|
|
22889
|
+
"--account",
|
|
22890
|
+
name
|
|
22891
|
+
], context.platform))}; this approval is not used again.`, ...unrecorded].join(" "),
|
|
22892
|
+
details: {
|
|
22893
|
+
...error instanceof CommsError ? error.details : {},
|
|
22894
|
+
approvalId: options.approvalId,
|
|
22895
|
+
outcome: "unknown"
|
|
22896
|
+
},
|
|
22897
|
+
cause: error
|
|
22898
|
+
});
|
|
22899
|
+
}
|
|
22900
|
+
const unrecorded = [];
|
|
22901
|
+
try {
|
|
22902
|
+
await context.core.approvals.complete(options.approvalId, { sentMessageId: resendId });
|
|
22903
|
+
} catch (error) {
|
|
22904
|
+
unrecorded.push(`the approval could not be marked used (${error instanceof Error ? error.message : String(error)}), so it will read as unknown`);
|
|
22905
|
+
}
|
|
22906
|
+
try {
|
|
22382
22907
|
await records.record(named.account.id, {
|
|
22383
22908
|
approvalId: options.approvalId,
|
|
22384
|
-
event:
|
|
22385
|
-
|
|
22909
|
+
event: "sent",
|
|
22910
|
+
resendId
|
|
22386
22911
|
});
|
|
22912
|
+
} catch (error) {
|
|
22913
|
+
unrecorded.push(`the send record could not record it (${error instanceof Error ? error.message : String(error)})`);
|
|
22914
|
+
}
|
|
22915
|
+
try {
|
|
22387
22916
|
await context.core.audit.append({
|
|
22388
22917
|
inboxId: named.account.id,
|
|
22389
22918
|
alias: name,
|
|
22390
22919
|
operation: "resend.send.execute",
|
|
22391
|
-
outcome: "
|
|
22920
|
+
outcome: "ok",
|
|
22392
22921
|
surface: context.surface,
|
|
22393
22922
|
approvalId: options.approvalId,
|
|
22394
|
-
|
|
22923
|
+
ids: { resendIds: [resendId] },
|
|
22924
|
+
recipients: [
|
|
22925
|
+
...claimed.expect.to,
|
|
22926
|
+
...claimed.expect.cc,
|
|
22927
|
+
...claimed.expect.bcc
|
|
22928
|
+
].map(canonicalAddress),
|
|
22929
|
+
reason: [`digest ${digest.slice(0, 12)} · policy ${livePolicy} · ${claimed.approvedVia ?? "chat"}`, ...unrecorded].join(" · ")
|
|
22395
22930
|
});
|
|
22396
|
-
|
|
22397
|
-
|
|
22398
|
-
details: {
|
|
22399
|
-
approvalId: options.approvalId,
|
|
22400
|
-
outcome: "unknown"
|
|
22401
|
-
}
|
|
22402
|
-
});
|
|
22403
|
-
throw error;
|
|
22931
|
+
} catch (error) {
|
|
22932
|
+
unrecorded.push(`the audit log could not record it (${error instanceof Error ? error.message : String(error)})`);
|
|
22404
22933
|
}
|
|
22405
|
-
await context.core.approvals.complete(options.approvalId, { sentMessageId: resendId });
|
|
22406
|
-
await records.record(named.account.id, {
|
|
22407
|
-
approvalId: options.approvalId,
|
|
22408
|
-
event: "sent",
|
|
22409
|
-
resendId
|
|
22410
|
-
});
|
|
22411
|
-
await context.core.audit.append({
|
|
22412
|
-
inboxId: named.account.id,
|
|
22413
|
-
alias: name,
|
|
22414
|
-
operation: "resend.send.execute",
|
|
22415
|
-
outcome: "ok",
|
|
22416
|
-
surface: context.surface,
|
|
22417
|
-
approvalId: options.approvalId,
|
|
22418
|
-
ids: { resendIds: [resendId] },
|
|
22419
|
-
recipients: [
|
|
22420
|
-
...claimed.expect.to,
|
|
22421
|
-
...claimed.expect.cc,
|
|
22422
|
-
...claimed.expect.bcc
|
|
22423
|
-
].map(canonicalAddress),
|
|
22424
|
-
reason: `digest ${digest.slice(0, 12)} · policy ${livePolicy} · ${claimed.approvedVia ?? "chat"}`
|
|
22425
|
-
});
|
|
22426
22934
|
return {
|
|
22427
22935
|
account: name,
|
|
22428
22936
|
approvalId: options.approvalId,
|
|
@@ -22432,7 +22940,8 @@ async function executeSend(context, name, options) {
|
|
|
22432
22940
|
to: claimed.expect.to,
|
|
22433
22941
|
cc: claimed.expect.cc,
|
|
22434
22942
|
bcc: claimed.expect.bcc,
|
|
22435
|
-
subject: claimed.expect.subject
|
|
22943
|
+
subject: claimed.expect.subject,
|
|
22944
|
+
...unrecorded.length > 0 ? { note: unrecorded.join("; ") } : {}
|
|
22436
22945
|
};
|
|
22437
22946
|
}
|
|
22438
22947
|
/**
|
|
@@ -22528,7 +23037,7 @@ async function beginSendApproval(context, approvalId) {
|
|
|
22528
23037
|
const config = await context.config();
|
|
22529
23038
|
const study = await studyRecipients(context, named, prepared.message, config.defaults.riskEscalation);
|
|
22530
23039
|
const effective = stricterPolicy(named.account.sendPolicy ?? config.defaults.sendPolicy, record.requiredPolicy);
|
|
22531
|
-
const challenge = await context.core.approvals.issueChallenge(approvalId);
|
|
23040
|
+
const challenge = await context.core.approvals.issueChallenge(approvalId, "send", context.platform);
|
|
22532
23041
|
return {
|
|
22533
23042
|
approvalId,
|
|
22534
23043
|
account: named.name,
|
|
@@ -22543,7 +23052,8 @@ async function beginSendApproval(context, approvalId) {
|
|
|
22543
23052
|
study,
|
|
22544
23053
|
domainNote: prepared.warnings[0] ?? "",
|
|
22545
23054
|
effective,
|
|
22546
|
-
flags: record.riskFlags
|
|
23055
|
+
flags: record.riskFlags,
|
|
23056
|
+
platform: context.platform
|
|
22547
23057
|
}),
|
|
22548
23058
|
challenge
|
|
22549
23059
|
};
|
|
@@ -22554,7 +23064,7 @@ async function finishSendApproval(context, approvalId, answer) {
|
|
|
22554
23064
|
const approved = await context.core.approvals.approve(approvalId, "terminal", {
|
|
22555
23065
|
draftMessageId: digest,
|
|
22556
23066
|
digest
|
|
22557
|
-
}, answer);
|
|
23067
|
+
}, answer, "send", context.platform);
|
|
22558
23068
|
await context.core.audit.append({
|
|
22559
23069
|
inboxId: named.account.id,
|
|
22560
23070
|
alias: named.name,
|
|
@@ -22580,4 +23090,4 @@ async function revokeSendApproval(context, approvalId) {
|
|
|
22580
23090
|
});
|
|
22581
23091
|
}
|
|
22582
23092
|
//#endregion
|
|
22583
|
-
export {
|
|
23093
|
+
export { inlineCommand as $, CHANGE_POLICIES as A, agentMarker as B, policyChange as C, sendPolicyOf as D, removeAccountChange as E, CHANNEL_SERVERS as F, changeToolResult as G, approvalsOf as H, CommsError as I, commandPathOf as J, checkForUpdates as K, EXIT_CODES as L, SEND_POLICIES as M, checkNewName as N, showAccount as O, strictToolArguments as P, gatedChangeAtTerminal as Q, SEND_LOOKUP as R, policyApprovalRefusal as S, policyWanted as T, approveChangeAtTerminal as U, approvalKind as V, canPrompt as W, exemptFromUpdateGate as X, escapeForDisplay as Y, gatedChange as Z, runDoctor as _, REFUSED as _t, revokeSendApproval as a, renderPrune as at, listAccounts as b, ROUTES as bt, downloadReceived as c, serverInstallChange as ct, listReceived as d, terminalUpdateHooks as dt, installExitStatus as et, listScheduled as f, toCommsError as ft, showSentEmail as g, writeResult as gt, showReceived as h, updateToolGate as ht, prepareSend as i, renderInstall as it, MODES as j, ResendContext as k, getMetrics as l, serverPruneChange as lt, listSuppressions as m, updateGateAtTerminal as mt, executeSend as n, paint as nt, sendStatus as o, runCommand as ot, listSentEmails as p, truncateDisplay as pt, colorEnabled as q, finishSendApproval as r, refuseUnclaimedApproval as rt, cancelScheduledChange as s, runUpdateCheckChild as st, beginSendApproval as t, openCore as tt, listDomains as u, shellCommand as ut, addAccountChange as v, RESEND_API_ORIGIN as vt, policyReport as w, modeOf as x, VERSION$1 as xt, inspectKey as y, RESEND_DOWNLOAD_ORIGIN as yt, UPDATE_CHECK_CHILD_COMMAND as z };
|