@agentcomms/whatsapp 0.9.0 → 0.11.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
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as
|
|
2
|
+
import { $ as renderPrune, A as CommsError, B as colorEnabled, C as checkStorePath, F as approvalsOf, G as installExitStatus, H as escapeForDisplay, I as approveChangeAtTerminal, J as nameAvailable, K as isCommsError, L as canPrompt, M as absoluteSearchPath, N as agentMarker, O as CHANNELS, P as approvalKind, Q as renderInstall, R as channelManifest, S as storeIdentity, T as expandSource, U as exemptFromUpdateGate, V as commandPathOf, W as gatedChangeAtTerminal, X as openCore, Y as nameShapeProblem, Z as paint, _ as chatRefOf, a as listChats, at as updateGateAtTerminal, b as requireAccount, c as Presenter, ct as withFileLock, d as draftMessage, et as runCommand, f as Visibility, g as requireSupportedNode, h as WhatsAppIndex, i as CHAT_KINDS, it as truncateDisplay, j as EXIT_CODES, k as CHANNEL_SERVERS, l as innerText, lt as writeResult, m as WhatsAppContext, n as whatsappStatus, nt as serverPruneChange, o as readChat, p as listKey, q as lookupName, r as probeStore, s as searchMessages, st as windowsSystemProgram, t as syncAccount, tt as serverInstallChange, ut as VERSION, v as PLATFORM, w as defaultStorePath, x as requireNamedConfig, y as newWhatsAppAccount, z as childEnvironment } from "./sync-CinH_TIY.mjs";
|
|
3
3
|
import { rm } from "node:fs/promises";
|
|
4
|
-
import path from "node:path";
|
|
4
|
+
import path, { delimiter, join } from "node:path";
|
|
5
5
|
import childProcess, { spawn } from "node:child_process";
|
|
6
6
|
import { stripVTControlCharacters } from "node:util";
|
|
7
|
-
import fs from "node:fs";
|
|
7
|
+
import fs, { accessSync, constants as constants$1 } from "node:fs";
|
|
8
8
|
import process$1 from "node:process";
|
|
9
9
|
import { EventEmitter } from "node:events";
|
|
10
10
|
//#region ../../node_modules/.pnpm/commander@15.0.0/node_modules/commander/lib/error.js
|
|
@@ -3161,18 +3161,17 @@ function clearChats(context, request) {
|
|
|
3161
3161
|
}
|
|
3162
3162
|
//#endregion
|
|
3163
3163
|
//#region src/cli/opener.ts
|
|
3164
|
-
function openLink(url, platform = process.platform) {
|
|
3164
|
+
function openLink(url, platform = process.platform, deps = {}) {
|
|
3165
3165
|
if (!/^(?:whatsapp:\/\/send\?|https:\/\/wa\.me\/)/.test(url)) return false;
|
|
3166
|
-
const
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
url
|
|
3171
|
-
]] : ["xdg-open", [url]];
|
|
3166
|
+
const env = deps.env ?? process.env;
|
|
3167
|
+
const command = linkOpener(platform, env);
|
|
3168
|
+
if (command === null) return false;
|
|
3169
|
+
const args = platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
|
|
3172
3170
|
try {
|
|
3173
|
-
const child = spawn(command, args, {
|
|
3171
|
+
const child = (deps.spawn ?? spawn)(command, args, {
|
|
3174
3172
|
stdio: "ignore",
|
|
3175
|
-
detached: true
|
|
3173
|
+
detached: true,
|
|
3174
|
+
env: childEnvironment(env, platform)
|
|
3176
3175
|
});
|
|
3177
3176
|
child.on("error", () => void 0);
|
|
3178
3177
|
child.unref();
|
|
@@ -3181,6 +3180,19 @@ function openLink(url, platform = process.platform) {
|
|
|
3181
3180
|
return false;
|
|
3182
3181
|
}
|
|
3183
3182
|
}
|
|
3183
|
+
/** The opener's full path, or null when there is none that can be named without looking in the current folder. */
|
|
3184
|
+
function linkOpener(platform, env) {
|
|
3185
|
+
if (platform === "win32") return windowsSystemProgram("rundll32.exe", env);
|
|
3186
|
+
if (platform === "darwin") return "/usr/bin/open";
|
|
3187
|
+
for (const directory of absoluteSearchPath((env.PATH ?? "").split(delimiter), platform)) {
|
|
3188
|
+
const candidate = join(directory, "xdg-open");
|
|
3189
|
+
try {
|
|
3190
|
+
accessSync(candidate, constants$1.X_OK);
|
|
3191
|
+
return candidate;
|
|
3192
|
+
} catch {}
|
|
3193
|
+
}
|
|
3194
|
+
return null;
|
|
3195
|
+
}
|
|
3184
3196
|
//#endregion
|
|
3185
3197
|
//#region src/cli/render.ts
|
|
3186
3198
|
/**
|
|
@@ -3522,7 +3534,7 @@ a macOS dialog may be waiting) · 77 permission needed (macOS privacy) · 78 con
|
|
|
3522
3534
|
ran = true;
|
|
3523
3535
|
exitCode = await runCommand(output(), async () => {
|
|
3524
3536
|
requireSupportedNode();
|
|
3525
|
-
const { startWhatsAppStdioServer } = await import("./stdio-entry-
|
|
3537
|
+
const { startWhatsAppStdioServer } = await import("./stdio-entry-r40LJtFu.mjs");
|
|
3526
3538
|
await startWhatsAppStdioServer({
|
|
3527
3539
|
env,
|
|
3528
3540
|
...flags.account ? { account: String(flags.account) } : {}
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { t as createWhatsAppMcpServer } from "./server-
|
|
1
|
+
import { u as composeDraft, ut as VERSION } from "./sync-CinH_TIY.mjs";
|
|
2
|
+
import { t as createWhatsAppMcpServer } from "./server-BLF6Xumr.mjs";
|
|
3
3
|
//#region src/index.ts
|
|
4
4
|
/**
|
|
5
5
|
* The library entry of `@agentcomms/whatsapp`.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { D as strictToolArguments, E as UNTRUSTED_NOTICE, a as listChats, d as draftMessage, i as CHAT_KINDS,
|
|
1
|
+
import { D as strictToolArguments, E as UNTRUSTED_NOTICE, a as listChats, d as draftMessage, i as CHAT_KINDS, m as WhatsAppContext, n as whatsappStatus, o as readChat, ot as updateToolGate, rt as toCommsError, s as searchMessages, t as syncAccount, ut as VERSION } from "./sync-CinH_TIY.mjs";
|
|
2
2
|
import { c as boolean, h as number, x as string } from "./schemas-DiGKNDNq.mjs";
|
|
3
3
|
import { t as McpServer } from "./mcp-DXXb3Vv3-Co5BzjYe.mjs";
|
|
4
4
|
//#region src/mcp/server.ts
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as createWhatsAppMcpServer } from "./server-
|
|
1
|
+
import { t as createWhatsAppMcpServer } from "./server-BLF6Xumr.mjs";
|
|
2
2
|
//#region src/mcp/stdio-entry.ts
|
|
3
3
|
/**
|
|
4
4
|
* Starting the stdio server. On stdio, stdout is the protocol, so the console is redirected to stderr first — the
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { C as unknown, b as strictObject, c as boolean, h as number, i as _enum, m as looseObject, p as literal, s as array, x as string, y as record } from "./schemas-DiGKNDNq.mjs";
|
|
2
2
|
import { access, chmod, constants, link, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, stat, utimes } from "node:fs/promises";
|
|
3
|
-
import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
3
|
+
import path, { basename, delimiter, dirname, isAbsolute, join, resolve, sep } 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
|
-
import { homedir, platform } from "node:os";
|
|
7
|
+
import { homedir, platform, userInfo } from "node:os";
|
|
8
8
|
import { execFile, spawn } from "node:child_process";
|
|
9
9
|
import { styleText } from "node:util";
|
|
10
10
|
import { constants as constants$1 } from "node:fs";
|
|
11
11
|
//#endregion
|
|
12
12
|
//#region src/version.ts
|
|
13
13
|
/** The package version, read from package.json at build time. */
|
|
14
|
-
const VERSION$1 = "0.
|
|
14
|
+
const VERSION$1 = "0.11.0";
|
|
15
15
|
//#endregion
|
|
16
|
-
//#region ../core/dist/update-gate-
|
|
16
|
+
//#region ../core/dist/update-gate-BMcLWU3V.mjs
|
|
17
17
|
/**
|
|
18
18
|
* Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
|
|
19
19
|
* optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
|
|
@@ -1033,7 +1033,7 @@ const APP_DIR_NAME = "agent-communications";
|
|
|
1033
1033
|
function resolvePaths(options = {}) {
|
|
1034
1034
|
const env = options.env ?? process.env;
|
|
1035
1035
|
const platform = options.platform ?? process.platform;
|
|
1036
|
-
const home = options.home ?? (
|
|
1036
|
+
const home = options.home ?? homeOf(env, platform);
|
|
1037
1037
|
const configDir = resolve(env.AGENT_COMMS_CONFIG_DIR || (env.XDG_CONFIG_HOME ? join(env.XDG_CONFIG_HOME, "agent-communications") : platform === "win32" ? join(env.APPDATA || join(home, "AppData", "Roaming"), "agent-communications") : join(home, ".config", "agent-communications")));
|
|
1038
1038
|
const explicitConfigDir = Boolean(env.AGENT_COMMS_CONFIG_DIR);
|
|
1039
1039
|
const localRoot = platform === "win32" && !explicitConfigDir ? join(env.LOCALAPPDATA || join(home, "AppData", "Local"), APP_DIR_NAME) : configDir;
|
|
@@ -1045,10 +1045,40 @@ function resolvePaths(options = {}) {
|
|
|
1045
1045
|
downloadsDir: resolve(join(home, "Downloads", APP_DIR_NAME))
|
|
1046
1046
|
};
|
|
1047
1047
|
}
|
|
1048
|
-
/**
|
|
1049
|
-
|
|
1048
|
+
/**
|
|
1049
|
+
* The home the environment names, which is where Node's own `homedir()` looks for the running process — `HOME`, or
|
|
1050
|
+
* `USERPROFILE` on Windows. Asking `homedir()` directly ignored an environment passed in, so every test that gave the
|
|
1051
|
+
* harness a temporary HOME still had its data and downloads resolved to the real ones: one day of test runs left 748
|
|
1052
|
+
* backups of fixture entries in the maintainer's own data directory.
|
|
1053
|
+
*
|
|
1054
|
+
* Its own function since a download asks where to save: the person's Downloads folder is `<this>/Downloads`, and a
|
|
1055
|
+
* folder they type as `~/…` is expanded from it — the same home every other path here is resolved from. When the
|
|
1056
|
+
* environment names none, the account's own home is taken ({@link accountHome}); `account` is for a test to say what
|
|
1057
|
+
* that is.
|
|
1058
|
+
*/
|
|
1059
|
+
function homeOf(env = process.env, platform = process.platform, account = accountHome) {
|
|
1060
|
+
return (platform === "win32" ? env.USERPROFILE : env.HOME) || account();
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* The home the account itself has, for when the environment names none: the password database's (`os.userInfo()`),
|
|
1064
|
+
* which no variable moves, rather than `homedir()`, which reads `HOME` first — from this process's environment, not the
|
|
1065
|
+
* one the caller passed, and so from somewhere the caller did not say. Only when there is no such entry — some
|
|
1066
|
+
* containers run as an id with none — is `homedir()` asked instead.
|
|
1067
|
+
*/
|
|
1068
|
+
function accountHome() {
|
|
1069
|
+
try {
|
|
1070
|
+
const home = userInfo().homedir;
|
|
1071
|
+
if (home) return home;
|
|
1072
|
+
} catch {}
|
|
1073
|
+
return homedir();
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Expands a leading `~` to the home directory. Nothing else is expanded. `joinPaths` is the platform's own, for a path
|
|
1077
|
+
* judged for another platform than this one: a Linux home joined on Windows would otherwise take its backslashes.
|
|
1078
|
+
*/
|
|
1079
|
+
function expandHome(path, home = homedir(), joinPaths = join) {
|
|
1050
1080
|
if (path === "~") return home;
|
|
1051
|
-
if (path.startsWith("~/") || path.startsWith("~\\")) return
|
|
1081
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) return joinPaths(home, path.slice(2));
|
|
1052
1082
|
return path;
|
|
1053
1083
|
}
|
|
1054
1084
|
/**
|
|
@@ -1855,6 +1885,55 @@ function challengeMatches(answer, storedHash) {
|
|
|
1855
1885
|
const b = Buffer.from(storedHash, "hex");
|
|
1856
1886
|
return a.length === b.length && timingSafeEqual(a, b);
|
|
1857
1887
|
}
|
|
1888
|
+
/**
|
|
1889
|
+
* The digest a download's question is bound to: the account, the download, the request, the files it listed and the
|
|
1890
|
+
* names it showed them under.
|
|
1891
|
+
*
|
|
1892
|
+
* The files and names keep their order, since the question showed them in it; the request is canonical JSON, so the
|
|
1893
|
+
* same arguments digest the same however an object happened to list its keys.
|
|
1894
|
+
*/
|
|
1895
|
+
function downloadDigest(download) {
|
|
1896
|
+
return sha256Hex(canonicalJson$1({
|
|
1897
|
+
kind: "download",
|
|
1898
|
+
target: {
|
|
1899
|
+
kind: download.target.kind,
|
|
1900
|
+
name: download.target.name,
|
|
1901
|
+
id: download.target.id
|
|
1902
|
+
},
|
|
1903
|
+
operation: download.operation,
|
|
1904
|
+
request: download.request,
|
|
1905
|
+
files: [...download.files],
|
|
1906
|
+
names: [...download.names]
|
|
1907
|
+
}));
|
|
1908
|
+
}
|
|
1909
|
+
/**
|
|
1910
|
+
* Why a download claimed is not the one its question was asked for, in a sentence — the first difference found. Only
|
|
1911
|
+
* the words; what refuses is the digest.
|
|
1912
|
+
*/
|
|
1913
|
+
function downloadDrift(asked, now) {
|
|
1914
|
+
if (asked.target.id !== now.target.id || asked.target.kind !== now.target.kind) return `the question was about ${asked.target.name}, not ${now.target.name}`;
|
|
1915
|
+
if (asked.operation !== now.operation) return "the question was asked for another kind of download";
|
|
1916
|
+
if (canonicalJson$1(asked.request) !== canonicalJson$1(now.request)) return "the question was asked about a different request: other messages, other files or another limit";
|
|
1917
|
+
if (canonicalJson$1(asked.files) !== canonicalJson$1(now.files)) return "the files are not the ones the question listed";
|
|
1918
|
+
const renamed = asked.files.find((_, index) => asked.names[index] !== now.names[index]);
|
|
1919
|
+
if (renamed !== void 0) return `${renamed} would now be saved under another name than the one the question showed: it was renamed since`;
|
|
1920
|
+
return "the download is not the one the question was asked for";
|
|
1921
|
+
}
|
|
1922
|
+
/**
|
|
1923
|
+
* Why a download's question cannot be claimed under this change policy — the stricter of `livePolicy` and the one it
|
|
1924
|
+
* was asked under — or null. Changes nothing, so a download can ask before it looks at a folder, and the store asks
|
|
1925
|
+
* again as it claims.
|
|
1926
|
+
*
|
|
1927
|
+
* Under `chat` nothing stops it: the person's answer, relayed from the conversation, is the answer. Anything stricter
|
|
1928
|
+
* needs the answer recorded on the question by a channel an agent cannot answer — the person's own terminal, or a
|
|
1929
|
+
* form a trusted client showed them. An answer carried in a tool's arguments or a command's flags is refused however
|
|
1930
|
+
* it was worded, with the command that answers it named, and the question left open for the person.
|
|
1931
|
+
*/
|
|
1932
|
+
function downloadClaimRefusal(record, livePolicy, pendingHint) {
|
|
1933
|
+
if (stricterPolicy(livePolicy, record.requiredPolicy) === "chat") return null;
|
|
1934
|
+
if (record.state === "approved" && (record.approvedVia === "terminal" || record.approvedVia === "elicitation") && record.download?.answer !== void 0) return null;
|
|
1935
|
+
return refuseDownload("APPROVAL_PENDING", "the change policy here is confirm, so the person answers where to save themselves — at their own terminal, not through an agent", record, pendingHint ?? "Ask the person to answer it at their own terminal, with the approve command of the channel the files come from and this choice id; then make the download again with the choice id alone.");
|
|
1936
|
+
}
|
|
1858
1937
|
/** The kind of a record. Absent is a send: every record written before changes had approvals. */
|
|
1859
1938
|
function approvalKind(record) {
|
|
1860
1939
|
return record.kind ?? "send";
|
|
@@ -1903,6 +1982,11 @@ function changeDrift(approved, now) {
|
|
|
1903
1982
|
if (canonicalJson$1(approved.effects) !== canonicalJson$1(now.effects)) return "what it does outside the configuration is not what was approved";
|
|
1904
1983
|
return "the change is not the one that was approved";
|
|
1905
1984
|
}
|
|
1985
|
+
/**
|
|
1986
|
+
* What to do with a download's question, for a caller that took it for something else: it is answered, not approved
|
|
1987
|
+
* with a code — in the chat, or at the person's own terminal with the command of the channel that asked.
|
|
1988
|
+
*/
|
|
1989
|
+
const DOWNLOAD_ANSWER_HINT = "It is answered, not approved with a code: the person says where in the chat, or — under a confirm change policy — at their own terminal, with the `approve` command of the channel the files come from and this id. The download that asked is then made again with this id.";
|
|
1906
1990
|
const POLICY_RANK = {
|
|
1907
1991
|
chat: 0,
|
|
1908
1992
|
confirm: 1,
|
|
@@ -1931,18 +2015,36 @@ function refuseChange(code, reason, record, hint) {
|
|
|
1931
2015
|
} : {}
|
|
1932
2016
|
});
|
|
1933
2017
|
}
|
|
2018
|
+
/**
|
|
2019
|
+
* The same refusal for a download's question, which saves nothing until it is claimed.
|
|
2020
|
+
*
|
|
2021
|
+
* Its hint says to ask again rather than to prepare anything: what the person is shown again is the question, and it
|
|
2022
|
+
* is the download itself that asks it.
|
|
2023
|
+
*/
|
|
2024
|
+
function refuseDownload(code, reason, record, hint) {
|
|
2025
|
+
return new CommsError(code, `nothing was saved: ${reason}`, {
|
|
2026
|
+
hint: hint ?? "Make the download again without an answer, and show the person the new question.",
|
|
2027
|
+
details: record ? {
|
|
2028
|
+
choiceId: record.approvalId,
|
|
2029
|
+
state: record.state
|
|
2030
|
+
} : {}
|
|
2031
|
+
});
|
|
2032
|
+
}
|
|
1934
2033
|
/** The refusal in the words of the record's own kind. */
|
|
1935
2034
|
function refusalFor(record) {
|
|
1936
|
-
|
|
2035
|
+
const kind = approvalKind(record);
|
|
2036
|
+
return kind === "change" ? refuseChange : kind === "download" ? refuseDownload : refuse;
|
|
1937
2037
|
}
|
|
1938
2038
|
var ApprovalStore = class {
|
|
1939
2039
|
directory;
|
|
1940
2040
|
#now;
|
|
1941
2041
|
#ttlMs;
|
|
2042
|
+
#downloadTtlMs;
|
|
1942
2043
|
constructor(stateDir, options = {}) {
|
|
1943
2044
|
this.directory = join(stateDir, "approvals");
|
|
1944
2045
|
this.#now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
1945
2046
|
this.#ttlMs = options.ttlMs ?? 6e5;
|
|
2047
|
+
this.#downloadTtlMs = options.downloadTtlMs ?? 18e5;
|
|
1946
2048
|
}
|
|
1947
2049
|
#path(approvalId, suffix = ".json") {
|
|
1948
2050
|
if (!APPROVAL_ID_PATTERN.test(approvalId)) throw refuse("USAGE", `"${approvalId}" is not an approval id`);
|
|
@@ -2030,6 +2132,11 @@ var ApprovalStore = class {
|
|
|
2030
2132
|
}
|
|
2031
2133
|
#stateError(record) {
|
|
2032
2134
|
const refusal = refusalFor(record);
|
|
2135
|
+
if (approvalKind(record) === "download") {
|
|
2136
|
+
if (record.state === "expired") return refusal("APPROVAL_EXPIRED", "the question expired before it was answered", record);
|
|
2137
|
+
if (record.state === "revoked") return refusal("APPROVAL_VOID", `the question was voided (${record.reason ?? "revoked"})`, record);
|
|
2138
|
+
return refusal("APPROVAL_VOID", record.state === "used" ? "the question was answered already, and an answer is used once" : `the question is ${record.state}`, record);
|
|
2139
|
+
}
|
|
2033
2140
|
if (record.state === "expired") return refusal("APPROVAL_EXPIRED", "the approval expired before it was used", record);
|
|
2034
2141
|
if (record.state === "revoked") return refusal("APPROVAL_VOID", `the approval was voided (${record.reason ?? "revoked"})`, record);
|
|
2035
2142
|
return refusal("APPROVAL_REQUIRED", `the approval is ${record.state}`, record);
|
|
@@ -2041,8 +2148,11 @@ var ApprovalStore = class {
|
|
|
2041
2148
|
* somebody is about to approve, because an agent passed its id to a change, would punish the wrong party.
|
|
2042
2149
|
*/
|
|
2043
2150
|
#requireKind(record, kind) {
|
|
2044
|
-
|
|
2151
|
+
const actual = approvalKind(record);
|
|
2152
|
+
if (actual === kind) return;
|
|
2045
2153
|
const id = record.approvalId;
|
|
2154
|
+
if (actual === "download") throw (kind === "change" ? refuseChange : refuse)("USAGE", `${id} is a question about where to save files, not ${kind === "send" ? "a send" : "a configuration change"}`, record, DOWNLOAD_ANSWER_HINT);
|
|
2155
|
+
if (kind === "download") throw refuseDownload("USAGE", `${id} is ${actual === "change" ? "a configuration change" : "a send"}, not a question about where to save files`, record, "Pass the choice id the download’s question came with, and the person’s answer beside it.");
|
|
2046
2156
|
throw kind === "send" ? refuse("USAGE", `approval ${id} is for a configuration change, not a send`, record, `A person approves it with \`agentcomms approve ${id}\` — or ${channelApproveCommands()}, whichever is installed — and it permits only the change it was prepared for.`) : refuseChange("USAGE", `approval ${id} is for a send, not a configuration change`, record, `It is approved with the command that prepared it — ${channelApproveCommands({ sending: true })} — and permits only that send.`);
|
|
2047
2157
|
}
|
|
2048
2158
|
/** Issues a new challenge to show a human; only its hash is kept. */
|
|
@@ -2264,6 +2374,139 @@ var ApprovalStore = class {
|
|
|
2264
2374
|
await this.#markClaimed(result);
|
|
2265
2375
|
return result;
|
|
2266
2376
|
}
|
|
2377
|
+
/**
|
|
2378
|
+
* A download's question, pending, bound to `downloadDigest(input.download)` — computed here, never taken from the
|
|
2379
|
+
* caller, as a change's digest is. It stands in for the draft revision too.
|
|
2380
|
+
*
|
|
2381
|
+
* `policy` is the change policy of the mailbox or workspace the files come from, as it stands when the question is
|
|
2382
|
+
* asked: where a stranger's files land on this machine is a change to it, and is answered the way that account's
|
|
2383
|
+
* other changes are approved. A claim holds the question to the stricter of this and the policy then.
|
|
2384
|
+
*/
|
|
2385
|
+
async createDownload(input) {
|
|
2386
|
+
const now = this.#now();
|
|
2387
|
+
const download = {
|
|
2388
|
+
summary: input.download.summary,
|
|
2389
|
+
target: { ...input.download.target },
|
|
2390
|
+
operation: input.download.operation,
|
|
2391
|
+
request: JSON.parse(canonicalJson$1(input.download.request)),
|
|
2392
|
+
files: [...input.download.files],
|
|
2393
|
+
names: [...input.download.names],
|
|
2394
|
+
folders: {
|
|
2395
|
+
downloads: input.download.folders.downloads,
|
|
2396
|
+
current: input.download.folders.current
|
|
2397
|
+
},
|
|
2398
|
+
...input.download.listing === void 0 ? {} : { listing: input.download.listing.map((file) => ({
|
|
2399
|
+
name: file.name,
|
|
2400
|
+
size: file.size,
|
|
2401
|
+
...file.renamed === void 0 ? {} : { renamed: file.renamed },
|
|
2402
|
+
...file.flags === void 0 || file.flags.length === 0 ? {} : { flags: [...file.flags] }
|
|
2403
|
+
})) }
|
|
2404
|
+
};
|
|
2405
|
+
const digest = downloadDigest(download);
|
|
2406
|
+
const policy = input.policy === "chat" ? "chat" : "confirm";
|
|
2407
|
+
const record = {
|
|
2408
|
+
approvalId: newApprovalId(),
|
|
2409
|
+
kind: "download",
|
|
2410
|
+
digestVersion: 1,
|
|
2411
|
+
inboxId: download.target.id,
|
|
2412
|
+
draftId: "download",
|
|
2413
|
+
draftMessageId: digest,
|
|
2414
|
+
digest,
|
|
2415
|
+
policy,
|
|
2416
|
+
requiredPolicy: policy,
|
|
2417
|
+
riskFlags: [],
|
|
2418
|
+
expect: {
|
|
2419
|
+
to: [],
|
|
2420
|
+
cc: [],
|
|
2421
|
+
bcc: [],
|
|
2422
|
+
subject: download.summary
|
|
2423
|
+
},
|
|
2424
|
+
challengeAttempts: 0,
|
|
2425
|
+
state: "pending",
|
|
2426
|
+
createdAt: now.toISOString(),
|
|
2427
|
+
expiresAt: new Date(now.getTime() + this.#downloadTtlMs).toISOString(),
|
|
2428
|
+
updatedAt: now.toISOString(),
|
|
2429
|
+
download
|
|
2430
|
+
};
|
|
2431
|
+
await this.#write(record);
|
|
2432
|
+
return record;
|
|
2433
|
+
}
|
|
2434
|
+
/**
|
|
2435
|
+
* Records the person's answer to a download's question, given where an agent cannot give it: at their own terminal,
|
|
2436
|
+
* or in a form a trusted client showed them. The question moves to `approved`, carrying the answer, and waits for
|
|
2437
|
+
* the download to claim it — which it may then do under `confirm`, and which saves where this answer says.
|
|
2438
|
+
*
|
|
2439
|
+
* Only a pending question can be answered, and only once: a second answer is refused, not taken over the first.
|
|
2440
|
+
*/
|
|
2441
|
+
async answerDownload(approvalId, via, answer) {
|
|
2442
|
+
const recorded = answer.choice === "other" ? {
|
|
2443
|
+
choice: "other",
|
|
2444
|
+
folder: answer.folder
|
|
2445
|
+
} : { choice: answer.choice };
|
|
2446
|
+
return await this.#transition(approvalId, (current) => {
|
|
2447
|
+
this.#requireKind(current, "download");
|
|
2448
|
+
if (current.state === "approved") throw refuseDownload("APPROVAL_VOID", "the question was answered already, and it is answered once", current);
|
|
2449
|
+
if (current.state !== "pending") throw this.#stateError(current);
|
|
2450
|
+
if (!current.download || downloadDigest(current.download) !== current.digest) throw refuseDownload("APPROVAL_VOID", "the question does not describe the download it is bound to", current);
|
|
2451
|
+
return {
|
|
2452
|
+
...current,
|
|
2453
|
+
state: "approved",
|
|
2454
|
+
approvedVia: via,
|
|
2455
|
+
approvedDigest: current.digest,
|
|
2456
|
+
download: {
|
|
2457
|
+
...current.download,
|
|
2458
|
+
answer: recorded
|
|
2459
|
+
}
|
|
2460
|
+
};
|
|
2461
|
+
});
|
|
2462
|
+
}
|
|
2463
|
+
/**
|
|
2464
|
+
* Claims a download's question, once, for the download the caller is about to make — and returns it, with the
|
|
2465
|
+
* folders it offered, so that `downloads` and `current` in the answer mean the paths the person read, and with the
|
|
2466
|
+
* answer the person recorded, when they recorded one.
|
|
2467
|
+
*
|
|
2468
|
+
* `live` is the download as the caller computes it now: the same account, the same request, the same files under
|
|
2469
|
+
* the same names — the person answered for those files and no others. Any other is refused, and the question left
|
|
2470
|
+
* open, as it was: a second call that got an argument wrong is the agent's slip, not the person's, and voiding the
|
|
2471
|
+
* question for it would make the person answer again for nothing. It still expires, and is claimed once.
|
|
2472
|
+
*
|
|
2473
|
+
* `options.policy` is the change policy of that account now, and the stricter of it and the one the question was
|
|
2474
|
+
* asked under decides. Under `chat`, a pending question is claimed with the answer the caller carries: the person
|
|
2475
|
+
* gave it in the conversation. Under `confirm`, only a question the person answered at a terminal or in a trusted
|
|
2476
|
+
* form can be claimed; a pending one is refused, and left as it is, so they can still answer it. One used, voided or
|
|
2477
|
+
* expired is refused as an approval in that state is.
|
|
2478
|
+
*/
|
|
2479
|
+
async claimForDownload(approvalId, live, options = {}) {
|
|
2480
|
+
const digest = downloadDigest(live);
|
|
2481
|
+
let failure = null;
|
|
2482
|
+
const result = await this.#transition(approvalId, (current) => {
|
|
2483
|
+
this.#requireKind(current, "download");
|
|
2484
|
+
if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
|
|
2485
|
+
const voidWith = (reason) => {
|
|
2486
|
+
failure = {
|
|
2487
|
+
code: "APPROVAL_VOID",
|
|
2488
|
+
reason
|
|
2489
|
+
};
|
|
2490
|
+
return {
|
|
2491
|
+
...current,
|
|
2492
|
+
state: "revoked",
|
|
2493
|
+
reason
|
|
2494
|
+
};
|
|
2495
|
+
};
|
|
2496
|
+
if (!current.download || downloadDigest(current.download) !== current.digest) return voidWith("the question does not describe the download it is bound to");
|
|
2497
|
+
if (digest !== current.digest) throw refuseDownload("USAGE", `${downloadDrift(current.download, live)}; the question is still open`, current, "Call again with the same arguments the question was asked with, and its choice id. If the files themselves changed, make the download again without an answer, and show the person the new question.");
|
|
2498
|
+
const refusal = downloadClaimRefusal(current, options.policy ?? "chat", options.pendingHint);
|
|
2499
|
+
if (refusal !== null) throw refusal;
|
|
2500
|
+
return {
|
|
2501
|
+
...current,
|
|
2502
|
+
state: "used"
|
|
2503
|
+
};
|
|
2504
|
+
});
|
|
2505
|
+
const failed = failure;
|
|
2506
|
+
if (failed) throw refuseDownload(failed.code, failed.reason, result);
|
|
2507
|
+
await this.#markClaimed(result);
|
|
2508
|
+
return result;
|
|
2509
|
+
}
|
|
2267
2510
|
/** Records the outcome of the one send attempt. */
|
|
2268
2511
|
async complete(approvalId, outcome) {
|
|
2269
2512
|
return this.#transition(approvalId, (current) => {
|
|
@@ -3144,6 +3387,59 @@ function packageFrom(server) {
|
|
|
3144
3387
|
const line = [server.command, ...server.args].join(" ");
|
|
3145
3388
|
return /(@[\w.-]+\/[\w.-]+|(?<=\s)[\w.-]+-mcp)(?=@|\s|$)/.exec(line)?.[1];
|
|
3146
3389
|
}
|
|
3390
|
+
/**
|
|
3391
|
+
* The programs this package starts, named so that no folder it happens to be in can stand in for them.
|
|
3392
|
+
*
|
|
3393
|
+
* A bare name is looked up, and on Windows the lookup starts in the current folder: libuv tries it before `PATH`, and
|
|
3394
|
+
* so does every program that looks a name up with `SearchPath` or `CreateProcess`. The current folder is where a
|
|
3395
|
+
* command was run or a server was started — and, since a download asks where to save, one of the three places a
|
|
3396
|
+
* stranger's file may be saved into. A `reg.exe` or `rundll32.exe` saved there would then be what runs the next time
|
|
3397
|
+
* this package reads the registry or opens a browser. So a Windows program is named by its full path, under the Windows
|
|
3398
|
+
* folder, and every process started on Windows is told, by `NoDefaultCurrentDirectoryInExePath`, not to look in its
|
|
3399
|
+
* own current folder for the programs it starts in turn.
|
|
3400
|
+
*/
|
|
3401
|
+
/** An environment variable by name, whatever its case: Windows's are case-blind, an object passed in is not. */
|
|
3402
|
+
function envValue(env, name) {
|
|
3403
|
+
const wanted = name.toLowerCase();
|
|
3404
|
+
for (const [key, value] of Object.entries(env)) if (key.toLowerCase() === wanted && value) return value;
|
|
3405
|
+
}
|
|
3406
|
+
/**
|
|
3407
|
+
* The Windows folder: `%SystemRoot%` (or `%windir%`) when it names a folder on a drive, and `C:\Windows` otherwise.
|
|
3408
|
+
* A value that is not a drive's absolute path — relative, a share, a device path — is not taken: it would be looked up
|
|
3409
|
+
* against the current folder, or on another machine, which is what naming the program in full is to avoid.
|
|
3410
|
+
*/
|
|
3411
|
+
function windowsFolder(env = process.env) {
|
|
3412
|
+
for (const name of ["SystemRoot", "windir"]) {
|
|
3413
|
+
const value = envValue(env, name);
|
|
3414
|
+
if (value !== void 0 && /^[A-Za-z]:[\\/]/.test(value)) return path.win32.resolve(value);
|
|
3415
|
+
}
|
|
3416
|
+
return "C:\\Windows";
|
|
3417
|
+
}
|
|
3418
|
+
/** A program of Windows's own by its full path: `%SystemRoot%\System32\<name>`, never a bare name to look up. */
|
|
3419
|
+
function windowsSystemProgram(name, env = process.env) {
|
|
3420
|
+
return path.win32.join(windowsFolder(env), "System32", name);
|
|
3421
|
+
}
|
|
3422
|
+
/**
|
|
3423
|
+
* The environment a child process is started with: `env` as it is, and on Windows also
|
|
3424
|
+
* `NoDefaultCurrentDirectoryInExePath=1`, so that the child — `rundll32.exe`, a Node started again — never takes a
|
|
3425
|
+
* program from its current folder either. Elsewhere nothing is added: a Unix shell looks in the current folder only
|
|
3426
|
+
* when `PATH` says to.
|
|
3427
|
+
*/
|
|
3428
|
+
function childEnvironment(env = process.env, platform = process.platform) {
|
|
3429
|
+
if (platform !== "win32") return env;
|
|
3430
|
+
return {
|
|
3431
|
+
...env,
|
|
3432
|
+
NoDefaultCurrentDirectoryInExePath: "1"
|
|
3433
|
+
};
|
|
3434
|
+
}
|
|
3435
|
+
/**
|
|
3436
|
+
* The directories of a search path that name a folder on their own: absolute, and on Windows on a drive. An empty
|
|
3437
|
+
* entry means the current folder to a Unix shell, and a relative one is read against it; either would make the program
|
|
3438
|
+
* found — and run — depend on where this was started, which is where a download may have saved a stranger's file.
|
|
3439
|
+
*/
|
|
3440
|
+
function absoluteSearchPath(entries, platform = process.platform) {
|
|
3441
|
+
return entries.filter((entry) => platform === "win32" ? /^[A-Za-z]:[\\/]/.test(entry) : entry !== "" && path.posix.isAbsolute(entry));
|
|
3442
|
+
}
|
|
3147
3443
|
/** The install options that pin a server to one account: the generic one, and Gmail's and Slack's own names. */
|
|
3148
3444
|
const PIN_OPTIONS = Object.freeze([
|
|
3149
3445
|
"account",
|
|
@@ -3199,9 +3495,14 @@ function clientCliSearch(env) {
|
|
|
3199
3495
|
/**
|
|
3200
3496
|
* The first of `directories` that holds an executable `name`. PATH is read by the caller, once: a test that counts
|
|
3201
3497
|
* how often the installer looks at PATH counts exactly the looks it means.
|
|
3498
|
+
*
|
|
3499
|
+
* Only absolute directories are looked in, whoever the caller. An empty entry of PATH is the current folder to a
|
|
3500
|
+
* shell, and a relative one is read against it: the `node`, `npx` or `claude` found there — and then run, or written
|
|
3501
|
+
* into a client's config to be run — would be whatever is in the folder this was started from, which is where a
|
|
3502
|
+
* download may have saved a stranger's file.
|
|
3202
3503
|
*/
|
|
3203
3504
|
async function executableIn(name, directories, env) {
|
|
3204
|
-
const paths = directories
|
|
3505
|
+
const paths = absoluteSearchPath(directories);
|
|
3205
3506
|
const extensions = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
|
|
3206
3507
|
for (const directory of paths) for (const extension of extensions) {
|
|
3207
3508
|
const candidate = join(directory, name + extension.toLowerCase());
|
|
@@ -3348,13 +3649,20 @@ async function findNpmCli() {
|
|
|
3348
3649
|
} catch {}
|
|
3349
3650
|
throw new CommsError("CONFIG", "npm could not be found next to this Node installation", { hint: "Install with `--launcher npx` instead, which runs the published package directly." });
|
|
3350
3651
|
}
|
|
3652
|
+
/**
|
|
3653
|
+
* A command run to its end. Its environment is this process's, and on Windows also tells it never to take a program
|
|
3654
|
+
* from its current folder: `npm` and a client's command both start programs of their own by name.
|
|
3655
|
+
*/
|
|
3351
3656
|
function run(command, args) {
|
|
3352
3657
|
return new Promise((resolvePromise, reject) => {
|
|
3353
|
-
const child = spawn(command, args, {
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3658
|
+
const child = spawn(command, args, {
|
|
3659
|
+
stdio: [
|
|
3660
|
+
"ignore",
|
|
3661
|
+
"ignore",
|
|
3662
|
+
"pipe"
|
|
3663
|
+
],
|
|
3664
|
+
env: childEnvironment(process.env)
|
|
3665
|
+
});
|
|
3358
3666
|
let stderr = "";
|
|
3359
3667
|
child.stderr?.on("data", (chunk) => {
|
|
3360
3668
|
stderr += String(chunk);
|
|
@@ -3580,14 +3888,20 @@ function installCommand$1(product, options, name, extra = []) {
|
|
|
3580
3888
|
words.push(...extra);
|
|
3581
3889
|
return words.map((word) => /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`).join(" ");
|
|
3582
3890
|
}
|
|
3583
|
-
/**
|
|
3891
|
+
/**
|
|
3892
|
+
* A command's exit status and what it printed, kept here and never shown: codex prints an entry's env. Started as
|
|
3893
|
+
* `run` starts one, so that on Windows it takes no program from its current folder either.
|
|
3894
|
+
*/
|
|
3584
3895
|
function capture$1(command, args) {
|
|
3585
3896
|
return new Promise((resolvePromise, reject) => {
|
|
3586
|
-
const child = spawn(command, args, {
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3897
|
+
const child = spawn(command, args, {
|
|
3898
|
+
stdio: [
|
|
3899
|
+
"ignore",
|
|
3900
|
+
"pipe",
|
|
3901
|
+
"pipe"
|
|
3902
|
+
],
|
|
3903
|
+
env: childEnvironment(process.env)
|
|
3904
|
+
});
|
|
3591
3905
|
let stdout = "";
|
|
3592
3906
|
let stderr = "";
|
|
3593
3907
|
child.stdout?.on("data", (chunk) => {
|
|
@@ -4040,11 +4354,27 @@ async function verifyEntry(entry, product) {
|
|
|
4040
4354
|
await client.close().catch(() => void 0);
|
|
4041
4355
|
}
|
|
4042
4356
|
}
|
|
4043
|
-
/**
|
|
4044
|
-
|
|
4357
|
+
/** Where `ps` is looked for, in order: the system's own directories, never PATH. */
|
|
4358
|
+
const PS_DIRECTORIES = ["/bin", "/usr/bin"];
|
|
4359
|
+
/**
|
|
4360
|
+
* Every running process's command line, or null when they cannot be listed.
|
|
4361
|
+
*
|
|
4362
|
+
* `ps` is started by its full path, `/bin/ps` or else `/usr/bin/ps`, never by its bare name: a name is looked up, and
|
|
4363
|
+
* a lookup can reach the folder this was started from — where a download may have saved a stranger's `ps`, which would
|
|
4364
|
+
* then run, and whose answer would decide what is deleted. With neither there, the processes cannot be listed.
|
|
4365
|
+
*
|
|
4366
|
+
* `directories` and `list` are for tests: where to look for `ps`, and what runs the one found.
|
|
4367
|
+
*/
|
|
4368
|
+
async function runningCommandLines(options = {}) {
|
|
4045
4369
|
if (process.platform === "win32") return null;
|
|
4370
|
+
const ps = await executableIn("ps", options.directories ?? PS_DIRECTORIES, process.env);
|
|
4371
|
+
if (ps === null) return null;
|
|
4372
|
+
return (options.list ?? commandLinesFrom)(ps);
|
|
4373
|
+
}
|
|
4374
|
+
/** What `ps` at this path says is running: one command line per process, or null when it fails. */
|
|
4375
|
+
function commandLinesFrom(ps) {
|
|
4046
4376
|
return new Promise((resolvePromise) => {
|
|
4047
|
-
execFile(
|
|
4377
|
+
execFile(ps, [
|
|
4048
4378
|
"-A",
|
|
4049
4379
|
"-o",
|
|
4050
4380
|
"args="
|
|
@@ -4780,6 +5110,7 @@ async function claimChange(core, approvalId, expect, options) {
|
|
|
4780
5110
|
async function changeRecord(core, approvalId) {
|
|
4781
5111
|
const record = await core.approvals.get(approvalId);
|
|
4782
5112
|
if (!record) throw new CommsError("NOT_FOUND", `no approval ${approvalId}`, { hint: "Prepare the change again; an approval expires ten minutes after it is made." });
|
|
5113
|
+
if (approvalKind(record) === "download") throw new CommsError("USAGE", `${approvalId} is a question about where to save files, not a configuration change`, { hint: DOWNLOAD_ANSWER_HINT });
|
|
4783
5114
|
if (approvalKind(record) !== "change") throw new CommsError("USAGE", `approval ${approvalId} is for a send, not a configuration change`, { hint: `Approve it with the command that prepared it: ${channelApproveCommands({ sending: true })}.` });
|
|
4784
5115
|
if (!record.change || changeDigest(record.change) !== record.digest) throw new CommsError("BAD_DATA", "this approval does not describe the change it is bound to", {
|
|
4785
5116
|
hint: "Nothing was approved. Prepare the change again.",
|
|
@@ -5627,7 +5958,7 @@ function isPrerelease(version) {
|
|
|
5627
5958
|
return VERSION_PATTERN.exec(version)?.[4] !== void 0;
|
|
5628
5959
|
}
|
|
5629
5960
|
/** The package version, read from package.json at build time. */
|
|
5630
|
-
const VERSION = "0.
|
|
5961
|
+
const VERSION = "0.11.0";
|
|
5631
5962
|
/**
|
|
5632
5963
|
* Registering, pruning and listing the MCP servers of every channel, from the core.
|
|
5633
5964
|
*
|
|
@@ -5882,7 +6213,7 @@ function serverPruneChange(core, env, request, own) {
|
|
|
5882
6213
|
};
|
|
5883
6214
|
const { packageName, version } = ownProduct(request.channel, own) ?? {
|
|
5884
6215
|
packageName: channelServer(request.channel).packageName,
|
|
5885
|
-
version: "0.
|
|
6216
|
+
version: "0.11.0"
|
|
5886
6217
|
};
|
|
5887
6218
|
const product = {
|
|
5888
6219
|
packageName,
|
|
@@ -6227,6 +6558,10 @@ Object.freeze({
|
|
|
6227
6558
|
lookup: true
|
|
6228
6559
|
});
|
|
6229
6560
|
Object.freeze({ kind: "change" });
|
|
6561
|
+
Object.freeze({
|
|
6562
|
+
kind: "download",
|
|
6563
|
+
argument: "choiceId"
|
|
6564
|
+
});
|
|
6230
6565
|
/**
|
|
6231
6566
|
* Whether a call claims an approval the person already gave (§2): an approval this machine's approval store holds —
|
|
6232
6567
|
* every channel keeps its approvals there — that is still waiting to be used, pending or approved, and of the kind the
|
|
@@ -6278,7 +6613,8 @@ function updateToolGate(options) {
|
|
|
6278
6613
|
surface: "server"
|
|
6279
6614
|
});
|
|
6280
6615
|
if (pending === null) return null;
|
|
6281
|
-
|
|
6616
|
+
const claim = options.approvals?.[tool];
|
|
6617
|
+
if (await claimsApproval(options.core, args[claim?.argument ?? "approvalId"], claim)) return null;
|
|
6282
6618
|
return stoppedCall(pending, {
|
|
6283
6619
|
server: options.server,
|
|
6284
6620
|
tool
|
|
@@ -6343,9 +6679,10 @@ function commandPathOf(command) {
|
|
|
6343
6679
|
}
|
|
6344
6680
|
/**
|
|
6345
6681
|
* The approvals a Commander command claims: its `--approval`, the `--mcp-approval` Gmail's `setup` carries beside it,
|
|
6346
|
-
* and an argument named `approvalId` — `agent-gmail send
|
|
6347
|
-
* <approvalId>`, the commands whose tools take it as `approvalId`.
|
|
6348
|
-
* call's `approvalId` is handed it. Commander has read the arguments
|
|
6682
|
+
* the `--choice` a download carries its question's id in, and an argument named `approvalId` — `agent-gmail send
|
|
6683
|
+
* cancel <approvalId>`, `agent-resend send execute <approvalId>`, the commands whose tools take it as `approvalId`.
|
|
6684
|
+
* What the gate hands `claimsApproval`, as a tool call's `approvalId` is handed it. Commander has read the arguments
|
|
6685
|
+
* by the time a `preAction` hook runs.
|
|
6349
6686
|
*/
|
|
6350
6687
|
function approvalsOf(command) {
|
|
6351
6688
|
const options = command.opts();
|
|
@@ -6353,6 +6690,7 @@ function approvalsOf(command) {
|
|
|
6353
6690
|
return [
|
|
6354
6691
|
options.approval,
|
|
6355
6692
|
options.mcpApproval,
|
|
6693
|
+
options.choice,
|
|
6356
6694
|
...named
|
|
6357
6695
|
];
|
|
6358
6696
|
}
|
|
@@ -6499,7 +6837,7 @@ async function askLine(streams, question) {
|
|
|
6499
6837
|
}
|
|
6500
6838
|
}
|
|
6501
6839
|
//#endregion
|
|
6502
|
-
//#region ../core/dist/tool-arguments-
|
|
6840
|
+
//#region ../core/dist/tool-arguments-5aAzuZnA.mjs
|
|
6503
6841
|
const APPLIED = Symbol.for("agentcomms.strictToolArguments");
|
|
6504
6842
|
const TARGET = "draft-2020-12";
|
|
6505
6843
|
/**
|
|
@@ -6517,7 +6855,7 @@ const TARGET = "draft-2020-12";
|
|
|
6517
6855
|
* in, because what it asks the registry with is each server's own — and WhatsApp's is nothing at all. A call it
|
|
6518
6856
|
* answers reaches nothing, as a refused one does.
|
|
6519
6857
|
*/
|
|
6520
|
-
function strictToolArguments(server, refuse, gate) {
|
|
6858
|
+
function strictToolArguments(server, refuse, gate, retired = {}) {
|
|
6521
6859
|
const target = server;
|
|
6522
6860
|
if (typeof target.registerTool !== "function") throw new Error("strictToolArguments needs an MCP server: this has no registerTool");
|
|
6523
6861
|
if (target[APPLIED]) return;
|
|
@@ -6533,7 +6871,7 @@ function strictToolArguments(server, refuse, gate) {
|
|
|
6533
6871
|
inputSchema: passThrough(schema)
|
|
6534
6872
|
}, async (input, context) => {
|
|
6535
6873
|
const parsed = await schema.safeParseAsync(input ?? {});
|
|
6536
|
-
if (!parsed.success) return refuse(refusal(name, published, input, parsed.error.issues));
|
|
6874
|
+
if (!parsed.success) return refuse(refusal(name, published, input, parsed.error.issues, retired[name] ?? {}));
|
|
6537
6875
|
const stopped = gate ? await gate(name, parsed.data) : null;
|
|
6538
6876
|
if (stopped !== null) return stopped;
|
|
6539
6877
|
return declared ? handler(parsed.data, context) : handler(context);
|
|
@@ -6577,12 +6915,22 @@ function passThrough(schema) {
|
|
|
6577
6915
|
} };
|
|
6578
6916
|
}
|
|
6579
6917
|
/** A USAGE refusal naming what was wrong and what the tool takes. */
|
|
6580
|
-
function refusal(tool, published, input, issues) {
|
|
6918
|
+
function refusal(tool, published, input, issues, retired) {
|
|
6581
6919
|
const properties = published.properties ?? {};
|
|
6582
6920
|
const takes = Object.keys(properties);
|
|
6583
6921
|
const required = new Set(published.required ?? []);
|
|
6584
6922
|
const signature = takes.length === 0 ? `${tool} takes no arguments.` : `${tool} takes ${spoken(takes.map((key) => required.has(key) ? `\`${key}\` (required)` : `\`${key}\``), "and")}.`;
|
|
6585
6923
|
const unknown = issues.flatMap((issue) => issue.code === "unrecognized_keys" && issue.path.length === 0 ? issue.keys : []);
|
|
6924
|
+
const removed = unknown.filter((key) => Object.hasOwn(retired, key));
|
|
6925
|
+
if (removed.length > 0) return new CommsError("USAGE", `${tool} no longer takes ${spoken(removed.map((key) => `\`${key}\``), "or")}`, {
|
|
6926
|
+
hint: removed.map((key) => retired[key]).join(" "),
|
|
6927
|
+
details: {
|
|
6928
|
+
tool,
|
|
6929
|
+
retired: removed,
|
|
6930
|
+
unknown,
|
|
6931
|
+
takes
|
|
6932
|
+
}
|
|
6933
|
+
});
|
|
6586
6934
|
if (unknown.length > 0) return new CommsError("USAGE", `${tool} does not take ${spoken(unknown.map((key) => `\`${key}\``), "or")}`, {
|
|
6587
6935
|
hint: takes.length === 0 ? signature : `${signature} Leave out anything else.`,
|
|
6588
6936
|
details: {
|
|
@@ -10096,4 +10444,4 @@ async function syncAccount(context, request) {
|
|
|
10096
10444
|
});
|
|
10097
10445
|
}
|
|
10098
10446
|
//#endregion
|
|
10099
|
-
export {
|
|
10447
|
+
export { renderPrune as $, CommsError as A, colorEnabled as B, checkStorePath as C, strictToolArguments as D, UNTRUSTED_NOTICE as E, approvalsOf as F, installExitStatus as G, escapeForDisplay as H, approveChangeAtTerminal as I, nameAvailable as J, isCommsError as K, canPrompt as L, absoluteSearchPath as M, agentMarker as N, CHANNELS as O, approvalKind as P, renderInstall as Q, channelManifest as R, storeIdentity as S, expandSource as T, exemptFromUpdateGate as U, commandPathOf as V, gatedChangeAtTerminal as W, openCore as X, nameShapeProblem as Y, paint as Z, chatRefOf as _, listChats as a, updateGateAtTerminal as at, requireAccount as b, Presenter as c, withFileLock as ct, draftMessage as d, runCommand as et, Visibility as f, requireSupportedNode as g, WhatsAppIndex as h, CHAT_KINDS as i, truncateDisplay as it, EXIT_CODES as j, CHANNEL_SERVERS as k, innerText as l, writeResult as lt, WhatsAppContext as m, whatsappStatus as n, serverPruneChange as nt, readChat as o, updateToolGate as ot, listKey as p, lookupName as q, probeStore as r, toCommsError as rt, searchMessages as s, windowsSystemProgram as st, syncAccount as t, serverInstallChange as tt, composeDraft as u, VERSION$1 as ut, PLATFORM as v, defaultStorePath as w, requireNamedConfig as x, newWhatsAppAccount as y, childEnvironment as z };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentcomms/whatsapp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "WhatsApp for coding agents, read-only: list, read and search the chats WhatsApp for Mac keeps on this Mac, and draft replies as links the person sends. No network client; it never sends.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
39
39
|
"commander": "^15.0.0",
|
|
40
40
|
"zod": "^4.6.5",
|
|
41
|
-
"@agentcomms/core": "0.
|
|
41
|
+
"@agentcomms/core": "0.11.0"
|
|
42
42
|
},
|
|
43
43
|
"agentcomms": {
|
|
44
44
|
"contract": 1,
|
|
@@ -87,5 +87,5 @@
|
|
|
87
87
|
"typecheck": "tsc -p tsconfig.json",
|
|
88
88
|
"test": "node --experimental-strip-types --disable-warning=ExperimentalWarning --test --test-timeout=600000 \"test/**/*.test.ts\""
|
|
89
89
|
},
|
|
90
|
-
"gitHead": "
|
|
90
|
+
"gitHead": "bcc1392f8380dbf523a910a87de652a18faead6f"
|
|
91
91
|
}
|