agent-coord-mcp 0.26.32 → 0.26.34

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.
@@ -0,0 +1,125 @@
1
+ // ⟨q-0c5476ec⟩ — A SEAT'S IDENTITY IS STATED ONCE, AND THE TWO HALVES CANNOT DISAGREE SILENTLY.
2
+ //
3
+ // A seat's identity was typed twice: as an agent id at launch, and as a bearer inside a config.
4
+ // Every HTTP identity failure so far is that duplication. Measured on the other machine: a seat
5
+ // launched WITHOUT `--strict-mcp-config` took the GLOBAL config's bearer and came back as whoever
6
+ // owned it — three times in two days on three seats, with no in-session recovery; and a config
7
+ // holding a token `tokens.json` had since rotated returned a bare 401, with nothing in the config
8
+ // saying it was stale.
9
+ //
10
+ // So the launcher derives BOTH halves from one statement (the agent id), and refuses before start
11
+ // when a config on disk disagrees, NAMING WHICH HALF disagreed. Refusing at launch is the point: a
12
+ // 401 later is the same fault read as a broken daemon.
13
+ //
14
+ // ⛔ NO SECRET IN ANY MESSAGE. Not the token, not a prefix, not a hash: a refusal a human pastes
15
+ // into a bus message must not carry the bearer. The messages name AGENTS and FILES, never bytes.
16
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import path from "node:path";
19
+
20
+ /** Must match store.ts ROOT/TOKENS_FILE resolution exactly, or we read a file the bus never wrote. */
21
+ export function coordRoot({ dir, env = process.env }: { dir?: string; env?: NodeJS.ProcessEnv } = {}): string {
22
+ return dir ?? env.AGENT_COORD_DIR ?? env.CLAUDE_COORD_DIR ?? path.join(homedir(), "agent-coord");
23
+ }
24
+ export const tokensFile = (opts?: { dir?: string; env?: NodeJS.ProcessEnv }) => path.join(coordRoot(opts), "tokens.json");
25
+
26
+ /** `{ "agent-id": "token" }`, or `{}` when absent. Throws on malformed, never on missing. */
27
+ export function loadTokens(file: string): Record<string, string> {
28
+ if (!existsSync(file)) return {};
29
+ const parsed: unknown = JSON.parse(readFileSync(file, "utf8"));
30
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
31
+ throw new Error(`${file} must be a JSON object of { "agent-id": "token" }`);
32
+ }
33
+ return parsed as Record<string, string>;
34
+ }
35
+
36
+ /**
37
+ * Write a project-local `.mcp.json` (⟨q-8c23e5a3⟩ acceptance (a)) whose `agent-coord` entry embeds
38
+ * this seat's own token — never a shared default, so a project that gets its OWN config never
39
+ * depends on (or silently becomes) a global fallback. mode 600, same as tokens.json.
40
+ * `pane` is recorded under `_agentCoordPane`, a key no MCP client reads or writes: it exists so a
41
+ * re-run, or `coord-seat`, can recover the pane a config was minted for without re-asking.
42
+ */
43
+ export function writeMcpConfig({ dir, agentId, server, token, pane }: { dir: string; agentId: string; server: string; token: string; pane?: string | null }): string {
44
+ mkdirSync(dir, { recursive: true });
45
+ const configPath = path.join(dir, ".mcp.json");
46
+ let existing: Record<string, any> = {};
47
+ if (existsSync(configPath)) {
48
+ try {
49
+ existing = JSON.parse(readFileSync(configPath, "utf8"));
50
+ } catch (e) {
51
+ throw new Error(`${configPath} exists and is not valid JSON (${(e as Error).message}) — refusing to overwrite blindly`);
52
+ }
53
+ }
54
+ const config: Record<string, any> = {
55
+ ...existing,
56
+ mcpServers: {
57
+ ...(existing.mcpServers ?? {}),
58
+ "agent-coord": {
59
+ type: "http",
60
+ url: server,
61
+ headers: { Authorization: `Bearer ${token}` },
62
+ ...(pane ? { _agentCoordPane: pane } : {}),
63
+ },
64
+ },
65
+ };
66
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
67
+ chmodSync(configPath, 0o600);
68
+ return configPath;
69
+ }
70
+
71
+ /** The agent-coord entry a config states, or null when there is no config / no such entry. */
72
+ export type ConfigIdentity = { token: string | null; url: string | null; pane: string | null };
73
+ export function readConfigIdentity(configPath: string): ConfigIdentity | null {
74
+ if (!existsSync(configPath)) return null;
75
+ let parsed: any;
76
+ try {
77
+ parsed = JSON.parse(readFileSync(configPath, "utf8"));
78
+ } catch (e) {
79
+ throw new Error(`${configPath} is not valid JSON (${(e as Error).message}) — refusing to read an identity out of it`);
80
+ }
81
+ const entry = parsed?.mcpServers?.["agent-coord"];
82
+ if (!entry) return null;
83
+ const auth = String(entry.headers?.Authorization ?? "");
84
+ const m = /^Bearer\s+(.+)$/.exec(auth);
85
+ return { token: m ? m[1].trim() : null, url: entry.url ?? null, pane: entry._agentCoordPane ?? null };
86
+ }
87
+
88
+ /**
89
+ * Does the config on disk state the same seat the launch claims?
90
+ *
91
+ * Returns null when they agree (or when there is no config to disagree — the launcher writes one
92
+ * from the single statement). Otherwise `{ half, message }`, where `half` names the side that is
93
+ * wrong relative to `tokens.json`, which is the bus's own map and therefore the authority.
94
+ */
95
+ export type IdentityProblem = { half: "config" | "tokens"; message: string };
96
+ export function identityProblem({ agentId, configPath, config, tokens, tokensPath }: { agentId: string; configPath: string; config: { token?: string | null } | null; tokens: Record<string, string>; tokensPath: string }): IdentityProblem | null {
97
+ const expected = tokens?.[agentId];
98
+ if (!config) return null;
99
+ if (!config.token) {
100
+ return {
101
+ half: "config",
102
+ message:
103
+ `${configPath} has an agent-coord entry with no bearer, so it states no identity, while the launch claims '${agentId}'. ` +
104
+ `Delete it and relaunch through coord-seat, which writes both halves from the one statement.`,
105
+ };
106
+ }
107
+ if (!expected) {
108
+ return {
109
+ half: "tokens",
110
+ message:
111
+ `${tokensPath} holds no token for '${agentId}', so the launch identity cannot be honoured, while ${configPath} carries a bearer. ` +
112
+ `Mint one with \`coord-token add ${agentId}\` (it rotates if one exists) and relaunch, or launch the agent whose token that config holds.`,
113
+ };
114
+ }
115
+ if (config.token === expected) return null;
116
+ const ownerOfConfigToken = Object.keys(tokens).find((a) => tokens[a] === config.token);
117
+ return {
118
+ half: "config",
119
+ message: ownerOfConfigToken
120
+ ? `${configPath} carries the bearer of '${ownerOfConfigToken}', but the launch claims '${agentId}' — the two halves of this seat's identity disagree. ` +
121
+ `Launch '${ownerOfConfigToken}', or let coord-seat rewrite the config for '${agentId}'.`
122
+ : `${configPath} carries a bearer that ${tokensPath} does not know — it was rotated or revoked since the config was written, while the launch claims '${agentId}'. ` +
123
+ `Relaunch through coord-seat, which rewrites the config from the token '${agentId}' has now.`,
124
+ };
125
+ }
package/src/server.ts CHANGED
@@ -18,6 +18,8 @@ import { capabilitiesSchema, capabilitiesTool } from "./capabilities.js";
18
18
  import {
19
19
  ensureDirs,
20
20
  getTokenMap,
21
+ agentForBearer,
22
+ tokenMapCensusLine,
21
23
  TOKENS_FILE,
22
24
  reloadTokenMapSync,
23
25
  refreshTokenMapIfChanged,
@@ -264,7 +266,8 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
264
266
  async function guardFirstClaim(claimed: string, args: Record<string, unknown>): Promise<string> {
265
267
  const token = typeof args["token"] === "string" ? (args["token"] as string) : undefined;
266
268
  if (token !== undefined) {
267
- if (getTokenMap()?.get(token) === claimed) return "token";
269
+ // ⟨q-4b9cdeab⟩ through agentForBearer, which hashes: the map holds hashes, never the secret.
270
+ if (agentForBearer(token) === claimed) return "token";
268
271
  throw new Error(
269
272
  `token presented for '${claimed}' does not match tokens.json (or no token map is loaded). ` +
270
273
  `Mint one with scripts/coord-token.mjs add ${claimed} (then SIGHUP the bus), or pass force:true if you are certain.`,
@@ -844,6 +847,10 @@ function loadTokenMap(initial: boolean): void {
844
847
  }
845
848
  if (!initial) {
846
849
  console.error(`[agent-coord-mcp] SIGHUP: token map reloaded (${getTokenMap()?.size ?? 0} agents)`);
850
+ // ⟨q-4b9cdeab⟩ say how many entries are still plaintext, so the migration is visible without
851
+ // anyone reading the file — and without this line ever naming a bearer, a prefix or a hash.
852
+ const hupCensus = tokenMapCensusLine();
853
+ if (hupCensus) console.error(hupCensus);
847
854
  }
848
855
  }
849
856
 
@@ -1096,6 +1103,8 @@ async function startHttp(port: number): Promise<void> {
1096
1103
  function reportTokenRefresh(r: TokenMapRefresh): void {
1097
1104
  if (r.changed) {
1098
1105
  console.error(`[agent-coord-mcp] token map reloaded (${r.count} agents) — ${TOKENS_FILE} ${r.reason}`);
1106
+ const census = tokenMapCensusLine();
1107
+ if (census) console.error(census);
1099
1108
  return;
1100
1109
  }
1101
1110
  if (r.refused) {
@@ -1117,12 +1126,12 @@ async function startHttp(port: number): Promise<void> {
1117
1126
  reportTokenRefresh(refreshTokenMapIfChanged());
1118
1127
  const tokenMap = getTokenMap();
1119
1128
  if (tokenMap) {
1120
- let agent = tokenMap.get(bearer);
1129
+ let agent = agentForBearer(bearer);
1121
1130
  if (!agent) {
1122
1131
  // An unknown bearer is exactly the symptom of a token minted since the last check, so it
1123
1132
  // forces one check past the rate limit. It cannot grant anything the file does not hold.
1124
1133
  reportTokenRefresh(refreshTokenMapIfChanged({ force: true }));
1125
- agent = getTokenMap()?.get(bearer);
1134
+ agent = agentForBearer(bearer);
1126
1135
  }
1127
1136
  // ⛔ THE ONLY PLACE A PRE-BOUND SEAT CAN GET A TAIL. The `startTailOnBind` sites in the tool
1128
1137
  // path all sit behind `bound === undefined` — the TOFU claim. Here identity arrives already
@@ -1205,6 +1214,10 @@ async function startHttp(port: number): Promise<void> {
1205
1214
  };
1206
1215
 
1207
1216
  const mode = bound ? `pre-bound (${getTokenMap()?.size ?? 0} agents)` : "TOFU";
1217
+ {
1218
+ const startupCensus = tokenMapCensusLine();
1219
+ if (startupCensus) console.error(startupCensus);
1220
+ }
1208
1221
  await Promise.all(
1209
1222
  bindAddrs.map(
1210
1223
  (addr) =>
package/src/store.ts CHANGED
@@ -153,10 +153,86 @@ export function ensureDirs(): void {
153
153
  }
154
154
  }
155
155
 
156
- // Synchronous, deliberate. The result feeds the server's bearer→agent
157
- // reverse-lookup map; we want startup to fail loudly on a malformed file
158
- // rather than silently degrade to advisory mode. Returns null if the file
159
- // is absent (operator hasn't configured binding yet).
156
+ // ⟨q-4b9cdeab⟩ RELEASE A — THE READER ACCEPTS BOTH FORMS AND WRITES NEITHER.
157
+ //
158
+ // `tokens.json` holds every seat's bearer in the clear, so one readable file is the whole fleet's
159
+ // identity. The fix is that the file stores a HASH while the seat's config keeps the secret. Because
160
+ // 13 live HTTP seats depend on this file's shape, the migration is two-form first (David's ruling,
161
+ // 2026-09-23, docs/notes/Q_4B9CDEAB_HASHED_TOKENS_PLAN.md): this release READS both forms and writes
162
+ // NEITHER. No migration command, no hashed file written, no cutover. `coord-token` keeps minting
163
+ // plaintext.
164
+ //
165
+ // v1 (today): { "alice": "tk_<secret>" }
166
+ // v2 (hashed): { "alice": { "alg": "sha256", "hash": "<hex>", "createdAt": "…", "previous": {…} } }
167
+ //
168
+ // The two are told apart by the VALUE TYPE, so an untouched file keeps working with no version field.
169
+ //
170
+ // ⭐ ONE LOOKUP PATH FOR BOTH FORMS: a v1 entry is HASHED AT LOAD, so the in-process map is always
171
+ // hash → agentId and `agentForBearer` hashes whatever is presented. There is no second comparison to
172
+ // drift, and no branch where a plaintext file authenticates differently from a hashed one.
173
+ //
174
+ // ⛔ AN ENTRY THIS BUILD CANNOT EVALUATE REFUSES THE WHOLE FILE, BY NAME. An unknown `alg` is never
175
+ // ignored (which would silently drop a seat) and never treated as plaintext (which would compare a
176
+ // bearer against a hash and lock that seat out while looking fine). Same for a missing or malformed
177
+ // hash. The refusal names the AGENT and the ALG — never any part of a bearer, and never the hash.
178
+ //
179
+ // ⚠ `previous` (the rotation grace window) is NOT honoured in release A: it is written by nothing in
180
+ // this release, so accepting it would be accepting a shape no tool here produces. A bearer that
181
+ // matches only `previous` does not authenticate, and that is asserted rather than left implicit.
182
+ //
183
+ // Synchronous, deliberate: startup fails loudly on a malformed file rather than silently degrading
184
+ // to advisory mode. Returns null if the file is absent (binding not configured).
185
+ /** The hash algorithms this build can evaluate. An entry naming anything else refuses the file. */
186
+ export const TOKEN_HASH_ALGS = Object.freeze(["sha256"] as const);
187
+ const HEX64 = /^[0-9a-f]{64}$/;
188
+ /**
189
+ * ⛔ sha256 OF THE EMPTY STRING. v1 refused an empty token STRUCTURALLY (`value.length === 0`); the
190
+ * hashed form has no such shape, because this hex is as well-formed as any other. It is the exact
191
+ * artefact of an ordinary shell slip during the hand migration this release exists for —
192
+ * `printf '%s' "$TOK" | shasum -a 256` with `$TOK` unset or mistyped writes precisely this — and an
193
+ * entry holding it authenticates an `Authorization: Bearer ` header with nothing after it.
194
+ */
195
+ const EMPTY_BEARER_HASH = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
196
+
197
+ /** sha256 hex of a bearer. Unsalted on purpose: a bearer is 24 CSPRNG bytes, so there is no
198
+ * dictionary to run, and a per-entry salt would force a linear scan of every entry on every
199
+ * request. See §2 of the plan for the condition that would make that choice wrong. */
200
+ export function bearerHash(bearer: string): string {
201
+ return createHash("sha256").update(bearer, "utf8").digest("hex");
202
+ }
203
+
204
+ /** How many entries of each form the loaded map came from — what the census line reports. */
205
+ export type TokenMapCensus = { total: number; hashed: number; plaintext: number };
206
+ let tokenMapCensus: TokenMapCensus = { total: 0, hashed: 0, plaintext: 0 };
207
+ export function getTokenMapCensus(): TokenMapCensus {
208
+ return { ...tokenMapCensus };
209
+ }
210
+
211
+ function refuse(detail: string): never {
212
+ throw new Error(`[agent-coord-mcp] ${TOKENS_FILE}: ${detail}`);
213
+ }
214
+
215
+ /**
216
+ * Insert a hash → agentId claim, REFUSING a hash two agents both claim.
217
+ *
218
+ * ⛔ LAST-WINS WOULD RE-ATTRIBUTE A LIVE BEARER. Under v1 a collision meant two identical plaintext
219
+ * strings, visible to anyone reading the file. With one side hashed it is invisible on inspection —
220
+ * a property this release removed, so the reader has to put it back. The invariant this keeps is
221
+ * `map.size === census.total`: every entry claims its own key or the file is refused.
222
+ */
223
+ function claim(out: Map<string, string>, hash: string, agentId: string): void {
224
+ const held = out.get(hash);
225
+ if (held !== undefined) {
226
+ refuse(
227
+ `agents "${held}" and "${agentId}" both resolve to the SAME token hash. One bearer cannot ` +
228
+ `authenticate two identities: whichever entry loaded last would silently take the other's ` +
229
+ `traffic, and with one side hashed the clash is invisible in the file. Give each agent its own token.`,
230
+ );
231
+ }
232
+ out.set(hash, agentId);
233
+ }
234
+
235
+ /** Read the file into a hash → agentId map, accepting v1 and v2 entries. NEVER writes. */
160
236
  export function readTokenMapSync(): Map<string, string> | null {
161
237
  if (!existsSync(TOKENS_FILE)) return null;
162
238
  const raw = readFileSync(TOKENS_FILE, "utf8");
@@ -171,21 +247,89 @@ export function readTokenMapSync(): Map<string, string> | null {
171
247
  }
172
248
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
173
249
  throw new Error(
174
- `[agent-coord-mcp] ${TOKENS_FILE} must be a JSON object mapping agentId → token.`,
250
+ `[agent-coord-mcp] ${TOKENS_FILE} must be a JSON object mapping agentId → token or hashed entry.`,
175
251
  );
176
252
  }
177
253
  const out = new Map<string, string>();
178
- for (const [agentId, token] of Object.entries(parsed as Record<string, unknown>)) {
179
- if (typeof token !== "string" || token.length === 0) {
180
- throw new Error(
181
- `[agent-coord-mcp] ${TOKENS_FILE}: agent "${agentId}" has a non-string/empty token.`,
254
+ const census: TokenMapCensus = { total: 0, hashed: 0, plaintext: 0 };
255
+ for (const [agentId, value] of Object.entries(parsed as Record<string, unknown>)) {
256
+ census.total += 1;
257
+ if (typeof value === "string") {
258
+ // v1: the secret itself is on disk. Hash it here so the map has ONE shape.
259
+ if (value.length === 0) refuse(`agent "${agentId}" has a non-string/empty token.`);
260
+ claim(out, bearerHash(value), agentId);
261
+ census.plaintext += 1;
262
+ continue;
263
+ }
264
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
265
+ refuse(`agent "${agentId}" has a non-string/empty token.`);
266
+ }
267
+ const entry = value as { alg?: unknown; hash?: unknown };
268
+ const alg = entry.alg;
269
+ if (typeof alg !== "string" || !(TOKEN_HASH_ALGS as readonly string[]).includes(alg)) {
270
+ refuse(
271
+ `agent "${agentId}" uses hash alg ${JSON.stringify(alg ?? null)}, which this build cannot evaluate ` +
272
+ `(known: ${TOKEN_HASH_ALGS.join(", ")}). Refusing the whole file: ignoring the entry would drop that ` +
273
+ `seat silently, and treating it as a plaintext token would compare a bearer against a hash and lock ` +
274
+ `the seat out while every file on disk looked correct.`,
182
275
  );
183
276
  }
184
- out.set(token, agentId);
277
+ if (typeof entry.hash !== "string" || !HEX64.test(entry.hash)) {
278
+ refuse(`agent "${agentId}" has a ${alg} entry whose hash is not 64 lowercase hex characters.`);
279
+ }
280
+ if (entry.hash === EMPTY_BEARER_HASH) {
281
+ refuse(
282
+ `agent "${agentId}" has a ${alg} entry whose hash is sha256 of the EMPTY STRING. An empty ` +
283
+ `Authorization bearer would authenticate as that agent. The plaintext form refused an empty ` +
284
+ `token by its shape; this hex is well-formed, so it is refused by name instead. It is what ` +
285
+ `hashing an unset or mistyped shell variable produces — re-hash the real token.`,
286
+ );
287
+ }
288
+ claim(out, entry.hash, agentId);
289
+ census.hashed += 1;
185
290
  }
291
+ tokenMapCensus = census;
186
292
  return out;
187
293
  }
188
294
 
295
+ /** The agent a presented bearer belongs to, or undefined. The ONE place a bearer is compared. */
296
+ export function agentForBearer(bearer: string): string | undefined {
297
+ // ⚠ NO SEPARATE EMPTY-BEARER GUARD HERE, DELIBERATELY. An empty `Authorization: Bearer ` slices to
298
+ // "" and hashes to sha256(""), and the reader REFUSES any entry carrying that hash — so the map can
299
+ // never hold the only key an empty bearer could match. A guard here would therefore be unkillable
300
+ // by any test: I added one, the mutation matrix showed removing it changed nothing, and a conjunct
301
+ // no test can fire belongs in a comment rather than in the code. The enforcement point is the
302
+ // reader's refusal, and it is tested.
303
+ //
304
+ // ⛔ WHAT THAT DELETION COSTS, NAMED BECAUSE NOTHING ASSERTS IT: the guarantee is no longer local to
305
+ // this function. It holds only while `readTokenMapSync` is the map's ONLY PRODUCER — true today at
306
+ // all three assignments to `tokenMapCache` (the lazy first load, `reloadTokenMapSync`, and the
307
+ // refresh, which reads through `readTokenMapSync`). TWO changes void it, and BOTH are silent because
308
+ // the guard that would have caught them was deleted for being unfireable:
309
+ // 1. LOOSENING THE REFUSAL — if an entry carrying sha256("") stops being refused;
310
+ // 2. ⛔ A SECOND PRODUCER — anything that populates or mutates the map outside `readTokenMapSync`.
311
+ // This is the one release B actually SCHEDULES: `coord-token migrate` writes hashed entries, and
312
+ // a migrate path that seeds or patches the in-process map rather than re-reading the file
313
+ // reintroduces the empty-bearer key with nothing between it and the auth path.
314
+ // Either one is where the guard goes back — with a test that can fail. If you are here writing
315
+ // `coord-token migrate`, this paragraph is addressed to you.
316
+ return getTokenMap()?.get(bearerHash(bearer));
317
+ }
318
+
319
+ /** One line an operator can act on. Says nothing about any bearer, prefix or hash. */
320
+ export function tokenMapCensusLine(): string | null {
321
+ const map = getTokenMap();
322
+ if (!map) return null;
323
+ const c = getTokenMapCensus();
324
+ const tail =
325
+ c.total === 0
326
+ ? " — no entries: every bearer will be refused"
327
+ : c.plaintext > 0
328
+ ? ` — ${c.plaintext} still plaintext, run coord-token migrate`
329
+ : " — all hashed";
330
+ return `[agent-coord-mcp] token map: ${c.total} entries (${c.hashed} hashed, ${c.plaintext} plaintext)${tail}`;
331
+ }
332
+
189
333
  // In-process cache of the token map, shared by the HTTP identity-binding
190
334
  // layer (server.ts) and rotateAgentToken below. A module-level cache (rather
191
335
  // than each caller re-reading the file) is what lets a rename refresh the