@agentcomms/whatsapp 0.18.2 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.mjs +12 -25
- package/dist/index.d.mts +55 -3
- package/dist/index.mjs +2 -2
- package/dist/{server-CRhBErHD.mjs → server-DP6vz_rw.mjs} +2 -3
- package/dist/{stdio-entry-CH_b3o13.mjs → stdio-entry-83qlMjK3.mjs} +1 -1
- package/dist/{sync-D5RWl-ZJ.mjs → sync-Coys7_vB.mjs} +2596 -955
- package/package.json +3 -3
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import { C as unknown, S as union, b as strictObject, c as boolean, h as number, i as _enum, l as discriminatedUnion, m as looseObject, n as datetime, p as literal, s as array, x as string, y as record } from "./iso-USuVU_sk.mjs";
|
|
1
|
+
import { C as unknown, S as union, b as strictObject, c as boolean, g as object, h as number, i as _enum, l as discriminatedUnion, m as looseObject, n as datetime, p as literal, s as array, x as string, y as record } from "./iso-USuVU_sk.mjs";
|
|
2
2
|
import { access, chmod, constants, link, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, stat, unlink, utimes } from "node:fs/promises";
|
|
3
|
-
import path, { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import path, { basename, delimiter, dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from "node:path";
|
|
4
4
|
import { domainToASCII, fileURLToPath, pathToFileURL } from "node:url";
|
|
5
5
|
import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
|
|
6
6
|
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
7
7
|
import { styleText } from "node:util";
|
|
8
8
|
import { homedir, platform, userInfo } from "node:os";
|
|
9
|
-
import { accessSync, constants as constants$1, readFileSync, realpathSync, statSync } from "node:fs";
|
|
9
|
+
import { accessSync, constants as constants$1, lstatSync, readFileSync, readlinkSync, realpathSync, statSync } from "node:fs";
|
|
10
10
|
import { execFile, spawn } from "node:child_process";
|
|
11
11
|
//#endregion
|
|
12
12
|
//#region src/version.ts
|
|
13
13
|
/** The package version, read from package.json at build time. */
|
|
14
|
-
const VERSION$
|
|
14
|
+
const VERSION$2 = "0.20.0";
|
|
15
15
|
//#endregion
|
|
16
16
|
//#region src/caller.ts
|
|
17
17
|
/** This package's name, as its manifest has it. */
|
|
@@ -28,23 +28,20 @@ const CALLER = Object.freeze({
|
|
|
28
28
|
packageName: PACKAGE_NAME
|
|
29
29
|
});
|
|
30
30
|
//#endregion
|
|
31
|
-
//#region ../core/dist/update-check-
|
|
31
|
+
//#region ../core/dist/update-check-N_-uYKZQ.mjs
|
|
32
32
|
/**
|
|
33
33
|
* Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
|
|
34
34
|
* optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
|
|
35
|
-
* on the code, humans read the message. Exit statuses follow BSD sysexits where one fits; 10
|
|
35
|
+
* on the code, humans read the message. Exit statuses follow BSD sysexits where one fits; 10 is ours.
|
|
36
|
+
*
|
|
37
|
+
* 11 was ours too, for a command the daily update check stopped (design 2026-09-28). Nothing is stopped for being behind
|
|
38
|
+
* any more (design 2026-10-16, U1), so `UPDATE_REQUIRED` and 11 are retired: a client that still sees one is talking to
|
|
39
|
+
* a server older than 0.20.0.
|
|
36
40
|
*/
|
|
37
41
|
const EXIT_CODES = {
|
|
38
42
|
OK: 0,
|
|
39
43
|
UNEXPECTED: 1,
|
|
40
44
|
APPROVAL: 10,
|
|
41
|
-
/**
|
|
42
|
-
* A command that did not run because a newer release is out and nobody at a terminal could be asked about it
|
|
43
|
-
* (design 2026-09-28 §3). None of sysexits' codes means "run something else first", and a script that branches on
|
|
44
|
-
* it has exactly two things to do — update, or put it off — so it has one of its own. 11, beside 10: both mean a
|
|
45
|
-
* person has to decide before the command can go on.
|
|
46
|
-
*/
|
|
47
|
-
UPDATE: 11,
|
|
48
45
|
USAGE: 64,
|
|
49
46
|
BAD_DATA: 65,
|
|
50
47
|
NOT_FOUND: 66,
|
|
@@ -109,11 +106,6 @@ const ERROR_REGISTRY = {
|
|
|
109
106
|
retryable: false,
|
|
110
107
|
summary: "a safety setting can only be loosened by a person"
|
|
111
108
|
},
|
|
112
|
-
UPDATE_REQUIRED: {
|
|
113
|
-
exit: EXIT_CODES.UPDATE,
|
|
114
|
-
retryable: false,
|
|
115
|
-
summary: "a newer release is out: update first, or put it off until tomorrow"
|
|
116
|
-
},
|
|
117
109
|
USAGE: {
|
|
118
110
|
exit: EXIT_CODES.USAGE,
|
|
119
111
|
retryable: false,
|
|
@@ -903,6 +895,203 @@ var TaintStore = class {
|
|
|
903
895
|
return sortedOrigins([...originEntry(sidecar), ...sidecar !== void 0 && isNewer(entry.at, sidecar.at) ? ["read"] : []]);
|
|
904
896
|
}
|
|
905
897
|
};
|
|
898
|
+
/**
|
|
899
|
+
* One table of dangerous characters, shared by the inbound sanitiser (which strips them from sender-controlled text)
|
|
900
|
+
* and the preview renderer (which makes them visible), so the two can never disagree.
|
|
901
|
+
*/
|
|
902
|
+
/** Zero-width and formatting characters, bidi controls, variation selectors and Unicode tag characters. */
|
|
903
|
+
const INVISIBLE_RANGES = [
|
|
904
|
+
[173, 173],
|
|
905
|
+
[847, 847],
|
|
906
|
+
[1564, 1564],
|
|
907
|
+
[4447, 4448],
|
|
908
|
+
[6068, 6069],
|
|
909
|
+
[6155, 6159],
|
|
910
|
+
[8203, 8207],
|
|
911
|
+
[8232, 8233],
|
|
912
|
+
[8234, 8238],
|
|
913
|
+
[8288, 8292],
|
|
914
|
+
[8294, 8303],
|
|
915
|
+
[12644, 12644],
|
|
916
|
+
[65024, 65039],
|
|
917
|
+
[65279, 65279],
|
|
918
|
+
[65440, 65440],
|
|
919
|
+
[917504, 917631]
|
|
920
|
+
];
|
|
921
|
+
function isInvisible(codePoint) {
|
|
922
|
+
for (const [from, to] of INVISIBLE_RANGES) if (codePoint >= from && codePoint <= to) return true;
|
|
923
|
+
return false;
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* C0 controls except tab and newline (ESC, and so every ANSI/OSC sequence, starts here), DEL, and C1 controls (CSI
|
|
927
|
+
* among them). A lone carriage return counts: it moves a terminal cursor back over text already printed.
|
|
928
|
+
*/
|
|
929
|
+
function isControl(codePoint) {
|
|
930
|
+
if (codePoint === 9 || codePoint === 10) return false;
|
|
931
|
+
return codePoint < 32 || codePoint === 127 || codePoint >= 128 && codePoint <= 159;
|
|
932
|
+
}
|
|
933
|
+
function isDangerous(codePoint) {
|
|
934
|
+
return isControl(codePoint) || isInvisible(codePoint);
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Strips control characters (ESC, CSI, OSC and the rest), DEL, lone carriage returns, zero-width, bidi-control and tag
|
|
938
|
+
* characters from sender-controlled text; returns the text and how many were removed. CRLF becomes LF first.
|
|
939
|
+
*
|
|
940
|
+
* This lives beside the table rather than in the sanitiser because `neutralise` needs it too, and the two must not
|
|
941
|
+
* drift: a pattern that looks for `</untrusted-content` cannot see it through a zero-width space, so stripping
|
|
942
|
+
* has to happen before any such pattern runs, on every path, not only on the ones that render a body.
|
|
943
|
+
*/
|
|
944
|
+
function stripInvisible(text) {
|
|
945
|
+
let removed = 0;
|
|
946
|
+
let out = "";
|
|
947
|
+
for (const char of text.replace(/\r\n/g, "\n")) if (isDangerous(char.codePointAt(0) ?? 0)) removed += 1;
|
|
948
|
+
else out += char;
|
|
949
|
+
return {
|
|
950
|
+
text: out,
|
|
951
|
+
removed
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Labelling what an agent sends (design 2026-10-15-agent-label): the phrase `<name> (<word>)`, who a message appears to
|
|
956
|
+
* come from, and which setting changes loosen that. Pure: nothing here reads the configuration or talks to a channel,
|
|
957
|
+
* so every channel decides the same way from the same settings.
|
|
958
|
+
*/
|
|
959
|
+
/** L2: the words a label may use, exactly as written. Adding one is a code change with its own review. */
|
|
960
|
+
const AGENT_LABEL_WORDS = Object.freeze([
|
|
961
|
+
"agent",
|
|
962
|
+
"assistant",
|
|
963
|
+
"twin",
|
|
964
|
+
"minion",
|
|
965
|
+
"bot",
|
|
966
|
+
"fake",
|
|
967
|
+
"AI"
|
|
968
|
+
]);
|
|
969
|
+
/** L4: the kinds of act a rule may name. Slack posts, replies, reacts, edits and shares files; mail sends and replies. */
|
|
970
|
+
const ACT_KINDS = Object.freeze([
|
|
971
|
+
"post",
|
|
972
|
+
"reply",
|
|
973
|
+
"reaction",
|
|
974
|
+
"edit",
|
|
975
|
+
"file",
|
|
976
|
+
"send"
|
|
977
|
+
]);
|
|
978
|
+
/**
|
|
979
|
+
* How an `agent` act looks in one Slack workspace (the owner's decision, 2026-10-17): `app`, the default, posts as the
|
|
980
|
+
* app where S2 says it can, with Slack's APP tag, and labelled as the person elsewhere; `labelled` posts every one as
|
|
981
|
+
* the person, labelled as S3's fallback is, even where the app could post. Slack's APP tag cannot be hidden or renamed,
|
|
982
|
+
* so this is the person's choice between that tag and words in the message.
|
|
983
|
+
*/
|
|
984
|
+
const SLACK_STYLES = Object.freeze(["app", "labelled"]);
|
|
985
|
+
/** L2's characters a name may not hold: parentheses would fake a second label, the rest break an address header. */
|
|
986
|
+
const FORBIDDEN_NAME_CHARACTERS = /[()<>"@,;:\\]/u;
|
|
987
|
+
function isAgentLabelWord(value) {
|
|
988
|
+
return typeof value === "string" && AGENT_LABEL_WORDS.includes(value);
|
|
989
|
+
}
|
|
990
|
+
function isSlackStyle(value) {
|
|
991
|
+
return typeof value === "string" && SLACK_STYLES.includes(value);
|
|
992
|
+
}
|
|
993
|
+
/** A Slack workspace's style, by its account id: `app` unless `labelled` is recorded for it. */
|
|
994
|
+
function slackStyleOf(settings, accountId) {
|
|
995
|
+
const styles = settings?.slackStyles;
|
|
996
|
+
return styles !== void 0 && Object.hasOwn(styles, accountId) && styles[accountId] === "labelled" ? "labelled" : "app";
|
|
997
|
+
}
|
|
998
|
+
/** Why a name cannot be labelled, or undefined when it can. */
|
|
999
|
+
function nameProblem(name) {
|
|
1000
|
+
if (name.length === 0) return "the name is empty";
|
|
1001
|
+
if (name.trim() !== name) return "the name starts or ends with a space";
|
|
1002
|
+
if (FORBIDDEN_NAME_CHARACTERS.test(name)) return "the name holds a parenthesis or one of <>\"@,;:\\";
|
|
1003
|
+
for (const char of name) if (isDangerous(char.codePointAt(0) ?? 0)) return "the name holds a hidden or control character";
|
|
1004
|
+
}
|
|
1005
|
+
const ruleKey = (rule) => JSON.stringify([
|
|
1006
|
+
rule.account,
|
|
1007
|
+
rule.accountId ?? null,
|
|
1008
|
+
rule.conversation ?? null,
|
|
1009
|
+
rule.kinds === void 0 ? null : [...rule.kinds].sort(),
|
|
1010
|
+
rule.as
|
|
1011
|
+
]);
|
|
1012
|
+
/**
|
|
1013
|
+
* L5: the changes that need the person's confirmation — anything that could leave a message unlabelled or change how
|
|
1014
|
+
* the label looks. Adding an `agent` rule or removing a `me` rule tightens, and is not listed. A Slack workspace moved
|
|
1015
|
+
* from `app` to `labelled` trades Slack's own APP tag, which no one can fake, for words in the message, so it waits;
|
|
1016
|
+
* moved back to `app`, it tightens. Its values are each moved workspace's style, before and after.
|
|
1017
|
+
*/
|
|
1018
|
+
function classifyAgentLabelChange(before, after) {
|
|
1019
|
+
const changes = [];
|
|
1020
|
+
const wordBefore = before?.word ?? "agent";
|
|
1021
|
+
const wordAfter = after?.word ?? "agent";
|
|
1022
|
+
if (wordBefore !== wordAfter) changes.push({
|
|
1023
|
+
path: "agentLabel.word",
|
|
1024
|
+
was: wordBefore,
|
|
1025
|
+
now: wordAfter
|
|
1026
|
+
});
|
|
1027
|
+
if ((before?.name ?? null) !== (after?.name ?? null)) changes.push({
|
|
1028
|
+
path: "agentLabel.name",
|
|
1029
|
+
was: before?.name,
|
|
1030
|
+
now: after?.name
|
|
1031
|
+
});
|
|
1032
|
+
const rulesBefore = new Map((before?.rules ?? []).map((rule) => [ruleKey(rule), rule]));
|
|
1033
|
+
const rulesAfter = new Map((after?.rules ?? []).map((rule) => [ruleKey(rule), rule]));
|
|
1034
|
+
const meAdded = [...rulesAfter].some(([key, rule]) => rule.as === "me" && !rulesBefore.has(key));
|
|
1035
|
+
const agentRemoved = [...rulesBefore].some(([key, rule]) => rule.as === "agent" && !rulesAfter.has(key));
|
|
1036
|
+
if (meAdded || agentRemoved) changes.push({
|
|
1037
|
+
path: "agentLabel.rules",
|
|
1038
|
+
was: before?.rules ?? [],
|
|
1039
|
+
now: after?.rules ?? []
|
|
1040
|
+
});
|
|
1041
|
+
if (JSON.stringify(sorted(before?.slackIcons)) !== JSON.stringify(sorted(after?.slackIcons))) changes.push({
|
|
1042
|
+
path: "agentLabel.slackIcons",
|
|
1043
|
+
was: before?.slackIcons ?? {},
|
|
1044
|
+
now: after?.slackIcons ?? {}
|
|
1045
|
+
});
|
|
1046
|
+
const styled = [.../* @__PURE__ */ new Set([...Object.keys(before?.slackStyles ?? {}), ...Object.keys(after?.slackStyles ?? {})])].filter((id) => slackStyleOf(before, id) !== slackStyleOf(after, id)).sort();
|
|
1047
|
+
if (styled.some((id) => slackStyleOf(after, id) === "labelled")) changes.push({
|
|
1048
|
+
path: "agentLabel.slackStyles",
|
|
1049
|
+
was: Object.fromEntries(styled.map((id) => [id, slackStyleOf(before, id)])),
|
|
1050
|
+
now: Object.fromEntries(styled.map((id) => [id, slackStyleOf(after, id)]))
|
|
1051
|
+
});
|
|
1052
|
+
return changes;
|
|
1053
|
+
}
|
|
1054
|
+
function sorted(record) {
|
|
1055
|
+
return Object.entries(record ?? {}).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
1056
|
+
}
|
|
1057
|
+
const EMOJI_NAME = /^:[a-z0-9_+'-]{1,100}:$/u;
|
|
1058
|
+
function isRule(value) {
|
|
1059
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1060
|
+
const rule = value;
|
|
1061
|
+
if (typeof rule.account !== "string" || rule.account.length === 0) return false;
|
|
1062
|
+
if (rule.as !== "agent" && rule.as !== "me") return false;
|
|
1063
|
+
if (rule.accountId !== void 0 && (typeof rule.accountId !== "string" || rule.accountId.length === 0)) return false;
|
|
1064
|
+
if (rule.conversation !== void 0 && (typeof rule.conversation !== "string" || rule.conversation.length === 0)) return false;
|
|
1065
|
+
if (rule.kinds !== void 0 && !(Array.isArray(rule.kinds) && rule.kinds.length > 0 && rule.kinds.every((kind) => ACT_KINDS.includes(kind)))) return false;
|
|
1066
|
+
return true;
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* The stored `agentLabel`, read so that a bad value can only tighten. An unknown word reads as `agent`, an invalid
|
|
1070
|
+
* name as no name, and a rule list with any invalid rule is ignored whole: dropping one `agent` rule could leave a
|
|
1071
|
+
* broader `me` rule in charge. A malformed icon entry is dropped, falling back to the plain photo. A Slack style that is
|
|
1072
|
+
* neither `app` nor `labelled` is dropped too, so that workspace is `app`: the stronger disclosure.
|
|
1073
|
+
*/
|
|
1074
|
+
function readAgentLabelSettings(raw) {
|
|
1075
|
+
if (typeof raw !== "object" || raw === null) return {};
|
|
1076
|
+
const stored = raw;
|
|
1077
|
+
const settings = {};
|
|
1078
|
+
if (isAgentLabelWord(stored.word)) settings.word = stored.word;
|
|
1079
|
+
if (typeof stored.name === "string" && nameProblem(stored.name) === void 0) settings.name = stored.name;
|
|
1080
|
+
if (Array.isArray(stored.rules) && stored.rules.every(isRule)) settings.rules = stored.rules.map((rule) => ({ ...rule }));
|
|
1081
|
+
if (typeof stored.slackIcons === "object" && stored.slackIcons !== null) {
|
|
1082
|
+
const icons = Object.entries(stored.slackIcons).filter((entry) => typeof entry[1] === "string" && EMOJI_NAME.test(entry[1]));
|
|
1083
|
+
if (icons.length > 0) settings.slackIcons = Object.fromEntries(icons);
|
|
1084
|
+
}
|
|
1085
|
+
if (typeof stored.slackStyles === "object" && stored.slackStyles !== null && !Array.isArray(stored.slackStyles)) {
|
|
1086
|
+
const styles = Object.entries(stored.slackStyles).filter((entry) => isSlackStyle(entry[1]));
|
|
1087
|
+
if (styles.length > 0) settings.slackStyles = Object.fromEntries(styles);
|
|
1088
|
+
}
|
|
1089
|
+
return settings;
|
|
1090
|
+
}
|
|
1091
|
+
/** The person's label settings from a loaded configuration, read as `readAgentLabelSettings` reads them. */
|
|
1092
|
+
function agentLabelOf(config) {
|
|
1093
|
+
return readAgentLabelSettings(config.agentLabel);
|
|
1094
|
+
}
|
|
906
1095
|
/** Every first-party channel, the core first: what core knows about each without installing it. */
|
|
907
1096
|
const CHANNEL_SNAPSHOT = [
|
|
908
1097
|
{
|
|
@@ -1072,7 +1261,11 @@ const CHANNEL_SNAPSHOT = [
|
|
|
1072
1261
|
word: "slack",
|
|
1073
1262
|
can: "post to Slack"
|
|
1074
1263
|
},
|
|
1075
|
-
hosts: [
|
|
1264
|
+
hosts: [
|
|
1265
|
+
"slack.com",
|
|
1266
|
+
"files.slack.com",
|
|
1267
|
+
"avatars.slack-edge.com"
|
|
1268
|
+
],
|
|
1076
1269
|
approve: "agent-slack approve",
|
|
1077
1270
|
skills: {
|
|
1078
1271
|
prefix: "slack-",
|
|
@@ -2968,17 +3161,508 @@ function commandLinePackage(command, args) {
|
|
|
2968
3161
|
const line = [command, ...withoutPathOptions(args).words].join(" ");
|
|
2969
3162
|
return /(@[\w.-]+\/[\w.-]+|(?<=\s)[\w.-]+-mcp)(?=@|\s|$)/.exec(line)?.[1];
|
|
2970
3163
|
}
|
|
3164
|
+
/** The scope every server package is published under; a launcher is named for the rest of the package name. */
|
|
3165
|
+
const SCOPE$1 = "@agentcomms";
|
|
3166
|
+
const LAUNCH = "launch";
|
|
3167
|
+
const RUNTIMES = "runtime";
|
|
3168
|
+
const POINTER = ".current";
|
|
3169
|
+
const FALLBACK = ".fallback";
|
|
3170
|
+
const LOCK = ".lock";
|
|
3171
|
+
/**
|
|
3172
|
+
* What a launcher hands the process it starts (re-review of 0.20.0, severity 6): its own name, so the process knows it
|
|
3173
|
+
* was started through its package's stable launcher — the one the background job switches — and not by npx, a
|
|
3174
|
+
* versioned or local registration, or a command from somewhere else. The update notice holds back only for such a
|
|
3175
|
+
* process what the job will install (`update-gate.ts`), and only a run of `update --background` started through the
|
|
3176
|
+
* core launcher, as the job starts it, records that the job ran (`update-background.ts`).
|
|
3177
|
+
*/
|
|
3178
|
+
const LAUNCHER_ENV = "AGENT_COMMS_LAUNCHER";
|
|
3179
|
+
/** A launcher's name: one plain word, never a path — the unscoped name of a server package. */
|
|
3180
|
+
const NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
3181
|
+
/** A runtime's folder in `<data>/runtime`: no separator, nothing that starts with a dot. */
|
|
3182
|
+
const FOLDER = /^[0-9A-Za-z][0-9A-Za-z.+_-]*$/;
|
|
3183
|
+
/** The version a managed runtime's folder starts with, as `mcp prune` reads one. */
|
|
3184
|
+
const VERSION$1 = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]*)?$/;
|
|
3185
|
+
/** The CLI inside a runtime, as words from the runtime's folder: what `managedRuntimeEntry` names. */
|
|
3186
|
+
function cliWords(name) {
|
|
3187
|
+
return [
|
|
3188
|
+
"node_modules",
|
|
3189
|
+
SCOPE$1,
|
|
3190
|
+
name,
|
|
3191
|
+
"dist",
|
|
3192
|
+
"cli.mjs"
|
|
3193
|
+
];
|
|
3194
|
+
}
|
|
3195
|
+
function checkedName(name) {
|
|
3196
|
+
if (!NAME.test(name)) throw new CommsError("CONFIG", `${JSON.stringify(name)} is not a launcher name: one plain word, such as gmail`);
|
|
3197
|
+
return name;
|
|
3198
|
+
}
|
|
3199
|
+
/** The launcher a server package is started through: its name without the scope, `@agentcomms/gmail` → `gmail`. */
|
|
3200
|
+
function launcherName(packageName) {
|
|
3201
|
+
const [scope, rest, extra] = packageName.split("/");
|
|
3202
|
+
if (scope !== SCOPE$1 || rest === void 0 || extra !== void 0) throw new CommsError("CONFIG", `${packageName} is not a package a stable launcher starts`);
|
|
3203
|
+
return checkedName(rest);
|
|
3204
|
+
}
|
|
3205
|
+
/** Where a launcher and its pointer are: `<data>/launch/<name>` and `<data>/launch/<name>.current`. */
|
|
3206
|
+
function launcherPaths(dataDir, name) {
|
|
3207
|
+
const directory = join(dataDir, LAUNCH);
|
|
3208
|
+
checkedName(name);
|
|
3209
|
+
return {
|
|
3210
|
+
launcher: join(directory, name),
|
|
3211
|
+
pointer: join(directory, `${name}${POINTER}`)
|
|
3212
|
+
};
|
|
3213
|
+
}
|
|
3214
|
+
/** Where the launcher records that it fell back: `<data>/launch/<name>.fallback`. */
|
|
3215
|
+
function fallbackRecordPath(dataDir, name) {
|
|
3216
|
+
return join(dataDir, LAUNCH, `${checkedName(name)}${FALLBACK}`);
|
|
3217
|
+
}
|
|
3218
|
+
/** The CLI a runtime holds for a launcher: `<runtime>/node_modules/@agentcomms/<name>/dist/cli.mjs`. */
|
|
3219
|
+
function launcherCli(runtimeDir, name) {
|
|
3220
|
+
return join(runtimeDir, ...cliWords(checkedName(name)));
|
|
3221
|
+
}
|
|
3222
|
+
/** The version a runtime folder of this launcher is, `0.20.0` for `0.20.0-gmail`; null for any other folder. */
|
|
3223
|
+
function launcherRuntimeVersion(runtimeDir, name) {
|
|
3224
|
+
const folder = basename(runtimeDir);
|
|
3225
|
+
const suffix = `-${checkedName(name)}`;
|
|
3226
|
+
if (!folder.endsWith(suffix)) return null;
|
|
3227
|
+
const version = folder.slice(0, -suffix.length);
|
|
3228
|
+
return VERSION$1.test(version) ? version : null;
|
|
3229
|
+
}
|
|
3230
|
+
/** A runtime folder this launcher may point at: a folder of its own in `<data>/runtime`, named for it. */
|
|
3231
|
+
function isOwnFolder(folder, name) {
|
|
3232
|
+
return FOLDER.test(folder) && folder.endsWith(`-${name}`) && folder.length > name.length + 1;
|
|
3233
|
+
}
|
|
3234
|
+
/** The pointer's text: one runtime folder a line, read by the launcher with nothing but its shell's built-ins. */
|
|
3235
|
+
function pointerText(current, previous) {
|
|
3236
|
+
return [
|
|
3237
|
+
"# The runtime the stable launcher beside this file starts, written by agent-communications. Do not edit.",
|
|
3238
|
+
`current=${current}`,
|
|
3239
|
+
...previous === void 0 ? [] : [`previous=${previous}`],
|
|
3240
|
+
""
|
|
3241
|
+
].join("\n");
|
|
3242
|
+
}
|
|
3243
|
+
/** The pointer in `text`, as runtime folders, or null when it is not one this writes. */
|
|
3244
|
+
function parsePointer(text, name) {
|
|
3245
|
+
let current;
|
|
3246
|
+
let previous;
|
|
3247
|
+
for (const line of text.split(/\r?\n/)) {
|
|
3248
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
3249
|
+
const at = line.indexOf("=");
|
|
3250
|
+
const key = at < 0 ? line : line.slice(0, at);
|
|
3251
|
+
const value = at < 0 ? "" : line.slice(at + 1);
|
|
3252
|
+
if (key === "current" && current === void 0) current = value;
|
|
3253
|
+
else if (key === "previous" && previous === void 0) previous = value;
|
|
3254
|
+
else return null;
|
|
3255
|
+
}
|
|
3256
|
+
if (current === void 0 || !isOwnFolder(current, name)) return null;
|
|
3257
|
+
if (previous !== void 0 && !isOwnFolder(previous, name)) return null;
|
|
3258
|
+
return previous === void 0 ? { current } : {
|
|
3259
|
+
current,
|
|
3260
|
+
previous
|
|
3261
|
+
};
|
|
3262
|
+
}
|
|
3263
|
+
function asPaths(dataDir, folders) {
|
|
3264
|
+
const runtimes = join(dataDir, RUNTIMES);
|
|
3265
|
+
return folders.previous === void 0 ? { current: join(runtimes, folders.current) } : {
|
|
3266
|
+
current: join(runtimes, folders.current),
|
|
3267
|
+
previous: join(runtimes, folders.previous)
|
|
3268
|
+
};
|
|
3269
|
+
}
|
|
3270
|
+
function unreadable$1(pointer, why) {
|
|
3271
|
+
return new CommsError("CONFIG", `${pointer} cannot be read as a pointer: ${why}`, { hint: "It is written by managed installs and updates, never by hand. Run a managed install or an update again: it writes the pointer afresh." });
|
|
3272
|
+
}
|
|
3273
|
+
/** The pointer's runtime folders, or undefined when there is no pointer; refused when it is not one this writes. */
|
|
3274
|
+
async function readFolders(dataDir, name) {
|
|
3275
|
+
const { pointer } = launcherPaths(dataDir, name);
|
|
3276
|
+
let text;
|
|
3277
|
+
try {
|
|
3278
|
+
text = await readFile(pointer, "utf8");
|
|
3279
|
+
} catch (error) {
|
|
3280
|
+
if (error.code === "ENOENT") return void 0;
|
|
3281
|
+
throw unreadable$1(pointer, error.code ?? String(error));
|
|
3282
|
+
}
|
|
3283
|
+
const folders = parsePointer(text, name);
|
|
3284
|
+
if (folders === null) throw unreadable$1(pointer, `it does not name a runtime of ${name} in ${join(dataDir, RUNTIMES)}`);
|
|
3285
|
+
return folders;
|
|
3286
|
+
}
|
|
3287
|
+
/**
|
|
3288
|
+
* The runtime a launcher starts, and the one before it; undefined when it has no pointer yet.
|
|
3289
|
+
*
|
|
3290
|
+
* A pointer that is there and is not one this writes is refused rather than taken for none: what it names may be in
|
|
3291
|
+
* use, and "no pointer" would let a prune or an update act as though nothing were.
|
|
3292
|
+
*/
|
|
3293
|
+
async function readPointer(dataDir, name) {
|
|
3294
|
+
const folders = await readFolders(dataDir, name);
|
|
3295
|
+
return folders === void 0 ? void 0 : asPaths(dataDir, folders);
|
|
3296
|
+
}
|
|
3297
|
+
/** `readPointer`, for a reader that cannot wait: the registration readers, which the command locator calls. */
|
|
3298
|
+
function readPointerSync(dataDir, name) {
|
|
3299
|
+
const { pointer } = launcherPaths(dataDir, name);
|
|
3300
|
+
let text;
|
|
3301
|
+
try {
|
|
3302
|
+
text = readFileSync(pointer, "utf8");
|
|
3303
|
+
} catch (error) {
|
|
3304
|
+
if (error.code === "ENOENT") return void 0;
|
|
3305
|
+
throw unreadable$1(pointer, error.code ?? String(error));
|
|
3306
|
+
}
|
|
3307
|
+
const folders = parsePointer(text, name);
|
|
3308
|
+
if (folders === null) throw unreadable$1(pointer, `it does not name a runtime of ${name} in ${join(dataDir, RUNTIMES)}`);
|
|
3309
|
+
return asPaths(dataDir, folders);
|
|
3310
|
+
}
|
|
3311
|
+
/** Whether the CLI a runtime holds for this launcher is a file that can be read: whether the launcher can start it. */
|
|
3312
|
+
async function launcherCanStart(runtimeDir, name) {
|
|
3313
|
+
const cli = launcherCli(runtimeDir, name);
|
|
3314
|
+
try {
|
|
3315
|
+
if (!(await stat(cli)).isFile()) return false;
|
|
3316
|
+
await access(cli, constants$1.R_OK);
|
|
3317
|
+
return true;
|
|
3318
|
+
} catch {
|
|
3319
|
+
return false;
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
/** `launcherCanStart`, for a reader that cannot wait. */
|
|
3323
|
+
function launcherCanStartSync(runtimeDir, name) {
|
|
3324
|
+
const cli = launcherCli(runtimeDir, name);
|
|
3325
|
+
try {
|
|
3326
|
+
if (!statSync(cli).isFile()) return false;
|
|
3327
|
+
accessSync(cli, constants$1.R_OK);
|
|
3328
|
+
return true;
|
|
3329
|
+
} catch {
|
|
3330
|
+
return false;
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
/** The folder of a runtime this launcher may be switched to, or why not. Nothing outside `<data>/runtime` is one. */
|
|
3334
|
+
async function switchableFolder(dataDir, name, runtimeDir) {
|
|
3335
|
+
const folder = basename(runtimeDir);
|
|
3336
|
+
if (!(resolve(dirname(runtimeDir)) === resolve(join(dataDir, RUNTIMES)) && isOwnFolder(folder, name))) throw new CommsError("CONFIG", `${runtimeDir} is not a runtime of ${name} in ${join(dataDir, RUNTIMES)}, so the launcher was not switched to it`);
|
|
3337
|
+
if (!await launcherCanStart(runtimeDir, name)) throw new CommsError("CONFIG", `${runtimeDir} has no CLI to start at ${launcherCli(runtimeDir, name)}, so the launcher was not switched to it`, { hint: "Install that runtime again: a managed install or an update puts its CLI in place." });
|
|
3338
|
+
return folder;
|
|
3339
|
+
}
|
|
3340
|
+
/** One change of a pointer at a time, across every process of this installation. */
|
|
3341
|
+
function withPointerLock(dataDir, name, change) {
|
|
3342
|
+
return withFileLock(join(dataDir, LAUNCH, `${checkedName(name)}${LOCK}`), change, {
|
|
3343
|
+
timeoutMs: 1e4,
|
|
3344
|
+
staleMs: 3e4
|
|
3345
|
+
});
|
|
3346
|
+
}
|
|
3347
|
+
/**
|
|
3348
|
+
* Points the launcher at `runtimeDir`, and returns the pointer it replaced (undefined when there was none).
|
|
3349
|
+
*
|
|
3350
|
+
* `switchPointer` without the answer; exported for an install or an update that may have to put it back
|
|
3351
|
+
* (`restorePointer`) and must know exactly what it was.
|
|
3352
|
+
*/
|
|
3353
|
+
async function exchangePointer(dataDir, name, runtimeDir) {
|
|
3354
|
+
const folder = await switchableFolder(dataDir, name, runtimeDir);
|
|
3355
|
+
return withPointerLock(dataDir, name, async () => {
|
|
3356
|
+
const before = await readFolders(dataDir, name).catch(() => void 0);
|
|
3357
|
+
if (before?.current === folder) return asPaths(dataDir, before);
|
|
3358
|
+
await writeFileAtomic(launcherPaths(dataDir, name).pointer, pointerText(folder, before?.current));
|
|
3359
|
+
return before === void 0 ? void 0 : asPaths(dataDir, before);
|
|
3360
|
+
});
|
|
3361
|
+
}
|
|
3362
|
+
/**
|
|
3363
|
+
* Puts back the pointer a switch replaced (`exchangePointer`'s answer; undefined removes the pointer), only while the
|
|
3364
|
+
* pointer still names `expected` — what that switch pointed it at. Anything else means something switched it since,
|
|
3365
|
+
* and that newer pointer stays. Says whether it put it back.
|
|
3366
|
+
*/
|
|
3367
|
+
async function restorePointer(dataDir, name, before, expected) {
|
|
3368
|
+
return withPointerLock(dataDir, name, async () => {
|
|
3369
|
+
const now = await readFolders(dataDir, name).catch(() => void 0);
|
|
3370
|
+
if (now === void 0 || now.current !== basename(expected)) return false;
|
|
3371
|
+
const { pointer } = launcherPaths(dataDir, name);
|
|
3372
|
+
if (before === void 0) await rm(pointer, { force: true });
|
|
3373
|
+
else await writeFileAtomic(pointer, pointerText(basename(before.current), before.previous === void 0 ? void 0 : basename(before.previous)));
|
|
3374
|
+
return true;
|
|
3375
|
+
});
|
|
3376
|
+
}
|
|
3377
|
+
function posixWord$1(word) {
|
|
3378
|
+
return `'${word.replaceAll("'", `'"'"'`)}'`;
|
|
3379
|
+
}
|
|
3380
|
+
/** The launcher for macOS and Linux: the shell's built-ins only, so nothing is looked for on PATH. */
|
|
3381
|
+
function posixSource(dataDir, name, node) {
|
|
3382
|
+
const runtimes = join(dataDir, RUNTIMES);
|
|
3383
|
+
const { pointer } = launcherPaths(dataDir, name);
|
|
3384
|
+
const record = fallbackRecordPath(dataDir, name);
|
|
3385
|
+
return `#!/bin/sh
|
|
3386
|
+
# The stable launcher of agent-communications for ${name}, format 1. Written by managed installs and updates; do not edit.
|
|
3387
|
+
# It starts the runtime the pointer beside it names, with the Node below. When that runtime's CLI is not there to
|
|
3388
|
+
# start, it starts the previous runtime instead, once, and records that beside the pointer. It never falls back from a
|
|
3389
|
+
# runtime that started, and never writes to stdout: an MCP client speaks there.
|
|
3390
|
+
node=${posixWord$1(node)}
|
|
3391
|
+
runtimes=${posixWord$1(runtimes)}
|
|
3392
|
+
pointer=${posixWord$1(pointer)}
|
|
3393
|
+
record=${posixWord$1(record)}
|
|
3394
|
+
cli=${posixWord$1(cliWords(name).join("/"))}
|
|
3395
|
+
suffix=${posixWord$1(`-${name}`)}
|
|
3396
|
+
${LAUNCHER_ENV}=${posixWord$1(name)}
|
|
3397
|
+
export ${LAUNCHER_ENV}
|
|
3398
|
+
current=
|
|
3399
|
+
previous=
|
|
3400
|
+
if [ -f "$pointer" ] && [ -r "$pointer" ]; then
|
|
3401
|
+
while IFS= read -r line || [ -n "$line" ]; do
|
|
3402
|
+
case $line in
|
|
3403
|
+
current=*) [ -z "$current" ] && current=\${line#current=} ;;
|
|
3404
|
+
previous=*) [ -z "$previous" ] && previous=\${line#previous=} ;;
|
|
3405
|
+
esac
|
|
3406
|
+
done < "$pointer"
|
|
3407
|
+
fi
|
|
3408
|
+
startable() {
|
|
3409
|
+
case $1 in
|
|
3410
|
+
''|.*|*[!0-9A-Za-z.+_-]*) return 1 ;;
|
|
3411
|
+
esac
|
|
3412
|
+
case $1 in
|
|
3413
|
+
*"$suffix") ;;
|
|
3414
|
+
*) return 1 ;;
|
|
3415
|
+
esac
|
|
3416
|
+
[ "$1" != "$suffix" ] && [ -f "$runtimes/$1/$cli" ] && [ -r "$runtimes/$1/$cli" ]
|
|
3417
|
+
}
|
|
3418
|
+
if startable "$current"; then
|
|
3419
|
+
exec "$node" "$runtimes/$current/$cli" "$@"
|
|
3420
|
+
fi
|
|
3421
|
+
if [ "$previous" != "$current" ] && startable "$previous"; then
|
|
3422
|
+
{ printf 'from=%s\\n' "$current"; printf 'to=%s\\n' "$previous"; } 2>/dev/null > "$record"
|
|
3423
|
+
exec "$node" "$runtimes/$previous/$cli" "$@"
|
|
3424
|
+
fi
|
|
3425
|
+
printf '%s\\n' "agent-communications: the ${name} launcher has no runtime it can start: its pointer, $pointer, names \${current:-none}, and the runtime before it \${previous:-none}, and neither CLI is in $runtimes. A managed install or an update puts one in place." >&2
|
|
3426
|
+
exit 78
|
|
3427
|
+
`;
|
|
3428
|
+
}
|
|
3429
|
+
/**
|
|
3430
|
+
* The launcher for Windows: a CommonJS script Node runs — the registration starts the Node, and this is its first word —
|
|
3431
|
+
* which runs the runtime's CLI in the same process, so nothing re-parses the words and no second process is left behind
|
|
3432
|
+
* when the client stops the server. `argv[1]` is set to that CLI, as though Node had been started on it.
|
|
3433
|
+
*/
|
|
3434
|
+
function windowsSource(dataDir, name, node) {
|
|
3435
|
+
const runtimes = join(dataDir, RUNTIMES);
|
|
3436
|
+
const { pointer } = launcherPaths(dataDir, name);
|
|
3437
|
+
const record = fallbackRecordPath(dataDir, name);
|
|
3438
|
+
const json = JSON.stringify;
|
|
3439
|
+
return `// The stable launcher of agent-communications for ${name}, format 1, for Windows. Written by managed installs and
|
|
3440
|
+
// updates; do not edit. Started by ${json(node)} with this file as its first word: it starts the runtime the pointer
|
|
3441
|
+
// beside it names, in this process. When that runtime's CLI is not there to start, it starts the previous runtime
|
|
3442
|
+
// instead, once, and records that beside the pointer. It never falls back from a runtime that started.
|
|
3443
|
+
'use strict';
|
|
3444
|
+
const fs = require('node:fs');
|
|
3445
|
+
const path = require('node:path');
|
|
3446
|
+
const { pathToFileURL } = require('node:url');
|
|
3447
|
+
const runtimes = ${json(runtimes)};
|
|
3448
|
+
const pointer = ${json(pointer)};
|
|
3449
|
+
const record = ${json(record)};
|
|
3450
|
+
const cli = ${json(cliWords(name))};
|
|
3451
|
+
const suffix = ${json(`-${name}`)};
|
|
3452
|
+
let current = '';
|
|
3453
|
+
let previous = '';
|
|
3454
|
+
try {
|
|
3455
|
+
for (const line of fs.readFileSync(pointer, 'utf8').split(/\\r?\\n/)) {
|
|
3456
|
+
if (line.startsWith('current=') && current === '') current = line.slice(8);
|
|
3457
|
+
else if (line.startsWith('previous=') && previous === '') previous = line.slice(9);
|
|
3458
|
+
}
|
|
3459
|
+
} catch {}
|
|
3460
|
+
function startable(folder) {
|
|
3461
|
+
if (!/^[0-9A-Za-z][0-9A-Za-z.+_-]*$/.test(folder) || !folder.endsWith(suffix) || folder === suffix) return null;
|
|
3462
|
+
const entry = path.join(runtimes, folder, ...cli);
|
|
3463
|
+
try {
|
|
3464
|
+
if (!fs.statSync(entry).isFile()) return null;
|
|
3465
|
+
fs.accessSync(entry, fs.constants.R_OK);
|
|
3466
|
+
return entry;
|
|
3467
|
+
} catch {
|
|
3468
|
+
return null;
|
|
3469
|
+
}
|
|
3470
|
+
}
|
|
3471
|
+
let entry = startable(current);
|
|
3472
|
+
if (entry === null && previous !== current) {
|
|
3473
|
+
entry = startable(previous);
|
|
3474
|
+
if (entry !== null) {
|
|
3475
|
+
try {
|
|
3476
|
+
const temp = record + '.' + process.pid + '.tmp';
|
|
3477
|
+
fs.writeFileSync(temp, 'from=' + current + '\\nto=' + previous + '\\n');
|
|
3478
|
+
fs.renameSync(temp, record);
|
|
3479
|
+
} catch {}
|
|
3480
|
+
}
|
|
3481
|
+
}
|
|
3482
|
+
if (entry === null) {
|
|
3483
|
+
process.stderr.write('agent-communications: the ' + ${json(name)} + ' launcher has no runtime it can start: its pointer, ' + pointer + ', names ' + (current || 'none') + ', and the runtime before it ' + (previous || 'none') + ', and neither CLI is in ' + runtimes + '. A managed install or an update puts one in place.\\n');
|
|
3484
|
+
process.exit(78);
|
|
3485
|
+
}
|
|
3486
|
+
process.argv[1] = entry;
|
|
3487
|
+
process.env.${LAUNCHER_ENV} = ${json(name)};
|
|
3488
|
+
import(pathToFileURL(entry).href).catch((error) => {
|
|
3489
|
+
process.exitCode = 1;
|
|
3490
|
+
console.error(error);
|
|
3491
|
+
});
|
|
3492
|
+
`;
|
|
3493
|
+
}
|
|
3494
|
+
/**
|
|
3495
|
+
* Writes the launcher `<data>/launch/<name>`: a temporary file, then a rename, so a launcher starting meanwhile runs
|
|
3496
|
+
* the old one or the new, whole. It starts the runtime its pointer names with `node` — given as the registration and
|
|
3497
|
+
* the shim name it, a link left a link (CUE-492) — and falls back once to `previous` when that runtime cannot start
|
|
3498
|
+
* (see the top of this module).
|
|
3499
|
+
*
|
|
3500
|
+
* POSIX: a shell script, executable by its owner alone. Windows (`platform`): a script for Node, with a `package.json`
|
|
3501
|
+
* beside it saying it is CommonJS, whatever a folder above the data directory says; the registration starts `node`.
|
|
3502
|
+
*/
|
|
3503
|
+
async function writeLauncher(dataDir, name, node, options = {}) {
|
|
3504
|
+
const platform = options.platform ?? process.platform;
|
|
3505
|
+
const { launcher } = launcherPaths(dataDir, name);
|
|
3506
|
+
if (!(platform === "win32" ? win32.isAbsolute(node) : posix.isAbsolute(node))) throw new CommsError("CONFIG", `the launcher's Node has to be named by its full path, not ${JSON.stringify(node)}`);
|
|
3507
|
+
const data = resolve(dataDir);
|
|
3508
|
+
if (platform === "win32") {
|
|
3509
|
+
await writeFileAtomic(join(dirname(launcher), "package.json"), `${JSON.stringify({ type: "commonjs" })}\n`);
|
|
3510
|
+
await writeFileAtomic(launcher, windowsSource(data, name, node));
|
|
3511
|
+
return;
|
|
3512
|
+
}
|
|
3513
|
+
await writeFileAtomic(launcher, posixSource(data, name, node), 448);
|
|
3514
|
+
}
|
|
3515
|
+
/** Whether the launcher file is there. */
|
|
3516
|
+
async function launcherExists(dataDir, name) {
|
|
3517
|
+
try {
|
|
3518
|
+
return (await stat(launcherPaths(dataDir, name).launcher)).isFile();
|
|
3519
|
+
} catch {
|
|
3520
|
+
return false;
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3523
|
+
/** The `--data-dir` a registration pins, in either form, before any `--`. */
|
|
3524
|
+
function dataDirPin(args) {
|
|
3525
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
3526
|
+
const word = args[index];
|
|
3527
|
+
if (word === "--") return void 0;
|
|
3528
|
+
if (word === "--data-dir") return args[index + 1] === "--" ? void 0 : args[index + 1] || void 0;
|
|
3529
|
+
if (word.startsWith("--data-dir=")) return word.slice(11) || void 0;
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
3532
|
+
/** A path as words to compare: separators made one, a trailing one dropped, without case on Windows. */
|
|
3533
|
+
function comparable(path, platform) {
|
|
3534
|
+
const plain = path.replaceAll("\\", "/").replace(/\/+/g, "/").replace(/\/$/, "");
|
|
3535
|
+
return platform === "win32" ? plain.toLowerCase() : plain;
|
|
3536
|
+
}
|
|
3537
|
+
/**
|
|
3538
|
+
* The stable launcher a registered entry starts, read from its own words, or null when it starts none.
|
|
3539
|
+
*
|
|
3540
|
+
* Ours only when the launcher is in the very data directory the entry itself pins with `--data-dir` — the program on
|
|
3541
|
+
* POSIX, the first word after Node on Windows (`platform`) — named like one: a program called `launch/gmail` anywhere
|
|
3542
|
+
* else is somebody else's. Nothing is read from disk.
|
|
3543
|
+
*/
|
|
3544
|
+
function registeredLauncher(server, platform) {
|
|
3545
|
+
const dataDir = dataDirPin(server.args);
|
|
3546
|
+
if (dataDir === void 0) return null;
|
|
3547
|
+
const directory = `${comparable(dataDir, platform)}/${LAUNCH}`;
|
|
3548
|
+
for (const candidate of [server.command, server.args[0]]) {
|
|
3549
|
+
if (candidate === void 0) continue;
|
|
3550
|
+
const words = comparable(candidate, platform);
|
|
3551
|
+
if (!words.startsWith(`${directory}/`)) continue;
|
|
3552
|
+
const name = candidate.split(/[\\/]+/).at(-1) ?? "";
|
|
3553
|
+
if (words !== `${directory}/${comparable(name, platform)}` || !NAME.test(name)) continue;
|
|
3554
|
+
return {
|
|
3555
|
+
dataDir,
|
|
3556
|
+
name,
|
|
3557
|
+
launcher: candidate
|
|
3558
|
+
};
|
|
3559
|
+
}
|
|
3560
|
+
return null;
|
|
3561
|
+
}
|
|
3562
|
+
/**
|
|
3563
|
+
* The programs this package starts, named so that no folder it happens to be in can stand in for them.
|
|
3564
|
+
*
|
|
3565
|
+
* A bare name is looked up, and on Windows the lookup starts in the current folder: libuv tries it before `PATH`, and
|
|
3566
|
+
* so does every program that looks a name up with `SearchPath` or `CreateProcess`. The current folder is where a
|
|
3567
|
+
* command was run or a server was started — and, since a download asks where to save, one of the three places a
|
|
3568
|
+
* stranger's file may be saved into. A `reg.exe` or `rundll32.exe` saved there would then be what runs the next time
|
|
3569
|
+
* this package reads the registry or opens a browser. So a Windows program is named by its full path, under the Windows
|
|
3570
|
+
* folder, and every process started on Windows is told, by `NoDefaultCurrentDirectoryInExePath`, not to look in its
|
|
3571
|
+
* own current folder for the programs it starts in turn.
|
|
3572
|
+
*/
|
|
3573
|
+
/** An environment variable by name, whatever its case: Windows's are case-blind, an object passed in is not. */
|
|
3574
|
+
function envValue(env, name) {
|
|
3575
|
+
const wanted = name.toLowerCase();
|
|
3576
|
+
for (const [key, value] of Object.entries(env)) if (key.toLowerCase() === wanted && value) return value;
|
|
3577
|
+
}
|
|
3578
|
+
/**
|
|
3579
|
+
* The Windows folder: `%SystemRoot%` (or `%windir%`) when it names a folder on a drive, and `C:\Windows` otherwise.
|
|
3580
|
+
* A value that is not a drive's absolute path — relative, a share, a device path — is not taken: it would be looked up
|
|
3581
|
+
* against the current folder, or on another machine, which is what naming the program in full is to avoid.
|
|
3582
|
+
*/
|
|
3583
|
+
function windowsFolder(env = process.env) {
|
|
3584
|
+
for (const name of ["SystemRoot", "windir"]) {
|
|
3585
|
+
const value = envValue(env, name);
|
|
3586
|
+
if (value !== void 0 && /^[A-Za-z]:[\\/]/.test(value)) return path.win32.resolve(value);
|
|
3587
|
+
}
|
|
3588
|
+
return "C:\\Windows";
|
|
3589
|
+
}
|
|
3590
|
+
/** A program of Windows's own by its full path: `%SystemRoot%\System32\<name>`, never a bare name to look up. */
|
|
3591
|
+
function windowsSystemProgram(name, env = process.env) {
|
|
3592
|
+
return path.win32.join(windowsFolder(env), "System32", name);
|
|
3593
|
+
}
|
|
3594
|
+
/**
|
|
3595
|
+
* The environment a child process is started with: `env` as it is, and on Windows also
|
|
3596
|
+
* `NoDefaultCurrentDirectoryInExePath=1`, so that the child — `rundll32.exe`, a Node started again — never takes a
|
|
3597
|
+
* program from its current folder either. Elsewhere nothing is added: a Unix shell looks in the current folder only
|
|
3598
|
+
* when `PATH` says to.
|
|
3599
|
+
*/
|
|
3600
|
+
function childEnvironment(env = process.env, platform = process.platform) {
|
|
3601
|
+
if (platform !== "win32") return env;
|
|
3602
|
+
return {
|
|
3603
|
+
...env,
|
|
3604
|
+
NoDefaultCurrentDirectoryInExePath: "1"
|
|
3605
|
+
};
|
|
3606
|
+
}
|
|
3607
|
+
/**
|
|
3608
|
+
* The directories of a search path that name a folder on their own: absolute, and on Windows on a drive. An empty
|
|
3609
|
+
* entry means the current folder to a Unix shell, and a relative one is read against it; either would make the program
|
|
3610
|
+
* found — and run — depend on where this was started, which is where a download may have saved a stranger's file.
|
|
3611
|
+
*/
|
|
3612
|
+
function absoluteSearchPath(entries, platform = process.platform) {
|
|
3613
|
+
return entries.filter((entry) => platform === "win32" ? /^[A-Za-z]:[\\/]/.test(entry) : entry !== "" && path.posix.isAbsolute(entry));
|
|
3614
|
+
}
|
|
3615
|
+
/** This Node's real file, or its path as reported when that cannot be resolved. */
|
|
3616
|
+
function realFile(path) {
|
|
3617
|
+
try {
|
|
3618
|
+
return realpathSync(path);
|
|
3619
|
+
} catch {
|
|
3620
|
+
return path;
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3623
|
+
/** Two real paths are one file: compared without case on Windows, whose paths are. */
|
|
3624
|
+
function sameRealPath(a, b, platform) {
|
|
3625
|
+
return platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
3626
|
+
}
|
|
3627
|
+
/** The names `node` is found by in one folder of PATH, as a shell finds it: with each PATHEXT extension on Windows. */
|
|
3628
|
+
function nodeNames(env, platform) {
|
|
3629
|
+
if (platform !== "win32") return ["node"];
|
|
3630
|
+
return (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").filter(Boolean).map((extension) => `node${extension.toLowerCase()}`);
|
|
3631
|
+
}
|
|
3632
|
+
/**
|
|
3633
|
+
* The path a command names this Node by: the first `node` on `env`'s PATH that is the same real file as
|
|
3634
|
+
* `process.execPath`, as PATH spells it; otherwise `process.execPath`. With no `env`, nothing is looked for.
|
|
3635
|
+
*
|
|
3636
|
+
* Read when a command is located, as everything else in it is (D6): a link removed or repointed later is what the
|
|
3637
|
+
* command then meets. Only absolute folders of PATH are read, as the registrations' own search reads them.
|
|
3638
|
+
*/
|
|
3639
|
+
function nodeProgram(env, platform = process.platform) {
|
|
3640
|
+
const own = process.execPath;
|
|
3641
|
+
if (env === void 0) return own;
|
|
3642
|
+
const real = realFile(own);
|
|
3643
|
+
const search = env.PATH ?? env.Path ?? "";
|
|
3644
|
+
for (const directory of absoluteSearchPath(search.split(delimiter), platform)) for (const name of nodeNames(env, platform)) {
|
|
3645
|
+
const candidate = join(directory, name);
|
|
3646
|
+
try {
|
|
3647
|
+
if (!statSync(candidate).isFile()) continue;
|
|
3648
|
+
accessSync(candidate, constants$1.X_OK);
|
|
3649
|
+
if (!sameRealPath(realpathSync(candidate), real, platform)) continue;
|
|
3650
|
+
return sameRealPath(candidate, own, platform) ? own : candidate;
|
|
3651
|
+
} catch {}
|
|
3652
|
+
}
|
|
3653
|
+
return own;
|
|
3654
|
+
}
|
|
2971
3655
|
const RECEIPT_VERSION = 1;
|
|
2972
3656
|
const RECEIPT_FILE = "managed-shims.json";
|
|
2973
3657
|
const MUTATION_LOCK_FILE = "managed-shims.lock";
|
|
2974
3658
|
const POSIX_SHIM_MODE = 493;
|
|
2975
3659
|
const WINDOWS_SHIM_NOTICE = "Managed CLI shims are not offered on Windows yet because cmd.exe re-parses arguments forwarded through .cmd files, including shell metacharacters. Use the full-path command or the labelled PowerShell & line the result provides.";
|
|
2976
|
-
function platformOf(context) {
|
|
3660
|
+
function platformOf$1(context) {
|
|
2977
3661
|
return context.platform ?? process.platform;
|
|
2978
3662
|
}
|
|
2979
3663
|
/** Why the selected platform has no managed shim in this release. */
|
|
2980
3664
|
function managedShimUnsupportedNotice(context = {}) {
|
|
2981
|
-
return platformOf(context) === "win32" ? WINDOWS_SHIM_NOTICE : "Managed CLI shims are offered only on macOS and Linux in this release. Use the full-path command the result provides.";
|
|
3665
|
+
return platformOf$1(context) === "win32" ? WINDOWS_SHIM_NOTICE : "Managed CLI shims are offered only on macOS and Linux in this release. Use the full-path command the result provides.";
|
|
2982
3666
|
}
|
|
2983
3667
|
/** The per-user directory managed installs put their command shims in. */
|
|
2984
3668
|
function managedShimDirectory(context) {
|
|
@@ -2987,7 +3671,7 @@ function managedShimDirectory(context) {
|
|
|
2987
3671
|
}
|
|
2988
3672
|
/** Managed CLI shims are POSIX-only until Windows has an argument-forwarding form that does not re-parse input. */
|
|
2989
3673
|
function managedShimsSupported(context = {}) {
|
|
2990
|
-
const platform = platformOf(context);
|
|
3674
|
+
const platform = platformOf$1(context);
|
|
2991
3675
|
return platform === "darwin" || platform === "linux";
|
|
2992
3676
|
}
|
|
2993
3677
|
/** A managed shim's name: one plain command word, never a path, so joining it to the directory stays inside it. */
|
|
@@ -3171,7 +3855,7 @@ function normalPath(path, platform) {
|
|
|
3171
3855
|
return platform === "win32" ? trimmed.toLowerCase() : trimmed;
|
|
3172
3856
|
}
|
|
3173
3857
|
function directoryOnPath(context, directory) {
|
|
3174
|
-
const platform = platformOf(context);
|
|
3858
|
+
const platform = platformOf$1(context);
|
|
3175
3859
|
const value = context.env.PATH ?? context.env.Path ?? "";
|
|
3176
3860
|
const separator = platform === "win32" ? ";" : ":";
|
|
3177
3861
|
return value.split(separator).filter(Boolean).some((part) => normalPath(part, platform) === normalPath(directory, platform));
|
|
@@ -3305,59 +3989,6 @@ async function removeManagedShimsForRuntimes(context, runtimes, options = {}) {
|
|
|
3305
3989
|
};
|
|
3306
3990
|
});
|
|
3307
3991
|
}
|
|
3308
|
-
/**
|
|
3309
|
-
* The programs this package starts, named so that no folder it happens to be in can stand in for them.
|
|
3310
|
-
*
|
|
3311
|
-
* A bare name is looked up, and on Windows the lookup starts in the current folder: libuv tries it before `PATH`, and
|
|
3312
|
-
* so does every program that looks a name up with `SearchPath` or `CreateProcess`. The current folder is where a
|
|
3313
|
-
* command was run or a server was started — and, since a download asks where to save, one of the three places a
|
|
3314
|
-
* stranger's file may be saved into. A `reg.exe` or `rundll32.exe` saved there would then be what runs the next time
|
|
3315
|
-
* this package reads the registry or opens a browser. So a Windows program is named by its full path, under the Windows
|
|
3316
|
-
* folder, and every process started on Windows is told, by `NoDefaultCurrentDirectoryInExePath`, not to look in its
|
|
3317
|
-
* own current folder for the programs it starts in turn.
|
|
3318
|
-
*/
|
|
3319
|
-
/** An environment variable by name, whatever its case: Windows's are case-blind, an object passed in is not. */
|
|
3320
|
-
function envValue(env, name) {
|
|
3321
|
-
const wanted = name.toLowerCase();
|
|
3322
|
-
for (const [key, value] of Object.entries(env)) if (key.toLowerCase() === wanted && value) return value;
|
|
3323
|
-
}
|
|
3324
|
-
/**
|
|
3325
|
-
* The Windows folder: `%SystemRoot%` (or `%windir%`) when it names a folder on a drive, and `C:\Windows` otherwise.
|
|
3326
|
-
* A value that is not a drive's absolute path — relative, a share, a device path — is not taken: it would be looked up
|
|
3327
|
-
* against the current folder, or on another machine, which is what naming the program in full is to avoid.
|
|
3328
|
-
*/
|
|
3329
|
-
function windowsFolder(env = process.env) {
|
|
3330
|
-
for (const name of ["SystemRoot", "windir"]) {
|
|
3331
|
-
const value = envValue(env, name);
|
|
3332
|
-
if (value !== void 0 && /^[A-Za-z]:[\\/]/.test(value)) return path.win32.resolve(value);
|
|
3333
|
-
}
|
|
3334
|
-
return "C:\\Windows";
|
|
3335
|
-
}
|
|
3336
|
-
/** A program of Windows's own by its full path: `%SystemRoot%\System32\<name>`, never a bare name to look up. */
|
|
3337
|
-
function windowsSystemProgram(name, env = process.env) {
|
|
3338
|
-
return path.win32.join(windowsFolder(env), "System32", name);
|
|
3339
|
-
}
|
|
3340
|
-
/**
|
|
3341
|
-
* The environment a child process is started with: `env` as it is, and on Windows also
|
|
3342
|
-
* `NoDefaultCurrentDirectoryInExePath=1`, so that the child — `rundll32.exe`, a Node started again — never takes a
|
|
3343
|
-
* program from its current folder either. Elsewhere nothing is added: a Unix shell looks in the current folder only
|
|
3344
|
-
* when `PATH` says to.
|
|
3345
|
-
*/
|
|
3346
|
-
function childEnvironment(env = process.env, platform = process.platform) {
|
|
3347
|
-
if (platform !== "win32") return env;
|
|
3348
|
-
return {
|
|
3349
|
-
...env,
|
|
3350
|
-
NoDefaultCurrentDirectoryInExePath: "1"
|
|
3351
|
-
};
|
|
3352
|
-
}
|
|
3353
|
-
/**
|
|
3354
|
-
* The directories of a search path that name a folder on their own: absolute, and on Windows on a drive. An empty
|
|
3355
|
-
* entry means the current folder to a Unix shell, and a relative one is read against it; either would make the program
|
|
3356
|
-
* found — and run — depend on where this was started, which is where a download may have saved a stranger's file.
|
|
3357
|
-
*/
|
|
3358
|
-
function absoluteSearchPath(entries, platform = process.platform) {
|
|
3359
|
-
return entries.filter((entry) => platform === "win32" ? /^[A-Za-z]:[\\/]/.test(entry) : entry !== "" && path.posix.isAbsolute(entry));
|
|
3360
|
-
}
|
|
3361
3992
|
/** The install options that pin a server to one account: the generic one, and Gmail's and Slack's own names. */
|
|
3362
3993
|
const PIN_OPTIONS = Object.freeze([
|
|
3363
3994
|
"account",
|
|
@@ -3539,6 +4170,8 @@ function pinnedVersion(argument, product, platform) {
|
|
|
3539
4170
|
*/
|
|
3540
4171
|
function isProductServer(server, product, platform) {
|
|
3541
4172
|
const fold = platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
|
|
4173
|
+
const stable = registeredLauncher(server, platform);
|
|
4174
|
+
if (stable !== null) return fold(stable.name) === fold(unscoped(product.packageName));
|
|
3542
4175
|
const launched = fold((server.command.split(/[\\/]+/).at(-1) ?? "").replace(/\.(?:cmd|exe|bat|ps1)$/i, ""));
|
|
3543
4176
|
if (launched && [product.binary, ...product.bins ?? []].map(fold).includes(launched)) return true;
|
|
3544
4177
|
if (server.packageName) {
|
|
@@ -3606,12 +4239,14 @@ async function installManagedRuntime(context, product, version) {
|
|
|
3606
4239
|
private: true
|
|
3607
4240
|
}, null, 2)}\n`);
|
|
3608
4241
|
const npmCli = await findNpmCli();
|
|
4242
|
+
const prefix = await realpath(root);
|
|
3609
4243
|
await run(context.env, process.execPath, [
|
|
3610
4244
|
npmCli,
|
|
3611
4245
|
"install",
|
|
3612
4246
|
"--prefix",
|
|
3613
|
-
|
|
4247
|
+
prefix,
|
|
3614
4248
|
"--save-exact",
|
|
4249
|
+
"--ignore-scripts",
|
|
3615
4250
|
"--no-audit",
|
|
3616
4251
|
"--no-fund",
|
|
3617
4252
|
`${product.packageName}@${version}`
|
|
@@ -3700,40 +4335,49 @@ async function buildEntry(context, product, options, apply, resolved) {
|
|
|
3700
4335
|
runtimeMissing: false
|
|
3701
4336
|
};
|
|
3702
4337
|
}
|
|
4338
|
+
const { dataDir } = context.core.paths;
|
|
4339
|
+
const entry = {
|
|
4340
|
+
...managedEntryShape(context, product, node, [
|
|
4341
|
+
...pathArgs,
|
|
4342
|
+
"mcp",
|
|
4343
|
+
...serverArgs
|
|
4344
|
+
]),
|
|
4345
|
+
env
|
|
4346
|
+
};
|
|
3703
4347
|
if (!apply) {
|
|
3704
|
-
const
|
|
4348
|
+
const name = launcherName(product.packageName);
|
|
3705
4349
|
const ready = await reusableRuntime(dataDir, product.packageName, product.version);
|
|
4350
|
+
const pointer = await readPointer(dataDir, name).catch(() => void 0);
|
|
3706
4351
|
return {
|
|
3707
|
-
entry
|
|
3708
|
-
command: node,
|
|
3709
|
-
args: [
|
|
3710
|
-
ready ?? managedRuntimeEntry(dataDir, product.packageName, product.version),
|
|
3711
|
-
...pathArgs,
|
|
3712
|
-
"mcp",
|
|
3713
|
-
...serverArgs
|
|
3714
|
-
],
|
|
3715
|
-
env
|
|
3716
|
-
},
|
|
4352
|
+
entry,
|
|
3717
4353
|
launcher,
|
|
3718
|
-
runtimeMissing: ready ===
|
|
4354
|
+
runtimeMissing: !(ready !== null && await launcherExists(dataDir, name) && pointer?.current === managedRuntimeDir(dataDir, product.packageName, product.version))
|
|
3719
4355
|
};
|
|
3720
4356
|
}
|
|
4357
|
+
await installManagedRuntime(context, product, product.version);
|
|
3721
4358
|
return {
|
|
3722
|
-
entry
|
|
3723
|
-
command: node,
|
|
3724
|
-
args: [
|
|
3725
|
-
await installManagedRuntime(context, product, product.version),
|
|
3726
|
-
...pathArgs,
|
|
3727
|
-
"mcp",
|
|
3728
|
-
...serverArgs
|
|
3729
|
-
],
|
|
3730
|
-
env
|
|
3731
|
-
},
|
|
4359
|
+
entry,
|
|
3732
4360
|
launcher,
|
|
3733
4361
|
runtimeMissing: false
|
|
3734
4362
|
};
|
|
3735
4363
|
}
|
|
3736
4364
|
/**
|
|
4365
|
+
* Points this product's stable launcher at the runtime of the release being installed — every registration of it on
|
|
4366
|
+
* this machine starts that from its next start — and writes the launcher afresh, with the Node the preview named.
|
|
4367
|
+
* Returns how to put the pointer back (`restorePointer`) if what comes after does not land.
|
|
4368
|
+
*/
|
|
4369
|
+
async function pointLauncher(context, product, node) {
|
|
4370
|
+
const { dataDir } = context.core.paths;
|
|
4371
|
+
const name = launcherName(product.packageName);
|
|
4372
|
+
const runtime = managedRuntimeDir(dataDir, product.packageName, product.version);
|
|
4373
|
+
await writeLauncher(dataDir, name, node, { platform: platformOf(context) });
|
|
4374
|
+
return {
|
|
4375
|
+
name,
|
|
4376
|
+
runtime,
|
|
4377
|
+
before: await exchangePointer(dataDir, name, runtime)
|
|
4378
|
+
};
|
|
4379
|
+
}
|
|
4380
|
+
/**
|
|
3737
4381
|
* The calling package's own CLI entry: `src/cli.ts` from source, `dist/cli.mjs` when bundled.
|
|
3738
4382
|
*
|
|
3739
4383
|
* `moduleUrl` comes from the product, not from this file. While this code lived inside the Gmail package,
|
|
@@ -4072,8 +4716,9 @@ async function preflightInstall(context, product, options) {
|
|
|
4072
4716
|
if (reported) previous = claimName(product, options, name, [reported], context.platform);
|
|
4073
4717
|
}
|
|
4074
4718
|
const { options: effective, kept } = keepNarrowing(product, options, previous);
|
|
4075
|
-
const
|
|
4076
|
-
const
|
|
4719
|
+
const launcher = options.launcher ?? "managed";
|
|
4720
|
+
const node = launcher === "managed" ? nodeProgram(context.env, platformOf(context)) : await resolveNode(context.env);
|
|
4721
|
+
const command = launcher === "npx" ? await resolveNpx(context.env, node) : launcher === "managed" ? managedEntryShape(context, product, node, []).command : node;
|
|
4077
4722
|
return {
|
|
4078
4723
|
scan,
|
|
4079
4724
|
target,
|
|
@@ -4084,6 +4729,27 @@ async function preflightInstall(context, product, options) {
|
|
|
4084
4729
|
command
|
|
4085
4730
|
};
|
|
4086
4731
|
}
|
|
4732
|
+
function platformOf(context) {
|
|
4733
|
+
return context.platform ?? process.platform;
|
|
4734
|
+
}
|
|
4735
|
+
/** A product's stable launcher in this installation's data directory: `<data>/launch/<name>`. */
|
|
4736
|
+
function productLauncher(dataDir, product) {
|
|
4737
|
+
return launcherPaths(dataDir, launcherName(product.packageName)).launcher;
|
|
4738
|
+
}
|
|
4739
|
+
/**
|
|
4740
|
+
* A managed entry: the stable launcher with `words`. On POSIX it is the program; on Windows Node is, with the launcher
|
|
4741
|
+
* as its first word, since a client starts no script there without a shell (see `launcher.ts`).
|
|
4742
|
+
*/
|
|
4743
|
+
function managedEntryShape(context, product, node, words) {
|
|
4744
|
+
const launcher = productLauncher(context.core.paths.dataDir, product);
|
|
4745
|
+
return platformOf(context) === "win32" ? {
|
|
4746
|
+
command: node,
|
|
4747
|
+
args: [launcher, ...words]
|
|
4748
|
+
} : {
|
|
4749
|
+
command: launcher,
|
|
4750
|
+
args: [...words]
|
|
4751
|
+
};
|
|
4752
|
+
}
|
|
4087
4753
|
/**
|
|
4088
4754
|
* The `npx` an npx entry starts, as a full path: from PATH, or beside the `node` found, where npm installs it.
|
|
4089
4755
|
*
|
|
@@ -4237,109 +4903,116 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
4237
4903
|
let method = "printed";
|
|
4238
4904
|
let applied = false;
|
|
4239
4905
|
let notApplied;
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
"
|
|
4245
|
-
|
|
4246
|
-
JSON.stringify(entry),
|
|
4247
|
-
"--scope",
|
|
4248
|
-
"user"
|
|
4249
|
-
] : [
|
|
4250
|
-
"mcp",
|
|
4251
|
-
"add",
|
|
4252
|
-
name,
|
|
4253
|
-
...codexEnv(entry.env),
|
|
4254
|
-
"--",
|
|
4255
|
-
entry.command,
|
|
4256
|
-
...entry.args
|
|
4257
|
-
];
|
|
4258
|
-
const replacing = previous.find((server) => server.client === options.client);
|
|
4259
|
-
if (replacing) {
|
|
4260
|
-
const removal = options.client === "claude-code" ? [
|
|
4906
|
+
const pointed = launcher === "managed" && apply ? await pointLauncher(context, product, preflight.node) : null;
|
|
4907
|
+
const unpoint = async () => pointed !== null && await restorePointer(context.core.paths.dataDir, pointed.name, pointed.before, pointed.runtime).catch(() => false);
|
|
4908
|
+
try {
|
|
4909
|
+
if (cliName && cliPath && writes) {
|
|
4910
|
+
const codexEnv = (values) => Object.entries(values ?? {}).flatMap(([key, value]) => ["--env", `${key}=${value}`]);
|
|
4911
|
+
const args = options.client === "claude-code" ? [
|
|
4261
4912
|
"mcp",
|
|
4262
|
-
"
|
|
4913
|
+
"add-json",
|
|
4263
4914
|
name,
|
|
4915
|
+
JSON.stringify(entry),
|
|
4264
4916
|
"--scope",
|
|
4265
4917
|
"user"
|
|
4266
4918
|
] : [
|
|
4267
4919
|
"mcp",
|
|
4268
|
-
"
|
|
4269
|
-
name
|
|
4920
|
+
"add",
|
|
4921
|
+
name,
|
|
4922
|
+
...codexEnv(entry.env),
|
|
4923
|
+
"--",
|
|
4924
|
+
entry.command,
|
|
4925
|
+
...entry.args
|
|
4270
4926
|
];
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
const message = error instanceof CommsError ? error.message : String(error);
|
|
4275
|
-
if (!/no (mcp )?server|not found|does not exist/i.test(message)) throw error;
|
|
4276
|
-
}
|
|
4277
|
-
try {
|
|
4278
|
-
await run(context.env, cliPath, args);
|
|
4279
|
-
} catch (error) {
|
|
4280
|
-
const restore = options.client === "claude-code" ? [
|
|
4927
|
+
const replacing = previous.find((server) => server.client === options.client);
|
|
4928
|
+
if (replacing) {
|
|
4929
|
+
const removal = options.client === "claude-code" ? [
|
|
4281
4930
|
"mcp",
|
|
4282
|
-
"
|
|
4931
|
+
"remove",
|
|
4283
4932
|
name,
|
|
4284
|
-
JSON.stringify({
|
|
4285
|
-
command: replacing.command,
|
|
4286
|
-
args: replacing.args,
|
|
4287
|
-
...replacing.env ? { env: replacing.env } : {}
|
|
4288
|
-
}),
|
|
4289
4933
|
"--scope",
|
|
4290
4934
|
"user"
|
|
4291
4935
|
] : [
|
|
4292
4936
|
"mcp",
|
|
4293
|
-
"
|
|
4294
|
-
name
|
|
4295
|
-
...codexEnv(replacing.env),
|
|
4296
|
-
"--",
|
|
4297
|
-
replacing.command,
|
|
4298
|
-
...replacing.args
|
|
4937
|
+
"remove",
|
|
4938
|
+
name
|
|
4299
4939
|
];
|
|
4300
|
-
let restored = true;
|
|
4301
4940
|
try {
|
|
4302
|
-
await run(context.env, cliPath,
|
|
4303
|
-
} catch {
|
|
4304
|
-
|
|
4941
|
+
await run(context.env, cliPath, removal);
|
|
4942
|
+
} catch (error) {
|
|
4943
|
+
const message = error instanceof CommsError ? error.message : String(error);
|
|
4944
|
+
if (!/no (mcp )?server|not found|does not exist/i.test(message)) throw error;
|
|
4945
|
+
}
|
|
4946
|
+
try {
|
|
4947
|
+
await run(context.env, cliPath, args);
|
|
4948
|
+
} catch (error) {
|
|
4949
|
+
const restore = options.client === "claude-code" ? [
|
|
4950
|
+
"mcp",
|
|
4951
|
+
"add-json",
|
|
4952
|
+
name,
|
|
4953
|
+
JSON.stringify({
|
|
4954
|
+
command: replacing.command,
|
|
4955
|
+
args: replacing.args,
|
|
4956
|
+
...replacing.env ? { env: replacing.env } : {}
|
|
4957
|
+
}),
|
|
4958
|
+
"--scope",
|
|
4959
|
+
"user"
|
|
4960
|
+
] : [
|
|
4961
|
+
"mcp",
|
|
4962
|
+
"add",
|
|
4963
|
+
name,
|
|
4964
|
+
...codexEnv(replacing.env),
|
|
4965
|
+
"--",
|
|
4966
|
+
replacing.command,
|
|
4967
|
+
...replacing.args
|
|
4968
|
+
];
|
|
4969
|
+
let restored = true;
|
|
4970
|
+
try {
|
|
4971
|
+
await run(context.env, cliPath, restore);
|
|
4972
|
+
} catch {
|
|
4973
|
+
restored = false;
|
|
4974
|
+
}
|
|
4975
|
+
if (restored) throw new CommsError("CONFIG", `could not register "${name}"; the previous entry was put back`, {
|
|
4976
|
+
hint: "Check the client is not running, then try again.",
|
|
4977
|
+
cause: error
|
|
4978
|
+
});
|
|
4979
|
+
throw refusedWithRemedy("CONFIG", `could not register "${name}", and the previous entry could not be put back either — ${cliName} now has no server called "${name}"`, {
|
|
4980
|
+
binary: product.binary,
|
|
4981
|
+
words: installWords(product, effective, name, []),
|
|
4982
|
+
say: (command) => `Re-register it with ${command}. The old entry is in ${backupPath}.`
|
|
4983
|
+
}, { cause: error });
|
|
4305
4984
|
}
|
|
4306
|
-
|
|
4307
|
-
|
|
4985
|
+
} else try {
|
|
4986
|
+
await run(context.env, cliPath, args);
|
|
4987
|
+
} catch (error) {
|
|
4988
|
+
const message = error instanceof CommsError ? error.message : String(error);
|
|
4989
|
+
if (/already exists/i.test(message)) throw new CommsError("CONFIG", `${cliName} already has an MCP server called "${name}", somewhere this could not read`, {
|
|
4990
|
+
hint: `Look at it with ${clientCommandHint([
|
|
4991
|
+
cliName,
|
|
4992
|
+
"mcp",
|
|
4993
|
+
"get",
|
|
4994
|
+
name
|
|
4995
|
+
], context.platform, `${cliName}'s own \`mcp get\`, for the entry called "${name}"`)}. If it is an older copy of this server, remove it with ${clientCommandHint([
|
|
4996
|
+
cliName,
|
|
4997
|
+
"mcp",
|
|
4998
|
+
"remove",
|
|
4999
|
+
name
|
|
5000
|
+
], context.platform, `${cliName}'s own \`mcp remove\``)} and run this again; if not, choose another --name.`,
|
|
4308
5001
|
cause: error
|
|
4309
5002
|
});
|
|
4310
|
-
throw
|
|
4311
|
-
binary: product.binary,
|
|
4312
|
-
words: installWords(product, effective, name, []),
|
|
4313
|
-
say: (command) => `Re-register it with ${command}. The old entry is in ${backupPath}.`
|
|
4314
|
-
}, { cause: error });
|
|
5003
|
+
throw error;
|
|
4315
5004
|
}
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
}
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
"mcp",
|
|
4324
|
-
"get",
|
|
4325
|
-
name
|
|
4326
|
-
], context.platform, `${cliName}'s own \`mcp get\`, for the entry called "${name}"`)}. If it is an older copy of this server, remove it with ${clientCommandHint([
|
|
4327
|
-
cliName,
|
|
4328
|
-
"mcp",
|
|
4329
|
-
"remove",
|
|
4330
|
-
name
|
|
4331
|
-
], context.platform, `${cliName}'s own \`mcp remove\``)} and run this again; if not, choose another --name.`,
|
|
4332
|
-
cause: error
|
|
4333
|
-
});
|
|
4334
|
-
throw error;
|
|
5005
|
+
method = "cli";
|
|
5006
|
+
applied = true;
|
|
5007
|
+
} else if (cliName && apply) notApplied = `${cliName} was not found ${clientCliSearch(context.env)}, so nothing was registered`;
|
|
5008
|
+
else if (configPath && writes) {
|
|
5009
|
+
await mergeIntoJsonConfig(configPath, options.client, name, entry);
|
|
5010
|
+
method = "file";
|
|
5011
|
+
applied = true;
|
|
4335
5012
|
}
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
else if (configPath && writes) {
|
|
4340
|
-
await mergeIntoJsonConfig(configPath, options.client, name, entry);
|
|
4341
|
-
method = "file";
|
|
4342
|
-
applied = true;
|
|
5013
|
+
} catch (error) {
|
|
5014
|
+
await unpoint();
|
|
5015
|
+
throw error;
|
|
4343
5016
|
}
|
|
4344
5017
|
if (record && !applied) try {
|
|
4345
5018
|
await appendPrivateLine(ledger, JSON.stringify(record));
|
|
@@ -4351,12 +5024,13 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
4351
5024
|
let verification = "skipped";
|
|
4352
5025
|
let verifyDetail;
|
|
4353
5026
|
if (!options.noVerify) {
|
|
4354
|
-
if (runtimeMissing) verifyDetail = "the
|
|
5027
|
+
if (runtimeMissing) verifyDetail = "the stable launcher does not start this release's managed runtime yet, and --print installs nothing";
|
|
4355
5028
|
else {
|
|
4356
5029
|
const check = await verifyEntry(entry, product);
|
|
4357
5030
|
verified = check.ok;
|
|
4358
5031
|
verification = check.ok ? "passed" : "failed";
|
|
4359
5032
|
verifyDetail = check.detail;
|
|
5033
|
+
if (!check.ok && pointed !== null && await unpoint()) verifyDetail = pointed.before === void 0 ? `${check.detail}; so the stable launcher's pointer was taken away again, as there was none before this install` : `${check.detail}; so the stable launcher was pointed back at ${pointed.before.current}, which it starts until this is fixed`;
|
|
4360
5034
|
}
|
|
4361
5035
|
}
|
|
4362
5036
|
return {
|
|
@@ -4595,11 +5269,25 @@ async function pruneManagedRuntimes(context, product, options = {}) {
|
|
|
4595
5269
|
const registered = scan.servers;
|
|
4596
5270
|
const known = new Set(knownClientConfigs(context.env).map((file) => file.path));
|
|
4597
5271
|
const mentions = (aliases, text) => aliases.some((alias) => text === alias || text.includes(`${alias}${sep}`) || text.includes(`${alias}/`));
|
|
5272
|
+
const name = launcherName(product.packageName);
|
|
5273
|
+
let pointer;
|
|
5274
|
+
try {
|
|
5275
|
+
pointer = await readPointer(context.core.paths.dataDir, name);
|
|
5276
|
+
} catch {
|
|
5277
|
+
const at = launcherPaths(context.core.paths.dataDir, name).pointer;
|
|
5278
|
+
return refuse(`${at} could not be read, so none of these can be shown not to be what the stable launcher starts`);
|
|
5279
|
+
}
|
|
5280
|
+
const pointed = (aliases) => {
|
|
5281
|
+
const named = (runtime) => runtime !== void 0 && aliases.some((alias) => resolve(alias) === resolve(runtime));
|
|
5282
|
+
if (named(pointer?.current)) return "the stable launcher starts it";
|
|
5283
|
+
if (named(pointer?.previous)) return "the stable launcher's previous runtime: what it falls back to, and what a rollback goes back to";
|
|
5284
|
+
return null;
|
|
5285
|
+
};
|
|
4598
5286
|
const current = managedRuntimeDir(context.core.paths.dataDir, product.packageName, product.version);
|
|
4599
5287
|
for (const { path, version, aliases } of candidates) {
|
|
4600
5288
|
const owner = registered.find((server) => [server.command, ...server.args].some((part) => mentions(aliases, part)));
|
|
4601
5289
|
const printed = options.includePrinted ? void 0 : handedOut.find((record) => !record.applied && mentions(aliases, record.runtime));
|
|
4602
|
-
const reason = path === current ? "this release" : owner ? `registered with ${owner.client} as "${owner.name}"${known.has(owner.path) ? "" : ` in ${owner.path}`}` : printed ? `printed for ${printed.client} as "${printed.name}" on ${printed.at.slice(0, 10)}, and where that entry went cannot be read; once it is gone, \`mcp prune --include-printed\` removes this` : processes.some((line) => mentions(aliases, line)) ? "a running process uses it" : null;
|
|
5290
|
+
const reason = path === current ? "this release" : pointed(aliases) ?? (owner ? `registered with ${owner.client} as "${owner.name}"${known.has(owner.path) ? "" : ` in ${owner.path}`}` : printed ? `printed for ${printed.client} as "${printed.name}" on ${printed.at.slice(0, 10)}, and where that entry went cannot be read; once it is gone, \`mcp prune --include-printed\` removes this` : processes.some((line) => mentions(aliases, line)) ? "a running process uses it" : null);
|
|
4603
5291
|
if (reason) {
|
|
4604
5292
|
result.kept.push({
|
|
4605
5293
|
path,
|
|
@@ -4945,62 +5633,6 @@ function connectMailboxWords() {
|
|
|
4945
5633
|
*/
|
|
4946
5634
|
const CONVERSION_HOOKS = Symbol("agentcomms.configConversionHooks");
|
|
4947
5635
|
/**
|
|
4948
|
-
* One table of dangerous characters, shared by the inbound sanitiser (which strips them from sender-controlled text)
|
|
4949
|
-
* and the preview renderer (which makes them visible), so the two can never disagree.
|
|
4950
|
-
*/
|
|
4951
|
-
/** Zero-width and formatting characters, bidi controls, variation selectors and Unicode tag characters. */
|
|
4952
|
-
const INVISIBLE_RANGES = [
|
|
4953
|
-
[173, 173],
|
|
4954
|
-
[847, 847],
|
|
4955
|
-
[1564, 1564],
|
|
4956
|
-
[4447, 4448],
|
|
4957
|
-
[6068, 6069],
|
|
4958
|
-
[6155, 6159],
|
|
4959
|
-
[8203, 8207],
|
|
4960
|
-
[8232, 8233],
|
|
4961
|
-
[8234, 8238],
|
|
4962
|
-
[8288, 8292],
|
|
4963
|
-
[8294, 8303],
|
|
4964
|
-
[12644, 12644],
|
|
4965
|
-
[65024, 65039],
|
|
4966
|
-
[65279, 65279],
|
|
4967
|
-
[65440, 65440],
|
|
4968
|
-
[917504, 917631]
|
|
4969
|
-
];
|
|
4970
|
-
function isInvisible(codePoint) {
|
|
4971
|
-
for (const [from, to] of INVISIBLE_RANGES) if (codePoint >= from && codePoint <= to) return true;
|
|
4972
|
-
return false;
|
|
4973
|
-
}
|
|
4974
|
-
/**
|
|
4975
|
-
* C0 controls except tab and newline (ESC, and so every ANSI/OSC sequence, starts here), DEL, and C1 controls (CSI
|
|
4976
|
-
* among them). A lone carriage return counts: it moves a terminal cursor back over text already printed.
|
|
4977
|
-
*/
|
|
4978
|
-
function isControl(codePoint) {
|
|
4979
|
-
if (codePoint === 9 || codePoint === 10) return false;
|
|
4980
|
-
return codePoint < 32 || codePoint === 127 || codePoint >= 128 && codePoint <= 159;
|
|
4981
|
-
}
|
|
4982
|
-
function isDangerous(codePoint) {
|
|
4983
|
-
return isControl(codePoint) || isInvisible(codePoint);
|
|
4984
|
-
}
|
|
4985
|
-
/**
|
|
4986
|
-
* Strips control characters (ESC, CSI, OSC and the rest), DEL, lone carriage returns, zero-width, bidi-control and tag
|
|
4987
|
-
* characters from sender-controlled text; returns the text and how many were removed. CRLF becomes LF first.
|
|
4988
|
-
*
|
|
4989
|
-
* This lives beside the table rather than in the sanitiser because `neutralise` needs it too, and the two must not
|
|
4990
|
-
* drift: a pattern that looks for `</untrusted-content` cannot see it through a zero-width space, so stripping
|
|
4991
|
-
* has to happen before any such pattern runs, on every path, not only on the ones that render a body.
|
|
4992
|
-
*/
|
|
4993
|
-
function stripInvisible(text) {
|
|
4994
|
-
let removed = 0;
|
|
4995
|
-
let out = "";
|
|
4996
|
-
for (const char of text.replace(/\r\n/g, "\n")) if (isDangerous(char.codePointAt(0) ?? 0)) removed += 1;
|
|
4997
|
-
else out += char;
|
|
4998
|
-
return {
|
|
4999
|
-
text: out,
|
|
5000
|
-
removed
|
|
5001
|
-
};
|
|
5002
|
-
}
|
|
5003
|
-
/**
|
|
5004
5636
|
* Whether a folder, as written, says where it is on its own: from the home (`~`), or absolute — and on Windows with its
|
|
5005
5637
|
* drive (`C:\…`) or share (`\\server\share\…`). Windows calls `\outgoing` absolute, but it is on whichever drive is
|
|
5006
5638
|
* current when it is read, and `C:outgoing` is relative to that drive's current folder; either would move with the
|
|
@@ -5151,6 +5783,19 @@ const READABLE_CONFIG_VERSIONS = [
|
|
|
5151
5783
|
];
|
|
5152
5784
|
const ALIAS_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
5153
5785
|
const ALIAS_MESSAGE = "names must be 1–32 lowercase letters, digits or hyphens, starting with a letter or digit";
|
|
5786
|
+
/**
|
|
5787
|
+
* How this machine updates (design 2026-10-16, U2), from the most updating to the least: `auto` — the default — checks
|
|
5788
|
+
* daily and installs in the background once its job is set up; `every-14-days` checks at most once every 14 days and
|
|
5789
|
+
* installs only with the person's yes; `never` asks the registry nothing.
|
|
5790
|
+
*/
|
|
5791
|
+
const UPDATE_LEVELS = [
|
|
5792
|
+
"auto",
|
|
5793
|
+
"every-14-days",
|
|
5794
|
+
"never"
|
|
5795
|
+
];
|
|
5796
|
+
function isUpdateLevel(value) {
|
|
5797
|
+
return typeof value === "string" && UPDATE_LEVELS.includes(value);
|
|
5798
|
+
}
|
|
5154
5799
|
/** Which naming a configuration's body has: 1 — plain words — or 2 — `organisation/platform`. */
|
|
5155
5800
|
function namingOf(config) {
|
|
5156
5801
|
return config.version === 3 ? config.naming : config.version;
|
|
@@ -5261,7 +5906,8 @@ const defaultsSchema = looseObject({
|
|
|
5261
5906
|
downloadsDir: string().optional(),
|
|
5262
5907
|
timezone: string().default("system"),
|
|
5263
5908
|
confirm: looseObject({ elicitationClients: array(string()).default([]) }).default({ elicitationClients: [] }),
|
|
5264
|
-
updateCheck: _enum(["on", "off"]).optional()
|
|
5909
|
+
updateCheck: _enum(["on", "off"]).optional(),
|
|
5910
|
+
updates: string().min(1).optional()
|
|
5265
5911
|
});
|
|
5266
5912
|
const RESERVED_ALIASES = /* @__PURE__ */ new Set(["all"]);
|
|
5267
5913
|
/** Within one map, a reserved alias or a duplicate id is a hard error in every version. */
|
|
@@ -5335,6 +5981,10 @@ const slackAppRecordSchema = looseObject({
|
|
|
5335
5981
|
clientId: string().min(1),
|
|
5336
5982
|
appId: string().min(1).optional()
|
|
5337
5983
|
});
|
|
5984
|
+
const agentAppRecordSchema = looseObject({
|
|
5985
|
+
appId: string().min(1),
|
|
5986
|
+
sha256: string().min(1)
|
|
5987
|
+
});
|
|
5338
5988
|
const organisationRecordSchema = looseObject({
|
|
5339
5989
|
label: string(),
|
|
5340
5990
|
source: looseObject({
|
|
@@ -5355,8 +6005,14 @@ const organisationRecordSchema = looseObject({
|
|
|
5355
6005
|
redirectPort: number().int().min(1).max(65535),
|
|
5356
6006
|
apps: looseObject({
|
|
5357
6007
|
read: slackAppRecordSchema.optional(),
|
|
5358
|
-
send: slackAppRecordSchema.optional()
|
|
6008
|
+
send: slackAppRecordSchema.optional(),
|
|
6009
|
+
agent: agentAppRecordSchema.optional()
|
|
5359
6010
|
}).default({})
|
|
6011
|
+
}).optional(),
|
|
6012
|
+
updates: looseObject({
|
|
6013
|
+
max: string().min(1).optional(),
|
|
6014
|
+
background: boolean().optional(),
|
|
6015
|
+
sha256: string().min(1)
|
|
5360
6016
|
}).optional()
|
|
5361
6017
|
});
|
|
5362
6018
|
const formerNameSchema = looseObject({
|
|
@@ -5538,10 +6194,30 @@ function governingChangePolicy(config, change) {
|
|
|
5538
6194
|
};
|
|
5539
6195
|
return [byId(change.target?.id), ...change.loosened.map((loosening) => byId(loosening.id))].includes("confirm") ? "confirm" : "chat";
|
|
5540
6196
|
}
|
|
5541
|
-
/**
|
|
6197
|
+
/** 0.18's switch, as written: `defaults.updateCheck`, absent reading as `on`. */
|
|
5542
6198
|
function updateCheckSetting(config) {
|
|
5543
6199
|
return config.defaults.updateCheck ?? "on";
|
|
5544
6200
|
}
|
|
6201
|
+
/**
|
|
6202
|
+
* The level this machine's configuration sets (design 2026-10-16, U2): `defaults.updates`, else 0.18's switch read as a
|
|
6203
|
+
* level — `on` as `auto`, `off` as `never`. A word this release does not know reads as `auto`, the default.
|
|
6204
|
+
*/
|
|
6205
|
+
function updateLevelOf(config) {
|
|
6206
|
+
const set = config.defaults.updates;
|
|
6207
|
+
if (set !== void 0) return isUpdateLevel(set) ? set : "auto";
|
|
6208
|
+
return updateCheckSetting(config) === "off" ? "never" : "auto";
|
|
6209
|
+
}
|
|
6210
|
+
/**
|
|
6211
|
+
* How much a level updates, for `classifyChange`: `auto` 2, `every-14-days` 1, `never` 0. A word this release does not
|
|
6212
|
+
* know is judged as the most updating on the side it leaves and the least on the side it arrives on, as an account's
|
|
6213
|
+
* mode is — an unknown word can cost a person a question, never skip one.
|
|
6214
|
+
*/
|
|
6215
|
+
function updateRank(config, side) {
|
|
6216
|
+
const set = config.defaults.updates;
|
|
6217
|
+
const level = set === void 0 ? updateLevelOf(config) : isUpdateLevel(set) ? set : null;
|
|
6218
|
+
if (level === null) return side === "before" ? UPDATE_LEVELS.length - 1 : 0;
|
|
6219
|
+
return UPDATE_LEVELS.length - 1 - UPDATE_LEVELS.indexOf(level);
|
|
6220
|
+
}
|
|
5545
6221
|
/** `naming` is the body's: 2 for `organisation/platform` names (version 2, or version 3 over it), 1 for plain ones. */
|
|
5546
6222
|
function describeIssues(error, naming) {
|
|
5547
6223
|
return error.issues.slice(0, 5).map((issue) => {
|
|
@@ -6098,7 +6774,8 @@ function classifyChange(before, after) {
|
|
|
6098
6774
|
if (POLICY_RANK$1[a.sendPolicy] < POLICY_RANK$1[b.sendPolicy]) loosen("defaults.sendPolicy", b.sendPolicy, a.sendPolicy);
|
|
6099
6775
|
if (POLICY_RANK$1[defaultChangePolicy(after)] < POLICY_RANK$1[defaultChangePolicy(before)]) loosen("defaults.changePolicy", defaultChangePolicy(before), defaultChangePolicy(after));
|
|
6100
6776
|
if (b.riskEscalation && !a.riskEscalation) loosen("defaults.riskEscalation", b.riskEscalation, a.riskEscalation);
|
|
6101
|
-
if (
|
|
6777
|
+
if (!(b.updates !== void 0 && b.updates === a.updates) && updateRank(after, "after") < updateRank(before, "before")) loosen("defaults.updates", before.defaults.updates ?? updateLevelOf(before), after.defaults.updates ?? updateLevelOf(after));
|
|
6778
|
+
else if (updateCheckSetting(before) === "on" && updateCheckSetting(after) === "off") loosen("defaults.updateCheck", updateCheckSetting(before), updateCheckSetting(after));
|
|
6102
6779
|
if (a.sendCaps.perHour > b.sendCaps.perHour || a.sendCaps.perDay > b.sendCaps.perDay) loosen("defaults.sendCaps", b.sendCaps, a.sendCaps);
|
|
6103
6780
|
const roots = (list) => new Set(list.map((path) => comparablePath(resolve(expandHome(path, homedir())))));
|
|
6104
6781
|
const readRoots = (list) => roots(list.filter((root) => namesItsPlace(root)));
|
|
@@ -6114,11 +6791,43 @@ function classifyChange(before, after) {
|
|
|
6114
6791
|
if (before.secrets === void 0 && after.secrets !== void 0 && after.secrets.store !== "keychain") {
|
|
6115
6792
|
if (holdsSecrets(before)) loosen("secrets.store", secretsStoreOf(before), after.secrets.store);
|
|
6116
6793
|
}
|
|
6794
|
+
for (const loosening of classifyAgentLabelChange(agentLabelOf(before), agentLabelOf(after))) loosen(loosening.path, labelSettingValue(loosening, loosening.was), labelSettingValue(loosening, loosening.now));
|
|
6117
6795
|
return {
|
|
6118
6796
|
loosened: changes.map((change) => change.path),
|
|
6119
6797
|
changes
|
|
6120
6798
|
};
|
|
6121
6799
|
}
|
|
6800
|
+
/**
|
|
6801
|
+
* One label rule in words, as a preview lists it: `acme/slack in C0ACME for post/reply: me`. No comma inside, since a
|
|
6802
|
+
* preview joins a list of them with commas.
|
|
6803
|
+
*/
|
|
6804
|
+
function agentLabelRuleText(rule) {
|
|
6805
|
+
const where = rule.conversation === void 0 ? "" : ` in ${rule.conversation}`;
|
|
6806
|
+
const kinds = rule.kinds === void 0 ? "" : ` for ${rule.kinds.join("/")}`;
|
|
6807
|
+
return `${rule.account}${where}${kinds}: ${rule.as}`;
|
|
6808
|
+
}
|
|
6809
|
+
/**
|
|
6810
|
+
* A label setting's value as a loosening carries it: the rules in words, the icons and the Slack styles as they are, the
|
|
6811
|
+
* rest as text.
|
|
6812
|
+
*/
|
|
6813
|
+
function labelSettingValue(loosening, value) {
|
|
6814
|
+
if (value === void 0 || value === null) return void 0;
|
|
6815
|
+
if (loosening.path === "agentLabel.rules") return value.map(agentLabelRuleText);
|
|
6816
|
+
if (loosening.path === "agentLabel.slackIcons" || loosening.path === "agentLabel.slackStyles") return { ...value };
|
|
6817
|
+
return String(value);
|
|
6818
|
+
}
|
|
6819
|
+
/** The label settings a change can write, as the file holds them — raw, as `changedSettings` records every setting. */
|
|
6820
|
+
const LABEL_SETTINGS = [
|
|
6821
|
+
"word",
|
|
6822
|
+
"name",
|
|
6823
|
+
"rules",
|
|
6824
|
+
"slackIcons",
|
|
6825
|
+
"slackStyles"
|
|
6826
|
+
];
|
|
6827
|
+
function storedAgentLabel(config) {
|
|
6828
|
+
const stored = config.agentLabel;
|
|
6829
|
+
return typeof stored === "object" && stored !== null && !Array.isArray(stored) ? stored : {};
|
|
6830
|
+
}
|
|
6122
6831
|
/** The settings of a mailbox, and of an account, that a change can write — the ones the classifier judges. */
|
|
6123
6832
|
const INBOX_SETTINGS = [
|
|
6124
6833
|
"sendPolicy",
|
|
@@ -6132,7 +6841,7 @@ const ACCOUNT_SETTINGS = [
|
|
|
6132
6841
|
];
|
|
6133
6842
|
/**
|
|
6134
6843
|
* Every setting that differs between `before` and `after`, loosened or tightened: the defaults, the secret store,
|
|
6135
|
-
* and each mailbox's and account's own settings — including those of one added or removed.
|
|
6844
|
+
* the agent label's settings, and each mailbox's and account's own settings — including those of one added or removed.
|
|
6136
6845
|
*
|
|
6137
6846
|
* What a change approval binds, beside its loosenings. A preview shows the whole change — "sends approved by never;
|
|
6138
6847
|
* changes approved by chat" — and binding only the loosenings let a claim drop the tightening a person read, since
|
|
@@ -6170,6 +6879,9 @@ function changedSettings(before, after) {
|
|
|
6170
6879
|
const defaultsAfter = after.defaults;
|
|
6171
6880
|
for (const key of [.../* @__PURE__ */ new Set([...Object.keys(defaultsBefore), ...Object.keys(defaultsAfter)])].sort()) record(`defaults.${key}`, defaultsBefore[key], defaultsAfter[key]);
|
|
6172
6881
|
record("secrets.store", before.secrets?.store, after.secrets?.store);
|
|
6882
|
+
const labelBefore = storedAgentLabel(before);
|
|
6883
|
+
const labelAfter = storedAgentLabel(after);
|
|
6884
|
+
for (const key of LABEL_SETTINGS) record(`agentLabel.${key}`, labelBefore[key], labelAfter[key]);
|
|
6173
6885
|
return changes;
|
|
6174
6886
|
}
|
|
6175
6887
|
/**
|
|
@@ -6973,11 +7685,15 @@ var ApprovalStore = class {
|
|
|
6973
7685
|
#now;
|
|
6974
7686
|
#handoffs;
|
|
6975
7687
|
#loadConfig;
|
|
7688
|
+
/** `ApprovalStoreOptions.answered`, never throwing. */
|
|
7689
|
+
answered;
|
|
6976
7690
|
constructor(stateDir, options = {}) {
|
|
6977
7691
|
this.directory = join(stateDir, "approvals");
|
|
6978
7692
|
this.#handoffs = options.handoffs;
|
|
6979
7693
|
this.#now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
6980
7694
|
this.#loadConfig = options.loadConfig;
|
|
7695
|
+
const answered = options.answered;
|
|
7696
|
+
this.answered = answered === void 0 ? void 0 : (approvalId) => answered(approvalId).catch(() => void 0);
|
|
6981
7697
|
registerStoreInternals(this, {
|
|
6982
7698
|
directory: this.directory,
|
|
6983
7699
|
now: () => this.#now(),
|
|
@@ -7566,6 +8282,7 @@ var ApprovalStore = class {
|
|
|
7566
8282
|
}, { usedSaid: options.usedSaid });
|
|
7567
8283
|
const failed = failure;
|
|
7568
8284
|
if (failed) throw refusedAfter(refusalFor(done.record)(failed.code, failed.reason, done.record), done);
|
|
8285
|
+
if (kind === "send" && done.record.state === "approved") await this.answered?.(approvalId);
|
|
7569
8286
|
return done.record;
|
|
7570
8287
|
}
|
|
7571
8288
|
/**
|
|
@@ -7615,6 +8332,7 @@ var ApprovalStore = class {
|
|
|
7615
8332
|
if (failed) throw refusedAfter(refuse(failed.code, failed.reason, done.record), done);
|
|
7616
8333
|
const claimToken = randomBytes(16).toString("hex");
|
|
7617
8334
|
await this.#markClaimed(done, claimToken);
|
|
8335
|
+
await this.answered?.(approvalId);
|
|
7618
8336
|
return {
|
|
7619
8337
|
record: done.record,
|
|
7620
8338
|
claimToken,
|
|
@@ -9248,6 +9966,7 @@ function registrationFactsOf({ packageName, manifest }) {
|
|
|
9248
9966
|
/** How a registered entry starts its server: the launcher `mcp install` wrote it with, or `other` for one it did not. */
|
|
9249
9967
|
function launcherOf(server, facts, platform) {
|
|
9250
9968
|
const fold = folding(platform);
|
|
9969
|
+
if (stableLauncherOf(server, facts, platform) !== null) return "managed";
|
|
9251
9970
|
const parts = [server.command, ...server.args];
|
|
9252
9971
|
if (parts.some((part) => managedRuntimeVersion(part, facts.packageName, platform) !== null)) return "managed";
|
|
9253
9972
|
if (server.args.some((arg) => fold(arg).startsWith(fold(`${facts.npxPackage}@`)))) return "npx";
|
|
@@ -9255,10 +9974,49 @@ function launcherOf(server, facts, platform) {
|
|
|
9255
9974
|
if (parts.some((part) => local.test(part))) return "local";
|
|
9256
9975
|
return "other";
|
|
9257
9976
|
}
|
|
9258
|
-
/**
|
|
9977
|
+
/**
|
|
9978
|
+
* The version an entry pins — a managed runtime's or an npx spec's — or null for one that pins none.
|
|
9979
|
+
*
|
|
9980
|
+
* An entry that starts the stable launcher names no version: it runs the release its launcher's pointer names, read
|
|
9981
|
+
* here — null when the pointer is not there, cannot be read, or names a folder that is not a release.
|
|
9982
|
+
*/
|
|
9259
9983
|
function registrationVersion(server, facts, platform) {
|
|
9984
|
+
const stable = stableLauncherOf({
|
|
9985
|
+
command: server.command ?? "",
|
|
9986
|
+
args: server.args
|
|
9987
|
+
}, facts, platform);
|
|
9988
|
+
if (stable !== null) {
|
|
9989
|
+
const current = pointedRuntime(stable.dataDir, stable.name);
|
|
9990
|
+
return current === null ? null : launcherRuntimeVersion(current, stable.name);
|
|
9991
|
+
}
|
|
9260
9992
|
return server.args.map((arg) => pinnedVersion(arg, facts, platform)).find((found) => found !== null) ?? null;
|
|
9261
9993
|
}
|
|
9994
|
+
/**
|
|
9995
|
+
* The stable launcher of this product an entry starts, in the data directory the entry pins, or null: see
|
|
9996
|
+
* `registeredLauncher`. Another product's launcher is not this one's.
|
|
9997
|
+
*/
|
|
9998
|
+
function stableLauncherOf(server, facts, platform) {
|
|
9999
|
+
const stable = registeredLauncher(server, platform);
|
|
10000
|
+
if (stable === null) return null;
|
|
10001
|
+
let own;
|
|
10002
|
+
try {
|
|
10003
|
+
own = launcherName(facts.packageName);
|
|
10004
|
+
} catch {
|
|
10005
|
+
return null;
|
|
10006
|
+
}
|
|
10007
|
+
return folding(platform)(stable.name) === folding(platform)(own) ? {
|
|
10008
|
+
...stable,
|
|
10009
|
+
name: own
|
|
10010
|
+
} : null;
|
|
10011
|
+
}
|
|
10012
|
+
/** The runtime a launcher's pointer names as current, or null when it has none that can be read. */
|
|
10013
|
+
function pointedRuntime(dataDir, name) {
|
|
10014
|
+
try {
|
|
10015
|
+
return readPointerSync(dataDir, name)?.current ?? null;
|
|
10016
|
+
} catch {
|
|
10017
|
+
return null;
|
|
10018
|
+
}
|
|
10019
|
+
}
|
|
9262
10020
|
PATH_OPTIONS.filter(({ key }) => key !== "downloadsDir");
|
|
9263
10021
|
/**
|
|
9264
10022
|
* Whether an entry starts this product, judged on its own words: the suite's path options and their values are taken
|
|
@@ -9266,6 +10024,7 @@ PATH_OPTIONS.filter(({ key }) => key !== "downloadsDir");
|
|
|
9266
10024
|
* A folder pinned as `/tmp/@agentcomms/slack` never makes a Gmail registration Slack's.
|
|
9267
10025
|
*/
|
|
9268
10026
|
function startsProduct(server, facts, platform) {
|
|
10027
|
+
if (registeredLauncher(server, platform) !== null) return stableLauncherOf(server, facts, platform) !== null;
|
|
9269
10028
|
const { words: args } = withoutPathOptions(server.args);
|
|
9270
10029
|
const packageName = commandLinePackage(server.command, args);
|
|
9271
10030
|
return isProductServer({
|
|
@@ -9291,6 +10050,24 @@ function isNpxRegistration(server, facts, platform) {
|
|
|
9291
10050
|
* program when that is one of the product's own commands — a link into the package, or a script npm wrote beside it.
|
|
9292
10051
|
*/
|
|
9293
10052
|
function registrationFiles(server, facts, platform) {
|
|
10053
|
+
const { packageName } = facts;
|
|
10054
|
+
const stable = packageName === void 0 ? null : stableLauncherOf(server, { packageName }, platform);
|
|
10055
|
+
if (stable !== null) {
|
|
10056
|
+
let pointer;
|
|
10057
|
+
try {
|
|
10058
|
+
pointer = readPointerSync(stable.dataDir, stable.name);
|
|
10059
|
+
} catch {
|
|
10060
|
+
return {
|
|
10061
|
+
scripts: [],
|
|
10062
|
+
command: null
|
|
10063
|
+
};
|
|
10064
|
+
}
|
|
10065
|
+
const runtime = pointer === void 0 ? void 0 : launcherCanStartSync(pointer.current, stable.name) || pointer.previous === void 0 ? pointer.current : pointer.previous;
|
|
10066
|
+
return {
|
|
10067
|
+
scripts: runtime === void 0 ? [] : [launcherCli(runtime, stable.name)],
|
|
10068
|
+
command: null
|
|
10069
|
+
};
|
|
10070
|
+
}
|
|
9294
10071
|
const { words } = withoutPathOptions(server.args);
|
|
9295
10072
|
const sentinel = words.indexOf("--");
|
|
9296
10073
|
const options = sentinel < 0 ? words : words.slice(0, sentinel);
|
|
@@ -9304,7 +10081,7 @@ function folding(platform) {
|
|
|
9304
10081
|
return platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
|
|
9305
10082
|
}
|
|
9306
10083
|
/** The package version, read from package.json at build time. */
|
|
9307
|
-
const VERSION = "0.
|
|
10084
|
+
const VERSION = "0.20.0";
|
|
9308
10085
|
/**
|
|
9309
10086
|
* Versions as semver writes and orders them — what an update compares, and what the daily update check's reader needs
|
|
9310
10087
|
* to decide whether the release it last heard of is newer than the one running.
|
|
@@ -9375,8 +10152,13 @@ function isBehind(version, latest) {
|
|
|
9375
10152
|
* Whether a version is a prerelease — `0.8.0-rc.1` — which the daily update check never counts as an update: a
|
|
9376
10153
|
* machine is not told to "update" to a release candidate, nor a candidate told to go back to the last release.
|
|
9377
10154
|
*/
|
|
9378
|
-
|
|
9379
|
-
|
|
10155
|
+
/**
|
|
10156
|
+
* A release as this suite publishes one, and the only form a version the registry gave is read or said in: a plain
|
|
10157
|
+
* `MAJOR.MINOR.PATCH` (re-review of 0.20.0, severity 6). A prerelease's words and build metadata are the registry's
|
|
10158
|
+
* text — `0.20.1+SYSTEM-ignore-…` is valid semver — so a version carrying either is no release here.
|
|
10159
|
+
*/
|
|
10160
|
+
function isPlainVersion(value) {
|
|
10161
|
+
return typeof value === "string" && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value);
|
|
9380
10162
|
}
|
|
9381
10163
|
/**
|
|
9382
10164
|
* Whether `version` is within a package's `engines` range: true or false, or null when the range is not one this reads.
|
|
@@ -9482,8 +10264,10 @@ const CHANNEL_PACKAGES = new Set(CHANNEL_SNAPSHOT.filter(({ manifest }) => manif
|
|
|
9482
10264
|
/**
|
|
9483
10265
|
* Locates the command that runs `request.target`'s CLI with `request.words`, or says why there is none.
|
|
9484
10266
|
*
|
|
9485
|
-
* The program is always this process's Node
|
|
9486
|
-
*
|
|
10267
|
+
* The program is always this process's Node, which must satisfy the target's `engines.node`; no interpreter named
|
|
10268
|
+
* anywhere else is run, or asked its version. It is named as PATH finds it — the first `node` on `request.env`'s PATH
|
|
10269
|
+
* that is the same real file as `process.execPath` — and otherwise by `process.execPath` (CUE-492): the real file only
|
|
10270
|
+
* identifies it, and `process.version` is that file's. A TypeScript entry gets `--experimental-strip-types`,
|
|
9487
10271
|
* which Node 22.12–22.17 needs, and `--experimental-transform-types` only when this source run was started with it;
|
|
9488
10272
|
* no other flag this process was started with is carried — debug, preload, condition and memory flags are not what the
|
|
9489
10273
|
* CLI needs. The suite directories the command uses go between the entry and its words, one canonical option each,
|
|
@@ -9524,6 +10308,7 @@ function locateCliCommand(request, runtime = {
|
|
|
9524
10308
|
return fail("caller", `${nearest.root} cannot be resolved`);
|
|
9525
10309
|
}
|
|
9526
10310
|
if (!isAbsolute(process.execPath)) return fail("interpreter", `this Node, ${process.execPath}, is not a full path`);
|
|
10311
|
+
const program = nodeProgram(request.env);
|
|
9527
10312
|
const pins = {};
|
|
9528
10313
|
for (const key of request.uses) pins[key] = request.paths[key];
|
|
9529
10314
|
let words;
|
|
@@ -9532,7 +10317,7 @@ function locateCliCommand(request, runtime = {
|
|
|
9532
10317
|
} catch (error) {
|
|
9533
10318
|
return fail("arguments", error instanceof Error ? error.message : String(error));
|
|
9534
10319
|
}
|
|
9535
|
-
const build = (found) => buildCommand(target, found, words, runtime, callerSource, platform);
|
|
10320
|
+
const build = (found) => buildCommand(target, found, words, runtime, callerSource, platform, program);
|
|
9536
10321
|
const located = (built, found, direction) => Object.freeze({
|
|
9537
10322
|
ok: true,
|
|
9538
10323
|
command: built.command,
|
|
@@ -9672,7 +10457,7 @@ function locateCliCommand(request, runtime = {
|
|
|
9672
10457
|
* Checks what was found and makes its command: the target's Node range against this Node, then the entry — the source
|
|
9673
10458
|
* CLI or the manifest's bin for the command it declares — as a readable file inside its package.
|
|
9674
10459
|
*/
|
|
9675
|
-
function buildCommand(target, found, words, runtime, callerSource, platform) {
|
|
10460
|
+
function buildCommand(target, found, words, runtime, callerSource, platform, program) {
|
|
9676
10461
|
const engines = found.manifest.engines;
|
|
9677
10462
|
const range = typeof engines === "object" && engines !== null ? engines.node : void 0;
|
|
9678
10463
|
if (typeof range !== "string") return {
|
|
@@ -9709,7 +10494,7 @@ function buildCommand(target, found, words, runtime, callerSource, platform) {
|
|
|
9709
10494
|
return {
|
|
9710
10495
|
ok: true,
|
|
9711
10496
|
command: new PrintedCommand(GATE, [
|
|
9712
|
-
|
|
10497
|
+
program,
|
|
9713
10498
|
...flags,
|
|
9714
10499
|
checked.path,
|
|
9715
10500
|
...words
|
|
@@ -10013,7 +10798,8 @@ function cliHandoffs(options) {
|
|
|
10013
10798
|
uses: usesOf(use),
|
|
10014
10799
|
paths,
|
|
10015
10800
|
platform,
|
|
10016
|
-
...registrations === void 0 ? {} : { registrations }
|
|
10801
|
+
...registrations === void 0 ? {} : { registrations },
|
|
10802
|
+
...options.env === void 0 ? {} : { env: options.env }
|
|
10017
10803
|
}, ...options.runtime === void 0 ? [] : [options.runtime]);
|
|
10018
10804
|
return result.ok ? result.command : result;
|
|
10019
10805
|
};
|
|
@@ -10546,7 +11332,13 @@ const SETTING_LABELS = {
|
|
|
10546
11332
|
"defaults.downloadsDir": "downloads folder",
|
|
10547
11333
|
"defaults.confirm.elicitationClients": "clients trusted to ask for a send approval",
|
|
10548
11334
|
"defaults.updateCheck": "daily update check",
|
|
10549
|
-
"
|
|
11335
|
+
"defaults.updates": "how this machine updates",
|
|
11336
|
+
"secrets.store": "where credentials are kept",
|
|
11337
|
+
"agentLabel.word": "the word after your name in the agent label",
|
|
11338
|
+
"agentLabel.name": "the name in the agent label",
|
|
11339
|
+
"agentLabel.rules": "who each kind of message appears to come from",
|
|
11340
|
+
"agentLabel.slackIcons": "the icon of the agent’s Slack posts",
|
|
11341
|
+
"agentLabel.slackStyles": "how the agent’s Slack messages look"
|
|
10550
11342
|
};
|
|
10551
11343
|
/**
|
|
10552
11344
|
* What the loosening means for the person, in their terms.
|
|
@@ -10569,8 +11361,14 @@ function meaning(field, after, scope) {
|
|
|
10569
11361
|
case "defaults.attachDeny": return "files the removed entries protected can be attached";
|
|
10570
11362
|
case "defaults.downloadsDir": return "files from other people will be saved here";
|
|
10571
11363
|
case "defaults.confirm.elicitationClients": return "these clients may ask you to approve a send in their own window";
|
|
10572
|
-
case "defaults.updateCheck": return "
|
|
11364
|
+
case "defaults.updateCheck": return "a release before 0.20 on this machine will no longer ask npm for a newer release";
|
|
11365
|
+
case "defaults.updates": return after === "every-14-days" ? "this machine will ask npm for a newer release at most once every 14 days, say one is out at most once every 14 days, and install nothing in the background" : after === "never" ? "nothing on this machine will ask npm for a newer release, say one is out, or install one" : "this release cannot tell how that level updates, so it counts as updating least";
|
|
10573
11366
|
case "secrets.store": return "credentials will move out of the system keychain into files on this disk";
|
|
11367
|
+
case "agentLabel.word": return "what an agent sends will carry this word after your name";
|
|
11368
|
+
case "agentLabel.name": return after === null ? "where a channel has no name of its own for you, an agent’s message will be refused until a name is set" : "where a channel has no name of its own for you, what an agent sends will carry this name";
|
|
11369
|
+
case "agentLabel.rules": return "what a rule sends as `me` goes out as you, with no label: it will look as if you wrote it yourself";
|
|
11370
|
+
case "agentLabel.slackIcons": return "the app’s Slack posts will show this icon beside your name";
|
|
11371
|
+
case "agentLabel.slackStyles": return "every agent message there goes out as you, labelled in its words, even where the app could post: it trades Slack’s own APP tag, which no one can fake, for words in the message";
|
|
10574
11372
|
default: return "";
|
|
10575
11373
|
}
|
|
10576
11374
|
}
|
|
@@ -10712,7 +11510,7 @@ async function gatedChangeAtTerminal(core, change, options) {
|
|
|
10712
11510
|
if ((await approveChangeAtTerminal(core, prepared.approvalId, options.env, options.output, streams)).state !== "approved") throw cancelled();
|
|
10713
11511
|
} else if (options.answered !== true) {
|
|
10714
11512
|
streams.stdout.write(`${prepared.preview}\n\n`);
|
|
10715
|
-
if ((await askLine
|
|
11513
|
+
if ((await askLine(streams, `Type ${paint(options.output.color, "bold", "yes")} to apply this change: `)).trim().toLowerCase() !== "yes") {
|
|
10716
11514
|
await revokeChange(core, prepared.approvalId, "cancelled at the terminal", {
|
|
10717
11515
|
surface: "cli",
|
|
10718
11516
|
disposition: "person"
|
|
@@ -10777,7 +11575,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
|
|
|
10777
11575
|
}
|
|
10778
11576
|
const prompt = await beginChangeApproval(core, approvalId, { surface: "cli" });
|
|
10779
11577
|
streams.stdout.write(`${prompt.preview}\n\n`);
|
|
10780
|
-
const answer = await askLine
|
|
11578
|
+
const answer = await askLine(streams, `Type ${paint(output.color, "bold", prompt.challenge)} to approve this change, or press Enter to cancel: `);
|
|
10781
11579
|
if (!answer.trim()) {
|
|
10782
11580
|
await revokeChange(core, approvalId, "cancelled at the terminal", {
|
|
10783
11581
|
surface: "cli",
|
|
@@ -10797,7 +11595,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
|
|
|
10797
11595
|
function cancelled() {
|
|
10798
11596
|
return new CommsError("USAGE", "cancelled: nothing was changed");
|
|
10799
11597
|
}
|
|
10800
|
-
async function askLine
|
|
11598
|
+
async function askLine(streams, question) {
|
|
10801
11599
|
const { createInterface } = await import("node:readline/promises");
|
|
10802
11600
|
const rl = createInterface({
|
|
10803
11601
|
input: streams.stdin,
|
|
@@ -11163,80 +11961,6 @@ var InboxStateStore = class {
|
|
|
11163
11961
|
});
|
|
11164
11962
|
}
|
|
11165
11963
|
};
|
|
11166
|
-
function openCore(options = {}) {
|
|
11167
|
-
const { paths, pathOverrides } = resolvePathIdentity(options);
|
|
11168
|
-
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
11169
|
-
const handoffs = options.caller === void 0 ? void 0 : cliHandoffs({
|
|
11170
|
-
caller: options.caller,
|
|
11171
|
-
paths,
|
|
11172
|
-
platform: options.platform,
|
|
11173
|
-
env: options.env
|
|
11174
|
-
});
|
|
11175
|
-
const config = new ConfigStore(paths.configDir, { handoffs });
|
|
11176
|
-
const audit = new AuditLog(paths.stateDir, now);
|
|
11177
|
-
let cached = null;
|
|
11178
|
-
return {
|
|
11179
|
-
paths,
|
|
11180
|
-
pathOverrides,
|
|
11181
|
-
config,
|
|
11182
|
-
states: new InboxStateStore(paths.stateDir),
|
|
11183
|
-
approvals: new ApprovalStore(paths.stateDir, {
|
|
11184
|
-
now,
|
|
11185
|
-
handoffs,
|
|
11186
|
-
loadConfig: () => config.load(),
|
|
11187
|
-
audit
|
|
11188
|
-
}),
|
|
11189
|
-
ledger: new SendLedger(paths.stateDir, now),
|
|
11190
|
-
plans: new PlanStore(paths.stateDir, now),
|
|
11191
|
-
taint: new TaintStore(paths.stateDir, now),
|
|
11192
|
-
audit,
|
|
11193
|
-
handoffs,
|
|
11194
|
-
async secrets(kind) {
|
|
11195
|
-
const chosen = kind ?? secretsStoreOf(await config.load());
|
|
11196
|
-
if (cached?.kind === chosen) return cached.store;
|
|
11197
|
-
const store = await openSecretStore(chosen, {
|
|
11198
|
-
secretsDir: paths.secretsDir,
|
|
11199
|
-
namespace: keychainNamespace(paths.configDir),
|
|
11200
|
-
handoffs
|
|
11201
|
-
});
|
|
11202
|
-
cached = {
|
|
11203
|
-
kind: chosen,
|
|
11204
|
-
store
|
|
11205
|
-
};
|
|
11206
|
-
return store;
|
|
11207
|
-
}
|
|
11208
|
-
};
|
|
11209
|
-
}
|
|
11210
|
-
/**
|
|
11211
|
-
* A whole number as it was given: a string of digits and nothing else (surrounding spaces aside), or a number that is
|
|
11212
|
-
* whole — and `NaN` for anything else.
|
|
11213
|
-
*
|
|
11214
|
-
* `Number.parseInt` read `abc` as NaN, `12abc` as 12 and `1e2` as 1; `Number` reads `1e2` as 100, `0x10` as 16 and an
|
|
11215
|
-
* empty string as 0. Each of those is a number nobody typed, so none of them is read as one here. A value too large to
|
|
11216
|
-
* hold exactly is not one either: it would be used as some other number.
|
|
11217
|
-
*/
|
|
11218
|
-
function readWholeNumber(raw) {
|
|
11219
|
-
const value = typeof raw === "number" ? raw : typeof raw === "string" && /^[0-9]+$/.test(raw.trim()) ? Number(raw.trim()) : NaN;
|
|
11220
|
-
return Number.isSafeInteger(value) ? value : NaN;
|
|
11221
|
-
}
|
|
11222
|
-
/**
|
|
11223
|
-
* A number option, checked rather than coerced: the number, `undefined` when none was given, or a USAGE refusal that
|
|
11224
|
-
* names the option and its range.
|
|
11225
|
-
*
|
|
11226
|
-
* One check for every command line's number options — `agentcomms`, `agent-gmail` and `agent-slack` — and for the
|
|
11227
|
-
* operations those commands share with their tools, so a number the command refuses is refused by the tool in the
|
|
11228
|
-
* same words, and nothing reads `--limit 1e2` as 1 or `--port abc` as no port at all.
|
|
11229
|
-
*/
|
|
11230
|
-
function wholeNumber(raw, rule) {
|
|
11231
|
-
if (raw === void 0) return void 0;
|
|
11232
|
-
const value = readWholeNumber(raw);
|
|
11233
|
-
if (Number.isNaN(value) || value < rule.min || rule.max !== void 0 && value > rule.max) {
|
|
11234
|
-
const range = rule.max === void 0 ? `of ${rule.min} or more` : `from ${rule.min} to ${rule.max}`;
|
|
11235
|
-
const hint = rule.hint ?? (Number.isNaN(value) ? "Digits only: no sign, decimal point, exponent or unit." : null);
|
|
11236
|
-
throw new CommsError("USAGE", `${rule.name} "${String(raw)}" is not a whole number ${range}`, hint === null ? {} : { hint });
|
|
11237
|
-
}
|
|
11238
|
-
return value;
|
|
11239
|
-
}
|
|
11240
11964
|
/**
|
|
11241
11965
|
* A Google client id as an organisation profile has to write it (design 2026-10-02 §D2): the project number, a hyphen,
|
|
11242
11966
|
* the client's own part, and Google's suffix — and nothing else.
|
|
@@ -11307,6 +12031,142 @@ strictObject({
|
|
|
11307
12031
|
})
|
|
11308
12032
|
}).optional()
|
|
11309
12033
|
});
|
|
12034
|
+
/**
|
|
12035
|
+
* A string that came from a profile, as it is shown anywhere: chat-template tokens, role markers and envelope look-alikes
|
|
12036
|
+
* neutralised (`neutralise`), every control and invisible character made visible, tabs and line breaks flattened to a
|
|
12037
|
+
* space, and cut to `width`. `neutralise` keeps line feeds and tabs on purpose — they are legitimate in a message body —
|
|
12038
|
+
* so the flattening is what keeps a label from printing a second line of a preview.
|
|
12039
|
+
*/
|
|
12040
|
+
function shownText(value, width = 120) {
|
|
12041
|
+
return truncateDisplay(neutralise(typeof value === "string" ? value : String(value ?? "")).text, width);
|
|
12042
|
+
}
|
|
12043
|
+
/**
|
|
12044
|
+
* A profile's path as it is shown — in a preview, an error, a command's output: neutralised and on one line, as every
|
|
12045
|
+
* other string a person did not write themselves. The path is chosen by whoever named the file — a repository, a
|
|
12046
|
+
* message with an attachment — and its name is text like any other.
|
|
12047
|
+
*/
|
|
12048
|
+
function shownPath(path) {
|
|
12049
|
+
return shownText(path, 400);
|
|
12050
|
+
}
|
|
12051
|
+
/**
|
|
12052
|
+
* The bytes at an absolute path, in one bounded read: open once, check it is a regular file, read at most one byte more
|
|
12053
|
+
* than `PROFILE_MAX_BYTES` from the same handle. A path somebody typed can name a FIFO that never ends, a device, a
|
|
12054
|
+
* directory; none of them is a profile, and none may be how a command hangs. A symlink is followed: the path is one the
|
|
12055
|
+
* person chose. `unopened` says what a file that cannot be opened is: a refusal for the profile, whatever the reason;
|
|
12056
|
+
* nothing for a file beside it (`readSideFile`) when it is not there, and a refusal when it is there and cannot be
|
|
12057
|
+
* opened.
|
|
12058
|
+
*/
|
|
12059
|
+
async function readBounded(path, what) {
|
|
12060
|
+
let handle;
|
|
12061
|
+
try {
|
|
12062
|
+
handle = await open(path, constants$1.O_RDONLY | (constants$1.O_NONBLOCK ?? 0));
|
|
12063
|
+
} catch (error) {
|
|
12064
|
+
return what.unopened(error);
|
|
12065
|
+
}
|
|
12066
|
+
try {
|
|
12067
|
+
if (!(await handle.stat()).isFile()) throw new CommsError("USAGE", `${shownPath(path)} is not a file`);
|
|
12068
|
+
const buffer = Buffer.allocUnsafe(65537);
|
|
12069
|
+
const { bytesRead } = await handle.read(buffer, 0, 65537, 0);
|
|
12070
|
+
if (bytesRead > 65536) throw new CommsError("BAD_DATA", `${shownPath(path)} is larger than ${what.size} can be (64 KiB)`, { hint: `${what.size.charAt(0).toUpperCase()}${what.size.slice(1)} is a few hundred bytes of JSON. Check this is the file your organisation sent.` });
|
|
12071
|
+
return Buffer.from(buffer.subarray(0, bytesRead));
|
|
12072
|
+
} catch (error) {
|
|
12073
|
+
if (error instanceof CommsError) throw error;
|
|
12074
|
+
throw new CommsError("NOT_FOUND", `${what.noun} at ${shownPath(path)} could not be read`, { cause: error });
|
|
12075
|
+
} finally {
|
|
12076
|
+
await handle.close();
|
|
12077
|
+
}
|
|
12078
|
+
}
|
|
12079
|
+
/** The most symbolic links followed from a profile's path to its file: as many as the system itself follows. */
|
|
12080
|
+
const LINK_HOPS = 40;
|
|
12081
|
+
/**
|
|
12082
|
+
* The file a profile's path names, through every symbolic link at its end: the path itself when it is not a link, or
|
|
12083
|
+
* cannot be looked at — what reading it then finds is the bounded read's to say. A link's target is read against the
|
|
12084
|
+
* folder the link is in. The folders on the way are left as they are spelt: the file beside the result is in the same
|
|
12085
|
+
* folder whatever they resolve to.
|
|
12086
|
+
*
|
|
12087
|
+
* What the files beside a profile are looked for beside (`readProfileFile`): an organisation keeps them beside its
|
|
12088
|
+
* profile, in its own checkout, and a person who links to that profile from somewhere else has linked to the profile
|
|
12089
|
+
* alone. Looked for beside the link, a caps file is not there, and the caps it names would be lifted.
|
|
12090
|
+
*/
|
|
12091
|
+
function profileTarget(path) {
|
|
12092
|
+
let at = path;
|
|
12093
|
+
for (let hops = 0; hops < LINK_HOPS; hops += 1) {
|
|
12094
|
+
let link;
|
|
12095
|
+
try {
|
|
12096
|
+
if (!lstatSync(at).isSymbolicLink()) return at;
|
|
12097
|
+
link = readlinkSync(at);
|
|
12098
|
+
} catch {
|
|
12099
|
+
return at;
|
|
12100
|
+
}
|
|
12101
|
+
at = resolve(dirname(at), link);
|
|
12102
|
+
}
|
|
12103
|
+
throw new CommsError("USAGE", `${shownPath(path)} is a chain of more than ${LINK_HOPS} symbolic links`, { hint: "Pass the path of the profile file itself." });
|
|
12104
|
+
}
|
|
12105
|
+
/** A side file's path for a profile: in the same folder, the profile's name with `.<suffix>.json` for its `.json`. */
|
|
12106
|
+
function sidePathFor(profilePath, suffix) {
|
|
12107
|
+
return /\.json$/i.test(profilePath) ? `${profilePath.slice(0, -5)}.${suffix}.json` : `${profilePath}.${suffix}.json`;
|
|
12108
|
+
}
|
|
12109
|
+
/**
|
|
12110
|
+
* Reads one file beside a profile, if there is one, and holds it to the profile: the same organisation, and whatever
|
|
12111
|
+
* else `agrees` asks. Read in one bounded read as the profile is (`readBounded`), strict as the profile is, and refused
|
|
12112
|
+
* in the same words — naming each problem by where it is, never by the value found there — but naming this file, so
|
|
12113
|
+
* the person knows which of the two to fix. Only a file that is not there is none: one that is there and cannot be
|
|
12114
|
+
* opened is refused, since read as none it would lift what it says — the caps, or the one app `workspace bot` takes.
|
|
12115
|
+
*/
|
|
12116
|
+
async function readSideFile(path, profile, side) {
|
|
12117
|
+
const where = shownPath(path);
|
|
12118
|
+
const bytes = await readBounded(path, {
|
|
12119
|
+
unopened: (error) => {
|
|
12120
|
+
if (error.code === "ENOENT") return null;
|
|
12121
|
+
throw new CommsError("NOT_FOUND", `${side.noun} at ${where} could not be opened`, { cause: error });
|
|
12122
|
+
},
|
|
12123
|
+
noun: side.noun,
|
|
12124
|
+
size: side.size
|
|
12125
|
+
});
|
|
12126
|
+
if (bytes === null) return void 0;
|
|
12127
|
+
const refuse = (problem) => {
|
|
12128
|
+
throw new CommsError("BAD_DATA", `${side.noun} ${where} is not valid: ${problem}`, { hint: side.hint });
|
|
12129
|
+
};
|
|
12130
|
+
let text;
|
|
12131
|
+
try {
|
|
12132
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
12133
|
+
} catch {
|
|
12134
|
+
return refuse("it is not UTF-8 text");
|
|
12135
|
+
}
|
|
12136
|
+
let raw;
|
|
12137
|
+
try {
|
|
12138
|
+
raw = JSON.parse(text.startsWith("") ? text.slice(1) : text);
|
|
12139
|
+
} catch {
|
|
12140
|
+
return refuse("it is not valid JSON");
|
|
12141
|
+
}
|
|
12142
|
+
const parsed = side.schema.safeParse(raw);
|
|
12143
|
+
if (!parsed.success) return refuse(parsed.error.issues.slice(0, 6).map((issue) => {
|
|
12144
|
+
const at = issue.path.length > 0 ? issue.path.map(String).join(".") : "the file";
|
|
12145
|
+
return issue.code === "unrecognized_keys" ? `${at}: ${issue.keys.map((key) => `"${shownText(key, 40)}"`).join(", ")} ${issue.keys.length === 1 ? "is not a key" : "are not keys"} it has` : `${at}: ${issue.message}`;
|
|
12146
|
+
}).join("; "));
|
|
12147
|
+
const document = parsed.data;
|
|
12148
|
+
if (document.organisation !== profile.organisation) return refuse(`it names the organisation ${shownText(document.organisation, 40)}, not ${profile.organisation}, whose profile it sits beside`);
|
|
12149
|
+
const problem = side.agrees?.(document, profile);
|
|
12150
|
+
if (problem !== void 0) return refuse(problem);
|
|
12151
|
+
return {
|
|
12152
|
+
path,
|
|
12153
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
12154
|
+
document
|
|
12155
|
+
};
|
|
12156
|
+
}
|
|
12157
|
+
strictObject({
|
|
12158
|
+
agentcomms: literal("organisation-agent-app"),
|
|
12159
|
+
version: literal(1),
|
|
12160
|
+
organisation: string(),
|
|
12161
|
+
slack: strictObject({
|
|
12162
|
+
workspace: string().regex(SLACK_WORKSPACE_ID_PATTERN, "a Slack workspace id is T followed by capital letters or digits"),
|
|
12163
|
+
appId: string().regex(SLACK_APP_ID_PATTERN, "a Slack app id is A followed by 2–20 capital letters or digits")
|
|
12164
|
+
})
|
|
12165
|
+
});
|
|
12166
|
+
/** The organisation profiles this configuration records — none on version 1, which cannot hold them. */
|
|
12167
|
+
function organisationsOf(config) {
|
|
12168
|
+
return hasNames(config) ? config.organisations ?? {} : {};
|
|
12169
|
+
}
|
|
11310
12170
|
strictObject({
|
|
11311
12171
|
organisation: string().refine((word) => parseOrganisation(word) !== null, "an organisation is a name’s first half"),
|
|
11312
12172
|
role: _enum(["read", "send"]),
|
|
@@ -11319,6 +12179,802 @@ strictObject({
|
|
|
11319
12179
|
sha256: string().regex(/^[0-9a-f]{64}$/, "a SHA-256 is 64 lowercase hex digits")
|
|
11320
12180
|
});
|
|
11321
12181
|
/**
|
|
12182
|
+
* An organisation's update caps (design 2026-10-16, U8): the most its members' machines may update — `max`,
|
|
12183
|
+
* `every-14-days` or `never` — and `background: false`, no background job. They only ever tighten: a member's level is
|
|
12184
|
+
* capped at `max`, and nothing a member sets goes past it.
|
|
12185
|
+
*
|
|
12186
|
+
* **They are named in a file beside the profile, not in it** (`readSideFile`): a key such as `updates` in the profile
|
|
12187
|
+
* would make every release from 0.13.0 to 0.19.0 refuse it. They go in `<profile>.updates.json`, recorded as the
|
|
12188
|
+
* organisation's `updates`.
|
|
12189
|
+
*/
|
|
12190
|
+
const UPDATE_CAPS = ["every-14-days", "never"];
|
|
12191
|
+
/** The updates file for a profile: in the same folder, the profile's name with `.updates.json` for its `.json`. */
|
|
12192
|
+
function updatesPathFor(profilePath) {
|
|
12193
|
+
return sidePathFor(profilePath, "updates");
|
|
12194
|
+
}
|
|
12195
|
+
const updatesSchema = strictObject({
|
|
12196
|
+
organisation: string(),
|
|
12197
|
+
max: _enum(UPDATE_CAPS, { message: "max is every-14-days or never: a cap only ever makes updates less frequent" }).optional(),
|
|
12198
|
+
background: literal(false, { message: "background is false or left out: a cap can only turn background updates off" }).optional()
|
|
12199
|
+
});
|
|
12200
|
+
/** The updates file, held to the profile's organisation (`readSideFile`). */
|
|
12201
|
+
async function readUpdatesFile(path, profile) {
|
|
12202
|
+
const read = await readSideFile(path, profile, {
|
|
12203
|
+
noun: "the updates file",
|
|
12204
|
+
size: "an updates file",
|
|
12205
|
+
schema: updatesSchema,
|
|
12206
|
+
hint: "It names the organisation, and the caps on how its members update and nothing else: `max` (every-14-days or never) and `background` (false). Ask your organisation for its current one."
|
|
12207
|
+
});
|
|
12208
|
+
if (read === void 0) return void 0;
|
|
12209
|
+
const { max, background } = read.document;
|
|
12210
|
+
return {
|
|
12211
|
+
path: read.path,
|
|
12212
|
+
sha256: read.sha256,
|
|
12213
|
+
...max === void 0 ? {} : { max },
|
|
12214
|
+
...background === void 0 ? {} : { background: false }
|
|
12215
|
+
};
|
|
12216
|
+
}
|
|
12217
|
+
/**
|
|
12218
|
+
* The caps file beside a profile, read with no profile to hold it to but the organisation's word: what stands in for
|
|
12219
|
+
* the caps the configuration records when `config.json` cannot be read (`effectiveUpdates`, failing closed). Beside the
|
|
12220
|
+
* file the path names (`profileTarget`), in the same bounded, strict read, held to the same organisation. Refused as
|
|
12221
|
+
* `readProfileFile` refuses it; undefined when there is none.
|
|
12222
|
+
*/
|
|
12223
|
+
async function readUpdatesBeside(profilePath, organisation) {
|
|
12224
|
+
return readUpdatesFile(updatesPathFor(profileTarget(profilePath)), { organisation });
|
|
12225
|
+
}
|
|
12226
|
+
/** The systemd units' and the Windows task's name. */
|
|
12227
|
+
const UPDATE_JOB_NAME = "agentcomms-update";
|
|
12228
|
+
Object.freeze(["update", "--background"]);
|
|
12229
|
+
function updateJobSupported(platform) {
|
|
12230
|
+
return platform === "darwin" || platform === "linux" || platform === "win32";
|
|
12231
|
+
}
|
|
12232
|
+
function updateJobMarkerPath(stateDir) {
|
|
12233
|
+
return join(stateDir, "update-job.json");
|
|
12234
|
+
}
|
|
12235
|
+
const isTime$1 = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
12236
|
+
/** The record, or null when there is none — or none this release can read, which is treated as none. */
|
|
12237
|
+
async function readUpdateJobMarker(stateDir) {
|
|
12238
|
+
let parsed;
|
|
12239
|
+
try {
|
|
12240
|
+
parsed = JSON.parse(await readFile(updateJobMarkerPath(stateDir), "utf8"));
|
|
12241
|
+
} catch {
|
|
12242
|
+
return null;
|
|
12243
|
+
}
|
|
12244
|
+
const record = parsed;
|
|
12245
|
+
if (typeof record !== "object" || record === null || !updateJobSupported(String(record.platform)) || typeof record.installedAt !== "string" || typeof record.label !== "string") return null;
|
|
12246
|
+
return {
|
|
12247
|
+
platform: record.platform,
|
|
12248
|
+
installedAt: record.installedAt,
|
|
12249
|
+
label: record.label,
|
|
12250
|
+
...isTime$1(record.lastRun) ? { lastRun: record.lastRun } : {}
|
|
12251
|
+
};
|
|
12252
|
+
}
|
|
12253
|
+
function within$1(at, now, intervalMs) {
|
|
12254
|
+
if (at === void 0 || !isTime$1(at)) return false;
|
|
12255
|
+
const since = now.getTime() - Date.parse(at);
|
|
12256
|
+
return since >= -3e5 && since < intervalMs;
|
|
12257
|
+
}
|
|
12258
|
+
/** The job's health, from its record; null when there is none (`readUpdateJobMarker`). */
|
|
12259
|
+
async function updateJobHealth(stateDir, now = /* @__PURE__ */ new Date()) {
|
|
12260
|
+
const marker = await readUpdateJobMarker(stateDir);
|
|
12261
|
+
if (marker === null) return null;
|
|
12262
|
+
return {
|
|
12263
|
+
marker,
|
|
12264
|
+
working: within$1(marker.lastRun, now, 1728e5) || within$1(marker.installedAt, now, 1728e5),
|
|
12265
|
+
since: marker.lastRun ?? marker.installedAt
|
|
12266
|
+
};
|
|
12267
|
+
}
|
|
12268
|
+
Object.freeze(["/usr/bin", "/bin"]);
|
|
12269
|
+
/** A PowerShell single-quoted string: nothing inside is special but a single quote — the curly ones too — doubled. */
|
|
12270
|
+
function powerShellLiteral(text) {
|
|
12271
|
+
return `'${text.replace(/['‘’‚‛]/g, (quote) => quote + quote)}'`;
|
|
12272
|
+
}
|
|
12273
|
+
`${powerShellLiteral(UPDATE_JOB_NAME)}`;
|
|
12274
|
+
Object.freeze(["every-14-days", "never"]);
|
|
12275
|
+
/**
|
|
12276
|
+
* The daily update check, as every server and command reads it (design 2026-09-28, and 2026-10-16 for the notice that
|
|
12277
|
+
* replaced its stop): one file per machine, `update-check.json` in the state directory, saying when the registry was
|
|
12278
|
+
* last asked, the latest release it named, whether this machine's registrations were behind it, any "not now" with when
|
|
12279
|
+
* it runs out, and when a command last said so.
|
|
12280
|
+
*
|
|
12281
|
+
* This is the reader, and it has **no network code at all**: it reads and writes that file, and says what it means.
|
|
12282
|
+
* Asking the registry is the checker's (`update-check.ts`), which every server and command but WhatsApp's also
|
|
12283
|
+
* imports. WhatsApp's package promises it reaches no network — `packages/whatsapp/test/no-network.test.ts` holds it to
|
|
12284
|
+
* that — so it imports only this, and learns of an update once any other server or command on the machine has asked.
|
|
12285
|
+
*/
|
|
12286
|
+
/** The file, in the state directory: one per machine, shared by every server and command on it. */
|
|
12287
|
+
const UPDATE_CHECK_FILE = "update-check.json";
|
|
12288
|
+
/**
|
|
12289
|
+
* The switch that turns the whole thing off for one process — no check, no notice — whatever the setting says. Every
|
|
12290
|
+
* test harness and every script here that runs a command or a server sets it, so no test and no verify step asks the
|
|
12291
|
+
* real registry.
|
|
12292
|
+
*/
|
|
12293
|
+
const UPDATE_CHECK_ENV = "AGENT_COMMS_UPDATE_CHECK";
|
|
12294
|
+
/**
|
|
12295
|
+
* The level for one process, whatever the machine's (design 2026-10-16, U2): `never` switches it all off as
|
|
12296
|
+
* `AGENT_COMMS_UPDATE_CHECK=off` does. Any other word leaves the machine's level as it is.
|
|
12297
|
+
*/
|
|
12298
|
+
const UPDATES_ENV = "AGENT_COMMS_UPDATES";
|
|
12299
|
+
/**
|
|
12300
|
+
* Set by what starts a server from a release it pins itself — the Claude Code plugin's launcher, the Gemini
|
|
12301
|
+
* extension's manifest — to say so: `claude-code-plugin`, `gemini-extension`. No registration here names that server,
|
|
12302
|
+
* and `comms_update` never moves it; it changes when the plugin or the extension is updated. So its notice always says
|
|
12303
|
+
* where it is updated, whatever the channel's registrations say: restarting would start the release the plugin pins.
|
|
12304
|
+
*/
|
|
12305
|
+
const UPDATE_STARTED_BY_ENV = "AGENT_COMMS_STARTED_BY";
|
|
12306
|
+
const DAY_MS = 864e5;
|
|
12307
|
+
/** How long a check is good for on `auto`: the registry is asked at most once in this long, by the whole machine. */
|
|
12308
|
+
const UPDATE_CHECK_INTERVAL_MS = DAY_MS;
|
|
12309
|
+
/** How long a check, and a notice, is good for on `every-14-days`. */
|
|
12310
|
+
const SPACED_INTERVAL_MS = 14 * DAY_MS;
|
|
12311
|
+
Object.freeze([
|
|
12312
|
+
1,
|
|
12313
|
+
7,
|
|
12314
|
+
14
|
|
12315
|
+
]);
|
|
12316
|
+
function readSecurityShown(value) {
|
|
12317
|
+
if (typeof value !== "object" || value === null) return null;
|
|
12318
|
+
const { version, approvals } = value;
|
|
12319
|
+
if (!isPlainVersion(version) || !Array.isArray(approvals)) return null;
|
|
12320
|
+
return {
|
|
12321
|
+
version,
|
|
12322
|
+
approvals: approvals.filter((id) => typeof id === "string" && APPROVAL_ID_PATTERN.test(id)).slice(-32)
|
|
12323
|
+
};
|
|
12324
|
+
}
|
|
12325
|
+
const NOTICE_OUTCOMES = [
|
|
12326
|
+
"installed",
|
|
12327
|
+
"skipped",
|
|
12328
|
+
"refused",
|
|
12329
|
+
"failed",
|
|
12330
|
+
"job-removed"
|
|
12331
|
+
];
|
|
12332
|
+
function readBackgroundNotice(value) {
|
|
12333
|
+
if (typeof value !== "object" || value === null) return null;
|
|
12334
|
+
const { at, outcome, version, text } = value;
|
|
12335
|
+
if (!isTime(at) || !NOTICE_OUTCOMES.includes(outcome) || typeof text !== "string") return null;
|
|
12336
|
+
return {
|
|
12337
|
+
at,
|
|
12338
|
+
outcome,
|
|
12339
|
+
version: isVersion(version) ? version : null,
|
|
12340
|
+
text: text.slice(0, 600)
|
|
12341
|
+
};
|
|
12342
|
+
}
|
|
12343
|
+
const EMPTY_UPDATE_CHECK = Object.freeze({
|
|
12344
|
+
lastChecked: null,
|
|
12345
|
+
latest: null,
|
|
12346
|
+
behind: null,
|
|
12347
|
+
current: null,
|
|
12348
|
+
lastError: null,
|
|
12349
|
+
snoozedUntil: null,
|
|
12350
|
+
checking: null,
|
|
12351
|
+
commandNoticeAt: null,
|
|
12352
|
+
noticeAt: null,
|
|
12353
|
+
pausedAt: null,
|
|
12354
|
+
pausedUntil: null,
|
|
12355
|
+
release: null,
|
|
12356
|
+
securityAsked: null,
|
|
12357
|
+
securityShown: null,
|
|
12358
|
+
backgroundNotice: null,
|
|
12359
|
+
moves: null
|
|
12360
|
+
});
|
|
12361
|
+
function updateCheckPath(stateDir) {
|
|
12362
|
+
return join(stateDir, UPDATE_CHECK_FILE);
|
|
12363
|
+
}
|
|
12364
|
+
const isTime = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
12365
|
+
const isWords = (value) => Array.isArray(value) && value.every((word) => typeof word === "string");
|
|
12366
|
+
function readRelease(value) {
|
|
12367
|
+
if (typeof value !== "object" || value === null) return null;
|
|
12368
|
+
const { version, security, needsDecision } = value;
|
|
12369
|
+
if (!isPlainVersion(version)) return null;
|
|
12370
|
+
return {
|
|
12371
|
+
version,
|
|
12372
|
+
security: security === true,
|
|
12373
|
+
needsDecision: needsDecision === true
|
|
12374
|
+
};
|
|
12375
|
+
}
|
|
12376
|
+
function readCurrent(value) {
|
|
12377
|
+
if (typeof value !== "object" || value === null) return null;
|
|
12378
|
+
const { registered, global } = value;
|
|
12379
|
+
return isWords(registered) && isWords(global) ? {
|
|
12380
|
+
registered: [...registered],
|
|
12381
|
+
global: [...global]
|
|
12382
|
+
} : null;
|
|
12383
|
+
}
|
|
12384
|
+
/**
|
|
12385
|
+
* The record as the file holds it, each field checked on its own: one that is missing or not what it should be reads
|
|
12386
|
+
* as not known. A file that is missing, unreadable or not JSON is an empty record — never a reason to stop anything.
|
|
12387
|
+
*/
|
|
12388
|
+
async function readUpdateCheck(stateDir) {
|
|
12389
|
+
let parsed;
|
|
12390
|
+
try {
|
|
12391
|
+
parsed = JSON.parse(await readFile(updateCheckPath(stateDir), "utf8"));
|
|
12392
|
+
} catch {
|
|
12393
|
+
return { ...EMPTY_UPDATE_CHECK };
|
|
12394
|
+
}
|
|
12395
|
+
const held = typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
12396
|
+
return {
|
|
12397
|
+
lastChecked: isTime(held.lastChecked) ? held.lastChecked : null,
|
|
12398
|
+
latest: isPlainVersion(held.latest) ? held.latest : null,
|
|
12399
|
+
behind: typeof held.behind === "boolean" ? held.behind : null,
|
|
12400
|
+
current: readCurrent(held.current),
|
|
12401
|
+
lastError: typeof held.lastError === "string" ? held.lastError.slice(0, 300) : null,
|
|
12402
|
+
snoozedUntil: isTime(held.snoozedUntil) ? held.snoozedUntil : null,
|
|
12403
|
+
checking: isTime(held.checking) ? held.checking : null,
|
|
12404
|
+
commandNoticeAt: isTime(held.commandNoticeAt) ? held.commandNoticeAt : null,
|
|
12405
|
+
noticeAt: isTime(held.noticeAt) ? held.noticeAt : null,
|
|
12406
|
+
pausedAt: isTime(held.pausedAt) ? held.pausedAt : null,
|
|
12407
|
+
pausedUntil: isTime(held.pausedUntil) ? held.pausedUntil : null,
|
|
12408
|
+
release: readRelease(held.release),
|
|
12409
|
+
securityAsked: isPlainVersion(held.securityAsked) ? held.securityAsked : null,
|
|
12410
|
+
securityShown: readSecurityShown(held.securityShown),
|
|
12411
|
+
backgroundNotice: readBackgroundNotice(held.backgroundNotice),
|
|
12412
|
+
moves: typeof held.moves === "number" && Number.isInteger(held.moves) && held.moves >= 0 ? held.moves : null
|
|
12413
|
+
};
|
|
12414
|
+
}
|
|
12415
|
+
/**
|
|
12416
|
+
* Reads the record, changes it and writes it back, under a lock beside it, atomically — so a server recording a check
|
|
12417
|
+
* and a person's "not now" arriving at the same moment each keep what the other wrote. Returns what `change` returned
|
|
12418
|
+
* alongside the record written; `change` returning `null` writes nothing.
|
|
12419
|
+
*/
|
|
12420
|
+
async function changeUpdateCheck(stateDir, change) {
|
|
12421
|
+
return withFileLock(join(stateDir, ".update-check.lock"), async () => {
|
|
12422
|
+
const current = await readUpdateCheck(stateDir);
|
|
12423
|
+
const next = change(current);
|
|
12424
|
+
if (next === null) return {
|
|
12425
|
+
record: current,
|
|
12426
|
+
result: void 0,
|
|
12427
|
+
written: false
|
|
12428
|
+
};
|
|
12429
|
+
await writeFileAtomic(updateCheckPath(stateDir), `${JSON.stringify(next.record, null, 2)}\n`);
|
|
12430
|
+
return {
|
|
12431
|
+
record: next.record,
|
|
12432
|
+
result: next.result,
|
|
12433
|
+
written: true
|
|
12434
|
+
};
|
|
12435
|
+
}, { timeoutMs: 2e3 });
|
|
12436
|
+
}
|
|
12437
|
+
/**
|
|
12438
|
+
* Whether the check is due: never asked, asked a day or more ago, or — a clock that went back — asked more than a few
|
|
12439
|
+
* minutes in the future. A few minutes are allowed for, because two processes' clocks are read at slightly different
|
|
12440
|
+
* moments, and a check just claimed by one must not look due to the other.
|
|
12441
|
+
*/
|
|
12442
|
+
function updateCheckDue(record, now, intervalMs = UPDATE_CHECK_INTERVAL_MS) {
|
|
12443
|
+
return !within(record.lastChecked, now, intervalMs);
|
|
12444
|
+
}
|
|
12445
|
+
/** Whether `at` is less than `intervalMs` ago — a time from more than a few minutes in the future is not trusted. */
|
|
12446
|
+
function within(at, now, intervalMs) {
|
|
12447
|
+
if (at === null) return false;
|
|
12448
|
+
const since = now.getTime() - Date.parse(at);
|
|
12449
|
+
return since >= -CLOCK_SKEW_MS && since < intervalMs;
|
|
12450
|
+
}
|
|
12451
|
+
const CLOCK_SKEW_MS = 3e5;
|
|
12452
|
+
/** What turned updates off for this process, from its environment: `CI`, or a switch. Null when nothing did. */
|
|
12453
|
+
function updateCheckSwitchedOff(env) {
|
|
12454
|
+
if (env.CI && env.CI !== "0" && env.CI !== "false") return "CI";
|
|
12455
|
+
const value = env[UPDATE_CHECK_ENV]?.trim().toLowerCase();
|
|
12456
|
+
if (value !== void 0 && [
|
|
12457
|
+
"off",
|
|
12458
|
+
"0",
|
|
12459
|
+
"false",
|
|
12460
|
+
"no"
|
|
12461
|
+
].includes(value)) return UPDATE_CHECK_ENV;
|
|
12462
|
+
if (env["AGENT_COMMS_UPDATES"]?.trim().toLowerCase() === "never") return UPDATES_ENV;
|
|
12463
|
+
return null;
|
|
12464
|
+
}
|
|
12465
|
+
/** How much a level updates: `auto` 2, `every-14-days` 1, `never` 0. */
|
|
12466
|
+
const rankOf = (level) => UPDATE_LEVELS.length - 1 - UPDATE_LEVELS.indexOf(level);
|
|
12467
|
+
/** `profileTarget`, for a path read back from the configuration: one it cannot follow is shown as it is. */
|
|
12468
|
+
function safeTarget(path) {
|
|
12469
|
+
try {
|
|
12470
|
+
return profileTarget(path);
|
|
12471
|
+
} catch {
|
|
12472
|
+
return path;
|
|
12473
|
+
}
|
|
12474
|
+
}
|
|
12475
|
+
/**
|
|
12476
|
+
* The caps the organisation profiles here name (design 2026-10-16, U8): the strictest `max`, and the first profile
|
|
12477
|
+
* with `background: false`, each with where it comes from. A `max` word this release does not know counts as `never`,
|
|
12478
|
+
* the strictest it could mean: caps only ever tighten.
|
|
12479
|
+
*/
|
|
12480
|
+
function organisationCaps(config) {
|
|
12481
|
+
let cap;
|
|
12482
|
+
let backgroundOff;
|
|
12483
|
+
for (const [organisation, record] of Object.entries(organisationsOf(config)).sort(([a], [b]) => a.localeCompare(b))) {
|
|
12484
|
+
const caps = record.updates;
|
|
12485
|
+
if (caps === void 0) continue;
|
|
12486
|
+
const where = {
|
|
12487
|
+
organisation,
|
|
12488
|
+
source: updatesPathFor(safeTarget(record.source.path))
|
|
12489
|
+
};
|
|
12490
|
+
if (caps.max !== void 0) {
|
|
12491
|
+
const level = caps.max === "every-14-days" ? "every-14-days" : "never";
|
|
12492
|
+
if (cap === void 0 || rankOf(level) < rankOf(cap.level)) cap = {
|
|
12493
|
+
...where,
|
|
12494
|
+
level
|
|
12495
|
+
};
|
|
12496
|
+
}
|
|
12497
|
+
if (caps.background === false && backgroundOff === void 0) backgroundOff = where;
|
|
12498
|
+
}
|
|
12499
|
+
return {
|
|
12500
|
+
cap,
|
|
12501
|
+
backgroundOff
|
|
12502
|
+
};
|
|
12503
|
+
}
|
|
12504
|
+
/**
|
|
12505
|
+
* The level in force by the configuration alone (design 2026-10-16, U2, U8): the level set, capped by its
|
|
12506
|
+
* organisations' caps, and the organisation that turns background updates off. What `effectiveUpdates` starts from,
|
|
12507
|
+
* and what a background run and the background job's setup read — neither of which runs where the environment
|
|
12508
|
+
* switched updates off, and neither of which counts a configuration it cannot read as anything.
|
|
12509
|
+
*/
|
|
12510
|
+
function configuredUpdates(config) {
|
|
12511
|
+
const set = updateLevelOf(config);
|
|
12512
|
+
const caps = organisationCaps(config);
|
|
12513
|
+
const cap = caps.cap !== void 0 && rankOf(caps.cap.level) < rankOf(set) ? caps.cap : void 0;
|
|
12514
|
+
return {
|
|
12515
|
+
level: cap?.level ?? set,
|
|
12516
|
+
set,
|
|
12517
|
+
...cap ? { cap } : {},
|
|
12518
|
+
...caps.backgroundOff ? { backgroundOff: caps.backgroundOff } : {}
|
|
12519
|
+
};
|
|
12520
|
+
}
|
|
12521
|
+
/**
|
|
12522
|
+
* The caps that stand in for the configuration's when `config.json` cannot be read (U8, failing closed): for every
|
|
12523
|
+
* organisation the file still names — read loosely, as JSON, whatever its schema says — the caps it records, and the
|
|
12524
|
+
* caps file beside its profile, read again (`readUpdatesBeside`). The strictest `max` of them all, and the first
|
|
12525
|
+
* `background: false`. A caps file that cannot be read is passed over; what the configuration recorded of it stands.
|
|
12526
|
+
* A file that is not JSON at all names no organisation, and so no cap.
|
|
12527
|
+
*/
|
|
12528
|
+
async function capsWithoutConfig(core) {
|
|
12529
|
+
let raw;
|
|
12530
|
+
try {
|
|
12531
|
+
raw = JSON.parse(await readFile(core.config.path, "utf8"));
|
|
12532
|
+
} catch {
|
|
12533
|
+
return { read: 0 };
|
|
12534
|
+
}
|
|
12535
|
+
const organisations = raw?.organisations;
|
|
12536
|
+
if (typeof organisations !== "object" || organisations === null) return { read: 0 };
|
|
12537
|
+
let cap;
|
|
12538
|
+
let backgroundOff;
|
|
12539
|
+
let read = 0;
|
|
12540
|
+
const take = (organisation, source, caps) => {
|
|
12541
|
+
read += 1;
|
|
12542
|
+
if (caps.max !== void 0) {
|
|
12543
|
+
const level = caps.max === "every-14-days" ? "every-14-days" : "never";
|
|
12544
|
+
if (cap === void 0 || rankOf(level) < rankOf(cap.level)) cap = {
|
|
12545
|
+
organisation,
|
|
12546
|
+
source,
|
|
12547
|
+
level
|
|
12548
|
+
};
|
|
12549
|
+
}
|
|
12550
|
+
if (caps.background === false && backgroundOff === void 0) backgroundOff = {
|
|
12551
|
+
organisation,
|
|
12552
|
+
source
|
|
12553
|
+
};
|
|
12554
|
+
};
|
|
12555
|
+
for (const [organisation, record] of Object.entries(organisations).sort(([a], [b]) => a.localeCompare(b))) {
|
|
12556
|
+
const held = typeof record === "object" && record !== null ? record : {};
|
|
12557
|
+
const path = typeof held.source?.path === "string" && isAbsolute(held.source.path) ? held.source.path : void 0;
|
|
12558
|
+
const source = path === void 0 ? core.config.path : updatesPathFor(safeTarget(path));
|
|
12559
|
+
if (typeof held.updates === "object" && held.updates !== null) take(organisation, source, held.updates);
|
|
12560
|
+
if (path === void 0) continue;
|
|
12561
|
+
try {
|
|
12562
|
+
const file = await readUpdatesBeside(path, organisation);
|
|
12563
|
+
if (file !== void 0) take(organisation, file.path, file);
|
|
12564
|
+
} catch {}
|
|
12565
|
+
}
|
|
12566
|
+
return {
|
|
12567
|
+
...cap ? { cap } : {},
|
|
12568
|
+
...backgroundOff ? { backgroundOff } : {},
|
|
12569
|
+
read
|
|
12570
|
+
};
|
|
12571
|
+
}
|
|
12572
|
+
/**
|
|
12573
|
+
* The level in force here, read from the environment first, then the configuration and its organisations' caps.
|
|
12574
|
+
*
|
|
12575
|
+
* A configuration that cannot be read sets the default, `auto` — what is wrong with it is for the doctor and the tools
|
|
12576
|
+
* to say, and it must not quietly turn updates off — but it never lifts an organisation's cap: the strictest cap that
|
|
12577
|
+
* can still be read in its place holds (`capsWithoutConfig`), and nothing installs in the background, which reads the
|
|
12578
|
+
* configuration too.
|
|
12579
|
+
*/
|
|
12580
|
+
async function effectiveUpdates(core, env, now = /* @__PURE__ */ new Date()) {
|
|
12581
|
+
let configured;
|
|
12582
|
+
try {
|
|
12583
|
+
configured = configuredUpdates(await core.config.load());
|
|
12584
|
+
} catch (error) {
|
|
12585
|
+
const { read, cap, backgroundOff } = await capsWithoutConfig(core);
|
|
12586
|
+
configured = {
|
|
12587
|
+
level: cap?.level ?? "auto",
|
|
12588
|
+
set: "auto",
|
|
12589
|
+
...cap ? { cap } : {},
|
|
12590
|
+
...backgroundOff ? { backgroundOff } : {},
|
|
12591
|
+
unreadableConfig: {
|
|
12592
|
+
reason: shownText(error instanceof Error ? error.message : String(error), 200),
|
|
12593
|
+
read
|
|
12594
|
+
}
|
|
12595
|
+
};
|
|
12596
|
+
}
|
|
12597
|
+
const { level, set, ...extra } = configured;
|
|
12598
|
+
const switched = updateCheckSwitchedOff(env);
|
|
12599
|
+
if (switched !== null) return {
|
|
12600
|
+
level: "never",
|
|
12601
|
+
set,
|
|
12602
|
+
by: switched,
|
|
12603
|
+
background: false,
|
|
12604
|
+
...extra
|
|
12605
|
+
};
|
|
12606
|
+
if (level !== "auto" || extra.backgroundOff !== void 0 || extra.unreadableConfig !== void 0) return {
|
|
12607
|
+
level,
|
|
12608
|
+
set,
|
|
12609
|
+
by: "setting",
|
|
12610
|
+
background: false,
|
|
12611
|
+
...extra
|
|
12612
|
+
};
|
|
12613
|
+
const job = await updateJobHealth(core.paths.stateDir, now);
|
|
12614
|
+
return {
|
|
12615
|
+
level,
|
|
12616
|
+
set,
|
|
12617
|
+
by: "setting",
|
|
12618
|
+
background: job?.working === true,
|
|
12619
|
+
...extra,
|
|
12620
|
+
...job !== null && !job.working ? { jobSilent: { since: job.since } } : {}
|
|
12621
|
+
};
|
|
12622
|
+
}
|
|
12623
|
+
/** Whether a check may run at all, how often, and if not, what turned it off: the environment, then the level. */
|
|
12624
|
+
async function updateCheckEnabled(core, env) {
|
|
12625
|
+
const updates = await effectiveUpdates(core, env);
|
|
12626
|
+
if (updates.level === "never") return {
|
|
12627
|
+
on: false,
|
|
12628
|
+
by: updates.by
|
|
12629
|
+
};
|
|
12630
|
+
return {
|
|
12631
|
+
on: true,
|
|
12632
|
+
intervalMs: updates.level === "every-14-days" ? SPACED_INTERVAL_MS : UPDATE_CHECK_INTERVAL_MS
|
|
12633
|
+
};
|
|
12634
|
+
}
|
|
12635
|
+
/**
|
|
12636
|
+
* When a pause ends, while one holds at `now` — or null. A pause holds from when it was asked until its end, and only
|
|
12637
|
+
* while that end is at most 14 days after it was asked: a clock gone back to before it was asked, or an end written by
|
|
12638
|
+
* hand further off, holds nothing, so no pause ever lasts longer than 14 days from when it was asked.
|
|
12639
|
+
*/
|
|
12640
|
+
function pauseEnd(record, now) {
|
|
12641
|
+
if (record.pausedAt === null || record.pausedUntil === null) return null;
|
|
12642
|
+
const asked = Date.parse(record.pausedAt);
|
|
12643
|
+
const ends = Date.parse(record.pausedUntil);
|
|
12644
|
+
if (ends - asked > 12099e5) return null;
|
|
12645
|
+
if (now.getTime() < asked - CLOCK_SKEW_MS || now.getTime() >= ends) return null;
|
|
12646
|
+
return new Date(ends);
|
|
12647
|
+
}
|
|
12648
|
+
/**
|
|
12649
|
+
* The security fix this record says is out, for a process running `running`: the latest release, when its manifest
|
|
12650
|
+
* declares one and `running` is behind it. Null otherwise.
|
|
12651
|
+
*/
|
|
12652
|
+
function securityRelease(record, running) {
|
|
12653
|
+
const latest = countedLatest(record);
|
|
12654
|
+
if (latest === null || record.release?.version !== latest || !record.release.security) return null;
|
|
12655
|
+
return isVersion(running) && isBehind(running, latest) ? latest : null;
|
|
12656
|
+
}
|
|
12657
|
+
/**
|
|
12658
|
+
* The person answered the security line (design 2026-10-16, U7): the send or post `approvalId` was approved, or claimed
|
|
12659
|
+
* to be sent. When its preview carried the line, that release counts as asked from now on, and the next preview is as
|
|
12660
|
+
* it always was. An approval no preview carried it on answers nothing. Called by the approval store
|
|
12661
|
+
* (`ApprovalStoreOptions.answered`); it never throws, and a send never waits long on it.
|
|
12662
|
+
*/
|
|
12663
|
+
async function securityAnswered(stateDir, approvalId) {
|
|
12664
|
+
const carried = (record) => record.securityShown?.approvals.includes(approvalId) === true;
|
|
12665
|
+
try {
|
|
12666
|
+
if (!carried(await readUpdateCheck(stateDir))) return;
|
|
12667
|
+
await changeUpdateCheck(stateDir, (record) => {
|
|
12668
|
+
if (!carried(record) || record.securityShown === null || record.securityShown === void 0) return null;
|
|
12669
|
+
return { record: {
|
|
12670
|
+
...record,
|
|
12671
|
+
securityAsked: record.securityShown.version,
|
|
12672
|
+
securityShown: null
|
|
12673
|
+
} };
|
|
12674
|
+
});
|
|
12675
|
+
} catch {}
|
|
12676
|
+
}
|
|
12677
|
+
/** A local time as a person reads it in a preview: `2026-09-29 00:00`. */
|
|
12678
|
+
function localStamp(at) {
|
|
12679
|
+
const two = (value) => String(value).padStart(2, "0");
|
|
12680
|
+
return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
|
|
12681
|
+
}
|
|
12682
|
+
/**
|
|
12683
|
+
* The latest release as the check counts it: a plain `MAJOR.MINOR.PATCH` — 0.8.0-rc.1 is never an update anybody is
|
|
12684
|
+
* told of, and 0.8.1+anything is not a release at all. Null when the file names none.
|
|
12685
|
+
*/
|
|
12686
|
+
function countedLatest(record) {
|
|
12687
|
+
return isPlainVersion(record.latest) ? record.latest : null;
|
|
12688
|
+
}
|
|
12689
|
+
/**
|
|
12690
|
+
* What the record means for a process running `running`, ignoring any "not now": null when there is nothing newer.
|
|
12691
|
+
*
|
|
12692
|
+
* `restart` only on the check's positive finding about this very server or command (`current`): a server whose every
|
|
12693
|
+
* registration names `latest`, or a command whose package is installed globally at `latest`. Anything else — no
|
|
12694
|
+
* registration the scan could see (a plugin's, an extension's, one written by hand), one that pins no release, a
|
|
12695
|
+
* configuration it could not read, a check that found out nothing — is `update`: restarting would start the same old
|
|
12696
|
+
* code, and a notice that said to restart would give advice that cannot work.
|
|
12697
|
+
*/
|
|
12698
|
+
function updateVerdict(record, running, where) {
|
|
12699
|
+
const latest = countedLatest(record);
|
|
12700
|
+
if (latest === null || !isVersion(running) || !isBehind(running, latest)) return null;
|
|
12701
|
+
return {
|
|
12702
|
+
kind: (where.surface === "server" ? record.current?.registered : record.current?.global)?.includes(where.channel) === true ? "restart" : "update",
|
|
12703
|
+
running,
|
|
12704
|
+
latest
|
|
12705
|
+
};
|
|
12706
|
+
}
|
|
12707
|
+
function openCore(options = {}) {
|
|
12708
|
+
const { paths, pathOverrides } = resolvePathIdentity(options);
|
|
12709
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
12710
|
+
const handoffs = options.caller === void 0 ? void 0 : cliHandoffs({
|
|
12711
|
+
caller: options.caller,
|
|
12712
|
+
paths,
|
|
12713
|
+
platform: options.platform,
|
|
12714
|
+
env: options.env
|
|
12715
|
+
});
|
|
12716
|
+
const config = new ConfigStore(paths.configDir, { handoffs });
|
|
12717
|
+
const audit = new AuditLog(paths.stateDir, now);
|
|
12718
|
+
let cached = null;
|
|
12719
|
+
return {
|
|
12720
|
+
paths,
|
|
12721
|
+
pathOverrides,
|
|
12722
|
+
config,
|
|
12723
|
+
states: new InboxStateStore(paths.stateDir),
|
|
12724
|
+
approvals: new ApprovalStore(paths.stateDir, {
|
|
12725
|
+
now,
|
|
12726
|
+
handoffs,
|
|
12727
|
+
loadConfig: () => config.load(),
|
|
12728
|
+
audit,
|
|
12729
|
+
answered: (approvalId) => securityAnswered(paths.stateDir, approvalId)
|
|
12730
|
+
}),
|
|
12731
|
+
ledger: new SendLedger(paths.stateDir, now),
|
|
12732
|
+
plans: new PlanStore(paths.stateDir, now),
|
|
12733
|
+
taint: new TaintStore(paths.stateDir, now),
|
|
12734
|
+
audit,
|
|
12735
|
+
handoffs,
|
|
12736
|
+
async secrets(kind) {
|
|
12737
|
+
const chosen = kind ?? secretsStoreOf(await config.load());
|
|
12738
|
+
if (cached?.kind === chosen) return cached.store;
|
|
12739
|
+
const store = await openSecretStore(chosen, {
|
|
12740
|
+
secretsDir: paths.secretsDir,
|
|
12741
|
+
namespace: keychainNamespace(paths.configDir),
|
|
12742
|
+
handoffs
|
|
12743
|
+
});
|
|
12744
|
+
cached = {
|
|
12745
|
+
kind: chosen,
|
|
12746
|
+
store
|
|
12747
|
+
};
|
|
12748
|
+
return store;
|
|
12749
|
+
}
|
|
12750
|
+
};
|
|
12751
|
+
}
|
|
12752
|
+
/**
|
|
12753
|
+
* A whole number as it was given: a string of digits and nothing else (surrounding spaces aside), or a number that is
|
|
12754
|
+
* whole — and `NaN` for anything else.
|
|
12755
|
+
*
|
|
12756
|
+
* `Number.parseInt` read `abc` as NaN, `12abc` as 12 and `1e2` as 1; `Number` reads `1e2` as 100, `0x10` as 16 and an
|
|
12757
|
+
* empty string as 0. Each of those is a number nobody typed, so none of them is read as one here. A value too large to
|
|
12758
|
+
* hold exactly is not one either: it would be used as some other number.
|
|
12759
|
+
*/
|
|
12760
|
+
function readWholeNumber(raw) {
|
|
12761
|
+
const value = typeof raw === "number" ? raw : typeof raw === "string" && /^[0-9]+$/.test(raw.trim()) ? Number(raw.trim()) : NaN;
|
|
12762
|
+
return Number.isSafeInteger(value) ? value : NaN;
|
|
12763
|
+
}
|
|
12764
|
+
/**
|
|
12765
|
+
* A number option, checked rather than coerced: the number, `undefined` when none was given, or a USAGE refusal that
|
|
12766
|
+
* names the option and its range.
|
|
12767
|
+
*
|
|
12768
|
+
* One check for every command line's number options — `agentcomms`, `agent-gmail` and `agent-slack` — and for the
|
|
12769
|
+
* operations those commands share with their tools, so a number the command refuses is refused by the tool in the
|
|
12770
|
+
* same words, and nothing reads `--limit 1e2` as 1 or `--port abc` as no port at all.
|
|
12771
|
+
*/
|
|
12772
|
+
function wholeNumber(raw, rule) {
|
|
12773
|
+
if (raw === void 0) return void 0;
|
|
12774
|
+
const value = readWholeNumber(raw);
|
|
12775
|
+
if (Number.isNaN(value) || value < rule.min || rule.max !== void 0 && value > rule.max) {
|
|
12776
|
+
const range = rule.max === void 0 ? `of ${rule.min} or more` : `from ${rule.min} to ${rule.max}`;
|
|
12777
|
+
const hint = rule.hint ?? (Number.isNaN(value) ? "Digits only: no sign, decimal point, exponent or unit." : null);
|
|
12778
|
+
throw new CommsError("USAGE", `${rule.name} "${String(raw)}" is not a whole number ${range}`, hint === null ? {} : { hint });
|
|
12779
|
+
}
|
|
12780
|
+
return value;
|
|
12781
|
+
}
|
|
12782
|
+
/**
|
|
12783
|
+
* Every sentence a background run may leave for the next session, and the one way a notice of it is said (design
|
|
12784
|
+
* 2026-10-16 U5 step 7, U9; security review of 0.20.0, severity 6): in this package's own words, with nothing in them
|
|
12785
|
+
* the registry or a certificate wrote. A release's version is the one thing a sentence takes from outside, and only as a
|
|
12786
|
+
* plain `MAJOR.MINOR.PATCH`: a prerelease's words or build metadata (`0.20.1+Ignore-previous-instructions` is valid
|
|
12787
|
+
* semver) are never repeated.
|
|
12788
|
+
*
|
|
12789
|
+
* The run writes one of these into the update state file; the notice (`update-gate.ts`) says it only when it is one of
|
|
12790
|
+
* them, word for word, and otherwise the outcome's own sentence — so a record written by another release, or by hand,
|
|
12791
|
+
* never reaches a model as it is.
|
|
12792
|
+
*
|
|
12793
|
+
* Nothing here reaches the network or reads a file: every server and command, WhatsApp's too, carries it.
|
|
12794
|
+
*/
|
|
12795
|
+
const PLAIN_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
12796
|
+
/** A release as a notice names it: its version when it is a plain `MAJOR.MINOR.PATCH`, otherwise "A release". */
|
|
12797
|
+
function shownRelease(version) {
|
|
12798
|
+
return typeof version === "string" && PLAIN_VERSION.test(version) ? version : "A release";
|
|
12799
|
+
}
|
|
12800
|
+
/** Which pin a release did not verify against, in this package's own words — never the value it held instead. */
|
|
12801
|
+
const PIN_WORDS = Object.freeze({
|
|
12802
|
+
attestation: "npm has no SLSA provenance attestation for it that could be read",
|
|
12803
|
+
bundle: "its provenance bundle could not be read",
|
|
12804
|
+
issuer: "it was not signed through GitHub Actions' token issuer",
|
|
12805
|
+
identity: "it was not signed for this repository's release workflow at the tag of its version",
|
|
12806
|
+
runner: "it was not built on a GitHub-hosted runner",
|
|
12807
|
+
repository: "it was not built from this repository",
|
|
12808
|
+
ref: "it was not built from the tag of its version",
|
|
12809
|
+
subject: "what its provenance signs is not the tarball the registry serves for it",
|
|
12810
|
+
signature: "Sigstore did not verify its signature, certificate or transparency-log entry"
|
|
12811
|
+
});
|
|
12812
|
+
const PIN_FIELDS = Object.keys(PIN_WORDS);
|
|
12813
|
+
/** What went wrong, in fixed words. */
|
|
12814
|
+
const FAILED_WORDS = Object.freeze({
|
|
12815
|
+
install: "one of its packages could not be installed",
|
|
12816
|
+
tarball: "what npm installed is not the tarball whose provenance was verified",
|
|
12817
|
+
selfCheck: "one of its packages failed its self-check",
|
|
12818
|
+
switch: "a launcher could not be switched, so every launcher was put back",
|
|
12819
|
+
inUse: "an unfinished runtime of it is in the way"
|
|
12820
|
+
});
|
|
12821
|
+
const RESTART = "restart each MCP client that starts these servers (in Claude Code, /mcp and then Reconnect) to use it: a server already running keeps the code it started with";
|
|
12822
|
+
/** The sentences, by what happened. */
|
|
12823
|
+
const BACKGROUND_WORDS = Object.freeze({
|
|
12824
|
+
installed: (version) => `${shownRelease(version)} is installed; ${RESTART}.`,
|
|
12825
|
+
major: (version) => `${shownRelease(version)} is a new major release: it is never installed in the background. Update when it suits you — comms_update shows each step first.`,
|
|
12826
|
+
decision: (version) => `${shownRelease(version)} needs a decision from you — it, or a release before it, says so — so it was not installed in the background. comms_update shows each step first; its release notes say what it changes.`,
|
|
12827
|
+
downgrade: (version) => `The registry names ${shownRelease(version)} as the latest release, older than what runs here: nothing was changed in the background.`,
|
|
12828
|
+
waits: (version) => `${shownRelease(version)} waits for you: it was not installed in the background. comms_update shows each step first.`,
|
|
12829
|
+
unverified: (version, field) => `${shownRelease(version)} was not installed: a release could not be verified as built by this repository's release workflow — ${PIN_WORDS[field]}. Nothing was changed.`,
|
|
12830
|
+
refused: (version) => `${shownRelease(version)} was not installed: a release could not be verified as built by this repository's release workflow. Nothing was changed.`,
|
|
12831
|
+
failed: (version, what) => `${shownRelease(version)} was not installed in the background: ${FAILED_WORDS[what]}. Nothing was switched; the next run tries again, and comms_audit_tail shows what went wrong.`,
|
|
12832
|
+
stopped: (version) => `${shownRelease(version)} was not installed in the background, and nothing was switched; the next run tries again, and comms_audit_tail shows what went wrong.`,
|
|
12833
|
+
partlySwitched: (version) => `${shownRelease(version)} was not installed in the background: a launcher could not be switched, and not every launcher could be put back, so some here start ${shownRelease(version)} from their next start and some the release before. comms_audit_tail says which.`,
|
|
12834
|
+
heldUnreadable: "No update is installed in the background while update-held.json in the state directory cannot be read: it says which releases were rolled back. Delete it to hold none.",
|
|
12835
|
+
jobRemoved: "An organisation profile on this machine turned background updates off, so the background update job was removed. Updates are still checked daily, and a notice says when one is out."
|
|
12836
|
+
});
|
|
12837
|
+
/** Every sentence a run may leave for `outcome` and `version`, the outcome's own first. */
|
|
12838
|
+
function sentencesFor(outcome, version) {
|
|
12839
|
+
const words = BACKGROUND_WORDS;
|
|
12840
|
+
switch (outcome) {
|
|
12841
|
+
case "installed": return [words.installed(version ?? "")];
|
|
12842
|
+
case "skipped": return [
|
|
12843
|
+
words.waits(version),
|
|
12844
|
+
words.major(version ?? ""),
|
|
12845
|
+
words.decision(version ?? ""),
|
|
12846
|
+
words.downgrade(version ?? "")
|
|
12847
|
+
];
|
|
12848
|
+
case "refused": return [words.refused(version), ...PIN_FIELDS.map((field) => words.unverified(version, field))];
|
|
12849
|
+
case "failed": return [
|
|
12850
|
+
words.stopped(version),
|
|
12851
|
+
...Object.keys(FAILED_WORDS).map((what) => words.failed(version, what)),
|
|
12852
|
+
words.partlySwitched(version),
|
|
12853
|
+
words.heldUnreadable
|
|
12854
|
+
];
|
|
12855
|
+
case "job-removed": return [words.jobRemoved];
|
|
12856
|
+
}
|
|
12857
|
+
}
|
|
12858
|
+
/**
|
|
12859
|
+
* What a notice says of the last background run: the run's own sentence when it is one of these, word for word, for
|
|
12860
|
+
* its outcome and release — and otherwise the outcome's own. Never the record's text as it is.
|
|
12861
|
+
*/
|
|
12862
|
+
function backgroundNoticeLine(notice) {
|
|
12863
|
+
const sentences = sentencesFor(notice.outcome, notice.version);
|
|
12864
|
+
return sentences.includes(notice.text) ? notice.text : sentences[0];
|
|
12865
|
+
}
|
|
12866
|
+
/** The SHA-512 in a subresource-integrity string, as hex; null when it holds none. */
|
|
12867
|
+
function sha512Hex(integrity) {
|
|
12868
|
+
for (const part of integrity.trim().split(/\s+/)) {
|
|
12869
|
+
if (!part.startsWith("sha512-")) continue;
|
|
12870
|
+
const bytes = Buffer.from(part.slice(7).split("?")[0] ?? "", "base64");
|
|
12871
|
+
if (bytes.length === 64) return bytes.toString("hex");
|
|
12872
|
+
}
|
|
12873
|
+
return null;
|
|
12874
|
+
}
|
|
12875
|
+
/** The tarball integrity the registry gives for one version, or null when it gives none. */
|
|
12876
|
+
function releaseIntegrity(packument, version) {
|
|
12877
|
+
const integrity = packument.versions?.[version]?.dist?.integrity;
|
|
12878
|
+
return typeof integrity === "string" && integrity.trim() !== "" ? integrity : null;
|
|
12879
|
+
}
|
|
12880
|
+
/** What a release that verified was built by, in one phrase: the preview's and the dry run's words. */
|
|
12881
|
+
const BUILT_BY = "this repository's release workflow (.github/workflows/release.yml) at the tag of its version, on a GitHub-hosted runner, as its Sigstore provenance shows";
|
|
12882
|
+
/** The scope every package of this suite is published under, and so attested for. */
|
|
12883
|
+
const SUITE = "@agentcomms/";
|
|
12884
|
+
/**
|
|
12885
|
+
* Why the runtime at `runtime` is not what was verified, or null when it is (security review of 0.20.0, severity 5).
|
|
12886
|
+
*
|
|
12887
|
+
* Every package of this suite npm installed in it — `packageName` itself, and any of ours it depends on at any depth,
|
|
12888
|
+
* as Gmail depends on core — has to be, by the runtime's own lockfile, a tarball whose provenance verified: the one
|
|
12889
|
+
* whose SHA-512 is `sha512` for `packageName`, and for each other either one already verified (`known`, by
|
|
12890
|
+
* `package@version`, the SHA-512 it signs) or one `verify` verifies now, against the integrity npm recorded. npm checks
|
|
12891
|
+
* each tarball it downloads against the integrity the registry gives it then, and records that; a registry that gave
|
|
12892
|
+
* another one to the install than to the check, or a runtime made some other way, is caught here.
|
|
12893
|
+
*
|
|
12894
|
+
* The packages of others it holds are checked by npm alone, against the registry's integrity: no pin says who builds
|
|
12895
|
+
* them (SECURITY.md says so). No package's install script ran (`installManagedRuntime`).
|
|
12896
|
+
*/
|
|
12897
|
+
async function runtimeIntegrityProblem(runtime, packageName, sha512, options = {}) {
|
|
12898
|
+
let lock;
|
|
12899
|
+
try {
|
|
12900
|
+
lock = JSON.parse(await readFile(join(runtime, "package-lock.json"), "utf8"));
|
|
12901
|
+
} catch {
|
|
12902
|
+
lock = null;
|
|
12903
|
+
}
|
|
12904
|
+
const own = `node_modules/${packageName}`;
|
|
12905
|
+
const integrity = lock?.packages?.[own]?.integrity;
|
|
12906
|
+
if (typeof integrity !== "string") return `what is in ${runtime} cannot be shown to be the tarball whose provenance was verified: its lockfile records no integrity for ${packageName}`;
|
|
12907
|
+
if (sha512Hex(integrity) !== sha512) return `what is in ${runtime} is not the tarball whose provenance was verified: its lockfile records another integrity for ${packageName}`;
|
|
12908
|
+
const known = options.known ?? /* @__PURE__ */ new Map();
|
|
12909
|
+
for (const [key, entry] of Object.entries(lock?.packages ?? {})) {
|
|
12910
|
+
if (key === own) continue;
|
|
12911
|
+
const name = key.slice(key.lastIndexOf("node_modules/") + 13);
|
|
12912
|
+
if (!key.includes("node_modules/") || !name.startsWith(SUITE)) continue;
|
|
12913
|
+
const version = entry?.version;
|
|
12914
|
+
const recorded = entry?.integrity;
|
|
12915
|
+
if (!isPlainVersion(version) || typeof recorded !== "string" || sha512Hex(recorded) === null) return `${name}, which ${packageName} in ${runtime} depends on, cannot be shown to be a verified tarball: its lockfile records no plain release version or no SHA-512 integrity for it`;
|
|
12916
|
+
const hex = sha512Hex(recorded);
|
|
12917
|
+
if (known.get(`${name}@${version}`) === hex) continue;
|
|
12918
|
+
if (!options.verify) return `${name}@${version}, which ${packageName} in ${runtime} depends on, cannot be verified here`;
|
|
12919
|
+
const verdict = await options.verify({
|
|
12920
|
+
packageName: name,
|
|
12921
|
+
version,
|
|
12922
|
+
integrity: recorded
|
|
12923
|
+
});
|
|
12924
|
+
if (!verdict.ok) return `${name}@${version}, which ${packageName} in ${runtime} depends on, is not a tarball built by this repository's release workflow — ${PIN_WORDS[verdict.field]}`;
|
|
12925
|
+
known.set(`${name}@${version}`, hex);
|
|
12926
|
+
}
|
|
12927
|
+
return null;
|
|
12928
|
+
}
|
|
12929
|
+
/**
|
|
12930
|
+
* The refusal of a whole update or install for one release that did not verify: which, and which pin — never what it
|
|
12931
|
+
* held. `hint` replaces the general one where there is more to say: where it can be verified, for a process that
|
|
12932
|
+
* cannot.
|
|
12933
|
+
*/
|
|
12934
|
+
function unverifiedRelease(packageName, version, why, pin, act = "update", hint) {
|
|
12935
|
+
const which = act === "update" ? "An update installs" : "An install fetches";
|
|
12936
|
+
const plain = isPlainVersion(version);
|
|
12937
|
+
return new CommsError("BAD_DATA", `nothing was installed or changed: ${plain ? `${packageName}@${version}` : `${packageName} at a version that is not a plain release`} could not be verified as built by this repository's release workflow — ${why}`, {
|
|
12938
|
+
hint: hint ?? `${which} only a release whose Sigstore provenance shows it was built by this repository's release workflow at the tag of its version, on a GitHub-hosted runner, and nothing skips that check. Nothing was prepared to approve. A release published minutes ago can be without its attestation for a while: try again later. If it still does not verify, do not install it some other way; report it as SECURITY.md says.`,
|
|
12939
|
+
details: {
|
|
12940
|
+
package: packageName,
|
|
12941
|
+
...plain ? { version } : {},
|
|
12942
|
+
pin
|
|
12943
|
+
}
|
|
12944
|
+
});
|
|
12945
|
+
}
|
|
12946
|
+
/**
|
|
12947
|
+
* Verifies every release in `wanted` — its tarball's integrity as the registry gives it, and npm's attestation for it,
|
|
12948
|
+
* held to the pins the background run holds a release to (design 2026-10-16 U5 step 3). The first that does not verify
|
|
12949
|
+
* refuses the whole update or install (`unverifiedRelease`); what verified is returned by package, for the preview
|
|
12950
|
+
* and for the integrity check after install. Reads; changes nothing. With no registry or no verifier, the first
|
|
12951
|
+
* release is refused before anything is asked.
|
|
12952
|
+
*/
|
|
12953
|
+
async function verifyReleaseSet(sources, wanted, act = "update", noVerifierHint) {
|
|
12954
|
+
const verified = /* @__PURE__ */ new Map();
|
|
12955
|
+
if (wanted.size === 0) return verified;
|
|
12956
|
+
const { registry, verify, why } = sources;
|
|
12957
|
+
for (const [packageName, version] of [...wanted].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
12958
|
+
if (!isPlainVersion(version)) throw unverifiedRelease(packageName, version, "it is not a plain release (MAJOR.MINOR.PATCH)", "version", act);
|
|
12959
|
+
if (registry === null || verify === null) throw unverifiedRelease(packageName, version, why, "verifier", act, noVerifierHint);
|
|
12960
|
+
let integrity;
|
|
12961
|
+
try {
|
|
12962
|
+
integrity = releaseIntegrity((await registry(packageName)).packument, version);
|
|
12963
|
+
} catch (error) {
|
|
12964
|
+
throw new CommsError("PROVIDER_UNAVAILABLE", `nothing was installed or changed: the registry's document of ${packageName} could not be read to verify ${version}: ${error instanceof Error ? error.message : String(error)}`, { hint: "Try again once the registry can be reached." });
|
|
12965
|
+
}
|
|
12966
|
+
if (integrity === null) throw unverifiedRelease(packageName, version, "the registry gives no tarball integrity for it", "subject", act);
|
|
12967
|
+
const verdict = await verify({
|
|
12968
|
+
packageName,
|
|
12969
|
+
version,
|
|
12970
|
+
integrity
|
|
12971
|
+
});
|
|
12972
|
+
if (!verdict.ok) throw unverifiedRelease(packageName, version, PIN_WORDS[verdict.field], verdict.field, act);
|
|
12973
|
+
verified.set(packageName, verdict.provenance);
|
|
12974
|
+
}
|
|
12975
|
+
return verified;
|
|
12976
|
+
}
|
|
12977
|
+
/**
|
|
11322
12978
|
* Registering, pruning and listing every declared channel or service MCP server, from the core.
|
|
11323
12979
|
*
|
|
11324
12980
|
* `agentcomms mcp install` registers core or a declared service; `comms_server_install` registers any declared
|
|
@@ -11482,6 +13138,14 @@ function installOptions(request) {
|
|
|
11482
13138
|
};
|
|
11483
13139
|
}
|
|
11484
13140
|
/**
|
|
13141
|
+
* The install receipt a fetched runtime keeps in its folder: which release, whether its provenance was verified, and
|
|
13142
|
+
* by whom it was installed — core, which verifies, or a channel's own command, which cannot (owner's decision,
|
|
13143
|
+
* 2026-10-17). Doctor and a person can read it; nothing else depends on it.
|
|
13144
|
+
*/
|
|
13145
|
+
const INSTALL_RECEIPT = ".agentcomms-install.json";
|
|
13146
|
+
/** A sentence's first letter in capitals: core's command, said after a full stop. */
|
|
13147
|
+
const capitalised = (text) => `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
|
|
13148
|
+
/**
|
|
11485
13149
|
* Registering one declared server with one client, as a change.
|
|
11486
13150
|
*
|
|
11487
13151
|
* The effects are what a person is agreeing to, one sentence each, and everything that decides what the server may
|
|
@@ -11514,6 +13178,101 @@ function serverInstallChange(core, env, request, own, deps = {}) {
|
|
|
11514
13178
|
const launcher = request.launcher ?? "managed";
|
|
11515
13179
|
const productNow = async () => own ?? await serverProduct(request.channel, request.launcher);
|
|
11516
13180
|
let planned;
|
|
13181
|
+
const sources = () => ({
|
|
13182
|
+
registry: deps.registry ?? null,
|
|
13183
|
+
verify: deps.verifyRelease ?? null,
|
|
13184
|
+
why: deps.verifyRelease === void 0 ? "nothing here can verify provenance: run the install from core's own command or server" : "this install was handed a verifier and no registry"
|
|
13185
|
+
});
|
|
13186
|
+
/**
|
|
13187
|
+
* Core's own command for this same install, located — the one that verifies — said by `say`; `instead` where no
|
|
13188
|
+
* command can be named.
|
|
13189
|
+
*/
|
|
13190
|
+
const coreWords = () => {
|
|
13191
|
+
const words = [
|
|
13192
|
+
"mcp",
|
|
13193
|
+
"install",
|
|
13194
|
+
"--server",
|
|
13195
|
+
request.channel,
|
|
13196
|
+
"--client",
|
|
13197
|
+
request.client
|
|
13198
|
+
];
|
|
13199
|
+
if (request.name !== void 0) words.push("--name", request.name);
|
|
13200
|
+
const pin = pinOf(request);
|
|
13201
|
+
const option = pinOption(request.channel);
|
|
13202
|
+
if (pin !== void 0 && option !== void 0 && option !== "readOnly") words.push(`--${option}`, pin);
|
|
13203
|
+
if (request.readOnly) words.push("--read-only");
|
|
13204
|
+
if (request.force) words.push("--force");
|
|
13205
|
+
return words;
|
|
13206
|
+
};
|
|
13207
|
+
const inCore = (say, instead) => {
|
|
13208
|
+
try {
|
|
13209
|
+
return handoffSentence(requireHandoffs(core, deps.platform).core(coreWords()), say, { instead });
|
|
13210
|
+
} catch {
|
|
13211
|
+
return instead;
|
|
13212
|
+
}
|
|
13213
|
+
};
|
|
13214
|
+
/** Where a process that cannot verify — a channel's own, with no Sigstore — sends the person: core, which can. */
|
|
13215
|
+
const verifyInCore = () => inCore((command) => `Core verifies a release's provenance before it installs it: run ${command} in your own terminal, or call comms_server_install from a chat. Nothing was prepared to approve.`, "Core verifies a release's provenance before it installs it: run core's `mcp install --server` in your own terminal, or call comms_server_install from a chat. Nothing was prepared to approve.");
|
|
13216
|
+
/** Where the release a channel's own command installs unverified is checked: core's command, located. */
|
|
13217
|
+
const checkedInCore = () => inCore((command) => `core's ${command} checks it before it installs it`, "core's `mcp install --server` checks it before it installs it");
|
|
13218
|
+
/** The receipt a runtime this install fetched keeps beside it (`INSTALL_RECEIPT`). */
|
|
13219
|
+
const writeReceipt = (runtime, packageName, version, verified, by) => writeFileAtomic(join(runtime, INSTALL_RECEIPT), `${JSON.stringify({
|
|
13220
|
+
package: packageName,
|
|
13221
|
+
version,
|
|
13222
|
+
verified,
|
|
13223
|
+
by,
|
|
13224
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
13225
|
+
}, null, 2)}\n`);
|
|
13226
|
+
const verifiedRuntime = async (done) => {
|
|
13227
|
+
const { dataDir } = core.paths;
|
|
13228
|
+
const { packageName, version } = done.product;
|
|
13229
|
+
const runtime = managedRuntimeDir(dataDir, packageName, version);
|
|
13230
|
+
const there = await reusableRuntime(dataDir, packageName, version);
|
|
13231
|
+
if (done.verified === void 0) {
|
|
13232
|
+
if (there !== null) return void 0;
|
|
13233
|
+
if (done.unverifiedOwn) {
|
|
13234
|
+
await installManagedRuntime(context, done.product, version).catch(async (error) => {
|
|
13235
|
+
throw await remedyLocated(error, core);
|
|
13236
|
+
});
|
|
13237
|
+
await writeReceipt(runtime, packageName, version, false, `${label}’s own command`);
|
|
13238
|
+
return {
|
|
13239
|
+
package: packageName,
|
|
13240
|
+
version,
|
|
13241
|
+
verified: false,
|
|
13242
|
+
note: `${packageName}@${version} was installed without its provenance verified: this command cannot check provenance, so it installed the release it is running itself. ${capitalised(checkedInCore())}.`
|
|
13243
|
+
};
|
|
13244
|
+
}
|
|
13245
|
+
throw new CommsError("CONFIG", `this registration changed between being planned and being applied: ${runtime} was on this machine when it was planned, and is not now, so it would be fetched from npm without being verified. So nothing was installed or registered`, { hint: "Run the same install again: it is planned afresh from what is there now, verifies the release it would fetch, and asks again." });
|
|
13246
|
+
}
|
|
13247
|
+
if (there === null) await installManagedRuntime(context, done.product, version).catch(async (error) => {
|
|
13248
|
+
throw await remedyLocated(error, core);
|
|
13249
|
+
});
|
|
13250
|
+
const { sha512 } = done.verified;
|
|
13251
|
+
const problem = await runtimeIntegrityProblem(runtime, packageName, sha512, {
|
|
13252
|
+
verify: sources().verify,
|
|
13253
|
+
known: /* @__PURE__ */ new Map([[`${packageName}@${version}`, sha512]])
|
|
13254
|
+
});
|
|
13255
|
+
if (problem === null) {
|
|
13256
|
+
await writeReceipt(runtime, packageName, version, true, "core");
|
|
13257
|
+
return {
|
|
13258
|
+
package: packageName,
|
|
13259
|
+
version,
|
|
13260
|
+
verified: true
|
|
13261
|
+
};
|
|
13262
|
+
}
|
|
13263
|
+
if (there === null) await rm(runtime, {
|
|
13264
|
+
recursive: true,
|
|
13265
|
+
force: true
|
|
13266
|
+
}).catch(() => void 0);
|
|
13267
|
+
const general = "An install registers only a runtime whose packages of this suite were all built by this repository's release workflow, as their Sigstore provenance shows. If it happens again, do not install it some other way; report it as SECURITY.md says.";
|
|
13268
|
+
throw new CommsError("BAD_DATA", `nothing was registered: ${problem}${there === null ? ", so it was removed" : ""}. The launcher was not pointed at it`, {
|
|
13269
|
+
hint: there === null ? general : `To fetch a verified one, remove ${runtime} — whatever starts it stops until it is installed again — and run this install again: it fetches ${packageName}@${version} from npm and verifies it before anything is registered. ${general}`,
|
|
13270
|
+
details: {
|
|
13271
|
+
package: packageName,
|
|
13272
|
+
version
|
|
13273
|
+
}
|
|
13274
|
+
});
|
|
13275
|
+
};
|
|
11517
13276
|
return {
|
|
11518
13277
|
plan: async (config) => {
|
|
11519
13278
|
checkPin(config, request);
|
|
@@ -11523,9 +13282,21 @@ function serverInstallChange(core, env, request, own, deps = {}) {
|
|
|
11523
13282
|
throw await remedyLocated(error, core);
|
|
11524
13283
|
});
|
|
11525
13284
|
const { target, previous, effective, kept, command } = preflight;
|
|
13285
|
+
const managedApply = request.print !== true && launcher === "managed";
|
|
13286
|
+
const fetches = managedApply && await reusableRuntime(core.paths.dataDir, product.packageName, version) === null;
|
|
13287
|
+
const checksDisk = managedApply && !fetches && deps.verifyRelease !== void 0;
|
|
13288
|
+
const from = sources();
|
|
13289
|
+
const running = deps.running ?? (own === void 0 ? void 0 : {
|
|
13290
|
+
packageName: own.packageName,
|
|
13291
|
+
version: own.version
|
|
13292
|
+
});
|
|
13293
|
+
const unverifiedOwn = fetches && deps.verifyRelease === void 0 && running !== void 0 && running.packageName === product.packageName && running.version === version;
|
|
13294
|
+
const verified = fetches && !unverifiedOwn || checksDisk ? (await verifyReleaseSet(from, /* @__PURE__ */ new Map([[product.packageName, version]]), "install", from.registry === null || from.verify === null ? verifyInCore() : void 0)).get(product.packageName) : void 0;
|
|
11526
13295
|
planned = {
|
|
11527
13296
|
product,
|
|
11528
|
-
install: plannedInstall(preflight)
|
|
13297
|
+
install: plannedInstall(preflight),
|
|
13298
|
+
verified,
|
|
13299
|
+
unverifiedOwn
|
|
11529
13300
|
};
|
|
11530
13301
|
const effects = [];
|
|
11531
13302
|
if (target.writes) {
|
|
@@ -11542,7 +13313,8 @@ function serverInstallChange(core, env, request, own, deps = {}) {
|
|
|
11542
13313
|
if (launcher === "npx") effects.push(`${request.client} will fetch ${facts.npxPackage}@${version} from npm each time it starts it`);
|
|
11543
13314
|
else if (launcher === "local") effects.push(`${request.client} will start it from ${await localCliEntry(product.moduleUrl)}`);
|
|
11544
13315
|
}
|
|
11545
|
-
if (
|
|
13316
|
+
if (fetches) effects.push(`installs ${facts.packageName}@${version} from npm into ${managedRuntimeDir(core.paths.dataDir, facts.packageName, version)}`);
|
|
13317
|
+
if (request.print !== true && launcher === "managed") effects.push(launcherSwitchEffect(core.paths.dataDir, facts.packageName, label, version));
|
|
11546
13318
|
if (request.print !== true && launcher === "managed" && managedShimsSupported(context)) {
|
|
11547
13319
|
const path = managedShimPath({
|
|
11548
13320
|
env,
|
|
@@ -11551,6 +13323,8 @@ function serverInstallChange(core, env, request, own, deps = {}) {
|
|
|
11551
13323
|
}, facts.binary);
|
|
11552
13324
|
effects.push(`writes or repoints the owned CLI shim at ${path}; a foreign file there is left unchanged`);
|
|
11553
13325
|
}
|
|
13326
|
+
if (verified !== void 0 && fetches) effects.push(`${verified.package}@${verified.version} was verified before this preview: built by ${BUILT_BY}`);
|
|
13327
|
+
else if (unverifiedOwn) effects.push(`${product.packageName}@${version} will not be verified: this command cannot check provenance, so it installs the release it is running itself; core's \`${coreWords().join(" ")}\` checks it before it installs it`);
|
|
11554
13328
|
return {
|
|
11555
13329
|
before: config,
|
|
11556
13330
|
after: config,
|
|
@@ -11560,18 +13334,30 @@ function serverInstallChange(core, env, request, own, deps = {}) {
|
|
|
11560
13334
|
},
|
|
11561
13335
|
apply: async () => {
|
|
11562
13336
|
if (planned === void 0) throw new CommsError("UNEXPECTED", "the registration was applied before it was planned");
|
|
13337
|
+
const provenance = request.print !== true && launcher === "managed" ? await verifiedRuntime(planned) : void 0;
|
|
11563
13338
|
const installed = await mcpInstall(context, planned.product, installOptions(request), planned.install).catch(async (error) => {
|
|
11564
13339
|
throw await remedyLocated(error, core);
|
|
11565
13340
|
});
|
|
11566
13341
|
const result = request.print === true ? installed : await withManagedShim(context, planned.product, installed);
|
|
11567
13342
|
return {
|
|
11568
13343
|
...result,
|
|
13344
|
+
...provenance?.note === void 0 ? {} : { warnings: [...result.warnings, provenance.note] },
|
|
13345
|
+
...provenance === void 0 ? {} : { provenance },
|
|
11569
13346
|
restart: result.applied ? `Restart ${request.client} to load "${result.name}": no MCP client loads a new server into a session that is already running.` : null
|
|
11570
13347
|
};
|
|
11571
13348
|
}
|
|
11572
13349
|
};
|
|
11573
13350
|
}
|
|
11574
13351
|
/**
|
|
13352
|
+
* What a managed install or update does to the product's stable launcher, as the preview says it (design 2026-10-16,
|
|
13353
|
+
* U6): it points it at the release's runtime, and every registration of the product that starts it — in any client —
|
|
13354
|
+
* runs that release from its next start.
|
|
13355
|
+
*/
|
|
13356
|
+
function launcherSwitchEffect(dataDir, packageName, label, version) {
|
|
13357
|
+
const { launcher } = launcherPaths(dataDir, launcherName(packageName));
|
|
13358
|
+
return `points the stable launcher ${launcher} at ${managedRuntimeDir(dataDir, packageName, version)}: every ${label} registration on this machine that starts it runs ${version} from its next start`;
|
|
13359
|
+
}
|
|
13360
|
+
/**
|
|
11575
13361
|
* A managed install's CLI shim (CUE-406), once the registration's outcome is known: the command the locator finds in
|
|
11576
13362
|
* the runtime the entry starts, written where nothing is or over this installation's own unchanged shim, and never
|
|
11577
13363
|
* over anything else. A shim that cannot be written leaves the registration as it went, and says how to repair both.
|
|
@@ -11579,8 +13365,6 @@ function serverInstallChange(core, env, request, own, deps = {}) {
|
|
|
11579
13365
|
*/
|
|
11580
13366
|
async function withManagedShim(context, product, installed) {
|
|
11581
13367
|
if (installed.launcher !== "managed") return installed;
|
|
11582
|
-
const cliEntry = installed.entry.args[0];
|
|
11583
|
-
if (cliEntry === void 0) throw new CommsError("UNEXPECTED", "the managed entry has no CLI path");
|
|
11584
13368
|
const shimContext = {
|
|
11585
13369
|
env: context.env,
|
|
11586
13370
|
dataDir: context.core.paths.dataDir,
|
|
@@ -11594,6 +13378,11 @@ async function withManagedShim(context, product, installed) {
|
|
|
11594
13378
|
let shim;
|
|
11595
13379
|
let shimFailure;
|
|
11596
13380
|
try {
|
|
13381
|
+
const name = launcherName(product.packageName);
|
|
13382
|
+
const { launcher } = launcherPaths(context.core.paths.dataDir, name);
|
|
13383
|
+
const pointer = await readPointer(context.core.paths.dataDir, name);
|
|
13384
|
+
if (pointer === void 0) throw new CommsError("CONFIG", `the stable launcher ${launcher} has no pointer, so it starts nothing yet`);
|
|
13385
|
+
const cliEntry = launcherCli(pointer.current, name);
|
|
11597
13386
|
const located = locateCliCommand({
|
|
11598
13387
|
caller: {
|
|
11599
13388
|
url: pathToFileURL(cliEntry).href,
|
|
@@ -11612,13 +13401,14 @@ async function withManagedShim(context, product, installed) {
|
|
|
11612
13401
|
...context.core.paths,
|
|
11613
13402
|
downloadsDir: context.core.paths.dataDir
|
|
11614
13403
|
},
|
|
11615
|
-
platform: context.platform
|
|
13404
|
+
platform: context.platform,
|
|
13405
|
+
env: context.env
|
|
11616
13406
|
});
|
|
11617
13407
|
if (!located.ok) throw new CommsError("CONFIG", `the managed CLI command could not be located: ${located.message}`);
|
|
11618
13408
|
shim = await (context.writeShim ?? writeManagedShim)(shimContext, {
|
|
11619
13409
|
binary: product.binary,
|
|
11620
|
-
command:
|
|
11621
|
-
entry:
|
|
13410
|
+
command: [launcher],
|
|
13411
|
+
entry: launcher
|
|
11622
13412
|
});
|
|
11623
13413
|
} catch (error) {
|
|
11624
13414
|
shimFailure = error instanceof Error ? error.message : String(error);
|
|
@@ -11631,334 +13421,88 @@ async function withManagedShim(context, product, installed) {
|
|
|
11631
13421
|
warnings,
|
|
11632
13422
|
...shim === void 0 ? {} : { shim },
|
|
11633
13423
|
...shimFailure === void 0 ? {} : { shimFailure }
|
|
11634
|
-
};
|
|
11635
|
-
}
|
|
11636
|
-
/**
|
|
11637
|
-
* A refusal of `mcp install` with its remedy — another install of the same product — said with the command located:
|
|
11638
|
-
* the printing package's own, core's, or another product's found among this machine's registrations (CUE-403). The
|
|
11639
|
-
* installer says only the words; it may not import the locator.
|
|
11640
|
-
*/
|
|
11641
|
-
async function remedyLocated(error, core) {
|
|
11642
|
-
const remedy = installRemedyOf(error);
|
|
11643
|
-
if (remedy === void 0 || !(error instanceof CommsError)) return error;
|
|
11644
|
-
const server = SERVERS.find((each) => serverFacts(each).binary === remedy.binary);
|
|
11645
|
-
if (server === void 0) return error;
|
|
11646
|
-
const maker = await requireHandoffs(core).registered();
|
|
11647
|
-
const handoff = isChannel(server) ? maker.of(server, remedy.words) : maker.core([
|
|
11648
|
-
"mcp",
|
|
11649
|
-
"install",
|
|
11650
|
-
"--server",
|
|
11651
|
-
server,
|
|
11652
|
-
...remedy.words.slice(2)
|
|
11653
|
-
]);
|
|
11654
|
-
return new CommsError(error.code, error.message, {
|
|
11655
|
-
hint: handoffSentence(handoff, remedy.say),
|
|
11656
|
-
...error.details === void 0 ? {} : { details: error.details },
|
|
11657
|
-
...error.cause === void 0 ? {} : { cause: error.cause }
|
|
11658
|
-
});
|
|
11659
|
-
}
|
|
11660
|
-
/**
|
|
11661
|
-
* Removing a declared server's unused managed runtimes, as a change.
|
|
11662
|
-
*
|
|
11663
|
-
* A dry run is free: it removes nothing. Otherwise the plan is that same dry run, and each runtime it would remove is
|
|
11664
|
-
* one effect — the removal cannot be taken back — so the approval is bound to exactly that list; and the removal is
|
|
11665
|
-
* then restricted to it (`only`), so a runtime that became unused after the list was shown stays.
|
|
11666
|
-
*
|
|
11667
|
-
* `own` is the calling channel's product, from its own `mcp prune`, so the runtime kept as "this release" is the
|
|
11668
|
-
* release of the command that was run; checked to be that channel's, as for an install.
|
|
11669
|
-
*/
|
|
11670
|
-
function serverPruneChange(core, env, request, own) {
|
|
11671
|
-
if (!isServer(request.channel)) throw new CommsError("USAGE", `"${String(request.channel)}" is not a channel`, { hint: `One of: ${CHANNELS.join(", ")}.` });
|
|
11672
|
-
const context = {
|
|
11673
|
-
env,
|
|
11674
|
-
core
|
|
11675
|
-
};
|
|
11676
|
-
const { packageName, version } = ownProduct(request.channel, own) ?? {
|
|
11677
|
-
packageName: serverFacts(request.channel).packageName,
|
|
11678
|
-
version: "0.18.2"
|
|
11679
|
-
};
|
|
11680
|
-
const product = {
|
|
11681
|
-
packageName,
|
|
11682
|
-
version
|
|
11683
|
-
};
|
|
11684
|
-
const label = serverLabel(request.channel);
|
|
11685
|
-
const base = {
|
|
11686
|
-
includePrinted: request.includePrinted === true,
|
|
11687
|
-
...request.processes ? { processes: request.processes } : {}
|
|
11688
|
-
};
|
|
11689
|
-
let approved = [];
|
|
11690
|
-
return {
|
|
11691
|
-
plan: async (config) => {
|
|
11692
|
-
if (request.dryRun) return {
|
|
11693
|
-
before: config,
|
|
11694
|
-
after: config,
|
|
11695
|
-
summary: `Show which ${label} runtimes are unused`
|
|
11696
|
-
};
|
|
11697
|
-
const preview = await pruneManagedRuntimes(context, product, {
|
|
11698
|
-
...base,
|
|
11699
|
-
dryRun: true
|
|
11700
|
-
});
|
|
11701
|
-
approved = preview.removed.map((item) => item.path);
|
|
11702
|
-
return {
|
|
11703
|
-
before: config,
|
|
11704
|
-
after: config,
|
|
11705
|
-
effects: [...preview.removed.map((item) => `deletes the unused ${label} runtime ${item.version} at ${item.path}`), ...preview.shims.removed.map((path) => `deletes the owned ${label} CLI shim at ${path}`)],
|
|
11706
|
-
summary: `Remove ${approved.length} unused ${label} runtime${approved.length === 1 ? "" : "s"}`
|
|
11707
|
-
};
|
|
11708
|
-
},
|
|
11709
|
-
apply: () => pruneManagedRuntimes(context, product, {
|
|
11710
|
-
...base,
|
|
11711
|
-
dryRun: request.dryRun === true,
|
|
11712
|
-
...request.dryRun ? {} : { only: approved }
|
|
11713
|
-
})
|
|
11714
|
-
};
|
|
11715
|
-
}
|
|
11716
|
-
/**
|
|
11717
|
-
* The daily update check, as every server and command reads it (design 2026-09-28): one file per machine,
|
|
11718
|
-
* `update-check.json` in the state directory, saying when the registry was last asked, the latest release it named,
|
|
11719
|
-
* whether this machine's registrations were behind it, and any "not now" with when it runs out.
|
|
11720
|
-
*
|
|
11721
|
-
* This is the reader, and it has **no network code at all**: it reads and writes that file, and says what it means.
|
|
11722
|
-
* Asking the registry is the checker's (`update-check.ts`), which every server and command but WhatsApp's also
|
|
11723
|
-
* imports. WhatsApp's package promises it reaches no network — `packages/whatsapp/test/no-network.test.ts` holds it to
|
|
11724
|
-
* that — so it imports only this, and learns of an update once any other server or command on the machine has asked.
|
|
11725
|
-
*/
|
|
11726
|
-
/** The file, in the state directory: one per machine, shared by every server and command on it. */
|
|
11727
|
-
const UPDATE_CHECK_FILE = "update-check.json";
|
|
11728
|
-
/**
|
|
11729
|
-
* The switch that turns the whole thing off for one process — no check, no stop — whatever the setting says. Every
|
|
11730
|
-
* test harness and every script here that runs a command or a server sets it, so no test and no verify step asks the
|
|
11731
|
-
* real registry.
|
|
11732
|
-
*/
|
|
11733
|
-
const UPDATE_CHECK_ENV = "AGENT_COMMS_UPDATE_CHECK";
|
|
11734
|
-
/**
|
|
11735
|
-
* Set by what starts a server from a release it pins itself — the Claude Code plugin's launcher, the Gemini
|
|
11736
|
-
* extension's manifest — to say so: `claude-code-plugin`, `gemini-extension`. No registration here names that server,
|
|
11737
|
-
* and `comms_update` never moves it; it changes when the plugin or the extension is updated. So its stop always says
|
|
11738
|
-
* "update", whatever the channel's registrations say: restarting would start the release the plugin pins.
|
|
11739
|
-
*/
|
|
11740
|
-
const UPDATE_STARTED_BY_ENV = "AGENT_COMMS_STARTED_BY";
|
|
11741
|
-
/** What the stop says first, word for word: the owner's words. */
|
|
11742
|
-
const UPDATE_FIRST = "Hang on a minute, there's an update. Let's update first.";
|
|
11743
|
-
const EMPTY_UPDATE_CHECK = Object.freeze({
|
|
11744
|
-
lastChecked: null,
|
|
11745
|
-
latest: null,
|
|
11746
|
-
behind: null,
|
|
11747
|
-
current: null,
|
|
11748
|
-
lastError: null,
|
|
11749
|
-
snoozedUntil: null,
|
|
11750
|
-
checking: null
|
|
11751
|
-
});
|
|
11752
|
-
function updateCheckPath(stateDir) {
|
|
11753
|
-
return join(stateDir, UPDATE_CHECK_FILE);
|
|
11754
|
-
}
|
|
11755
|
-
const isTime = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
11756
|
-
const isWords = (value) => Array.isArray(value) && value.every((word) => typeof word === "string");
|
|
11757
|
-
function readCurrent(value) {
|
|
11758
|
-
if (typeof value !== "object" || value === null) return null;
|
|
11759
|
-
const { registered, global } = value;
|
|
11760
|
-
return isWords(registered) && isWords(global) ? {
|
|
11761
|
-
registered: [...registered],
|
|
11762
|
-
global: [...global]
|
|
11763
|
-
} : null;
|
|
11764
|
-
}
|
|
11765
|
-
/**
|
|
11766
|
-
* The record as the file holds it, each field checked on its own: one that is missing or not what it should be reads
|
|
11767
|
-
* as not known. A file that is missing, unreadable or not JSON is an empty record — never a reason to stop anything.
|
|
11768
|
-
*/
|
|
11769
|
-
async function readUpdateCheck(stateDir) {
|
|
11770
|
-
let parsed;
|
|
11771
|
-
try {
|
|
11772
|
-
parsed = JSON.parse(await readFile(updateCheckPath(stateDir), "utf8"));
|
|
11773
|
-
} catch {
|
|
11774
|
-
return { ...EMPTY_UPDATE_CHECK };
|
|
11775
|
-
}
|
|
11776
|
-
const held = typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
11777
|
-
return {
|
|
11778
|
-
lastChecked: isTime(held.lastChecked) ? held.lastChecked : null,
|
|
11779
|
-
latest: isVersion(held.latest) ? held.latest : null,
|
|
11780
|
-
behind: typeof held.behind === "boolean" ? held.behind : null,
|
|
11781
|
-
current: readCurrent(held.current),
|
|
11782
|
-
lastError: typeof held.lastError === "string" ? held.lastError.slice(0, 300) : null,
|
|
11783
|
-
snoozedUntil: isTime(held.snoozedUntil) ? held.snoozedUntil : null,
|
|
11784
|
-
checking: isTime(held.checking) ? held.checking : null
|
|
11785
|
-
};
|
|
11786
|
-
}
|
|
11787
|
-
/**
|
|
11788
|
-
* Reads the record, changes it and writes it back, under a lock beside it, atomically — so a server recording a check
|
|
11789
|
-
* and a person's "not now" arriving at the same moment each keep what the other wrote. Returns what `change` returned
|
|
11790
|
-
* alongside the record written; `change` returning `null` writes nothing.
|
|
11791
|
-
*/
|
|
11792
|
-
async function changeUpdateCheck(stateDir, change) {
|
|
11793
|
-
return withFileLock(join(stateDir, ".update-check.lock"), async () => {
|
|
11794
|
-
const current = await readUpdateCheck(stateDir);
|
|
11795
|
-
const next = change(current);
|
|
11796
|
-
if (next === null) return {
|
|
11797
|
-
record: current,
|
|
11798
|
-
result: void 0,
|
|
11799
|
-
written: false
|
|
11800
|
-
};
|
|
11801
|
-
await writeFileAtomic(updateCheckPath(stateDir), `${JSON.stringify(next.record, null, 2)}\n`);
|
|
11802
|
-
return {
|
|
11803
|
-
record: next.record,
|
|
11804
|
-
result: next.result,
|
|
11805
|
-
written: true
|
|
11806
|
-
};
|
|
11807
|
-
}, { timeoutMs: 2e3 });
|
|
11808
|
-
}
|
|
11809
|
-
/**
|
|
11810
|
-
* Whether the check is due: never asked, asked a day or more ago, or — a clock that went back — asked more than a few
|
|
11811
|
-
* minutes in the future. A few minutes are allowed for, because two processes' clocks are read at slightly different
|
|
11812
|
-
* moments, and a check just claimed by one must not look due to the other.
|
|
11813
|
-
*/
|
|
11814
|
-
function updateCheckDue(record, now) {
|
|
11815
|
-
if (record.lastChecked === null) return true;
|
|
11816
|
-
const since = now.getTime() - Date.parse(record.lastChecked);
|
|
11817
|
-
return !(since >= -CLOCK_SKEW_MS && since < 864e5);
|
|
11818
|
-
}
|
|
11819
|
-
const CLOCK_SKEW_MS = 3e5;
|
|
11820
|
-
/** What turned the check off for this process, from its environment: `CI`, or the switch. Null when neither did. */
|
|
11821
|
-
function updateCheckSwitchedOff(env) {
|
|
11822
|
-
if (env.CI && env.CI !== "0" && env.CI !== "false") return "CI";
|
|
11823
|
-
const value = env[UPDATE_CHECK_ENV]?.trim().toLowerCase();
|
|
11824
|
-
if (value !== void 0 && [
|
|
11825
|
-
"off",
|
|
11826
|
-
"0",
|
|
11827
|
-
"false",
|
|
11828
|
-
"no"
|
|
11829
|
-
].includes(value)) return UPDATE_CHECK_ENV;
|
|
11830
|
-
return null;
|
|
11831
|
-
}
|
|
11832
|
-
/** Whether a check may run at all, and if not, what turned it off: the environment first, then the machine's setting. */
|
|
11833
|
-
async function updateCheckEnabled(core, env) {
|
|
11834
|
-
const switched = updateCheckSwitchedOff(env);
|
|
11835
|
-
if (switched !== null) return {
|
|
11836
|
-
on: false,
|
|
11837
|
-
by: switched
|
|
11838
|
-
};
|
|
11839
|
-
return await machineSetting(core) === "off" ? {
|
|
11840
|
-
on: false,
|
|
11841
|
-
by: "setting"
|
|
11842
|
-
} : { on: true };
|
|
11843
|
-
}
|
|
11844
|
-
/**
|
|
11845
|
-
* The machine's setting, from `config.json`. A configuration that cannot be read counts as `on`, the default: what is
|
|
11846
|
-
* wrong with it is for the doctor and the tools to say, and it must not quietly turn this off.
|
|
11847
|
-
*/
|
|
11848
|
-
async function machineSetting(core) {
|
|
11849
|
-
try {
|
|
11850
|
-
return updateCheckSetting(await core.config.load());
|
|
11851
|
-
} catch {
|
|
11852
|
-
return "on";
|
|
11853
|
-
}
|
|
11854
|
-
}
|
|
11855
|
-
/** The start of the next local day: when a "not now" runs out. */
|
|
11856
|
-
function nextLocalMidnight(now) {
|
|
11857
|
-
return new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0, 0);
|
|
11858
|
-
}
|
|
11859
|
-
/** A local time as a person reads it in a preview: `2026-09-29 00:00`. */
|
|
11860
|
-
function localStamp(at) {
|
|
11861
|
-
const two = (value) => String(value).padStart(2, "0");
|
|
11862
|
-
return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
|
|
11863
|
-
}
|
|
11864
|
-
/** Whether a person's "not now" still holds. */
|
|
11865
|
-
function updateSnoozed(record, now) {
|
|
11866
|
-
return record.snoozedUntil !== null && now.getTime() < Date.parse(record.snoozedUntil);
|
|
11867
|
-
}
|
|
11868
|
-
/**
|
|
11869
|
-
* The latest release as the check counts it: a version, and not a prerelease — 0.8.0-rc.1 is never an update anybody
|
|
11870
|
-
* is stopped for. Null when the file names none.
|
|
11871
|
-
*/
|
|
11872
|
-
function countedLatest(record) {
|
|
11873
|
-
return record.latest !== null && isVersion(record.latest) && !isPrerelease(record.latest) ? record.latest : null;
|
|
13424
|
+
};
|
|
11874
13425
|
}
|
|
11875
13426
|
/**
|
|
11876
|
-
*
|
|
11877
|
-
*
|
|
11878
|
-
*
|
|
11879
|
-
* registration names `latest`, or a command whose package is installed globally at `latest`. Anything else — no
|
|
11880
|
-
* registration the scan could see (a plugin's, an extension's, one written by hand), one that pins no release, a
|
|
11881
|
-
* configuration it could not read, a check that found out nothing — is `update`: restarting would start the same old
|
|
11882
|
-
* code, and a reply that said to restart would stop every call with advice that cannot work.
|
|
13427
|
+
* A refusal of `mcp install` with its remedy — another install of the same product — said with the command located:
|
|
13428
|
+
* the printing package's own, core's, or another product's found among this machine's registrations (CUE-403). The
|
|
13429
|
+
* installer says only the words; it may not import the locator.
|
|
11883
13430
|
*/
|
|
11884
|
-
function
|
|
11885
|
-
const
|
|
11886
|
-
if (
|
|
11887
|
-
|
|
11888
|
-
|
|
11889
|
-
|
|
11890
|
-
|
|
11891
|
-
|
|
13431
|
+
async function remedyLocated(error, core) {
|
|
13432
|
+
const remedy = installRemedyOf(error);
|
|
13433
|
+
if (remedy === void 0 || !(error instanceof CommsError)) return error;
|
|
13434
|
+
const server = SERVERS.find((each) => serverFacts(each).binary === remedy.binary);
|
|
13435
|
+
if (server === void 0) return error;
|
|
13436
|
+
const maker = await requireHandoffs(core).registered();
|
|
13437
|
+
const handoff = isChannel(server) ? maker.of(server, remedy.words) : maker.core([
|
|
13438
|
+
"mcp",
|
|
13439
|
+
"install",
|
|
13440
|
+
"--server",
|
|
13441
|
+
server,
|
|
13442
|
+
...remedy.words.slice(2)
|
|
13443
|
+
]);
|
|
13444
|
+
return new CommsError(error.code, error.message, {
|
|
13445
|
+
hint: handoffSentence(handoff, remedy.say),
|
|
13446
|
+
...error.details === void 0 ? {} : { details: error.details },
|
|
13447
|
+
...error.cause === void 0 ? {} : { cause: error.cause }
|
|
13448
|
+
});
|
|
11892
13449
|
}
|
|
11893
13450
|
/**
|
|
11894
|
-
*
|
|
11895
|
-
*
|
|
13451
|
+
* Removing a declared server's unused managed runtimes, as a change.
|
|
13452
|
+
*
|
|
13453
|
+
* A dry run is free: it removes nothing. Otherwise the plan is that same dry run, and each runtime it would remove is
|
|
13454
|
+
* one effect — the removal cannot be taken back — so the approval is bound to exactly that list; and the removal is
|
|
13455
|
+
* then restricted to it (`only`), so a runtime that became unused after the list was shown stays.
|
|
11896
13456
|
*
|
|
11897
|
-
*
|
|
11898
|
-
* of
|
|
11899
|
-
* restarting started the release the plugin pins. It is told to update.
|
|
13457
|
+
* `own` is the calling channel's product, from its own `mcp prune`, so the runtime kept as "this release" is the
|
|
13458
|
+
* release of the command that was run; checked to be that channel's, as for an install.
|
|
11900
13459
|
*/
|
|
11901
|
-
|
|
11902
|
-
if (!(
|
|
11903
|
-
const
|
|
11904
|
-
|
|
11905
|
-
|
|
11906
|
-
const startedBy = options.env[UPDATE_STARTED_BY_ENV]?.trim();
|
|
11907
|
-
if (verdict?.kind === "restart" && options.surface === "server" && startedBy) return {
|
|
11908
|
-
...verdict,
|
|
11909
|
-
kind: "update"
|
|
13460
|
+
function serverPruneChange(core, env, request, own) {
|
|
13461
|
+
if (!isServer(request.channel)) throw new CommsError("USAGE", `"${String(request.channel)}" is not a channel`, { hint: `One of: ${CHANNELS.join(", ")}.` });
|
|
13462
|
+
const context = {
|
|
13463
|
+
env,
|
|
13464
|
+
core
|
|
11910
13465
|
};
|
|
11911
|
-
|
|
11912
|
-
|
|
11913
|
-
|
|
11914
|
-
const handoffs = requireHandoffs(core, platform);
|
|
11915
|
-
return {
|
|
11916
|
-
update: handoffs.core(["update"]),
|
|
11917
|
-
later: handoffs.core(["update", "--later"])
|
|
13466
|
+
const { packageName, version } = ownProduct(request.channel, own) ?? {
|
|
13467
|
+
packageName: serverFacts(request.channel).packageName,
|
|
13468
|
+
version: "0.20.0"
|
|
11918
13469
|
};
|
|
11919
|
-
|
|
11920
|
-
|
|
11921
|
-
|
|
11922
|
-
|
|
11923
|
-
|
|
11924
|
-
|
|
13470
|
+
const product = {
|
|
13471
|
+
packageName,
|
|
13472
|
+
version
|
|
13473
|
+
};
|
|
13474
|
+
const label = serverLabel(request.channel);
|
|
13475
|
+
const base = {
|
|
13476
|
+
includePrinted: request.includePrinted === true,
|
|
13477
|
+
...request.processes ? { processes: request.processes } : {}
|
|
13478
|
+
};
|
|
13479
|
+
let approved = [];
|
|
11925
13480
|
return {
|
|
11926
|
-
|
|
11927
|
-
|
|
11928
|
-
|
|
13481
|
+
plan: async (config) => {
|
|
13482
|
+
if (request.dryRun) return {
|
|
13483
|
+
before: config,
|
|
13484
|
+
after: config,
|
|
13485
|
+
summary: `Show which ${label} runtimes are unused`
|
|
13486
|
+
};
|
|
13487
|
+
const preview = await pruneManagedRuntimes(context, product, {
|
|
13488
|
+
...base,
|
|
13489
|
+
dryRun: true
|
|
13490
|
+
});
|
|
13491
|
+
approved = preview.removed.map((item) => item.path);
|
|
13492
|
+
return {
|
|
13493
|
+
before: config,
|
|
13494
|
+
after: config,
|
|
13495
|
+
effects: [...preview.removed.map((item) => `deletes the unused ${label} runtime ${item.version} at ${item.path}`), ...preview.shims.removed.map((path) => `deletes the owned ${label} CLI shim at ${path}`)],
|
|
13496
|
+
summary: `Remove ${approved.length} unused ${label} runtime${approved.length === 1 ? "" : "s"}`
|
|
13497
|
+
};
|
|
11929
13498
|
},
|
|
11930
|
-
|
|
11931
|
-
|
|
11932
|
-
|
|
11933
|
-
|
|
11934
|
-
}
|
|
13499
|
+
apply: () => pruneManagedRuntimes(context, product, {
|
|
13500
|
+
...base,
|
|
13501
|
+
dryRun: request.dryRun === true,
|
|
13502
|
+
...request.dryRun ? {} : { only: approved }
|
|
13503
|
+
})
|
|
11935
13504
|
};
|
|
11936
13505
|
}
|
|
11937
|
-
/** A channel's product name, from core's snapshot of the manifests: `Gmail`. */
|
|
11938
|
-
function labelOf(channel) {
|
|
11939
|
-
return CHANNEL_SNAPSHOT.find((entry) => entry.manifest.channel === channel)?.manifest.label ?? channel;
|
|
11940
|
-
}
|
|
11941
|
-
/**
|
|
11942
|
-
* What a stopped tool call says, in words an agent passes on: the owner's sentence first, then the versions, then the
|
|
11943
|
-
* two ways on — the update, from chat or a terminal, or "not now", which the person approves like any other change.
|
|
11944
|
-
*/
|
|
11945
|
-
function updateStopMessage(pending, where, commands) {
|
|
11946
|
-
const server = where.channel === "core" ? "the core server" : `the ${labelOf(where.channel)} server`;
|
|
11947
|
-
const didNotRun = `Nothing was done: ${where.tool} did not run.`;
|
|
11948
|
-
const later = `Not now: call comms_update with \`later: true\` — a change the person approves — and nothing stops again until midnight; the next request after it asks again. ${updateCommandSaid(commands, "later", (command) => `At a terminal: ${command}.`)}`;
|
|
11949
|
-
if (pending.kind === "restart") return [
|
|
11950
|
-
"Hang on a minute, the update is installed, but this server isn't running it yet. Restart the client first.",
|
|
11951
|
-
`This is ${server}, ${pending.running}; ${pending.latest} is installed on this machine — every registration of this server names it — and a client starts it only once it is restarted. ${didNotRun}`,
|
|
11952
|
-
`Ask the person to restart the MCP client — quit it and open it again — and carry on after. ${later}`
|
|
11953
|
-
].join("\n");
|
|
11954
|
-
return [
|
|
11955
|
-
UPDATE_FIRST,
|
|
11956
|
-
`This is ${server}, ${pending.running}; the latest release is ${pending.latest}. ${didNotRun}`,
|
|
11957
|
-
"Ask the person which they want:",
|
|
11958
|
-
`- Update now: call comms_update on the core server. It shows every step and asks before it changes anything; restart the client after. ${updateCommandSaid(commands, "update", (command) => `At a terminal: ${command}.`)} A server comms_update does not find registered here — a plugin's, an extension's — is updated where it was installed.`,
|
|
11959
|
-
`- ${later}`
|
|
11960
|
-
].join("\n");
|
|
11961
|
-
}
|
|
11962
13506
|
/**
|
|
11963
13507
|
* Bringing a machine to the latest release, from a terminal (`agentcomms update`) or a chat (`comms_update`).
|
|
11964
13508
|
*
|
|
@@ -11973,6 +13517,11 @@ function updateStopMessage(pending, where, commands) {
|
|
|
11973
13517
|
* The check reads and asks nobody. The update is a change whose effects are every step, one sentence each, so the
|
|
11974
13518
|
* approval is bound to exactly that list — the versions in it included: a release published between the question and
|
|
11975
13519
|
* the yes is a different change, and the claim is refused. Nothing behind, nothing is prepared.
|
|
13520
|
+
*
|
|
13521
|
+
* A registration that starts the stable launcher (design 2026-10-16, U6) is not registered again: the update installs
|
|
13522
|
+
* the latest runtime and switches the launcher's pointer, and no client's configuration is touched. A managed
|
|
13523
|
+
* registration from before the launcher names a versioned runtime; the update registers it again on the launcher —
|
|
13524
|
+
* the one update that edits it — even when it is already at the latest release.
|
|
11976
13525
|
*/
|
|
11977
13526
|
/**
|
|
11978
13527
|
* The packages this suite publishes, whose latest releases an update reads: every declared server owner's, from the
|
|
@@ -11984,149 +13533,253 @@ const SERVER_PACKAGES = Object.freeze(SERVERS.map((server) => serverFacts(server
|
|
|
11984
13533
|
Object.freeze([.../* @__PURE__ */ new Set([...OWNER_PACKAGES, ...SERVER_PACKAGES])].sort());
|
|
11985
13534
|
serverFacts("core").packageName;
|
|
11986
13535
|
/**
|
|
11987
|
-
*
|
|
11988
|
-
*
|
|
11989
|
-
*
|
|
11990
|
-
*
|
|
11991
|
-
*/
|
|
11992
|
-
function updateLaterChange(core, options = {}) {
|
|
11993
|
-
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
11994
|
-
let planned = null;
|
|
11995
|
-
return {
|
|
11996
|
-
plan: async (config) => {
|
|
11997
|
-
const record = await readUpdateCheck(core.paths.stateDir);
|
|
11998
|
-
const until = nextLocalMidnight(now());
|
|
11999
|
-
const latest = countedLatest(record);
|
|
12000
|
-
const changed = record.snoozedUntil === null || Date.parse(record.snoozedUntil) < until.getTime();
|
|
12001
|
-
planned = {
|
|
12002
|
-
until,
|
|
12003
|
-
latest,
|
|
12004
|
-
changed
|
|
12005
|
-
};
|
|
12006
|
-
const what = latest === null ? "the daily update check" : `the update to ${latest}`;
|
|
12007
|
-
return {
|
|
12008
|
-
before: config,
|
|
12009
|
-
after: config,
|
|
12010
|
-
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`] : [],
|
|
12011
|
-
summary: latest === null ? "Skip the daily update check until tomorrow" : `Skip the update to ${latest} until tomorrow`
|
|
12012
|
-
};
|
|
12013
|
-
},
|
|
12014
|
-
apply: async () => {
|
|
12015
|
-
if (planned === null) throw new CommsError("UNEXPECTED", "not now was applied before it was planned");
|
|
12016
|
-
const { until, latest, changed } = planned;
|
|
12017
|
-
if (changed) await changeUpdateCheck(core.paths.stateDir, (record) => ({ record: {
|
|
12018
|
-
...record,
|
|
12019
|
-
snoozedUntil: until.toISOString()
|
|
12020
|
-
} }));
|
|
12021
|
-
return {
|
|
12022
|
-
snoozedUntil: (await readUpdateCheck(core.paths.stateDir)).snoozedUntil,
|
|
12023
|
-
latest,
|
|
12024
|
-
changed
|
|
12025
|
-
};
|
|
12026
|
-
}
|
|
12027
|
-
};
|
|
12028
|
-
}
|
|
12029
|
-
Object.freeze({
|
|
12030
|
-
kind: "send",
|
|
12031
|
-
lookup: true
|
|
12032
|
-
});
|
|
12033
|
-
Object.freeze({ kind: "change" });
|
|
12034
|
-
Object.freeze({
|
|
12035
|
-
kind: "download",
|
|
12036
|
-
argument: "choiceId"
|
|
12037
|
-
});
|
|
12038
|
-
/**
|
|
12039
|
-
* Whether a call claims an approval the person already gave (§2): an approval this machine's approval store holds —
|
|
12040
|
-
* every channel keeps its approvals there — that is still waiting to be used, pending or approved, and of the kind the
|
|
12041
|
-
* call takes where the surface says which. Only then does the call go past the stop.
|
|
12042
|
-
*
|
|
12043
|
-
* The key alone is not a claim. An empty `approvalId`, or an id nobody prepared, claims nothing, and let past it
|
|
12044
|
-
* would run the tool's first-call path with an update out — a preview, or a tightening applied at once. Each tool
|
|
12045
|
-
* refuses an approval it does not know anyway; the stop must not be what an unknown one walks around. The store
|
|
12046
|
-
* refuses anything that is not an approval id before it looks, so that is not checked twice.
|
|
13536
|
+
* The update notice, where every server and every command meets it (design 2026-10-16, U1). It replaces the stop of
|
|
13537
|
+
* the daily update check (design 2026-09-28): nothing is refused for being behind any more. A server's first tool
|
|
13538
|
+
* result of its process carries one line beside the result; a command prints the same line once on stderr, at most
|
|
13539
|
+
* once a day for the machine, and runs.
|
|
12047
13540
|
*
|
|
12048
|
-
*
|
|
12049
|
-
*
|
|
12050
|
-
* channels` straight through. Such a record claims nothing any more — there is nothing left of it for the person to
|
|
12051
|
-
* lose by waiting — except to a look-up (`lookup`), which only reads it.
|
|
12052
|
-
*
|
|
12053
|
-
* What is not checked here is that the approval was prepared for this very call. The store binds a change to its
|
|
12054
|
-
* digest, and a send to its draft, which only the tool can compute; it does, when it claims the approval, and refuses
|
|
12055
|
-
* any other. A change that needs no approval claims none, and refuses one it is handed (`gatedChange`), so an approval
|
|
12056
|
-
* prepared for another change gets a call past the stop only to that refusal.
|
|
13541
|
+
* Reader only, like `update-state.ts`: what asks the registry is handed in — `refresh` to a server's notice, `check` to
|
|
13542
|
+
* a command's — by every package but WhatsApp, which has no network code and hands in nothing.
|
|
12057
13543
|
*/
|
|
12058
|
-
|
|
12059
|
-
|
|
12060
|
-
const seen = await core.approvals.inspect(id, claim.kind === void 0 ? {} : { kind: claim.kind }).catch(() => null);
|
|
12061
|
-
if (seen === null) return false;
|
|
12062
|
-
const { stored, outcome } = seen;
|
|
12063
|
-
if (claim.lookup === true) return stored.form === "v2" || stored.form === "legacy";
|
|
12064
|
-
return stored.form === "v2" && (outcome.state === "pending" || outcome.claimable);
|
|
12065
|
-
}
|
|
13544
|
+
/** The product, as a notice names it: never a command's name, which a sentence would read as one to run (CUE-403). */
|
|
13545
|
+
const PRODUCT = "agent-communications";
|
|
12066
13546
|
/**
|
|
12067
|
-
* A server's
|
|
12068
|
-
*
|
|
12069
|
-
*
|
|
13547
|
+
* A server's notice. After a tool answers: if a newer release is out, the machine's level says so (`decideNotice`),
|
|
13548
|
+
* and the tool is not exempt, the result carries one line, and no later result of this process carries it again.
|
|
13549
|
+
* Nothing is ever refused, and nothing waits for the check: the file is read as it is.
|
|
12070
13550
|
*
|
|
12071
|
-
*
|
|
12072
|
-
*
|
|
12073
|
-
* from the next new request (§2, as agreed).
|
|
13551
|
+
* At most once per process, on the first result there is something to say on: a server started on a day the check is
|
|
13552
|
+
* due learns of the release a moment after its first call, and says so on the next.
|
|
12074
13553
|
*/
|
|
12075
|
-
function
|
|
13554
|
+
function updateToolNotice(options) {
|
|
12076
13555
|
const exempt = new Set(options.exempt);
|
|
12077
13556
|
let running = null;
|
|
12078
|
-
|
|
12079
|
-
|
|
13557
|
+
let given = false;
|
|
13558
|
+
let turn = Promise.resolve();
|
|
13559
|
+
return (tool) => {
|
|
13560
|
+
if (updateCheckSwitchedOff(options.env) !== null) return Promise.resolve(null);
|
|
12080
13561
|
if (options.refresh && running === null) running = options.refresh().catch(() => void 0).finally(() => {
|
|
12081
13562
|
running = null;
|
|
12082
13563
|
});
|
|
12083
|
-
if (exempt.has(tool)) return null;
|
|
12084
|
-
const
|
|
12085
|
-
|
|
12086
|
-
|
|
13564
|
+
if (given || exempt.has(tool)) return Promise.resolve(null);
|
|
13565
|
+
const decided = turn.then(async () => {
|
|
13566
|
+
if (given) return null;
|
|
13567
|
+
const decision = await decideNotice(options, {
|
|
13568
|
+
channel: options.channel,
|
|
13569
|
+
surface: "server"
|
|
13570
|
+
});
|
|
13571
|
+
if (decision === null) return null;
|
|
13572
|
+
given = true;
|
|
13573
|
+
return serverDecisionLine(decision, options.env);
|
|
12087
13574
|
});
|
|
12088
|
-
|
|
12089
|
-
|
|
12090
|
-
if (await claimsApproval(options.core, args[claim?.argument ?? "approvalId"], claim)) return null;
|
|
12091
|
-
return stoppedCall(pending, {
|
|
12092
|
-
server: options.server,
|
|
12093
|
-
channel: options.channel,
|
|
12094
|
-
tool
|
|
12095
|
-
}, updateCommands(options.core));
|
|
13575
|
+
turn = decided.catch(() => null);
|
|
13576
|
+
return decided.catch(() => null);
|
|
12096
13577
|
};
|
|
12097
13578
|
}
|
|
13579
|
+
/** How long a command's notice is good for, by level: a day, or 14 days on `every-14-days`. */
|
|
13580
|
+
function spacingOf(updates, surface) {
|
|
13581
|
+
if (updates.level === "every-14-days") return SPACED_INTERVAL_MS;
|
|
13582
|
+
return surface === "command" ? COMMAND_NOTICE_INTERVAL_MS : null;
|
|
13583
|
+
}
|
|
13584
|
+
const majorOf = (version) => Number(version.split(".")[0]);
|
|
12098
13585
|
/**
|
|
12099
|
-
*
|
|
12100
|
-
*
|
|
12101
|
-
*
|
|
13586
|
+
* What the record means for this process's notice now — or null, with nothing written — decided and recorded under the
|
|
13587
|
+
* file's lock, so two processes deciding together say it once where the level says once (design 2026-10-16, U2, U3,
|
|
13588
|
+
* U7):
|
|
13589
|
+
*
|
|
13590
|
+
* - switched off for the process — `CI`, `AGENT_COMMS_UPDATE_CHECK=off`, `AGENT_COMMS_UPDATES=never` — nothing;
|
|
13591
|
+
* - a security fix (the latest release's manifest says so) is said on `every-14-days`, on `never`, and on `auto` while
|
|
13592
|
+
* its background job is not set up, whatever the pause or the 14-day spacing: the job installs one within a day;
|
|
13593
|
+
* - otherwise nothing on `never`, nothing during a pause, and on `auto` with its job set up only what needs the person:
|
|
13594
|
+
* a restart, a major release, a release needing a decision;
|
|
13595
|
+
* - `every-14-days` says it at most once in 14 days for the machine, and a command at most once a day.
|
|
13596
|
+
*
|
|
13597
|
+
* Beside it, by the same rules of level and pause: what the last background run left (U5 step 7), said by the first
|
|
13598
|
+
* process it applies to and then cleared from the file — "installed" applies only to a process behind what was
|
|
13599
|
+
* installed — and the registrations still naming a versioned runtime (U6), spaced as the notice is.
|
|
12102
13600
|
*/
|
|
12103
|
-
function
|
|
12104
|
-
|
|
12105
|
-
const
|
|
12106
|
-
const
|
|
12107
|
-
|
|
12108
|
-
|
|
12109
|
-
|
|
12110
|
-
|
|
12111
|
-
|
|
12112
|
-
|
|
12113
|
-
|
|
12114
|
-
|
|
12115
|
-
|
|
12116
|
-
|
|
12117
|
-
|
|
12118
|
-
|
|
12119
|
-
|
|
12120
|
-
|
|
12121
|
-
|
|
12122
|
-
|
|
12123
|
-
|
|
12124
|
-
|
|
13601
|
+
async function decideNotice(options, where) {
|
|
13602
|
+
if (updateCheckSwitchedOff(options.env) !== null) return null;
|
|
13603
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
13604
|
+
const updates = await effectiveUpdates(options.core, options.env, now());
|
|
13605
|
+
const startedBy = options.env[UPDATE_STARTED_BY_ENV]?.trim();
|
|
13606
|
+
const offerJob = updates.level === "auto" && !updates.background && await jobCanBeSetUp(options.core, updates);
|
|
13607
|
+
const spacing = spacingOf(updates, where.surface);
|
|
13608
|
+
const reach = updates.background ? await jobReach(options.core, options.env, where.channel) : null;
|
|
13609
|
+
/** What the level and the spacing let this process say now, or null: decided once as read, then under the lock. */
|
|
13610
|
+
const due = (record, at) => {
|
|
13611
|
+
let notice = noticeOf(record, options.running, where, updates, at, reach);
|
|
13612
|
+
const last = where.surface === "command" && updates.level !== "every-14-days" ? record.commandNoticeAt : record.noticeAt;
|
|
13613
|
+
const spaced = spacing !== null && within(last, at, spacing);
|
|
13614
|
+
if (notice !== null) {
|
|
13615
|
+
if (!notice.security) {
|
|
13616
|
+
if (spaced) notice = null;
|
|
13617
|
+
} else if (where.surface === "command" && within(record.commandNoticeAt, at, 864e5)) notice = null;
|
|
13618
|
+
}
|
|
13619
|
+
const quiet = updates.level === "never" || pauseEnd(record, at) !== null;
|
|
13620
|
+
const background = quiet ? null : backgroundFor(record.backgroundNotice, options.running);
|
|
13621
|
+
const moves = quiet || spaced ? 0 : record.moves ?? 0;
|
|
13622
|
+
const jobSilent = quiet || spaced ? null : updates.jobSilent?.since ?? null;
|
|
13623
|
+
if (notice === null && background === null && moves === 0 && jobSilent === null) return null;
|
|
13624
|
+
return {
|
|
13625
|
+
notice,
|
|
13626
|
+
background,
|
|
13627
|
+
moves,
|
|
13628
|
+
jobSilent,
|
|
13629
|
+
offerJob: offerJob && (notice !== null || jobSilent !== null)
|
|
13630
|
+
};
|
|
13631
|
+
};
|
|
13632
|
+
if (due(await readUpdateCheck(options.core.paths.stateDir), now()) === null) return null;
|
|
13633
|
+
return (await changeUpdateCheck(options.core.paths.stateDir, (record) => {
|
|
13634
|
+
const at = now();
|
|
13635
|
+
const decision = due(record, at);
|
|
13636
|
+
if (decision === null) return null;
|
|
13637
|
+
const stamp = at.toISOString();
|
|
13638
|
+
const spaces = decision.notice !== null || decision.moves > 0 || (decision.jobSilent ?? null) !== null;
|
|
13639
|
+
const written = {
|
|
13640
|
+
...record,
|
|
13641
|
+
...spaces ? { noticeAt: stamp } : {},
|
|
13642
|
+
...spaces && where.surface === "command" ? { commandNoticeAt: stamp } : {},
|
|
13643
|
+
...decision.background !== null ? { backgroundNotice: null } : {}
|
|
13644
|
+
};
|
|
13645
|
+
const { notice } = decision;
|
|
13646
|
+
const kind = notice?.kind === "restart" && where.surface === "server" && startedBy ? "update" : notice?.kind;
|
|
13647
|
+
return {
|
|
13648
|
+
record: written,
|
|
13649
|
+
result: {
|
|
13650
|
+
...decision,
|
|
13651
|
+
notice: notice === null || kind === void 0 ? null : {
|
|
13652
|
+
...notice,
|
|
13653
|
+
kind
|
|
13654
|
+
}
|
|
12125
13655
|
}
|
|
12126
|
-
}
|
|
13656
|
+
};
|
|
13657
|
+
})).result ?? null;
|
|
13658
|
+
}
|
|
13659
|
+
/**
|
|
13660
|
+
* What a background run left, when it is this process's to say: an install only to a process running a release before
|
|
13661
|
+
* it — one already running it has nothing to restart into — and anything else, a refusal, a skip, a removed job, to
|
|
13662
|
+
* any process.
|
|
13663
|
+
*/
|
|
13664
|
+
function backgroundFor(left, running) {
|
|
13665
|
+
if (left === null || left === void 0) return null;
|
|
13666
|
+
if (left.outcome !== "installed") return left;
|
|
13667
|
+
return left.version !== null && isVersion(running) && isBehind(running, left.version) ? left : null;
|
|
13668
|
+
}
|
|
13669
|
+
/**
|
|
13670
|
+
* Whether the background job could be set up here, so offering it is not offering a refusal (design 2026-10-16 U4):
|
|
13671
|
+
* a platform it is written for, no organisation turning background updates off, and the stable core launcher it runs.
|
|
13672
|
+
*/
|
|
13673
|
+
async function jobCanBeSetUp(core, updates) {
|
|
13674
|
+
if (updates.backgroundOff !== void 0 || !updateJobSupported(process.platform)) return false;
|
|
13675
|
+
try {
|
|
13676
|
+
return (await stat(launcherPaths(core.paths.dataDir, "core").launcher)).isFile();
|
|
13677
|
+
} catch {
|
|
13678
|
+
return false;
|
|
13679
|
+
}
|
|
13680
|
+
}
|
|
13681
|
+
/** Whether the job reaches a process of `name`'s launcher (`JobReach`); null where it does not, or it cannot be read. */
|
|
13682
|
+
async function jobReach(core, env, name) {
|
|
13683
|
+
const launched = env[LAUNCHER_ENV]?.trim();
|
|
13684
|
+
if (name === void 0 || launched !== name) return null;
|
|
13685
|
+
try {
|
|
13686
|
+
const pointer = await readPointer(core.paths.dataDir, name);
|
|
13687
|
+
if (pointer === void 0) return null;
|
|
13688
|
+
const folder = basename(pointer.current);
|
|
13689
|
+
const version = folder.endsWith(`-${name}`) ? folder.slice(0, -(name.length + 1)) : "";
|
|
13690
|
+
return { pointed: isPlainVersion(version) ? version : null };
|
|
13691
|
+
} catch {
|
|
13692
|
+
return null;
|
|
13693
|
+
}
|
|
13694
|
+
}
|
|
13695
|
+
/**
|
|
13696
|
+
* Whether the job covers a security fix for this process: it reaches it (`jobReach`), it installs the fix on its own —
|
|
13697
|
+
* not a new major, nor one needing a decision, which it leaves for the person — and its launcher does not start the
|
|
13698
|
+
* fix yet, so the job installs it within a day. Once it does, the process still runs the release before: it is told.
|
|
13699
|
+
*/
|
|
13700
|
+
function coveredByJob(reach, fix, record, running) {
|
|
13701
|
+
if (reach === null) return false;
|
|
13702
|
+
const from = reach.pointed ?? running;
|
|
13703
|
+
if (!isVersion(from) || majorOf(fix) > majorOf(from) || record.release?.needsDecision === true) return false;
|
|
13704
|
+
return !(reach.pointed !== null && !isBehind(reach.pointed, fix));
|
|
13705
|
+
}
|
|
13706
|
+
/** The notice the record makes for this process, before any spacing: see `decideNotice`. */
|
|
13707
|
+
function noticeOf(record, running, where, updates, at, reach) {
|
|
13708
|
+
const pending = updateVerdict(record, running, where);
|
|
13709
|
+
if (pending === null) return null;
|
|
13710
|
+
const background = updates.background && reach !== null;
|
|
13711
|
+
const fix = securityRelease(record, running);
|
|
13712
|
+
const security = fix !== null && !(background && coveredByJob(reach, fix, record, running));
|
|
13713
|
+
const pausedUntil = pauseEnd(record, at);
|
|
13714
|
+
const facts = record.release?.version === pending.latest ? record.release : null;
|
|
13715
|
+
const notice = {
|
|
13716
|
+
...pending,
|
|
13717
|
+
security,
|
|
13718
|
+
major: background && isVersion(running) && majorOf(pending.latest) > majorOf(running),
|
|
13719
|
+
needsDecision: background && facts?.needsDecision === true,
|
|
13720
|
+
pausedUntil
|
|
12127
13721
|
};
|
|
13722
|
+
if (security) return notice;
|
|
13723
|
+
if (updates.level === "never" || pausedUntil !== null) return null;
|
|
13724
|
+
if (background && pending.kind !== "restart" && !notice.major && !notice.needsDecision) return null;
|
|
13725
|
+
return notice;
|
|
13726
|
+
}
|
|
13727
|
+
/** Who started a server from a release it pins itself, in words: the plugin's, the extension's. */
|
|
13728
|
+
function startedByWords(startedBy) {
|
|
13729
|
+
if (startedBy === "claude-code-plugin") return "the Claude Code plugin";
|
|
13730
|
+
if (startedBy === "gemini-extension") return "the Gemini extension";
|
|
13731
|
+
return "a plugin or an extension";
|
|
13732
|
+
}
|
|
13733
|
+
/**
|
|
13734
|
+
* The server's line. "Installed" only on the check's positive finding that restarting starts `latest`; a server a
|
|
13735
|
+
* plugin or an extension started is updated where it was installed, which `comms_update` never moves.
|
|
13736
|
+
*/
|
|
13737
|
+
function serverNoticeLine(notice, env = {}) {
|
|
13738
|
+
const paused = pausedWords(notice);
|
|
13739
|
+
if (notice.kind === "restart") return `${PRODUCT} ${notice.latest} is installed; restart the client (in Claude Code, /mcp → Reconnect) to use it.${paused}`;
|
|
13740
|
+
const startedBy = env[UPDATE_STARTED_BY_ENV]?.trim();
|
|
13741
|
+
if (startedBy) return `${PRODUCT} ${notice.latest} is out${whatItIs(notice)}; this server came with ${startedByWords(startedBy)}, and is updated there ${notice.security ? "soon" : "when it suits you"}.${paused}`;
|
|
13742
|
+
return `${PRODUCT} ${notice.latest} is out${whatItIs(notice)}; say "update" ${notice.security ? "soon" : "when it suits you"}.${paused}`;
|
|
13743
|
+
}
|
|
13744
|
+
/**
|
|
13745
|
+
* The line for a background job that has not run in 48 hours (U4, U7): as though there were none, every notice and the
|
|
13746
|
+
* security line are said again, and this says why, and where to look.
|
|
13747
|
+
*/
|
|
13748
|
+
function jobSilentLine(since) {
|
|
13749
|
+
const at = new Date(since);
|
|
13750
|
+
const day = Number.isFinite(at.getTime()) ? localStamp(at).slice(0, 10) : "it was set up";
|
|
13751
|
+
return `${PRODUCT}: background updates have not run since ${day}; check Login Items (macOS) or run doctor.`;
|
|
13752
|
+
}
|
|
13753
|
+
/** How many registrations, in words: "one registration", "2 registrations". */
|
|
13754
|
+
const registrations = (count) => count === 1 ? "one registration" : `${count} registrations`;
|
|
13755
|
+
/**
|
|
13756
|
+
* A server's one line, from everything `decideNotice` decided: the background run's word first, then the release this
|
|
13757
|
+
* server is behind — unless the run's word is about that same release — then the move to the stable launcher, and the
|
|
13758
|
+
* offer of the background job.
|
|
13759
|
+
*/
|
|
13760
|
+
function serverDecisionLine(decision, env = {}) {
|
|
13761
|
+
const { notice, background, moves } = decision;
|
|
13762
|
+
const parts = [];
|
|
13763
|
+
if (background !== null) parts.push(backgroundNoticeLine(background));
|
|
13764
|
+
if (notice !== null && !(background !== null && background.version === notice.latest)) parts.push(serverNoticeLine(notice, env));
|
|
13765
|
+
if (decision.jobSilent) parts.push(jobSilentLine(decision.jobSilent));
|
|
13766
|
+
if (moves > 0) parts.push(notice !== null ? `The update also moves ${registrations(moves)} onto the stable launcher, the last update that edits a client's configuration.` : `${PRODUCT}: ${moves === 1 ? "one registration still starts" : `${moves} registrations still start`} a versioned runtime; one approved update (say "update") moves ${moves === 1 ? "it" : "them"} onto the stable launcher, after which updates never edit a client's configuration again.`);
|
|
13767
|
+
if (decision.offerJob) parts.push("To have releases install in the background instead, comms_update with backgroundJob \"on\" sets up a daily job: its preview shows the job, when it runs and how to remove it, and sets nothing up without your yes.");
|
|
13768
|
+
return parts.join(" ");
|
|
12128
13769
|
}
|
|
12129
|
-
/**
|
|
13770
|
+
/** What is particular about the release, after "is out": a security fix, a major release, a decision it needs. */
|
|
13771
|
+
function whatItIs(notice) {
|
|
13772
|
+
if (notice.security) return ", and fixes a security problem";
|
|
13773
|
+
if (notice.major) return ", a major release, which waits for you";
|
|
13774
|
+
if (notice.needsDecision) return ", and needs a decision from you before it installs";
|
|
13775
|
+
return "";
|
|
13776
|
+
}
|
|
13777
|
+
/** Every notice during a pause says when it ends (U3): only a security fix is said then. */
|
|
13778
|
+
function pausedWords(notice) {
|
|
13779
|
+
if (!notice.pausedUntil) return "";
|
|
13780
|
+
return ` Updates are paused until ${localStamp(notice.pausedUntil)}; a security fix is not held.`;
|
|
13781
|
+
}
|
|
13782
|
+
/** The commands that never print the notice, by their first word; `mcp` alone runs a server, which has its own. */
|
|
12130
13783
|
const EXEMPT_COMMANDS = Object.freeze([
|
|
12131
13784
|
"update",
|
|
12132
13785
|
"doctor",
|
|
@@ -12136,201 +13789,105 @@ const EXEMPT_COMMANDS = Object.freeze([
|
|
|
12136
13789
|
"help"
|
|
12137
13790
|
]);
|
|
12138
13791
|
/**
|
|
12139
|
-
* Whether a command is left alone by the update
|
|
12140
|
-
* `approvals`, help, and `mcp` alone — the server, which
|
|
12141
|
-
*
|
|
12142
|
-
* doctor. `--help` and `--version` never reach a command.
|
|
13792
|
+
* Whether a command is left alone by the update notice: `update` in every form, `doctor`, `paths`, `approve`,
|
|
13793
|
+
* `approvals`, help, and `mcp` alone — the server, which gives its own notice. `mcp install` and `mcp prune` are not.
|
|
13794
|
+
* `also` adds a channel's own — WhatsApp's `status`, its doctor. `--help` and `--version` never reach a command.
|
|
12143
13795
|
*/
|
|
12144
|
-
function
|
|
13796
|
+
function exemptFromUpdateNotice(path, also = []) {
|
|
12145
13797
|
const [first] = path;
|
|
12146
13798
|
if (first === void 0) return true;
|
|
12147
13799
|
if (EXEMPT_COMMANDS.includes(first) || also.includes(first)) return true;
|
|
12148
13800
|
return first === "mcp" && path.length === 1;
|
|
12149
13801
|
}
|
|
12150
|
-
/** A Commander command's path below the program, `['mcp', 'install']`: what `
|
|
13802
|
+
/** A Commander command's path below the program, `['mcp', 'install']`: what `exemptFromUpdateNotice` reads. */
|
|
12151
13803
|
function commandPathOf(command) {
|
|
12152
13804
|
const path = [];
|
|
12153
13805
|
for (let at = command; at?.parent; at = at.parent) path.unshift(at.name());
|
|
12154
13806
|
return path;
|
|
12155
13807
|
}
|
|
13808
|
+
/** How often a command says it, for the whole machine: once a day. */
|
|
13809
|
+
const COMMAND_NOTICE_INTERVAL_MS = 864e5;
|
|
12156
13810
|
/**
|
|
12157
|
-
* The
|
|
12158
|
-
* the `--choice` a download carries its question's id in, and an argument named `approvalId` — `agent-gmail send
|
|
12159
|
-
* cancel <approvalId>`, `agent-resend send execute <approvalId>`, the commands whose tools take it as `approvalId`.
|
|
12160
|
-
* What the gate hands `claimsApproval`, as a tool call's `approvalId` is handed it. Commander has read the arguments
|
|
12161
|
-
* by the time a `preAction` hook runs.
|
|
12162
|
-
*/
|
|
12163
|
-
function approvalsOf(command) {
|
|
12164
|
-
const options = command.opts();
|
|
12165
|
-
const named = (command.registeredArguments ?? []).flatMap((argument, index) => argument.name() === "approvalId" ? [command.processedArgs?.[index]] : []);
|
|
12166
|
-
return [
|
|
12167
|
-
options.approval,
|
|
12168
|
-
options.mcpApproval,
|
|
12169
|
-
options.choice,
|
|
12170
|
-
...named
|
|
12171
|
-
];
|
|
12172
|
-
}
|
|
12173
|
-
/**
|
|
12174
|
-
* The update gate at a terminal, before a command runs. Resolves to null to run the command, or to the exit status to
|
|
12175
|
-
* end with instead; throws `UPDATE_REQUIRED` (exit 11) where nobody can be asked.
|
|
12176
|
-
*
|
|
12177
|
-
* It reads the file first. If the file is a day old it asks the registry — through `check` — waiting at most about
|
|
12178
|
-
* three seconds and going on without it after that. Then, when a newer release is out, nothing puts it off, and the
|
|
12179
|
-
* command claims no approval the person already gave:
|
|
13811
|
+
* The update notice at a terminal, before a command runs. It never stops the command, and never throws.
|
|
12180
13812
|
*
|
|
12181
|
-
*
|
|
12182
|
-
*
|
|
12183
|
-
*
|
|
12184
|
-
* done nothing;
|
|
12185
|
-
* - anything else — a script, cron, an agent, `--json` — gets `UPDATE_REQUIRED`, naming `agentcomms update` and
|
|
12186
|
-
* `agentcomms update --later`, and the command does not run.
|
|
12187
|
-
*
|
|
12188
|
-
* An update installed and not yet run by this command — this command's package is installed globally at the latest
|
|
12189
|
-
* release, and what is running is an older copy from somewhere else — stops it the same way, as the servers stop for
|
|
12190
|
-
* a client not yet restarted (§3: the terminal stops as chat does). Its "now" says to run the command again from the
|
|
12191
|
-
* installed one: there is nothing to update.
|
|
13813
|
+
* It reads the file first. If the check is due for the machine's level it asks the registry — through `check` — waiting
|
|
13814
|
+
* at most about three seconds and going on without it after that. Then, when the level says so (`decideNotice`), it
|
|
13815
|
+
* prints one line on stderr — never on stdout, where a result or `--json` is — and the command runs.
|
|
12192
13816
|
*/
|
|
12193
|
-
async function
|
|
12194
|
-
const { core, env, streams
|
|
13817
|
+
async function updateNoticeAtTerminal(options) {
|
|
13818
|
+
const { core, env, streams } = options;
|
|
12195
13819
|
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
12196
|
-
|
|
12197
|
-
|
|
12198
|
-
|
|
12199
|
-
|
|
12200
|
-
timer
|
|
12201
|
-
|
|
12202
|
-
|
|
12203
|
-
|
|
12204
|
-
|
|
12205
|
-
|
|
13820
|
+
try {
|
|
13821
|
+
if (updateCheckSwitchedOff(env) !== null) return;
|
|
13822
|
+
const enabled = await updateCheckEnabled(core, env);
|
|
13823
|
+
if (enabled.on && options.check && updateCheckDue(await readUpdateCheck(core.paths.stateDir), now(), enabled.intervalMs)) {
|
|
13824
|
+
let timer;
|
|
13825
|
+
const gaveUp = new Promise((resolve) => {
|
|
13826
|
+
timer = setTimeout(resolve, options.waitMs ?? 3e3);
|
|
13827
|
+
});
|
|
13828
|
+
try {
|
|
13829
|
+
await Promise.race([options.check().catch(() => void 0), gaveUp]);
|
|
13830
|
+
} finally {
|
|
13831
|
+
clearTimeout(timer);
|
|
13832
|
+
}
|
|
12206
13833
|
}
|
|
12207
|
-
|
|
12208
|
-
|
|
12209
|
-
|
|
13834
|
+
const line = await commandNotice(options, now);
|
|
13835
|
+
if (line !== null) streams.stderr.write(`${line}\n`);
|
|
13836
|
+
} catch {}
|
|
13837
|
+
}
|
|
13838
|
+
/** The command's line, decided and recorded under the file's lock: see `decideNotice`. */
|
|
13839
|
+
async function commandNotice(options, now) {
|
|
13840
|
+
const decision = await decideNotice({
|
|
13841
|
+
...options,
|
|
13842
|
+
now
|
|
13843
|
+
}, {
|
|
12210
13844
|
channel: options.channel,
|
|
12211
13845
|
surface: "command"
|
|
12212
|
-
};
|
|
12213
|
-
const pending = await pendingUpdate({
|
|
12214
|
-
core,
|
|
12215
|
-
env,
|
|
12216
|
-
running: options.running,
|
|
12217
|
-
now,
|
|
12218
|
-
...where
|
|
12219
|
-
});
|
|
12220
|
-
if (pending === null) return null;
|
|
12221
|
-
const person = agentMarker(env) === null && canPrompt(env, streams, {
|
|
12222
|
-
json: output.json,
|
|
12223
|
-
noInput: options.noInput === true
|
|
12224
13846
|
});
|
|
12225
|
-
|
|
12226
|
-
|
|
12227
|
-
const bold = (word) => paint(output.color, "bold", word);
|
|
12228
|
-
const answer = (await askLine(streams, pending.kind === "restart" ? `${pending.latest} is installed (this is ${pending.running}). Switch ${bold("now")}, ${bold("later")} today, or ${bold("cancel")}? ` : `${pending.latest} is out (you have ${pending.running}). Update ${bold("now")}, ${bold("later")} today, or ${bold("cancel")}? `)).trim().toLowerCase();
|
|
12229
|
-
if (answer === "now" || answer === "update") {
|
|
12230
|
-
if (pending.kind === "restart") {
|
|
12231
|
-
streams.stdout.write(`${pending.latest} is installed globally; this ${cliOf(options.channel)} is ${pending.running}, started from somewhere else — npx's cache, a project's own install, a checkout. Run your command again from the installed one. The command did not run.\n`);
|
|
12232
|
-
return EXIT_CODES.UPDATE;
|
|
12233
|
-
}
|
|
12234
|
-
if (!options.update) {
|
|
12235
|
-
streams.stdout.write(`This ${cliOf(options.channel)} reads only this machine and cannot fetch the update itself. ${updateCommandSaid(commands, "update", (command) => `Run ${command}, then run your command again.`)}\n`);
|
|
12236
|
-
return EXIT_CODES.UPDATE;
|
|
12237
|
-
}
|
|
12238
|
-
return afterUpdate(await options.update(), options, pending);
|
|
12239
|
-
}
|
|
12240
|
-
if (answer === "later" || answer === "l") {
|
|
12241
|
-
const result = await gatedChangeAtTerminal(core, updateLaterChange(core, { now }), {
|
|
12242
|
-
channel: options.channel,
|
|
12243
|
-
env,
|
|
12244
|
-
output,
|
|
12245
|
-
rerun: ["update", "--later"],
|
|
12246
|
-
rerunOn: "core",
|
|
12247
|
-
answered: true,
|
|
12248
|
-
streams
|
|
12249
|
-
});
|
|
12250
|
-
streams.stderr.write(`Put off until midnight${result.latest ? `: ${result.latest} will be asked about again tomorrow` : ""}.\n`);
|
|
12251
|
-
return null;
|
|
12252
|
-
}
|
|
12253
|
-
throw new CommsError("USAGE", "cancelled: the command did not run, and nothing was changed", { hint: bothSaid(commands, (update, later) => `Update with ${update}, or put it off until tomorrow with ${later}.`) });
|
|
13847
|
+
if (decision === null) return null;
|
|
13848
|
+
return commandDecisionLine(options.core, decision, options.channel, options.output.platform);
|
|
12254
13849
|
}
|
|
12255
13850
|
/**
|
|
12256
|
-
* The CLI a
|
|
12257
|
-
*
|
|
13851
|
+
* The CLI a notice names, in product words — "Gmail CLI", "core CLI" — never its binary, which is no command most
|
|
13852
|
+
* people can run, and which a sentence would read as one (CUE-403).
|
|
12258
13853
|
*/
|
|
12259
13854
|
function cliOf(channel) {
|
|
12260
13855
|
if (channel === "core") return "core CLI";
|
|
12261
13856
|
return `${CHANNEL_SNAPSHOT.find((entry) => entry.manifest.channel === channel)?.manifest.label ?? channel} CLI`;
|
|
12262
13857
|
}
|
|
12263
13858
|
/**
|
|
12264
|
-
* The
|
|
12265
|
-
*
|
|
13859
|
+
* The command's line: the update, as core's own command located from what is printing — or why there is none here —
|
|
13860
|
+
* or, when this command's package is installed globally at `latest` and an older copy is running, to run that one.
|
|
12266
13861
|
*/
|
|
12267
|
-
function
|
|
12268
|
-
const
|
|
12269
|
-
|
|
12270
|
-
|
|
12271
|
-
|
|
12272
|
-
|
|
12273
|
-
update: ways.update,
|
|
12274
|
-
later: ways.later
|
|
12275
|
-
};
|
|
12276
|
-
if (pending.kind === "restart") return new CommsError("UPDATE_REQUIRED", `Hang on a minute, the update is installed, but this command isn't running it yet. ${pending.latest} is installed globally, and this ${cli} is ${pending.running}: ${handoffSentence(commands.later, (later) => `run the command again from the installed one, or ${later} to put it off until tomorrow`)}`, {
|
|
12277
|
-
hint: `Nothing was done. An older copy is running — npx's cache, a project's own install, a checkout. "Not now" is a change a person approves: an agent gets the preview and an approval id (exit 10), and runs the same command again with --approval <id> once the person agrees.`,
|
|
12278
|
-
details
|
|
12279
|
-
});
|
|
12280
|
-
return new CommsError("UPDATE_REQUIRED", `${UPDATE_FIRST} ${pending.latest} is out (you have ${pending.running}): ${bothSaid(commands, (update, later) => `run ${update} first, or ${later} to put it off until tomorrow`)}`, {
|
|
12281
|
-
hint: `Nothing was done. Both are changes a person approves: an agent gets the preview and an approval id (exit 10), and runs the same command again with --approval <id> once the person agrees.`,
|
|
12282
|
-
details
|
|
12283
|
-
});
|
|
12284
|
-
}
|
|
12285
|
-
/** The update and "not now" in one sentence; with no command here — the same core gives both or neither — why not. */
|
|
12286
|
-
function bothSaid(commands, say) {
|
|
12287
|
-
return handoffSentence(commands.update, (update) => handoffSentence(commands.later, (later) => say(update, later)));
|
|
13862
|
+
function commandNoticeLine(core, notice, channel, platform) {
|
|
13863
|
+
const paused = pausedWords(notice);
|
|
13864
|
+
if (notice.kind === "restart") return `${PRODUCT} ${notice.latest} is installed; this ${cliOf(channel)} is ${notice.running}, started from somewhere else — npx's cache, a project's own install, a checkout: run it from the installed one.${paused}`;
|
|
13865
|
+
const update = requireHandoffs(core, platform).core(["update"]);
|
|
13866
|
+
const when = notice.security ? "soon" : "when it suits you";
|
|
13867
|
+
return `${PRODUCT} ${notice.latest} is out${whatItIs(notice)} (this is ${notice.running}); ${handoffSentence(update, (command) => `run ${command} ${when}.`)}${paused}`;
|
|
12288
13868
|
}
|
|
12289
13869
|
/**
|
|
12290
|
-
*
|
|
12291
|
-
*
|
|
12292
|
-
* the latest. Anything short of that says what, and ends with a status that is not 0: the command has not run, and a
|
|
12293
|
-
* script that goes on after `cmd && next` must not take it for the command's success.
|
|
13870
|
+
* A command's one line, from everything `decideNotice` decided, as `serverDecisionLine` puts it together — every
|
|
13871
|
+
* command named located for this installation, or why there is none here.
|
|
12294
13872
|
*/
|
|
12295
|
-
|
|
12296
|
-
const {
|
|
12297
|
-
const
|
|
12298
|
-
const
|
|
12299
|
-
|
|
12300
|
-
if (
|
|
12301
|
-
|
|
12302
|
-
|
|
12303
|
-
|
|
12304
|
-
|
|
12305
|
-
|
|
12306
|
-
|
|
12307
|
-
});
|
|
12308
|
-
|
|
12309
|
-
if (outcome === "updated") {
|
|
12310
|
-
streams.stdout.write("Updated. Run your command again.\n");
|
|
12311
|
-
return EXIT_CODES.OK;
|
|
12312
|
-
}
|
|
12313
|
-
streams.stdout.write(`${before.latest} is installed here: run your command again from it. It did not run.\n`);
|
|
12314
|
-
return EXIT_CODES.UPDATE;
|
|
12315
|
-
}
|
|
12316
|
-
streams.stdout.write(`${notRun}: the update did not bring it to ${before.latest}. The update moves what this machine registers and has installed globally — what it found is said above — and this ${cli}, ${before.running}, is started from somewhere else: npx's cache, a project's own install, a checkout. ${handoffSentence(commands.later, (later) => `Run it from ${before.latest}, or put this off until tomorrow with ${later}.`)}\n`);
|
|
12317
|
-
return EXIT_CODES.UPDATE;
|
|
12318
|
-
}
|
|
12319
|
-
async function askLine(streams, question) {
|
|
12320
|
-
const { createInterface } = await import("node:readline/promises");
|
|
12321
|
-
const rl = createInterface({
|
|
12322
|
-
input: streams.stdin,
|
|
12323
|
-
output: streams.stderr
|
|
12324
|
-
});
|
|
12325
|
-
try {
|
|
12326
|
-
return await rl.question(question);
|
|
12327
|
-
} finally {
|
|
12328
|
-
rl.close();
|
|
12329
|
-
}
|
|
13873
|
+
function commandDecisionLine(core, decision, channel, platform) {
|
|
13874
|
+
const { notice, background, moves } = decision;
|
|
13875
|
+
const handoffs = requireHandoffs(core, platform);
|
|
13876
|
+
const parts = [];
|
|
13877
|
+
if (background !== null) parts.push(backgroundNoticeLine(background));
|
|
13878
|
+
if (notice !== null && !(background !== null && background.version === notice.latest)) parts.push(commandNoticeLine(core, notice, channel, platform));
|
|
13879
|
+
if (decision.jobSilent) parts.push(jobSilentLine(decision.jobSilent));
|
|
13880
|
+
if (moves > 0) parts.push(notice !== null ? `The update also moves ${registrations(moves)} onto the stable launcher, the last update that edits a client's configuration.` : `${PRODUCT}: ${moves === 1 ? "one registration still starts" : `${moves} registrations still start`} a versioned runtime; ${handoffSentence(handoffs.core(["update"]), (update) => `one approved update, ${update}, moves ${moves === 1 ? "it" : "them"} onto the stable launcher, after which updates never edit a client's configuration again.`)}`);
|
|
13881
|
+
if (decision.offerJob) parts.push(handoffSentence(handoffs.core([
|
|
13882
|
+
"update",
|
|
13883
|
+
"--background-job",
|
|
13884
|
+
"on"
|
|
13885
|
+
]), (command) => `To have releases install in the background instead, run ${command}: it shows the job, when it runs and how to remove it, and sets nothing up without your yes.`));
|
|
13886
|
+
return parts.join(" ");
|
|
12330
13887
|
}
|
|
12331
13888
|
channelServer("core").packageName;
|
|
12332
13889
|
//#endregion
|
|
12333
|
-
//#region ../core/dist/tool-arguments-
|
|
13890
|
+
//#region ../core/dist/tool-arguments-4c3WR2Gy.mjs
|
|
12334
13891
|
const APPLIED = Symbol.for("agentcomms.strictToolArguments");
|
|
12335
13892
|
const TARGET = "draft-2020-12";
|
|
12336
13893
|
/**
|
|
@@ -12342,13 +13899,14 @@ const TARGET = "draft-2020-12";
|
|
|
12342
13899
|
* `refuse` is handed a `USAGE` `CommsError` and returns the tool result, so the refusal is in the same envelope as
|
|
12343
13900
|
* every other refusal that server makes.
|
|
12344
13901
|
*
|
|
12345
|
-
* `
|
|
12346
|
-
*
|
|
12347
|
-
*
|
|
12348
|
-
*
|
|
12349
|
-
*
|
|
13902
|
+
* `notice` is asked once the handler has answered: the update notice (design 2026-10-16, U1), which every server builds
|
|
13903
|
+
* with `updateToolNotice`. Here because this is the one place every tool of every server already passes through, so no
|
|
13904
|
+
* tool can be registered without it; given to the wrapper rather than built in, because what it asks the registry
|
|
13905
|
+
* with is each server's own — and WhatsApp's is nothing at all. It never stops a call: a line it gives is carried
|
|
13906
|
+
* beside the result (`withUpdateNotice`), and a tool's output schema is published with room for it
|
|
13907
|
+
* (`withUpdateField`), so a client that checks a result against its schema takes the one that carries it.
|
|
12350
13908
|
*/
|
|
12351
|
-
function strictToolArguments(server, refuse,
|
|
13909
|
+
function strictToolArguments(server, refuse, notice, retired = {}) {
|
|
12352
13910
|
const target = server;
|
|
12353
13911
|
if (typeof target.registerTool !== "function") throw new Error("strictToolArguments needs an MCP server: this has no registerTool");
|
|
12354
13912
|
if (target[APPLIED]) return;
|
|
@@ -12359,15 +13917,21 @@ function strictToolArguments(server, refuse, gate, retired = {}) {
|
|
|
12359
13917
|
const declared = config.inputSchema !== void 0;
|
|
12360
13918
|
const schema = strictSchemaOf(name, config.inputSchema);
|
|
12361
13919
|
const published = schema["~standard"].jsonSchema.input({ target: TARGET });
|
|
12362
|
-
|
|
13920
|
+
const output = notice && config.outputSchema !== void 0 ? withUpdateField(config.outputSchema) : null;
|
|
13921
|
+
const room = config.outputSchema === void 0 || output !== null;
|
|
13922
|
+
const registered = output === null ? config : {
|
|
12363
13923
|
...config,
|
|
13924
|
+
outputSchema: output
|
|
13925
|
+
};
|
|
13926
|
+
return register(name, {
|
|
13927
|
+
...registered,
|
|
12364
13928
|
inputSchema: passThrough(schema)
|
|
12365
13929
|
}, async (input, context) => {
|
|
12366
13930
|
const parsed = await schema.safeParseAsync(input ?? {});
|
|
12367
13931
|
if (!parsed.success) return refuse(refusal(name, published, input, parsed.error.issues, retired[name] ?? {}));
|
|
12368
|
-
const
|
|
12369
|
-
|
|
12370
|
-
return
|
|
13932
|
+
const result = await (declared ? handler(parsed.data, context) : handler(context));
|
|
13933
|
+
const line = notice ? await notice(name).catch(() => null) : null;
|
|
13934
|
+
return line === null ? result : withUpdateNotice(result, line, room);
|
|
12371
13935
|
});
|
|
12372
13936
|
};
|
|
12373
13937
|
Object.defineProperty(target, APPLIED, { value: true });
|
|
@@ -12407,6 +13971,83 @@ function passThrough(schema) {
|
|
|
12407
13971
|
jsonSchema: schema["~standard"].jsonSchema
|
|
12408
13972
|
} };
|
|
12409
13973
|
}
|
|
13974
|
+
/** The field a result carries the update notice in, as every output schema publishes it. */
|
|
13975
|
+
const UPDATE_FIELD = "update";
|
|
13976
|
+
const UPDATE_PROPERTY = {
|
|
13977
|
+
type: "string",
|
|
13978
|
+
description: "Once per server process at most: a newer agent-communications release is out, or installed and waiting for a restart. Pass it on to the person; nothing was refused."
|
|
13979
|
+
};
|
|
13980
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13981
|
+
/**
|
|
13982
|
+
* A result with the notice beside it: a text block of its own after the result's — a client that shows the model only
|
|
13983
|
+
* text reads it there — and, where the structured content is an object with room for it, its `update` field, which is
|
|
13984
|
+
* all Claude Code and Codex show. Nothing of the result is changed or taken away.
|
|
13985
|
+
*/
|
|
13986
|
+
function withUpdateNotice(result, line, room = true) {
|
|
13987
|
+
if (!isPlainObject(result)) return result;
|
|
13988
|
+
const content = Array.isArray(result.content) ? result.content : [];
|
|
13989
|
+
const next = {
|
|
13990
|
+
...result,
|
|
13991
|
+
content: [...content, {
|
|
13992
|
+
type: "text",
|
|
13993
|
+
text: line
|
|
13994
|
+
}]
|
|
13995
|
+
};
|
|
13996
|
+
const structured = result.structuredContent;
|
|
13997
|
+
if (room && isPlainObject(structured) && !Object.hasOwn(structured, UPDATE_FIELD)) next.structuredContent = {
|
|
13998
|
+
...structured,
|
|
13999
|
+
[UPDATE_FIELD]: line
|
|
14000
|
+
};
|
|
14001
|
+
return next;
|
|
14002
|
+
}
|
|
14003
|
+
/** A JSON Schema with the `update` field among its properties — each side of a union — or null with no room for it. */
|
|
14004
|
+
function publishedWithUpdate(json) {
|
|
14005
|
+
const alternatives = json.anyOf ?? json.oneOf;
|
|
14006
|
+
if (Array.isArray(alternatives) && json.properties === void 0) {
|
|
14007
|
+
const each = alternatives.map(publishedWithUpdate);
|
|
14008
|
+
if (each.some((alternative) => alternative === null)) return null;
|
|
14009
|
+
return {
|
|
14010
|
+
...json,
|
|
14011
|
+
...json.anyOf ? { anyOf: each } : { oneOf: each }
|
|
14012
|
+
};
|
|
14013
|
+
}
|
|
14014
|
+
if (!(Array.isArray(json.type) ? json.type : [json.type]).includes("object") || json.properties === void 0) return null;
|
|
14015
|
+
if (Object.hasOwn(json.properties, UPDATE_FIELD)) return null;
|
|
14016
|
+
return {
|
|
14017
|
+
...json,
|
|
14018
|
+
properties: {
|
|
14019
|
+
...json.properties,
|
|
14020
|
+
[UPDATE_FIELD]: UPDATE_PROPERTY
|
|
14021
|
+
}
|
|
14022
|
+
};
|
|
14023
|
+
}
|
|
14024
|
+
/**
|
|
14025
|
+
* A tool's output schema, published with room for the `update` field: the same schema, with that one property added
|
|
14026
|
+
* — a zod object publishes `additionalProperties: false`, and a client that checks a result against it would refuse
|
|
14027
|
+
* the one carrying the notice — and checked as it always was, without the field. Null when the schema has no room for
|
|
14028
|
+
* it: one that declares `update` itself, or is not an object; that tool's notice is a text block alone.
|
|
14029
|
+
*/
|
|
14030
|
+
function withUpdateField(outputSchema) {
|
|
14031
|
+
const standard = (isPlainObject(outputSchema) && !("~standard" in outputSchema) && Object.values(outputSchema).every(isZodSchema) ? object(outputSchema) : outputSchema)?.["~standard"];
|
|
14032
|
+
if (typeof standard?.validate !== "function" || standard.jsonSchema === void 0) return null;
|
|
14033
|
+
const { jsonSchema } = standard;
|
|
14034
|
+
const input = publishedWithUpdate(jsonSchema.input({ target: TARGET }));
|
|
14035
|
+
const output = publishedWithUpdate(jsonSchema.output({ target: TARGET }));
|
|
14036
|
+
if (input === null || output === null) return null;
|
|
14037
|
+
return { "~standard": {
|
|
14038
|
+
version: 1,
|
|
14039
|
+
vendor: "agentcomms",
|
|
14040
|
+
validate: (value) => {
|
|
14041
|
+
if (!isPlainObject(value) || !Object.hasOwn(value, UPDATE_FIELD)) return standard.validate(value);
|
|
14042
|
+
const { [UPDATE_FIELD]: _notice, ...rest } = value;
|
|
14043
|
+
return standard.validate(rest);
|
|
14044
|
+
},
|
|
14045
|
+
jsonSchema: {
|
|
14046
|
+
input: () => input,
|
|
14047
|
+
output: () => output
|
|
14048
|
+
}
|
|
14049
|
+
} };
|
|
14050
|
+
}
|
|
12410
14051
|
/** A USAGE refusal naming what was wrong and what the tool takes. */
|
|
12411
14052
|
function refusal(tool, published, input, issues, retired) {
|
|
12412
14053
|
const properties = published.properties ?? {};
|
|
@@ -16039,4 +17680,4 @@ async function syncAccount(context, request) {
|
|
|
16039
17680
|
});
|
|
16040
17681
|
}
|
|
16041
17682
|
//#endregion
|
|
16042
|
-
export {
|
|
17683
|
+
export { gatedChangeAtTerminal as $, requireAccount as A, PATH_OPTIONS as B, chatKindOf as C, CALLER as Ct, text as D, numeric as E, expandSource as F, approveChangeAtTerminal as G, absoluteSearchPath as H, strictToolArguments as I, cliHandoffs as J, canPrompt as K, CHANNEL_SERVERS as L, storeIdentity as M, checkStorePath as N, PLATFORM as O, defaultStorePath as P, exemptFromUpdateNotice as Q, CommsError as R, chatRefOf as S, writeResult as St, kindOf as T, VERSION$2 as Tt, agentMarker as U, UNTRUSTED_NOTICE as V, approveAndWaitSentence as W, commandPathOf as X, colorEnabled as Y, escapeForDisplay as Z, WhatsAppContext as _, truncateDisplay as _t, CHAT_KINDS as a, nameAvailable as at, requireSupportedNode as b, windowsSystemProgram as bt, searchMessages as c, paint as ct, composeDraft as d, renderPrune as dt, handoffSentence as et, draftMessage as f, resolvePaths as ft, visibilityUnitKey as g, toCommsError as gt, statusAuthor as h, serverPruneChange as ht, probeStore as i, lookupName as it, requireNamedConfig as j, newWhatsAppAccount as k, Presenter as l, pathOverridesFromCliOptions as lt, listKey as m, serverInstallChange as mt, withCheckedCopy as n, installExitStatus as nt, listChats as o, nameShapeProblem as ot, Visibility as p, runCommand as pt, childEnvironment as q, whatsappStatus as r, isCommsError as rt, readChat as s, openCore as st, syncAccount as t, handoffText as tt, innerText as u, renderInstall as ut, WhatsAppIndex as v, updateNoticeAtTerminal as vt, coreDataToIso as w, PACKAGE_NAME as wt, inspectSchema as x, withFileLock as xt, rebuildIndex as y, updateToolNotice as yt, EXIT_CODES as z };
|