@agentcomms/whatsapp 0.12.2 → 0.12.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as
|
|
2
|
+
import { $ as paint, A as CommsError, B as colorEnabled, C as checkStorePath, F as approvalsOf, G as gatedChangeAtTerminal, H as commandText, I as approveChangeAtTerminal, J as isCommsError, K as inlineCommand, L as canPrompt, M as absoluteSearchPath, N as agentMarker, O as CHANNELS, P as approvalKind, Q as openCore, R as channelManifest, S as storeIdentity, T as expandSource, U as escapeForDisplay, V as commandPathOf, W as exemptFromUpdateGate, X as nameAvailable, Y as lookupName, Z as nameShapeProblem, _ as chatRefOf, a as listChats, at as shellCommand, b as requireAccount, c as Presenter, ct as updateGateAtTerminal, d as draftMessage, dt as withFileLock, et as renderInstall, f as Visibility, ft as writeResult, g as requireSupportedNode, h as WhatsAppIndex, i as CHAT_KINDS, it as serverPruneChange, j as EXIT_CODES, k as CHANNEL_SERVERS, l as innerText, m as WhatsAppContext, n as whatsappStatus, nt as runCommand, o as readChat, p as listKey, pt as VERSION, q as installExitStatus, r as probeStore, rt as serverInstallChange, s as searchMessages, st as truncateDisplay, t as syncAccount, tt as renderPrune, ut as windowsSystemProgram, v as PLATFORM, w as defaultStorePath, x as requireNamedConfig, y as newWhatsAppAccount, z as childEnvironment } from "./sync-DJlsr6xq.mjs";
|
|
3
3
|
import { rm } from "node:fs/promises";
|
|
4
4
|
import path, { delimiter, join } from "node:path";
|
|
5
|
-
import fs, { accessSync, constants as constants$1 } from "node:fs";
|
|
6
5
|
import { stripVTControlCharacters } from "node:util";
|
|
6
|
+
import fs, { accessSync, constants as constants$1 } from "node:fs";
|
|
7
7
|
import childProcess, { spawn } from "node:child_process";
|
|
8
8
|
import process$1 from "node:process";
|
|
9
9
|
import { EventEmitter } from "node:events";
|
|
@@ -2994,12 +2994,17 @@ const WHATSAPP_MCP = {
|
|
|
2994
2994
|
function refuseAnAgent(context, command, what) {
|
|
2995
2995
|
const marker = agentMarker(context.env);
|
|
2996
2996
|
if (marker !== null || context.surface !== "cli") throw new CommsError("LOOSENING_REFUSED", `only a person ${what}`, {
|
|
2997
|
-
hint: `Ask the person to run
|
|
2997
|
+
hint: `Ask the person to run ${inlineCommand(command)} in their own terminal.`,
|
|
2998
2998
|
...marker === null ? {} : { details: { marker } }
|
|
2999
2999
|
});
|
|
3000
3000
|
}
|
|
3001
3001
|
async function addAccount(context, request) {
|
|
3002
|
-
refuseAnAgent(context,
|
|
3002
|
+
refuseAnAgent(context, shellCommand([
|
|
3003
|
+
"agent-whatsapp",
|
|
3004
|
+
"add",
|
|
3005
|
+
request.name,
|
|
3006
|
+
...request.source === void 0 ? [] : ["--source", request.source]
|
|
3007
|
+
], context.platform), "chooses which WhatsApp store an agent may read");
|
|
3003
3008
|
const problem = nameShapeProblem(request.name, PLATFORM);
|
|
3004
3009
|
if (problem) throw new CommsError("USAGE", problem);
|
|
3005
3010
|
const isDefault = request.source === void 0;
|
|
@@ -3013,7 +3018,11 @@ async function addAccount(context, request) {
|
|
|
3013
3018
|
const refuseTaken = (config) => {
|
|
3014
3019
|
requireNamedConfig(config);
|
|
3015
3020
|
const free = nameAvailable(config, "account", request.name, PLATFORM);
|
|
3016
|
-
if (!free.ok) throw lookupName(config, "account", request.name)?.platform === "whatsapp" ? new CommsError("USAGE", `"${request.name}" is already added`, { hint: `Remove it first with
|
|
3021
|
+
if (!free.ok) throw lookupName(config, "account", request.name)?.platform === "whatsapp" ? new CommsError("USAGE", `"${request.name}" is already added`, { hint: `Remove it first with ${inlineCommand(shellCommand([
|
|
3022
|
+
"agent-whatsapp",
|
|
3023
|
+
"remove",
|
|
3024
|
+
request.name
|
|
3025
|
+
], context.platform))} to point it at another store.` }) : free.error;
|
|
3017
3026
|
for (const [name, other] of Object.entries(config.accounts)) {
|
|
3018
3027
|
if (other.platform !== "whatsapp") continue;
|
|
3019
3028
|
if (context.storeOf(other).path === path || other.workspace === identity.workspace) throw new CommsError("USAGE", `that store is already added, as "${name}"`, { hint: "One name per message store." });
|
|
@@ -3043,7 +3052,12 @@ async function addAccount(context, request) {
|
|
|
3043
3052
|
default: isDefault,
|
|
3044
3053
|
id: account.workspace
|
|
3045
3054
|
},
|
|
3046
|
-
next:
|
|
3055
|
+
next: commandText(shellCommand([
|
|
3056
|
+
"agent-whatsapp",
|
|
3057
|
+
"sync",
|
|
3058
|
+
"--account",
|
|
3059
|
+
request.name
|
|
3060
|
+
], context.platform))
|
|
3047
3061
|
};
|
|
3048
3062
|
}
|
|
3049
3063
|
/**
|
|
@@ -3052,8 +3066,12 @@ async function addAccount(context, request) {
|
|
|
3052
3066
|
*/
|
|
3053
3067
|
const REMOVE_WAIT_MS = 6e4;
|
|
3054
3068
|
async function removeAccount(context, request) {
|
|
3055
|
-
refuseAnAgent(context,
|
|
3056
|
-
|
|
3069
|
+
refuseAnAgent(context, shellCommand([
|
|
3070
|
+
"agent-whatsapp",
|
|
3071
|
+
"remove",
|
|
3072
|
+
request.name
|
|
3073
|
+
], context.platform), "removes a WhatsApp account");
|
|
3074
|
+
const { account } = requireAccount(await context.config(), request.name, context.platform);
|
|
3057
3075
|
await withFileLock(context.syncLock(account), async () => {
|
|
3058
3076
|
await context.core.config.update((config) => {
|
|
3059
3077
|
if (lookupName(config, "account", request.name)?.id !== account.id) throw new CommsError("TRANSIENT", `"${request.name}" changed while it was being removed`, { hint: "Nothing was removed. Run it again." });
|
|
@@ -3097,7 +3115,7 @@ function effectOf(lists) {
|
|
|
3097
3115
|
async function named(context, name, account, chatId) {
|
|
3098
3116
|
let index;
|
|
3099
3117
|
try {
|
|
3100
|
-
index = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(void 0));
|
|
3118
|
+
index = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(void 0), context.platform);
|
|
3101
3119
|
} catch (error) {
|
|
3102
3120
|
if (!isCommsError(error) || error.details?.reason !== "NOT_SYNCED") throw error;
|
|
3103
3121
|
return {
|
|
@@ -3118,7 +3136,12 @@ async function named(context, name, account, chatId) {
|
|
|
3118
3136
|
}
|
|
3119
3137
|
/** The one path every list change takes: the person check first, before any input is read. */
|
|
3120
3138
|
async function change(context, command, request, next) {
|
|
3121
|
-
refuseAnAgent(context,
|
|
3139
|
+
refuseAnAgent(context, shellCommand([
|
|
3140
|
+
"agent-whatsapp",
|
|
3141
|
+
command,
|
|
3142
|
+
...request.chat === void 0 ? [] : [request.chat],
|
|
3143
|
+
...request.account === void 0 ? [] : ["--account", request.account]
|
|
3144
|
+
], context.platform), "changes which chats an agent may see");
|
|
3122
3145
|
const chatId = request.chat === void 0 ? void 0 : chatRefOf(request.chat, { international: true }).id;
|
|
3123
3146
|
const { name, account } = await context.account(request.account);
|
|
3124
3147
|
const { before, after } = await context.lists.update(account.id, (lists) => next(lists, chatId));
|
|
@@ -3128,7 +3151,12 @@ async function change(context, command, request, next) {
|
|
|
3128
3151
|
deny: [...after.deny],
|
|
3129
3152
|
changed: JSON.stringify(before) !== JSON.stringify(after),
|
|
3130
3153
|
effect: effectOf(after),
|
|
3131
|
-
next:
|
|
3154
|
+
next: commandText(shellCommand([
|
|
3155
|
+
"agent-whatsapp",
|
|
3156
|
+
"sync",
|
|
3157
|
+
"--account",
|
|
3158
|
+
name
|
|
3159
|
+
], context.platform)),
|
|
3132
3160
|
...chatId === void 0 ? {} : await named(context, name, account, chatId)
|
|
3133
3161
|
};
|
|
3134
3162
|
}
|
|
@@ -3215,7 +3243,7 @@ function renderAdded(result, color) {
|
|
|
3215
3243
|
function renderRemoved(result, color) {
|
|
3216
3244
|
return `${paint(color, "green", "Removed")} ${result.account}, and its local index. WhatsApp's own store was not touched.`;
|
|
3217
3245
|
}
|
|
3218
|
-
function renderStatus(result, color) {
|
|
3246
|
+
function renderStatus(result, color, platform = process.platform) {
|
|
3219
3247
|
const lines = [];
|
|
3220
3248
|
if (result.accounts.length === 0) lines.push(`No WhatsApp account yet. ${result.setup ?? ""}`);
|
|
3221
3249
|
for (const account of result.accounts) {
|
|
@@ -3229,7 +3257,12 @@ function renderStatus(result, color) {
|
|
|
3229
3257
|
lines.push(` index ${account.index.chats} chats, ${account.index.messages} messages, ${account.index.media} with media, ${account.index.statusChats} status-update chat(s) · synced ${account.index.indexedAt}`);
|
|
3230
3258
|
if (account.index.unattributedStatus > 0) lines.push(` ${unattributed(account.index.unattributedStatus)}`);
|
|
3231
3259
|
for (const entry of account.index.degraded) lines.push(paint(color, "dim", ` without ${entry.part}: ${entry.costs}`));
|
|
3232
|
-
} else lines.push(` index not synced yet —
|
|
3260
|
+
} else lines.push(` index not synced yet — ${commandText(shellCommand([
|
|
3261
|
+
"agent-whatsapp",
|
|
3262
|
+
"sync",
|
|
3263
|
+
"--account",
|
|
3264
|
+
account.account
|
|
3265
|
+
], platform))}`);
|
|
3233
3266
|
}
|
|
3234
3267
|
lines.push("", paint(color, "dim", `Reads: ${result.reads}.`), paint(color, "dim", `Sends: ${result.sends}.`));
|
|
3235
3268
|
return lines.join("\n");
|
|
@@ -3338,6 +3371,7 @@ async function run(argv, deps = {}) {
|
|
|
3338
3371
|
stdin: process.stdin
|
|
3339
3372
|
};
|
|
3340
3373
|
const env = deps.env ?? process.env;
|
|
3374
|
+
const platform = deps.platform ?? process.platform;
|
|
3341
3375
|
const open = deps.open ?? openLink;
|
|
3342
3376
|
const program = new Command();
|
|
3343
3377
|
let exitCode = 0;
|
|
@@ -3365,7 +3399,8 @@ a macOS dialog may be waiting) · 77 permission needed (macOS privacy) · 78 con
|
|
|
3365
3399
|
const options = program.opts();
|
|
3366
3400
|
return {
|
|
3367
3401
|
json: Boolean(options.json),
|
|
3368
|
-
color: colorEnabled(env, streams.stdout, options.color)
|
|
3402
|
+
color: colorEnabled(env, streams.stdout, options.color),
|
|
3403
|
+
platform
|
|
3369
3404
|
};
|
|
3370
3405
|
};
|
|
3371
3406
|
/**
|
|
@@ -3451,7 +3486,7 @@ a macOS dialog may be waiting) · 77 permission needed (macOS privacy) · 78 con
|
|
|
3451
3486
|
account: flags.account === void 0 ? void 0 : String(flags.account),
|
|
3452
3487
|
check: flags.check !== false
|
|
3453
3488
|
});
|
|
3454
|
-
writeResult(result, options, () => renderStatus(result, options.color), streams);
|
|
3489
|
+
writeResult(result, options, () => renderStatus(result, options.color, context.platform), streams);
|
|
3455
3490
|
}));
|
|
3456
3491
|
accountOption(program.command("sync")).description("copy WhatsApp's store privately, check it, and rebuild the local index from the copy").action(act(async (context, options, flags) => {
|
|
3457
3492
|
const result = await syncAccount(context, { account: String(flags.account) });
|
|
@@ -3509,16 +3544,25 @@ a macOS dialog may be waiting) · 77 permission needed (macOS privacy) · 78 con
|
|
|
3509
3544
|
program.command("approve <approvalId>").description("approve a change at this terminal — registering or pruning this server — by reading it and typing the code back; WhatsApp never sends, so there is no message to approve").action(act(async (context, options, approvalId) => {
|
|
3510
3545
|
const marker = agentMarker(env);
|
|
3511
3546
|
if (marker) throw new CommsError("APPROVAL_REQUIRED", "only a person can approve a change, not an agent", {
|
|
3512
|
-
hint: `Ask the person to run
|
|
3547
|
+
hint: `Ask the person to run ${inlineCommand(shellCommand([
|
|
3548
|
+
"agent-whatsapp",
|
|
3549
|
+
"approve",
|
|
3550
|
+
approvalId
|
|
3551
|
+
], context.platform))} in their own terminal.`,
|
|
3513
3552
|
details: { marker }
|
|
3514
3553
|
});
|
|
3515
|
-
if (!canPrompt(env, streams, { json: options.json })) throw new CommsError("APPROVAL_REQUIRED", "approving a change needs an interactive terminal", { hint: `Run
|
|
3554
|
+
if (!canPrompt(env, streams, { json: options.json })) throw new CommsError("APPROVAL_REQUIRED", "approving a change needs an interactive terminal", { hint: `Run ${inlineCommand(shellCommand([
|
|
3555
|
+
"agent-whatsapp",
|
|
3556
|
+
"approve",
|
|
3557
|
+
approvalId
|
|
3558
|
+
], context.platform))} directly in a terminal.` });
|
|
3516
3559
|
const pending = await context.core.approvals.get(approvalId);
|
|
3517
3560
|
if (!pending) throw new CommsError("NOT_FOUND", `no approval ${approvalId}`, { hint: "It may have expired, been used, or been cancelled. Prepare the change again." });
|
|
3518
3561
|
if (approvalKind(pending) !== "change") throw new CommsError("USAGE", `approval ${approvalId} is for a send, and WhatsApp never sends`, { hint: `Approve it with the command that prepared it — ${sendApproveCommands()} — not this one.` });
|
|
3519
3562
|
const outcome = await approveChangeAtTerminal(context.core, approvalId, env, {
|
|
3520
3563
|
json: options.json,
|
|
3521
|
-
color: options.color
|
|
3564
|
+
color: options.color,
|
|
3565
|
+
platform: context.platform
|
|
3522
3566
|
}, streams);
|
|
3523
3567
|
streams.stdout.write(outcome.state === "approved" ? "Approved. This command approves; the change is applied by the command that prepared it.\n" : "Cancelled. Nothing was changed.\n");
|
|
3524
3568
|
}));
|
|
@@ -3534,9 +3578,9 @@ a macOS dialog may be waiting) · 77 permission needed (macOS privacy) · 78 con
|
|
|
3534
3578
|
ran = true;
|
|
3535
3579
|
exitCode = await runCommand(output(), async () => {
|
|
3536
3580
|
requireSupportedNode();
|
|
3537
|
-
|
|
3538
|
-
await startWhatsAppStdioServer({
|
|
3581
|
+
await (deps.startMcp ?? (await import("./stdio-entry-CAM7LpPv.mjs")).startWhatsAppStdioServer)({
|
|
3539
3582
|
env,
|
|
3583
|
+
platform,
|
|
3540
3584
|
...flags.account ? { account: String(flags.account) } : {}
|
|
3541
3585
|
});
|
|
3542
3586
|
}, streams);
|
|
@@ -3559,7 +3603,7 @@ a macOS dialog may be waiting) · 77 permission needed (macOS privacy) · 78 con
|
|
|
3559
3603
|
const launcher = flags.launcher;
|
|
3560
3604
|
const name = flags.name;
|
|
3561
3605
|
await context.migration();
|
|
3562
|
-
const again = [
|
|
3606
|
+
const again = shellCommand([
|
|
3563
3607
|
"agent-whatsapp",
|
|
3564
3608
|
"mcp",
|
|
3565
3609
|
"install",
|
|
@@ -3570,7 +3614,7 @@ a macOS dialog may be waiting) · 77 permission needed (macOS privacy) · 78 con
|
|
|
3570
3614
|
...launcher !== void 0 ? ["--launcher", launcher] : [],
|
|
3571
3615
|
...flags.verify === false ? ["--no-verify"] : [],
|
|
3572
3616
|
...flags.force === true ? ["--force"] : []
|
|
3573
|
-
].
|
|
3617
|
+
], context.platform);
|
|
3574
3618
|
const result = await changeAt(context, serverInstallChange(context.core, env, {
|
|
3575
3619
|
channel: "whatsapp",
|
|
3576
3620
|
client: flags.client,
|
|
@@ -3590,7 +3634,12 @@ a macOS dialog may be waiting) · 77 permission needed (macOS privacy) · 78 con
|
|
|
3590
3634
|
channel: "whatsapp",
|
|
3591
3635
|
dryRun: flags.dryRun === true,
|
|
3592
3636
|
includePrinted: flags.includePrinted === true
|
|
3593
|
-
}, WHATSAPP_MCP), flags,
|
|
3637
|
+
}, WHATSAPP_MCP), flags, shellCommand([
|
|
3638
|
+
"agent-whatsapp",
|
|
3639
|
+
"mcp",
|
|
3640
|
+
"prune",
|
|
3641
|
+
...flags.includePrinted === true ? ["--include-printed"] : []
|
|
3642
|
+
], context.platform));
|
|
3594
3643
|
writeResult(result, output(), () => renderPrune(result, options.color), streams);
|
|
3595
3644
|
}));
|
|
3596
3645
|
try {
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { t as createWhatsAppMcpServer } from "./server-
|
|
1
|
+
import { pt as VERSION, u as composeDraft } from "./sync-DJlsr6xq.mjs";
|
|
2
|
+
import { t as createWhatsAppMcpServer } from "./server-B1EmZ6FZ.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, m as WhatsAppContext, n as whatsappStatus, o as readChat, ot as
|
|
1
|
+
import { D as strictToolArguments, E as UNTRUSTED_NOTICE, a as listChats, d as draftMessage, i as CHAT_KINDS, lt as updateToolGate, m as WhatsAppContext, n as whatsappStatus, o as readChat, ot as toCommsError, pt as VERSION, s as searchMessages, t as syncAccount } from "./sync-DJlsr6xq.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-B1EmZ6FZ.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
|
|
@@ -4,16 +4,16 @@ import path, { basename, delimiter, dirname, isAbsolute, join, resolve, sep } fr
|
|
|
4
4
|
import { domainToASCII, fileURLToPath, pathToFileURL } from "node:url";
|
|
5
5
|
import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
|
|
6
6
|
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
7
|
+
import { styleText } from "node:util";
|
|
7
8
|
import { homedir, platform, userInfo } from "node:os";
|
|
8
9
|
import { constants as constants$1 } from "node:fs";
|
|
9
|
-
import { styleText } from "node:util";
|
|
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$1 = "0.12.
|
|
14
|
+
const VERSION$1 = "0.12.3";
|
|
15
15
|
//#endregion
|
|
16
|
-
//#region ../core/dist/update-check-
|
|
16
|
+
//#region ../core/dist/update-check-Dq-D4bAD.mjs
|
|
17
17
|
/**
|
|
18
18
|
* Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
|
|
19
19
|
* optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
|
|
@@ -966,6 +966,253 @@ function connectMailboxCommand() {
|
|
|
966
966
|
const mail = accountChannels().find((manifest) => manifest.accounts?.map === "inboxes");
|
|
967
967
|
return mail ? `${mail.binary} inbox add` : void 0;
|
|
968
968
|
}
|
|
969
|
+
/** Crockford-style alphabet without I, L, O and U, so ids and challenges survive being read aloud or retyped. */
|
|
970
|
+
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
971
|
+
function randomString(length) {
|
|
972
|
+
const bytes = randomBytes(length);
|
|
973
|
+
let out = "";
|
|
974
|
+
for (let i = 0; i < length; i += 1) out += ALPHABET[(bytes[i] ?? 0) & 31];
|
|
975
|
+
return out;
|
|
976
|
+
}
|
|
977
|
+
/** `ap_` + 26 characters: 130 random bits. Validated before an id ever names a file. */
|
|
978
|
+
const APPROVAL_ID_PATTERN = /^ap_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
979
|
+
const PLAN_TOKEN_PATTERN = /^pl_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
980
|
+
function newApprovalId() {
|
|
981
|
+
return `ap_${randomString(26)}`;
|
|
982
|
+
}
|
|
983
|
+
function newPlanToken() {
|
|
984
|
+
return `pl_${randomString(26)}`;
|
|
985
|
+
}
|
|
986
|
+
const CHALLENGE_LETTERS = "ABCDEFGHJKMNPQRSTVWXYZ";
|
|
987
|
+
/** A short challenge a human types back to approve: letters only, no look-alikes. */
|
|
988
|
+
function newChallenge(length = 4) {
|
|
989
|
+
let out = "";
|
|
990
|
+
for (let i = 0; i < length; i += 1) out += CHALLENGE_LETTERS[randomInt(22)];
|
|
991
|
+
return out;
|
|
992
|
+
}
|
|
993
|
+
/** Challenges are stored only as hashes, so no listing or tool result can reveal one. */
|
|
994
|
+
function hashChallenge(challenge) {
|
|
995
|
+
return createHash("sha256").update(challenge.trim().toUpperCase()).digest("hex");
|
|
996
|
+
}
|
|
997
|
+
/** Case-insensitive, constant-time comparison of an answer with a stored challenge hash. */
|
|
998
|
+
function challengeMatches(answer, storedHash) {
|
|
999
|
+
const a = Buffer.from(hashChallenge(answer), "hex");
|
|
1000
|
+
const b = Buffer.from(storedHash, "hex");
|
|
1001
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
1002
|
+
}
|
|
1003
|
+
function okEnvelope(data) {
|
|
1004
|
+
return {
|
|
1005
|
+
ok: true,
|
|
1006
|
+
schemaVersion: 1,
|
|
1007
|
+
data
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
function errorEnvelope(error) {
|
|
1011
|
+
const body = {
|
|
1012
|
+
code: error.code,
|
|
1013
|
+
message: error.message
|
|
1014
|
+
};
|
|
1015
|
+
if (error.hint !== void 0) body.hint = error.hint;
|
|
1016
|
+
if (error.details !== void 0) body.details = error.details;
|
|
1017
|
+
return {
|
|
1018
|
+
ok: false,
|
|
1019
|
+
schemaVersion: 1,
|
|
1020
|
+
error: body
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
const defaultStreams = {
|
|
1024
|
+
stdout: process.stdout,
|
|
1025
|
+
stderr: process.stderr,
|
|
1026
|
+
stdin: process.stdin
|
|
1027
|
+
};
|
|
1028
|
+
function colorEnabled(env, stream, flag) {
|
|
1029
|
+
if (flag === false) return false;
|
|
1030
|
+
if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
|
|
1031
|
+
if (env.TERM === "dumb") return false;
|
|
1032
|
+
if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
|
|
1033
|
+
return Boolean(stream.isTTY);
|
|
1034
|
+
}
|
|
1035
|
+
/** True when a human could answer a prompt: both ends are terminals, no --json/--no-input, not CI. */
|
|
1036
|
+
function canPrompt(env, streams, options) {
|
|
1037
|
+
if (options.json || options.noInput) return false;
|
|
1038
|
+
if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
|
|
1039
|
+
return Boolean(streams.stdin?.isTTY && streams.stdout.isTTY);
|
|
1040
|
+
}
|
|
1041
|
+
/** Environment variables well-known coding agents set. Used only as a speed bump, never as a security boundary. */
|
|
1042
|
+
const AGENT_MARKERS = [
|
|
1043
|
+
"CLAUDECODE",
|
|
1044
|
+
"CLAUDE_CODE_ENTRYPOINT",
|
|
1045
|
+
"CODEX_SANDBOX",
|
|
1046
|
+
"CODEX_HOME",
|
|
1047
|
+
"CURSOR_AGENT",
|
|
1048
|
+
"GEMINI_CLI",
|
|
1049
|
+
"AGENT_COMMS_AGENT"
|
|
1050
|
+
];
|
|
1051
|
+
function agentMarker(env) {
|
|
1052
|
+
for (const name of AGENT_MARKERS) if (env[name] !== void 0 && env[name] !== "") return name;
|
|
1053
|
+
return null;
|
|
1054
|
+
}
|
|
1055
|
+
function paint(color, format, text) {
|
|
1056
|
+
return color ? styleText(format, text, { validateStream: false }) : text;
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* A command line for a person to copy and run, each word quoted only where the shell it is pasted into would need it.
|
|
1060
|
+
* Every command this package prints to be run — a change to run again with its approval, a folder to take out, an
|
|
1061
|
+
* entry to register again or remove — is quoted here, so no printer quotes for a shell of its own.
|
|
1062
|
+
*
|
|
1063
|
+
* Everywhere but Windows that shell is a POSIX one, and a word goes in single quotes, inside which nothing is special
|
|
1064
|
+
* but the quote itself. Windows has two shells, and neither reads single quotes that way: cmd.exe does not take them
|
|
1065
|
+
* as quotes at all, so `'C:\Profiles\First Last\outgoing'` reached the command as two words, quote marks and all, and
|
|
1066
|
+
* PowerShell does, but escapes a quote inside them by doubling it rather than as `'\''`. A command printed on Windows
|
|
1067
|
+
* has to be safe in both, because nothing says which one it will be pasted into (CUE-306) — and safe is not enough:
|
|
1068
|
+
* the program has to receive the same words from either. Three readers stand between the line and the program. cmd.exe
|
|
1069
|
+
* hands the program the line as it is, and the program's own parser (the C runtime's, Node's) splits it. PowerShell
|
|
1070
|
+
* reads the line itself, a double-quoted word with its backslashes as plain characters, and then writes a new command
|
|
1071
|
+
* line for the program: Windows PowerShell 5.1 the old way ("Legacy" in about_Parsing, "Passing arguments to native
|
|
1072
|
+
* applications") — a word quoted only when it holds whitespace, as it is, and an empty word dropped — and PowerShell
|
|
1073
|
+
* 7.3 and later its own way ("Standard") — quoted when it must be, with every backslash before a quote doubled. For a
|
|
1074
|
+
* `.cmd` script, which is how npm installs `agentcomms`, `claude` and `codex` on Windows, PowerShell 7 goes back to the
|
|
1075
|
+
* old way, and cmd.exe then reads that new line. So on Windows:
|
|
1076
|
+
*
|
|
1077
|
+
* - A word of letters, digits and `_ + = : . / \`, with `@` and `-` anywhere but first, is left as it is: no reader
|
|
1078
|
+
* does anything with it, and a backslash is an ordinary character to all of them, at the end too. A first `@` is
|
|
1079
|
+
* splatting to PowerShell, and a `,` its array operator — two words — so a word with either is quoted. So is a word
|
|
1080
|
+
* that starts with a digit, which PowerShell may read as a number (`1kb`, `0x10`), and one that starts with `-` but
|
|
1081
|
+
* is not a plain option, which PowerShell may read as a parameter of its own and split (`-name.x`).
|
|
1082
|
+
* - A word that double quotes bring through all three readers whole goes in double quotes. Inside them cmd.exe reads
|
|
1083
|
+
* `& | < > ^ ( )` and spaces as ordinary characters, and PowerShell reads everything as ordinary but `$`, the
|
|
1084
|
+
* backtick and a double quote. What is left special in one or the other is kept out: a double quote, which ends the
|
|
1085
|
+
* quoting in both — and PowerShell takes the curly ones, `“ ” „`, for one too; `$` and the backtick, PowerShell's
|
|
1086
|
+
* expansion and escape; `%`, which cmd.exe expands as `%NAME%` before it looks at quotes at all; `!`, which it
|
|
1087
|
+
* expands as `!NAME!` inside quotes too wherever delayed expansion is on, and which then makes a `^` inside quotes an
|
|
1088
|
+
* escape; and any control or formatting character — a line break ends the command in cmd.exe even inside quotes, a
|
|
1089
|
+
* tab pasted into cmd.exe can complete a file name, and a right-to-left override shows a line other than the one
|
|
1090
|
+
* that runs. Three more cannot come through: an empty word, which Windows PowerShell drops; a word ending in a
|
|
1091
|
+
* backslash, which the C runtime reads with the closing quote as `\"` — and doubled for it, PowerShell 7 passes both
|
|
1092
|
+
* backslashes on where cmd.exe and Windows PowerShell pass one; and a word with `& | < > ^ ( )` but no whitespace,
|
|
1093
|
+
* which PowerShell passes to a `.cmd` script unquoted, for cmd.exe to run `&whoami` from or to split at `|`.
|
|
1094
|
+
* - With any other word in it, no line is printed at all (`line` is null), and the printers show the command's words
|
|
1095
|
+
* as JSON instead, saying it has to be typed. Not a line with the word left out: a placeholder in its place was
|
|
1096
|
+
* still a command that ran — `claude mcp remove NAME` removed whatever entry was called `NAME`, and an install
|
|
1097
|
+
* hint's `--force` replaced it — and before that, a word quoted for PowerShell's single quotes, which cmd.exe reads
|
|
1098
|
+
* as characters, ran `whoami` from a server named `$x&whoami&`. See `commandAsJson` for why the JSON runs nothing.
|
|
1099
|
+
*
|
|
1100
|
+
* Everywhere else there is always a line: single quotes make any word safe.
|
|
1101
|
+
*/
|
|
1102
|
+
function shellCommand(words, platform = process.platform) {
|
|
1103
|
+
if (platform !== "win32") return {
|
|
1104
|
+
words,
|
|
1105
|
+
line: words.map(posixShellWord).join(" "),
|
|
1106
|
+
platform
|
|
1107
|
+
};
|
|
1108
|
+
const printed = words.map(windowsShellWord);
|
|
1109
|
+
return {
|
|
1110
|
+
words,
|
|
1111
|
+
line: printed.includes(null) ? null : printed.join(" "),
|
|
1112
|
+
platform
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
/** The same command with more words at its end — the approval it is to be run again with — for the same shell. */
|
|
1116
|
+
function withWords(command, ...more) {
|
|
1117
|
+
return shellCommand([...command.words, ...more], command.platform);
|
|
1118
|
+
}
|
|
1119
|
+
function posixShellWord(word) {
|
|
1120
|
+
return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* The word as cmd.exe, Windows PowerShell and PowerShell 7 all hand it to a program, or null when no printing of it
|
|
1124
|
+
* does (see `shellCommand`). Bare: a plain option, or ordinary characters that do not start like a number.
|
|
1125
|
+
*/
|
|
1126
|
+
function windowsShellWord(word) {
|
|
1127
|
+
if (/^(?:--?[A-Za-z][A-Za-z0-9-]*|(?!\+?\.?\d)[\w+=:./\\][\w@+=:./\\-]*)$/.test(word)) return word;
|
|
1128
|
+
if (word === "" || word.endsWith("\\")) return null;
|
|
1129
|
+
if (/["$`%!\u201C-\u201E]|[\p{C}\p{Zl}\p{Zp}]/u.test(word)) return null;
|
|
1130
|
+
if (/[&|<>^()]/.test(word) && !/\s/.test(word)) return null;
|
|
1131
|
+
return `"${word}"`;
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* The words of a command as a JSON array, for a command that cannot be printed as a line: something to read, and to
|
|
1135
|
+
* parse, and nothing to run.
|
|
1136
|
+
*
|
|
1137
|
+
* Pasted into either Windows shell by mistake, it runs nothing. PowerShell refuses it before running anything: a `[`
|
|
1138
|
+
* opens a type name, and a quoted string is none. cmd.exe looks for a program called `["agentcomms"` or the like, and
|
|
1139
|
+
* finds none — and nothing in the line is anything else to it: every character it acts on, inside quotes or out (`%`,
|
|
1140
|
+
* `!`), is written as a `\u` escape, and so is every double quote inside a word, so the quotes cmd.exe sees are the
|
|
1141
|
+
* JSON's own, in pairs, and `&`, `|`, `<`, `>`, `^` and the parentheses are only ever inside them, where it reads them
|
|
1142
|
+
* as characters. `$` and the backtick are escaped too, so that PowerShell would expand nothing even if it read on, and
|
|
1143
|
+
* so is everything outside printable ASCII — a line break, a curly quote, a right-to-left override. A backslash is
|
|
1144
|
+
* doubled, as JSON has it.
|
|
1145
|
+
*/
|
|
1146
|
+
function commandAsJson(words) {
|
|
1147
|
+
const word = (text) => {
|
|
1148
|
+
let json = "";
|
|
1149
|
+
for (let index = 0; index < text.length; index++) {
|
|
1150
|
+
const code = text.charCodeAt(index);
|
|
1151
|
+
const char = text[index];
|
|
1152
|
+
if (char === "\\") json += "\\\\";
|
|
1153
|
+
else if (code >= 32 && code <= 126 && !"\"%!$`".includes(char)) json += char;
|
|
1154
|
+
else json += `\\u${code.toString(16).padStart(4, "0")}`;
|
|
1155
|
+
}
|
|
1156
|
+
return `"${json}"`;
|
|
1157
|
+
};
|
|
1158
|
+
return `[${words.map(word).join(",")}]`;
|
|
1159
|
+
}
|
|
1160
|
+
/** What is said beside a command shown as JSON. */
|
|
1161
|
+
const TO_TYPE = "the command's words, written as JSON: one of them cannot be quoted the same way for cmd.exe and for PowerShell, so type the command yourself, with that word quoted for the shell you use";
|
|
1162
|
+
/** A command in backticks, for a sentence — as its words in JSON, saying it has to be typed, when it has no line. */
|
|
1163
|
+
function inlineCommand(command) {
|
|
1164
|
+
return command.line === null ? `\`${commandAsJson(command.words)}\` (${TO_TYPE})` : `\`${command.line}\``;
|
|
1165
|
+
}
|
|
1166
|
+
/** A command as text of its own — a list's line, a field's value — or its words in JSON, saying it has to be typed. */
|
|
1167
|
+
function commandText(command) {
|
|
1168
|
+
return command.line === null ? `${commandAsJson(command.words)} (${TO_TYPE})` : command.line;
|
|
1169
|
+
}
|
|
1170
|
+
/** Writes a successful result: the envelope with --json, otherwise the human rendering. */
|
|
1171
|
+
function writeResult(data, options, human, streams = defaultStreams) {
|
|
1172
|
+
if (options.json) {
|
|
1173
|
+
streams.stdout.write(`${JSON.stringify(okEnvelope(data))}\n`);
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
const text = human(data);
|
|
1177
|
+
if (text) streams.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
1178
|
+
}
|
|
1179
|
+
/** Writes an error and returns the exit code to use. */
|
|
1180
|
+
function writeError(error, options, streams = defaultStreams) {
|
|
1181
|
+
const commsError = toCommsError(error);
|
|
1182
|
+
if (options.json) streams.stdout.write(`${JSON.stringify(errorEnvelope(commsError))}\n`);
|
|
1183
|
+
else {
|
|
1184
|
+
streams.stderr.write(`${paint(options.color, "red", "error")}: ${commsError.message}\n`);
|
|
1185
|
+
if (commsError.hint) streams.stderr.write(`${paint(options.color, "dim", "hint")}: ${commsError.hint}\n`);
|
|
1186
|
+
}
|
|
1187
|
+
return commsError.exitCode;
|
|
1188
|
+
}
|
|
1189
|
+
/** Runs a command body and exits with the documented code. Unexpected errors keep only their message. */
|
|
1190
|
+
async function runCommand(options, body, streams = defaultStreams) {
|
|
1191
|
+
try {
|
|
1192
|
+
await body();
|
|
1193
|
+
return EXIT_CODES.OK;
|
|
1194
|
+
} catch (error) {
|
|
1195
|
+
return writeError(error, options, streams);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* The first two steps of `requirePerson` — an agent is refused, then anything without a terminal — for a command
|
|
1200
|
+
* whose code is not its own to make up.
|
|
1201
|
+
*
|
|
1202
|
+
* `agentcomms approve` asks for a code the approval store issued and will check, so it cannot use `askChallenge`;
|
|
1203
|
+
* but it refuses in exactly the same order and words, because it is the same gate.
|
|
1204
|
+
*/
|
|
1205
|
+
function refuseUnlessPerson(env, streams, gate) {
|
|
1206
|
+
const marker = agentMarker(env);
|
|
1207
|
+
if (marker) throw new CommsError("LOOSENING_REFUSED", gate.refusedToAgent, {
|
|
1208
|
+
hint: `Ask the user to run \`${gate.command}\` in their own terminal.`,
|
|
1209
|
+
details: { marker }
|
|
1210
|
+
});
|
|
1211
|
+
const prompting = {};
|
|
1212
|
+
if (gate.json !== void 0) prompting.json = gate.json;
|
|
1213
|
+
if (gate.noInput !== void 0) prompting.noInput = gate.noInput;
|
|
1214
|
+
if (!canPrompt(env, streams, prompting)) throw new CommsError("LOOSENING_REFUSED", gate.refusedWithoutTerminal, { hint: `Run \`${gate.command}\` directly in a terminal.` });
|
|
1215
|
+
}
|
|
969
1216
|
/**
|
|
970
1217
|
* One table of dangerous characters, shared by the inbound sanitiser (which strips them from sender-controlled text)
|
|
971
1218
|
* and the preview renderer (which makes them visible), so the two can never disagree.
|
|
@@ -1967,40 +2214,6 @@ function canonicalLoosening(loosening) {
|
|
|
1967
2214
|
function sameLoosening(a, b) {
|
|
1968
2215
|
return canonicalLoosening(a) === canonicalLoosening(b);
|
|
1969
2216
|
}
|
|
1970
|
-
/** Crockford-style alphabet without I, L, O and U, so ids and challenges survive being read aloud or retyped. */
|
|
1971
|
-
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
1972
|
-
function randomString(length) {
|
|
1973
|
-
const bytes = randomBytes(length);
|
|
1974
|
-
let out = "";
|
|
1975
|
-
for (let i = 0; i < length; i += 1) out += ALPHABET[(bytes[i] ?? 0) & 31];
|
|
1976
|
-
return out;
|
|
1977
|
-
}
|
|
1978
|
-
/** `ap_` + 26 characters: 130 random bits. Validated before an id ever names a file. */
|
|
1979
|
-
const APPROVAL_ID_PATTERN = /^ap_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
1980
|
-
const PLAN_TOKEN_PATTERN = /^pl_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
1981
|
-
function newApprovalId() {
|
|
1982
|
-
return `ap_${randomString(26)}`;
|
|
1983
|
-
}
|
|
1984
|
-
function newPlanToken() {
|
|
1985
|
-
return `pl_${randomString(26)}`;
|
|
1986
|
-
}
|
|
1987
|
-
const CHALLENGE_LETTERS = "ABCDEFGHJKMNPQRSTVWXYZ";
|
|
1988
|
-
/** A short challenge a human types back to approve: letters only, no look-alikes. */
|
|
1989
|
-
function newChallenge(length = 4) {
|
|
1990
|
-
let out = "";
|
|
1991
|
-
for (let i = 0; i < length; i += 1) out += CHALLENGE_LETTERS[randomInt(22)];
|
|
1992
|
-
return out;
|
|
1993
|
-
}
|
|
1994
|
-
/** Challenges are stored only as hashes, so no listing or tool result can reveal one. */
|
|
1995
|
-
function hashChallenge(challenge) {
|
|
1996
|
-
return createHash("sha256").update(challenge.trim().toUpperCase()).digest("hex");
|
|
1997
|
-
}
|
|
1998
|
-
/** Case-insensitive, constant-time comparison of an answer with a stored challenge hash. */
|
|
1999
|
-
function challengeMatches(answer, storedHash) {
|
|
2000
|
-
const a = Buffer.from(hashChallenge(answer), "hex");
|
|
2001
|
-
const b = Buffer.from(storedHash, "hex");
|
|
2002
|
-
return a.length === b.length && timingSafeEqual(a, b);
|
|
2003
|
-
}
|
|
2004
2217
|
/**
|
|
2005
2218
|
* The digest a download's question is bound to: the account, the download, the request, the files it listed and the
|
|
2006
2219
|
* names it showed them under.
|
|
@@ -2289,19 +2502,23 @@ var ApprovalStore = class {
|
|
|
2289
2502
|
* Nothing written, because the caller made a mistake about an approval that may be perfectly good: voiding a post
|
|
2290
2503
|
* somebody is about to approve, because an agent passed its id to a change, would punish the wrong party.
|
|
2291
2504
|
*/
|
|
2292
|
-
#requireKind(record, kind) {
|
|
2505
|
+
#requireKind(record, kind, platform) {
|
|
2293
2506
|
const actual = approvalKind(record);
|
|
2294
2507
|
if (actual === kind) return;
|
|
2295
2508
|
const id = record.approvalId;
|
|
2296
2509
|
if (actual === "download") throw (kind === "change" ? refuseChange : refuse)("USAGE", `${id} is a question about where to save files, not ${kind === "send" ? "a send" : "a configuration change"}`, record, DOWNLOAD_ANSWER_HINT);
|
|
2297
2510
|
if (kind === "download") throw refuseDownload("USAGE", `${id} is ${actual === "change" ? "a configuration change" : "a send"}, not a question about where to save files`, record, "Pass the choice id the download’s question came with, and the person’s answer beside it.");
|
|
2298
|
-
throw kind === "send" ? refuse("USAGE", `approval ${id} is for a configuration change, not a send`, record, `A person approves it with
|
|
2511
|
+
throw kind === "send" ? refuse("USAGE", `approval ${id} is for a configuration change, not a send`, record, `A person approves it with ${inlineCommand(shellCommand([
|
|
2512
|
+
"agentcomms",
|
|
2513
|
+
"approve",
|
|
2514
|
+
id
|
|
2515
|
+
], platform))} — or ${channelApproveCommands()}, whichever is installed — and it permits only the change it was prepared for.`) : refuseChange("USAGE", `approval ${id} is for a send, not a configuration change`, record, `It is approved with the command that prepared it — ${channelApproveCommands({ sending: true })} — and permits only that send.`);
|
|
2299
2516
|
}
|
|
2300
2517
|
/** Issues a new challenge to show a human; only its hash is kept. */
|
|
2301
|
-
async issueChallenge(approvalId, kind = "send") {
|
|
2518
|
+
async issueChallenge(approvalId, kind = "send", platform = process.platform) {
|
|
2302
2519
|
const challenge = newChallenge();
|
|
2303
2520
|
await this.#transition(approvalId, (current) => {
|
|
2304
|
-
this.#requireKind(current, kind);
|
|
2521
|
+
this.#requireKind(current, kind, platform);
|
|
2305
2522
|
if (current.state !== "pending") throw this.#stateError(current);
|
|
2306
2523
|
return {
|
|
2307
2524
|
...current,
|
|
@@ -2318,10 +2535,10 @@ var ApprovalStore = class {
|
|
|
2318
2535
|
* A change is approved the same way, with `kind: 'change'` and its digest standing in for the draft (see
|
|
2319
2536
|
* `createChange`), so a person's typed code means one thing whichever kind of approval it is typed for.
|
|
2320
2537
|
*/
|
|
2321
|
-
async approve(approvalId, via, live, answer, kind = "send") {
|
|
2538
|
+
async approve(approvalId, via, live, answer, kind = "send", platform = process.platform) {
|
|
2322
2539
|
let failure = null;
|
|
2323
2540
|
const result = await this.#transition(approvalId, (current) => {
|
|
2324
|
-
this.#requireKind(current, kind);
|
|
2541
|
+
this.#requireKind(current, kind, platform);
|
|
2325
2542
|
if (current.state !== "pending") throw this.#stateError(current);
|
|
2326
2543
|
if (!current.challengeHash) throw refusalFor(current)("APPROVAL_REQUIRED", "no challenge was issued for this approval", current);
|
|
2327
2544
|
if (live.draftMessageId !== current.draftMessageId || live.digest !== current.digest) {
|
|
@@ -2378,7 +2595,7 @@ var ApprovalStore = class {
|
|
|
2378
2595
|
async claimForSend(approvalId, live, options = {}) {
|
|
2379
2596
|
let failure = null;
|
|
2380
2597
|
const result = await this.#transition(approvalId, (current) => {
|
|
2381
|
-
this.#requireKind(current, "send");
|
|
2598
|
+
this.#requireKind(current, "send", options.platform ?? process.platform);
|
|
2382
2599
|
if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
|
|
2383
2600
|
if (options.signal?.aborted) throw cancelledClaim(current);
|
|
2384
2601
|
const voidWith = (code, reason) => {
|
|
@@ -2489,7 +2706,7 @@ var ApprovalStore = class {
|
|
|
2489
2706
|
const digest = changeDigest(live.change);
|
|
2490
2707
|
let failure = null;
|
|
2491
2708
|
const result = await this.#transition(approvalId, (current) => {
|
|
2492
|
-
this.#requireKind(current, "change");
|
|
2709
|
+
this.#requireKind(current, "change", options.platform ?? process.platform);
|
|
2493
2710
|
if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
|
|
2494
2711
|
if (options.signal?.aborted) throw cancelledClaim(current);
|
|
2495
2712
|
const voidWith = (reason) => {
|
|
@@ -2505,7 +2722,11 @@ var ApprovalStore = class {
|
|
|
2505
2722
|
};
|
|
2506
2723
|
if (digest !== current.digest) return voidWith(current.change ? changeDrift(current.change, live.change) : "the change is not the one that was approved");
|
|
2507
2724
|
if (stricterPolicy(live.policy, current.requiredPolicy) !== "chat") {
|
|
2508
|
-
if (current.state !== "approved") throw refuseChange("APPROVAL_PENDING", "this change needs a person to approve it at a terminal first", current, options.pendingHint ?? `Ask the user to run
|
|
2725
|
+
if (current.state !== "approved") throw refuseChange("APPROVAL_PENDING", "this change needs a person to approve it at a terminal first", current, options.pendingHint ?? `Ask the user to run ${inlineCommand(shellCommand([
|
|
2726
|
+
"agentcomms",
|
|
2727
|
+
"approve",
|
|
2728
|
+
approvalId
|
|
2729
|
+
], options.platform ?? process.platform))} in their own terminal, then try again with the same approval.`);
|
|
2509
2730
|
if (current.approvedVia !== "terminal") return voidWith("the change policy is confirm, and this was not approved at a terminal");
|
|
2510
2731
|
}
|
|
2511
2732
|
return {
|
|
@@ -2582,13 +2803,13 @@ var ApprovalStore = class {
|
|
|
2582
2803
|
*
|
|
2583
2804
|
* Only a pending question can be answered, and only once: a second answer is refused, not taken over the first.
|
|
2584
2805
|
*/
|
|
2585
|
-
async answerDownload(approvalId, via, answer) {
|
|
2806
|
+
async answerDownload(approvalId, via, answer, platform = process.platform) {
|
|
2586
2807
|
const recorded = answer.choice === "other" ? {
|
|
2587
2808
|
choice: "other",
|
|
2588
2809
|
folder: answer.folder
|
|
2589
2810
|
} : { choice: answer.choice };
|
|
2590
2811
|
return await this.#transition(approvalId, (current) => {
|
|
2591
|
-
this.#requireKind(current, "download");
|
|
2812
|
+
this.#requireKind(current, "download", platform);
|
|
2592
2813
|
if (current.state === "approved") throw refuseDownload("APPROVAL_VOID", "the question was answered already, and it is answered once", current);
|
|
2593
2814
|
if (current.state !== "pending") throw this.#stateError(current);
|
|
2594
2815
|
if (!current.download || downloadDigest(current.download) !== current.digest) throw refuseDownload("APPROVAL_VOID", "the question does not describe the download it is bound to", current);
|
|
@@ -2627,7 +2848,7 @@ var ApprovalStore = class {
|
|
|
2627
2848
|
const digest = downloadDigest(live);
|
|
2628
2849
|
let failure = null;
|
|
2629
2850
|
const result = await this.#transition(approvalId, (current) => {
|
|
2630
|
-
this.#requireKind(current, "download");
|
|
2851
|
+
this.#requireKind(current, "download", options.platform ?? process.platform);
|
|
2631
2852
|
if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
|
|
2632
2853
|
if (options.signal?.aborted) throw cancelledClaim(current);
|
|
2633
2854
|
const voidWith = (reason) => {
|
|
@@ -2898,219 +3119,6 @@ function nameAvailable(config, kind, name, platform) {
|
|
|
2898
3119
|
}
|
|
2899
3120
|
return { ok: true };
|
|
2900
3121
|
}
|
|
2901
|
-
function okEnvelope(data) {
|
|
2902
|
-
return {
|
|
2903
|
-
ok: true,
|
|
2904
|
-
schemaVersion: 1,
|
|
2905
|
-
data
|
|
2906
|
-
};
|
|
2907
|
-
}
|
|
2908
|
-
function errorEnvelope(error) {
|
|
2909
|
-
const body = {
|
|
2910
|
-
code: error.code,
|
|
2911
|
-
message: error.message
|
|
2912
|
-
};
|
|
2913
|
-
if (error.hint !== void 0) body.hint = error.hint;
|
|
2914
|
-
if (error.details !== void 0) body.details = error.details;
|
|
2915
|
-
return {
|
|
2916
|
-
ok: false,
|
|
2917
|
-
schemaVersion: 1,
|
|
2918
|
-
error: body
|
|
2919
|
-
};
|
|
2920
|
-
}
|
|
2921
|
-
const defaultStreams = {
|
|
2922
|
-
stdout: process.stdout,
|
|
2923
|
-
stderr: process.stderr,
|
|
2924
|
-
stdin: process.stdin
|
|
2925
|
-
};
|
|
2926
|
-
function colorEnabled(env, stream, flag) {
|
|
2927
|
-
if (flag === false) return false;
|
|
2928
|
-
if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
|
|
2929
|
-
if (env.TERM === "dumb") return false;
|
|
2930
|
-
if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
|
|
2931
|
-
return Boolean(stream.isTTY);
|
|
2932
|
-
}
|
|
2933
|
-
/** True when a human could answer a prompt: both ends are terminals, no --json/--no-input, not CI. */
|
|
2934
|
-
function canPrompt(env, streams, options) {
|
|
2935
|
-
if (options.json || options.noInput) return false;
|
|
2936
|
-
if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
|
|
2937
|
-
return Boolean(streams.stdin?.isTTY && streams.stdout.isTTY);
|
|
2938
|
-
}
|
|
2939
|
-
/** Environment variables well-known coding agents set. Used only as a speed bump, never as a security boundary. */
|
|
2940
|
-
const AGENT_MARKERS = [
|
|
2941
|
-
"CLAUDECODE",
|
|
2942
|
-
"CLAUDE_CODE_ENTRYPOINT",
|
|
2943
|
-
"CODEX_SANDBOX",
|
|
2944
|
-
"CODEX_HOME",
|
|
2945
|
-
"CURSOR_AGENT",
|
|
2946
|
-
"GEMINI_CLI",
|
|
2947
|
-
"AGENT_COMMS_AGENT"
|
|
2948
|
-
];
|
|
2949
|
-
function agentMarker(env) {
|
|
2950
|
-
for (const name of AGENT_MARKERS) if (env[name] !== void 0 && env[name] !== "") return name;
|
|
2951
|
-
return null;
|
|
2952
|
-
}
|
|
2953
|
-
function paint(color, format, text) {
|
|
2954
|
-
return color ? styleText(format, text, { validateStream: false }) : text;
|
|
2955
|
-
}
|
|
2956
|
-
/**
|
|
2957
|
-
* A command line for a person to copy and run, each word quoted only where the shell it is pasted into would need it.
|
|
2958
|
-
* Every command this package prints to be run — a change to run again with its approval, a folder to take out, an
|
|
2959
|
-
* entry to register again or remove — is quoted here, so no printer quotes for a shell of its own.
|
|
2960
|
-
*
|
|
2961
|
-
* Everywhere but Windows that shell is a POSIX one, and a word goes in single quotes, inside which nothing is special
|
|
2962
|
-
* but the quote itself. Windows has two shells, and neither reads single quotes that way: cmd.exe does not take them
|
|
2963
|
-
* as quotes at all, so `'C:\Profiles\First Last\outgoing'` reached the command as two words, quote marks and all, and
|
|
2964
|
-
* PowerShell does, but escapes a quote inside them by doubling it rather than as `'\''`. A command printed on Windows
|
|
2965
|
-
* has to be safe in both, because nothing says which one it will be pasted into (CUE-306) — and safe is not enough:
|
|
2966
|
-
* the program has to receive the same words from either. Three readers stand between the line and the program. cmd.exe
|
|
2967
|
-
* hands the program the line as it is, and the program's own parser (the C runtime's, Node's) splits it. PowerShell
|
|
2968
|
-
* reads the line itself, a double-quoted word with its backslashes as plain characters, and then writes a new command
|
|
2969
|
-
* line for the program: Windows PowerShell 5.1 the old way ("Legacy" in about_Parsing, "Passing arguments to native
|
|
2970
|
-
* applications") — a word quoted only when it holds whitespace, as it is, and an empty word dropped — and PowerShell
|
|
2971
|
-
* 7.3 and later its own way ("Standard") — quoted when it must be, with every backslash before a quote doubled. For a
|
|
2972
|
-
* `.cmd` script, which is how npm installs `agentcomms`, `claude` and `codex` on Windows, PowerShell 7 goes back to the
|
|
2973
|
-
* old way, and cmd.exe then reads that new line. So on Windows:
|
|
2974
|
-
*
|
|
2975
|
-
* - A word of letters, digits and `_ + = : . / \`, with `@` and `-` anywhere but first, is left as it is: no reader
|
|
2976
|
-
* does anything with it, and a backslash is an ordinary character to all of them, at the end too. A first `@` is
|
|
2977
|
-
* splatting to PowerShell, and a `,` its array operator — two words — so a word with either is quoted. So is a word
|
|
2978
|
-
* that starts with a digit, which PowerShell may read as a number (`1kb`, `0x10`), and one that starts with `-` but
|
|
2979
|
-
* is not a plain option, which PowerShell may read as a parameter of its own and split (`-name.x`).
|
|
2980
|
-
* - A word that double quotes bring through all three readers whole goes in double quotes. Inside them cmd.exe reads
|
|
2981
|
-
* `& | < > ^ ( )` and spaces as ordinary characters, and PowerShell reads everything as ordinary but `$`, the
|
|
2982
|
-
* backtick and a double quote. What is left special in one or the other is kept out: a double quote, which ends the
|
|
2983
|
-
* quoting in both — and PowerShell takes the curly ones, `“ ” „`, for one too; `$` and the backtick, PowerShell's
|
|
2984
|
-
* expansion and escape; `%`, which cmd.exe expands as `%NAME%` before it looks at quotes at all; `!`, which it
|
|
2985
|
-
* expands as `!NAME!` inside quotes too wherever delayed expansion is on, and which then makes a `^` inside quotes an
|
|
2986
|
-
* escape; and any control or formatting character — a line break ends the command in cmd.exe even inside quotes, a
|
|
2987
|
-
* tab pasted into cmd.exe can complete a file name, and a right-to-left override shows a line other than the one
|
|
2988
|
-
* that runs. Three more cannot come through: an empty word, which Windows PowerShell drops; a word ending in a
|
|
2989
|
-
* backslash, which the C runtime reads with the closing quote as `\"` — and doubled for it, PowerShell 7 passes both
|
|
2990
|
-
* backslashes on where cmd.exe and Windows PowerShell pass one; and a word with `& | < > ^ ( )` but no whitespace,
|
|
2991
|
-
* which PowerShell passes to a `.cmd` script unquoted, for cmd.exe to run `&whoami` from or to split at `|`.
|
|
2992
|
-
* - With any other word in it, no line is printed at all (`line` is null), and the printers show the command's words
|
|
2993
|
-
* as JSON instead, saying it has to be typed. Not a line with the word left out: a placeholder in its place was
|
|
2994
|
-
* still a command that ran — `claude mcp remove NAME` removed whatever entry was called `NAME`, and an install
|
|
2995
|
-
* hint's `--force` replaced it — and before that, a word quoted for PowerShell's single quotes, which cmd.exe reads
|
|
2996
|
-
* as characters, ran `whoami` from a server named `$x&whoami&`. See `commandAsJson` for why the JSON runs nothing.
|
|
2997
|
-
*
|
|
2998
|
-
* Everywhere else there is always a line: single quotes make any word safe.
|
|
2999
|
-
*/
|
|
3000
|
-
function shellCommand(words, platform = process.platform) {
|
|
3001
|
-
if (platform !== "win32") return {
|
|
3002
|
-
words,
|
|
3003
|
-
line: words.map(posixShellWord).join(" "),
|
|
3004
|
-
platform
|
|
3005
|
-
};
|
|
3006
|
-
const printed = words.map(windowsShellWord);
|
|
3007
|
-
return {
|
|
3008
|
-
words,
|
|
3009
|
-
line: printed.includes(null) ? null : printed.join(" "),
|
|
3010
|
-
platform
|
|
3011
|
-
};
|
|
3012
|
-
}
|
|
3013
|
-
/** The same command with more words at its end — the approval it is to be run again with — for the same shell. */
|
|
3014
|
-
function withWords(command, ...more) {
|
|
3015
|
-
return shellCommand([...command.words, ...more], command.platform);
|
|
3016
|
-
}
|
|
3017
|
-
function posixShellWord(word) {
|
|
3018
|
-
return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
|
|
3019
|
-
}
|
|
3020
|
-
/**
|
|
3021
|
-
* The word as cmd.exe, Windows PowerShell and PowerShell 7 all hand it to a program, or null when no printing of it
|
|
3022
|
-
* does (see `shellCommand`). Bare: a plain option, or ordinary characters that do not start like a number.
|
|
3023
|
-
*/
|
|
3024
|
-
function windowsShellWord(word) {
|
|
3025
|
-
if (/^(?:--?[A-Za-z][A-Za-z0-9-]*|(?!\+?\.?\d)[\w+=:./\\][\w@+=:./\\-]*)$/.test(word)) return word;
|
|
3026
|
-
if (word === "" || word.endsWith("\\")) return null;
|
|
3027
|
-
if (/["$`%!\u201C-\u201E]|[\p{C}\p{Zl}\p{Zp}]/u.test(word)) return null;
|
|
3028
|
-
if (/[&|<>^()]/.test(word) && !/\s/.test(word)) return null;
|
|
3029
|
-
return `"${word}"`;
|
|
3030
|
-
}
|
|
3031
|
-
/**
|
|
3032
|
-
* The words of a command as a JSON array, for a command that cannot be printed as a line: something to read, and to
|
|
3033
|
-
* parse, and nothing to run.
|
|
3034
|
-
*
|
|
3035
|
-
* Pasted into either Windows shell by mistake, it runs nothing. PowerShell refuses it before running anything: a `[`
|
|
3036
|
-
* opens a type name, and a quoted string is none. cmd.exe looks for a program called `["agentcomms"` or the like, and
|
|
3037
|
-
* finds none — and nothing in the line is anything else to it: every character it acts on, inside quotes or out (`%`,
|
|
3038
|
-
* `!`), is written as a `\u` escape, and so is every double quote inside a word, so the quotes cmd.exe sees are the
|
|
3039
|
-
* JSON's own, in pairs, and `&`, `|`, `<`, `>`, `^` and the parentheses are only ever inside them, where it reads them
|
|
3040
|
-
* as characters. `$` and the backtick are escaped too, so that PowerShell would expand nothing even if it read on, and
|
|
3041
|
-
* so is everything outside printable ASCII — a line break, a curly quote, a right-to-left override. A backslash is
|
|
3042
|
-
* doubled, as JSON has it.
|
|
3043
|
-
*/
|
|
3044
|
-
function commandAsJson(words) {
|
|
3045
|
-
const word = (text) => {
|
|
3046
|
-
let json = "";
|
|
3047
|
-
for (let index = 0; index < text.length; index++) {
|
|
3048
|
-
const code = text.charCodeAt(index);
|
|
3049
|
-
const char = text[index];
|
|
3050
|
-
if (char === "\\") json += "\\\\";
|
|
3051
|
-
else if (code >= 32 && code <= 126 && !"\"%!$`".includes(char)) json += char;
|
|
3052
|
-
else json += `\\u${code.toString(16).padStart(4, "0")}`;
|
|
3053
|
-
}
|
|
3054
|
-
return `"${json}"`;
|
|
3055
|
-
};
|
|
3056
|
-
return `[${words.map(word).join(",")}]`;
|
|
3057
|
-
}
|
|
3058
|
-
/** What is said beside a command shown as JSON. */
|
|
3059
|
-
const TO_TYPE = "the command's words, written as JSON: one of them cannot be quoted the same way for cmd.exe and for PowerShell, so type the command yourself, with that word quoted for the shell you use";
|
|
3060
|
-
/** A command in backticks, for a sentence — as its words in JSON, saying it has to be typed, when it has no line. */
|
|
3061
|
-
function inlineCommand(command) {
|
|
3062
|
-
return command.line === null ? `\`${commandAsJson(command.words)}\` (${TO_TYPE})` : `\`${command.line}\``;
|
|
3063
|
-
}
|
|
3064
|
-
/** A command as text of its own — a list's line, a field's value — or its words in JSON, saying it has to be typed. */
|
|
3065
|
-
function commandText(command) {
|
|
3066
|
-
return command.line === null ? `${commandAsJson(command.words)} (${TO_TYPE})` : command.line;
|
|
3067
|
-
}
|
|
3068
|
-
/** Writes a successful result: the envelope with --json, otherwise the human rendering. */
|
|
3069
|
-
function writeResult(data, options, human, streams = defaultStreams) {
|
|
3070
|
-
if (options.json) {
|
|
3071
|
-
streams.stdout.write(`${JSON.stringify(okEnvelope(data))}\n`);
|
|
3072
|
-
return;
|
|
3073
|
-
}
|
|
3074
|
-
const text = human(data);
|
|
3075
|
-
if (text) streams.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
3076
|
-
}
|
|
3077
|
-
/** Writes an error and returns the exit code to use. */
|
|
3078
|
-
function writeError(error, options, streams = defaultStreams) {
|
|
3079
|
-
const commsError = toCommsError(error);
|
|
3080
|
-
if (options.json) streams.stdout.write(`${JSON.stringify(errorEnvelope(commsError))}\n`);
|
|
3081
|
-
else {
|
|
3082
|
-
streams.stderr.write(`${paint(options.color, "red", "error")}: ${commsError.message}\n`);
|
|
3083
|
-
if (commsError.hint) streams.stderr.write(`${paint(options.color, "dim", "hint")}: ${commsError.hint}\n`);
|
|
3084
|
-
}
|
|
3085
|
-
return commsError.exitCode;
|
|
3086
|
-
}
|
|
3087
|
-
/** Runs a command body and exits with the documented code. Unexpected errors keep only their message. */
|
|
3088
|
-
async function runCommand(options, body, streams = defaultStreams) {
|
|
3089
|
-
try {
|
|
3090
|
-
await body();
|
|
3091
|
-
return EXIT_CODES.OK;
|
|
3092
|
-
} catch (error) {
|
|
3093
|
-
return writeError(error, options, streams);
|
|
3094
|
-
}
|
|
3095
|
-
}
|
|
3096
|
-
/**
|
|
3097
|
-
* The first two steps of `requirePerson` — an agent is refused, then anything without a terminal — for a command
|
|
3098
|
-
* whose code is not its own to make up.
|
|
3099
|
-
*
|
|
3100
|
-
* `agentcomms approve` asks for a code the approval store issued and will check, so it cannot use `askChallenge`;
|
|
3101
|
-
* but it refuses in exactly the same order and words, because it is the same gate.
|
|
3102
|
-
*/
|
|
3103
|
-
function refuseUnlessPerson(env, streams, gate) {
|
|
3104
|
-
const marker = agentMarker(env);
|
|
3105
|
-
if (marker) throw new CommsError("LOOSENING_REFUSED", gate.refusedToAgent, {
|
|
3106
|
-
hint: `Ask the user to run \`${gate.command}\` in their own terminal.`,
|
|
3107
|
-
details: { marker }
|
|
3108
|
-
});
|
|
3109
|
-
const prompting = {};
|
|
3110
|
-
if (gate.json !== void 0) prompting.json = gate.json;
|
|
3111
|
-
if (gate.noInput !== void 0) prompting.noInput = gate.noInput;
|
|
3112
|
-
if (!canPrompt(env, streams, prompting)) throw new CommsError("LOOSENING_REFUSED", gate.refusedWithoutTerminal, { hint: `Run \`${gate.command}\` directly in a terminal.` });
|
|
3113
|
-
}
|
|
3114
3122
|
/**
|
|
3115
3123
|
* Enough of TOML to read codex's `config.toml`, and a refusal for anything else.
|
|
3116
3124
|
*
|
|
@@ -4544,7 +4552,7 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
4544
4552
|
};
|
|
4545
4553
|
const existing = scan.servers;
|
|
4546
4554
|
const { cliName, binary, own, configPath, writes } = target;
|
|
4547
|
-
const warnings = (product.warnAbout?.(existing.filter((server) => server.client === options.client)) ?? []).filter(Boolean);
|
|
4555
|
+
const warnings = (product.warnAbout?.(existing.filter((server) => server.client === options.client), context.platform) ?? []).filter(Boolean);
|
|
4548
4556
|
if (kept.length > 0) {
|
|
4549
4557
|
const removal = options.client === "claude-code" ? inlineCommand(shellCommand([
|
|
4550
4558
|
"claude",
|
|
@@ -5050,8 +5058,8 @@ function findRivalPackageServers(servers, packages, platform = process.platform)
|
|
|
5050
5058
|
return findings;
|
|
5051
5059
|
}
|
|
5052
5060
|
/** What registering a server says about the rival packages its manifest names: one line each. */
|
|
5053
|
-
function rivalPackageWarnings(servers, packages) {
|
|
5054
|
-
return findRivalPackageServers(servers, packages).map((finding) => `${finding.packageName} is registered with ${finding.client} as "${finding.name}": ${finding.reason}. Remove it: ${finding.removal}`);
|
|
5061
|
+
function rivalPackageWarnings(servers, packages, platform = process.platform) {
|
|
5062
|
+
return findRivalPackageServers(servers, packages, platform).map((finding) => `${finding.packageName} is registered with ${finding.client} as "${finding.name}": ${finding.reason}. Remove it: ${finding.removal}`);
|
|
5055
5063
|
}
|
|
5056
5064
|
/**
|
|
5057
5065
|
* Registered servers that name the service's `word` anywhere — their name, command, arguments or URL — and are not
|
|
@@ -5137,7 +5145,7 @@ function serverFactsOf({ packageName, manifest }) {
|
|
|
5137
5145
|
if (rivals === void 0) return facts;
|
|
5138
5146
|
return {
|
|
5139
5147
|
...facts,
|
|
5140
|
-
warnAbout: (servers) => [...rivals.packages ? rivalPackageWarnings(servers, rivals.packages) : [], ...rivals.word !== void 0 && rivals.can !== void 0 ? rivalWordWarnings(servers, rivals.word, rivals.can, facts) : []]
|
|
5148
|
+
warnAbout: (servers, platform) => [...rivals.packages ? rivalPackageWarnings(servers, rivals.packages, platform) : [], ...rivals.word !== void 0 && rivals.can !== void 0 ? rivalWordWarnings(servers, rivals.word, rivals.can, facts) : []]
|
|
5141
5149
|
};
|
|
5142
5150
|
}
|
|
5143
5151
|
const CHANNEL_SERVERS = Object.freeze(Object.fromEntries(CHANNEL_SNAPSHOT.map((entry) => [entry.manifest.channel, serverFactsOf(entry)])));
|
|
@@ -5228,9 +5236,9 @@ function renderPrune(result, color) {
|
|
|
5228
5236
|
if (lines.length === 0) lines.push(`No managed runtimes to remove in ${result.runtimeDir}.`);
|
|
5229
5237
|
return lines.join("\n");
|
|
5230
5238
|
}
|
|
5231
|
-
/** The
|
|
5232
|
-
function
|
|
5233
|
-
return
|
|
5239
|
+
/** The channel's fixed approval-command prefix with this generated id, rendered by the common shell rule. */
|
|
5240
|
+
function changeApprovalCommand(approveCommand, approvalId, platform = process.platform) {
|
|
5241
|
+
return shellCommand([...(approveCommand ?? "agentcomms approve").trim().split(/\s+/), approvalId], platform);
|
|
5234
5242
|
}
|
|
5235
5243
|
/**
|
|
5236
5244
|
* The change policy that governs a change, as `config` stands: the strictest of the policies over everything the change
|
|
@@ -5341,7 +5349,7 @@ async function prepareChange(core, request, options) {
|
|
|
5341
5349
|
change: binding
|
|
5342
5350
|
}),
|
|
5343
5351
|
expiresAt: record.expiresAt,
|
|
5344
|
-
next: nextStep(record.approvalId, policy,
|
|
5352
|
+
next: nextStep(record.approvalId, policy, options)
|
|
5345
5353
|
};
|
|
5346
5354
|
} catch (error) {
|
|
5347
5355
|
await auditRefusal(core, "change.prepare", error, {
|
|
@@ -5372,7 +5380,10 @@ async function claimChange(core, approvalId, expect, options) {
|
|
|
5372
5380
|
const record = await core.approvals.claimForChange(approvalId, {
|
|
5373
5381
|
change: binding,
|
|
5374
5382
|
policy
|
|
5375
|
-
}, {
|
|
5383
|
+
}, {
|
|
5384
|
+
pendingHint: `Ask the user to run ${inlineCommand(changeApprovalCommand(options.approveCommand, approvalId, options.platform))} in their own terminal, then try again with the same approval.`,
|
|
5385
|
+
platform: options.platform
|
|
5386
|
+
});
|
|
5376
5387
|
const decided = stricterPolicy(policy, record.requiredPolicy) === "chat" ? "chat" : "confirm";
|
|
5377
5388
|
await auditChange(core, {
|
|
5378
5389
|
operation: "change.claim",
|
|
@@ -5420,7 +5431,7 @@ async function beginChangeApproval(core, approvalId, options) {
|
|
|
5420
5431
|
let record;
|
|
5421
5432
|
try {
|
|
5422
5433
|
record = await changeRecord(core, approvalId);
|
|
5423
|
-
const challenge = await core.approvals.issueChallenge(approvalId, "change");
|
|
5434
|
+
const challenge = await core.approvals.issueChallenge(approvalId, "change", options.platform);
|
|
5424
5435
|
return {
|
|
5425
5436
|
approvalId,
|
|
5426
5437
|
preview: renderChangePreview(record),
|
|
@@ -5445,7 +5456,7 @@ async function finishChangeApproval(core, approvalId, answer, options) {
|
|
|
5445
5456
|
const approved = await core.approvals.approve(approvalId, "terminal", {
|
|
5446
5457
|
draftMessageId: digest,
|
|
5447
5458
|
digest
|
|
5448
|
-
}, answer, "change");
|
|
5459
|
+
}, answer, "change", options.platform);
|
|
5449
5460
|
await auditChange(core, {
|
|
5450
5461
|
operation: "change.approve",
|
|
5451
5462
|
outcome: "ok",
|
|
@@ -5501,8 +5512,8 @@ async function revokeChange(core, approvalId, reason, options) {
|
|
|
5501
5512
|
function changePolicyOf(record) {
|
|
5502
5513
|
return record.requiredPolicy === "chat" ? "chat" : "confirm";
|
|
5503
5514
|
}
|
|
5504
|
-
function nextStep(approvalId, policy,
|
|
5505
|
-
return policy === "chat" ? `Show this preview to the user and ask. If they say yes, claim approval ${approvalId} and apply the change; if not, revoke it.` : `The change policy is confirm: ask the user to run
|
|
5515
|
+
function nextStep(approvalId, policy, options) {
|
|
5516
|
+
return policy === "chat" ? `Show this preview to the user and ask. If they say yes, claim approval ${approvalId} and apply the change; if not, revoke it.` : `The change policy is confirm: ask the user to run ${inlineCommand(changeApprovalCommand(options.approveCommand, approvalId, options.platform))} in their own terminal and type the code it shows. Then claim approval ${approvalId} and apply the change.`;
|
|
5506
5517
|
}
|
|
5507
5518
|
/**
|
|
5508
5519
|
* One line per step of a change approval: who asked, from which surface, under which policy, and how it ended.
|
|
@@ -5651,12 +5662,14 @@ async function gatedChange(core, change, options) {
|
|
|
5651
5662
|
status: "approval-required",
|
|
5652
5663
|
prepared: await prepareChange(core, request, {
|
|
5653
5664
|
surface: options.surface,
|
|
5654
|
-
approveCommand: options.approveCommand
|
|
5665
|
+
approveCommand: options.approveCommand,
|
|
5666
|
+
platform: options.platform
|
|
5655
5667
|
})
|
|
5656
5668
|
};
|
|
5657
5669
|
const consent = await claimChange(core, options.approvalId, request, {
|
|
5658
5670
|
surface: options.surface,
|
|
5659
|
-
approveCommand: options.approveCommand
|
|
5671
|
+
approveCommand: options.approveCommand,
|
|
5672
|
+
platform: options.platform
|
|
5660
5673
|
});
|
|
5661
5674
|
return {
|
|
5662
5675
|
status: "applied",
|
|
@@ -5677,7 +5690,8 @@ async function gatedChangeAtTerminal(core, change, options) {
|
|
|
5677
5690
|
const first = await gatedChange(core, change, {
|
|
5678
5691
|
surface: "cli",
|
|
5679
5692
|
approvalId: options.approvalId,
|
|
5680
|
-
approveCommand
|
|
5693
|
+
approveCommand,
|
|
5694
|
+
platform: options.output.platform
|
|
5681
5695
|
});
|
|
5682
5696
|
if (first.status === "applied") return first.result;
|
|
5683
5697
|
const { prepared } = first;
|
|
@@ -5686,7 +5700,7 @@ async function gatedChangeAtTerminal(core, change, options) {
|
|
|
5686
5700
|
const carrying = [options.approvalFlag ?? "--approval", prepared.approvalId];
|
|
5687
5701
|
const { command } = options;
|
|
5688
5702
|
throw new CommsError("APPROVAL_PENDING", `this change needs approval first: ${prepared.summary}`, {
|
|
5689
|
-
hint: approvalHint(prepared, typeof command === "string" ? `${command} ${carrying.join(" ")}` : withWords(command, ...carrying), approveCommand),
|
|
5703
|
+
hint: approvalHint(prepared, typeof command === "string" ? `${command} ${carrying.join(" ")}` : withWords(command, ...carrying), approveCommand, options.output.platform),
|
|
5690
5704
|
details: {
|
|
5691
5705
|
approvalId: prepared.approvalId,
|
|
5692
5706
|
policy: prepared.policy,
|
|
@@ -5707,7 +5721,8 @@ async function gatedChangeAtTerminal(core, change, options) {
|
|
|
5707
5721
|
const second = await gatedChange(core, change, {
|
|
5708
5722
|
surface: "cli",
|
|
5709
5723
|
approvalId: prepared.approvalId,
|
|
5710
|
-
approveCommand
|
|
5724
|
+
approveCommand,
|
|
5725
|
+
platform: options.output.platform
|
|
5711
5726
|
});
|
|
5712
5727
|
if (second.status !== "applied") throw new CommsError("UNEXPECTED", "the approved change asked for approval again");
|
|
5713
5728
|
return second.result;
|
|
@@ -5719,9 +5734,9 @@ async function gatedChangeAtTerminal(core, change, options) {
|
|
|
5719
5734
|
* Shared with a command that reports a waiting change rather than failing on it — `agent-gmail setup`, which stops at
|
|
5720
5735
|
* its registration step and says why in its report — so the two say it in the same words.
|
|
5721
5736
|
*/
|
|
5722
|
-
function approvalHint(prepared, rerun, approveCommand) {
|
|
5737
|
+
function approvalHint(prepared, rerun, approveCommand, platform = typeof rerun === "string" ? process.platform : rerun.platform) {
|
|
5723
5738
|
const run = typeof rerun === "string" ? `\`${rerun}\`` : inlineCommand(rerun);
|
|
5724
|
-
return prepared.policy === "confirm" ? `Show the person the preview. They run
|
|
5739
|
+
return prepared.policy === "confirm" ? `Show the person the preview. They run ${inlineCommand(changeApprovalCommand(approveCommand, prepared.approvalId, platform))}; then run ${run}.` : `Show the person the preview. Once they say yes, run ${run}.`;
|
|
5725
5740
|
}
|
|
5726
5741
|
/**
|
|
5727
5742
|
* Approves a configuration change at a terminal, the way a person approves a send: read the preview, type the code.
|
|
@@ -5741,7 +5756,11 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
|
|
|
5741
5756
|
refuseUnlessPerson(env, streams, {
|
|
5742
5757
|
refusedToAgent: "only a person can approve a change, not an agent",
|
|
5743
5758
|
refusedWithoutTerminal: "approving a change needs an interactive terminal",
|
|
5744
|
-
command:
|
|
5759
|
+
command: commandText(shellCommand([
|
|
5760
|
+
"agentcomms",
|
|
5761
|
+
"approve",
|
|
5762
|
+
approvalId
|
|
5763
|
+
], output.platform)),
|
|
5745
5764
|
color: output.color,
|
|
5746
5765
|
json: output.json
|
|
5747
5766
|
});
|
|
@@ -6258,7 +6277,7 @@ function isPrerelease(version) {
|
|
|
6258
6277
|
return VERSION_PATTERN.exec(version)?.[4] !== void 0;
|
|
6259
6278
|
}
|
|
6260
6279
|
/** The package version, read from package.json at build time. */
|
|
6261
|
-
const VERSION = "0.12.
|
|
6280
|
+
const VERSION = "0.12.3";
|
|
6262
6281
|
/**
|
|
6263
6282
|
* Registering, pruning and listing the MCP servers of every channel, from the core.
|
|
6264
6283
|
*
|
|
@@ -6516,7 +6535,7 @@ function serverPruneChange(core, env, request, own) {
|
|
|
6516
6535
|
};
|
|
6517
6536
|
const { packageName, version } = ownProduct(request.channel, own) ?? {
|
|
6518
6537
|
packageName: channelServer(request.channel).packageName,
|
|
6519
|
-
version: "0.12.
|
|
6538
|
+
version: "0.12.3"
|
|
6520
6539
|
};
|
|
6521
6540
|
const product = {
|
|
6522
6541
|
packageName,
|
|
@@ -7140,7 +7159,7 @@ async function askLine(streams, question) {
|
|
|
7140
7159
|
}
|
|
7141
7160
|
channelServer("core").packageName;
|
|
7142
7161
|
//#endregion
|
|
7143
|
-
//#region ../core/dist/tool-arguments-
|
|
7162
|
+
//#region ../core/dist/tool-arguments-BMgRZ6DZ.mjs
|
|
7144
7163
|
const APPLIED = Symbol.for("agentcomms.strictToolArguments");
|
|
7145
7164
|
const TARGET = "draft-2020-12";
|
|
7146
7165
|
/**
|
|
@@ -8561,13 +8580,17 @@ function notFound(config, name) {
|
|
|
8561
8580
|
* is not a store path this package would read, is refused rather than acted on: nothing here would know what it
|
|
8562
8581
|
* means, and the vocabulary is closed (design §5).
|
|
8563
8582
|
*/
|
|
8564
|
-
function requireAccount(config, name) {
|
|
8583
|
+
function requireAccount(config, name, platform = process.platform) {
|
|
8565
8584
|
if (name === void 0 || name === "") throw new CommsError("USAGE", "which WhatsApp account? there is no default", { hint: "Pass the account, as `organisation/whatsapp`." });
|
|
8566
8585
|
requireNamedConfig(config);
|
|
8567
8586
|
const { account } = resolveName(config, "account", name, notFound(config, name));
|
|
8568
8587
|
if (account.platform !== "whatsapp") throw notFound(config, name)();
|
|
8569
8588
|
const mode = account.mode ?? account.tier;
|
|
8570
|
-
if (mode !== "read") throw new CommsError("CONFIG", `"${name}" is recorded in mode "${String(mode)}", and WhatsApp accounts only read`, { hint: `Nothing was read. Remove it with
|
|
8589
|
+
if (mode !== "read") throw new CommsError("CONFIG", `"${name}" is recorded in mode "${String(mode)}", and WhatsApp accounts only read`, { hint: `Nothing was read. Remove it with ${inlineCommand(shellCommand([
|
|
8590
|
+
"agent-whatsapp",
|
|
8591
|
+
"remove",
|
|
8592
|
+
name
|
|
8593
|
+
], platform))} and add it again.` });
|
|
8571
8594
|
const source = account.source;
|
|
8572
8595
|
if (source !== void 0 && (typeof source !== "string" || basename(source) !== "ChatStorage.sqlite")) throw new CommsError("CONFIG", `"${name}" names a store this package does not read`, { hint: `Only a file called ${STORE_FILE} is read. Remove the account and add it again.` });
|
|
8573
8596
|
return {
|
|
@@ -9219,13 +9242,18 @@ var WhatsAppIndex = class WhatsAppIndex {
|
|
|
9219
9242
|
this.#db = db;
|
|
9220
9243
|
}
|
|
9221
9244
|
/** Every read opens the index through here, and cannot without the account's lists. */
|
|
9222
|
-
static async open(directory, accountName, visibility) {
|
|
9245
|
+
static async open(directory, accountName, visibility, platform = process.platform) {
|
|
9223
9246
|
const path = join(directory, INDEX_FILE);
|
|
9224
9247
|
try {
|
|
9225
9248
|
await stat(path);
|
|
9226
9249
|
} catch {
|
|
9227
9250
|
throw new CommsError("NOT_FOUND", `"${accountName}" has not been synced yet, so there is nothing to read`, {
|
|
9228
|
-
hint: `Run
|
|
9251
|
+
hint: `Run ${inlineCommand(shellCommand([
|
|
9252
|
+
"agent-whatsapp",
|
|
9253
|
+
"sync",
|
|
9254
|
+
"--account",
|
|
9255
|
+
accountName
|
|
9256
|
+
], platform))} (or the whatsapp_sync tool) first.`,
|
|
9229
9257
|
details: { reason: "NOT_SYNCED" }
|
|
9230
9258
|
});
|
|
9231
9259
|
}
|
|
@@ -9233,7 +9261,12 @@ var WhatsAppIndex = class WhatsAppIndex {
|
|
|
9233
9261
|
const db = await openDatabase(path, { readOnly: true });
|
|
9234
9262
|
if (db.prepare("SELECT value FROM meta WHERE key = 'format'").get()?.value !== String(2)) {
|
|
9235
9263
|
db.close();
|
|
9236
|
-
throw new CommsError("CONFIG", `the index for "${accountName}" was written by another version of this package`, { hint: `Run
|
|
9264
|
+
throw new CommsError("CONFIG", `the index for "${accountName}" was written by another version of this package`, { hint: `Run ${inlineCommand(shellCommand([
|
|
9265
|
+
"agent-whatsapp",
|
|
9266
|
+
"sync",
|
|
9267
|
+
"--account",
|
|
9268
|
+
accountName
|
|
9269
|
+
], platform))} to rebuild it.` });
|
|
9237
9270
|
}
|
|
9238
9271
|
db.function("agent_sees_chat", { deterministic: true }, (id) => visibility.seesChat(String(id)) ? 1 : 0);
|
|
9239
9272
|
db.function("agent_kind", { deterministic: true }, (id) => chatKindOf(String(id)));
|
|
@@ -9701,6 +9734,7 @@ var WhatsAppContext = class {
|
|
|
9701
9734
|
env;
|
|
9702
9735
|
now;
|
|
9703
9736
|
surface;
|
|
9737
|
+
platform;
|
|
9704
9738
|
core;
|
|
9705
9739
|
lists;
|
|
9706
9740
|
/** The name a pinned server was started for, as given. */
|
|
@@ -9713,6 +9747,7 @@ var WhatsAppContext = class {
|
|
|
9713
9747
|
this.env = options.env ?? process.env;
|
|
9714
9748
|
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
9715
9749
|
this.surface = options.surface ?? "cli";
|
|
9750
|
+
this.platform = options.platform ?? process.platform;
|
|
9716
9751
|
this.core = openCore({
|
|
9717
9752
|
env: this.env,
|
|
9718
9753
|
now: this.now
|
|
@@ -9723,7 +9758,7 @@ var WhatsAppContext = class {
|
|
|
9723
9758
|
this.#source = {
|
|
9724
9759
|
io: options.sourceIo,
|
|
9725
9760
|
timeoutMs: options.sourceTimeoutMs,
|
|
9726
|
-
platform:
|
|
9761
|
+
platform: this.platform
|
|
9727
9762
|
};
|
|
9728
9763
|
}
|
|
9729
9764
|
/** The spike's accounts moved in, once per process; what happened, when there was a spike file to move. */
|
|
@@ -9753,7 +9788,7 @@ var WhatsAppContext = class {
|
|
|
9753
9788
|
*/
|
|
9754
9789
|
async checkPin() {
|
|
9755
9790
|
if (this.pinned === void 0) return;
|
|
9756
|
-
this.#pinnedId = requireAccount(await this.config(), this.pinned).account.id;
|
|
9791
|
+
this.#pinnedId = requireAccount(await this.config(), this.pinned, this.platform).account.id;
|
|
9757
9792
|
}
|
|
9758
9793
|
/** The name a call acts on: the one it gave, checked against the pin when there is one. */
|
|
9759
9794
|
async #nameFor(config, named) {
|
|
@@ -9769,7 +9804,7 @@ var WhatsAppContext = class {
|
|
|
9769
9804
|
/** One account, resolved and checked, with the person's lists for it — which every read applies. */
|
|
9770
9805
|
async account(named) {
|
|
9771
9806
|
const config = await this.config();
|
|
9772
|
-
const { name, account } = requireAccount(config, await this.#nameFor(config, named));
|
|
9807
|
+
const { name, account } = requireAccount(config, await this.#nameFor(config, named), this.platform);
|
|
9773
9808
|
return {
|
|
9774
9809
|
name,
|
|
9775
9810
|
account,
|
|
@@ -9991,7 +10026,7 @@ async function draftMessage(context, request) {
|
|
|
9991
10026
|
for (const { name, account, lists } of scope) {
|
|
9992
10027
|
let index;
|
|
9993
10028
|
try {
|
|
9994
|
-
index = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists));
|
|
10029
|
+
index = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists), context.platform);
|
|
9995
10030
|
} catch (error) {
|
|
9996
10031
|
if (scope.length > 1 && isCommsError(error) && error.details?.reason === "NOT_SYNCED") continue;
|
|
9997
10032
|
throw error;
|
|
@@ -10176,7 +10211,7 @@ async function openIndex(context, accountName) {
|
|
|
10176
10211
|
const { name, account, lists } = await context.account(accountName);
|
|
10177
10212
|
return {
|
|
10178
10213
|
name,
|
|
10179
|
-
index: await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists))
|
|
10214
|
+
index: await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists), context.platform)
|
|
10180
10215
|
};
|
|
10181
10216
|
}
|
|
10182
10217
|
async function listChats(context, request) {
|
|
@@ -10616,7 +10651,7 @@ async function statusOf(context, named, check) {
|
|
|
10616
10651
|
}
|
|
10617
10652
|
let index = { synced: false };
|
|
10618
10653
|
try {
|
|
10619
|
-
const opened = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists));
|
|
10654
|
+
const opened = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists), context.platform);
|
|
10620
10655
|
try {
|
|
10621
10656
|
const stats = opened.stats();
|
|
10622
10657
|
index = {
|
|
@@ -10747,4 +10782,4 @@ async function syncAccount(context, request) {
|
|
|
10747
10782
|
});
|
|
10748
10783
|
}
|
|
10749
10784
|
//#endregion
|
|
10750
|
-
export {
|
|
10785
|
+
export { paint as $, CommsError as A, colorEnabled as B, checkStorePath as C, strictToolArguments as D, UNTRUSTED_NOTICE as E, approvalsOf as F, gatedChangeAtTerminal as G, commandText as H, approveChangeAtTerminal as I, isCommsError as J, inlineCommand as K, canPrompt as L, absoluteSearchPath as M, agentMarker as N, CHANNELS as O, approvalKind as P, openCore as Q, channelManifest as R, storeIdentity as S, expandSource as T, escapeForDisplay as U, commandPathOf as V, exemptFromUpdateGate as W, nameAvailable as X, lookupName as Y, nameShapeProblem as Z, chatRefOf as _, listChats as a, shellCommand as at, requireAccount as b, Presenter as c, updateGateAtTerminal as ct, draftMessage as d, withFileLock as dt, renderInstall as et, Visibility as f, writeResult as ft, requireSupportedNode as g, WhatsAppIndex as h, CHAT_KINDS as i, serverPruneChange as it, EXIT_CODES as j, CHANNEL_SERVERS as k, innerText as l, updateToolGate as lt, WhatsAppContext as m, whatsappStatus as n, runCommand as nt, readChat as o, toCommsError as ot, listKey as p, VERSION$1 as pt, installExitStatus as q, probeStore as r, serverInstallChange as rt, searchMessages as s, truncateDisplay as st, syncAccount as t, renderPrune as tt, composeDraft as u, windowsSystemProgram as ut, PLATFORM as v, defaultStorePath as w, requireNamedConfig as x, newWhatsAppAccount as y, childEnvironment as z };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentcomms/whatsapp",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.3",
|
|
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.12.
|
|
41
|
+
"@agentcomms/core": "0.12.3"
|
|
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": "1945e1e3a0532c20d3da33ad9f84ceeda88c30a2"
|
|
91
91
|
}
|