@agentcomms/resend 0.9.0 → 0.10.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 +2 -2
- package/dist/index.d.mts +135 -5
- package/dist/index.mjs +2 -2
- package/dist/{send-D9aAM9kv.mjs → send-Ba1caJ6x.mjs} +359 -40
- package/dist/{server-Cr9ZXT1i.mjs → server-BHHLf1lV.mjs} +1 -1
- package/dist/{stdio-entry-F1YLLUXd.mjs → stdio-entry-CKC1hDbG.mjs} +1 -1
- package/package.json +3 -3
package/dist/cli.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as openCore, B as approvalKind, C as policyChange, D as sendPolicyOf, E as removeAccountChange, F as CHANNEL_SERVERS, H as approveChangeAtTerminal, I as CommsError, J as escapeForDisplay, K as colorEnabled, L as EXIT_CODES, N as checkNewName, O as showAccount, Q as installExitStatus, R as SEND_LOOKUP, S as policyApprovalRefusal, T as policyWanted, U as canPrompt, V as approvalsOf, Y as exemptFromUpdateGate, Z as gatedChangeAtTerminal, _ as runDoctor, _t as VERSION, a as revokeSendApproval, at as serverInstallChange, b as listAccounts, c as downloadReceived, d as listReceived, et as paint, f as listScheduled, ft as writeResult, g as showSentEmail, h as showReceived, i as prepareSend, it as runCommand, k as ResendContext, l as getMetrics, lt as truncateDisplay, m as listSuppressions, n as executeSend, nt as renderInstall, o as sendStatus, ot as serverPruneChange, p as listSentEmails, q as commandPathOf, r as finishSendApproval, rt as renderPrune, s as cancelScheduledChange, st as terminalUpdateHooks, t as beginSendApproval, tt as refuseUnclaimedApproval, u as listDomains, ut as updateGateAtTerminal, v as addAccountChange, w as policyReport, x as modeOf, y as inspectKey, z as agentMarker } from "./send-
|
|
2
|
+
import { $ as openCore, B as approvalKind, C as policyChange, D as sendPolicyOf, E as removeAccountChange, F as CHANNEL_SERVERS, H as approveChangeAtTerminal, I as CommsError, J as escapeForDisplay, K as colorEnabled, L as EXIT_CODES, N as checkNewName, O as showAccount, Q as installExitStatus, R as SEND_LOOKUP, S as policyApprovalRefusal, T as policyWanted, U as canPrompt, V as approvalsOf, Y as exemptFromUpdateGate, Z as gatedChangeAtTerminal, _ as runDoctor, _t as VERSION, a as revokeSendApproval, at as serverInstallChange, b as listAccounts, c as downloadReceived, d as listReceived, et as paint, f as listScheduled, ft as writeResult, g as showSentEmail, h as showReceived, i as prepareSend, it as runCommand, k as ResendContext, l as getMetrics, lt as truncateDisplay, m as listSuppressions, n as executeSend, nt as renderInstall, o as sendStatus, ot as serverPruneChange, p as listSentEmails, q as commandPathOf, r as finishSendApproval, rt as renderPrune, s as cancelScheduledChange, st as terminalUpdateHooks, t as beginSendApproval, tt as refuseUnclaimedApproval, u as listDomains, ut as updateGateAtTerminal, v as addAccountChange, w as policyReport, x as modeOf, y as inspectKey, z as agentMarker } from "./send-Ba1caJ6x.mjs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import childProcess from "node:child_process";
|
|
5
5
|
import { createInterface } from "node:readline/promises";
|
|
@@ -3405,7 +3405,7 @@ is out: update first, or put it off (agentcomms update, agentcomms update --late
|
|
|
3405
3405
|
}));
|
|
3406
3406
|
const mcp = program.command("mcp").description("run the MCP server on stdio, for a coding agent to connect to").option("--account <name>", "pin the server to one account; every tool then acts on it and no other").action(async (flags) => {
|
|
3407
3407
|
ran = true;
|
|
3408
|
-
const { startResendStdioServer } = await import("./stdio-entry-
|
|
3408
|
+
const { startResendStdioServer } = await import("./stdio-entry-CKC1hDbG.mjs");
|
|
3409
3409
|
await startResendStdioServer({
|
|
3410
3410
|
...deps,
|
|
3411
3411
|
env,
|
package/dist/index.d.mts
CHANGED
|
@@ -11797,6 +11797,11 @@ interface LooseningConsent {
|
|
|
11797
11797
|
*/
|
|
11798
11798
|
changes?: readonly Loosening[] | undefined;
|
|
11799
11799
|
}
|
|
11800
|
+
/**
|
|
11801
|
+
* Why a saved name has {@link DOWNLOAD_SUFFIX} after it: a name tools read or run on their own (`auto-read`), an
|
|
11802
|
+
* extension that is not one of the inert ones (`type`), or no extension at all (`no-extension`).
|
|
11803
|
+
*/
|
|
11804
|
+
type RenameReason = "auto-read" | "type" | "no-extension";
|
|
11800
11805
|
//#endregion
|
|
11801
11806
|
//#region src/approvals.d.ts
|
|
11802
11807
|
/**
|
|
@@ -11812,17 +11817,26 @@ interface LooseningConsent {
|
|
|
11812
11817
|
*
|
|
11813
11818
|
* A change approval (`kind: 'change'`) lives in the same store and goes through the same states, except that its
|
|
11814
11819
|
* claim goes straight to `used`: what it permits is a write to the configuration, which the claimant makes itself.
|
|
11820
|
+
*
|
|
11821
|
+
* So does a download's question (`kind: 'download'`): where to save the files a Gmail or Slack download names. It is
|
|
11822
|
+
* held to the change policy of the mailbox or workspace it is about, as every other change there is. Under `chat` the
|
|
11823
|
+
* person's answer, relayed from the chat, claims it straight from `pending`. Under `confirm` it has to be answered
|
|
11824
|
+
* where an agent cannot answer for them — at their own terminal, or in a form a trusted client shows them — which
|
|
11825
|
+
* records the answer and moves it to `approved`; only then is it claimed, straight to `used`, and the recorded answer
|
|
11826
|
+
* is the one that saves. It gets the store's expiry, its single use and its binding for the reason a change does: an
|
|
11827
|
+
* answer given for three invoices must not save the next conversation's files, nor be spent twice.
|
|
11815
11828
|
*/
|
|
11816
11829
|
type ApprovalState = "pending" | "approved" | "sending" | "used" | "failed" | "unknown" | "expired" | "revoked";
|
|
11817
11830
|
type ApprovalChannel = "elicitation" | "terminal";
|
|
11818
11831
|
/**
|
|
11819
|
-
* What an approval permits: a send (a mail, a post, a reaction)
|
|
11832
|
+
* What an approval permits: a send (a mail, a post, a reaction), a change to the configuration, or a download saved
|
|
11833
|
+
* where the person said.
|
|
11820
11834
|
*
|
|
11821
|
-
* One store for
|
|
11822
|
-
* a kind on every record, so that
|
|
11835
|
+
* One store for all three, so a change gets the machinery a send already has — expiry, single use, the typed code —
|
|
11836
|
+
* and a kind on every record, so that none can be spent as another. Without it, a person who approved a post at a
|
|
11823
11837
|
* terminal would also have approved whatever change an agent claimed under the same id.
|
|
11824
11838
|
*/
|
|
11825
|
-
type ApprovalKind = "send" | "change";
|
|
11839
|
+
type ApprovalKind = "send" | "change" | "download";
|
|
11826
11840
|
/** The inbox or account a change is about. `id` is absent when the change connects it, and it does not exist yet. */
|
|
11827
11841
|
interface ChangeTarget {
|
|
11828
11842
|
kind: "inbox" | "account";
|
|
@@ -11850,6 +11864,70 @@ interface ChangeBinding {
|
|
|
11850
11864
|
/** What it does outside the configuration, in words: a sign-in, a registration, files removed. */
|
|
11851
11865
|
effects: string[];
|
|
11852
11866
|
}
|
|
11867
|
+
/**
|
|
11868
|
+
* Exactly which download a question was asked for, stored on the record so the answer can be held to it.
|
|
11869
|
+
*
|
|
11870
|
+
* The files are the ones the question listed, by the platform's own ids — `<message id>/<part id>` for Gmail, the file
|
|
11871
|
+
* id for Slack — in the order they were listed: a claim for any other set is a claim for a download the person was
|
|
11872
|
+
* not asked about. So are the names they would be saved under, in the same order: a Slack file renamed between the
|
|
11873
|
+
* question and the answer would otherwise be saved under a name the person was never shown. The folders are the two
|
|
11874
|
+
* the question showed, as absolute paths. They are not part of the digest, because they are what the answer *means*
|
|
11875
|
+
* rather than what it is for: `downloads` in the answer is the path the person read, even when an agent runs the
|
|
11876
|
+
* download again from another folder.
|
|
11877
|
+
*/
|
|
11878
|
+
interface DownloadBinding {
|
|
11879
|
+
/** One line about the download, for a listing: "where to save 2 files from acme/gmail". Not part of the digest. */
|
|
11880
|
+
summary: string;
|
|
11881
|
+
/** The mailbox or workspace the files come from, by the id it has now. */
|
|
11882
|
+
target: {
|
|
11883
|
+
kind: "inbox" | "account";
|
|
11884
|
+
name: string;
|
|
11885
|
+
id: string;
|
|
11886
|
+
};
|
|
11887
|
+
/** Which download asked: `attachments.download`, `files.download`. */
|
|
11888
|
+
operation: string;
|
|
11889
|
+
/** The request as the caller made it, in the words of its own arguments: the selection, and how many at most. */
|
|
11890
|
+
request: Record<string, unknown>;
|
|
11891
|
+
/** The files the question listed, by the platform's ids, in order. */
|
|
11892
|
+
files: string[];
|
|
11893
|
+
/** The names those files would be saved under, as the question listed them, in the same order. */
|
|
11894
|
+
names: string[];
|
|
11895
|
+
/** The two folders the question offered, as absolute paths. */
|
|
11896
|
+
folders: {
|
|
11897
|
+
downloads: string;
|
|
11898
|
+
current: string;
|
|
11899
|
+
};
|
|
11900
|
+
/**
|
|
11901
|
+
* The files as the question listed them to the person — the names they would be saved under, their sizes, why a
|
|
11902
|
+
* name has `.download` after it, and their risk flags — so a terminal or a form can show the question, and its
|
|
11903
|
+
* warnings, again. Not part of the digest: the ids and names above are.
|
|
11904
|
+
*/
|
|
11905
|
+
listing?: ListedFile[] | undefined;
|
|
11906
|
+
/** The person's answer, when they gave it where an agent cannot: at a terminal, or in a trusted client's form. */
|
|
11907
|
+
answer?: RecordedSaveAnswer | undefined;
|
|
11908
|
+
}
|
|
11909
|
+
/** One file as a question lists it. */
|
|
11910
|
+
interface ListedFile {
|
|
11911
|
+
/** The name it would be saved under: the sender's made safe, with `.download` after it unless it is inert. */
|
|
11912
|
+
name: string;
|
|
11913
|
+
size: number | null;
|
|
11914
|
+
/** Why `.download` is after its name; absent when the name is the sender's, made safe. */
|
|
11915
|
+
renamed?: RenameReason | undefined;
|
|
11916
|
+
/** Its risk flags, as the file's own listing gives them. */
|
|
11917
|
+
flags?: string[] | undefined;
|
|
11918
|
+
}
|
|
11919
|
+
/**
|
|
11920
|
+
* An answer recorded on a question: one of the two folders it offered, or the folder the person named, as an absolute
|
|
11921
|
+
* path — resolved where they typed it, so it means the folder they read.
|
|
11922
|
+
*/
|
|
11923
|
+
type RecordedSaveAnswer = {
|
|
11924
|
+
readonly choice: "downloads" | "current";
|
|
11925
|
+
} | {
|
|
11926
|
+
readonly choice: "other";
|
|
11927
|
+
readonly folder: string;
|
|
11928
|
+
};
|
|
11929
|
+
/** What a claim of a download's question is held to: all of the binding but its summary and the folders it offered. */
|
|
11930
|
+
type DownloadRequest = Pick<DownloadBinding, "target" | "operation" | "request" | "files" | "names">;
|
|
11853
11931
|
interface Expectation {
|
|
11854
11932
|
to: string[];
|
|
11855
11933
|
cc: string[];
|
|
@@ -11888,6 +11966,8 @@ interface ApprovalRecord {
|
|
|
11888
11966
|
reason?: string | undefined;
|
|
11889
11967
|
/** For a change: exactly what it permits. `digest` is `changeDigest` of this. */
|
|
11890
11968
|
change?: ChangeBinding | undefined;
|
|
11969
|
+
/** For a download's question: what it asked about, and the folders it offered. `digest` is `downloadDigest`. */
|
|
11970
|
+
download?: DownloadBinding | undefined;
|
|
11891
11971
|
}
|
|
11892
11972
|
interface CreateApprovalInput {
|
|
11893
11973
|
inboxId: string;
|
|
@@ -11933,6 +12013,7 @@ declare class ApprovalStore {
|
|
|
11933
12013
|
constructor(stateDir: string, options?: {
|
|
11934
12014
|
now?: () => Date;
|
|
11935
12015
|
ttlMs?: number;
|
|
12016
|
+
downloadTtlMs?: number;
|
|
11936
12017
|
});
|
|
11937
12018
|
create(input: CreateApprovalInput): Promise<ApprovalRecord>;
|
|
11938
12019
|
get(approvalId: string): Promise<ApprovalRecord | null>;
|
|
@@ -11979,6 +12060,52 @@ declare class ApprovalStore {
|
|
|
11979
12060
|
* person to have typed the code at a terminal first; until then the refusal leaves the record as it is.
|
|
11980
12061
|
*/
|
|
11981
12062
|
claimForChange(approvalId: string, live: LiveChange, options?: ClaimOptions): Promise<ApprovalRecord>;
|
|
12063
|
+
/**
|
|
12064
|
+
* A download's question, pending, bound to `downloadDigest(input.download)` — computed here, never taken from the
|
|
12065
|
+
* caller, as a change's digest is. It stands in for the draft revision too.
|
|
12066
|
+
*
|
|
12067
|
+
* `policy` is the change policy of the mailbox or workspace the files come from, as it stands when the question is
|
|
12068
|
+
* asked: where a stranger's files land on this machine is a change to it, and is answered the way that account's
|
|
12069
|
+
* other changes are approved. A claim holds the question to the stricter of this and the policy then.
|
|
12070
|
+
*/
|
|
12071
|
+
createDownload(input: {
|
|
12072
|
+
download: DownloadBinding;
|
|
12073
|
+
policy: ChangePolicy;
|
|
12074
|
+
}): Promise<ApprovalRecord & {
|
|
12075
|
+
download: DownloadBinding;
|
|
12076
|
+
}>;
|
|
12077
|
+
/**
|
|
12078
|
+
* Records the person's answer to a download's question, given where an agent cannot give it: at their own terminal,
|
|
12079
|
+
* or in a form a trusted client showed them. The question moves to `approved`, carrying the answer, and waits for
|
|
12080
|
+
* the download to claim it — which it may then do under `confirm`, and which saves where this answer says.
|
|
12081
|
+
*
|
|
12082
|
+
* Only a pending question can be answered, and only once: a second answer is refused, not taken over the first.
|
|
12083
|
+
*/
|
|
12084
|
+
answerDownload(approvalId: string, via: ApprovalChannel, answer: RecordedSaveAnswer): Promise<ApprovalRecord & {
|
|
12085
|
+
download: DownloadBinding;
|
|
12086
|
+
}>;
|
|
12087
|
+
/**
|
|
12088
|
+
* Claims a download's question, once, for the download the caller is about to make — and returns it, with the
|
|
12089
|
+
* folders it offered, so that `downloads` and `current` in the answer mean the paths the person read, and with the
|
|
12090
|
+
* answer the person recorded, when they recorded one.
|
|
12091
|
+
*
|
|
12092
|
+
* `live` is the download as the caller computes it now: the same account, the same request, the same files under
|
|
12093
|
+
* the same names — the person answered for those files and no others. Any other is refused, and the question left
|
|
12094
|
+
* open, as it was: a second call that got an argument wrong is the agent's slip, not the person's, and voiding the
|
|
12095
|
+
* question for it would make the person answer again for nothing. It still expires, and is claimed once.
|
|
12096
|
+
*
|
|
12097
|
+
* `options.policy` is the change policy of that account now, and the stricter of it and the one the question was
|
|
12098
|
+
* asked under decides. Under `chat`, a pending question is claimed with the answer the caller carries: the person
|
|
12099
|
+
* gave it in the conversation. Under `confirm`, only a question the person answered at a terminal or in a trusted
|
|
12100
|
+
* form can be claimed; a pending one is refused, and left as it is, so they can still answer it. One used, voided or
|
|
12101
|
+
* expired is refused as an approval in that state is.
|
|
12102
|
+
*/
|
|
12103
|
+
claimForDownload(approvalId: string, live: DownloadRequest, options?: {
|
|
12104
|
+
policy?: ChangePolicy | undefined;
|
|
12105
|
+
pendingHint?: string | undefined;
|
|
12106
|
+
}): Promise<ApprovalRecord & {
|
|
12107
|
+
download: DownloadBinding;
|
|
12108
|
+
}>;
|
|
11982
12109
|
/** Records the outcome of the one send attempt. */
|
|
11983
12110
|
complete(approvalId: string, outcome: {
|
|
11984
12111
|
sentMessageId: string;
|
|
@@ -12078,7 +12205,10 @@ interface ResolvedPaths {
|
|
|
12078
12205
|
secretsDir: string;
|
|
12079
12206
|
/** Managed runtime installs for MCP clients. */
|
|
12080
12207
|
dataDir: string;
|
|
12081
|
-
/**
|
|
12208
|
+
/**
|
|
12209
|
+
* Default root for exports and for the files Resend's received mail carries. A Gmail attachment or a Slack file is
|
|
12210
|
+
* not saved here: those downloads ask the person where to save (`save-destination.ts`).
|
|
12211
|
+
*/
|
|
12082
12212
|
downloadsDir: string;
|
|
12083
12213
|
}
|
|
12084
12214
|
//#endregion
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { _t as VERSION, gt as ROUTES, ht as RESEND_DOWNLOAD_ORIGIN, mt as RESEND_API_ORIGIN, pt as REFUSED } from "./send-
|
|
2
|
-
import { t as createResendMcpServer } from "./server-
|
|
1
|
+
import { _t as VERSION, gt as ROUTES, ht as RESEND_DOWNLOAD_ORIGIN, mt as RESEND_API_ORIGIN, pt as REFUSED } from "./send-Ba1caJ6x.mjs";
|
|
2
|
+
import { t as createResendMcpServer } from "./server-BHHLf1lV.mjs";
|
|
3
3
|
//#region src/index.ts
|
|
4
4
|
/**
|
|
5
5
|
* The library entry of `@agentcomms/resend`.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { b as strictObject, c as boolean, h as number, i as _enum, m as looseObject, p as literal$1, 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, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import path, { basename, delimiter, dirname, extname, isAbsolute, join, relative, 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 "node:readline/promises";
|
|
10
10
|
import { styleText } from "node:util";
|
|
@@ -12,7 +12,7 @@ import { constants as constants$1 } from "node:fs";
|
|
|
12
12
|
//#endregion
|
|
13
13
|
//#region src/version.ts
|
|
14
14
|
/** The package version, read from package.json at build time. */
|
|
15
|
-
const VERSION$1 = "0.
|
|
15
|
+
const VERSION$1 = "0.10.0";
|
|
16
16
|
//#endregion
|
|
17
17
|
//#region src/api/routes.ts
|
|
18
18
|
/**
|
|
@@ -187,7 +187,7 @@ function refusedBecause(method, pathname) {
|
|
|
187
187
|
return null;
|
|
188
188
|
}
|
|
189
189
|
//#endregion
|
|
190
|
-
//#region ../core/dist/update-gate-
|
|
190
|
+
//#region ../core/dist/update-gate-BiceOnzC.mjs
|
|
191
191
|
/**
|
|
192
192
|
* Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
|
|
193
193
|
* optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
|
|
@@ -1278,7 +1278,7 @@ const APP_DIR_NAME = "agent-communications";
|
|
|
1278
1278
|
function resolvePaths(options = {}) {
|
|
1279
1279
|
const env = options.env ?? process.env;
|
|
1280
1280
|
const platform = options.platform ?? process.platform;
|
|
1281
|
-
const home = options.home ?? (
|
|
1281
|
+
const home = options.home ?? homeOf(env, platform);
|
|
1282
1282
|
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")));
|
|
1283
1283
|
const explicitConfigDir = Boolean(env.AGENT_COMMS_CONFIG_DIR);
|
|
1284
1284
|
const localRoot = platform === "win32" && !explicitConfigDir ? join(env.LOCALAPPDATA || join(home, "AppData", "Local"), APP_DIR_NAME) : configDir;
|
|
@@ -1290,10 +1290,40 @@ function resolvePaths(options = {}) {
|
|
|
1290
1290
|
downloadsDir: resolve(join(home, "Downloads", APP_DIR_NAME))
|
|
1291
1291
|
};
|
|
1292
1292
|
}
|
|
1293
|
-
/**
|
|
1294
|
-
|
|
1293
|
+
/**
|
|
1294
|
+
* The home the environment names, which is where Node's own `homedir()` looks for the running process — `HOME`, or
|
|
1295
|
+
* `USERPROFILE` on Windows. Asking `homedir()` directly ignored an environment passed in, so every test that gave the
|
|
1296
|
+
* harness a temporary HOME still had its data and downloads resolved to the real ones: one day of test runs left 748
|
|
1297
|
+
* backups of fixture entries in the maintainer's own data directory.
|
|
1298
|
+
*
|
|
1299
|
+
* Its own function since a download asks where to save: the person's Downloads folder is `<this>/Downloads`, and a
|
|
1300
|
+
* folder they type as `~/…` is expanded from it — the same home every other path here is resolved from. When the
|
|
1301
|
+
* environment names none, the account's own home is taken ({@link accountHome}); `account` is for a test to say what
|
|
1302
|
+
* that is.
|
|
1303
|
+
*/
|
|
1304
|
+
function homeOf(env = process.env, platform = process.platform, account = accountHome) {
|
|
1305
|
+
return (platform === "win32" ? env.USERPROFILE : env.HOME) || account();
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* The home the account itself has, for when the environment names none: the password database's (`os.userInfo()`),
|
|
1309
|
+
* which no variable moves, rather than `homedir()`, which reads `HOME` first — from this process's environment, not the
|
|
1310
|
+
* one the caller passed, and so from somewhere the caller did not say. Only when there is no such entry — some
|
|
1311
|
+
* containers run as an id with none — is `homedir()` asked instead.
|
|
1312
|
+
*/
|
|
1313
|
+
function accountHome() {
|
|
1314
|
+
try {
|
|
1315
|
+
const home = userInfo().homedir;
|
|
1316
|
+
if (home) return home;
|
|
1317
|
+
} catch {}
|
|
1318
|
+
return homedir();
|
|
1319
|
+
}
|
|
1320
|
+
/**
|
|
1321
|
+
* Expands a leading `~` to the home directory. Nothing else is expanded. `joinPaths` is the platform's own, for a path
|
|
1322
|
+
* judged for another platform than this one: a Linux home joined on Windows would otherwise take its backslashes.
|
|
1323
|
+
*/
|
|
1324
|
+
function expandHome(path, home = homedir(), joinPaths = join) {
|
|
1295
1325
|
if (path === "~") return home;
|
|
1296
|
-
if (path.startsWith("~/") || path.startsWith("~\\")) return
|
|
1326
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) return joinPaths(home, path.slice(2));
|
|
1297
1327
|
return path;
|
|
1298
1328
|
}
|
|
1299
1329
|
/**
|
|
@@ -2131,6 +2161,55 @@ function challengeMatches(answer, storedHash) {
|
|
|
2131
2161
|
const b = Buffer.from(storedHash, "hex");
|
|
2132
2162
|
return a.length === b.length && timingSafeEqual(a, b);
|
|
2133
2163
|
}
|
|
2164
|
+
/**
|
|
2165
|
+
* The digest a download's question is bound to: the account, the download, the request, the files it listed and the
|
|
2166
|
+
* names it showed them under.
|
|
2167
|
+
*
|
|
2168
|
+
* The files and names keep their order, since the question showed them in it; the request is canonical JSON, so the
|
|
2169
|
+
* same arguments digest the same however an object happened to list its keys.
|
|
2170
|
+
*/
|
|
2171
|
+
function downloadDigest(download) {
|
|
2172
|
+
return sha256Hex(canonicalJson$1({
|
|
2173
|
+
kind: "download",
|
|
2174
|
+
target: {
|
|
2175
|
+
kind: download.target.kind,
|
|
2176
|
+
name: download.target.name,
|
|
2177
|
+
id: download.target.id
|
|
2178
|
+
},
|
|
2179
|
+
operation: download.operation,
|
|
2180
|
+
request: download.request,
|
|
2181
|
+
files: [...download.files],
|
|
2182
|
+
names: [...download.names]
|
|
2183
|
+
}));
|
|
2184
|
+
}
|
|
2185
|
+
/**
|
|
2186
|
+
* Why a download claimed is not the one its question was asked for, in a sentence — the first difference found. Only
|
|
2187
|
+
* the words; what refuses is the digest.
|
|
2188
|
+
*/
|
|
2189
|
+
function downloadDrift(asked, now) {
|
|
2190
|
+
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}`;
|
|
2191
|
+
if (asked.operation !== now.operation) return "the question was asked for another kind of download";
|
|
2192
|
+
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";
|
|
2193
|
+
if (canonicalJson$1(asked.files) !== canonicalJson$1(now.files)) return "the files are not the ones the question listed";
|
|
2194
|
+
const renamed = asked.files.find((_, index) => asked.names[index] !== now.names[index]);
|
|
2195
|
+
if (renamed !== void 0) return `${renamed} would now be saved under another name than the one the question showed: it was renamed since`;
|
|
2196
|
+
return "the download is not the one the question was asked for";
|
|
2197
|
+
}
|
|
2198
|
+
/**
|
|
2199
|
+
* Why a download's question cannot be claimed under this change policy — the stricter of `livePolicy` and the one it
|
|
2200
|
+
* was asked under — or null. Changes nothing, so a download can ask before it looks at a folder, and the store asks
|
|
2201
|
+
* again as it claims.
|
|
2202
|
+
*
|
|
2203
|
+
* Under `chat` nothing stops it: the person's answer, relayed from the conversation, is the answer. Anything stricter
|
|
2204
|
+
* needs the answer recorded on the question by a channel an agent cannot answer — the person's own terminal, or a
|
|
2205
|
+
* form a trusted client showed them. An answer carried in a tool's arguments or a command's flags is refused however
|
|
2206
|
+
* it was worded, with the command that answers it named, and the question left open for the person.
|
|
2207
|
+
*/
|
|
2208
|
+
function downloadClaimRefusal(record, livePolicy, pendingHint) {
|
|
2209
|
+
if (stricterPolicy(livePolicy, record.requiredPolicy) === "chat") return null;
|
|
2210
|
+
if (record.state === "approved" && (record.approvedVia === "terminal" || record.approvedVia === "elicitation") && record.download?.answer !== void 0) return null;
|
|
2211
|
+
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.");
|
|
2212
|
+
}
|
|
2134
2213
|
/** The kind of a record. Absent is a send: every record written before changes had approvals. */
|
|
2135
2214
|
function approvalKind(record) {
|
|
2136
2215
|
return record.kind ?? "send";
|
|
@@ -2179,6 +2258,11 @@ function changeDrift(approved, now) {
|
|
|
2179
2258
|
if (canonicalJson$1(approved.effects) !== canonicalJson$1(now.effects)) return "what it does outside the configuration is not what was approved";
|
|
2180
2259
|
return "the change is not the one that was approved";
|
|
2181
2260
|
}
|
|
2261
|
+
/**
|
|
2262
|
+
* What to do with a download's question, for a caller that took it for something else: it is answered, not approved
|
|
2263
|
+
* with a code — in the chat, or at the person's own terminal with the command of the channel that asked.
|
|
2264
|
+
*/
|
|
2265
|
+
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.";
|
|
2182
2266
|
const POLICY_RANK = {
|
|
2183
2267
|
chat: 0,
|
|
2184
2268
|
confirm: 1,
|
|
@@ -2207,9 +2291,25 @@ function refuseChange(code, reason, record, hint) {
|
|
|
2207
2291
|
} : {}
|
|
2208
2292
|
});
|
|
2209
2293
|
}
|
|
2294
|
+
/**
|
|
2295
|
+
* The same refusal for a download's question, which saves nothing until it is claimed.
|
|
2296
|
+
*
|
|
2297
|
+
* Its hint says to ask again rather than to prepare anything: what the person is shown again is the question, and it
|
|
2298
|
+
* is the download itself that asks it.
|
|
2299
|
+
*/
|
|
2300
|
+
function refuseDownload(code, reason, record, hint) {
|
|
2301
|
+
return new CommsError(code, `nothing was saved: ${reason}`, {
|
|
2302
|
+
hint: hint ?? "Make the download again without an answer, and show the person the new question.",
|
|
2303
|
+
details: record ? {
|
|
2304
|
+
choiceId: record.approvalId,
|
|
2305
|
+
state: record.state
|
|
2306
|
+
} : {}
|
|
2307
|
+
});
|
|
2308
|
+
}
|
|
2210
2309
|
/** The refusal in the words of the record's own kind. */
|
|
2211
2310
|
function refusalFor(record) {
|
|
2212
|
-
|
|
2311
|
+
const kind = approvalKind(record);
|
|
2312
|
+
return kind === "change" ? refuseChange : kind === "download" ? refuseDownload : refuse$1;
|
|
2213
2313
|
}
|
|
2214
2314
|
/** The record as it may be shown to anyone, agents included: never the challenge hash. */
|
|
2215
2315
|
function publicView(record) {
|
|
@@ -2220,10 +2320,12 @@ var ApprovalStore = class {
|
|
|
2220
2320
|
directory;
|
|
2221
2321
|
#now;
|
|
2222
2322
|
#ttlMs;
|
|
2323
|
+
#downloadTtlMs;
|
|
2223
2324
|
constructor(stateDir, options = {}) {
|
|
2224
2325
|
this.directory = join(stateDir, "approvals");
|
|
2225
2326
|
this.#now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
2226
2327
|
this.#ttlMs = options.ttlMs ?? 6e5;
|
|
2328
|
+
this.#downloadTtlMs = options.downloadTtlMs ?? 18e5;
|
|
2227
2329
|
}
|
|
2228
2330
|
#path(approvalId, suffix = ".json") {
|
|
2229
2331
|
if (!APPROVAL_ID_PATTERN.test(approvalId)) throw refuse$1("USAGE", `"${approvalId}" is not an approval id`);
|
|
@@ -2311,6 +2413,11 @@ var ApprovalStore = class {
|
|
|
2311
2413
|
}
|
|
2312
2414
|
#stateError(record) {
|
|
2313
2415
|
const refusal = refusalFor(record);
|
|
2416
|
+
if (approvalKind(record) === "download") {
|
|
2417
|
+
if (record.state === "expired") return refusal("APPROVAL_EXPIRED", "the question expired before it was answered", record);
|
|
2418
|
+
if (record.state === "revoked") return refusal("APPROVAL_VOID", `the question was voided (${record.reason ?? "revoked"})`, record);
|
|
2419
|
+
return refusal("APPROVAL_VOID", record.state === "used" ? "the question was answered already, and an answer is used once" : `the question is ${record.state}`, record);
|
|
2420
|
+
}
|
|
2314
2421
|
if (record.state === "expired") return refusal("APPROVAL_EXPIRED", "the approval expired before it was used", record);
|
|
2315
2422
|
if (record.state === "revoked") return refusal("APPROVAL_VOID", `the approval was voided (${record.reason ?? "revoked"})`, record);
|
|
2316
2423
|
return refusal("APPROVAL_REQUIRED", `the approval is ${record.state}`, record);
|
|
@@ -2322,8 +2429,11 @@ var ApprovalStore = class {
|
|
|
2322
2429
|
* somebody is about to approve, because an agent passed its id to a change, would punish the wrong party.
|
|
2323
2430
|
*/
|
|
2324
2431
|
#requireKind(record, kind) {
|
|
2325
|
-
|
|
2432
|
+
const actual = approvalKind(record);
|
|
2433
|
+
if (actual === kind) return;
|
|
2326
2434
|
const id = record.approvalId;
|
|
2435
|
+
if (actual === "download") throw (kind === "change" ? refuseChange : refuse$1)("USAGE", `${id} is a question about where to save files, not ${kind === "send" ? "a send" : "a configuration change"}`, record, DOWNLOAD_ANSWER_HINT);
|
|
2436
|
+
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.");
|
|
2327
2437
|
throw kind === "send" ? refuse$1("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.`);
|
|
2328
2438
|
}
|
|
2329
2439
|
/** Issues a new challenge to show a human; only its hash is kept. */
|
|
@@ -2545,6 +2655,139 @@ var ApprovalStore = class {
|
|
|
2545
2655
|
await this.#markClaimed(result);
|
|
2546
2656
|
return result;
|
|
2547
2657
|
}
|
|
2658
|
+
/**
|
|
2659
|
+
* A download's question, pending, bound to `downloadDigest(input.download)` — computed here, never taken from the
|
|
2660
|
+
* caller, as a change's digest is. It stands in for the draft revision too.
|
|
2661
|
+
*
|
|
2662
|
+
* `policy` is the change policy of the mailbox or workspace the files come from, as it stands when the question is
|
|
2663
|
+
* asked: where a stranger's files land on this machine is a change to it, and is answered the way that account's
|
|
2664
|
+
* other changes are approved. A claim holds the question to the stricter of this and the policy then.
|
|
2665
|
+
*/
|
|
2666
|
+
async createDownload(input) {
|
|
2667
|
+
const now = this.#now();
|
|
2668
|
+
const download = {
|
|
2669
|
+
summary: input.download.summary,
|
|
2670
|
+
target: { ...input.download.target },
|
|
2671
|
+
operation: input.download.operation,
|
|
2672
|
+
request: JSON.parse(canonicalJson$1(input.download.request)),
|
|
2673
|
+
files: [...input.download.files],
|
|
2674
|
+
names: [...input.download.names],
|
|
2675
|
+
folders: {
|
|
2676
|
+
downloads: input.download.folders.downloads,
|
|
2677
|
+
current: input.download.folders.current
|
|
2678
|
+
},
|
|
2679
|
+
...input.download.listing === void 0 ? {} : { listing: input.download.listing.map((file) => ({
|
|
2680
|
+
name: file.name,
|
|
2681
|
+
size: file.size,
|
|
2682
|
+
...file.renamed === void 0 ? {} : { renamed: file.renamed },
|
|
2683
|
+
...file.flags === void 0 || file.flags.length === 0 ? {} : { flags: [...file.flags] }
|
|
2684
|
+
})) }
|
|
2685
|
+
};
|
|
2686
|
+
const digest = downloadDigest(download);
|
|
2687
|
+
const policy = input.policy === "chat" ? "chat" : "confirm";
|
|
2688
|
+
const record = {
|
|
2689
|
+
approvalId: newApprovalId(),
|
|
2690
|
+
kind: "download",
|
|
2691
|
+
digestVersion: 1,
|
|
2692
|
+
inboxId: download.target.id,
|
|
2693
|
+
draftId: "download",
|
|
2694
|
+
draftMessageId: digest,
|
|
2695
|
+
digest,
|
|
2696
|
+
policy,
|
|
2697
|
+
requiredPolicy: policy,
|
|
2698
|
+
riskFlags: [],
|
|
2699
|
+
expect: {
|
|
2700
|
+
to: [],
|
|
2701
|
+
cc: [],
|
|
2702
|
+
bcc: [],
|
|
2703
|
+
subject: download.summary
|
|
2704
|
+
},
|
|
2705
|
+
challengeAttempts: 0,
|
|
2706
|
+
state: "pending",
|
|
2707
|
+
createdAt: now.toISOString(),
|
|
2708
|
+
expiresAt: new Date(now.getTime() + this.#downloadTtlMs).toISOString(),
|
|
2709
|
+
updatedAt: now.toISOString(),
|
|
2710
|
+
download
|
|
2711
|
+
};
|
|
2712
|
+
await this.#write(record);
|
|
2713
|
+
return record;
|
|
2714
|
+
}
|
|
2715
|
+
/**
|
|
2716
|
+
* Records the person's answer to a download's question, given where an agent cannot give it: at their own terminal,
|
|
2717
|
+
* or in a form a trusted client showed them. The question moves to `approved`, carrying the answer, and waits for
|
|
2718
|
+
* the download to claim it — which it may then do under `confirm`, and which saves where this answer says.
|
|
2719
|
+
*
|
|
2720
|
+
* Only a pending question can be answered, and only once: a second answer is refused, not taken over the first.
|
|
2721
|
+
*/
|
|
2722
|
+
async answerDownload(approvalId, via, answer) {
|
|
2723
|
+
const recorded = answer.choice === "other" ? {
|
|
2724
|
+
choice: "other",
|
|
2725
|
+
folder: answer.folder
|
|
2726
|
+
} : { choice: answer.choice };
|
|
2727
|
+
return await this.#transition(approvalId, (current) => {
|
|
2728
|
+
this.#requireKind(current, "download");
|
|
2729
|
+
if (current.state === "approved") throw refuseDownload("APPROVAL_VOID", "the question was answered already, and it is answered once", current);
|
|
2730
|
+
if (current.state !== "pending") throw this.#stateError(current);
|
|
2731
|
+
if (!current.download || downloadDigest(current.download) !== current.digest) throw refuseDownload("APPROVAL_VOID", "the question does not describe the download it is bound to", current);
|
|
2732
|
+
return {
|
|
2733
|
+
...current,
|
|
2734
|
+
state: "approved",
|
|
2735
|
+
approvedVia: via,
|
|
2736
|
+
approvedDigest: current.digest,
|
|
2737
|
+
download: {
|
|
2738
|
+
...current.download,
|
|
2739
|
+
answer: recorded
|
|
2740
|
+
}
|
|
2741
|
+
};
|
|
2742
|
+
});
|
|
2743
|
+
}
|
|
2744
|
+
/**
|
|
2745
|
+
* Claims a download's question, once, for the download the caller is about to make — and returns it, with the
|
|
2746
|
+
* folders it offered, so that `downloads` and `current` in the answer mean the paths the person read, and with the
|
|
2747
|
+
* answer the person recorded, when they recorded one.
|
|
2748
|
+
*
|
|
2749
|
+
* `live` is the download as the caller computes it now: the same account, the same request, the same files under
|
|
2750
|
+
* the same names — the person answered for those files and no others. Any other is refused, and the question left
|
|
2751
|
+
* open, as it was: a second call that got an argument wrong is the agent's slip, not the person's, and voiding the
|
|
2752
|
+
* question for it would make the person answer again for nothing. It still expires, and is claimed once.
|
|
2753
|
+
*
|
|
2754
|
+
* `options.policy` is the change policy of that account now, and the stricter of it and the one the question was
|
|
2755
|
+
* asked under decides. Under `chat`, a pending question is claimed with the answer the caller carries: the person
|
|
2756
|
+
* gave it in the conversation. Under `confirm`, only a question the person answered at a terminal or in a trusted
|
|
2757
|
+
* form can be claimed; a pending one is refused, and left as it is, so they can still answer it. One used, voided or
|
|
2758
|
+
* expired is refused as an approval in that state is.
|
|
2759
|
+
*/
|
|
2760
|
+
async claimForDownload(approvalId, live, options = {}) {
|
|
2761
|
+
const digest = downloadDigest(live);
|
|
2762
|
+
let failure = null;
|
|
2763
|
+
const result = await this.#transition(approvalId, (current) => {
|
|
2764
|
+
this.#requireKind(current, "download");
|
|
2765
|
+
if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
|
|
2766
|
+
const voidWith = (reason) => {
|
|
2767
|
+
failure = {
|
|
2768
|
+
code: "APPROVAL_VOID",
|
|
2769
|
+
reason
|
|
2770
|
+
};
|
|
2771
|
+
return {
|
|
2772
|
+
...current,
|
|
2773
|
+
state: "revoked",
|
|
2774
|
+
reason
|
|
2775
|
+
};
|
|
2776
|
+
};
|
|
2777
|
+
if (!current.download || downloadDigest(current.download) !== current.digest) return voidWith("the question does not describe the download it is bound to");
|
|
2778
|
+
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.");
|
|
2779
|
+
const refusal = downloadClaimRefusal(current, options.policy ?? "chat", options.pendingHint);
|
|
2780
|
+
if (refusal !== null) throw refusal;
|
|
2781
|
+
return {
|
|
2782
|
+
...current,
|
|
2783
|
+
state: "used"
|
|
2784
|
+
};
|
|
2785
|
+
});
|
|
2786
|
+
const failed = failure;
|
|
2787
|
+
if (failed) throw refuseDownload(failed.code, failed.reason, result);
|
|
2788
|
+
await this.#markClaimed(result);
|
|
2789
|
+
return result;
|
|
2790
|
+
}
|
|
2548
2791
|
/** Records the outcome of the one send attempt. */
|
|
2549
2792
|
async complete(approvalId, outcome) {
|
|
2550
2793
|
return this.#transition(approvalId, (current) => {
|
|
@@ -3425,6 +3668,27 @@ function packageFrom(server) {
|
|
|
3425
3668
|
const line = [server.command, ...server.args].join(" ");
|
|
3426
3669
|
return /(@[\w.-]+\/[\w.-]+|(?<=\s)[\w.-]+-mcp)(?=@|\s|$)/.exec(line)?.[1];
|
|
3427
3670
|
}
|
|
3671
|
+
/**
|
|
3672
|
+
* The environment a child process is started with: `env` as it is, and on Windows also
|
|
3673
|
+
* `NoDefaultCurrentDirectoryInExePath=1`, so that the child — `rundll32.exe`, a Node started again — never takes a
|
|
3674
|
+
* program from its current folder either. Elsewhere nothing is added: a Unix shell looks in the current folder only
|
|
3675
|
+
* when `PATH` says to.
|
|
3676
|
+
*/
|
|
3677
|
+
function childEnvironment(env = process.env, platform = process.platform) {
|
|
3678
|
+
if (platform !== "win32") return env;
|
|
3679
|
+
return {
|
|
3680
|
+
...env,
|
|
3681
|
+
NoDefaultCurrentDirectoryInExePath: "1"
|
|
3682
|
+
};
|
|
3683
|
+
}
|
|
3684
|
+
/**
|
|
3685
|
+
* The directories of a search path that name a folder on their own: absolute, and on Windows on a drive. An empty
|
|
3686
|
+
* entry means the current folder to a Unix shell, and a relative one is read against it; either would make the program
|
|
3687
|
+
* found — and run — depend on where this was started, which is where a download may have saved a stranger's file.
|
|
3688
|
+
*/
|
|
3689
|
+
function absoluteSearchPath(entries, platform = process.platform) {
|
|
3690
|
+
return entries.filter((entry) => platform === "win32" ? /^[A-Za-z]:[\\/]/.test(entry) : entry !== "" && path.posix.isAbsolute(entry));
|
|
3691
|
+
}
|
|
3428
3692
|
/** The install options that pin a server to one account: the generic one, and Gmail's and Slack's own names. */
|
|
3429
3693
|
const PIN_OPTIONS = Object.freeze([
|
|
3430
3694
|
"account",
|
|
@@ -3480,9 +3744,14 @@ function clientCliSearch(env) {
|
|
|
3480
3744
|
/**
|
|
3481
3745
|
* The first of `directories` that holds an executable `name`. PATH is read by the caller, once: a test that counts
|
|
3482
3746
|
* how often the installer looks at PATH counts exactly the looks it means.
|
|
3747
|
+
*
|
|
3748
|
+
* Only absolute directories are looked in, whoever the caller. An empty entry of PATH is the current folder to a
|
|
3749
|
+
* shell, and a relative one is read against it: the `node`, `npx` or `claude` found there — and then run, or written
|
|
3750
|
+
* into a client's config to be run — would be whatever is in the folder this was started from, which is where a
|
|
3751
|
+
* download may have saved a stranger's file.
|
|
3483
3752
|
*/
|
|
3484
3753
|
async function executableIn(name, directories, env) {
|
|
3485
|
-
const paths = directories
|
|
3754
|
+
const paths = absoluteSearchPath(directories);
|
|
3486
3755
|
const extensions = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
|
|
3487
3756
|
for (const directory of paths) for (const extension of extensions) {
|
|
3488
3757
|
const candidate = join(directory, name + extension.toLowerCase());
|
|
@@ -3654,13 +3923,20 @@ async function findNpmCli() {
|
|
|
3654
3923
|
} catch {}
|
|
3655
3924
|
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." });
|
|
3656
3925
|
}
|
|
3926
|
+
/**
|
|
3927
|
+
* A command run to its end. Its environment is this process's, and on Windows also tells it never to take a program
|
|
3928
|
+
* from its current folder: `npm` and a client's command both start programs of their own by name.
|
|
3929
|
+
*/
|
|
3657
3930
|
function run(command, args) {
|
|
3658
3931
|
return new Promise((resolvePromise, reject) => {
|
|
3659
|
-
const child = spawn(command, args, {
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
|
|
3663
|
-
|
|
3932
|
+
const child = spawn(command, args, {
|
|
3933
|
+
stdio: [
|
|
3934
|
+
"ignore",
|
|
3935
|
+
"ignore",
|
|
3936
|
+
"pipe"
|
|
3937
|
+
],
|
|
3938
|
+
env: childEnvironment(process.env)
|
|
3939
|
+
});
|
|
3664
3940
|
let stderr = "";
|
|
3665
3941
|
child.stderr?.on("data", (chunk) => {
|
|
3666
3942
|
stderr += String(chunk);
|
|
@@ -3886,14 +4162,20 @@ function installCommand$1(product, options, name, extra = []) {
|
|
|
3886
4162
|
words.push(...extra);
|
|
3887
4163
|
return words.map((word) => /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`).join(" ");
|
|
3888
4164
|
}
|
|
3889
|
-
/**
|
|
4165
|
+
/**
|
|
4166
|
+
* A command's exit status and what it printed, kept here and never shown: codex prints an entry's env. Started as
|
|
4167
|
+
* `run` starts one, so that on Windows it takes no program from its current folder either.
|
|
4168
|
+
*/
|
|
3890
4169
|
function capture$1(command, args) {
|
|
3891
4170
|
return new Promise((resolvePromise, reject) => {
|
|
3892
|
-
const child = spawn(command, args, {
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
4171
|
+
const child = spawn(command, args, {
|
|
4172
|
+
stdio: [
|
|
4173
|
+
"ignore",
|
|
4174
|
+
"pipe",
|
|
4175
|
+
"pipe"
|
|
4176
|
+
],
|
|
4177
|
+
env: childEnvironment(process.env)
|
|
4178
|
+
});
|
|
3897
4179
|
let stdout = "";
|
|
3898
4180
|
let stderr = "";
|
|
3899
4181
|
child.stdout?.on("data", (chunk) => {
|
|
@@ -4346,11 +4628,27 @@ async function verifyEntry(entry, product) {
|
|
|
4346
4628
|
await client.close().catch(() => void 0);
|
|
4347
4629
|
}
|
|
4348
4630
|
}
|
|
4349
|
-
/**
|
|
4350
|
-
|
|
4631
|
+
/** Where `ps` is looked for, in order: the system's own directories, never PATH. */
|
|
4632
|
+
const PS_DIRECTORIES = ["/bin", "/usr/bin"];
|
|
4633
|
+
/**
|
|
4634
|
+
* Every running process's command line, or null when they cannot be listed.
|
|
4635
|
+
*
|
|
4636
|
+
* `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
|
|
4637
|
+
* a lookup can reach the folder this was started from — where a download may have saved a stranger's `ps`, which would
|
|
4638
|
+
* then run, and whose answer would decide what is deleted. With neither there, the processes cannot be listed.
|
|
4639
|
+
*
|
|
4640
|
+
* `directories` and `list` are for tests: where to look for `ps`, and what runs the one found.
|
|
4641
|
+
*/
|
|
4642
|
+
async function runningCommandLines(options = {}) {
|
|
4351
4643
|
if (process.platform === "win32") return null;
|
|
4644
|
+
const ps = await executableIn("ps", options.directories ?? PS_DIRECTORIES, process.env);
|
|
4645
|
+
if (ps === null) return null;
|
|
4646
|
+
return (options.list ?? commandLinesFrom)(ps);
|
|
4647
|
+
}
|
|
4648
|
+
/** What `ps` at this path says is running: one command line per process, or null when it fails. */
|
|
4649
|
+
function commandLinesFrom(ps) {
|
|
4352
4650
|
return new Promise((resolvePromise) => {
|
|
4353
|
-
execFile(
|
|
4651
|
+
execFile(ps, [
|
|
4354
4652
|
"-A",
|
|
4355
4653
|
"-o",
|
|
4356
4654
|
"args="
|
|
@@ -5193,6 +5491,7 @@ async function claimChange(core, approvalId, expect, options) {
|
|
|
5193
5491
|
async function changeRecord(core, approvalId) {
|
|
5194
5492
|
const record = await core.approvals.get(approvalId);
|
|
5195
5493
|
if (!record) throw new CommsError("NOT_FOUND", `no approval ${approvalId}`, { hint: "Prepare the change again; an approval expires ten minutes after it is made." });
|
|
5494
|
+
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 });
|
|
5196
5495
|
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 })}.` });
|
|
5197
5496
|
if (!record.change || changeDigest(record.change) !== record.digest) throw new CommsError("BAD_DATA", "this approval does not describe the change it is bound to", {
|
|
5198
5497
|
hint: "Nothing was approved. Prepare the change again.",
|
|
@@ -6135,11 +6434,14 @@ async function npmLatestVersion(packageName, options) {
|
|
|
6135
6434
|
if (typeof latest !== "string") throw new Error(`${where} names no \`latest\` release`);
|
|
6136
6435
|
return latest;
|
|
6137
6436
|
}
|
|
6138
|
-
/**
|
|
6437
|
+
/**
|
|
6438
|
+
* A command's exit status and output, given up on after `timeoutMs`. npm starts programs of its own by name — scripts,
|
|
6439
|
+
* `node-gyp`, a shell — so on Windows it is told never to take one from its current folder.
|
|
6440
|
+
*/
|
|
6139
6441
|
function capture(args, env, timeoutMs) {
|
|
6140
6442
|
return new Promise((resolve, reject) => {
|
|
6141
6443
|
const child = spawn(process.execPath, args, {
|
|
6142
|
-
env,
|
|
6444
|
+
env: childEnvironment(env),
|
|
6143
6445
|
stdio: [
|
|
6144
6446
|
"ignore",
|
|
6145
6447
|
"pipe",
|
|
@@ -6223,7 +6525,7 @@ async function npmInstallGlobal(env, spec) {
|
|
|
6223
6525
|
}
|
|
6224
6526
|
}
|
|
6225
6527
|
/** The package version, read from package.json at build time. */
|
|
6226
|
-
const VERSION = "0.
|
|
6528
|
+
const VERSION = "0.10.0";
|
|
6227
6529
|
/**
|
|
6228
6530
|
* Registering, pruning and listing the MCP servers of every channel, from the core.
|
|
6229
6531
|
*
|
|
@@ -6478,7 +6780,7 @@ function serverPruneChange(core, env, request, own) {
|
|
|
6478
6780
|
};
|
|
6479
6781
|
const { packageName, version } = ownProduct(request.channel, own) ?? {
|
|
6480
6782
|
packageName: channelServer(request.channel).packageName,
|
|
6481
|
-
version: "0.
|
|
6783
|
+
version: "0.10.0"
|
|
6482
6784
|
};
|
|
6483
6785
|
const product = {
|
|
6484
6786
|
packageName,
|
|
@@ -7577,6 +7879,10 @@ const SEND_LOOKUP = Object.freeze({
|
|
|
7577
7879
|
lookup: true
|
|
7578
7880
|
});
|
|
7579
7881
|
Object.freeze({ kind: "change" });
|
|
7882
|
+
Object.freeze({
|
|
7883
|
+
kind: "download",
|
|
7884
|
+
argument: "choiceId"
|
|
7885
|
+
});
|
|
7580
7886
|
/**
|
|
7581
7887
|
* Whether a call claims an approval the person already gave (§2): an approval this machine's approval store holds —
|
|
7582
7888
|
* every channel keeps its approvals there — that is still waiting to be used, pending or approved, and of the kind the
|
|
@@ -7628,7 +7934,8 @@ function updateToolGate(options) {
|
|
|
7628
7934
|
surface: "server"
|
|
7629
7935
|
});
|
|
7630
7936
|
if (pending === null) return null;
|
|
7631
|
-
|
|
7937
|
+
const claim = options.approvals?.[tool];
|
|
7938
|
+
if (await claimsApproval(options.core, args[claim?.argument ?? "approvalId"], claim)) return null;
|
|
7632
7939
|
return stoppedCall(pending, {
|
|
7633
7940
|
server: options.server,
|
|
7634
7941
|
tool
|
|
@@ -7693,9 +8000,10 @@ function commandPathOf(command) {
|
|
|
7693
8000
|
}
|
|
7694
8001
|
/**
|
|
7695
8002
|
* The approvals a Commander command claims: its `--approval`, the `--mcp-approval` Gmail's `setup` carries beside it,
|
|
7696
|
-
* and an argument named `approvalId` — `agent-gmail send
|
|
7697
|
-
* <approvalId>`, the commands whose tools take it as `approvalId`.
|
|
7698
|
-
* call's `approvalId` is handed it. Commander has read the arguments
|
|
8003
|
+
* the `--choice` a download carries its question's id in, and an argument named `approvalId` — `agent-gmail send
|
|
8004
|
+
* cancel <approvalId>`, `agent-resend send execute <approvalId>`, the commands whose tools take it as `approvalId`.
|
|
8005
|
+
* What the gate hands `claimsApproval`, as a tool call's `approvalId` is handed it. Commander has read the arguments
|
|
8006
|
+
* by the time a `preAction` hook runs.
|
|
7699
8007
|
*/
|
|
7700
8008
|
function approvalsOf(command) {
|
|
7701
8009
|
const options = command.opts();
|
|
@@ -7703,6 +8011,7 @@ function approvalsOf(command) {
|
|
|
7703
8011
|
return [
|
|
7704
8012
|
options.approval,
|
|
7705
8013
|
options.mcpApproval,
|
|
8014
|
+
options.choice,
|
|
7706
8015
|
...named
|
|
7707
8016
|
];
|
|
7708
8017
|
}
|
|
@@ -7849,7 +8158,7 @@ async function askLine(streams, question) {
|
|
|
7849
8158
|
}
|
|
7850
8159
|
}
|
|
7851
8160
|
//#endregion
|
|
7852
|
-
//#region ../core/dist/tool-arguments-
|
|
8161
|
+
//#region ../core/dist/tool-arguments-De_3CNxx.mjs
|
|
7853
8162
|
const APPLIED = Symbol.for("agentcomms.strictToolArguments");
|
|
7854
8163
|
const TARGET = "draft-2020-12";
|
|
7855
8164
|
/**
|
|
@@ -7867,7 +8176,7 @@ const TARGET = "draft-2020-12";
|
|
|
7867
8176
|
* in, because what it asks the registry with is each server's own — and WhatsApp's is nothing at all. A call it
|
|
7868
8177
|
* answers reaches nothing, as a refused one does.
|
|
7869
8178
|
*/
|
|
7870
|
-
function strictToolArguments(server, refuse, gate) {
|
|
8179
|
+
function strictToolArguments(server, refuse, gate, retired = {}) {
|
|
7871
8180
|
const target = server;
|
|
7872
8181
|
if (typeof target.registerTool !== "function") throw new Error("strictToolArguments needs an MCP server: this has no registerTool");
|
|
7873
8182
|
if (target[APPLIED]) return;
|
|
@@ -7883,7 +8192,7 @@ function strictToolArguments(server, refuse, gate) {
|
|
|
7883
8192
|
inputSchema: passThrough(schema)
|
|
7884
8193
|
}, async (input, context) => {
|
|
7885
8194
|
const parsed = await schema.safeParseAsync(input ?? {});
|
|
7886
|
-
if (!parsed.success) return refuse(refusal(name, published, input, parsed.error.issues));
|
|
8195
|
+
if (!parsed.success) return refuse(refusal(name, published, input, parsed.error.issues, retired[name] ?? {}));
|
|
7887
8196
|
const stopped = gate ? await gate(name, parsed.data) : null;
|
|
7888
8197
|
if (stopped !== null) return stopped;
|
|
7889
8198
|
return declared ? handler(parsed.data, context) : handler(context);
|
|
@@ -7927,12 +8236,22 @@ function passThrough(schema) {
|
|
|
7927
8236
|
} };
|
|
7928
8237
|
}
|
|
7929
8238
|
/** A USAGE refusal naming what was wrong and what the tool takes. */
|
|
7930
|
-
function refusal(tool, published, input, issues) {
|
|
8239
|
+
function refusal(tool, published, input, issues, retired) {
|
|
7931
8240
|
const properties = published.properties ?? {};
|
|
7932
8241
|
const takes = Object.keys(properties);
|
|
7933
8242
|
const required = new Set(published.required ?? []);
|
|
7934
8243
|
const signature = takes.length === 0 ? `${tool} takes no arguments.` : `${tool} takes ${spoken(takes.map((key) => required.has(key) ? `\`${key}\` (required)` : `\`${key}\``), "and")}.`;
|
|
7935
8244
|
const unknown = issues.flatMap((issue) => issue.code === "unrecognized_keys" && issue.path.length === 0 ? issue.keys : []);
|
|
8245
|
+
const removed = unknown.filter((key) => Object.hasOwn(retired, key));
|
|
8246
|
+
if (removed.length > 0) return new CommsError("USAGE", `${tool} no longer takes ${spoken(removed.map((key) => `\`${key}\``), "or")}`, {
|
|
8247
|
+
hint: removed.map((key) => retired[key]).join(" "),
|
|
8248
|
+
details: {
|
|
8249
|
+
tool,
|
|
8250
|
+
retired: removed,
|
|
8251
|
+
unknown,
|
|
8252
|
+
takes
|
|
8253
|
+
}
|
|
8254
|
+
});
|
|
7936
8255
|
if (unknown.length > 0) return new CommsError("USAGE", `${tool} does not take ${spoken(unknown.map((key) => `\`${key}\``), "or")}`, {
|
|
7937
8256
|
hint: takes.length === 0 ? signature : `${signature} Leave out anything else.`,
|
|
7938
8257
|
details: {
|
|
@@ -17629,7 +17948,7 @@ strictObject({
|
|
|
17629
17948
|
if (manifest.skills.contract !== `skills/_shared/contract-${family}.md`) issue(["skills", "contract"], `skills/_shared/contract-${family}.md: a skill's contract is chosen by its prefix`);
|
|
17630
17949
|
}
|
|
17631
17950
|
});
|
|
17632
|
-
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i;
|
|
17951
|
+
const WINDOWS_RESERVED = /^(con|prn|aux|nul|conin\$|conout\$|com[0-9¹²³]|lpt[0-9¹²³])$/i;
|
|
17633
17952
|
const UNSAFE_FILENAME_CHARS = /[/\\\u0000-\u001f\u007f<>:"|?*]/g;
|
|
17634
17953
|
/** Truncates a string to at most `maxBytes` UTF-8 bytes without splitting a code point. */
|
|
17635
17954
|
function truncateBytes(value, maxBytes) {
|
|
@@ -17653,7 +17972,7 @@ function safeFilename(name, maxBytes = 255, fallback = "attachment") {
|
|
|
17653
17972
|
cleaned = cleaned.replace(/^[.\s]+/, "").replace(/[.\s]+$/, "");
|
|
17654
17973
|
if (cleaned === "") cleaned = fallback;
|
|
17655
17974
|
const extension = extname(cleaned);
|
|
17656
|
-
if (WINDOWS_RESERVED.test(cleaned.split(".")[0] ?? cleaned)) cleaned = `_${cleaned}`;
|
|
17975
|
+
if (WINDOWS_RESERVED.test((cleaned.split(".")[0] ?? cleaned).trimEnd())) cleaned = `_${cleaned}`;
|
|
17657
17976
|
if (Buffer.byteLength(cleaned, "utf8") <= maxBytes) return cleaned;
|
|
17658
17977
|
const keptExtension = Buffer.byteLength(extension, "utf8") < maxBytes / 2 ? extension : "";
|
|
17659
17978
|
return truncateBytes(keptExtension ? cleaned.slice(0, -keptExtension.length) : cleaned, maxBytes - Buffer.byteLength(keptExtension, "utf8")) + keptExtension;
|
|
@@ -17708,7 +18027,7 @@ async function createUniqueFile(directory, filename) {
|
|
|
17708
18027
|
throw error;
|
|
17709
18028
|
}
|
|
17710
18029
|
}
|
|
17711
|
-
throw new CommsError("BAD_DATA", `could not find a free file name
|
|
18030
|
+
throw new CommsError("BAD_DATA", `could not find a free file name in ${directory}: the name and every numbered one after it are taken`);
|
|
17712
18031
|
}
|
|
17713
18032
|
/**
|
|
17714
18033
|
* Default places attachments may never be read from, whatever the allowed roots say. `~/.*` means every dot-entry
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as CHANGE_POLICIES, C as policyChange, E as removeAccountChange, G as checkForUpdates, I as CommsError, M as SEND_POLICIES, O as showAccount, P as strictToolArguments, R as SEND_LOOKUP, S as policyApprovalRefusal, T as policyWanted, W as changeToolResult, X as gatedChange, _ as runDoctor, _t as VERSION, b as listAccounts, c as downloadReceived, ct as toCommsError, d as listReceived, dt as updateToolGate, f as listScheduled, g as showSentEmail, h as showReceived, i as prepareSend, j as MODES, k as ResendContext, l as getMetrics, m as listSuppressions, n as executeSend, o as sendStatus, p as listSentEmails, s as cancelScheduledChange, tt as refuseUnclaimedApproval, u as listDomains, w as policyReport } from "./send-
|
|
1
|
+
import { A as CHANGE_POLICIES, C as policyChange, E as removeAccountChange, G as checkForUpdates, I as CommsError, M as SEND_POLICIES, O as showAccount, P as strictToolArguments, R as SEND_LOOKUP, S as policyApprovalRefusal, T as policyWanted, W as changeToolResult, X as gatedChange, _ as runDoctor, _t as VERSION, b as listAccounts, c as downloadReceived, ct as toCommsError, d as listReceived, dt as updateToolGate, f as listScheduled, g as showSentEmail, h as showReceived, i as prepareSend, j as MODES, k as ResendContext, l as getMetrics, m as listSuppressions, n as executeSend, o as sendStatus, p as listSentEmails, s as cancelScheduledChange, tt as refuseUnclaimedApproval, u as listDomains, w as policyReport } from "./send-Ba1caJ6x.mjs";
|
|
2
2
|
import { S as union, c as boolean, g as object, h as number, s as array, 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 createResendMcpServer } from "./server-
|
|
1
|
+
import { t as createResendMcpServer } from "./server-BHHLf1lV.mjs";
|
|
2
2
|
//#region src/mcp/stdio-entry.ts
|
|
3
3
|
/**
|
|
4
4
|
* Starting the stdio server. On stdio, **stdout is the protocol**: one stray `console.log` corrupts the stream, so
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentcomms/resend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Resend for coding agents: read domains, sent and received mail, metrics and suppressions, and send mail that nothing sends without a person's approval of exactly what goes out.",
|
|
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.10.0"
|
|
42
42
|
},
|
|
43
43
|
"publishConfig": {
|
|
44
44
|
"access": "public"
|
|
@@ -94,5 +94,5 @@
|
|
|
94
94
|
"typecheck": "tsc -p tsconfig.json",
|
|
95
95
|
"test": "node --experimental-strip-types --test --test-timeout=600000 \"test/**/*.test.ts\""
|
|
96
96
|
},
|
|
97
|
-
"gitHead": "
|
|
97
|
+
"gitHead": "5bb3ca06a2f436f12f3eb8d6ec4e51a6b366d901"
|
|
98
98
|
}
|