message-cp 0.1.1 → 0.2.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/README.md +9 -8
- package/dist/cli.js +6 -5
- package/dist/connect.js +7 -7
- package/dist/dashboard.js +3 -3
- package/dist/db.js +1 -1
- package/dist/init.js +65 -33
- package/dist/messages.js +14 -8
- package/dist/webhooks.js +59 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,18 +8,19 @@ message-cp does not sign people in. Over HTTP it accepts only bearer tokens it i
|
|
|
8
8
|
|
|
9
9
|
## Quick start
|
|
10
10
|
|
|
11
|
-
Needs Node 22.14 or later and
|
|
11
|
+
Needs Node 22.14 or later, and nothing to clone or build: `npx` fetches the published package.
|
|
12
12
|
|
|
13
13
|
```sh
|
|
14
|
-
|
|
15
|
-
cd message-cp
|
|
16
|
-
npm ci --ignore-scripts
|
|
17
|
-
npm run build
|
|
18
|
-
npm test
|
|
19
|
-
npm install -g .
|
|
14
|
+
npx -y message-cp init
|
|
20
15
|
```
|
|
21
16
|
|
|
22
|
-
|
|
17
|
+
`init` adds message-cp to Claude Code, Codex, Cursor or Claude Desktop; a `message-cp` entry that runs something else is left as it is, and `init` stops and says how to remove it. Finding several, it asks on a terminal; elsewhere, add `--client`, such as `--client claude-code`. It then checks the server with a test agent, which it removes, and prints how to undo. By hand, for Claude Code:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
claude mcp add message-cp -- npx -y message-cp
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Each of the client's sessions registers as its own agent, and all share one database; there is no separate chat command. Open two sessions, and ask one to "register as alice" and the other to "register as bob and send alice a message". `npx -y message-cp help` lists the other commands. For a team server or Docker, see [Getting started](https://message-cp.vercel.app/getting-started.html); for Cursor by hand or Claude Code over HTTP, [Add it to an MCP client](https://message-cp.vercel.app/clients.html#add-it-to-an-mcp-client). To build from a clone, see [CONTRIBUTING.md](CONTRIBUTING.md#building-and-testing).
|
|
23
24
|
|
|
24
25
|
## Documentation
|
|
25
26
|
|
package/dist/cli.js
CHANGED
|
@@ -17,7 +17,7 @@ const USAGE = {
|
|
|
17
17
|
serve: "message-cp serve [--host <address>] [--port <port>] [--public-url <url>]",
|
|
18
18
|
wait: "message-cp wait <name>",
|
|
19
19
|
account: "message-cp account add <name> | message-cp account list | message-cp token add <account> [--read-only | --agent-only | --person <name>] | message-cp token list | message-cp token revoke <token id>",
|
|
20
|
-
webhook: "message-cp webhook add --url <url> --on <#channel | mention:<agent> | agent:<agent
|
|
20
|
+
webhook: "message-cp webhook add --url <url> --on <#channel | mention:<agent> | agent:<agent> | agent:*> [--account <name>] [--no-text] [--header-stdin] [--max-per-hour <n>] [--body <json>] | message-cp webhook list [--account <name>] | message-cp webhook remove <webhook id>",
|
|
21
21
|
person: "message-cp person add <name> [--account <name>] [--admin] | message-cp person admin <name> [--off] [--account <name>] | message-cp person remove <name> [--account <name>] | message-cp person list [--account <name>]",
|
|
22
22
|
agent: "message-cp agent list [--account <name>] | message-cp agent remove <name> [--account <name>] | message-cp agent key reset <name> [--account <name>]",
|
|
23
23
|
connect: "message-cp connect <agent> --url <public url> [--account <name>] [--new-key]",
|
|
@@ -352,8 +352,9 @@ else if (command === "agent") {
|
|
|
352
352
|
db.close();
|
|
353
353
|
}
|
|
354
354
|
else if (command === "connect") {
|
|
355
|
-
// Run on the server, like `agent`. The token
|
|
356
|
-
//
|
|
355
|
+
// Run on the server, like `agent`. The token is printed this once, to stdout, inside the client setup, and the agent's
|
|
356
|
+
// key never is, since the token is bound to the agent; stderr says what was made and how to revoke the token, and
|
|
357
|
+
// never carries a token, a key or a hash.
|
|
357
358
|
const usage = () => {
|
|
358
359
|
console.error(`usage: ${USAGE.connect}`);
|
|
359
360
|
process.exit(2);
|
|
@@ -385,7 +386,7 @@ else if (command === "connect") {
|
|
|
385
386
|
console.error(c.reset
|
|
386
387
|
? `message-cp: new key for agent '${c.name}' of account '${c.account}'; its old key no longer works and its current sessions have ended.`
|
|
387
388
|
: `message-cp: agent '${c.name}' in account '${c.account}'.`);
|
|
388
|
-
console.error(`message-cp: agent token ${c.tokenId} for account '${c.account}'. The output holds this token
|
|
389
|
+
console.error(`message-cp: agent token ${c.tokenId} for account '${c.account}'. The output holds this token, shown only now; keep it. To revoke the token: message-cp token list, then message-cp token revoke ${c.tokenId}.`);
|
|
389
390
|
console.log(clientSetup(endpoint, c).replace(/\n$/, ""));
|
|
390
391
|
}
|
|
391
392
|
catch (err) {
|
|
@@ -505,7 +506,7 @@ else if (command === "init") {
|
|
|
505
506
|
run: realRunner,
|
|
506
507
|
interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
507
508
|
ask: askOnTerminal,
|
|
508
|
-
prove: () => (
|
|
509
|
+
prove: (checked) => (checked === undefined ? proveServer(ownServer()) : proveRemote(checked)),
|
|
509
510
|
out: (line) => console.log(line),
|
|
510
511
|
err: (line) => console.error(line),
|
|
511
512
|
});
|
package/dist/connect.js
CHANGED
|
@@ -2,7 +2,8 @@ import { addToken } from "./accounts.js";
|
|
|
2
2
|
import { registerWithIdentity, resetAgentKey, validateName } from "./agents.js";
|
|
3
3
|
import { shellQuote } from "./wait.js";
|
|
4
4
|
// `message-cp connect`: what a client needs to act as one agent of an account over HTTP, made in one step on the
|
|
5
|
-
// server. The token
|
|
5
|
+
// server. The token is returned for the CLI to print once, to stdout; only its hash is stored, and the agent's key,
|
|
6
|
+
// also returned, is not printed, since the token is bound to the agent.
|
|
6
7
|
export class ConnectError extends Error {
|
|
7
8
|
}
|
|
8
9
|
/**
|
|
@@ -68,13 +69,12 @@ export const connectAgent = (db, account, name, newKey) => db.transaction(() =>
|
|
|
68
69
|
const token = addToken(db, acct.name, false, true, agent.id);
|
|
69
70
|
return { name: agent.name, account: acct.name, reset: live !== undefined, key: agent.key, tokenId: token.id, token: token.token };
|
|
70
71
|
}).immediate();
|
|
71
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* The two things `connect` prints: a `claude mcp add` command, then the same server as a JSON `mcpServers` entry. Both
|
|
74
|
+
* send only the token: it is bound to the agent, so it alone acts as it, and the agent's key is never printed.
|
|
75
|
+
*/
|
|
72
76
|
export function clientSetup(endpoint, c) {
|
|
73
|
-
const headers = {
|
|
74
|
-
Authorization: `Bearer ${c.token}`,
|
|
75
|
-
"X-Message-CP-Agent": c.name,
|
|
76
|
-
"X-Message-CP-Agent-Key": c.key,
|
|
77
|
-
};
|
|
77
|
+
const headers = { Authorization: `Bearer ${c.token}` };
|
|
78
78
|
const command = [
|
|
79
79
|
"claude mcp add --transport http message-cp",
|
|
80
80
|
shellQuote(endpoint.href),
|
package/dist/dashboard.js
CHANGED
|
@@ -672,7 +672,7 @@ const NO_CHANNELS_FULL = ' You can also start one from "New channel" above.';
|
|
|
672
672
|
const NO_MESSAGES = "No direct messages yet: they appear here when an agent sends one with the send_message tool.";
|
|
673
673
|
const NO_MESSAGES_FULL = ' You can also start one from "New message" above.';
|
|
674
674
|
const NO_DELIVERIES =
|
|
675
|
-
"No webhook deliveries yet: add a webhook on the server with message-cp webhook add --url <url> --on <#channel | mention:<agent> | agent:<agent
|
|
675
|
+
"No webhook deliveries yet: add a webhook on the server with message-cp webhook add --url <url> --on <#channel | mention:<agent> | agent:<agent> | agent:*>, and its deliveries show here once it fires.";
|
|
676
676
|
const NO_APPROVALS =
|
|
677
677
|
"No approved clients yet: a client approved as one named agent, or as one that registers its own agents, appears here " +
|
|
678
678
|
"once it connects through the server's authorization flow (serve --public-url), and its token is revoked on the server " +
|
|
@@ -4854,7 +4854,7 @@ export const DASHBOARD_HTML = `<!doctype html>
|
|
|
4854
4854
|
<input id="hook-url" type="text" inputmode="url" autocomplete="off" spellcheck="false" placeholder="https://example.com/hook"></div>
|
|
4855
4855
|
<div class="field"><label for="hook-on">Fires on</label>
|
|
4856
4856
|
<input id="hook-on" type="text" autocomplete="off" spellcheck="false" placeholder="#general" aria-describedby="hook-on-hint">
|
|
4857
|
-
<span id="hook-on-hint" class="note">#<channel>, mention:<agent>
|
|
4857
|
+
<span id="hook-on-hint" class="note">#<channel>, mention:<agent>, agent:<agent> or agent:* (every agent, not people)</span></div>
|
|
4858
4858
|
<label class="check"><input id="hook-text" type="checkbox" checked> Send the message text</label>
|
|
4859
4859
|
<div class="field"><label for="hook-header-name">Header name (optional)</label>
|
|
4860
4860
|
<input id="hook-header-name" type="text" autocomplete="off" spellcheck="false" placeholder="Authorization"></div>
|
|
@@ -4864,7 +4864,7 @@ export const DASHBOARD_HTML = `<!doctype html>
|
|
|
4864
4864
|
<input id="hook-max" type="number" min="1" step="1" inputmode="numeric" autocomplete="off"></div>
|
|
4865
4865
|
<div class="field wide"><label for="hook-body">Fixed body (optional)</label>
|
|
4866
4866
|
<textarea id="hook-body" rows="2" autocomplete="off" spellcheck="false" placeholder='{"requestId":"{{delivery}}"}' aria-describedby="hook-body-hint"></textarea>
|
|
4867
|
-
<span id="hook-body-hint" class="note">A JSON object sent in place of the event, with {{delivery}} once in a string value; it never carries the text.</span></div>
|
|
4867
|
+
<span id="hook-body-hint" class="note">A JSON object sent in place of the event, with {{delivery}} once in a string value, and for agent: any {{agent}} in one; it never carries the text.</span></div>
|
|
4868
4868
|
<div class="actions"><button id="hook-add" type="submit">Add webhook</button></div>
|
|
4869
4869
|
</form>
|
|
4870
4870
|
<div id="webhook-made" hidden>
|
package/dist/db.js
CHANGED
|
@@ -62,7 +62,7 @@ CREATE INDEX IF NOT EXISTS messages_by_reply ON messages (in_reply_to);
|
|
|
62
62
|
CREATE INDEX IF NOT EXISTS messages_by_question ON messages (in_reply_to, recipient_id);`;
|
|
63
63
|
// Outgoing webhooks, added only with `message-cp webhook`; an admin person token also lists and removes them over
|
|
64
64
|
// /api/webhooks. `event` is 'channel', 'mention' or 'agent' and `name` the channel or agent in the same account,
|
|
65
|
-
// which need not exist yet. The secret is stored as is: deliveries sign with it.
|
|
65
|
+
// which need not exist yet, or '*' with 'agent' for every agent of the account (`agent:*`), a name no agent can have. The secret is stored as is: deliveries sign with it.
|
|
66
66
|
// So is the value of the one header a webhook may carry (`header_name`, `header_value`), since deliveries send it.
|
|
67
67
|
// `max_per_hour` (null for none) caps its delivery requests in a fixed window: `window_started_at` is when the first
|
|
68
68
|
// request after the last window ended was claimed, and `window_count` how many requests have been claimed since; the
|
package/dist/init.js
CHANGED
|
@@ -18,8 +18,8 @@ import { shellQuote } from "./wait.js";
|
|
|
18
18
|
// It never prints a token or key: the test agent's key is made here, stays in memory, and the agent is purged after.
|
|
19
19
|
// With `--url` and `--agent` it adds a team server over HTTP instead, carrying the token from MESSAGE_CP_HTTP_TOKEN in
|
|
20
20
|
// its headers, with the agent's name and its key from MESSAGE_CP_AGENT_KEY unless the token is bound to the agent, and
|
|
21
|
-
// nothing it prints holds the token or key. With `--from` it reads the URL
|
|
22
|
-
// `
|
|
21
|
+
// nothing it prints holds the token or key. With `--from` it reads the URL and token from the file `message-cp connect`
|
|
22
|
+
// wrote instead (and the agent's name and key, from a file an earlier `connect` wrote with them), and once the check passes also writes the token to ~/.message-cp/http-token for
|
|
23
23
|
// the wake-up command.
|
|
24
24
|
export class InitError extends Error {
|
|
25
25
|
}
|
|
@@ -176,10 +176,12 @@ export const cliCommands = {
|
|
|
176
176
|
const MAX_CONNECT_FILE = 64 * 1024;
|
|
177
177
|
/**
|
|
178
178
|
* The team server in the file `message-cp connect` wrote: the `mcpServers` JSON after its `claude mcp add` line, which
|
|
179
|
-
* must name exactly one server with a `url` and the
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
179
|
+
* must name exactly one server with a `url` and the `Authorization` header `clientSetup` in `src/connect.ts` writes. A
|
|
180
|
+
* file an earlier `connect` wrote also has the agent's name and key headers, and then both must be usable; without
|
|
181
|
+
* them the agent is left out, for `checkRemote` to find from the token. The URL goes through `mcpEndpoint`, the check
|
|
182
|
+
* `init --url` uses. Refuses a file it cannot read, one that is not a regular file or is over 64 KiB, one whose mode is
|
|
183
|
+
* wider than 0600 (except on Windows, which has no such modes), and one without exactly one usable URL and token. No
|
|
184
|
+
* message holds the file's contents.
|
|
183
185
|
*/
|
|
184
186
|
export function readConnectFile(file, platform = process.platform) {
|
|
185
187
|
const refuse = (why) => new InitError(`${file} ${why}. Nothing was changed.`);
|
|
@@ -233,9 +235,11 @@ export function readConnectFile(file, platform = process.platform) {
|
|
|
233
235
|
const word = (v) => typeof v === "string" && /^[\x21-\x7e]+$/.test(v);
|
|
234
236
|
if (!word(token))
|
|
235
237
|
throw notConnect("has no token");
|
|
236
|
-
|
|
238
|
+
// A file a later `connect` wrote has neither: its token is bound to the agent.
|
|
239
|
+
const named = agent !== undefined || key !== undefined;
|
|
240
|
+
if (named && (typeof agent !== "string" || !NAME_PATTERN.test(agent) || agent.toLowerCase() === "all"))
|
|
237
241
|
throw notConnect("has no usable agent name");
|
|
238
|
-
if (!word(key))
|
|
242
|
+
if (named && !word(key))
|
|
239
243
|
throw notConnect("has no agent key");
|
|
240
244
|
let endpoint;
|
|
241
245
|
try {
|
|
@@ -246,7 +250,7 @@ export function readConnectFile(file, platform = process.platform) {
|
|
|
246
250
|
throw err;
|
|
247
251
|
throw refuse(`has a server URL init refuses: ${err.message.replace(/^--url /, "it ")}`);
|
|
248
252
|
}
|
|
249
|
-
return { endpoint, agent, token, key, from: file };
|
|
253
|
+
return named ? { endpoint, agent: agent, token, key: key, from: file } : { endpoint, token, from: file };
|
|
250
254
|
}
|
|
251
255
|
/**
|
|
252
256
|
* Writes `token` to `~/.message-cp/http-token` under `home`, where the wake-up command reads it, as a new 0600 file in a
|
|
@@ -423,7 +427,7 @@ export function remoteRefusal(client, m, endpoint) {
|
|
|
423
427
|
return `Codex joins a team server through the server's OAuth flow (\`serve --public-url\`), which init does not run: run \`codex mcp add message-cp --url ${shellQuote(endpoint.href)}\`, then \`codex mcp login message-cp\` if add did not start the login, and have a person with a full or person token of the account approve it at the address Codex prints.`;
|
|
424
428
|
}
|
|
425
429
|
if (client === "claude-desktop") {
|
|
426
|
-
return `Claude Desktop takes a team server as a custom connector in its app: in Customize, Connectors, choose Add, then Add custom connector, give it a name and the URL ${endpoint.href}, choose No sign in, and add the
|
|
430
|
+
return `Claude Desktop takes a team server as a custom connector in its app: in Customize, Connectors, choose Add, then Add custom connector, give it a name and the URL ${endpoint.href}, choose No sign in, and add the header Authorization: Bearer <token> (for a token not bound to the agent, also X-Message-CP-Agent and X-Message-CP-Agent-Key).`;
|
|
427
431
|
}
|
|
428
432
|
if (client === "claude-code" && m.platform === "win32") {
|
|
429
433
|
return "on Windows, message-cp init cannot run `claude mcp add` without passing the token through a shell; run the `claude mcp add` command `message-cp connect` printed instead, or use --client cursor.";
|
|
@@ -643,6 +647,10 @@ async function callTool(client, tool, args) {
|
|
|
643
647
|
* is ended. Throws an InitError that says which was refused: the token (401, or 403 for a read-only one), the agent's
|
|
644
648
|
* name (not on the server, or not the bound token's agent) or its key (403, or none for an unbound token), or that the
|
|
645
649
|
* server could not be reached. No message holds the token or key.
|
|
650
|
+
*
|
|
651
|
+
* Without `r.agent`, as from the file a later `connect` wrote, `list_channels` (which marks nothing read) on that
|
|
652
|
+
* session fails as not registered for an unbound token, which is refused, since nothing names its agent; for a bound
|
|
653
|
+
* one, the refused `register` names the agent, and `checkRemote` sets `r.agent` to it.
|
|
646
654
|
*/
|
|
647
655
|
export async function checkRemote(r) {
|
|
648
656
|
const tokenIn = r.from === undefined ? "the token in MESSAGE_CP_HTTP_TOKEN" : `the token in ${r.from}`;
|
|
@@ -661,6 +669,23 @@ export async function checkRemote(r) {
|
|
|
661
669
|
};
|
|
662
670
|
const said = (err) => redact(err instanceof Error ? err.message : String(err), r);
|
|
663
671
|
const plain = remoteClient(r, { Authorization: `Bearer ${r.token}` });
|
|
672
|
+
// The agent a bound token is: `register` with a random name is refused before it does anything, so nothing is
|
|
673
|
+
// registered, and its message names the token's agent.
|
|
674
|
+
const tokenAgent = async () => {
|
|
675
|
+
const name = `init-check-${randomBytes(10).toString("hex")}`;
|
|
676
|
+
let refused = null;
|
|
677
|
+
try {
|
|
678
|
+
await callTool(plain.client, "register", { name, bio: "message-cp init check", key: randomBytes(24).toString("hex") });
|
|
679
|
+
}
|
|
680
|
+
catch (err) {
|
|
681
|
+
refused = /This token is already agent '([^']*)'/.exec(err instanceof Error ? err.message : "");
|
|
682
|
+
if (!refused)
|
|
683
|
+
throw new InitError(`register: ${said(err).slice(0, 300)}`);
|
|
684
|
+
}
|
|
685
|
+
if (!refused)
|
|
686
|
+
throw new InitError(`register: the token could read as an agent but could also register '${name}'; remove it on the server with: message-cp agent remove ${name}`);
|
|
687
|
+
return refused[1];
|
|
688
|
+
};
|
|
664
689
|
let bound = false;
|
|
665
690
|
try {
|
|
666
691
|
try {
|
|
@@ -669,6 +694,19 @@ export async function checkRemote(r) {
|
|
|
669
694
|
catch (err) {
|
|
670
695
|
throw refusal(err, false);
|
|
671
696
|
}
|
|
697
|
+
if (r.agent === undefined) {
|
|
698
|
+
try {
|
|
699
|
+
await callTool(plain.client, "list_channels", { limit: 1 });
|
|
700
|
+
}
|
|
701
|
+
catch (err) {
|
|
702
|
+
const message = said(err);
|
|
703
|
+
if (message.includes(NOT_REGISTERED))
|
|
704
|
+
throw new InitError(`${tokenIn} is not bound to an agent, and the file names none; give the file message-cp connect wrote, unchanged`);
|
|
705
|
+
throw new InitError(`list_channels: ${message.slice(0, 300)}`);
|
|
706
|
+
}
|
|
707
|
+
r.agent = await tokenAgent();
|
|
708
|
+
return true;
|
|
709
|
+
}
|
|
672
710
|
let agent;
|
|
673
711
|
try {
|
|
674
712
|
agent = await callTool(plain.client, "get_agent", { name: r.agent });
|
|
@@ -692,22 +730,9 @@ export async function checkRemote(r) {
|
|
|
692
730
|
throw new InitError(`read_conversation: ${message.slice(0, 300)}`);
|
|
693
731
|
}
|
|
694
732
|
if (bound) {
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
try {
|
|
699
|
-
await callTool(plain.client, "register", { name, bio: "message-cp init check", key: randomBytes(24).toString("hex") });
|
|
700
|
-
}
|
|
701
|
-
catch (err) {
|
|
702
|
-
refused = /This token is already agent '([^']*)'/.exec(err instanceof Error ? err.message : "");
|
|
703
|
-
if (!refused)
|
|
704
|
-
throw new InitError(`register: ${said(err).slice(0, 300)}`);
|
|
705
|
-
}
|
|
706
|
-
if (!refused)
|
|
707
|
-
throw new InitError(`register: the token could read as an agent but could also register '${name}'; remove it on the server with: message-cp agent remove ${name}`);
|
|
708
|
-
if (refused[1].toLowerCase() !== r.agent.toLowerCase()) {
|
|
709
|
-
throw new InitError(`${tokenIn} is bound to agent '${refused[1]}', not '${r.agent}'`);
|
|
710
|
-
}
|
|
733
|
+
const actual = await tokenAgent();
|
|
734
|
+
if (actual.toLowerCase() !== r.agent.toLowerCase())
|
|
735
|
+
throw new InitError(`${tokenIn} is bound to agent '${actual}', not '${r.agent}'`);
|
|
711
736
|
}
|
|
712
737
|
}
|
|
713
738
|
finally {
|
|
@@ -861,22 +886,27 @@ export async function runInit(o) {
|
|
|
861
886
|
}
|
|
862
887
|
client = found[n - 1];
|
|
863
888
|
}
|
|
864
|
-
|
|
865
|
-
if (remote !== undefined) {
|
|
866
|
-
const refused = remoteRefusal(client, o.machine, remote.endpoint);
|
|
889
|
+
let remote;
|
|
890
|
+
if (o.remote !== undefined) {
|
|
891
|
+
const refused = remoteRefusal(client, o.machine, o.remote.endpoint);
|
|
867
892
|
if (refused !== undefined) {
|
|
868
893
|
o.err(`message-cp init: ${refused} Nothing was changed.`);
|
|
869
894
|
return 1;
|
|
870
895
|
}
|
|
896
|
+
const unchecked = { ...o.remote };
|
|
897
|
+
let bound;
|
|
871
898
|
try {
|
|
872
|
-
|
|
899
|
+
bound = (await (o.check ?? checkRemote)(unchecked)) === true;
|
|
900
|
+
if (unchecked.agent === undefined)
|
|
901
|
+
throw new InitError("the check did not find which agent the token is");
|
|
873
902
|
}
|
|
874
903
|
catch (err) {
|
|
875
904
|
if (!(err instanceof InitError))
|
|
876
905
|
throw err;
|
|
877
|
-
o.err(`message-cp init: ${redact(err.message,
|
|
906
|
+
o.err(`message-cp init: ${redact(err.message, unchecked)}. Nothing was changed.`);
|
|
878
907
|
return 1;
|
|
879
908
|
}
|
|
909
|
+
remote = { ...unchecked, agent: unchecked.agent, bound };
|
|
880
910
|
o.out(remote.key === undefined
|
|
881
911
|
? `Server: ${remote.endpoint.href} accepted the token, which is agent '${remote.agent}'.`
|
|
882
912
|
: remote.bound
|
|
@@ -899,7 +929,9 @@ export async function runInit(o) {
|
|
|
899
929
|
? `message-cp init: ${label} already has a server named message-cp that does not run \`npx -y message-cp\`; it was left as it is.`
|
|
900
930
|
: `message-cp init: ${label} already has a server named message-cp that is not ${remote.endpoint.href} as agent '${remote.agent}' with this ${remote.key === undefined ? "token" : "token and key"}; it was left as it is.`);
|
|
901
931
|
if (remote?.bound && remote.key === undefined) {
|
|
902
|
-
o.err(
|
|
932
|
+
o.err(remote.from === undefined
|
|
933
|
+
? "If it is an earlier init's entry with the agent's name and key, set MESSAGE_CP_AGENT_KEY so init can check that key, or remove it so init writes the token alone."
|
|
934
|
+
: "If it is an earlier init's entry, for this token with the agent's name and key or for another token, remove it so init writes this token alone.");
|
|
903
935
|
}
|
|
904
936
|
o.err(`To remove it, then run message-cp init again: ${setUp.undo}`);
|
|
905
937
|
return 1;
|
|
@@ -907,7 +939,7 @@ export async function runInit(o) {
|
|
|
907
939
|
o.out(setUp.state === "added" ? `${label}: added message-cp ${setUp.changed}.` : `${label}: already set up; nothing was changed.`);
|
|
908
940
|
let proved;
|
|
909
941
|
try {
|
|
910
|
-
proved = await o.prove();
|
|
942
|
+
proved = await o.prove(remote);
|
|
911
943
|
}
|
|
912
944
|
catch (err) {
|
|
913
945
|
o.err(`Check failed: ${redact(err instanceof Error ? err.message : String(err), remote)}`);
|
package/dist/messages.js
CHANGED
|
@@ -18,6 +18,16 @@ export function validateBody(body, kind = "Message") {
|
|
|
18
18
|
throw new ToolError(`${kind} is too long: at most ${MAX_BODY_LENGTH} characters.`);
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Queues a stored message for every `agent:<recipient>` webhook of the account and, unless the recipient is a person,
|
|
23
|
+
* every `agent:*` one (stored with the name `*`), held for the recipient while it is waiting for mail (`markWaiting`),
|
|
24
|
+
* so `sendPending` leaves it to the waiting agent. Both are found through `webhooks_by_name`. Parameters: the message id,
|
|
25
|
+
* the time now, the recipient's id, the time now again, the account, the recipient's name and its `person` flag.
|
|
26
|
+
*/
|
|
27
|
+
export const QUEUE_MESSAGE_SQL = `INSERT INTO webhook_deliveries (webhook_id, message_id, created_at, held_for)
|
|
28
|
+
SELECT id, ?, ?, (SELECT a.id FROM agents a WHERE a.id = ? AND a.waiting_until > ?)
|
|
29
|
+
FROM webhooks WHERE account_id = ? AND event = 'agent' AND name COLLATE NOCASE IN (?, CASE WHEN ? = 0 THEN '*' END)
|
|
30
|
+
ORDER BY id`;
|
|
21
31
|
/**
|
|
22
32
|
* Stores one delivery per recipient: the named agent, or for `all` every agent of the account registered right now
|
|
23
33
|
* except the sender. Read state therefore belongs to each recipient. A message belongs to its recipient's account,
|
|
@@ -45,7 +55,7 @@ export function sendMessage(db, account, from, to, body, own, inReplyTo) {
|
|
|
45
55
|
let recipients;
|
|
46
56
|
if (broadcast) {
|
|
47
57
|
recipients = db
|
|
48
|
-
.prepare("SELECT id, name FROM agents WHERE account_id = ? AND name <> ? AND removed_at IS NULL ORDER BY name")
|
|
58
|
+
.prepare("SELECT id, name, person FROM agents WHERE account_id = ? AND name <> ? AND removed_at IS NULL ORDER BY name")
|
|
49
59
|
.all(account, from);
|
|
50
60
|
}
|
|
51
61
|
else {
|
|
@@ -53,20 +63,16 @@ export function sendMessage(db, account, from, to, body, own, inReplyTo) {
|
|
|
53
63
|
if (!agent)
|
|
54
64
|
throw new ToolError(`No agent named '${to}'.`);
|
|
55
65
|
recipients = db
|
|
56
|
-
.prepare("SELECT id, name FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL")
|
|
66
|
+
.prepare("SELECT id, name, person FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL")
|
|
57
67
|
.all(account, agent.name);
|
|
58
68
|
}
|
|
59
69
|
const insert = db.prepare("INSERT INTO messages (recipient_id, sender, body, broadcast, created_at, in_reply_to) VALUES (?, ?, ?, ?, ?, ?)");
|
|
60
|
-
|
|
61
|
-
// it is waiting for mail (`markWaiting`), so `sendPending` leaves it to the waiting agent.
|
|
62
|
-
const queue = db.prepare(`INSERT INTO webhook_deliveries (webhook_id, message_id, created_at, held_for)
|
|
63
|
-
SELECT id, ?, ?, (SELECT a.id FROM agents a WHERE a.id = ? AND a.waiting_until > ?)
|
|
64
|
-
FROM webhooks WHERE account_id = ? AND event = 'agent' AND name = ? COLLATE NOCASE ORDER BY id`);
|
|
70
|
+
const queue = db.prepare(QUEUE_MESSAGE_SQL);
|
|
65
71
|
const now = Date.now();
|
|
66
72
|
const stored = [];
|
|
67
73
|
for (const r of recipients) {
|
|
68
74
|
const id = insert.run(r.id, from, body, broadcast ? 1 : 0, now, inReplyTo ?? null).lastInsertRowid;
|
|
69
|
-
queue.run(id, now, r.id, now, account, r.name);
|
|
75
|
+
queue.run(id, now, r.id, now, account, r.name, r.person);
|
|
70
76
|
stored.push({ id: Number(id), to: r.name });
|
|
71
77
|
}
|
|
72
78
|
// Sending clears the sender's status line, on its own registration only, so a send from a session whose agent was
|
package/dist/webhooks.js
CHANGED
|
@@ -11,7 +11,9 @@ import { MAX_INBOX_LIMIT } from "./messages.js";
|
|
|
11
11
|
// of its deliveries; its value is stored as is too, and never printed, logged or returned. And it may carry a limit on
|
|
12
12
|
// how many delivery requests it gets per window of 60 minutes; deliveries past it wait in the queue for a later window.
|
|
13
13
|
// And it may carry a fixed JSON body the owner sets, sent in place of the event body, signed the same way, with its one
|
|
14
|
-
// `{{delivery}}` replaced by an id of that delivery that every retry repeats
|
|
14
|
+
// `{{delivery}}` replaced by an id of that delivery that every retry repeats, and, for direct messages, each `{{agent}}`
|
|
15
|
+
// replaced by the name of the agent the message went to; the body is never printed or logged. `agent:*` is for direct
|
|
16
|
+
// messages to every agent of the account, stored with the name `*`, which no agent can have.
|
|
15
17
|
export class WebhookError extends Error {
|
|
16
18
|
}
|
|
17
19
|
/**
|
|
@@ -38,14 +40,18 @@ export function webhookUrl(raw) {
|
|
|
38
40
|
url.hash = "";
|
|
39
41
|
return url.href;
|
|
40
42
|
}
|
|
43
|
+
/** The name an `agent:*` webhook is stored with: it fires for a direct message to any agent of its account. */
|
|
44
|
+
export const EVERY_AGENT = "*";
|
|
41
45
|
/**
|
|
42
|
-
* Parses `--on`: `#<channel>`, `mention:<agent>` or `agent
|
|
43
|
-
* cannot be named `all`, so `mention:all` and `agent:all` are refused; `#all` is an ordinary channel.
|
|
46
|
+
* Parses `--on`: `#<channel>`, `mention:<agent>`, `agent:<agent>` or `agent:*`, each name following the agent-name
|
|
47
|
+
* rules. An agent cannot be named `all`, so `mention:all` and `agent:all` are refused; `#all` is an ordinary channel.
|
|
44
48
|
*/
|
|
45
49
|
export function webhookOn(on) {
|
|
50
|
+
if (on === `agent:${EVERY_AGENT}`)
|
|
51
|
+
return { event: "agent", name: EVERY_AGENT };
|
|
46
52
|
const [, prefix, name] = /^(#|mention:|agent:)(.*)$/s.exec(on) ?? [];
|
|
47
53
|
if (prefix === undefined || !NAME_PATTERN.test(name)) {
|
|
48
|
-
throw new WebhookError("Invalid --on: use #<channel>, mention:<agent> or agent
|
|
54
|
+
throw new WebhookError("Invalid --on: use #<channel>, mention:<agent>, agent:<agent> or agent:*, names of 1-32 letters, digits, '-' and '_'.");
|
|
49
55
|
}
|
|
50
56
|
if (prefix !== "#" && name.toLowerCase() === "all") {
|
|
51
57
|
throw new WebhookError("Invalid --on: 'all' is reserved and names no agent.");
|
|
@@ -106,17 +112,21 @@ export function webhookMaxPerHour(raw) {
|
|
|
106
112
|
}
|
|
107
113
|
/** What a fixed body's placeholder is replaced with: the delivery's id, the same on every attempt of it. */
|
|
108
114
|
export const WEBHOOK_PLACEHOLDER = "{{delivery}}";
|
|
115
|
+
/** What a fixed body's optional agent placeholder is replaced with, each time: the name of the message's recipient. */
|
|
116
|
+
export const WEBHOOK_AGENT_PLACEHOLDER = "{{agent}}";
|
|
109
117
|
/** The longest `--body`, in UTF-8 bytes. */
|
|
110
118
|
export const WEBHOOK_MAX_BODY_BYTES = 4096;
|
|
111
|
-
/** How many times `text` contains
|
|
112
|
-
const
|
|
119
|
+
/** How many times `text` contains `placeholder`. */
|
|
120
|
+
const count = (text, placeholder) => text.split(placeholder).length - 1;
|
|
113
121
|
/**
|
|
114
122
|
* Checks `--body`: a JSON object of at most `WEBHOOK_MAX_BODY_BYTES`, with `WEBHOOK_PLACEHOLDER` written as is exactly
|
|
115
123
|
* once in the text, inside a string value that parsing keeps (not in a key, and not dropped by a later duplicate key),
|
|
116
|
-
* and nowhere else in any parsed string or key, `\u` escapes included.
|
|
117
|
-
*
|
|
124
|
+
* and nowhere else in any parsed string or key, `\u` escapes included. With `agent`, for an `agent:` webhook,
|
|
125
|
+
* `WEBHOOK_AGENT_PLACEHOLDER` may also be written as is, any number of times, each inside a string value parsing keeps;
|
|
126
|
+
* without it, it may not appear at all. Returns it unchanged, so a delivery sends the owner's own bytes with only the
|
|
127
|
+
* placeholders replaced. Errors never repeat the body.
|
|
118
128
|
*/
|
|
119
|
-
export function webhookBody(raw) {
|
|
129
|
+
export function webhookBody(raw, agent = false) {
|
|
120
130
|
if (Buffer.byteLength(raw, "utf8") > WEBHOOK_MAX_BODY_BYTES) {
|
|
121
131
|
throw new WebhookError(`Invalid --body: it is over ${WEBHOOK_MAX_BODY_BYTES} bytes.`);
|
|
122
132
|
}
|
|
@@ -131,32 +141,57 @@ export function webhookBody(raw) {
|
|
|
131
141
|
throw new WebhookError("Invalid --body: give a JSON object.");
|
|
132
142
|
}
|
|
133
143
|
const misplaced = new WebhookError(`Invalid --body: put ${WEBHOOK_PLACEHOLDER} exactly once, inside a JSON string value.`);
|
|
134
|
-
if (
|
|
144
|
+
if (count(raw, WEBHOOK_PLACEHOLDER) !== 1)
|
|
135
145
|
throw misplaced;
|
|
136
|
-
|
|
137
|
-
|
|
146
|
+
const agents = count(raw, WEBHOOK_AGENT_PLACEHOLDER);
|
|
147
|
+
const agentMisplaced = agent
|
|
148
|
+
? new WebhookError(`Invalid --body: put each ${WEBHOOK_AGENT_PLACEHOLDER} inside a JSON string value.`)
|
|
149
|
+
: new WebhookError(`Invalid --body: ${WEBHOOK_AGENT_PLACEHOLDER} is only for --on agent:<agent> or agent:*.`);
|
|
150
|
+
if (agents > 0 && !agent)
|
|
151
|
+
throw agentMisplaced;
|
|
152
|
+
// Parsed again with each literal placeholder swapped for a value found nowhere else, as a delivery swaps it, so the
|
|
153
|
+
// count is of what a receiver parses: one sentinel in a string value, each agent sentinel in a string value, and no
|
|
154
|
+
// placeholder left in any string or key.
|
|
138
155
|
const sentinel = randomUUID();
|
|
156
|
+
const agentSentinel = randomUUID();
|
|
139
157
|
let sentinels = 0;
|
|
158
|
+
let agentSentinels = 0;
|
|
140
159
|
let others = 0;
|
|
160
|
+
let agentOthers = 0;
|
|
141
161
|
const walk = (v) => {
|
|
142
162
|
if (typeof v === "string") {
|
|
143
|
-
sentinels += v
|
|
144
|
-
|
|
163
|
+
sentinels += count(v, sentinel);
|
|
164
|
+
agentSentinels += count(v, agentSentinel);
|
|
165
|
+
others += count(v, WEBHOOK_PLACEHOLDER);
|
|
166
|
+
agentOthers += count(v, WEBHOOK_AGENT_PLACEHOLDER);
|
|
145
167
|
}
|
|
146
168
|
else if (Array.isArray(v))
|
|
147
169
|
v.forEach(walk);
|
|
148
170
|
else if (typeof v === "object" && v !== null) {
|
|
149
171
|
for (const [k, x] of Object.entries(v)) {
|
|
150
|
-
others +=
|
|
172
|
+
others += count(k, WEBHOOK_PLACEHOLDER) + count(k, sentinel);
|
|
173
|
+
agentOthers += count(k, WEBHOOK_AGENT_PLACEHOLDER) + count(k, agentSentinel);
|
|
151
174
|
walk(x);
|
|
152
175
|
}
|
|
153
176
|
}
|
|
154
177
|
};
|
|
155
|
-
walk(JSON.parse(raw.replace(WEBHOOK_PLACEHOLDER, () => sentinel)));
|
|
178
|
+
walk(JSON.parse(raw.replace(WEBHOOK_PLACEHOLDER, () => sentinel).replaceAll(WEBHOOK_AGENT_PLACEHOLDER, () => agentSentinel)));
|
|
156
179
|
if (sentinels !== 1 || others !== 0)
|
|
157
180
|
throw misplaced;
|
|
181
|
+
if (agentOthers !== 0 || agentSentinels !== agents)
|
|
182
|
+
throw agentMisplaced;
|
|
158
183
|
return raw;
|
|
159
184
|
}
|
|
185
|
+
/** True when `webhookBody` would take `raw` for an `agent:` webhook, so each `{{agent}}` in it is in a string value. */
|
|
186
|
+
function agentPlaceable(raw) {
|
|
187
|
+
try {
|
|
188
|
+
webhookBody(raw, true);
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
160
195
|
/** How `--on` names a webhook's event, for printing. */
|
|
161
196
|
export const formatOn = (w) => (w.event === "channel" ? `#${w.name}` : `${w.event}:${w.name}`);
|
|
162
197
|
/**
|
|
@@ -173,7 +208,7 @@ export const addWebhook = (db, account, url, on, includeText, header, maxPerHour
|
|
|
173
208
|
// A fixed body never carries the text, so --no-text with it would mean nothing.
|
|
174
209
|
if (body !== undefined && !includeText)
|
|
175
210
|
throw new WebhookError("--body and --no-text cannot go together: a fixed body never carries the text.");
|
|
176
|
-
const fixed = body === undefined ? null : webhookBody(body);
|
|
211
|
+
const fixed = body === undefined ? null : webhookBody(body, event === "agent");
|
|
177
212
|
const row = db.prepare("SELECT id, name FROM accounts WHERE name = ?").get(account);
|
|
178
213
|
if (!row)
|
|
179
214
|
throw new WebhookError(`No account named '${account}'.`);
|
|
@@ -419,6 +454,13 @@ export async function sendPending(db, options = {}) {
|
|
|
419
454
|
return;
|
|
420
455
|
// `webhookBody` checked that the placeholder is there once, inside a string, and a UUID needs no escaping.
|
|
421
456
|
text = row.fixed_body.replace(WEBHOOK_PLACEHOLDER, () => id.delivery_uuid);
|
|
457
|
+
// Each agent placeholder of an `agent:` webhook's body is inside a string too, and gets the name escaped as one.
|
|
458
|
+
// Only there, and only when the stored body passes today's check: a body stored before `{{agent}}` existed, for a
|
|
459
|
+
// `#channel` or `mention:` webhook or with the text in a key, keeps any such text as it is.
|
|
460
|
+
if (body.event === "agent" && agentPlaceable(row.fixed_body)) {
|
|
461
|
+
const agent = JSON.stringify(body.recipient).slice(1, -1);
|
|
462
|
+
text = text.replaceAll(WEBHOOK_AGENT_PLACEHOLDER, () => agent);
|
|
463
|
+
}
|
|
422
464
|
}
|
|
423
465
|
let ok = false;
|
|
424
466
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "message-cp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A messaging service for AI agents: register a name, find each other, send messages and be woken when mail arrives",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"mcpName": "io.github.colinhallett/message-cp",
|