@agentcomms/resend 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -5,14 +5,14 @@ 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
7
|
import { homedir, platform, userInfo } from "node:os";
|
|
8
|
+
import { constants as constants$1, realpathSync } from "node:fs";
|
|
8
9
|
import { execFile, spawn } from "node:child_process";
|
|
9
10
|
import "node:readline/promises";
|
|
10
11
|
import { styleText } from "node:util";
|
|
11
|
-
import { constants as constants$1 } from "node:fs";
|
|
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.
|
|
15
|
+
const VERSION$1 = "0.12.0";
|
|
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-
|
|
190
|
+
//#region ../core/dist/update-check-pKViNOsH.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
|
|
@@ -1196,74 +1196,60 @@ function connectMailboxCommand() {
|
|
|
1196
1196
|
return mail ? `${mail.binary} inbox add` : void 0;
|
|
1197
1197
|
}
|
|
1198
1198
|
/**
|
|
1199
|
-
*
|
|
1200
|
-
*
|
|
1201
|
-
* `cue/gmail` is the CUE++ mailbox, `cue/slack` the CUE++ workspace, `wf/gmail-tech` a second Wherefrom mailbox. The
|
|
1202
|
-
* first half says who an account belongs to and the second what it is — and the schema checks the second half against
|
|
1203
|
-
* the account itself, so a name cannot claim to be a Slack workspace while naming a mailbox.
|
|
1204
|
-
*
|
|
1205
|
-
* Kept apart from `config.ts`, with no imports, so the schema and the helpers built on it can both depend on it
|
|
1206
|
-
* without depending on each other.
|
|
1207
|
-
*/
|
|
1208
|
-
/**
|
|
1209
|
-
* Organisation names Windows cannot use as a directory.
|
|
1210
|
-
*
|
|
1211
|
-
* The organisation becomes a folder — downloads land in `downloads/cue/gmail/…` — and `con`, `nul` and the rest are
|
|
1212
|
-
* device names on Windows in every directory, with or without an extension. Refused here rather than discovered the
|
|
1213
|
-
* first time somebody on Windows saves an attachment.
|
|
1199
|
+
* One table of dangerous characters, shared by the inbound sanitiser (which strips them from sender-controlled text)
|
|
1200
|
+
* and the preview renderer (which makes them visible), so the two can never disagree.
|
|
1214
1201
|
*/
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1202
|
+
/** Zero-width and formatting characters, bidi controls, variation selectors and Unicode tag characters. */
|
|
1203
|
+
const INVISIBLE_RANGES = [
|
|
1204
|
+
[173, 173],
|
|
1205
|
+
[847, 847],
|
|
1206
|
+
[1564, 1564],
|
|
1207
|
+
[4447, 4448],
|
|
1208
|
+
[6068, 6069],
|
|
1209
|
+
[6155, 6159],
|
|
1210
|
+
[8203, 8207],
|
|
1211
|
+
[8232, 8233],
|
|
1212
|
+
[8234, 8238],
|
|
1213
|
+
[8288, 8292],
|
|
1214
|
+
[8294, 8303],
|
|
1215
|
+
[12644, 12644],
|
|
1216
|
+
[65024, 65039],
|
|
1217
|
+
[65279, 65279],
|
|
1218
|
+
[65440, 65440],
|
|
1219
|
+
[917504, 917631]
|
|
1222
1220
|
];
|
|
1223
|
-
function
|
|
1224
|
-
|
|
1221
|
+
function isInvisible(codePoint) {
|
|
1222
|
+
for (const [from, to] of INVISIBLE_RANGES) if (codePoint >= from && codePoint <= to) return true;
|
|
1223
|
+
return false;
|
|
1225
1224
|
}
|
|
1226
|
-
const ORG = "[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?";
|
|
1227
|
-
const PLATFORM$1 = "[a-z][a-z0-9]{0,15}";
|
|
1228
1225
|
/**
|
|
1229
|
-
*
|
|
1230
|
-
*
|
|
1231
|
-
* Segments never start or end with a hyphen, and the character set is `[a-z0-9/-]` and nothing else: no `.`, so no
|
|
1232
|
-
* `..`; no whitespace, no controls, no upper case, nothing that looks like something else.
|
|
1226
|
+
* C0 controls except tab and newline (ESC, and so every ANSI/OSC sequence, starts here), DEL, and C1 controls (CSI
|
|
1227
|
+
* among them). A lone carriage return counts: it moves a terminal cursor back over text already printed.
|
|
1233
1228
|
*/
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
const match = NAME_PATTERN.exec(name);
|
|
1241
|
-
if (!match) return null;
|
|
1242
|
-
const [, org = "", platform = "", qualifier] = match;
|
|
1243
|
-
return qualifier === void 0 ? {
|
|
1244
|
-
org,
|
|
1245
|
-
platform
|
|
1246
|
-
} : {
|
|
1247
|
-
org,
|
|
1248
|
-
platform,
|
|
1249
|
-
qualifier
|
|
1250
|
-
};
|
|
1229
|
+
function isControl(codePoint) {
|
|
1230
|
+
if (codePoint === 9 || codePoint === 10) return false;
|
|
1231
|
+
return codePoint < 32 || codePoint === 127 || codePoint >= 128 && codePoint <= 159;
|
|
1232
|
+
}
|
|
1233
|
+
function isDangerous(codePoint) {
|
|
1234
|
+
return isControl(codePoint) || isInvisible(codePoint);
|
|
1251
1235
|
}
|
|
1252
1236
|
/**
|
|
1253
|
-
*
|
|
1237
|
+
* Strips control characters (ESC, CSI, OSC and the rest), DEL, lone carriage returns, zero-width, bidi-control and tag
|
|
1238
|
+
* characters from sender-controlled text; returns the text and how many were removed. CRLF becomes LF first.
|
|
1254
1239
|
*
|
|
1255
|
-
*
|
|
1256
|
-
*
|
|
1240
|
+
* This lives beside the table rather than in the sanitiser because `neutralise` needs it too, and the two must not
|
|
1241
|
+
* drift: a pattern that looks for `</untrusted-content` cannot see it through a zero-width space, so stripping
|
|
1242
|
+
* has to happen before any such pattern runs, on every path, not only on the ones that render a body.
|
|
1257
1243
|
*/
|
|
1258
|
-
function
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1244
|
+
function stripInvisible(text) {
|
|
1245
|
+
let removed = 0;
|
|
1246
|
+
let out = "";
|
|
1247
|
+
for (const char of text.replace(/\r\n/g, "\n")) if (isDangerous(char.codePointAt(0) ?? 0)) removed += 1;
|
|
1248
|
+
else out += char;
|
|
1249
|
+
return {
|
|
1250
|
+
text: out,
|
|
1251
|
+
removed
|
|
1252
|
+
};
|
|
1267
1253
|
}
|
|
1268
1254
|
const APP_DIR_NAME = "agent-communications";
|
|
1269
1255
|
/**
|
|
@@ -1327,6 +1313,23 @@ function expandHome(path, home = homedir(), joinPaths = join) {
|
|
|
1327
1313
|
return path;
|
|
1328
1314
|
}
|
|
1329
1315
|
/**
|
|
1316
|
+
* A path as a preview shows it: the home at its start written `~`, the way a person reads and types it. The inverse of
|
|
1317
|
+
* `expandHome`, and like it, nothing else is touched. Case is ignored on Windows, whose paths are not case-sensitive.
|
|
1318
|
+
*/
|
|
1319
|
+
function shortenHome(path, home, platform = process.platform) {
|
|
1320
|
+
if (!home) return path;
|
|
1321
|
+
const same = (a, b) => platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
1322
|
+
let end = home.length;
|
|
1323
|
+
while (end > 0 && (home[end - 1] === "/" || home[end - 1] === "\\")) end -= 1;
|
|
1324
|
+
const root = home.slice(0, end);
|
|
1325
|
+
if (same(path, root)) return "~";
|
|
1326
|
+
for (const separator of platform === "win32" ? ["\\", "/"] : ["/"]) {
|
|
1327
|
+
const prefix = `${root}${separator}`;
|
|
1328
|
+
if (path.length > prefix.length && same(path.slice(0, prefix.length), prefix)) return `~${separator}${path.slice(prefix.length)}`;
|
|
1329
|
+
}
|
|
1330
|
+
return path;
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1330
1333
|
* The user's home directory, read from an environment.
|
|
1331
1334
|
*
|
|
1332
1335
|
* Windows does not set `HOME`; it sets `USERPROFILE`. Call sites that wrote `env.HOME ?? ''` and handed the result to
|
|
@@ -1341,6 +1344,265 @@ function expandHome(path, home = homedir(), joinPaths = join) {
|
|
|
1341
1344
|
function homeDirectory(env = process.env) {
|
|
1342
1345
|
return env.HOME || env.USERPROFILE || homedir();
|
|
1343
1346
|
}
|
|
1347
|
+
const WINDOWS_RESERVED$1 = /^(con|prn|aux|nul|conin\$|conout\$|com[0-9¹²³]|lpt[0-9¹²³])$/i;
|
|
1348
|
+
const UNSAFE_FILENAME_CHARS = /[/\\\u0000-\u001f\u007f<>:"|?*]/g;
|
|
1349
|
+
/** Truncates a string to at most `maxBytes` UTF-8 bytes without splitting a code point. */
|
|
1350
|
+
function truncateBytes(value, maxBytes) {
|
|
1351
|
+
let out = "";
|
|
1352
|
+
let bytes = 0;
|
|
1353
|
+
for (const char of value) {
|
|
1354
|
+
const size = Buffer.byteLength(char, "utf8");
|
|
1355
|
+
if (bytes + size > maxBytes) break;
|
|
1356
|
+
out += char;
|
|
1357
|
+
bytes += size;
|
|
1358
|
+
}
|
|
1359
|
+
return out;
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* A file name that is safe to create on macOS, Linux and Windows: NFC-normalised, no separators or control
|
|
1363
|
+
* characters, no leading or trailing dots and spaces, not a Windows reserved device name, at most `maxBytes` UTF-8
|
|
1364
|
+
* bytes with the extension kept. Never empty.
|
|
1365
|
+
*/
|
|
1366
|
+
function safeFilename(name, maxBytes = 255, fallback = "attachment") {
|
|
1367
|
+
let cleaned = [...name.normalize("NFC")].filter((character) => !isDangerous(character.codePointAt(0) ?? 0)).join("").replace(UNSAFE_FILENAME_CHARS, "_").replace(/\s+/g, " ");
|
|
1368
|
+
cleaned = cleaned.replace(/^[.\s]+/, "").replace(/[.\s]+$/, "");
|
|
1369
|
+
if (cleaned === "") cleaned = fallback;
|
|
1370
|
+
const extension = extname(cleaned);
|
|
1371
|
+
if (WINDOWS_RESERVED$1.test((cleaned.split(".")[0] ?? cleaned).trimEnd())) cleaned = `_${cleaned}`;
|
|
1372
|
+
if (Buffer.byteLength(cleaned, "utf8") <= maxBytes) return cleaned;
|
|
1373
|
+
const keptExtension = Buffer.byteLength(extension, "utf8") < maxBytes / 2 ? extension : "";
|
|
1374
|
+
return truncateBytes(keptExtension ? cleaned.slice(0, -keptExtension.length) : cleaned, maxBytes - Buffer.byteLength(keptExtension, "utf8")) + keptExtension;
|
|
1375
|
+
}
|
|
1376
|
+
/** True when `candidate` is `root` itself or strictly inside it (lexically). */
|
|
1377
|
+
function isInside(candidate, root) {
|
|
1378
|
+
const path = relative(resolve(root), resolve(candidate));
|
|
1379
|
+
return path === "" || !path.startsWith(`..${sep}`) && path !== ".." && !isAbsolute(path);
|
|
1380
|
+
}
|
|
1381
|
+
async function realpathOfExistingAncestor(path) {
|
|
1382
|
+
let current = resolve(path);
|
|
1383
|
+
const tail = [];
|
|
1384
|
+
for (;;) try {
|
|
1385
|
+
const real = await realpath(current);
|
|
1386
|
+
return tail.length ? join(real, ...tail.reverse()) : real;
|
|
1387
|
+
} catch (error) {
|
|
1388
|
+
const parent = dirname(current);
|
|
1389
|
+
if (error.code !== "ENOENT" || parent === current) throw error;
|
|
1390
|
+
tail.push(basename(current));
|
|
1391
|
+
current = parent;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Resolves `target` for writing and proves it stays inside `root` after symlinks in its existing ancestors are
|
|
1396
|
+
* followed. Throws BAD_DATA otherwise. The root must already exist.
|
|
1397
|
+
*/
|
|
1398
|
+
async function resolveInsideRoot(root, target) {
|
|
1399
|
+
const realRoot = await realpath(root);
|
|
1400
|
+
const lexical = resolve(root, target);
|
|
1401
|
+
if (!isInside(lexical, root)) throw new CommsError("BAD_DATA", `refusing to write outside ${root}`, { details: { target } });
|
|
1402
|
+
if (!isInside(await realpathOfExistingAncestor(lexical), realRoot)) throw new CommsError("BAD_DATA", `refusing to write through a link that leaves ${root}`, { details: { target } });
|
|
1403
|
+
return lexical;
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* Creates a new file for writing without following a symlink at the final component and without overwriting an
|
|
1407
|
+
* existing file. Picks `name-2.ext`, `name-3.ext`… when the name is taken.
|
|
1408
|
+
*/
|
|
1409
|
+
async function createUniqueFile(directory, filename) {
|
|
1410
|
+
const extension = extname(filename);
|
|
1411
|
+
const stem = extension ? filename.slice(0, -extension.length) : filename;
|
|
1412
|
+
const noFollow = process.platform === "win32" ? 0 : constants$1.O_NOFOLLOW;
|
|
1413
|
+
for (let attempt = 1; attempt < 1e3; attempt += 1) {
|
|
1414
|
+
const name = attempt === 1 ? filename : `${stem}-${attempt}${extension}`;
|
|
1415
|
+
const path = join(directory, name);
|
|
1416
|
+
try {
|
|
1417
|
+
return {
|
|
1418
|
+
path,
|
|
1419
|
+
handle: await open(path, constants$1.O_WRONLY | constants$1.O_CREAT | constants$1.O_EXCL | noFollow, 384)
|
|
1420
|
+
};
|
|
1421
|
+
} catch (error) {
|
|
1422
|
+
if (error.code === "EEXIST") continue;
|
|
1423
|
+
throw error;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
throw new CommsError("BAD_DATA", `could not find a free file name in ${directory}: the name and every numbered one after it are taken`);
|
|
1427
|
+
}
|
|
1428
|
+
/**
|
|
1429
|
+
* Default places attachments may never be read from, whatever the allowed roots say. `~/.*` means every dot-entry
|
|
1430
|
+
* directly under home — SSH, cloud, npm, git and shell credentials, agent configs; `**∕.git/**` any repository's git
|
|
1431
|
+
* directory; `**∕.env*` dotenv files anywhere.
|
|
1432
|
+
*/
|
|
1433
|
+
function defaultAttachDeny(configDir, env = process.env) {
|
|
1434
|
+
const deny = [
|
|
1435
|
+
configDir,
|
|
1436
|
+
"~/.*",
|
|
1437
|
+
"~/Library",
|
|
1438
|
+
"**/.git/**",
|
|
1439
|
+
"**/.env*"
|
|
1440
|
+
];
|
|
1441
|
+
if (env.APPDATA) deny.push(env.APPDATA);
|
|
1442
|
+
if (env.LOCALAPPDATA) deny.push(env.LOCALAPPDATA);
|
|
1443
|
+
return deny;
|
|
1444
|
+
}
|
|
1445
|
+
async function realOrResolved(path) {
|
|
1446
|
+
try {
|
|
1447
|
+
return await realpath(path);
|
|
1448
|
+
} catch {
|
|
1449
|
+
return resolve(path);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Whether a folder, as written, says where it is on its own: from the home (`~`), or absolute — and on Windows with its
|
|
1454
|
+
* drive (`C:\…`) or share (`\\server\share\…`). Windows calls `\outgoing` absolute, but it is on whichever drive is
|
|
1455
|
+
* current when it is read, and `C:outgoing` is relative to that drive's current folder; either would move with the
|
|
1456
|
+
* process that reads it. A folder in the configuration that does not name its place — written by hand before 0.12.0,
|
|
1457
|
+
* say — allows nothing, and `agentcomms attach` takes none (#45).
|
|
1458
|
+
*/
|
|
1459
|
+
function namesItsPlace(path, platform = process.platform) {
|
|
1460
|
+
if (path === "~" || path.startsWith("~/") || path.startsWith("~\\")) return true;
|
|
1461
|
+
if (platform === "win32") return /^[A-Za-z]:[\\/]/.test(path) || /^[\\/]{2}[^\\/?.][^\\/]*[\\/][^\\/]+/.test(path);
|
|
1462
|
+
return isAbsolute(path);
|
|
1463
|
+
}
|
|
1464
|
+
/**
|
|
1465
|
+
* Proves a local file may be attached to a draft: it resolves (following links) to a regular file inside one of the
|
|
1466
|
+
* allowed roots and inside none of the deny entries. A deny entry of the form `**` + `/name*` matches by file name
|
|
1467
|
+
* prefix (so `.env*` matches `.env` and `.env.local`). Returns the real path.
|
|
1468
|
+
*/
|
|
1469
|
+
async function checkAttachable(path, policy) {
|
|
1470
|
+
const home = policy.home;
|
|
1471
|
+
const requested = resolve(expandHome(path, home));
|
|
1472
|
+
let real;
|
|
1473
|
+
try {
|
|
1474
|
+
real = await realpath(requested);
|
|
1475
|
+
} catch {
|
|
1476
|
+
throw new CommsError("NOT_FOUND", `attachment not found: ${path}`);
|
|
1477
|
+
}
|
|
1478
|
+
if (!(await stat(real)).isFile()) throw new CommsError("BAD_DATA", `not a regular file: ${path}`);
|
|
1479
|
+
if (!(await Promise.all(policy.roots.filter((root) => namesItsPlace(root)).map((root) => realOrResolved(expandHome(root, home))))).some((root) => isInside(real, root))) throw new CommsError("BAD_DATA", `attachments must come from an allowed folder; ${path} is outside them`, { hint: "Copy the file under your home folder — not into one of its hidden folders — and name the copy instead, or allow its folder with `agentcomms attach roots add <folder>` (needs your approval)." });
|
|
1480
|
+
const name = basename(real);
|
|
1481
|
+
const homeDir = await realOrResolved(home ?? (await import("node:os")).homedir());
|
|
1482
|
+
for (const entry of policy.deny) {
|
|
1483
|
+
if (entry === "~/.*") {
|
|
1484
|
+
if (isInside(real, homeDir)) {
|
|
1485
|
+
const first = relative(homeDir, real).split(sep)[0] ?? "";
|
|
1486
|
+
if (first.startsWith(".")) throw new CommsError("BAD_DATA", `refusing to attach a file from ~/${first}: hidden folders in your home are never attached`);
|
|
1487
|
+
}
|
|
1488
|
+
continue;
|
|
1489
|
+
}
|
|
1490
|
+
if (entry === "**/.git/**") {
|
|
1491
|
+
if (real.split(sep).some((segment) => segment.toLowerCase() === ".git")) throw new CommsError("BAD_DATA", "refusing to attach a file from a .git folder");
|
|
1492
|
+
continue;
|
|
1493
|
+
}
|
|
1494
|
+
if (entry.startsWith("**/")) {
|
|
1495
|
+
const pattern = entry.slice(3).toLowerCase();
|
|
1496
|
+
const candidate = name.toLowerCase();
|
|
1497
|
+
const prefix = pattern.endsWith("*") ? pattern.slice(0, -1) : pattern;
|
|
1498
|
+
if (pattern.endsWith("*") ? candidate.startsWith(prefix) : candidate === prefix) throw new CommsError("BAD_DATA", `refusing to attach ${name}: files matching ${entry} are never attached`);
|
|
1499
|
+
continue;
|
|
1500
|
+
}
|
|
1501
|
+
const denied = await realOrResolved(expandHome(entry, home));
|
|
1502
|
+
if (isInside(real, denied)) throw new CommsError("BAD_DATA", `refusing to attach a file from ${entry}`);
|
|
1503
|
+
}
|
|
1504
|
+
return real;
|
|
1505
|
+
}
|
|
1506
|
+
/**
|
|
1507
|
+
* A caller-supplied subdirectory, checked before it is joined to anything.
|
|
1508
|
+
*
|
|
1509
|
+
* `path.join` is not a boundary, and it was being used as one. `join('work', '../personal')` is `'personal'`: the
|
|
1510
|
+
* alias segment is cancelled, the result still resolves inside the downloads root, so `resolveInsideRoot` allows it
|
|
1511
|
+
* — and one mailbox's files are written into another mailbox's folder, over the `manifest.json` that is that
|
|
1512
|
+
* mailbox's own record of where its attachments came from. `join('work', '/etc/cron.d')` is `'work/etc/cron.d'`:
|
|
1513
|
+
* the leading separator is simply dropped, so an absolute path that every document describes as refused is quietly
|
|
1514
|
+
* accepted under a name the caller never asked for.
|
|
1515
|
+
*
|
|
1516
|
+
* Neither is an escape from the root, which is why neither showed up as a jail failure. In one respect they are
|
|
1517
|
+
* worse than an escape: they succeed, and report a path the caller was never told about.
|
|
1518
|
+
*
|
|
1519
|
+
* So the check happens here, on the caller's own string, before any join — absolute paths and `..` are refused
|
|
1520
|
+
* rather than normalised away. A nested `reports/august` is fine; that is what the option is for.
|
|
1521
|
+
*/
|
|
1522
|
+
function relativeSubpath(out, field = "out") {
|
|
1523
|
+
const value = (out ?? "").trim();
|
|
1524
|
+
if (!value) return "";
|
|
1525
|
+
const refuse = (why) => {
|
|
1526
|
+
throw new CommsError("BAD_DATA", `${field} must be a relative subpath: ${why}`, {
|
|
1527
|
+
hint: "Pass something like \"reports/august\". It is always placed inside this account’s own folder.",
|
|
1528
|
+
details: { value }
|
|
1529
|
+
});
|
|
1530
|
+
};
|
|
1531
|
+
if (isAbsolute(value) || /^[A-Za-z]:/.test(value) || value.startsWith("/") || value.startsWith("\\")) refuse("it is an absolute path");
|
|
1532
|
+
const segments = value.split(/[/\\]+/);
|
|
1533
|
+
if (segments.includes("..")) refuse("it climbs out with \"..\"");
|
|
1534
|
+
return segments.filter((segment) => segment && segment !== ".").join("/");
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* The grammar of an account name in version 2 of the config: `<organisation>/<platform>[-<qualifier>]`.
|
|
1538
|
+
*
|
|
1539
|
+
* `cue/gmail` is the CUE++ mailbox, `cue/slack` the CUE++ workspace, `wf/gmail-tech` a second Wherefrom mailbox. The
|
|
1540
|
+
* first half says who an account belongs to and the second what it is — and the schema checks the second half against
|
|
1541
|
+
* the account itself, so a name cannot claim to be a Slack workspace while naming a mailbox.
|
|
1542
|
+
*
|
|
1543
|
+
* Kept apart from `config.ts`, with no imports, so the schema and the helpers built on it can both depend on it
|
|
1544
|
+
* without depending on each other.
|
|
1545
|
+
*/
|
|
1546
|
+
/**
|
|
1547
|
+
* Organisation names Windows cannot use as a directory.
|
|
1548
|
+
*
|
|
1549
|
+
* The organisation becomes a folder — downloads land in `downloads/cue/gmail/…` — and `con`, `nul` and the rest are
|
|
1550
|
+
* device names on Windows in every directory, with or without an extension. Refused here rather than discovered the
|
|
1551
|
+
* first time somebody on Windows saves an attachment.
|
|
1552
|
+
*/
|
|
1553
|
+
const WINDOWS_RESERVED = [
|
|
1554
|
+
"con",
|
|
1555
|
+
"prn",
|
|
1556
|
+
"aux",
|
|
1557
|
+
"nul",
|
|
1558
|
+
...range$1("com"),
|
|
1559
|
+
...range$1("lpt")
|
|
1560
|
+
];
|
|
1561
|
+
function range$1(prefix) {
|
|
1562
|
+
return Array.from({ length: 9 }, (_, index) => `${prefix}${index + 1}`);
|
|
1563
|
+
}
|
|
1564
|
+
const ORG = "[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?";
|
|
1565
|
+
const PLATFORM$1 = "[a-z][a-z0-9]{0,15}";
|
|
1566
|
+
/**
|
|
1567
|
+
* The whole grammar as one expression, so a record key can be checked by the schema without a second pass.
|
|
1568
|
+
*
|
|
1569
|
+
* Segments never start or end with a hyphen, and the character set is `[a-z0-9/-]` and nothing else: no `.`, so no
|
|
1570
|
+
* `..`; no whitespace, no controls, no upper case, nothing that looks like something else.
|
|
1571
|
+
*/
|
|
1572
|
+
const NAME_PATTERN = new RegExp(`^(?!(?:${WINDOWS_RESERVED.join("|")})/)(${ORG})/(${PLATFORM$1})(?:-([a-z0-9](?:[a-z0-9-]{0,14}[a-z0-9])?))?$`);
|
|
1573
|
+
/** A platform word on its own — `gmail`, `slack` — which is also a channel's word in its manifest. */
|
|
1574
|
+
const PLATFORM_PATTERN = new RegExp(`^${PLATFORM$1}$`);
|
|
1575
|
+
const NAME_MESSAGE = "names look like organisation/platform, optionally with a qualifier: cue/gmail, wf/gmail-tech, cue/slack";
|
|
1576
|
+
/** The three parts of a valid name, or null for anything the grammar refuses. */
|
|
1577
|
+
function parseName(name) {
|
|
1578
|
+
const match = NAME_PATTERN.exec(name);
|
|
1579
|
+
if (!match) return null;
|
|
1580
|
+
const [, org = "", platform = "", qualifier] = match;
|
|
1581
|
+
return qualifier === void 0 ? {
|
|
1582
|
+
org,
|
|
1583
|
+
platform
|
|
1584
|
+
} : {
|
|
1585
|
+
org,
|
|
1586
|
+
platform,
|
|
1587
|
+
qualifier
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
/**
|
|
1591
|
+
* Why a name is refused, in words a person can act on — or null when it is fine.
|
|
1592
|
+
*
|
|
1593
|
+
* `platform` is what the account actually is. Checked here as well as in the schema, so the error arrives when the
|
|
1594
|
+
* name is proposed rather than as a refused config write after a sign-in has already been spent.
|
|
1595
|
+
*/
|
|
1596
|
+
function nameShapeProblem(name, platform) {
|
|
1597
|
+
const parsed = parseName(name);
|
|
1598
|
+
if (!parsed) {
|
|
1599
|
+
const org = name.split("/")[0] ?? "";
|
|
1600
|
+
if (WINDOWS_RESERVED.includes(org)) return `"${org}" cannot be an organisation name: Windows reserves it, and the organisation becomes a folder`;
|
|
1601
|
+
return `"${name}" is not a valid name: it should be the organisation, a slash, then ${platform} — for example acme/${platform}, or acme/${platform}-support for a second one`;
|
|
1602
|
+
}
|
|
1603
|
+
if (parsed.platform !== platform) return `"${name}" ends in /${parsed.platform}, but this is a ${platform} account`;
|
|
1604
|
+
return null;
|
|
1605
|
+
}
|
|
1344
1606
|
/**
|
|
1345
1607
|
* Whether this release may write version 2 of the config.
|
|
1346
1608
|
*
|
|
@@ -1947,8 +2209,17 @@ const POLICY_RANK$1 = {
|
|
|
1947
2209
|
* user a consent prompt for a change that is not one. Linux is case-sensitive, so case is kept.
|
|
1948
2210
|
*/
|
|
1949
2211
|
function normalisePath(path) {
|
|
1950
|
-
|
|
1951
|
-
|
|
2212
|
+
return comparablePath(resolve(expandHome(path.trim(), homedir())));
|
|
2213
|
+
}
|
|
2214
|
+
/**
|
|
2215
|
+
* An absolute path in the form two paths are compared in here: no trailing separator, and case folded on macOS and
|
|
2216
|
+
* Windows, whose filesystems are case-insensitive by default (see `normalisePath`). The top of a disk keeps its
|
|
2217
|
+
* separator — `/`, `C:\` — so it is not read as no path at all.
|
|
2218
|
+
*/
|
|
2219
|
+
function comparablePath(absolute) {
|
|
2220
|
+
const stripped = withoutTrailingSeparators(absolute);
|
|
2221
|
+
const kept = stripped === "" || /^[A-Za-z]:$/.test(stripped) ? absolute.slice(0, stripped.length + 1) : stripped;
|
|
2222
|
+
return platform() === "darwin" || platform() === "win32" ? kept.toLowerCase() : kept;
|
|
1952
2223
|
}
|
|
1953
2224
|
/**
|
|
1954
2225
|
* The path with any separators at its end removed. A loop, not `/[/\\]+$/`: that pattern backtracks over every run of
|
|
@@ -1963,9 +2234,14 @@ function withoutTrailingSeparators(path) {
|
|
|
1963
2234
|
function holdsSecrets(config) {
|
|
1964
2235
|
return Object.values(config.clients).some((client) => Boolean(client.secretRef)) || Object.values(config.inboxes).some((inbox) => Boolean(inbox.secretRef)) || Object.values(config.accounts).some((account) => Boolean(account.secretRef));
|
|
1965
2236
|
}
|
|
1966
|
-
/**
|
|
2237
|
+
/**
|
|
2238
|
+
* True when `candidate` is the same directory as `parent`, or inside it. Both may be unset. Both are compared as
|
|
2239
|
+
* written, so both have to be in one form first: `normalisePath`, or `comparablePath` of a real path. A parent that is
|
|
2240
|
+
* the top of a disk — `/`, `c:\` — holds everything on it.
|
|
2241
|
+
*/
|
|
1967
2242
|
function isInsideDirectory(candidate, parent) {
|
|
1968
2243
|
if (!candidate || !parent) return false;
|
|
2244
|
+
if (parent.endsWith("/") || parent.endsWith("\\")) return candidate.startsWith(parent);
|
|
1969
2245
|
return candidate === parent || candidate.startsWith(`${parent}/`) || candidate.startsWith(`${parent}\\`);
|
|
1970
2246
|
}
|
|
1971
2247
|
/**
|
|
@@ -2038,10 +2314,11 @@ function classifyChange(before, after) {
|
|
|
2038
2314
|
if (b.riskEscalation && !a.riskEscalation) loosen("defaults.riskEscalation", b.riskEscalation, a.riskEscalation);
|
|
2039
2315
|
if (updateCheckSetting(before) === "on" && updateCheckSetting(after) === "off") loosen("defaults.updateCheck", updateCheckSetting(before), updateCheckSetting(after));
|
|
2040
2316
|
if (a.sendCaps.perHour > b.sendCaps.perHour || a.sendCaps.perDay > b.sendCaps.perDay) loosen("defaults.sendCaps", b.sendCaps, a.sendCaps);
|
|
2041
|
-
const roots = (list) => new Set(list.map(
|
|
2042
|
-
const
|
|
2317
|
+
const roots = (list) => new Set(list.map((path) => comparablePath(resolve(expandHome(path, homedir())))));
|
|
2318
|
+
const readRoots = (list) => roots(list.filter((root) => namesItsPlace(root)));
|
|
2319
|
+
const before_roots = readRoots(b.attachRoots);
|
|
2043
2320
|
const after_deny = roots(a.attachDeny);
|
|
2044
|
-
if ([...
|
|
2321
|
+
if ([...readRoots(a.attachRoots)].some((root) => !before_roots.has(root))) loosen("defaults.attachRoots", b.attachRoots, a.attachRoots);
|
|
2045
2322
|
if ([...roots(b.attachDeny)].some((deny) => !after_deny.has(deny))) loosen("defaults.attachDeny", b.attachDeny, a.attachDeny);
|
|
2046
2323
|
const downloadsBefore = b.downloadsDir === void 0 ? void 0 : normalisePath(b.downloadsDir);
|
|
2047
2324
|
const downloadsAfter = a.downloadsDir === void 0 ? void 0 : normalisePath(a.downloadsDir);
|
|
@@ -3900,7 +4177,7 @@ async function installManagedRuntime(context, product, version) {
|
|
|
3900
4177
|
private: true
|
|
3901
4178
|
}, null, 2)}\n`);
|
|
3902
4179
|
const npmCli = await findNpmCli();
|
|
3903
|
-
await run(process.execPath, [
|
|
4180
|
+
await run(context.env, process.execPath, [
|
|
3904
4181
|
npmCli,
|
|
3905
4182
|
"install",
|
|
3906
4183
|
"--prefix",
|
|
@@ -3924,10 +4201,12 @@ async function findNpmCli() {
|
|
|
3924
4201
|
throw new CommsError("CONFIG", "npm could not be found next to this Node installation", { hint: "Install with `--launcher npx` instead, which runs the published package directly." });
|
|
3925
4202
|
}
|
|
3926
4203
|
/**
|
|
3927
|
-
* A command run to its end
|
|
3928
|
-
*
|
|
4204
|
+
* A command run to its end, in `env`: the environment the install resolved its target from — which client config,
|
|
4205
|
+
* which client CLI, which node — and not this process's, which may be another. On Windows it also tells the command
|
|
4206
|
+
* never to take a program from its current folder: `npm` and a client's command both start programs of their own by
|
|
4207
|
+
* name.
|
|
3929
4208
|
*/
|
|
3930
|
-
function run(command, args) {
|
|
4209
|
+
function run(env, command, args) {
|
|
3931
4210
|
return new Promise((resolvePromise, reject) => {
|
|
3932
4211
|
const child = spawn(command, args, {
|
|
3933
4212
|
stdio: [
|
|
@@ -3935,7 +4214,7 @@ function run(command, args) {
|
|
|
3935
4214
|
"ignore",
|
|
3936
4215
|
"pipe"
|
|
3937
4216
|
],
|
|
3938
|
-
env: childEnvironment(
|
|
4217
|
+
env: childEnvironment(env)
|
|
3939
4218
|
});
|
|
3940
4219
|
let stderr = "";
|
|
3941
4220
|
child.stderr?.on("data", (chunk) => {
|
|
@@ -3948,14 +4227,19 @@ function run(command, args) {
|
|
|
3948
4227
|
});
|
|
3949
4228
|
});
|
|
3950
4229
|
}
|
|
3951
|
-
|
|
4230
|
+
/**
|
|
4231
|
+
* The entry for one server, starting the command its preflight resolved (`resolved`): what the plan named and the
|
|
4232
|
+
* person approved. Nothing here looks on PATH again — a node or an npx that turned up since would be another command
|
|
4233
|
+
* than the one the preview said.
|
|
4234
|
+
*/
|
|
4235
|
+
async function buildEntry(context, product, options, apply, resolved) {
|
|
3952
4236
|
const launcher = options.launcher ?? "managed";
|
|
3953
|
-
const node =
|
|
4237
|
+
const { node } = resolved;
|
|
3954
4238
|
const env = minimalEnv(context, node);
|
|
3955
4239
|
const serverArgs = product.serverArgs(options);
|
|
3956
4240
|
if (launcher === "npx") return {
|
|
3957
4241
|
entry: {
|
|
3958
|
-
command:
|
|
4242
|
+
command: resolved.command,
|
|
3959
4243
|
args: [
|
|
3960
4244
|
"-y",
|
|
3961
4245
|
`${product.npxPackage}@${product.version}`,
|
|
@@ -4099,6 +4383,13 @@ function claimName(product, options, name, existing) {
|
|
|
4099
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: \`${installCommand$1(product, options, other)}\`. --force does not replace a server this did not install.` });
|
|
4100
4384
|
}
|
|
4101
4385
|
if (taken.length > 0 && !options.force) {
|
|
4386
|
+
for (const pin of PIN_OPTIONS) {
|
|
4387
|
+
const wanted = options[pin];
|
|
4388
|
+
const served = taken.map((server) => product.narrowingOf(server.args)[pin]).find(Boolean);
|
|
4389
|
+
if (!wanted || !served || served === wanted) continue;
|
|
4390
|
+
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: \`${installCommand$1(product, options, second === name ? `${name}-2` : second)}\`.` });
|
|
4392
|
+
}
|
|
4102
4393
|
const npx = taken.some((server) => server.args.some((argument) => argument.startsWith(`${product.npxPackage}@`)));
|
|
4103
4394
|
const again = {
|
|
4104
4395
|
...keepNarrowing(product, options, taken).options,
|
|
@@ -4164,9 +4455,10 @@ function installCommand$1(product, options, name, extra = []) {
|
|
|
4164
4455
|
}
|
|
4165
4456
|
/**
|
|
4166
4457
|
* A command's exit status and what it printed, kept here and never shown: codex prints an entry's env. Started as
|
|
4167
|
-
* `run` starts one
|
|
4458
|
+
* `run` starts one — in the environment the target was resolved from — so that on Windows it takes no program from its
|
|
4459
|
+
* current folder either.
|
|
4168
4460
|
*/
|
|
4169
|
-
function capture$1(command, args) {
|
|
4461
|
+
function capture$1(env, command, args) {
|
|
4170
4462
|
return new Promise((resolvePromise, reject) => {
|
|
4171
4463
|
const child = spawn(command, args, {
|
|
4172
4464
|
stdio: [
|
|
@@ -4174,7 +4466,7 @@ function capture$1(command, args) {
|
|
|
4174
4466
|
"pipe",
|
|
4175
4467
|
"pipe"
|
|
4176
4468
|
],
|
|
4177
|
-
env: childEnvironment(
|
|
4469
|
+
env: childEnvironment(env)
|
|
4178
4470
|
});
|
|
4179
4471
|
let stdout = "";
|
|
4180
4472
|
let stderr = "";
|
|
@@ -4202,11 +4494,11 @@ function capture$1(command, args) {
|
|
|
4202
4494
|
* or a failure that is not "no such server", stops the install: not knowing is not the same as nothing there.
|
|
4203
4495
|
* Nothing codex prints is repeated, because its answer carries the entry's env.
|
|
4204
4496
|
*/
|
|
4205
|
-
async function codexRegistration(binary, name, path) {
|
|
4497
|
+
async function codexRegistration(env, binary, name, path) {
|
|
4206
4498
|
const unknown = () => new CommsError("CONFIG", `codex would not say what it has registered as "${name}", so nothing was written`, { hint: `Look with \`codex mcp get ${name}\`. If it is not an older copy of this server, choose another --name; \`--print\` shows the entry to add by hand.` });
|
|
4207
4499
|
let answer;
|
|
4208
4500
|
try {
|
|
4209
|
-
answer = await capture$1(binary, [
|
|
4501
|
+
answer = await capture$1(env, binary, [
|
|
4210
4502
|
"mcp",
|
|
4211
4503
|
"get",
|
|
4212
4504
|
name,
|
|
@@ -4308,21 +4600,48 @@ async function preflightInstall(context, product, options) {
|
|
|
4308
4600
|
}
|
|
4309
4601
|
let previous = writes ? claimName(product, options, name, existing) : [];
|
|
4310
4602
|
if (writes && binary && options.client === "codex") {
|
|
4311
|
-
const reported = await codexRegistration(binary, name, configPath ?? "codex");
|
|
4603
|
+
const reported = await codexRegistration(context.env, binary, name, configPath ?? "codex");
|
|
4312
4604
|
if (reported) previous = claimName(product, options, name, [reported]);
|
|
4313
4605
|
}
|
|
4314
4606
|
const { options: effective, kept } = keepNarrowing(product, options, previous);
|
|
4607
|
+
const node = await resolveNode(context.env);
|
|
4608
|
+
const command = (options.launcher ?? "managed") === "npx" ? await resolveNpx(context.env, node) : node;
|
|
4315
4609
|
return {
|
|
4316
4610
|
scan,
|
|
4317
4611
|
target,
|
|
4318
4612
|
previous,
|
|
4319
4613
|
effective,
|
|
4320
|
-
kept
|
|
4614
|
+
kept,
|
|
4615
|
+
node,
|
|
4616
|
+
command
|
|
4321
4617
|
};
|
|
4322
4618
|
}
|
|
4619
|
+
/**
|
|
4620
|
+
* The `npx` an npx entry starts, as a full path: from PATH, or beside the `node` found, where npm installs it.
|
|
4621
|
+
*
|
|
4622
|
+
* Never the bare name. An entry that says only `npx` is resolved by the client, later, in its own environment — its
|
|
4623
|
+
* PATH, and on Windows its current folder — so the approval would bind a word, and the program run would be whichever
|
|
4624
|
+
* the client found. With no `npx` either way, nothing is written.
|
|
4625
|
+
*/
|
|
4626
|
+
async function resolveNpx(env, node) {
|
|
4627
|
+
const found = await whichExecutable("npx", env) ?? await executableIn("npx", [dirname(node)], env);
|
|
4628
|
+
if (found !== null) return found;
|
|
4629
|
+
throw new CommsError("CONFIG", "npx could not be found, on PATH or beside node, so nothing was written", { hint: "Install Node.js with npm, which brings npx, or register with the managed launcher — the default — which needs no npx." });
|
|
4630
|
+
}
|
|
4631
|
+
/**
|
|
4632
|
+
* Where an install puts the entry and what the entry starts, as one sentence of the preview a person approves: the
|
|
4633
|
+
* client's own config file and the command, each with the home written `~`. In the approval's digest with the rest, so
|
|
4634
|
+
* a claim from an environment that resolves another file or another command — `CLAUDE_CONFIG_DIR` set to another
|
|
4635
|
+
* account's, a PATH that finds another node — is another change, and refused.
|
|
4636
|
+
*/
|
|
4637
|
+
function entryDestination(client, target, command, env) {
|
|
4638
|
+
const home = homeDirectory(env);
|
|
4639
|
+
const file = target.own ?? target.configPath;
|
|
4640
|
+
return `the entry goes in ${file === void 0 ? `${client}'s own configuration` : shortenHome(file, home)}, and ${client} will start it with ${shortenHome(command, home)}`;
|
|
4641
|
+
}
|
|
4323
4642
|
/** The plan a preflight arrived at, in the form `mcpInstall` compares: see `PlannedInstall`. */
|
|
4324
4643
|
function plannedInstall(preflight) {
|
|
4325
|
-
const { target, previous, effective } = preflight;
|
|
4644
|
+
const { target, previous, effective, node, command } = preflight;
|
|
4326
4645
|
return {
|
|
4327
4646
|
writes: target.writes,
|
|
4328
4647
|
cliName: target.cliName,
|
|
@@ -4344,7 +4663,9 @@ function plannedInstall(preflight) {
|
|
|
4344
4663
|
inbox: effective.inbox ?? null,
|
|
4345
4664
|
workspace: effective.workspace ?? null,
|
|
4346
4665
|
readOnly: effective.readOnly === true
|
|
4347
|
-
}
|
|
4666
|
+
},
|
|
4667
|
+
node,
|
|
4668
|
+
command
|
|
4348
4669
|
};
|
|
4349
4670
|
}
|
|
4350
4671
|
/**
|
|
@@ -4368,6 +4689,8 @@ function planDrift(product, client, name, planned, now) {
|
|
|
4368
4689
|
}).join(" ") || "no pin";
|
|
4369
4690
|
drift.push(`the server would start with ${flags(now.narrowing)}, not ${flags(planned.narrowing)}`);
|
|
4370
4691
|
}
|
|
4692
|
+
if (planned.command !== now.command) drift.push(`it would start the server with ${now.command}, not ${planned.command}`);
|
|
4693
|
+
else if (planned.node !== now.node) drift.push(`it would run under the node at ${now.node}, not ${planned.node}`);
|
|
4371
4694
|
return drift;
|
|
4372
4695
|
}
|
|
4373
4696
|
/** The planned pin and `--read-only`, as the options that start the server: each one given, none left to be found. */
|
|
@@ -4411,7 +4734,7 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
4411
4734
|
if (!writes) {
|
|
4412
4735
|
if (existing.find((server) => server.client === options.client && server.name === name && server.scope !== "project" && !isProductServer(server, product))) warnings.push(`${options.client} already has a different server called "${name}"; pasting this would replace it. Use --name to choose another name.`);
|
|
4413
4736
|
}
|
|
4414
|
-
const { entry, launcher, runtimeMissing } = await buildEntry(context, product, effective, apply);
|
|
4737
|
+
const { entry, launcher, runtimeMissing } = await buildEntry(context, product, effective, apply, preflight);
|
|
4415
4738
|
const snippet = snippetFor(options.client, name, entry);
|
|
4416
4739
|
const backupPath = previous.length > 0 ? await backUp(context, options.client, name, previous, /* @__PURE__ */ new Date()) : void 0;
|
|
4417
4740
|
const ledger = handedOutRuntimesPath(context.core.paths.dataDir);
|
|
@@ -4465,13 +4788,13 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
4465
4788
|
name
|
|
4466
4789
|
];
|
|
4467
4790
|
try {
|
|
4468
|
-
await run(binary, removal);
|
|
4791
|
+
await run(context.env, binary, removal);
|
|
4469
4792
|
} catch (error) {
|
|
4470
4793
|
const message = error instanceof CommsError ? error.message : String(error);
|
|
4471
4794
|
if (!/no (mcp )?server|not found|does not exist/i.test(message)) throw error;
|
|
4472
4795
|
}
|
|
4473
4796
|
try {
|
|
4474
|
-
await run(binary, args);
|
|
4797
|
+
await run(context.env, binary, args);
|
|
4475
4798
|
} catch (error) {
|
|
4476
4799
|
const restore = options.client === "claude-code" ? [
|
|
4477
4800
|
"mcp",
|
|
@@ -4495,7 +4818,7 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
4495
4818
|
];
|
|
4496
4819
|
let restored = true;
|
|
4497
4820
|
try {
|
|
4498
|
-
await run(binary, restore);
|
|
4821
|
+
await run(context.env, binary, restore);
|
|
4499
4822
|
} catch {
|
|
4500
4823
|
restored = false;
|
|
4501
4824
|
}
|
|
@@ -4505,7 +4828,7 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
4505
4828
|
});
|
|
4506
4829
|
}
|
|
4507
4830
|
} else try {
|
|
4508
|
-
await run(binary, args);
|
|
4831
|
+
await run(context.env, binary, args);
|
|
4509
4832
|
} catch (error) {
|
|
4510
4833
|
const message = error instanceof CommsError ? error.message : String(error);
|
|
4511
4834
|
if (/already exists/i.test(message)) throw new CommsError("CONFIG", `${cliName} already has an MCP server called "${name}", somewhere this could not read`, {
|
|
@@ -4994,62 +5317,6 @@ function requireChannelManifest(channel) {
|
|
|
4994
5317
|
if (manifest === void 0) throw notAChannel(channel);
|
|
4995
5318
|
return manifest;
|
|
4996
5319
|
}
|
|
4997
|
-
/**
|
|
4998
|
-
* One table of dangerous characters, shared by the inbound sanitiser (which strips them from sender-controlled text)
|
|
4999
|
-
* and the preview renderer (which makes them visible), so the two can never disagree.
|
|
5000
|
-
*/
|
|
5001
|
-
/** Zero-width and formatting characters, bidi controls, variation selectors and Unicode tag characters. */
|
|
5002
|
-
const INVISIBLE_RANGES = [
|
|
5003
|
-
[173, 173],
|
|
5004
|
-
[847, 847],
|
|
5005
|
-
[1564, 1564],
|
|
5006
|
-
[4447, 4448],
|
|
5007
|
-
[6068, 6069],
|
|
5008
|
-
[6155, 6159],
|
|
5009
|
-
[8203, 8207],
|
|
5010
|
-
[8232, 8233],
|
|
5011
|
-
[8234, 8238],
|
|
5012
|
-
[8288, 8292],
|
|
5013
|
-
[8294, 8303],
|
|
5014
|
-
[12644, 12644],
|
|
5015
|
-
[65024, 65039],
|
|
5016
|
-
[65279, 65279],
|
|
5017
|
-
[65440, 65440],
|
|
5018
|
-
[917504, 917631]
|
|
5019
|
-
];
|
|
5020
|
-
function isInvisible(codePoint) {
|
|
5021
|
-
for (const [from, to] of INVISIBLE_RANGES) if (codePoint >= from && codePoint <= to) return true;
|
|
5022
|
-
return false;
|
|
5023
|
-
}
|
|
5024
|
-
/**
|
|
5025
|
-
* C0 controls except tab and newline (ESC, and so every ANSI/OSC sequence, starts here), DEL, and C1 controls (CSI
|
|
5026
|
-
* among them). A lone carriage return counts: it moves a terminal cursor back over text already printed.
|
|
5027
|
-
*/
|
|
5028
|
-
function isControl(codePoint) {
|
|
5029
|
-
if (codePoint === 9 || codePoint === 10) return false;
|
|
5030
|
-
return codePoint < 32 || codePoint === 127 || codePoint >= 128 && codePoint <= 159;
|
|
5031
|
-
}
|
|
5032
|
-
function isDangerous(codePoint) {
|
|
5033
|
-
return isControl(codePoint) || isInvisible(codePoint);
|
|
5034
|
-
}
|
|
5035
|
-
/**
|
|
5036
|
-
* Strips control characters (ESC, CSI, OSC and the rest), DEL, lone carriage returns, zero-width, bidi-control and tag
|
|
5037
|
-
* characters from sender-controlled text; returns the text and how many were removed. CRLF becomes LF first.
|
|
5038
|
-
*
|
|
5039
|
-
* This lives beside the table rather than in the sanitiser because `neutralise` needs it too, and the two must not
|
|
5040
|
-
* drift: a pattern that looks for `</untrusted-content` cannot see it through a zero-width space, so stripping
|
|
5041
|
-
* has to happen before any such pattern runs, on every path, not only on the ones that render a body.
|
|
5042
|
-
*/
|
|
5043
|
-
function stripInvisible(text) {
|
|
5044
|
-
let removed = 0;
|
|
5045
|
-
let out = "";
|
|
5046
|
-
for (const char of text.replace(/\r\n/g, "\n")) if (isDangerous(char.codePointAt(0) ?? 0)) removed += 1;
|
|
5047
|
-
else out += char;
|
|
5048
|
-
return {
|
|
5049
|
-
text: out,
|
|
5050
|
-
removed
|
|
5051
|
-
};
|
|
5052
|
-
}
|
|
5053
5320
|
function okEnvelope(data) {
|
|
5054
5321
|
return {
|
|
5055
5322
|
ok: true,
|
|
@@ -6525,7 +6792,7 @@ async function npmInstallGlobal(env, spec) {
|
|
|
6525
6792
|
}
|
|
6526
6793
|
}
|
|
6527
6794
|
/** The package version, read from package.json at build time. */
|
|
6528
|
-
const VERSION = "0.
|
|
6795
|
+
const VERSION = "0.12.0";
|
|
6529
6796
|
/**
|
|
6530
6797
|
* Registering, pruning and listing the MCP servers of every channel, from the core.
|
|
6531
6798
|
*
|
|
@@ -6700,9 +6967,10 @@ function installOptions(request) {
|
|
|
6700
6967
|
*
|
|
6701
6968
|
* `own` is the calling channel's product, from its own `mcp install`: see `ownProduct`. The sentences depend on the
|
|
6702
6969
|
* request and the machine, not on which surface prepared them, so an approval prepared by one is claimed by the
|
|
6703
|
-
* other. The
|
|
6704
|
-
*
|
|
6705
|
-
*
|
|
6970
|
+
* other. The sentences that name code name it as this environment resolves it: the client's config file and the node
|
|
6971
|
+
* or npx the entry starts (#46), and for `--launcher local` the checkout's file. A claim from an environment that
|
|
6972
|
+
* resolves another — `CLAUDE_CONFIG_DIR` set to another account's, a PATH that finds another node, a core running
|
|
6973
|
+
* from another build of the checkout — is another registration, refused as one.
|
|
6706
6974
|
*/
|
|
6707
6975
|
function serverInstallChange(core, env, request, own) {
|
|
6708
6976
|
checkRequest(request);
|
|
@@ -6726,7 +6994,7 @@ function serverInstallChange(core, env, request, own) {
|
|
|
6726
6994
|
const product = await productNow();
|
|
6727
6995
|
const { version } = product;
|
|
6728
6996
|
const preflight = await preflightInstall(context, product, installOptions(request));
|
|
6729
|
-
const { target, previous, effective, kept } = preflight;
|
|
6997
|
+
const { target, previous, effective, kept, command } = preflight;
|
|
6730
6998
|
planned = {
|
|
6731
6999
|
product,
|
|
6732
7000
|
install: plannedInstall(preflight)
|
|
@@ -6735,12 +7003,14 @@ function serverInstallChange(core, env, request, own) {
|
|
|
6735
7003
|
if (target.writes) {
|
|
6736
7004
|
const pinned = pinName === void 0 || pinName === "readOnly" ? void 0 : effective[pinName];
|
|
6737
7005
|
const pins = [pinned !== void 0 ? `pinned to the ${noun} ${pinned}` : "", effective.readOnly ? "read-only" : ""].filter(Boolean);
|
|
6738
|
-
const
|
|
7006
|
+
const what = (pinName === void 0 || pinName === "readOnly" ? [] : [...new Set(previous.map((server) => facts.narrowingOf(server.args)[pinName] ?? null))]).map((pin) => pin === null ? `every ${noun}` : `the ${noun} ${pin}`).join(" and ");
|
|
7007
|
+
const replacing = previous.length > 0 ? `, replacing its own earlier entry of that name${what ? `, which served ${what}${kept.length > 0 ? "," : ""}` : ""}${kept.length > 0 ? ` and keeping ${kept.join(" ")} from it` : ""}` : "";
|
|
6739
7008
|
effects.push(`registers the ${label} MCP server with ${request.client} as "${name}"${pins.length > 0 ? `, ${pins.join(", ")}` : ""}${replacing}`);
|
|
6740
7009
|
const everyAccount = pinName !== void 0 && pinned === void 0;
|
|
6741
7010
|
const everyTool = switchable && effective.readOnly !== true;
|
|
6742
7011
|
if (everyAccount) effects.push(`not pinned: it reaches every ${noun} on this machine${everyTool ? ", with every tool" : ""}`);
|
|
6743
7012
|
else if (everyTool) effects.push(`not read-only: it has every tool for ${pinned}, including those that change it`);
|
|
7013
|
+
effects.push(entryDestination(request.client, target, command, env));
|
|
6744
7014
|
if (launcher === "npx") effects.push(`${request.client} will fetch ${facts.npxPackage}@${version} from npm each time it starts it`);
|
|
6745
7015
|
else if (launcher === "local") effects.push(`${request.client} will start it from ${await localCliEntry(product.moduleUrl)}`);
|
|
6746
7016
|
}
|
|
@@ -6780,7 +7050,7 @@ function serverPruneChange(core, env, request, own) {
|
|
|
6780
7050
|
};
|
|
6781
7051
|
const { packageName, version } = ownProduct(request.channel, own) ?? {
|
|
6782
7052
|
packageName: channelServer(request.channel).packageName,
|
|
6783
|
-
version: "0.
|
|
7053
|
+
version: "0.12.0"
|
|
6784
7054
|
};
|
|
6785
7055
|
const product = {
|
|
6786
7056
|
packageName,
|
|
@@ -7450,7 +7720,11 @@ function registrationEffects(step) {
|
|
|
7450
7720
|
const { item } = step;
|
|
7451
7721
|
const reach = reachOf(item.channel, channelServer(item.channel).narrowingOf(item.narrowing));
|
|
7452
7722
|
const entry = `registers the ${channelLabel(item.channel)} MCP server with ${item.client} as "${item.name}" again (${item.scope} scope, ${item.launcher} launcher), at ${item.latest} in place of ${item.version}${reach ? ` — ${reach}, as now` : ""}`;
|
|
7453
|
-
return item.launcher === "npx" ? [
|
|
7723
|
+
return item.launcher === "npx" ? [
|
|
7724
|
+
entry,
|
|
7725
|
+
step.destination,
|
|
7726
|
+
`${item.client} will fetch ${item.package}@${item.latest} from npm each time it starts "${item.name}"`
|
|
7727
|
+
] : [entry, step.destination];
|
|
7454
7728
|
}
|
|
7455
7729
|
/** Every step as the sentences a person reads, in the order the steps are taken. */
|
|
7456
7730
|
function effectsOf(planned) {
|
|
@@ -7510,7 +7784,9 @@ async function planRegistrations(context, candidates, request) {
|
|
|
7510
7784
|
steps.push({
|
|
7511
7785
|
item,
|
|
7512
7786
|
product,
|
|
7513
|
-
options
|
|
7787
|
+
options,
|
|
7788
|
+
install: plannedInstall(preflight),
|
|
7789
|
+
destination: entryDestination(item.client, preflight.target, preflight.command, context.env)
|
|
7514
7790
|
});
|
|
7515
7791
|
}
|
|
7516
7792
|
return {
|
|
@@ -7605,7 +7881,7 @@ async function applyUpdate(context, planned, deps) {
|
|
|
7605
7881
|
});
|
|
7606
7882
|
}
|
|
7607
7883
|
}
|
|
7608
|
-
for (const { item, product, options } of planned.registrations) {
|
|
7884
|
+
for (const { item, product, options, install } of planned.registrations) {
|
|
7609
7885
|
const base = {
|
|
7610
7886
|
kind: "registration",
|
|
7611
7887
|
channel: item.channel,
|
|
@@ -7631,7 +7907,7 @@ async function applyUpdate(context, planned, deps) {
|
|
|
7631
7907
|
const result = await mcpInstall(context, product, {
|
|
7632
7908
|
...options,
|
|
7633
7909
|
...pins
|
|
7634
|
-
});
|
|
7910
|
+
}, install);
|
|
7635
7911
|
if (!result.applied) {
|
|
7636
7912
|
steps.push({
|
|
7637
7913
|
...base,
|
|
@@ -7659,217 +7935,78 @@ async function applyUpdate(context, planned, deps) {
|
|
|
7659
7935
|
for (const global of planned.globals) {
|
|
7660
7936
|
const base = {
|
|
7661
7937
|
kind: "global",
|
|
7662
|
-
package: global.package,
|
|
7663
|
-
from: global.version,
|
|
7664
|
-
to: global.latest
|
|
7665
|
-
};
|
|
7666
|
-
try {
|
|
7667
|
-
await installGlobal(`${global.package}@${global.latest}`);
|
|
7668
|
-
steps.push({
|
|
7669
|
-
...base,
|
|
7670
|
-
outcome: "updated"
|
|
7671
|
-
});
|
|
7672
|
-
} catch (error) {
|
|
7673
|
-
steps.push({
|
|
7674
|
-
...base,
|
|
7675
|
-
outcome: "failed",
|
|
7676
|
-
detail: message(error)
|
|
7677
|
-
});
|
|
7678
|
-
}
|
|
7679
|
-
}
|
|
7680
|
-
const worked = (step) => step.kind === "registration" ? step.outcome === "registered" && step.verification !== "failed" : step.outcome !== "failed";
|
|
7681
|
-
const ok = steps.every(worked);
|
|
7682
|
-
const status = steps.length === 0 ? planned.nothingBehind ? "up-to-date" : "manual" : ok ? "updated" : "failed";
|
|
7683
|
-
const clients = [...new Set(steps.flatMap((step) => step.kind === "registration" && step.outcome === "registered" ? [step.client] : []))];
|
|
7684
|
-
const next = clients.length > 0 ? `Restart ${clients.length === 1 ? clients[0] : `${clients.slice(0, -1).join(", ")} and ${clients.at(-1)}`} to load the new servers: no MCP client loads a new server into a session that is already running. Then, from the restarted server, prune the runtimes the old versions leave behind: comms_server_prune for each channel — at a terminal, ${PRUNE_COMMANDS}.` : null;
|
|
7685
|
-
return {
|
|
7686
|
-
status,
|
|
7687
|
-
latest: planned.latest,
|
|
7688
|
-
steps,
|
|
7689
|
-
manual: planned.manual,
|
|
7690
|
-
ok,
|
|
7691
|
-
next
|
|
7692
|
-
};
|
|
7693
|
-
}
|
|
7694
|
-
/**
|
|
7695
|
-
* "Not now": no server or command on this machine stops for the update until the next local midnight.
|
|
7696
|
-
*
|
|
7697
|
-
* `plan` reads the file on both calls, so a claim made after midnight — a different midnight in the sentence — or
|
|
7698
|
-
* for a newer release is a different change, and refused.
|
|
7699
|
-
*/
|
|
7700
|
-
function updateLaterChange(core, options = {}) {
|
|
7701
|
-
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
7702
|
-
let planned = null;
|
|
7703
|
-
return {
|
|
7704
|
-
plan: async (config) => {
|
|
7705
|
-
const record = await readUpdateCheck(core.paths.stateDir);
|
|
7706
|
-
const until = nextLocalMidnight(now());
|
|
7707
|
-
const latest = countedLatest(record);
|
|
7708
|
-
const changed = record.snoozedUntil === null || Date.parse(record.snoozedUntil) < until.getTime();
|
|
7709
|
-
planned = {
|
|
7710
|
-
until,
|
|
7711
|
-
latest,
|
|
7712
|
-
changed
|
|
7713
|
-
};
|
|
7714
|
-
const what = latest === null ? "the daily update check" : `the update to ${latest}`;
|
|
7715
|
-
return {
|
|
7716
|
-
before: config,
|
|
7717
|
-
after: config,
|
|
7718
|
-
effects: changed ? [`puts off ${what} until midnight, local time (${localStamp(until)}): until then no server or command on this machine stops for it, and the first request after asks again`] : [],
|
|
7719
|
-
summary: latest === null ? "Skip the daily update check until tomorrow" : `Skip the update to ${latest} until tomorrow`
|
|
7720
|
-
};
|
|
7721
|
-
},
|
|
7722
|
-
apply: async () => {
|
|
7723
|
-
if (planned === null) throw new CommsError("UNEXPECTED", "not now was applied before it was planned");
|
|
7724
|
-
const { until, latest, changed } = planned;
|
|
7725
|
-
if (changed) await changeUpdateCheck(core.paths.stateDir, (record) => ({ record: {
|
|
7726
|
-
...record,
|
|
7727
|
-
snoozedUntil: until.toISOString()
|
|
7728
|
-
} }));
|
|
7729
|
-
return {
|
|
7730
|
-
snoozedUntil: (await readUpdateCheck(core.paths.stateDir)).snoozedUntil,
|
|
7731
|
-
latest,
|
|
7732
|
-
changed
|
|
7733
|
-
};
|
|
7734
|
-
}
|
|
7735
|
-
};
|
|
7736
|
-
}
|
|
7737
|
-
/**
|
|
7738
|
-
* The daily update check's checker: the half that asks the registry (design 2026-09-28 §1).
|
|
7739
|
-
*
|
|
7740
|
-
* At most once in 24 hours for the whole machine, it reads the `latest` dist-tag of `@agentcomms/core` — every
|
|
7741
|
-
* package here is released together under one version, so that one number is the latest of each — and runs the same
|
|
7742
|
-
* "what is behind" check `comms_update` runs with `check`, handing it that number, so the registry is asked once. What
|
|
7743
|
-
* it found goes in `update-check.json`, where every server and command reads it.
|
|
7744
|
-
*
|
|
7745
|
-
* Two processes finding the file a day old at the same moment do not both ask: the first to take the file's lock
|
|
7746
|
-
* claims the check (`checking`), and the second, reading the file under the same lock, finds it claimed. The day's
|
|
7747
|
-
* `lastChecked` is written when the ask is over, so an ask cut short — a command the person interrupted, a server
|
|
7748
|
-
* whose client closed it — does not use up the day: its claim runs out (`UPDATE_CHECK_LEASE_MS`) and the next process
|
|
7749
|
-
* asks. An ask that fails — offline, a registry that does not answer — is recorded as the day's ask, keeps the last
|
|
7750
|
-
* result, and stops nothing. It never throws.
|
|
7751
|
-
*
|
|
7752
|
-
* Nothing here gives up on the registry sooner than the registry's own timeout: a command at a terminal stops
|
|
7753
|
-
* *waiting* after about three seconds and goes on, and the check carries on beside it. Cut off at three seconds, a
|
|
7754
|
-
* registry that takes five would have been asked every day and heard from never.
|
|
7755
|
-
*
|
|
7756
|
-
* WhatsApp imports none of this: see `update-state.ts`.
|
|
7757
|
-
*/
|
|
7758
|
-
const CORE_PACKAGE = channelServer("core").packageName;
|
|
7759
|
-
/** The check, when it is on and due: once in 24 hours per machine, recorded in `update-check.json`. */
|
|
7760
|
-
async function checkForUpdates(core, env, options = {}) {
|
|
7761
|
-
const stateDir = core.paths.stateDir;
|
|
7762
|
-
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
7763
|
-
try {
|
|
7764
|
-
if (!(await updateCheckEnabled(core, env)).on) return {
|
|
7765
|
-
asked: false,
|
|
7766
|
-
record: await readUpdateCheck(stateDir)
|
|
7767
|
-
};
|
|
7768
|
-
const claim = await changeUpdateCheck(stateDir, (record) => {
|
|
7769
|
-
const claimedAt = now();
|
|
7770
|
-
if (!updateCheckDue(record, claimedAt) || updateCheckUnderway(record, claimedAt)) return null;
|
|
7771
|
-
const checking = claimedAt.toISOString();
|
|
7772
|
-
return {
|
|
7773
|
-
record: {
|
|
7774
|
-
...record,
|
|
7775
|
-
checking
|
|
7776
|
-
},
|
|
7777
|
-
result: checking
|
|
7778
|
-
};
|
|
7779
|
-
}).catch(() => null);
|
|
7780
|
-
if (claim?.result === void 0) return {
|
|
7781
|
-
asked: false,
|
|
7782
|
-
record: await readUpdateCheck(stateDir)
|
|
7783
|
-
};
|
|
7784
|
-
await ask(core, env, {
|
|
7785
|
-
...options,
|
|
7786
|
-
now,
|
|
7787
|
-
claimedAt: claim.result
|
|
7788
|
-
});
|
|
7789
|
-
return {
|
|
7790
|
-
asked: true,
|
|
7791
|
-
record: await readUpdateCheck(stateDir)
|
|
7792
|
-
};
|
|
7793
|
-
} catch {
|
|
7794
|
-
return {
|
|
7795
|
-
asked: false,
|
|
7796
|
-
record: await readUpdateCheck(stateDir).catch(() => ({ ...EMPTY_UPDATE_CHECK }))
|
|
7797
|
-
};
|
|
7798
|
-
}
|
|
7799
|
-
}
|
|
7800
|
-
async function ask(core, env, options) {
|
|
7801
|
-
const deps = options.deps ?? {};
|
|
7802
|
-
/**
|
|
7803
|
-
* The ask is over: what it found is written, the day's check recorded as of the claim, and the claim given up — if
|
|
7804
|
-
* the claim is still this ask's. A check a person asked for, or an update, records what it found and gives up any
|
|
7805
|
-
* claim as it does; so does another process once this claim ran out and it claimed the check itself. Either way
|
|
7806
|
-
* what is in the file is newer than what this ask found, and writing over it would put back the machine as it was
|
|
7807
|
-
* before an update: "update" where "restart" is right.
|
|
7808
|
-
*/
|
|
7809
|
-
const settle = (found) => changeUpdateCheck(core.paths.stateDir, (record) => record.checking === options.claimedAt ? { record: {
|
|
7810
|
-
...record,
|
|
7811
|
-
...found,
|
|
7812
|
-
lastChecked: options.claimedAt,
|
|
7813
|
-
checking: null
|
|
7814
|
-
} } : null).catch(() => void 0);
|
|
7815
|
-
const reason = (error) => (error instanceof Error ? error.message : String(error)).slice(0, 300);
|
|
7816
|
-
let latest;
|
|
7817
|
-
try {
|
|
7818
|
-
latest = await (deps.latestVersion ?? ((name) => npmLatestVersion(name, { env })))(CORE_PACKAGE);
|
|
7819
|
-
if (!isVersion(latest)) throw new Error(`the npm registry names ${JSON.stringify(String(latest)).slice(0, 60)} as the latest release, which is not a version`);
|
|
7820
|
-
} catch (error) {
|
|
7821
|
-
await settle({ lastError: reason(error) });
|
|
7822
|
-
return;
|
|
7823
|
-
}
|
|
7824
|
-
if (isPrerelease(latest)) {
|
|
7825
|
-
await settle({
|
|
7826
|
-
latest,
|
|
7827
|
-
behind: null,
|
|
7828
|
-
current: null,
|
|
7829
|
-
lastError: null
|
|
7830
|
-
});
|
|
7831
|
-
return;
|
|
7832
|
-
}
|
|
7833
|
-
try {
|
|
7834
|
-
await updateCheck(core, env, {
|
|
7835
|
-
...deps,
|
|
7836
|
-
latestVersion: async () => latest,
|
|
7837
|
-
now: options.now
|
|
7838
|
-
}, options.claimedAt);
|
|
7839
|
-
} catch (error) {
|
|
7840
|
-
await settle({
|
|
7841
|
-
latest,
|
|
7842
|
-
behind: null,
|
|
7843
|
-
current: null,
|
|
7844
|
-
lastError: reason(error)
|
|
7845
|
-
});
|
|
7938
|
+
package: global.package,
|
|
7939
|
+
from: global.version,
|
|
7940
|
+
to: global.latest
|
|
7941
|
+
};
|
|
7942
|
+
try {
|
|
7943
|
+
await installGlobal(`${global.package}@${global.latest}`);
|
|
7944
|
+
steps.push({
|
|
7945
|
+
...base,
|
|
7946
|
+
outcome: "updated"
|
|
7947
|
+
});
|
|
7948
|
+
} catch (error) {
|
|
7949
|
+
steps.push({
|
|
7950
|
+
...base,
|
|
7951
|
+
outcome: "failed",
|
|
7952
|
+
detail: message(error)
|
|
7953
|
+
});
|
|
7954
|
+
}
|
|
7846
7955
|
}
|
|
7956
|
+
const worked = (step) => step.kind === "registration" ? step.outcome === "registered" && step.verification !== "failed" : step.outcome !== "failed";
|
|
7957
|
+
const ok = steps.every(worked);
|
|
7958
|
+
const status = steps.length === 0 ? planned.nothingBehind ? "up-to-date" : "manual" : ok ? "updated" : "failed";
|
|
7959
|
+
const clients = [...new Set(steps.flatMap((step) => step.kind === "registration" && step.outcome === "registered" ? [step.client] : []))];
|
|
7960
|
+
const next = clients.length > 0 ? `Restart ${clients.length === 1 ? clients[0] : `${clients.slice(0, -1).join(", ")} and ${clients.at(-1)}`} to load the new servers: no MCP client loads a new server into a session that is already running. Then, from the restarted server, prune the runtimes the old versions leave behind: comms_server_prune for each channel — at a terminal, ${PRUNE_COMMANDS}.` : null;
|
|
7961
|
+
return {
|
|
7962
|
+
status,
|
|
7963
|
+
latest: planned.latest,
|
|
7964
|
+
steps,
|
|
7965
|
+
manual: planned.manual,
|
|
7966
|
+
ok,
|
|
7967
|
+
next
|
|
7968
|
+
};
|
|
7847
7969
|
}
|
|
7848
7970
|
/**
|
|
7849
|
-
*
|
|
7850
|
-
* the person's "now" — its own preview and yes.
|
|
7971
|
+
* "Not now": no server or command on this machine stops for the update until the next local midnight.
|
|
7851
7972
|
*
|
|
7852
|
-
*
|
|
7973
|
+
* `plan` reads the file on both calls, so a claim made after midnight — a different midnight in the sentence — or
|
|
7974
|
+
* for a newer release is a different change, and refused.
|
|
7853
7975
|
*/
|
|
7854
|
-
function
|
|
7976
|
+
function updateLaterChange(core, options = {}) {
|
|
7977
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
7978
|
+
let planned = null;
|
|
7855
7979
|
return {
|
|
7856
|
-
|
|
7857
|
-
await
|
|
7858
|
-
|
|
7859
|
-
|
|
7860
|
-
|
|
7980
|
+
plan: async (config) => {
|
|
7981
|
+
const record = await readUpdateCheck(core.paths.stateDir);
|
|
7982
|
+
const until = nextLocalMidnight(now());
|
|
7983
|
+
const latest = countedLatest(record);
|
|
7984
|
+
const changed = record.snoozedUntil === null || Date.parse(record.snoozedUntil) < until.getTime();
|
|
7985
|
+
planned = {
|
|
7986
|
+
until,
|
|
7987
|
+
latest,
|
|
7988
|
+
changed
|
|
7989
|
+
};
|
|
7990
|
+
const what = latest === null ? "the daily update check" : `the update to ${latest}`;
|
|
7991
|
+
return {
|
|
7992
|
+
before: config,
|
|
7993
|
+
after: config,
|
|
7994
|
+
effects: changed ? [`puts off ${what} until midnight, local time (${localStamp(until)}): until then no server or command on this machine stops for it, and the first request after asks again`] : [],
|
|
7995
|
+
summary: latest === null ? "Skip the daily update check until tomorrow" : `Skip the update to ${latest} until tomorrow`
|
|
7996
|
+
};
|
|
7861
7997
|
},
|
|
7862
|
-
|
|
7863
|
-
|
|
7864
|
-
|
|
7865
|
-
|
|
7866
|
-
|
|
7867
|
-
|
|
7868
|
-
|
|
7869
|
-
|
|
7870
|
-
|
|
7871
|
-
|
|
7872
|
-
|
|
7998
|
+
apply: async () => {
|
|
7999
|
+
if (planned === null) throw new CommsError("UNEXPECTED", "not now was applied before it was planned");
|
|
8000
|
+
const { until, latest, changed } = planned;
|
|
8001
|
+
if (changed) await changeUpdateCheck(core.paths.stateDir, (record) => ({ record: {
|
|
8002
|
+
...record,
|
|
8003
|
+
snoozedUntil: until.toISOString()
|
|
8004
|
+
} }));
|
|
8005
|
+
return {
|
|
8006
|
+
snoozedUntil: (await readUpdateCheck(core.paths.stateDir)).snoozedUntil,
|
|
8007
|
+
latest,
|
|
8008
|
+
changed
|
|
8009
|
+
};
|
|
7873
8010
|
}
|
|
7874
8011
|
};
|
|
7875
8012
|
}
|
|
@@ -8015,6 +8152,7 @@ function approvalsOf(command) {
|
|
|
8015
8152
|
...named
|
|
8016
8153
|
];
|
|
8017
8154
|
}
|
|
8155
|
+
const TERMINAL_CHECK_WAIT_MS = 3e3;
|
|
8018
8156
|
/**
|
|
8019
8157
|
* The update gate at a terminal, before a command runs. Resolves to null to run the command, or to the exit status to
|
|
8020
8158
|
* end with instead; throws `UPDATE_REQUIRED` (exit 11) where nobody can be asked.
|
|
@@ -8118,47 +8256,411 @@ function updateRequired(pending, binary) {
|
|
|
8118
8256
|
});
|
|
8119
8257
|
}
|
|
8120
8258
|
/**
|
|
8121
|
-
* What the terminal says after the person's "now", and the exit status. "Updated. Run your command again." only when
|
|
8122
|
-
* the update did everything and this command's release is now installed where it runs from — its global package at
|
|
8123
|
-
* the latest. Anything short of that says what, and ends with a status that is not 0: the command has not run, and a
|
|
8124
|
-
* script that goes on after `cmd && next` must not take it for the command's success.
|
|
8259
|
+
* What the terminal says after the person's "now", and the exit status. "Updated. Run your command again." only when
|
|
8260
|
+
* the update did everything and this command's release is now installed where it runs from — its global package at
|
|
8261
|
+
* the latest. Anything short of that says what, and ends with a status that is not 0: the command has not run, and a
|
|
8262
|
+
* script that goes on after `cmd && next` must not take it for the command's success.
|
|
8263
|
+
*/
|
|
8264
|
+
async function afterUpdate(outcome, options, before) {
|
|
8265
|
+
const { streams, binary } = options;
|
|
8266
|
+
const notRun = `${binary} did not run`;
|
|
8267
|
+
if (outcome === "failed") {
|
|
8268
|
+
streams.stdout.write(`${notRun}: the update did not finish — each step says how it went, above.\n`);
|
|
8269
|
+
return EXIT_CODES.UNAVAILABLE;
|
|
8270
|
+
}
|
|
8271
|
+
const after = updateVerdict(await readUpdateCheck(options.core.paths.stateDir), options.running, {
|
|
8272
|
+
channel: options.channel,
|
|
8273
|
+
surface: "command"
|
|
8274
|
+
});
|
|
8275
|
+
if (after === null || after.kind === "restart") {
|
|
8276
|
+
if (outcome === "updated") {
|
|
8277
|
+
streams.stdout.write("Updated. Run your command again.\n");
|
|
8278
|
+
return EXIT_CODES.OK;
|
|
8279
|
+
}
|
|
8280
|
+
streams.stdout.write(`${before.latest} is installed here: run your command again from it. ${notRun}.\n`);
|
|
8281
|
+
return EXIT_CODES.UPDATE;
|
|
8282
|
+
}
|
|
8283
|
+
streams.stdout.write(`${notRun}: the update did not bring it to ${before.latest}. \`${UPDATE_WAYS.update.command}\` updates what this machine registers and has installed globally — what it found is said above — and this ${binary} ${before.running} is started from somewhere else: npx's cache, a project's own install, a checkout. Run it from ${before.latest}, or put this off until tomorrow with \`${UPDATE_WAYS.later.command}\`.\n`);
|
|
8284
|
+
return EXIT_CODES.UPDATE;
|
|
8285
|
+
}
|
|
8286
|
+
async function askLine(streams, question) {
|
|
8287
|
+
const { createInterface } = await import("node:readline/promises");
|
|
8288
|
+
const rl = createInterface({
|
|
8289
|
+
input: streams.stdin,
|
|
8290
|
+
output: streams.stderr
|
|
8291
|
+
});
|
|
8292
|
+
try {
|
|
8293
|
+
return await rl.question(question);
|
|
8294
|
+
} finally {
|
|
8295
|
+
rl.close();
|
|
8296
|
+
}
|
|
8297
|
+
}
|
|
8298
|
+
/**
|
|
8299
|
+
* The daily update check's checker: the half that asks the registry (design 2026-09-28 §1).
|
|
8300
|
+
*
|
|
8301
|
+
* At most once in 24 hours for the whole machine, it reads the `latest` dist-tag of `@agentcomms/core` — every
|
|
8302
|
+
* package here is released together under one version, so that one number is the latest of each — and runs the same
|
|
8303
|
+
* "what is behind" check `comms_update` runs with `check`, handing it that number, so the registry is asked once. What
|
|
8304
|
+
* it found goes in `update-check.json`, where every server and command reads it.
|
|
8305
|
+
*
|
|
8306
|
+
* Two processes finding the file a day old at the same moment do not both ask: the first to take the file's lock
|
|
8307
|
+
* claims the check (`checking`), and the second, reading the file under the same lock, finds it claimed. The day's
|
|
8308
|
+
* `lastChecked` is written when the ask is over, so an ask cut short — a command the person interrupted, a server
|
|
8309
|
+
* whose client closed it — does not use up the day: its claim runs out (`UPDATE_CHECK_LEASE_MS`) and the next process
|
|
8310
|
+
* asks. An ask that fails — offline, a registry that does not answer — is recorded as the day's ask, keeps the last
|
|
8311
|
+
* result, and stops nothing. It never throws.
|
|
8312
|
+
*
|
|
8313
|
+
* Nothing here gives up on the registry sooner than the registry's own timeout: a command at a terminal stops
|
|
8314
|
+
* *waiting* after about three seconds and goes on, and the check carries on beside it — in a detached child of its
|
|
8315
|
+
* own (`UPDATE_CHECK_CHILD_COMMAND`), so the command's process, and its output, end when the command does (#48). Cut
|
|
8316
|
+
* off at three seconds, a registry that takes five would have been asked every day and heard from never.
|
|
8317
|
+
*
|
|
8318
|
+
* WhatsApp imports none of this: see `update-state.ts`.
|
|
8319
|
+
*/
|
|
8320
|
+
const CORE_PACKAGE = channelServer("core").packageName;
|
|
8321
|
+
/** The check, when it is on and due: once in 24 hours per machine, recorded in `update-check.json`. */
|
|
8322
|
+
async function checkForUpdates(core, env, options = {}) {
|
|
8323
|
+
const stateDir = core.paths.stateDir;
|
|
8324
|
+
try {
|
|
8325
|
+
if (!(await updateCheckEnabled(core, env)).on) return {
|
|
8326
|
+
asked: false,
|
|
8327
|
+
record: await readUpdateCheck(stateDir)
|
|
8328
|
+
};
|
|
8329
|
+
const claimedAt = await claimUpdateCheck(core, options);
|
|
8330
|
+
if (claimedAt === null) return {
|
|
8331
|
+
asked: false,
|
|
8332
|
+
record: await readUpdateCheck(stateDir)
|
|
8333
|
+
};
|
|
8334
|
+
await askUnderClaim(core, env, claimedAt, options);
|
|
8335
|
+
return {
|
|
8336
|
+
asked: true,
|
|
8337
|
+
record: await readUpdateCheck(stateDir)
|
|
8338
|
+
};
|
|
8339
|
+
} catch {
|
|
8340
|
+
return {
|
|
8341
|
+
asked: false,
|
|
8342
|
+
record: await readUpdateCheck(stateDir).catch(() => ({ ...EMPTY_UPDATE_CHECK }))
|
|
8343
|
+
};
|
|
8344
|
+
}
|
|
8345
|
+
}
|
|
8346
|
+
/**
|
|
8347
|
+
* The day's claim, when the check is due and nobody else is asking: the claim's time, written to the file as
|
|
8348
|
+
* `checking`, or null — not due, claimed by another process, or the file could not be written. Whoever is handed the
|
|
8349
|
+
* time asks under it (`askUnderClaim`). Whether the check is on at all is the caller's to decide first.
|
|
8350
|
+
*
|
|
8351
|
+
* Under the lock, due or not — and claimed by another process or not — is decided on the file as it is now, and the
|
|
8352
|
+
* claim written before anyone asks, so of two processes that both found it a day old, one asks. The time is read under
|
|
8353
|
+
* the lock too: read before it, the second process's time could be earlier than the first's claim, which reads as a
|
|
8354
|
+
* clock gone back.
|
|
8355
|
+
*/
|
|
8356
|
+
async function claimUpdateCheck(core, options = {}) {
|
|
8357
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
8358
|
+
return (await changeUpdateCheck(core.paths.stateDir, (record) => {
|
|
8359
|
+
const claimedAt = now();
|
|
8360
|
+
if (!updateCheckDue(record, claimedAt) || updateCheckUnderway(record, claimedAt)) return null;
|
|
8361
|
+
const checking = claimedAt.toISOString();
|
|
8362
|
+
return {
|
|
8363
|
+
record: {
|
|
8364
|
+
...record,
|
|
8365
|
+
checking
|
|
8366
|
+
},
|
|
8367
|
+
result: checking
|
|
8368
|
+
};
|
|
8369
|
+
}).catch(() => null))?.result ?? null;
|
|
8370
|
+
}
|
|
8371
|
+
/**
|
|
8372
|
+
* The ask, under a claim already taken (`claimUpdateCheck`): the registry, then `comms_update`'s own check. What it
|
|
8373
|
+
* found is written — the day's check recorded, and the claim given up — only while the claim in the file is still
|
|
8374
|
+
* `claimedAt`. It claims nothing itself.
|
|
8375
|
+
*/
|
|
8376
|
+
async function askUnderClaim(core, env, claimedAt, options = {}) {
|
|
8377
|
+
await ask(core, env, {
|
|
8378
|
+
...options,
|
|
8379
|
+
now: options.now ?? (() => /* @__PURE__ */ new Date()),
|
|
8380
|
+
claimedAt
|
|
8381
|
+
});
|
|
8382
|
+
}
|
|
8383
|
+
async function ask(core, env, options) {
|
|
8384
|
+
const deps = options.deps ?? {};
|
|
8385
|
+
/**
|
|
8386
|
+
* The ask is over: what it found is written, the day's check recorded as of the claim, and the claim given up — if
|
|
8387
|
+
* the claim is still this ask's. A check a person asked for, or an update, records what it found and gives up any
|
|
8388
|
+
* claim as it does; so does another process once this claim ran out and it claimed the check itself. Either way
|
|
8389
|
+
* what is in the file is newer than what this ask found, and writing over it would put back the machine as it was
|
|
8390
|
+
* before an update: "update" where "restart" is right.
|
|
8391
|
+
*/
|
|
8392
|
+
const settle = (found) => changeUpdateCheck(core.paths.stateDir, (record) => record.checking === options.claimedAt ? { record: {
|
|
8393
|
+
...record,
|
|
8394
|
+
...found,
|
|
8395
|
+
lastChecked: options.claimedAt,
|
|
8396
|
+
checking: null
|
|
8397
|
+
} } : null).catch(() => void 0);
|
|
8398
|
+
const reason = (error) => (error instanceof Error ? error.message : String(error)).slice(0, 300);
|
|
8399
|
+
let latest;
|
|
8400
|
+
try {
|
|
8401
|
+
latest = await (deps.latestVersion ?? ((name) => npmLatestVersion(name, { env })))(CORE_PACKAGE);
|
|
8402
|
+
if (!isVersion(latest)) throw new Error(`the npm registry names ${JSON.stringify(String(latest)).slice(0, 60)} as the latest release, which is not a version`);
|
|
8403
|
+
} catch (error) {
|
|
8404
|
+
await settle({ lastError: reason(error) });
|
|
8405
|
+
return;
|
|
8406
|
+
}
|
|
8407
|
+
if (isPrerelease(latest)) {
|
|
8408
|
+
await settle({
|
|
8409
|
+
latest,
|
|
8410
|
+
behind: null,
|
|
8411
|
+
current: null,
|
|
8412
|
+
lastError: null
|
|
8413
|
+
});
|
|
8414
|
+
return;
|
|
8415
|
+
}
|
|
8416
|
+
try {
|
|
8417
|
+
await updateCheck(core, env, {
|
|
8418
|
+
...deps,
|
|
8419
|
+
latestVersion: async () => latest,
|
|
8420
|
+
now: options.now
|
|
8421
|
+
}, options.claimedAt);
|
|
8422
|
+
} catch (error) {
|
|
8423
|
+
await settle({
|
|
8424
|
+
latest,
|
|
8425
|
+
behind: null,
|
|
8426
|
+
current: null,
|
|
8427
|
+
lastError: reason(error)
|
|
8428
|
+
});
|
|
8429
|
+
}
|
|
8430
|
+
}
|
|
8431
|
+
/**
|
|
8432
|
+
* The hidden command every CLI that asks the registry answers to — `agentcomms`, `agent-gmail`, `agent-slack`,
|
|
8433
|
+
* `agent-resend` — with the claim's time after it: the ask a command handed on (`terminalUpdateHooks`). Not in any
|
|
8434
|
+
* help, never stopped by the update gate, and nothing a person or an agent runs.
|
|
8435
|
+
*/
|
|
8436
|
+
const UPDATE_CHECK_CHILD_COMMAND = "update-check-child";
|
|
8437
|
+
/**
|
|
8438
|
+
* How to start the CLI this process is running: this Node, the flags it was started with, and the script it runs —
|
|
8439
|
+
* when that script is a CLI's entry, `cli.mjs` built or `cli.ts` from a checkout. Links are followed first: npm's
|
|
8440
|
+
* `agentcomms` is a link to `dist/cli.mjs`, and on Windows its `.cmd` shim has already started Node on the `.mjs`, so
|
|
8441
|
+
* no shim is ever started here. Every package that answers the hidden command bundles this function into its own
|
|
8442
|
+
* build, so the entry is the running script, not this module's neighbour — core's own CLI, next to a bundled copy of
|
|
8443
|
+
* core, is not the command the person ran.
|
|
8444
|
+
*
|
|
8445
|
+
* Null when the script is not a CLI's entry: a CLI's `run()` called from something else, a test runner for one. The
|
|
8446
|
+
* check is then asked in this process, as it always was.
|
|
8447
|
+
*/
|
|
8448
|
+
function updateCheckChildEntry(script = process.argv[1], execArgv = process.execArgv) {
|
|
8449
|
+
if (!script) return null;
|
|
8450
|
+
let path;
|
|
8451
|
+
try {
|
|
8452
|
+
path = realpathSync.native(script);
|
|
8453
|
+
} catch {
|
|
8454
|
+
return null;
|
|
8455
|
+
}
|
|
8456
|
+
if (!/^cli\.(?:mjs|ts)$/.test(basename(path))) return null;
|
|
8457
|
+
return {
|
|
8458
|
+
command: process.execPath,
|
|
8459
|
+
args: [...withoutDebugger(execArgv), path]
|
|
8460
|
+
};
|
|
8461
|
+
}
|
|
8462
|
+
/**
|
|
8463
|
+
* Node's own flags, less a debugger's. A child started with `--inspect-brk` would wait for a debugger nobody attaches
|
|
8464
|
+
* until its claim ran out, and one with `--inspect` would ask for the port the command already holds. The rest —
|
|
8465
|
+
* `--experimental-strip-types` for a checkout, say — the child needs as the command did.
|
|
8466
|
+
*/
|
|
8467
|
+
function withoutDebugger(execArgv) {
|
|
8468
|
+
return keptWords(execArgv.map((word) => ({
|
|
8469
|
+
word,
|
|
8470
|
+
raw: word
|
|
8471
|
+
}))).map(({ raw }) => raw);
|
|
8472
|
+
}
|
|
8473
|
+
/** Words as Node reads them, each with how it was written, less a debugger's; `--inspect-port 9229` is two words. */
|
|
8474
|
+
function keptWords(words) {
|
|
8475
|
+
const kept = [];
|
|
8476
|
+
for (let index = 0; index < words.length; index++) {
|
|
8477
|
+
const word = words[index];
|
|
8478
|
+
const equals = word.word.indexOf("=");
|
|
8479
|
+
const name = (equals < 0 ? word.word : word.word.slice(0, equals)).replaceAll("_", "-");
|
|
8480
|
+
if (!/^--(?:inspect|debug)(?:-brk(?:-node)?|-port|-wait)?$/.test(name)) {
|
|
8481
|
+
kept.push(word);
|
|
8482
|
+
continue;
|
|
8483
|
+
}
|
|
8484
|
+
if (equals < 0 && /^--(?:inspect|debug)-port$/.test(name)) index++;
|
|
8485
|
+
}
|
|
8486
|
+
return kept;
|
|
8487
|
+
}
|
|
8488
|
+
/**
|
|
8489
|
+
* `NODE_OPTIONS` split as Node splits it: at a space — not a tab or a new line — except inside double quotes, where a backslash takes the
|
|
8490
|
+
* character after it as it is. Each word keeps how it was written, so what is kept goes to the child unchanged —
|
|
8491
|
+
* `--require "/tmp/with --inspect hook.js"` is one word, and not a debugger's.
|
|
8492
|
+
*/
|
|
8493
|
+
function nodeOptionWords(options) {
|
|
8494
|
+
const words = [];
|
|
8495
|
+
let index = 0;
|
|
8496
|
+
while (index < options.length) {
|
|
8497
|
+
while (index < options.length && options[index] === " ") index++;
|
|
8498
|
+
if (index >= options.length) break;
|
|
8499
|
+
const start = index;
|
|
8500
|
+
let word = "";
|
|
8501
|
+
let quoted = false;
|
|
8502
|
+
for (; index < options.length; index++) {
|
|
8503
|
+
const character = options[index] ?? "";
|
|
8504
|
+
if (quoted) {
|
|
8505
|
+
if (character === "\\" && index + 1 < options.length) word += options[++index];
|
|
8506
|
+
else if (character === "\"") quoted = false;
|
|
8507
|
+
else word += character;
|
|
8508
|
+
} else if (character === " ") break;
|
|
8509
|
+
else if (character === "\"") quoted = true;
|
|
8510
|
+
else word += character;
|
|
8511
|
+
}
|
|
8512
|
+
words.push({
|
|
8513
|
+
word,
|
|
8514
|
+
raw: options.slice(start, index)
|
|
8515
|
+
});
|
|
8516
|
+
}
|
|
8517
|
+
return words;
|
|
8518
|
+
}
|
|
8519
|
+
/**
|
|
8520
|
+
* The child's environment: the command's, with a debugger's flags taken out of `NODE_OPTIONS` too. Node reads
|
|
8521
|
+
* `NODE_OPTIONS` before any flag, so a command debugged through it — `NODE_OPTIONS=--inspect-brk` — would start a child
|
|
8522
|
+
* that waits for a debugger, holds its claim until the lease runs out, and outlives the command. Every other option in
|
|
8523
|
+
* it is kept as written; with none left, `NODE_OPTIONS` is left out. On Windows a variable's name is the same in any
|
|
8524
|
+
* case, so `node_options` is read, and taken out, as `NODE_OPTIONS` is.
|
|
8525
|
+
*/
|
|
8526
|
+
function updateCheckChildEnvironment(env, platform = process.platform) {
|
|
8527
|
+
const child = { ...childEnvironment(env, platform) };
|
|
8528
|
+
const names = Object.keys(child).filter((name) => platform === "win32" ? name.toUpperCase() === "NODE_OPTIONS" : name === "NODE_OPTIONS");
|
|
8529
|
+
if (names.length === 0) return child;
|
|
8530
|
+
const kept = names.map((name) => keptWords(nodeOptionWords(child[name] ?? "")).map(({ raw }) => raw).join(" ")).filter((options) => options !== "").join(" ");
|
|
8531
|
+
for (const name of names) delete child[name];
|
|
8532
|
+
if (kept !== "") child.NODE_OPTIONS = kept;
|
|
8533
|
+
return child;
|
|
8534
|
+
}
|
|
8535
|
+
/**
|
|
8536
|
+
* The day's check at a terminal, handed on (#48). The command claims the check itself, as it always has, so only one
|
|
8537
|
+
* process asks — then starts this CLI again, detached, with the hidden command and the claim's time, and waits up to
|
|
8538
|
+
* the gate's three seconds for it to say the ask is over. After that it lets go of the child, which carries on alone,
|
|
8539
|
+
* and the command's process ends when the command does: before, it lived until the ask was over — the registry's ten
|
|
8540
|
+
* seconds, then `npm ls`'s minute — and a `$(…)`, a pipe or an agent's shell waited with it. A fast answer is written
|
|
8541
|
+
* before the wait ends, and the gate reads the file after it, so it still stops the day's first command.
|
|
8542
|
+
*
|
|
8543
|
+
* The child holds none of the command's output — stdin, stdout and stderr ignored, only the IPC channel for its one
|
|
8544
|
+
* message — or whatever reads that output would wait for the child as it waited for the check. Detached, so a
|
|
8545
|
+
* Ctrl-C at the terminal does not cut the ask short, and hidden, so Windows opens no console for it.
|
|
8546
|
+
*
|
|
8547
|
+
* When the child cannot be started — no entry to start, a program not there or not allowed — the check is asked in
|
|
8548
|
+
* this process instead, as it was before. It never throws.
|
|
8549
|
+
*/
|
|
8550
|
+
async function handUpdateCheckToChild(core, env, options) {
|
|
8551
|
+
const deadline = Date.now() + TERMINAL_CHECK_WAIT_MS;
|
|
8552
|
+
try {
|
|
8553
|
+
if (!(await updateCheckEnabled(core, env)).on) return;
|
|
8554
|
+
const claimedAt = await claimUpdateCheck(core, { now: options.now });
|
|
8555
|
+
if (claimedAt === null) return;
|
|
8556
|
+
const entry = options.entry === void 0 ? updateCheckChildEntry() : options.entry;
|
|
8557
|
+
if (!(entry !== null && await startUpdateCheckChild(entry, claimedAt, env, deadline))) await askUnderClaim(core, env, claimedAt, { now: options.now });
|
|
8558
|
+
} catch {}
|
|
8559
|
+
}
|
|
8560
|
+
/**
|
|
8561
|
+
* Starts the child and waits, until `deadline` at most, for it to say the ask is over, or to end. False when it could
|
|
8562
|
+
* not be started at all; then nothing was asked, and the claim is still this process's to ask under.
|
|
8125
8563
|
*/
|
|
8126
|
-
async function
|
|
8127
|
-
|
|
8128
|
-
|
|
8129
|
-
|
|
8130
|
-
|
|
8131
|
-
|
|
8564
|
+
async function startUpdateCheckChild(entry, claimedAt, env, deadline) {
|
|
8565
|
+
let child;
|
|
8566
|
+
try {
|
|
8567
|
+
child = spawn(entry.command, [
|
|
8568
|
+
...entry.args,
|
|
8569
|
+
UPDATE_CHECK_CHILD_COMMAND,
|
|
8570
|
+
claimedAt
|
|
8571
|
+
], {
|
|
8572
|
+
detached: true,
|
|
8573
|
+
windowsHide: true,
|
|
8574
|
+
stdio: [
|
|
8575
|
+
"ignore",
|
|
8576
|
+
"ignore",
|
|
8577
|
+
"ignore",
|
|
8578
|
+
"ipc"
|
|
8579
|
+
],
|
|
8580
|
+
env: updateCheckChildEnvironment(env)
|
|
8581
|
+
});
|
|
8582
|
+
} catch {
|
|
8583
|
+
return false;
|
|
8132
8584
|
}
|
|
8133
|
-
|
|
8134
|
-
|
|
8135
|
-
|
|
8585
|
+
let failed = false;
|
|
8586
|
+
child.on("error", () => {
|
|
8587
|
+
if (child.pid === void 0) failed = true;
|
|
8136
8588
|
});
|
|
8137
|
-
|
|
8138
|
-
|
|
8139
|
-
|
|
8140
|
-
|
|
8141
|
-
|
|
8142
|
-
|
|
8143
|
-
|
|
8144
|
-
|
|
8145
|
-
|
|
8146
|
-
|
|
8147
|
-
}
|
|
8148
|
-
async function askLine(streams, question) {
|
|
8149
|
-
const { createInterface } = await import("node:readline/promises");
|
|
8150
|
-
const rl = createInterface({
|
|
8151
|
-
input: streams.stdin,
|
|
8152
|
-
output: streams.stderr
|
|
8589
|
+
await new Promise((resolve) => {
|
|
8590
|
+
const timer = setTimeout(resolve, Math.max(0, deadline - Date.now()));
|
|
8591
|
+
const done = () => {
|
|
8592
|
+
clearTimeout(timer);
|
|
8593
|
+
resolve();
|
|
8594
|
+
};
|
|
8595
|
+
child.once("error", done);
|
|
8596
|
+
child.once("exit", done);
|
|
8597
|
+
child.on("message", (message) => {
|
|
8598
|
+
if (message?.type === "settled") done();
|
|
8599
|
+
});
|
|
8153
8600
|
});
|
|
8601
|
+
if (child.connected) child.disconnect();
|
|
8602
|
+
child.unref();
|
|
8603
|
+
return !failed;
|
|
8604
|
+
}
|
|
8605
|
+
/**
|
|
8606
|
+
* The hidden command's work, in the child a command started (`UPDATE_CHECK_CHILD_COMMAND`): the ask, under the claim
|
|
8607
|
+
* the command took and handed over. It claims nothing itself, and asks only while the file still holds that claim —
|
|
8608
|
+
* a claim that ran out, and that another process took since, is that process's to ask under. It says `settled` to the
|
|
8609
|
+
* command if the command is still listening, lets go of the channel, and ends; a command that stopped listening has
|
|
8610
|
+
* already gone on, and the ask is finished all the same. Results are written only while the claim is this child's.
|
|
8611
|
+
*/
|
|
8612
|
+
async function runUpdateCheckChild(core, env, claimedAt) {
|
|
8613
|
+
if (claimedAt === void 0 || !Number.isFinite(Date.parse(claimedAt))) throw new CommsError("USAGE", `${UPDATE_CHECK_CHILD_COMMAND} takes the time of the claim it asks under`, { hint: "Nothing runs this but a command finishing the day’s update check; `agentcomms update --check` asks now." });
|
|
8154
8614
|
try {
|
|
8155
|
-
|
|
8615
|
+
if ((await readUpdateCheck(core.paths.stateDir)).checking === claimedAt) await askUnderClaim(core, env, claimedAt);
|
|
8156
8616
|
} finally {
|
|
8157
|
-
|
|
8617
|
+
settled();
|
|
8158
8618
|
}
|
|
8159
8619
|
}
|
|
8620
|
+
/** Tells the command that started this child the ask is over, if it is still listening, and closes the channel. */
|
|
8621
|
+
function settled() {
|
|
8622
|
+
if (!process.send || !process.connected) return;
|
|
8623
|
+
process.send({ type: "settled" }, void 0, {}, () => {
|
|
8624
|
+
if (process.connected) process.disconnect();
|
|
8625
|
+
});
|
|
8626
|
+
}
|
|
8627
|
+
/**
|
|
8628
|
+
* What a command at a terminal hands the update gate (`updateGateAtTerminal`): the check, and the update itself for
|
|
8629
|
+
* the person's "now" — its own preview and yes.
|
|
8630
|
+
*
|
|
8631
|
+
* Every command but WhatsApp's is given these. WhatsApp's is given neither, and reads the file as it is.
|
|
8632
|
+
*/
|
|
8633
|
+
function terminalUpdateHooks(core, env, options) {
|
|
8634
|
+
return {
|
|
8635
|
+
check: async () => {
|
|
8636
|
+
if (options.deps !== void 0) {
|
|
8637
|
+
await checkForUpdates(core, env, {
|
|
8638
|
+
deps: options.deps,
|
|
8639
|
+
now: options.now
|
|
8640
|
+
});
|
|
8641
|
+
return;
|
|
8642
|
+
}
|
|
8643
|
+
await handUpdateCheckToChild(core, env, {
|
|
8644
|
+
now: options.now,
|
|
8645
|
+
entry: options.childEntry
|
|
8646
|
+
});
|
|
8647
|
+
},
|
|
8648
|
+
update: async () => {
|
|
8649
|
+
const result = await gatedChangeAtTerminal(core, updateChange(core, env, {}, options.deps), {
|
|
8650
|
+
env,
|
|
8651
|
+
output: options.output,
|
|
8652
|
+
command: "agentcomms update",
|
|
8653
|
+
approveCommand: options.approveCommand,
|
|
8654
|
+
streams: options.streams
|
|
8655
|
+
});
|
|
8656
|
+
writeResult(result, options.output, (r) => renderUpdate(r, options.output.color), options.streams);
|
|
8657
|
+
if (!result.ok) return "failed";
|
|
8658
|
+
return result.status === "updated" && result.manual.length === 0 ? "updated" : "short";
|
|
8659
|
+
}
|
|
8660
|
+
};
|
|
8661
|
+
}
|
|
8160
8662
|
//#endregion
|
|
8161
|
-
//#region ../core/dist/tool-arguments-
|
|
8663
|
+
//#region ../core/dist/tool-arguments-Bc-xUPsF.mjs
|
|
8162
8664
|
const APPLIED = Symbol.for("agentcomms.strictToolArguments");
|
|
8163
8665
|
const TARGET = "draft-2020-12";
|
|
8164
8666
|
/**
|
|
@@ -17948,183 +18450,6 @@ strictObject({
|
|
|
17948
18450
|
if (manifest.skills.contract !== `skills/_shared/contract-${family}.md`) issue(["skills", "contract"], `skills/_shared/contract-${family}.md: a skill's contract is chosen by its prefix`);
|
|
17949
18451
|
}
|
|
17950
18452
|
});
|
|
17951
|
-
const WINDOWS_RESERVED = /^(con|prn|aux|nul|conin\$|conout\$|com[0-9¹²³]|lpt[0-9¹²³])$/i;
|
|
17952
|
-
const UNSAFE_FILENAME_CHARS = /[/\\\u0000-\u001f\u007f<>:"|?*]/g;
|
|
17953
|
-
/** Truncates a string to at most `maxBytes` UTF-8 bytes without splitting a code point. */
|
|
17954
|
-
function truncateBytes(value, maxBytes) {
|
|
17955
|
-
let out = "";
|
|
17956
|
-
let bytes = 0;
|
|
17957
|
-
for (const char of value) {
|
|
17958
|
-
const size = Buffer.byteLength(char, "utf8");
|
|
17959
|
-
if (bytes + size > maxBytes) break;
|
|
17960
|
-
out += char;
|
|
17961
|
-
bytes += size;
|
|
17962
|
-
}
|
|
17963
|
-
return out;
|
|
17964
|
-
}
|
|
17965
|
-
/**
|
|
17966
|
-
* A file name that is safe to create on macOS, Linux and Windows: NFC-normalised, no separators or control
|
|
17967
|
-
* characters, no leading or trailing dots and spaces, not a Windows reserved device name, at most `maxBytes` UTF-8
|
|
17968
|
-
* bytes with the extension kept. Never empty.
|
|
17969
|
-
*/
|
|
17970
|
-
function safeFilename(name, maxBytes = 255, fallback = "attachment") {
|
|
17971
|
-
let cleaned = [...name.normalize("NFC")].filter((character) => !isDangerous(character.codePointAt(0) ?? 0)).join("").replace(UNSAFE_FILENAME_CHARS, "_").replace(/\s+/g, " ");
|
|
17972
|
-
cleaned = cleaned.replace(/^[.\s]+/, "").replace(/[.\s]+$/, "");
|
|
17973
|
-
if (cleaned === "") cleaned = fallback;
|
|
17974
|
-
const extension = extname(cleaned);
|
|
17975
|
-
if (WINDOWS_RESERVED.test((cleaned.split(".")[0] ?? cleaned).trimEnd())) cleaned = `_${cleaned}`;
|
|
17976
|
-
if (Buffer.byteLength(cleaned, "utf8") <= maxBytes) return cleaned;
|
|
17977
|
-
const keptExtension = Buffer.byteLength(extension, "utf8") < maxBytes / 2 ? extension : "";
|
|
17978
|
-
return truncateBytes(keptExtension ? cleaned.slice(0, -keptExtension.length) : cleaned, maxBytes - Buffer.byteLength(keptExtension, "utf8")) + keptExtension;
|
|
17979
|
-
}
|
|
17980
|
-
/** True when `candidate` is `root` itself or strictly inside it (lexically). */
|
|
17981
|
-
function isInside(candidate, root) {
|
|
17982
|
-
const path = relative(resolve(root), resolve(candidate));
|
|
17983
|
-
return path === "" || !path.startsWith(`..${sep}`) && path !== ".." && !isAbsolute(path);
|
|
17984
|
-
}
|
|
17985
|
-
async function realpathOfExistingAncestor(path) {
|
|
17986
|
-
let current = resolve(path);
|
|
17987
|
-
const tail = [];
|
|
17988
|
-
for (;;) try {
|
|
17989
|
-
const real = await realpath(current);
|
|
17990
|
-
return tail.length ? join(real, ...tail.reverse()) : real;
|
|
17991
|
-
} catch (error) {
|
|
17992
|
-
const parent = dirname(current);
|
|
17993
|
-
if (error.code !== "ENOENT" || parent === current) throw error;
|
|
17994
|
-
tail.push(basename(current));
|
|
17995
|
-
current = parent;
|
|
17996
|
-
}
|
|
17997
|
-
}
|
|
17998
|
-
/**
|
|
17999
|
-
* Resolves `target` for writing and proves it stays inside `root` after symlinks in its existing ancestors are
|
|
18000
|
-
* followed. Throws BAD_DATA otherwise. The root must already exist.
|
|
18001
|
-
*/
|
|
18002
|
-
async function resolveInsideRoot(root, target) {
|
|
18003
|
-
const realRoot = await realpath(root);
|
|
18004
|
-
const lexical = resolve(root, target);
|
|
18005
|
-
if (!isInside(lexical, root)) throw new CommsError("BAD_DATA", `refusing to write outside ${root}`, { details: { target } });
|
|
18006
|
-
if (!isInside(await realpathOfExistingAncestor(lexical), realRoot)) throw new CommsError("BAD_DATA", `refusing to write through a link that leaves ${root}`, { details: { target } });
|
|
18007
|
-
return lexical;
|
|
18008
|
-
}
|
|
18009
|
-
/**
|
|
18010
|
-
* Creates a new file for writing without following a symlink at the final component and without overwriting an
|
|
18011
|
-
* existing file. Picks `name-2.ext`, `name-3.ext`… when the name is taken.
|
|
18012
|
-
*/
|
|
18013
|
-
async function createUniqueFile(directory, filename) {
|
|
18014
|
-
const extension = extname(filename);
|
|
18015
|
-
const stem = extension ? filename.slice(0, -extension.length) : filename;
|
|
18016
|
-
const noFollow = process.platform === "win32" ? 0 : constants$1.O_NOFOLLOW;
|
|
18017
|
-
for (let attempt = 1; attempt < 1e3; attempt += 1) {
|
|
18018
|
-
const name = attempt === 1 ? filename : `${stem}-${attempt}${extension}`;
|
|
18019
|
-
const path = join(directory, name);
|
|
18020
|
-
try {
|
|
18021
|
-
return {
|
|
18022
|
-
path,
|
|
18023
|
-
handle: await open(path, constants$1.O_WRONLY | constants$1.O_CREAT | constants$1.O_EXCL | noFollow, 384)
|
|
18024
|
-
};
|
|
18025
|
-
} catch (error) {
|
|
18026
|
-
if (error.code === "EEXIST") continue;
|
|
18027
|
-
throw error;
|
|
18028
|
-
}
|
|
18029
|
-
}
|
|
18030
|
-
throw new CommsError("BAD_DATA", `could not find a free file name in ${directory}: the name and every numbered one after it are taken`);
|
|
18031
|
-
}
|
|
18032
|
-
/**
|
|
18033
|
-
* Default places attachments may never be read from, whatever the allowed roots say. `~/.*` means every dot-entry
|
|
18034
|
-
* directly under home — SSH, cloud, npm, git and shell credentials, agent configs; `**∕.git/**` any repository's git
|
|
18035
|
-
* directory; `**∕.env*` dotenv files anywhere.
|
|
18036
|
-
*/
|
|
18037
|
-
function defaultAttachDeny(configDir, env = process.env) {
|
|
18038
|
-
const deny = [
|
|
18039
|
-
configDir,
|
|
18040
|
-
"~/.*",
|
|
18041
|
-
"~/Library",
|
|
18042
|
-
"**/.git/**",
|
|
18043
|
-
"**/.env*"
|
|
18044
|
-
];
|
|
18045
|
-
if (env.APPDATA) deny.push(env.APPDATA);
|
|
18046
|
-
if (env.LOCALAPPDATA) deny.push(env.LOCALAPPDATA);
|
|
18047
|
-
return deny;
|
|
18048
|
-
}
|
|
18049
|
-
async function realOrResolved(path) {
|
|
18050
|
-
try {
|
|
18051
|
-
return await realpath(path);
|
|
18052
|
-
} catch {
|
|
18053
|
-
return resolve(path);
|
|
18054
|
-
}
|
|
18055
|
-
}
|
|
18056
|
-
/**
|
|
18057
|
-
* Proves a local file may be attached to a draft: it resolves (following links) to a regular file inside one of the
|
|
18058
|
-
* allowed roots and inside none of the deny entries. A deny entry of the form `**` + `/name*` matches by file name
|
|
18059
|
-
* prefix (so `.env*` matches `.env` and `.env.local`). Returns the real path.
|
|
18060
|
-
*/
|
|
18061
|
-
async function checkAttachable(path, policy) {
|
|
18062
|
-
const home = policy.home;
|
|
18063
|
-
const requested = resolve(expandHome(path, home));
|
|
18064
|
-
let real;
|
|
18065
|
-
try {
|
|
18066
|
-
real = await realpath(requested);
|
|
18067
|
-
} catch {
|
|
18068
|
-
throw new CommsError("NOT_FOUND", `attachment not found: ${path}`);
|
|
18069
|
-
}
|
|
18070
|
-
if (!(await stat(real)).isFile()) throw new CommsError("BAD_DATA", `not a regular file: ${path}`);
|
|
18071
|
-
if (!(await Promise.all(policy.roots.map((root) => realOrResolved(expandHome(root, home))))).some((root) => isInside(real, root))) throw new CommsError("BAD_DATA", `attachments must come from an allowed folder; ${path} is outside them`, { hint: "Move the file into an allowed folder, or add its folder to defaults.attachRoots with the CLI." });
|
|
18072
|
-
const name = basename(real);
|
|
18073
|
-
const homeDir = await realOrResolved(home ?? (await import("node:os")).homedir());
|
|
18074
|
-
for (const entry of policy.deny) {
|
|
18075
|
-
if (entry === "~/.*") {
|
|
18076
|
-
if (isInside(real, homeDir)) {
|
|
18077
|
-
const first = relative(homeDir, real).split(sep)[0] ?? "";
|
|
18078
|
-
if (first.startsWith(".")) throw new CommsError("BAD_DATA", `refusing to attach a file from ~/${first}: hidden folders in your home are never attached`);
|
|
18079
|
-
}
|
|
18080
|
-
continue;
|
|
18081
|
-
}
|
|
18082
|
-
if (entry === "**/.git/**") {
|
|
18083
|
-
if (real.split(sep).some((segment) => segment.toLowerCase() === ".git")) throw new CommsError("BAD_DATA", "refusing to attach a file from a .git folder");
|
|
18084
|
-
continue;
|
|
18085
|
-
}
|
|
18086
|
-
if (entry.startsWith("**/")) {
|
|
18087
|
-
const pattern = entry.slice(3).toLowerCase();
|
|
18088
|
-
const candidate = name.toLowerCase();
|
|
18089
|
-
const prefix = pattern.endsWith("*") ? pattern.slice(0, -1) : pattern;
|
|
18090
|
-
if (pattern.endsWith("*") ? candidate.startsWith(prefix) : candidate === prefix) throw new CommsError("BAD_DATA", `refusing to attach ${name}: files matching ${entry} are never attached`);
|
|
18091
|
-
continue;
|
|
18092
|
-
}
|
|
18093
|
-
const denied = await realOrResolved(expandHome(entry, home));
|
|
18094
|
-
if (isInside(real, denied)) throw new CommsError("BAD_DATA", `refusing to attach a file from ${entry}`);
|
|
18095
|
-
}
|
|
18096
|
-
return real;
|
|
18097
|
-
}
|
|
18098
|
-
/**
|
|
18099
|
-
* A caller-supplied subdirectory, checked before it is joined to anything.
|
|
18100
|
-
*
|
|
18101
|
-
* `path.join` is not a boundary, and it was being used as one. `join('work', '../personal')` is `'personal'`: the
|
|
18102
|
-
* alias segment is cancelled, the result still resolves inside the downloads root, so `resolveInsideRoot` allows it
|
|
18103
|
-
* — and one mailbox's files are written into another mailbox's folder, over the `manifest.json` that is that
|
|
18104
|
-
* mailbox's own record of where its attachments came from. `join('work', '/etc/cron.d')` is `'work/etc/cron.d'`:
|
|
18105
|
-
* the leading separator is simply dropped, so an absolute path that every document describes as refused is quietly
|
|
18106
|
-
* accepted under a name the caller never asked for.
|
|
18107
|
-
*
|
|
18108
|
-
* Neither is an escape from the root, which is why neither showed up as a jail failure. In one respect they are
|
|
18109
|
-
* worse than an escape: they succeed, and report a path the caller was never told about.
|
|
18110
|
-
*
|
|
18111
|
-
* So the check happens here, on the caller's own string, before any join — absolute paths and `..` are refused
|
|
18112
|
-
* rather than normalised away. A nested `reports/august` is fine; that is what the option is for.
|
|
18113
|
-
*/
|
|
18114
|
-
function relativeSubpath(out, field = "out") {
|
|
18115
|
-
const value = (out ?? "").trim();
|
|
18116
|
-
if (!value) return "";
|
|
18117
|
-
const refuse = (why) => {
|
|
18118
|
-
throw new CommsError("BAD_DATA", `${field} must be a relative subpath: ${why}`, {
|
|
18119
|
-
hint: "Pass something like \"reports/august\". It is always placed inside this account’s own folder.",
|
|
18120
|
-
details: { value }
|
|
18121
|
-
});
|
|
18122
|
-
};
|
|
18123
|
-
if (isAbsolute(value) || /^[A-Za-z]:/.test(value) || value.startsWith("/") || value.startsWith("\\")) refuse("it is an absolute path");
|
|
18124
|
-
const segments = value.split(/[/\\]+/);
|
|
18125
|
-
if (segments.includes("..")) refuse("it climbs out with \"..\"");
|
|
18126
|
-
return segments.filter((segment) => segment && segment !== ".").join("/");
|
|
18127
|
-
}
|
|
18128
18453
|
/**
|
|
18129
18454
|
* Email HTML is attacker-controlled. Before any of it becomes text a model reads, this removes what a human reading
|
|
18130
18455
|
* the message in a mail client would not see — the channel used by hidden-text prompt injection (e.g. zero-size or
|
|
@@ -22255,4 +22580,4 @@ async function revokeSendApproval(context, approvalId) {
|
|
|
22255
22580
|
});
|
|
22256
22581
|
}
|
|
22257
22582
|
//#endregion
|
|
22258
|
-
export {
|
|
22583
|
+
export { installExitStatus 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 _, RESEND_DOWNLOAD_ORIGIN as _t, revokeSendApproval as a, runCommand as at, listAccounts as b, downloadReceived as c, serverPruneChange as ct, listReceived as d, truncateDisplay as dt, openCore as et, listScheduled as f, updateGateAtTerminal as ft, showSentEmail as g, RESEND_API_ORIGIN as gt, showReceived as h, REFUSED as ht, prepareSend as i, renderPrune as it, MODES as j, ResendContext as k, getMetrics as l, terminalUpdateHooks as lt, listSuppressions as m, writeResult as mt, executeSend as n, refuseUnclaimedApproval as nt, sendStatus as o, runUpdateCheckChild as ot, listSentEmails as p, updateToolGate as pt, colorEnabled as q, finishSendApproval as r, renderInstall as rt, cancelScheduledChange as s, serverInstallChange as st, beginSendApproval as t, paint as tt, listDomains as u, toCommsError as ut, addAccountChange as v, ROUTES as vt, policyReport as w, modeOf as x, inspectKey as y, VERSION$1 as yt, UPDATE_CHECK_CHILD_COMMAND as z };
|