@agentcomms/whatsapp 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.
|
@@ -4,16 +4,16 @@ import path, { basename, delimiter, dirname, isAbsolute, join, resolve, sep } fr
|
|
|
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 { styleText } from "node:util";
|
|
7
8
|
import { homedir, platform, userInfo } from "node:os";
|
|
8
9
|
import { constants as constants$1 } from "node:fs";
|
|
9
10
|
import { execFile, spawn } from "node:child_process";
|
|
10
|
-
import { styleText } from "node:util";
|
|
11
11
|
//#endregion
|
|
12
12
|
//#region src/version.ts
|
|
13
13
|
/** The package version, read from package.json at build time. */
|
|
14
|
-
const VERSION$1 = "0.12.
|
|
14
|
+
const VERSION$1 = "0.12.3";
|
|
15
15
|
//#endregion
|
|
16
|
-
//#region ../core/dist/update-check-
|
|
16
|
+
//#region ../core/dist/update-check-Dq-D4bAD.mjs
|
|
17
17
|
/**
|
|
18
18
|
* Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
|
|
19
19
|
* optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
|
|
@@ -392,9 +392,15 @@ async function takeOverStale(lockPath, staleMs) {
|
|
|
392
392
|
async function withFileLock(lockPath, fn, options = {}) {
|
|
393
393
|
const timeoutMs = options.timeoutMs ?? 5e3;
|
|
394
394
|
const staleMs = options.staleMs ?? 3e4;
|
|
395
|
-
const
|
|
395
|
+
const started = Date.now();
|
|
396
|
+
let deadline = started + timeoutMs;
|
|
397
|
+
/** `maxWaitMs`'s end: fixed when the wait begins, whatever the lock does. */
|
|
398
|
+
const limit = options.maxWaitMs === void 0 ? Number.POSITIVE_INFINITY : started + options.maxWaitMs;
|
|
396
399
|
const token = randomBytes(12).toString("hex");
|
|
397
400
|
let lastCode = "EEXIST";
|
|
401
|
+
/** The token of the holder this caller saw last, and how many times it has seen the lock change hands. */
|
|
402
|
+
let holder;
|
|
403
|
+
let handOvers = 0;
|
|
398
404
|
await ensurePrivateDir(dirname(lockPath));
|
|
399
405
|
for (;;) try {
|
|
400
406
|
const handle = await open(lockPath, "wx", 384);
|
|
@@ -409,10 +415,20 @@ async function withFileLock(lockPath, fn, options = {}) {
|
|
|
409
415
|
const code = error.code;
|
|
410
416
|
if (!CONTENDED.has(code ?? "")) throw error;
|
|
411
417
|
lastCode = code ?? lastCode;
|
|
412
|
-
if (code === "EEXIST"
|
|
413
|
-
await
|
|
414
|
-
|
|
418
|
+
if (code === "EEXIST") {
|
|
419
|
+
const body = await readLock(lockPath);
|
|
420
|
+
if (await isStale(lockPath, body, staleMs)) {
|
|
421
|
+
await takeOverStale(lockPath, staleMs);
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
const seen = typeof body?.token === "string" ? body.token : void 0;
|
|
425
|
+
if (seen !== void 0 && seen !== holder) {
|
|
426
|
+
if (holder !== void 0) handOvers += 1;
|
|
427
|
+
holder = seen;
|
|
428
|
+
if (options.timeoutPerHolder) deadline = Date.now() + timeoutMs;
|
|
429
|
+
}
|
|
415
430
|
}
|
|
431
|
+
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})`) });
|
|
416
432
|
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})`) });
|
|
417
433
|
await setTimeout$1(25 + Math.floor(Math.random() * 50));
|
|
418
434
|
}
|
|
@@ -950,6 +966,253 @@ function connectMailboxCommand() {
|
|
|
950
966
|
const mail = accountChannels().find((manifest) => manifest.accounts?.map === "inboxes");
|
|
951
967
|
return mail ? `${mail.binary} inbox add` : void 0;
|
|
952
968
|
}
|
|
969
|
+
/** Crockford-style alphabet without I, L, O and U, so ids and challenges survive being read aloud or retyped. */
|
|
970
|
+
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
971
|
+
function randomString(length) {
|
|
972
|
+
const bytes = randomBytes(length);
|
|
973
|
+
let out = "";
|
|
974
|
+
for (let i = 0; i < length; i += 1) out += ALPHABET[(bytes[i] ?? 0) & 31];
|
|
975
|
+
return out;
|
|
976
|
+
}
|
|
977
|
+
/** `ap_` + 26 characters: 130 random bits. Validated before an id ever names a file. */
|
|
978
|
+
const APPROVAL_ID_PATTERN = /^ap_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
979
|
+
const PLAN_TOKEN_PATTERN = /^pl_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
980
|
+
function newApprovalId() {
|
|
981
|
+
return `ap_${randomString(26)}`;
|
|
982
|
+
}
|
|
983
|
+
function newPlanToken() {
|
|
984
|
+
return `pl_${randomString(26)}`;
|
|
985
|
+
}
|
|
986
|
+
const CHALLENGE_LETTERS = "ABCDEFGHJKMNPQRSTVWXYZ";
|
|
987
|
+
/** A short challenge a human types back to approve: letters only, no look-alikes. */
|
|
988
|
+
function newChallenge(length = 4) {
|
|
989
|
+
let out = "";
|
|
990
|
+
for (let i = 0; i < length; i += 1) out += CHALLENGE_LETTERS[randomInt(22)];
|
|
991
|
+
return out;
|
|
992
|
+
}
|
|
993
|
+
/** Challenges are stored only as hashes, so no listing or tool result can reveal one. */
|
|
994
|
+
function hashChallenge(challenge) {
|
|
995
|
+
return createHash("sha256").update(challenge.trim().toUpperCase()).digest("hex");
|
|
996
|
+
}
|
|
997
|
+
/** Case-insensitive, constant-time comparison of an answer with a stored challenge hash. */
|
|
998
|
+
function challengeMatches(answer, storedHash) {
|
|
999
|
+
const a = Buffer.from(hashChallenge(answer), "hex");
|
|
1000
|
+
const b = Buffer.from(storedHash, "hex");
|
|
1001
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
1002
|
+
}
|
|
1003
|
+
function okEnvelope(data) {
|
|
1004
|
+
return {
|
|
1005
|
+
ok: true,
|
|
1006
|
+
schemaVersion: 1,
|
|
1007
|
+
data
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
function errorEnvelope(error) {
|
|
1011
|
+
const body = {
|
|
1012
|
+
code: error.code,
|
|
1013
|
+
message: error.message
|
|
1014
|
+
};
|
|
1015
|
+
if (error.hint !== void 0) body.hint = error.hint;
|
|
1016
|
+
if (error.details !== void 0) body.details = error.details;
|
|
1017
|
+
return {
|
|
1018
|
+
ok: false,
|
|
1019
|
+
schemaVersion: 1,
|
|
1020
|
+
error: body
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
const defaultStreams = {
|
|
1024
|
+
stdout: process.stdout,
|
|
1025
|
+
stderr: process.stderr,
|
|
1026
|
+
stdin: process.stdin
|
|
1027
|
+
};
|
|
1028
|
+
function colorEnabled(env, stream, flag) {
|
|
1029
|
+
if (flag === false) return false;
|
|
1030
|
+
if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
|
|
1031
|
+
if (env.TERM === "dumb") return false;
|
|
1032
|
+
if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
|
|
1033
|
+
return Boolean(stream.isTTY);
|
|
1034
|
+
}
|
|
1035
|
+
/** True when a human could answer a prompt: both ends are terminals, no --json/--no-input, not CI. */
|
|
1036
|
+
function canPrompt(env, streams, options) {
|
|
1037
|
+
if (options.json || options.noInput) return false;
|
|
1038
|
+
if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
|
|
1039
|
+
return Boolean(streams.stdin?.isTTY && streams.stdout.isTTY);
|
|
1040
|
+
}
|
|
1041
|
+
/** Environment variables well-known coding agents set. Used only as a speed bump, never as a security boundary. */
|
|
1042
|
+
const AGENT_MARKERS = [
|
|
1043
|
+
"CLAUDECODE",
|
|
1044
|
+
"CLAUDE_CODE_ENTRYPOINT",
|
|
1045
|
+
"CODEX_SANDBOX",
|
|
1046
|
+
"CODEX_HOME",
|
|
1047
|
+
"CURSOR_AGENT",
|
|
1048
|
+
"GEMINI_CLI",
|
|
1049
|
+
"AGENT_COMMS_AGENT"
|
|
1050
|
+
];
|
|
1051
|
+
function agentMarker(env) {
|
|
1052
|
+
for (const name of AGENT_MARKERS) if (env[name] !== void 0 && env[name] !== "") return name;
|
|
1053
|
+
return null;
|
|
1054
|
+
}
|
|
1055
|
+
function paint(color, format, text) {
|
|
1056
|
+
return color ? styleText(format, text, { validateStream: false }) : text;
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* A command line for a person to copy and run, each word quoted only where the shell it is pasted into would need it.
|
|
1060
|
+
* Every command this package prints to be run — a change to run again with its approval, a folder to take out, an
|
|
1061
|
+
* entry to register again or remove — is quoted here, so no printer quotes for a shell of its own.
|
|
1062
|
+
*
|
|
1063
|
+
* Everywhere but Windows that shell is a POSIX one, and a word goes in single quotes, inside which nothing is special
|
|
1064
|
+
* but the quote itself. Windows has two shells, and neither reads single quotes that way: cmd.exe does not take them
|
|
1065
|
+
* as quotes at all, so `'C:\Profiles\First Last\outgoing'` reached the command as two words, quote marks and all, and
|
|
1066
|
+
* PowerShell does, but escapes a quote inside them by doubling it rather than as `'\''`. A command printed on Windows
|
|
1067
|
+
* has to be safe in both, because nothing says which one it will be pasted into (CUE-306) — and safe is not enough:
|
|
1068
|
+
* the program has to receive the same words from either. Three readers stand between the line and the program. cmd.exe
|
|
1069
|
+
* hands the program the line as it is, and the program's own parser (the C runtime's, Node's) splits it. PowerShell
|
|
1070
|
+
* reads the line itself, a double-quoted word with its backslashes as plain characters, and then writes a new command
|
|
1071
|
+
* line for the program: Windows PowerShell 5.1 the old way ("Legacy" in about_Parsing, "Passing arguments to native
|
|
1072
|
+
* applications") — a word quoted only when it holds whitespace, as it is, and an empty word dropped — and PowerShell
|
|
1073
|
+
* 7.3 and later its own way ("Standard") — quoted when it must be, with every backslash before a quote doubled. For a
|
|
1074
|
+
* `.cmd` script, which is how npm installs `agentcomms`, `claude` and `codex` on Windows, PowerShell 7 goes back to the
|
|
1075
|
+
* old way, and cmd.exe then reads that new line. So on Windows:
|
|
1076
|
+
*
|
|
1077
|
+
* - A word of letters, digits and `_ + = : . / \`, with `@` and `-` anywhere but first, is left as it is: no reader
|
|
1078
|
+
* does anything with it, and a backslash is an ordinary character to all of them, at the end too. A first `@` is
|
|
1079
|
+
* splatting to PowerShell, and a `,` its array operator — two words — so a word with either is quoted. So is a word
|
|
1080
|
+
* that starts with a digit, which PowerShell may read as a number (`1kb`, `0x10`), and one that starts with `-` but
|
|
1081
|
+
* is not a plain option, which PowerShell may read as a parameter of its own and split (`-name.x`).
|
|
1082
|
+
* - A word that double quotes bring through all three readers whole goes in double quotes. Inside them cmd.exe reads
|
|
1083
|
+
* `& | < > ^ ( )` and spaces as ordinary characters, and PowerShell reads everything as ordinary but `$`, the
|
|
1084
|
+
* backtick and a double quote. What is left special in one or the other is kept out: a double quote, which ends the
|
|
1085
|
+
* quoting in both — and PowerShell takes the curly ones, `“ ” „`, for one too; `$` and the backtick, PowerShell's
|
|
1086
|
+
* expansion and escape; `%`, which cmd.exe expands as `%NAME%` before it looks at quotes at all; `!`, which it
|
|
1087
|
+
* expands as `!NAME!` inside quotes too wherever delayed expansion is on, and which then makes a `^` inside quotes an
|
|
1088
|
+
* escape; and any control or formatting character — a line break ends the command in cmd.exe even inside quotes, a
|
|
1089
|
+
* tab pasted into cmd.exe can complete a file name, and a right-to-left override shows a line other than the one
|
|
1090
|
+
* that runs. Three more cannot come through: an empty word, which Windows PowerShell drops; a word ending in a
|
|
1091
|
+
* backslash, which the C runtime reads with the closing quote as `\"` — and doubled for it, PowerShell 7 passes both
|
|
1092
|
+
* backslashes on where cmd.exe and Windows PowerShell pass one; and a word with `& | < > ^ ( )` but no whitespace,
|
|
1093
|
+
* which PowerShell passes to a `.cmd` script unquoted, for cmd.exe to run `&whoami` from or to split at `|`.
|
|
1094
|
+
* - With any other word in it, no line is printed at all (`line` is null), and the printers show the command's words
|
|
1095
|
+
* as JSON instead, saying it has to be typed. Not a line with the word left out: a placeholder in its place was
|
|
1096
|
+
* still a command that ran — `claude mcp remove NAME` removed whatever entry was called `NAME`, and an install
|
|
1097
|
+
* hint's `--force` replaced it — and before that, a word quoted for PowerShell's single quotes, which cmd.exe reads
|
|
1098
|
+
* as characters, ran `whoami` from a server named `$x&whoami&`. See `commandAsJson` for why the JSON runs nothing.
|
|
1099
|
+
*
|
|
1100
|
+
* Everywhere else there is always a line: single quotes make any word safe.
|
|
1101
|
+
*/
|
|
1102
|
+
function shellCommand(words, platform = process.platform) {
|
|
1103
|
+
if (platform !== "win32") return {
|
|
1104
|
+
words,
|
|
1105
|
+
line: words.map(posixShellWord).join(" "),
|
|
1106
|
+
platform
|
|
1107
|
+
};
|
|
1108
|
+
const printed = words.map(windowsShellWord);
|
|
1109
|
+
return {
|
|
1110
|
+
words,
|
|
1111
|
+
line: printed.includes(null) ? null : printed.join(" "),
|
|
1112
|
+
platform
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
/** The same command with more words at its end — the approval it is to be run again with — for the same shell. */
|
|
1116
|
+
function withWords(command, ...more) {
|
|
1117
|
+
return shellCommand([...command.words, ...more], command.platform);
|
|
1118
|
+
}
|
|
1119
|
+
function posixShellWord(word) {
|
|
1120
|
+
return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* The word as cmd.exe, Windows PowerShell and PowerShell 7 all hand it to a program, or null when no printing of it
|
|
1124
|
+
* does (see `shellCommand`). Bare: a plain option, or ordinary characters that do not start like a number.
|
|
1125
|
+
*/
|
|
1126
|
+
function windowsShellWord(word) {
|
|
1127
|
+
if (/^(?:--?[A-Za-z][A-Za-z0-9-]*|(?!\+?\.?\d)[\w+=:./\\][\w@+=:./\\-]*)$/.test(word)) return word;
|
|
1128
|
+
if (word === "" || word.endsWith("\\")) return null;
|
|
1129
|
+
if (/["$`%!\u201C-\u201E]|[\p{C}\p{Zl}\p{Zp}]/u.test(word)) return null;
|
|
1130
|
+
if (/[&|<>^()]/.test(word) && !/\s/.test(word)) return null;
|
|
1131
|
+
return `"${word}"`;
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* The words of a command as a JSON array, for a command that cannot be printed as a line: something to read, and to
|
|
1135
|
+
* parse, and nothing to run.
|
|
1136
|
+
*
|
|
1137
|
+
* Pasted into either Windows shell by mistake, it runs nothing. PowerShell refuses it before running anything: a `[`
|
|
1138
|
+
* opens a type name, and a quoted string is none. cmd.exe looks for a program called `["agentcomms"` or the like, and
|
|
1139
|
+
* finds none — and nothing in the line is anything else to it: every character it acts on, inside quotes or out (`%`,
|
|
1140
|
+
* `!`), is written as a `\u` escape, and so is every double quote inside a word, so the quotes cmd.exe sees are the
|
|
1141
|
+
* JSON's own, in pairs, and `&`, `|`, `<`, `>`, `^` and the parentheses are only ever inside them, where it reads them
|
|
1142
|
+
* as characters. `$` and the backtick are escaped too, so that PowerShell would expand nothing even if it read on, and
|
|
1143
|
+
* so is everything outside printable ASCII — a line break, a curly quote, a right-to-left override. A backslash is
|
|
1144
|
+
* doubled, as JSON has it.
|
|
1145
|
+
*/
|
|
1146
|
+
function commandAsJson(words) {
|
|
1147
|
+
const word = (text) => {
|
|
1148
|
+
let json = "";
|
|
1149
|
+
for (let index = 0; index < text.length; index++) {
|
|
1150
|
+
const code = text.charCodeAt(index);
|
|
1151
|
+
const char = text[index];
|
|
1152
|
+
if (char === "\\") json += "\\\\";
|
|
1153
|
+
else if (code >= 32 && code <= 126 && !"\"%!$`".includes(char)) json += char;
|
|
1154
|
+
else json += `\\u${code.toString(16).padStart(4, "0")}`;
|
|
1155
|
+
}
|
|
1156
|
+
return `"${json}"`;
|
|
1157
|
+
};
|
|
1158
|
+
return `[${words.map(word).join(",")}]`;
|
|
1159
|
+
}
|
|
1160
|
+
/** What is said beside a command shown as JSON. */
|
|
1161
|
+
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";
|
|
1162
|
+
/** A command in backticks, for a sentence — as its words in JSON, saying it has to be typed, when it has no line. */
|
|
1163
|
+
function inlineCommand(command) {
|
|
1164
|
+
return command.line === null ? `\`${commandAsJson(command.words)}\` (${TO_TYPE})` : `\`${command.line}\``;
|
|
1165
|
+
}
|
|
1166
|
+
/** 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. */
|
|
1167
|
+
function commandText(command) {
|
|
1168
|
+
return command.line === null ? `${commandAsJson(command.words)} (${TO_TYPE})` : command.line;
|
|
1169
|
+
}
|
|
1170
|
+
/** Writes a successful result: the envelope with --json, otherwise the human rendering. */
|
|
1171
|
+
function writeResult(data, options, human, streams = defaultStreams) {
|
|
1172
|
+
if (options.json) {
|
|
1173
|
+
streams.stdout.write(`${JSON.stringify(okEnvelope(data))}\n`);
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
const text = human(data);
|
|
1177
|
+
if (text) streams.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
1178
|
+
}
|
|
1179
|
+
/** Writes an error and returns the exit code to use. */
|
|
1180
|
+
function writeError(error, options, streams = defaultStreams) {
|
|
1181
|
+
const commsError = toCommsError(error);
|
|
1182
|
+
if (options.json) streams.stdout.write(`${JSON.stringify(errorEnvelope(commsError))}\n`);
|
|
1183
|
+
else {
|
|
1184
|
+
streams.stderr.write(`${paint(options.color, "red", "error")}: ${commsError.message}\n`);
|
|
1185
|
+
if (commsError.hint) streams.stderr.write(`${paint(options.color, "dim", "hint")}: ${commsError.hint}\n`);
|
|
1186
|
+
}
|
|
1187
|
+
return commsError.exitCode;
|
|
1188
|
+
}
|
|
1189
|
+
/** Runs a command body and exits with the documented code. Unexpected errors keep only their message. */
|
|
1190
|
+
async function runCommand(options, body, streams = defaultStreams) {
|
|
1191
|
+
try {
|
|
1192
|
+
await body();
|
|
1193
|
+
return EXIT_CODES.OK;
|
|
1194
|
+
} catch (error) {
|
|
1195
|
+
return writeError(error, options, streams);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* The first two steps of `requirePerson` — an agent is refused, then anything without a terminal — for a command
|
|
1200
|
+
* whose code is not its own to make up.
|
|
1201
|
+
*
|
|
1202
|
+
* `agentcomms approve` asks for a code the approval store issued and will check, so it cannot use `askChallenge`;
|
|
1203
|
+
* but it refuses in exactly the same order and words, because it is the same gate.
|
|
1204
|
+
*/
|
|
1205
|
+
function refuseUnlessPerson(env, streams, gate) {
|
|
1206
|
+
const marker = agentMarker(env);
|
|
1207
|
+
if (marker) throw new CommsError("LOOSENING_REFUSED", gate.refusedToAgent, {
|
|
1208
|
+
hint: `Ask the user to run \`${gate.command}\` in their own terminal.`,
|
|
1209
|
+
details: { marker }
|
|
1210
|
+
});
|
|
1211
|
+
const prompting = {};
|
|
1212
|
+
if (gate.json !== void 0) prompting.json = gate.json;
|
|
1213
|
+
if (gate.noInput !== void 0) prompting.noInput = gate.noInput;
|
|
1214
|
+
if (!canPrompt(env, streams, prompting)) throw new CommsError("LOOSENING_REFUSED", gate.refusedWithoutTerminal, { hint: `Run \`${gate.command}\` directly in a terminal.` });
|
|
1215
|
+
}
|
|
953
1216
|
/**
|
|
954
1217
|
* One table of dangerous characters, shared by the inbound sanitiser (which strips them from sender-controlled text)
|
|
955
1218
|
* and the preview renderer (which makes them visible), so the two can never disagree.
|
|
@@ -1951,40 +2214,6 @@ function canonicalLoosening(loosening) {
|
|
|
1951
2214
|
function sameLoosening(a, b) {
|
|
1952
2215
|
return canonicalLoosening(a) === canonicalLoosening(b);
|
|
1953
2216
|
}
|
|
1954
|
-
/** Crockford-style alphabet without I, L, O and U, so ids and challenges survive being read aloud or retyped. */
|
|
1955
|
-
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
1956
|
-
function randomString(length) {
|
|
1957
|
-
const bytes = randomBytes(length);
|
|
1958
|
-
let out = "";
|
|
1959
|
-
for (let i = 0; i < length; i += 1) out += ALPHABET[(bytes[i] ?? 0) & 31];
|
|
1960
|
-
return out;
|
|
1961
|
-
}
|
|
1962
|
-
/** `ap_` + 26 characters: 130 random bits. Validated before an id ever names a file. */
|
|
1963
|
-
const APPROVAL_ID_PATTERN = /^ap_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
1964
|
-
const PLAN_TOKEN_PATTERN = /^pl_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
1965
|
-
function newApprovalId() {
|
|
1966
|
-
return `ap_${randomString(26)}`;
|
|
1967
|
-
}
|
|
1968
|
-
function newPlanToken() {
|
|
1969
|
-
return `pl_${randomString(26)}`;
|
|
1970
|
-
}
|
|
1971
|
-
const CHALLENGE_LETTERS = "ABCDEFGHJKMNPQRSTVWXYZ";
|
|
1972
|
-
/** A short challenge a human types back to approve: letters only, no look-alikes. */
|
|
1973
|
-
function newChallenge(length = 4) {
|
|
1974
|
-
let out = "";
|
|
1975
|
-
for (let i = 0; i < length; i += 1) out += CHALLENGE_LETTERS[randomInt(22)];
|
|
1976
|
-
return out;
|
|
1977
|
-
}
|
|
1978
|
-
/** Challenges are stored only as hashes, so no listing or tool result can reveal one. */
|
|
1979
|
-
function hashChallenge(challenge) {
|
|
1980
|
-
return createHash("sha256").update(challenge.trim().toUpperCase()).digest("hex");
|
|
1981
|
-
}
|
|
1982
|
-
/** Case-insensitive, constant-time comparison of an answer with a stored challenge hash. */
|
|
1983
|
-
function challengeMatches(answer, storedHash) {
|
|
1984
|
-
const a = Buffer.from(hashChallenge(answer), "hex");
|
|
1985
|
-
const b = Buffer.from(storedHash, "hex");
|
|
1986
|
-
return a.length === b.length && timingSafeEqual(a, b);
|
|
1987
|
-
}
|
|
1988
2217
|
/**
|
|
1989
2218
|
* The digest a download's question is bound to: the account, the download, the request, the files it listed and the
|
|
1990
2219
|
* names it showed them under.
|
|
@@ -2135,6 +2364,32 @@ function refusalFor(record) {
|
|
|
2135
2364
|
const kind = approvalKind(record);
|
|
2136
2365
|
return kind === "change" ? refuseChange : kind === "download" ? refuseDownload : refuse;
|
|
2137
2366
|
}
|
|
2367
|
+
/**
|
|
2368
|
+
* A claim whose caller was cancelled before it changed anything (`ClaimOptions.signal`).
|
|
2369
|
+
*
|
|
2370
|
+
* `USAGE` and `cancelled:`, the words every cancellation in this repository is reported in, with `details.reason`
|
|
2371
|
+
* saying so for a caller that branches on it — and in the record's own kind: a question is not an approval, and
|
|
2372
|
+
* nothing is sent by answering one. The record is as it was, and the hint says so.
|
|
2373
|
+
*/
|
|
2374
|
+
function cancelledClaim(record) {
|
|
2375
|
+
const kind = approvalKind(record);
|
|
2376
|
+
if (kind === "download") return new CommsError("USAGE", "cancelled: nothing was saved", {
|
|
2377
|
+
hint: "The question was not used: the same call, made again with the same answer, can still use it until it expires.",
|
|
2378
|
+
details: {
|
|
2379
|
+
choiceId: record.approvalId,
|
|
2380
|
+
state: record.state,
|
|
2381
|
+
reason: "cancelled"
|
|
2382
|
+
}
|
|
2383
|
+
});
|
|
2384
|
+
return new CommsError("USAGE", `cancelled: ${kind === "change" ? "nothing was changed" : "nothing was sent"}`, {
|
|
2385
|
+
hint: "The approval was not used: the same call, made again, can still use it until it expires.",
|
|
2386
|
+
details: {
|
|
2387
|
+
approvalId: record.approvalId,
|
|
2388
|
+
state: record.state,
|
|
2389
|
+
reason: "cancelled"
|
|
2390
|
+
}
|
|
2391
|
+
});
|
|
2392
|
+
}
|
|
2138
2393
|
var ApprovalStore = class {
|
|
2139
2394
|
directory;
|
|
2140
2395
|
#now;
|
|
@@ -2247,19 +2502,23 @@ var ApprovalStore = class {
|
|
|
2247
2502
|
* Nothing written, because the caller made a mistake about an approval that may be perfectly good: voiding a post
|
|
2248
2503
|
* somebody is about to approve, because an agent passed its id to a change, would punish the wrong party.
|
|
2249
2504
|
*/
|
|
2250
|
-
#requireKind(record, kind) {
|
|
2505
|
+
#requireKind(record, kind, platform) {
|
|
2251
2506
|
const actual = approvalKind(record);
|
|
2252
2507
|
if (actual === kind) return;
|
|
2253
2508
|
const id = record.approvalId;
|
|
2254
2509
|
if (actual === "download") throw (kind === "change" ? refuseChange : refuse)("USAGE", `${id} is a question about where to save files, not ${kind === "send" ? "a send" : "a configuration change"}`, record, DOWNLOAD_ANSWER_HINT);
|
|
2255
2510
|
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.");
|
|
2256
|
-
throw kind === "send" ? refuse("USAGE", `approval ${id} is for a configuration change, not a send`, record, `A person approves it with
|
|
2511
|
+
throw kind === "send" ? refuse("USAGE", `approval ${id} is for a configuration change, not a send`, record, `A person approves it with ${inlineCommand(shellCommand([
|
|
2512
|
+
"agentcomms",
|
|
2513
|
+
"approve",
|
|
2514
|
+
id
|
|
2515
|
+
], 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.`);
|
|
2257
2516
|
}
|
|
2258
2517
|
/** Issues a new challenge to show a human; only its hash is kept. */
|
|
2259
|
-
async issueChallenge(approvalId, kind = "send") {
|
|
2518
|
+
async issueChallenge(approvalId, kind = "send", platform = process.platform) {
|
|
2260
2519
|
const challenge = newChallenge();
|
|
2261
2520
|
await this.#transition(approvalId, (current) => {
|
|
2262
|
-
this.#requireKind(current, kind);
|
|
2521
|
+
this.#requireKind(current, kind, platform);
|
|
2263
2522
|
if (current.state !== "pending") throw this.#stateError(current);
|
|
2264
2523
|
return {
|
|
2265
2524
|
...current,
|
|
@@ -2276,10 +2535,10 @@ var ApprovalStore = class {
|
|
|
2276
2535
|
* A change is approved the same way, with `kind: 'change'` and its digest standing in for the draft (see
|
|
2277
2536
|
* `createChange`), so a person's typed code means one thing whichever kind of approval it is typed for.
|
|
2278
2537
|
*/
|
|
2279
|
-
async approve(approvalId, via, live, answer, kind = "send") {
|
|
2538
|
+
async approve(approvalId, via, live, answer, kind = "send", platform = process.platform) {
|
|
2280
2539
|
let failure = null;
|
|
2281
2540
|
const result = await this.#transition(approvalId, (current) => {
|
|
2282
|
-
this.#requireKind(current, kind);
|
|
2541
|
+
this.#requireKind(current, kind, platform);
|
|
2283
2542
|
if (current.state !== "pending") throw this.#stateError(current);
|
|
2284
2543
|
if (!current.challengeHash) throw refusalFor(current)("APPROVAL_REQUIRED", "no challenge was issued for this approval", current);
|
|
2285
2544
|
if (live.draftMessageId !== current.draftMessageId || live.digest !== current.digest) {
|
|
@@ -2336,8 +2595,9 @@ var ApprovalStore = class {
|
|
|
2336
2595
|
async claimForSend(approvalId, live, options = {}) {
|
|
2337
2596
|
let failure = null;
|
|
2338
2597
|
const result = await this.#transition(approvalId, (current) => {
|
|
2339
|
-
this.#requireKind(current, "send");
|
|
2598
|
+
this.#requireKind(current, "send", options.platform ?? process.platform);
|
|
2340
2599
|
if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
|
|
2600
|
+
if (options.signal?.aborted) throw cancelledClaim(current);
|
|
2341
2601
|
const voidWith = (code, reason) => {
|
|
2342
2602
|
failure = {
|
|
2343
2603
|
code,
|
|
@@ -2446,8 +2706,9 @@ var ApprovalStore = class {
|
|
|
2446
2706
|
const digest = changeDigest(live.change);
|
|
2447
2707
|
let failure = null;
|
|
2448
2708
|
const result = await this.#transition(approvalId, (current) => {
|
|
2449
|
-
this.#requireKind(current, "change");
|
|
2709
|
+
this.#requireKind(current, "change", options.platform ?? process.platform);
|
|
2450
2710
|
if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
|
|
2711
|
+
if (options.signal?.aborted) throw cancelledClaim(current);
|
|
2451
2712
|
const voidWith = (reason) => {
|
|
2452
2713
|
failure = {
|
|
2453
2714
|
code: "APPROVAL_VOID",
|
|
@@ -2461,7 +2722,11 @@ var ApprovalStore = class {
|
|
|
2461
2722
|
};
|
|
2462
2723
|
if (digest !== current.digest) return voidWith(current.change ? changeDrift(current.change, live.change) : "the change is not the one that was approved");
|
|
2463
2724
|
if (stricterPolicy(live.policy, current.requiredPolicy) !== "chat") {
|
|
2464
|
-
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
|
|
2725
|
+
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([
|
|
2726
|
+
"agentcomms",
|
|
2727
|
+
"approve",
|
|
2728
|
+
approvalId
|
|
2729
|
+
], options.platform ?? process.platform))} in their own terminal, then try again with the same approval.`);
|
|
2465
2730
|
if (current.approvedVia !== "terminal") return voidWith("the change policy is confirm, and this was not approved at a terminal");
|
|
2466
2731
|
}
|
|
2467
2732
|
return {
|
|
@@ -2538,13 +2803,13 @@ var ApprovalStore = class {
|
|
|
2538
2803
|
*
|
|
2539
2804
|
* Only a pending question can be answered, and only once: a second answer is refused, not taken over the first.
|
|
2540
2805
|
*/
|
|
2541
|
-
async answerDownload(approvalId, via, answer) {
|
|
2806
|
+
async answerDownload(approvalId, via, answer, platform = process.platform) {
|
|
2542
2807
|
const recorded = answer.choice === "other" ? {
|
|
2543
2808
|
choice: "other",
|
|
2544
2809
|
folder: answer.folder
|
|
2545
2810
|
} : { choice: answer.choice };
|
|
2546
2811
|
return await this.#transition(approvalId, (current) => {
|
|
2547
|
-
this.#requireKind(current, "download");
|
|
2812
|
+
this.#requireKind(current, "download", platform);
|
|
2548
2813
|
if (current.state === "approved") throw refuseDownload("APPROVAL_VOID", "the question was answered already, and it is answered once", current);
|
|
2549
2814
|
if (current.state !== "pending") throw this.#stateError(current);
|
|
2550
2815
|
if (!current.download || downloadDigest(current.download) !== current.digest) throw refuseDownload("APPROVAL_VOID", "the question does not describe the download it is bound to", current);
|
|
@@ -2575,13 +2840,17 @@ var ApprovalStore = class {
|
|
|
2575
2840
|
* gave it in the conversation. Under `confirm`, only a question the person answered at a terminal or in a trusted
|
|
2576
2841
|
* form can be claimed; a pending one is refused, and left as it is, so they can still answer it. One used, voided or
|
|
2577
2842
|
* expired is refused as an approval in that state is.
|
|
2843
|
+
*
|
|
2844
|
+
* `options.signal` is the download's cancellation, asked under the lock as a send's is (`ClaimOptions.signal`): a
|
|
2845
|
+
* download cancelled while this waited saves nothing and leaves the question open, the answer still unused.
|
|
2578
2846
|
*/
|
|
2579
2847
|
async claimForDownload(approvalId, live, options = {}) {
|
|
2580
2848
|
const digest = downloadDigest(live);
|
|
2581
2849
|
let failure = null;
|
|
2582
2850
|
const result = await this.#transition(approvalId, (current) => {
|
|
2583
|
-
this.#requireKind(current, "download");
|
|
2851
|
+
this.#requireKind(current, "download", options.platform ?? process.platform);
|
|
2584
2852
|
if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
|
|
2853
|
+
if (options.signal?.aborted) throw cancelledClaim(current);
|
|
2585
2854
|
const voidWith = (reason) => {
|
|
2586
2855
|
failure = {
|
|
2587
2856
|
code: "APPROVAL_VOID",
|
|
@@ -3923,13 +4192,13 @@ function snippetFor(client, name, entry) {
|
|
|
3923
4192
|
* Project-scoped entries are not looked at: every write here targets user scope, so they are not what would be
|
|
3924
4193
|
* replaced.
|
|
3925
4194
|
*/
|
|
3926
|
-
function claimName(product, options, name, existing) {
|
|
4195
|
+
function claimName(product, options, name, existing, platform) {
|
|
3927
4196
|
const taken = existing.filter((server) => server.client === options.client && server.name === name && server.scope !== "project");
|
|
3928
4197
|
const foreign = taken.find((server) => !isProductServer(server, product));
|
|
3929
4198
|
if (foreign) {
|
|
3930
4199
|
const what = foreign.packageName ?? (foreign.url ? displayUrl(foreign.url) : void 0) ?? (foreign.command || "something else");
|
|
3931
4200
|
const other = name === product.binary ? `${product.binary}-${product.defaultServerName}` : product.binary;
|
|
3932
|
-
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:
|
|
4201
|
+
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.` });
|
|
3933
4202
|
}
|
|
3934
4203
|
if (taken.length > 0 && !options.force) {
|
|
3935
4204
|
for (const pin of PIN_OPTIONS) {
|
|
@@ -3937,14 +4206,14 @@ function claimName(product, options, name, existing) {
|
|
|
3937
4206
|
const served = taken.map((server) => product.narrowingOf(server.args)[pin]).find(Boolean);
|
|
3938
4207
|
if (!wanted || !served || served === wanted) continue;
|
|
3939
4208
|
const second = `${product.defaultServerName}-${wanted.split("/")[0]}`;
|
|
3940
|
-
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:
|
|
4209
|
+
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))}.` });
|
|
3941
4210
|
}
|
|
3942
4211
|
const npx = taken.some((server) => server.args.some((argument) => argument.startsWith(`${product.npxPackage}@`)));
|
|
3943
4212
|
const again = {
|
|
3944
4213
|
...keepNarrowing(product, options, taken).options,
|
|
3945
4214
|
launcher: options.launcher ?? (npx ? "npx" : void 0)
|
|
3946
4215
|
};
|
|
3947
|
-
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:
|
|
4216
|
+
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))}.` });
|
|
3948
4217
|
}
|
|
3949
4218
|
return taken;
|
|
3950
4219
|
}
|
|
@@ -3987,8 +4256,11 @@ function keepNarrowing(product, options, previous) {
|
|
|
3987
4256
|
* workspace on the machine; for Gmail it dropped `--inbox` and `--read-only` the same way. Doctor's repair already
|
|
3988
4257
|
* rebuilds these flags for exactly that reason. The product's `serverArgs` are the install command's own flags for
|
|
3989
4258
|
* the pin and the narrowing, so they are repeated as they are.
|
|
4259
|
+
*
|
|
4260
|
+
* A pin kept from the entry being replaced was read from the client's file, which may hold anything, so on Windows the
|
|
4261
|
+
* command can come back with no line to paste; every hint gives it with `inlineCommand`, which shows it as words then.
|
|
3990
4262
|
*/
|
|
3991
|
-
function installCommand$1(product, options, name, extra
|
|
4263
|
+
function installCommand$1(product, options, name, extra, platform) {
|
|
3992
4264
|
const words = [
|
|
3993
4265
|
product.binary,
|
|
3994
4266
|
"mcp",
|
|
@@ -4000,7 +4272,7 @@ function installCommand$1(product, options, name, extra = []) {
|
|
|
4000
4272
|
words.push(...product.serverArgs(options));
|
|
4001
4273
|
if (options.launcher && options.launcher !== "managed") words.push("--launcher", options.launcher);
|
|
4002
4274
|
words.push(...extra);
|
|
4003
|
-
return words
|
|
4275
|
+
return shellCommand(words, platform);
|
|
4004
4276
|
}
|
|
4005
4277
|
/**
|
|
4006
4278
|
* A command's exit status and what it printed, kept here and never shown: codex prints an entry's env. Started as
|
|
@@ -4043,8 +4315,13 @@ function capture$1(env, command, args) {
|
|
|
4043
4315
|
* or a failure that is not "no such server", stops the install: not knowing is not the same as nothing there.
|
|
4044
4316
|
* Nothing codex prints is repeated, because its answer carries the entry's env.
|
|
4045
4317
|
*/
|
|
4046
|
-
async function codexRegistration(env, binary, name, path) {
|
|
4047
|
-
const unknown = () => new CommsError("CONFIG", `codex would not say what it has registered as "${name}", so nothing was written`, { hint: `Look with
|
|
4318
|
+
async function codexRegistration(env, binary, name, path, platform) {
|
|
4319
|
+
const unknown = () => new CommsError("CONFIG", `codex would not say what it has registered as "${name}", so nothing was written`, { hint: `Look with ${inlineCommand(shellCommand([
|
|
4320
|
+
"codex",
|
|
4321
|
+
"mcp",
|
|
4322
|
+
"get",
|
|
4323
|
+
name
|
|
4324
|
+
], platform))}. If it is not an older copy of this server, choose another --name; \`--print\` shows the entry to add by hand.` });
|
|
4048
4325
|
let answer;
|
|
4049
4326
|
try {
|
|
4050
4327
|
answer = await capture$1(env, binary, [
|
|
@@ -4147,10 +4424,10 @@ async function preflightInstall(context, product, options) {
|
|
|
4147
4424
|
const blind = scan.unreadable.find((file) => file.path === own);
|
|
4148
4425
|
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." });
|
|
4149
4426
|
}
|
|
4150
|
-
let previous = writes ? claimName(product, options, name, existing) : [];
|
|
4427
|
+
let previous = writes ? claimName(product, options, name, existing, context.platform) : [];
|
|
4151
4428
|
if (writes && binary && options.client === "codex") {
|
|
4152
|
-
const reported = await codexRegistration(context.env, binary, name, configPath ?? "codex");
|
|
4153
|
-
if (reported) previous = claimName(product, options, name, [reported]);
|
|
4429
|
+
const reported = await codexRegistration(context.env, binary, name, configPath ?? "codex", context.platform);
|
|
4430
|
+
if (reported) previous = claimName(product, options, name, [reported], context.platform);
|
|
4154
4431
|
}
|
|
4155
4432
|
const { options: effective, kept } = keepNarrowing(product, options, previous);
|
|
4156
4433
|
const node = await resolveNode(context.env);
|
|
@@ -4275,9 +4552,21 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
4275
4552
|
};
|
|
4276
4553
|
const existing = scan.servers;
|
|
4277
4554
|
const { cliName, binary, own, configPath, writes } = target;
|
|
4278
|
-
const warnings = (product.warnAbout?.(existing.filter((server) => server.client === options.client)) ?? []).filter(Boolean);
|
|
4555
|
+
const warnings = (product.warnAbout?.(existing.filter((server) => server.client === options.client), context.platform) ?? []).filter(Boolean);
|
|
4279
4556
|
if (kept.length > 0) {
|
|
4280
|
-
const removal = options.client === "claude-code" ?
|
|
4557
|
+
const removal = options.client === "claude-code" ? inlineCommand(shellCommand([
|
|
4558
|
+
"claude",
|
|
4559
|
+
"mcp",
|
|
4560
|
+
"remove",
|
|
4561
|
+
name,
|
|
4562
|
+
"--scope",
|
|
4563
|
+
"user"
|
|
4564
|
+
], context.platform)) : options.client === "codex" ? inlineCommand(shellCommand([
|
|
4565
|
+
"codex",
|
|
4566
|
+
"mcp",
|
|
4567
|
+
"remove",
|
|
4568
|
+
name
|
|
4569
|
+
], context.platform)) : `delete "${name}" from ${configPath}`;
|
|
4281
4570
|
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.`);
|
|
4282
4571
|
}
|
|
4283
4572
|
if (!writes) {
|
|
@@ -4372,7 +4661,7 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
4372
4661
|
restored = false;
|
|
4373
4662
|
}
|
|
4374
4663
|
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}"`, {
|
|
4375
|
-
hint: restored ? "Check the client is not running, then try again." : `Re-register it with
|
|
4664
|
+
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}.`,
|
|
4376
4665
|
cause: error
|
|
4377
4666
|
});
|
|
4378
4667
|
}
|
|
@@ -4381,7 +4670,17 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
4381
4670
|
} catch (error) {
|
|
4382
4671
|
const message = error instanceof CommsError ? error.message : String(error);
|
|
4383
4672
|
if (/already exists/i.test(message)) throw new CommsError("CONFIG", `${cliName} already has an MCP server called "${name}", somewhere this could not read`, {
|
|
4384
|
-
hint: `Look at it with
|
|
4673
|
+
hint: `Look at it with ${inlineCommand(shellCommand([
|
|
4674
|
+
cliName,
|
|
4675
|
+
"mcp",
|
|
4676
|
+
"get",
|
|
4677
|
+
name
|
|
4678
|
+
], context.platform))}. If it is an older ${product.binary}, remove it with ${inlineCommand(shellCommand([
|
|
4679
|
+
cliName,
|
|
4680
|
+
"mcp",
|
|
4681
|
+
"remove",
|
|
4682
|
+
name
|
|
4683
|
+
], context.platform))} and run this again; if not, choose another --name.`,
|
|
4385
4684
|
cause: error
|
|
4386
4685
|
});
|
|
4387
4686
|
throw error;
|
|
@@ -4717,19 +5016,32 @@ function rivalPatterns(packages) {
|
|
|
4717
5016
|
function escapeRegExp(text) {
|
|
4718
5017
|
return text.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
|
|
4719
5018
|
}
|
|
4720
|
-
|
|
5019
|
+
/**
|
|
5020
|
+
* The command that removes a registered server, in its client's own terms — the name as the client's file has it,
|
|
5021
|
+
* which may be anything, so on Windows it can have no line to paste, and is shown as its words, to be typed
|
|
5022
|
+
* (`shellCommand`). Never as a line with the name left out: that removed whatever entry had the stand-in's name.
|
|
5023
|
+
*/
|
|
5024
|
+
function removalCommand(client, name, platform) {
|
|
5025
|
+
return commandText(shellCommand([
|
|
5026
|
+
client,
|
|
5027
|
+
"mcp",
|
|
5028
|
+
"remove",
|
|
5029
|
+
name
|
|
5030
|
+
], platform));
|
|
5031
|
+
}
|
|
5032
|
+
function gmailRemoval({ client, name: server, path, scope }, platform) {
|
|
4721
5033
|
if (scope === "project") return `remove "${server}" from the project entry in ${path} by hand`;
|
|
4722
5034
|
switch (client) {
|
|
4723
|
-
case "claude-code": return
|
|
4724
|
-
case "codex": return
|
|
5035
|
+
case "claude-code": return removalCommand("claude", server, platform);
|
|
5036
|
+
case "codex": return removalCommand("codex", server, platform);
|
|
4725
5037
|
default: return `remove the "${server}" entry from ${path}, then restart ${client}`;
|
|
4726
5038
|
}
|
|
4727
5039
|
}
|
|
4728
5040
|
/**
|
|
4729
5041
|
* Registered servers that are one of `packages` — another server for the same service whose send tools no approval
|
|
4730
|
-
* step gates — each with why it matters and how to remove it.
|
|
5042
|
+
* step gates — each with why it matters and how to remove it. `platform` is the shell the removal is quoted for.
|
|
4731
5043
|
*/
|
|
4732
|
-
function findRivalPackageServers(servers, packages) {
|
|
5044
|
+
function findRivalPackageServers(servers, packages, platform = process.platform) {
|
|
4733
5045
|
const patterns = rivalPatterns(packages);
|
|
4734
5046
|
const findings = [];
|
|
4735
5047
|
for (const server of servers) {
|
|
@@ -4740,14 +5052,14 @@ function findRivalPackageServers(servers, packages) {
|
|
|
4740
5052
|
...server,
|
|
4741
5053
|
packageName: known.name,
|
|
4742
5054
|
reason: `${known.name} exposes send tools that no approval step gates`,
|
|
4743
|
-
removal: gmailRemoval(server)
|
|
5055
|
+
removal: gmailRemoval(server, platform)
|
|
4744
5056
|
});
|
|
4745
5057
|
}
|
|
4746
5058
|
return findings;
|
|
4747
5059
|
}
|
|
4748
5060
|
/** What registering a server says about the rival packages its manifest names: one line each. */
|
|
4749
|
-
function rivalPackageWarnings(servers, packages) {
|
|
4750
|
-
return findRivalPackageServers(servers, packages).map((finding) => `${finding.packageName} is registered with ${finding.client} as "${finding.name}": ${finding.reason}. Remove it: ${finding.removal}`);
|
|
5061
|
+
function rivalPackageWarnings(servers, packages, platform = process.platform) {
|
|
5062
|
+
return findRivalPackageServers(servers, packages, platform).map((finding) => `${finding.packageName} is registered with ${finding.client} as "${finding.name}": ${finding.reason}. Remove it: ${finding.removal}`);
|
|
4751
5063
|
}
|
|
4752
5064
|
/**
|
|
4753
5065
|
* Registered servers that name the service's `word` anywhere — their name, command, arguments or URL — and are not
|
|
@@ -4833,7 +5145,7 @@ function serverFactsOf({ packageName, manifest }) {
|
|
|
4833
5145
|
if (rivals === void 0) return facts;
|
|
4834
5146
|
return {
|
|
4835
5147
|
...facts,
|
|
4836
|
-
warnAbout: (servers) => [...rivals.packages ? rivalPackageWarnings(servers, rivals.packages) : [], ...rivals.word !== void 0 && rivals.can !== void 0 ? rivalWordWarnings(servers, rivals.word, rivals.can, facts) : []]
|
|
5148
|
+
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) : []]
|
|
4837
5149
|
};
|
|
4838
5150
|
}
|
|
4839
5151
|
const CHANNEL_SERVERS = Object.freeze(Object.fromEntries(CHANNEL_SNAPSHOT.map((entry) => [entry.manifest.channel, serverFactsOf(entry)])));
|
|
@@ -4866,107 +5178,6 @@ function requireChannelManifest(channel) {
|
|
|
4866
5178
|
if (manifest === void 0) throw notAChannel(channel);
|
|
4867
5179
|
return manifest;
|
|
4868
5180
|
}
|
|
4869
|
-
function okEnvelope(data) {
|
|
4870
|
-
return {
|
|
4871
|
-
ok: true,
|
|
4872
|
-
schemaVersion: 1,
|
|
4873
|
-
data
|
|
4874
|
-
};
|
|
4875
|
-
}
|
|
4876
|
-
function errorEnvelope(error) {
|
|
4877
|
-
const body = {
|
|
4878
|
-
code: error.code,
|
|
4879
|
-
message: error.message
|
|
4880
|
-
};
|
|
4881
|
-
if (error.hint !== void 0) body.hint = error.hint;
|
|
4882
|
-
if (error.details !== void 0) body.details = error.details;
|
|
4883
|
-
return {
|
|
4884
|
-
ok: false,
|
|
4885
|
-
schemaVersion: 1,
|
|
4886
|
-
error: body
|
|
4887
|
-
};
|
|
4888
|
-
}
|
|
4889
|
-
const defaultStreams = {
|
|
4890
|
-
stdout: process.stdout,
|
|
4891
|
-
stderr: process.stderr,
|
|
4892
|
-
stdin: process.stdin
|
|
4893
|
-
};
|
|
4894
|
-
function colorEnabled(env, stream, flag) {
|
|
4895
|
-
if (flag === false) return false;
|
|
4896
|
-
if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
|
|
4897
|
-
if (env.TERM === "dumb") return false;
|
|
4898
|
-
if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
|
|
4899
|
-
return Boolean(stream.isTTY);
|
|
4900
|
-
}
|
|
4901
|
-
/** True when a human could answer a prompt: both ends are terminals, no --json/--no-input, not CI. */
|
|
4902
|
-
function canPrompt(env, streams, options) {
|
|
4903
|
-
if (options.json || options.noInput) return false;
|
|
4904
|
-
if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
|
|
4905
|
-
return Boolean(streams.stdin?.isTTY && streams.stdout.isTTY);
|
|
4906
|
-
}
|
|
4907
|
-
/** Environment variables well-known coding agents set. Used only as a speed bump, never as a security boundary. */
|
|
4908
|
-
const AGENT_MARKERS = [
|
|
4909
|
-
"CLAUDECODE",
|
|
4910
|
-
"CLAUDE_CODE_ENTRYPOINT",
|
|
4911
|
-
"CODEX_SANDBOX",
|
|
4912
|
-
"CODEX_HOME",
|
|
4913
|
-
"CURSOR_AGENT",
|
|
4914
|
-
"GEMINI_CLI",
|
|
4915
|
-
"AGENT_COMMS_AGENT"
|
|
4916
|
-
];
|
|
4917
|
-
function agentMarker(env) {
|
|
4918
|
-
for (const name of AGENT_MARKERS) if (env[name] !== void 0 && env[name] !== "") return name;
|
|
4919
|
-
return null;
|
|
4920
|
-
}
|
|
4921
|
-
function paint(color, format, text) {
|
|
4922
|
-
return color ? styleText(format, text, { validateStream: false }) : text;
|
|
4923
|
-
}
|
|
4924
|
-
/** Writes a successful result: the envelope with --json, otherwise the human rendering. */
|
|
4925
|
-
function writeResult(data, options, human, streams = defaultStreams) {
|
|
4926
|
-
if (options.json) {
|
|
4927
|
-
streams.stdout.write(`${JSON.stringify(okEnvelope(data))}\n`);
|
|
4928
|
-
return;
|
|
4929
|
-
}
|
|
4930
|
-
const text = human(data);
|
|
4931
|
-
if (text) streams.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
4932
|
-
}
|
|
4933
|
-
/** Writes an error and returns the exit code to use. */
|
|
4934
|
-
function writeError(error, options, streams = defaultStreams) {
|
|
4935
|
-
const commsError = toCommsError(error);
|
|
4936
|
-
if (options.json) streams.stdout.write(`${JSON.stringify(errorEnvelope(commsError))}\n`);
|
|
4937
|
-
else {
|
|
4938
|
-
streams.stderr.write(`${paint(options.color, "red", "error")}: ${commsError.message}\n`);
|
|
4939
|
-
if (commsError.hint) streams.stderr.write(`${paint(options.color, "dim", "hint")}: ${commsError.hint}\n`);
|
|
4940
|
-
}
|
|
4941
|
-
return commsError.exitCode;
|
|
4942
|
-
}
|
|
4943
|
-
/** Runs a command body and exits with the documented code. Unexpected errors keep only their message. */
|
|
4944
|
-
async function runCommand(options, body, streams = defaultStreams) {
|
|
4945
|
-
try {
|
|
4946
|
-
await body();
|
|
4947
|
-
return EXIT_CODES.OK;
|
|
4948
|
-
} catch (error) {
|
|
4949
|
-
return writeError(error, options, streams);
|
|
4950
|
-
}
|
|
4951
|
-
}
|
|
4952
|
-
/**
|
|
4953
|
-
* The first two steps of `requirePerson` — an agent is refused, then anything without a terminal — for a command
|
|
4954
|
-
* whose code is not its own to make up.
|
|
4955
|
-
*
|
|
4956
|
-
* `agentcomms approve` asks for a code the approval store issued and will check, so it cannot use `askChallenge`;
|
|
4957
|
-
* but it refuses in exactly the same order and words, because it is the same gate.
|
|
4958
|
-
*/
|
|
4959
|
-
function refuseUnlessPerson(env, streams, gate) {
|
|
4960
|
-
const marker = agentMarker(env);
|
|
4961
|
-
if (marker) throw new CommsError("LOOSENING_REFUSED", gate.refusedToAgent, {
|
|
4962
|
-
hint: `Ask the user to run \`${gate.command}\` in their own terminal.`,
|
|
4963
|
-
details: { marker }
|
|
4964
|
-
});
|
|
4965
|
-
const prompting = {};
|
|
4966
|
-
if (gate.json !== void 0) prompting.json = gate.json;
|
|
4967
|
-
if (gate.noInput !== void 0) prompting.noInput = gate.noInput;
|
|
4968
|
-
if (!canPrompt(env, streams, prompting)) throw new CommsError("LOOSENING_REFUSED", gate.refusedWithoutTerminal, { hint: `Run \`${gate.command}\` directly in a terminal.` });
|
|
4969
|
-
}
|
|
4970
5181
|
/**
|
|
4971
5182
|
* One renderer for every surface a send preview is shown on — the chat, an elicitation form, a terminal. Text the
|
|
4972
5183
|
* draft's author controls (body, subject, display names, file names, link text) must not be able to change how the
|
|
@@ -5025,9 +5236,9 @@ function renderPrune(result, color) {
|
|
|
5025
5236
|
if (lines.length === 0) lines.push(`No managed runtimes to remove in ${result.runtimeDir}.`);
|
|
5026
5237
|
return lines.join("\n");
|
|
5027
5238
|
}
|
|
5028
|
-
/** The
|
|
5029
|
-
function
|
|
5030
|
-
return
|
|
5239
|
+
/** The channel's fixed approval-command prefix with this generated id, rendered by the common shell rule. */
|
|
5240
|
+
function changeApprovalCommand(approveCommand, approvalId, platform = process.platform) {
|
|
5241
|
+
return shellCommand([...(approveCommand ?? "agentcomms approve").trim().split(/\s+/), approvalId], platform);
|
|
5031
5242
|
}
|
|
5032
5243
|
/**
|
|
5033
5244
|
* The change policy that governs a change, as `config` stands: the strictest of the policies over everything the change
|
|
@@ -5138,7 +5349,7 @@ async function prepareChange(core, request, options) {
|
|
|
5138
5349
|
change: binding
|
|
5139
5350
|
}),
|
|
5140
5351
|
expiresAt: record.expiresAt,
|
|
5141
|
-
next: nextStep(record.approvalId, policy,
|
|
5352
|
+
next: nextStep(record.approvalId, policy, options)
|
|
5142
5353
|
};
|
|
5143
5354
|
} catch (error) {
|
|
5144
5355
|
await auditRefusal(core, "change.prepare", error, {
|
|
@@ -5169,7 +5380,10 @@ async function claimChange(core, approvalId, expect, options) {
|
|
|
5169
5380
|
const record = await core.approvals.claimForChange(approvalId, {
|
|
5170
5381
|
change: binding,
|
|
5171
5382
|
policy
|
|
5172
|
-
}, {
|
|
5383
|
+
}, {
|
|
5384
|
+
pendingHint: `Ask the user to run ${inlineCommand(changeApprovalCommand(options.approveCommand, approvalId, options.platform))} in their own terminal, then try again with the same approval.`,
|
|
5385
|
+
platform: options.platform
|
|
5386
|
+
});
|
|
5173
5387
|
const decided = stricterPolicy(policy, record.requiredPolicy) === "chat" ? "chat" : "confirm";
|
|
5174
5388
|
await auditChange(core, {
|
|
5175
5389
|
operation: "change.claim",
|
|
@@ -5217,7 +5431,7 @@ async function beginChangeApproval(core, approvalId, options) {
|
|
|
5217
5431
|
let record;
|
|
5218
5432
|
try {
|
|
5219
5433
|
record = await changeRecord(core, approvalId);
|
|
5220
|
-
const challenge = await core.approvals.issueChallenge(approvalId, "change");
|
|
5434
|
+
const challenge = await core.approvals.issueChallenge(approvalId, "change", options.platform);
|
|
5221
5435
|
return {
|
|
5222
5436
|
approvalId,
|
|
5223
5437
|
preview: renderChangePreview(record),
|
|
@@ -5242,7 +5456,7 @@ async function finishChangeApproval(core, approvalId, answer, options) {
|
|
|
5242
5456
|
const approved = await core.approvals.approve(approvalId, "terminal", {
|
|
5243
5457
|
draftMessageId: digest,
|
|
5244
5458
|
digest
|
|
5245
|
-
}, answer, "change");
|
|
5459
|
+
}, answer, "change", options.platform);
|
|
5246
5460
|
await auditChange(core, {
|
|
5247
5461
|
operation: "change.approve",
|
|
5248
5462
|
outcome: "ok",
|
|
@@ -5298,8 +5512,8 @@ async function revokeChange(core, approvalId, reason, options) {
|
|
|
5298
5512
|
function changePolicyOf(record) {
|
|
5299
5513
|
return record.requiredPolicy === "chat" ? "chat" : "confirm";
|
|
5300
5514
|
}
|
|
5301
|
-
function nextStep(approvalId, policy,
|
|
5302
|
-
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
|
|
5515
|
+
function nextStep(approvalId, policy, options) {
|
|
5516
|
+
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.`;
|
|
5303
5517
|
}
|
|
5304
5518
|
/**
|
|
5305
5519
|
* One line per step of a change approval: who asked, from which surface, under which policy, and how it ended.
|
|
@@ -5420,21 +5634,25 @@ function renderChangePreview(record) {
|
|
|
5420
5634
|
* it on the second call, with its id.
|
|
5421
5635
|
*
|
|
5422
5636
|
* A change that loosens no setting and does nothing irreversible is applied directly: asking a person to agree to
|
|
5423
|
-
* something that needs no agreement teaches them to agree without reading. It refuses an approval id
|
|
5424
|
-
*
|
|
5425
|
-
* call claiming an approval through (design 2026-09-28 §2),
|
|
5426
|
-
* approval the store holds — one an agent had just had prepared for
|
|
5427
|
-
* or a cancellation made where the call itself was stopped.
|
|
5637
|
+
* something that needs no agreement teaches them to agree without reading. It refuses an approval id — in the change's
|
|
5638
|
+
* own words when it has them (`refuseApproval`) — as a report refuses one (`refuseApprovalWithoutChange`), rather than
|
|
5639
|
+
* applying and dropping it: the update check's stop lets a call claiming an approval through (design 2026-09-28 §2),
|
|
5640
|
+
* and one that is then never claimed would carry any approval the store holds — one an agent had just had prepared for
|
|
5641
|
+
* "not now", say — past the stop, to a tightening or a cancellation made where the call itself was stopped.
|
|
5428
5642
|
*/
|
|
5429
5643
|
async function gatedChange(core, change, options) {
|
|
5430
5644
|
const request = await change.plan(await core.config.load());
|
|
5431
5645
|
const loosens = classifyChange(request.before, request.after).loosened.length > 0;
|
|
5432
5646
|
const acts = (request.effects ?? []).length > 0;
|
|
5433
5647
|
if (!loosens && !acts) {
|
|
5434
|
-
if (options.approvalId)
|
|
5435
|
-
|
|
5436
|
-
|
|
5437
|
-
|
|
5648
|
+
if (options.approvalId) {
|
|
5649
|
+
const refusal = await change.refuseApproval?.(options.approvalId);
|
|
5650
|
+
if (refusal !== void 0) throw refusal;
|
|
5651
|
+
throw new CommsError("USAGE", "nothing was changed: as things stand this change needs no approval, so it takes none", {
|
|
5652
|
+
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.",
|
|
5653
|
+
details: { approvalId: options.approvalId }
|
|
5654
|
+
});
|
|
5655
|
+
}
|
|
5438
5656
|
return {
|
|
5439
5657
|
status: "applied",
|
|
5440
5658
|
result: await change.apply(void 0, request)
|
|
@@ -5444,12 +5662,14 @@ async function gatedChange(core, change, options) {
|
|
|
5444
5662
|
status: "approval-required",
|
|
5445
5663
|
prepared: await prepareChange(core, request, {
|
|
5446
5664
|
surface: options.surface,
|
|
5447
|
-
approveCommand: options.approveCommand
|
|
5665
|
+
approveCommand: options.approveCommand,
|
|
5666
|
+
platform: options.platform
|
|
5448
5667
|
})
|
|
5449
5668
|
};
|
|
5450
5669
|
const consent = await claimChange(core, options.approvalId, request, {
|
|
5451
5670
|
surface: options.surface,
|
|
5452
|
-
approveCommand: options.approveCommand
|
|
5671
|
+
approveCommand: options.approveCommand,
|
|
5672
|
+
platform: options.platform
|
|
5453
5673
|
});
|
|
5454
5674
|
return {
|
|
5455
5675
|
status: "applied",
|
|
@@ -5470,14 +5690,17 @@ async function gatedChangeAtTerminal(core, change, options) {
|
|
|
5470
5690
|
const first = await gatedChange(core, change, {
|
|
5471
5691
|
surface: "cli",
|
|
5472
5692
|
approvalId: options.approvalId,
|
|
5473
|
-
approveCommand
|
|
5693
|
+
approveCommand,
|
|
5694
|
+
platform: options.output.platform
|
|
5474
5695
|
});
|
|
5475
5696
|
if (first.status === "applied") return first.result;
|
|
5476
5697
|
const { prepared } = first;
|
|
5477
5698
|
if (!(agentMarker(options.env) === null && canPrompt(options.env, streams, { json: options.output.json === true }))) {
|
|
5478
5699
|
if (options.output.json !== true) streams.stdout.write(`${prepared.preview}\n\n`);
|
|
5700
|
+
const carrying = [options.approvalFlag ?? "--approval", prepared.approvalId];
|
|
5701
|
+
const { command } = options;
|
|
5479
5702
|
throw new CommsError("APPROVAL_PENDING", `this change needs approval first: ${prepared.summary}`, {
|
|
5480
|
-
hint: approvalHint(prepared, `${
|
|
5703
|
+
hint: approvalHint(prepared, typeof command === "string" ? `${command} ${carrying.join(" ")}` : withWords(command, ...carrying), approveCommand, options.output.platform),
|
|
5481
5704
|
details: {
|
|
5482
5705
|
approvalId: prepared.approvalId,
|
|
5483
5706
|
policy: prepared.policy,
|
|
@@ -5498,7 +5721,8 @@ async function gatedChangeAtTerminal(core, change, options) {
|
|
|
5498
5721
|
const second = await gatedChange(core, change, {
|
|
5499
5722
|
surface: "cli",
|
|
5500
5723
|
approvalId: prepared.approvalId,
|
|
5501
|
-
approveCommand
|
|
5724
|
+
approveCommand,
|
|
5725
|
+
platform: options.output.platform
|
|
5502
5726
|
});
|
|
5503
5727
|
if (second.status !== "applied") throw new CommsError("UNEXPECTED", "the approved change asked for approval again");
|
|
5504
5728
|
return second.result;
|
|
@@ -5510,8 +5734,9 @@ async function gatedChangeAtTerminal(core, change, options) {
|
|
|
5510
5734
|
* Shared with a command that reports a waiting change rather than failing on it — `agent-gmail setup`, which stops at
|
|
5511
5735
|
* its registration step and says why in its report — so the two say it in the same words.
|
|
5512
5736
|
*/
|
|
5513
|
-
function approvalHint(prepared, rerun, approveCommand) {
|
|
5514
|
-
|
|
5737
|
+
function approvalHint(prepared, rerun, approveCommand, platform = typeof rerun === "string" ? process.platform : rerun.platform) {
|
|
5738
|
+
const run = typeof rerun === "string" ? `\`${rerun}\`` : inlineCommand(rerun);
|
|
5739
|
+
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}.`;
|
|
5515
5740
|
}
|
|
5516
5741
|
/**
|
|
5517
5742
|
* Approves a configuration change at a terminal, the way a person approves a send: read the preview, type the code.
|
|
@@ -5531,7 +5756,11 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
|
|
|
5531
5756
|
refuseUnlessPerson(env, streams, {
|
|
5532
5757
|
refusedToAgent: "only a person can approve a change, not an agent",
|
|
5533
5758
|
refusedWithoutTerminal: "approving a change needs an interactive terminal",
|
|
5534
|
-
command:
|
|
5759
|
+
command: commandText(shellCommand([
|
|
5760
|
+
"agentcomms",
|
|
5761
|
+
"approve",
|
|
5762
|
+
approvalId
|
|
5763
|
+
], output.platform)),
|
|
5535
5764
|
color: output.color,
|
|
5536
5765
|
json: output.json
|
|
5537
5766
|
});
|
|
@@ -6048,7 +6277,7 @@ function isPrerelease(version) {
|
|
|
6048
6277
|
return VERSION_PATTERN.exec(version)?.[4] !== void 0;
|
|
6049
6278
|
}
|
|
6050
6279
|
/** The package version, read from package.json at build time. */
|
|
6051
|
-
const VERSION = "0.12.
|
|
6280
|
+
const VERSION = "0.12.3";
|
|
6052
6281
|
/**
|
|
6053
6282
|
* Registering, pruning and listing the MCP servers of every channel, from the core.
|
|
6054
6283
|
*
|
|
@@ -6306,7 +6535,7 @@ function serverPruneChange(core, env, request, own) {
|
|
|
6306
6535
|
};
|
|
6307
6536
|
const { packageName, version } = ownProduct(request.channel, own) ?? {
|
|
6308
6537
|
packageName: channelServer(request.channel).packageName,
|
|
6309
|
-
version: "0.12.
|
|
6538
|
+
version: "0.12.3"
|
|
6310
6539
|
};
|
|
6311
6540
|
const product = {
|
|
6312
6541
|
packageName,
|
|
@@ -6930,7 +7159,7 @@ async function askLine(streams, question) {
|
|
|
6930
7159
|
}
|
|
6931
7160
|
channelServer("core").packageName;
|
|
6932
7161
|
//#endregion
|
|
6933
|
-
//#region ../core/dist/tool-arguments-
|
|
7162
|
+
//#region ../core/dist/tool-arguments-BMgRZ6DZ.mjs
|
|
6934
7163
|
const APPLIED = Symbol.for("agentcomms.strictToolArguments");
|
|
6935
7164
|
const TARGET = "draft-2020-12";
|
|
6936
7165
|
/**
|
|
@@ -8351,13 +8580,17 @@ function notFound(config, name) {
|
|
|
8351
8580
|
* is not a store path this package would read, is refused rather than acted on: nothing here would know what it
|
|
8352
8581
|
* means, and the vocabulary is closed (design §5).
|
|
8353
8582
|
*/
|
|
8354
|
-
function requireAccount(config, name) {
|
|
8583
|
+
function requireAccount(config, name, platform = process.platform) {
|
|
8355
8584
|
if (name === void 0 || name === "") throw new CommsError("USAGE", "which WhatsApp account? there is no default", { hint: "Pass the account, as `organisation/whatsapp`." });
|
|
8356
8585
|
requireNamedConfig(config);
|
|
8357
8586
|
const { account } = resolveName(config, "account", name, notFound(config, name));
|
|
8358
8587
|
if (account.platform !== "whatsapp") throw notFound(config, name)();
|
|
8359
8588
|
const mode = account.mode ?? account.tier;
|
|
8360
|
-
if (mode !== "read") throw new CommsError("CONFIG", `"${name}" is recorded in mode "${String(mode)}", and WhatsApp accounts only read`, { hint: `Nothing was read. Remove it with
|
|
8589
|
+
if (mode !== "read") throw new CommsError("CONFIG", `"${name}" is recorded in mode "${String(mode)}", and WhatsApp accounts only read`, { hint: `Nothing was read. Remove it with ${inlineCommand(shellCommand([
|
|
8590
|
+
"agent-whatsapp",
|
|
8591
|
+
"remove",
|
|
8592
|
+
name
|
|
8593
|
+
], platform))} and add it again.` });
|
|
8361
8594
|
const source = account.source;
|
|
8362
8595
|
if (source !== void 0 && (typeof source !== "string" || basename(source) !== "ChatStorage.sqlite")) throw new CommsError("CONFIG", `"${name}" names a store this package does not read`, { hint: `Only a file called ${STORE_FILE} is read. Remove the account and add it again.` });
|
|
8363
8596
|
return {
|
|
@@ -9009,13 +9242,18 @@ var WhatsAppIndex = class WhatsAppIndex {
|
|
|
9009
9242
|
this.#db = db;
|
|
9010
9243
|
}
|
|
9011
9244
|
/** Every read opens the index through here, and cannot without the account's lists. */
|
|
9012
|
-
static async open(directory, accountName, visibility) {
|
|
9245
|
+
static async open(directory, accountName, visibility, platform = process.platform) {
|
|
9013
9246
|
const path = join(directory, INDEX_FILE);
|
|
9014
9247
|
try {
|
|
9015
9248
|
await stat(path);
|
|
9016
9249
|
} catch {
|
|
9017
9250
|
throw new CommsError("NOT_FOUND", `"${accountName}" has not been synced yet, so there is nothing to read`, {
|
|
9018
|
-
hint: `Run
|
|
9251
|
+
hint: `Run ${inlineCommand(shellCommand([
|
|
9252
|
+
"agent-whatsapp",
|
|
9253
|
+
"sync",
|
|
9254
|
+
"--account",
|
|
9255
|
+
accountName
|
|
9256
|
+
], platform))} (or the whatsapp_sync tool) first.`,
|
|
9019
9257
|
details: { reason: "NOT_SYNCED" }
|
|
9020
9258
|
});
|
|
9021
9259
|
}
|
|
@@ -9023,7 +9261,12 @@ var WhatsAppIndex = class WhatsAppIndex {
|
|
|
9023
9261
|
const db = await openDatabase(path, { readOnly: true });
|
|
9024
9262
|
if (db.prepare("SELECT value FROM meta WHERE key = 'format'").get()?.value !== String(2)) {
|
|
9025
9263
|
db.close();
|
|
9026
|
-
throw new CommsError("CONFIG", `the index for "${accountName}" was written by another version of this package`, { hint: `Run
|
|
9264
|
+
throw new CommsError("CONFIG", `the index for "${accountName}" was written by another version of this package`, { hint: `Run ${inlineCommand(shellCommand([
|
|
9265
|
+
"agent-whatsapp",
|
|
9266
|
+
"sync",
|
|
9267
|
+
"--account",
|
|
9268
|
+
accountName
|
|
9269
|
+
], platform))} to rebuild it.` });
|
|
9027
9270
|
}
|
|
9028
9271
|
db.function("agent_sees_chat", { deterministic: true }, (id) => visibility.seesChat(String(id)) ? 1 : 0);
|
|
9029
9272
|
db.function("agent_kind", { deterministic: true }, (id) => chatKindOf(String(id)));
|
|
@@ -9491,6 +9734,7 @@ var WhatsAppContext = class {
|
|
|
9491
9734
|
env;
|
|
9492
9735
|
now;
|
|
9493
9736
|
surface;
|
|
9737
|
+
platform;
|
|
9494
9738
|
core;
|
|
9495
9739
|
lists;
|
|
9496
9740
|
/** The name a pinned server was started for, as given. */
|
|
@@ -9503,6 +9747,7 @@ var WhatsAppContext = class {
|
|
|
9503
9747
|
this.env = options.env ?? process.env;
|
|
9504
9748
|
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
9505
9749
|
this.surface = options.surface ?? "cli";
|
|
9750
|
+
this.platform = options.platform ?? process.platform;
|
|
9506
9751
|
this.core = openCore({
|
|
9507
9752
|
env: this.env,
|
|
9508
9753
|
now: this.now
|
|
@@ -9513,7 +9758,7 @@ var WhatsAppContext = class {
|
|
|
9513
9758
|
this.#source = {
|
|
9514
9759
|
io: options.sourceIo,
|
|
9515
9760
|
timeoutMs: options.sourceTimeoutMs,
|
|
9516
|
-
platform:
|
|
9761
|
+
platform: this.platform
|
|
9517
9762
|
};
|
|
9518
9763
|
}
|
|
9519
9764
|
/** The spike's accounts moved in, once per process; what happened, when there was a spike file to move. */
|
|
@@ -9543,7 +9788,7 @@ var WhatsAppContext = class {
|
|
|
9543
9788
|
*/
|
|
9544
9789
|
async checkPin() {
|
|
9545
9790
|
if (this.pinned === void 0) return;
|
|
9546
|
-
this.#pinnedId = requireAccount(await this.config(), this.pinned).account.id;
|
|
9791
|
+
this.#pinnedId = requireAccount(await this.config(), this.pinned, this.platform).account.id;
|
|
9547
9792
|
}
|
|
9548
9793
|
/** The name a call acts on: the one it gave, checked against the pin when there is one. */
|
|
9549
9794
|
async #nameFor(config, named) {
|
|
@@ -9559,7 +9804,7 @@ var WhatsAppContext = class {
|
|
|
9559
9804
|
/** One account, resolved and checked, with the person's lists for it — which every read applies. */
|
|
9560
9805
|
async account(named) {
|
|
9561
9806
|
const config = await this.config();
|
|
9562
|
-
const { name, account } = requireAccount(config, await this.#nameFor(config, named));
|
|
9807
|
+
const { name, account } = requireAccount(config, await this.#nameFor(config, named), this.platform);
|
|
9563
9808
|
return {
|
|
9564
9809
|
name,
|
|
9565
9810
|
account,
|
|
@@ -9781,7 +10026,7 @@ async function draftMessage(context, request) {
|
|
|
9781
10026
|
for (const { name, account, lists } of scope) {
|
|
9782
10027
|
let index;
|
|
9783
10028
|
try {
|
|
9784
|
-
index = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists));
|
|
10029
|
+
index = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists), context.platform);
|
|
9785
10030
|
} catch (error) {
|
|
9786
10031
|
if (scope.length > 1 && isCommsError(error) && error.details?.reason === "NOT_SYNCED") continue;
|
|
9787
10032
|
throw error;
|
|
@@ -9966,7 +10211,7 @@ async function openIndex(context, accountName) {
|
|
|
9966
10211
|
const { name, account, lists } = await context.account(accountName);
|
|
9967
10212
|
return {
|
|
9968
10213
|
name,
|
|
9969
|
-
index: await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists))
|
|
10214
|
+
index: await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists), context.platform)
|
|
9970
10215
|
};
|
|
9971
10216
|
}
|
|
9972
10217
|
async function listChats(context, request) {
|
|
@@ -10406,7 +10651,7 @@ async function statusOf(context, named, check) {
|
|
|
10406
10651
|
}
|
|
10407
10652
|
let index = { synced: false };
|
|
10408
10653
|
try {
|
|
10409
|
-
const opened = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists));
|
|
10654
|
+
const opened = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists), context.platform);
|
|
10410
10655
|
try {
|
|
10411
10656
|
const stats = opened.stats();
|
|
10412
10657
|
index = {
|
|
@@ -10537,4 +10782,4 @@ async function syncAccount(context, request) {
|
|
|
10537
10782
|
});
|
|
10538
10783
|
}
|
|
10539
10784
|
//#endregion
|
|
10540
|
-
export {
|
|
10785
|
+
export { paint as $, CommsError as A, colorEnabled as B, checkStorePath as C, strictToolArguments as D, UNTRUSTED_NOTICE as E, approvalsOf as F, gatedChangeAtTerminal as G, commandText as H, approveChangeAtTerminal as I, isCommsError as J, inlineCommand as K, canPrompt as L, absoluteSearchPath as M, agentMarker as N, CHANNELS as O, approvalKind as P, openCore as Q, channelManifest as R, storeIdentity as S, expandSource as T, escapeForDisplay as U, commandPathOf as V, exemptFromUpdateGate as W, nameAvailable as X, lookupName as Y, nameShapeProblem as Z, chatRefOf as _, listChats as a, shellCommand as at, requireAccount as b, Presenter as c, updateGateAtTerminal as ct, draftMessage as d, withFileLock as dt, renderInstall as et, Visibility as f, writeResult as ft, requireSupportedNode as g, WhatsAppIndex as h, CHAT_KINDS as i, serverPruneChange as it, EXIT_CODES as j, CHANNEL_SERVERS as k, innerText as l, updateToolGate as lt, WhatsAppContext as m, whatsappStatus as n, runCommand as nt, readChat as o, toCommsError as ot, listKey as p, VERSION$1 as pt, installExitStatus as q, probeStore as r, serverInstallChange as rt, searchMessages as s, truncateDisplay as st, syncAccount as t, renderPrune as tt, composeDraft as u, windowsSystemProgram as ut, PLATFORM as v, defaultStorePath as w, requireNamedConfig as x, newWhatsAppAccount as y, childEnvironment as z };
|