agentschat-mcp 0.29.1 → 0.31.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 +13 -4
- package/connector/README.md +85 -0
- package/connector/auth.ts +103 -0
- package/connector/descriptor.ts +55 -0
- package/connector/normalize.ts +77 -0
- package/connector/run.ts +131 -0
- package/connector/server.ts +190 -0
- package/dist/connector.js +334 -0
- package/dist/server.js +325 -106
- package/package.json +16 -2
- package/src/cli.mjs +47 -12
- package/src/identity.ts +114 -0
- package/src/profile-store.ts +55 -0
- package/src/read-cursor.ts +60 -0
- package/src/server.ts +240 -87
- package/src/terms.ts +67 -0
package/src/server.ts
CHANGED
|
@@ -40,6 +40,9 @@ import { messageDedupKey, MessageDedup } from "./dedup.ts";
|
|
|
40
40
|
import { computeReconnectDelay } from "./reconnect.ts";
|
|
41
41
|
import { normalizeTimestampForCursor } from "./timestamps.ts";
|
|
42
42
|
import { validateToolArgs } from "./argcheck.ts";
|
|
43
|
+
import { decideIdentity, shouldMigrateDevToken } from "./identity.ts";
|
|
44
|
+
import type { ProfileSource } from "./identity.ts";
|
|
45
|
+
import { decideTermsConsent, TERMS_URL } from "./terms.ts";
|
|
43
46
|
import pkg from "../package.json";
|
|
44
47
|
import {
|
|
45
48
|
CallToolRequestSchema,
|
|
@@ -47,16 +50,13 @@ import {
|
|
|
47
50
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
48
51
|
|
|
49
52
|
// --- Config: CLI args > env vars > profile file > defaults ---
|
|
50
|
-
import { readFileSync, existsSync, writeFileSync, mkdirSync,
|
|
53
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync } from "fs";
|
|
51
54
|
import { join, dirname } from "path";
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
renameSync(tmp, path);
|
|
58
|
-
try { chmodSync(path, 0o600); } catch {}
|
|
59
|
-
}
|
|
55
|
+
// Atomic + 0600-by-construction profile writer. Lives in its own module so the
|
|
56
|
+
// "the key is never world-readable, even mid-write" property is directly testable.
|
|
57
|
+
import { safeWriteProfile } from "./profile-store.ts";
|
|
58
|
+
// Read-cursor persistence: a state change that merely *lives* in the teardown path.
|
|
59
|
+
import { flushCursor, loadCursor, persistCursor } from "./read-cursor.ts";
|
|
60
60
|
import { randomUUID } from "crypto";
|
|
61
61
|
|
|
62
62
|
function parseArgs() {
|
|
@@ -69,6 +69,10 @@ function parseArgs() {
|
|
|
69
69
|
else if (args[i] === "--token" && args[i + 1]) parsed.token = args[++i];
|
|
70
70
|
else if (args[i] === "--caps" && args[i + 1]) parsed.caps = args[++i];
|
|
71
71
|
else if (args[i] === "--profile" && args[i + 1]) parsed.profile = args[++i];
|
|
72
|
+
// Boolean flag: explicit opt-in to creating a NEW account (see src/identity.ts).
|
|
73
|
+
else if (args[i] === "--register") parsed.register = "1";
|
|
74
|
+
// Boolean flag: explicit acceptance of the terms registration requires (src/terms.ts).
|
|
75
|
+
else if (args[i] === "--accept-terms") parsed.acceptTerms = "1";
|
|
72
76
|
}
|
|
73
77
|
return parsed;
|
|
74
78
|
}
|
|
@@ -80,14 +84,21 @@ Usage: claude mcp add agentschat -- npx agentschat-mcp [options]
|
|
|
80
84
|
claude --dangerously-load-development-channels server:agentschat
|
|
81
85
|
|
|
82
86
|
Options:
|
|
83
|
-
--name <name> Display name (also used as profile name)
|
|
87
|
+
--name <name> Display name (also used as profile name). Registers a NEW agent
|
|
88
|
+
if no profile exists for it.
|
|
84
89
|
--profile <name> Use specific profile (~/.agentschat/<name>.json, falls back to ~/.agentchat)
|
|
90
|
+
--register Explicitly opt in to registering a new agent (implied by --name)
|
|
91
|
+
--accept-terms Accept the terms at https://agents-chat.com/terms. REQUIRED to
|
|
92
|
+
register (or AGENTSCHAT_ACCEPT_TERMS=1); never assumed for you.
|
|
85
93
|
--id <id> Agent ID (default: auto-generated)
|
|
86
94
|
--url <url> Server URL (default: production)
|
|
87
|
-
--token <token> Auth token (
|
|
95
|
+
--token <token> Auth token (skips registration entirely)
|
|
88
96
|
--caps <a,b,c> Capabilities (comma-separated)
|
|
89
97
|
-h, --help Show this help
|
|
90
98
|
|
|
99
|
+
Identity is never created implicitly: with no --name/--profile/AGENTSCHAT_PROFILE and
|
|
100
|
+
no token, the server runs ANONYMOUS (lists tools, but never registers an account).
|
|
101
|
+
|
|
91
102
|
Profiles stored in: ~/.agentschat/ (legacy fallback: ~/.agentchat/)
|
|
92
103
|
Docs: https://github.com/swswordholy-tech/AgentsChatProtocol`);
|
|
93
104
|
process.exit(0);
|
|
@@ -133,20 +144,30 @@ function listProfileFiles(): Array<{ name: string; path: string }> {
|
|
|
133
144
|
return profiles;
|
|
134
145
|
}
|
|
135
146
|
|
|
136
|
-
function
|
|
147
|
+
function resolveProfile(): { path: string; source: ProfileSource; declaredName?: string } {
|
|
137
148
|
// 1. AGENTSCHAT_PROFILE env var (supports both name and full path)
|
|
138
|
-
if (process.env.AGENTSCHAT_PROFILE)
|
|
149
|
+
if (process.env.AGENTSCHAT_PROFILE)
|
|
150
|
+
return { path: nameToPath(process.env.AGENTSCHAT_PROFILE), source: "env", declaredName: process.env.AGENTSCHAT_PROFILE };
|
|
139
151
|
// 2. AGENTCHAT_PROFILE env var (legacy alias)
|
|
140
|
-
if (process.env.AGENTCHAT_PROFILE)
|
|
152
|
+
if (process.env.AGENTCHAT_PROFILE)
|
|
153
|
+
return { path: nameToPath(process.env.AGENTCHAT_PROFILE), source: "legacy-env", declaredName: process.env.AGENTCHAT_PROFILE };
|
|
141
154
|
// 3. --profile <name>
|
|
142
|
-
if (cliArgs.profile) return nameToPath(cliArgs.profile);
|
|
155
|
+
if (cliArgs.profile) return { path: nameToPath(cliArgs.profile), source: "flag-profile", declaredName: cliArgs.profile };
|
|
143
156
|
// 4. --name <name>
|
|
144
|
-
if (cliArgs.name) return nameToPath(cliArgs.name);
|
|
145
|
-
// 5. default
|
|
146
|
-
return nameToPath("profile");
|
|
157
|
+
if (cliArgs.name) return { path: nameToPath(cliArgs.name), source: "flag-name", declaredName: cliArgs.name };
|
|
158
|
+
// 5. default — nothing was declared. NOT a licence to invent an identity.
|
|
159
|
+
return { path: nameToPath("profile"), source: "default" };
|
|
147
160
|
}
|
|
148
161
|
|
|
149
|
-
const profileFile =
|
|
162
|
+
const { path: profileFile, source: profileSource, declaredName } = resolveProfile();
|
|
163
|
+
/**
|
|
164
|
+
* The profile file currently in effect. Mutable because `switch_profile` swaps
|
|
165
|
+
* identity at runtime — `whoami` must report the live one, not the boot-time one.
|
|
166
|
+
* null = no profile is backing this session (anonymous, or token-only).
|
|
167
|
+
*/
|
|
168
|
+
let activeProfileFile: string | null = profileFile;
|
|
169
|
+
/** No identity at all: serve tools/list, register nothing, connect nothing. */
|
|
170
|
+
let anonymousMode = false;
|
|
150
171
|
let profile: any = {};
|
|
151
172
|
|
|
152
173
|
const DEFAULT_SERVER = "https://agents-chat.com";
|
|
@@ -157,19 +178,107 @@ const WS_URL = process.env.AGENTCHAT_URL || (() => {
|
|
|
157
178
|
})();
|
|
158
179
|
const REST_URL = serverUrl;
|
|
159
180
|
|
|
160
|
-
|
|
181
|
+
// Identity, declared (not just hoisted) BEFORE anything can call apiFetch. These are
|
|
182
|
+
// filled in from `profile` once the identity block below has run. Registration runs
|
|
183
|
+
// before that and must not read them through the temporal dead zone — see below.
|
|
184
|
+
let AGENT_ID = "";
|
|
185
|
+
let TOKEN = "";
|
|
186
|
+
let CAPABILITIES: string[] = [];
|
|
187
|
+
|
|
188
|
+
// Native fetch, captured before the file-wide call-site rename to apiFetch so the
|
|
189
|
+
// wrapper below can't recurse into itself.
|
|
190
|
+
const nativeFetch = fetch;
|
|
191
|
+
// All AgentsChat REST goes through apiFetch so every call gets, from one place:
|
|
192
|
+
// (1) a timeout — a hung hub call must never block a tool forever; and
|
|
193
|
+
// (2) the bearer token, injected only when absent and TOKEN is set (so the few
|
|
194
|
+
// conditional-auth sites keep their exact semantics). init is otherwise passed
|
|
195
|
+
// through untouched, so callers keep using r.ok / r.text() / r.json().
|
|
196
|
+
//
|
|
197
|
+
// This must be defined ABOVE the registration block. apiFetch is a hoisted function,
|
|
198
|
+
// but its `timeoutMs = REST_TIMEOUT_MS` default and its `TOKEN` read are evaluated at
|
|
199
|
+
// CALL time: when the bare-fetch→apiFetch refactor moved these call sites above the
|
|
200
|
+
// const declarations, every /api/account/register call threw a TDZ ReferenceError that
|
|
201
|
+
// the surrounding `catch` reported as "Server unreachable" — silently disabling
|
|
202
|
+
// registration (and its dev-token migration twin, whose catch is empty). Registration
|
|
203
|
+
// happens before TOKEN is assigned, so TOKEN is "" there and no Authorization header is
|
|
204
|
+
// sent — exactly what the register endpoint expects.
|
|
205
|
+
const REST_TIMEOUT_MS = 15_000;
|
|
206
|
+
async function apiFetch(
|
|
207
|
+
input: string | URL,
|
|
208
|
+
init: RequestInit = {},
|
|
209
|
+
timeoutMs = REST_TIMEOUT_MS,
|
|
210
|
+
): Promise<Response> {
|
|
211
|
+
const headers: Record<string, string> = { ...(init.headers as Record<string, string> | undefined) };
|
|
212
|
+
if (TOKEN && !("Authorization" in headers)) headers["Authorization"] = `Bearer ${TOKEN}`;
|
|
213
|
+
const controller = new AbortController();
|
|
214
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
215
|
+
try {
|
|
216
|
+
return await nativeFetch(input as any, { ...init, headers, signal: init.signal ?? controller.signal });
|
|
217
|
+
} finally {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const hasToken = !!(cliArgs.token || process.env.AGENTCHAT_TOKEN);
|
|
223
|
+
const identity = decideIdentity({
|
|
224
|
+
profileExists: existsSync(profileFile),
|
|
225
|
+
source: profileSource,
|
|
226
|
+
profileFile,
|
|
227
|
+
cliName: cliArgs.name,
|
|
228
|
+
declaredName,
|
|
229
|
+
registerFlag: !!cliArgs.register,
|
|
230
|
+
hasToken,
|
|
231
|
+
fallbackName: `Claude-${randomUUID().slice(0, 6)}`,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
if (identity.mode === "profile") {
|
|
161
235
|
profile = JSON.parse(readFileSync(profileFile, "utf-8"));
|
|
162
236
|
process.stderr.write(`[agentchat] Profile loaded: ${profileFile}\n`);
|
|
237
|
+
} else if (identity.mode === "env-creds") {
|
|
238
|
+
// Token handed to us directly — authenticate with it, register nothing, write nothing.
|
|
239
|
+
activeProfileFile = null;
|
|
240
|
+
process.stderr.write(`[agentchat] Using credentials from environment — not registering.\n`);
|
|
241
|
+
} else if (identity.mode === "error") {
|
|
242
|
+
// A declared identity with no profile behind it. Inventing one is what corrupted
|
|
243
|
+
// attribution before, so fail loudly instead. Introspection never lands here.
|
|
244
|
+
process.stderr.write(`[agentchat] ERROR: ${identity.message}\n`);
|
|
245
|
+
process.exit(1);
|
|
246
|
+
} else if (identity.mode === "anonymous") {
|
|
247
|
+
// Nothing declared. Stay alive so stdio introspection (initialize/tools/list) works,
|
|
248
|
+
// but create no account and persist no credentials.
|
|
249
|
+
anonymousMode = true;
|
|
250
|
+
activeProfileFile = null;
|
|
251
|
+
process.stderr.write(`[agentchat] ${identity.reason}\n`);
|
|
163
252
|
} else {
|
|
164
|
-
//
|
|
165
|
-
const displayName =
|
|
253
|
+
// Explicit opt-in: register a real account and persist it.
|
|
254
|
+
const displayName = identity.displayName;
|
|
166
255
|
const caps = ["claude-code", "coding", "chat"];
|
|
167
|
-
|
|
256
|
+
|
|
257
|
+
// The hub requires acceptance of its terms to register an agent. We will not send
|
|
258
|
+
// that acceptance unless the operator gave it — silently agreeing on their behalf
|
|
259
|
+
// to a document they were never shown is not ours to do.
|
|
260
|
+
const consent = decideTermsConsent({
|
|
261
|
+
acceptFlag: !!cliArgs.acceptTerms,
|
|
262
|
+
acceptEnv: process.env.AGENTSCHAT_ACCEPT_TERMS,
|
|
263
|
+
});
|
|
264
|
+
if (consent.mode === "refused") {
|
|
265
|
+
process.stderr.write(`[agentchat] ERROR: ${consent.message}\n`);
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
process.stderr.write(`[agentchat] Registering "${displayName}" with server...\n`);
|
|
168
270
|
try {
|
|
169
271
|
const regRes = await apiFetch(`${REST_URL}/api/account/register`, {
|
|
170
272
|
method: "POST",
|
|
171
273
|
headers: { "Content-Type": "application/json" },
|
|
172
|
-
body: JSON.stringify({
|
|
274
|
+
body: JSON.stringify({
|
|
275
|
+
name: displayName,
|
|
276
|
+
type: "agent",
|
|
277
|
+
capabilities: caps,
|
|
278
|
+
source: "mcp",
|
|
279
|
+
accepted_terms: true,
|
|
280
|
+
terms_version: consent.version,
|
|
281
|
+
}),
|
|
173
282
|
});
|
|
174
283
|
if (regRes.ok) {
|
|
175
284
|
const data = await regRes.json() as any;
|
|
@@ -183,27 +292,64 @@ if (existsSync(profileFile)) {
|
|
|
183
292
|
if (data.claim_url) process.stderr.write(`[agentchat] Share this with your owner: ${data.claim_url}\n`);
|
|
184
293
|
process.stderr.write(`[agentchat] Next steps: say hi in the welcome channel (reply tool) · try \`/loop 30m <prompt>\` in a DM (14-day trial) · call my_entitlements to see your powers\n`);
|
|
185
294
|
} else {
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
295
|
+
// A failed registration must NOT produce a runnable-looking agent. The old
|
|
296
|
+
// fallback wrote a `dev-token` profile and started anyway: the client showed a
|
|
297
|
+
// connected server with a full tool list while every authenticated call 401'd,
|
|
298
|
+
// and the placeholder profile then loaded as authoritative on every later run,
|
|
299
|
+
// making the dead identity permanent. Surface the hub's own words and stop.
|
|
300
|
+
const body = await regRes.text().catch(() => "");
|
|
301
|
+
process.stderr.write(
|
|
302
|
+
`[agentchat] ERROR: registration refused by ${REST_URL} — HTTP ${regRes.status} ${body.slice(0, 300)}\n` +
|
|
303
|
+
` No profile was written and no account exists. Nothing is running.\n` +
|
|
304
|
+
` If this mentions terms, read ${TERMS_URL} and re-run with --accept-terms.\n`,
|
|
305
|
+
);
|
|
306
|
+
process.exit(1);
|
|
189
307
|
}
|
|
190
308
|
} catch (e) {
|
|
191
|
-
|
|
192
|
-
|
|
309
|
+
// Always print the cause. This catch used to report EVERY failure as "Server
|
|
310
|
+
// unreachable" — including the TDZ ReferenceError above, which silently disabled
|
|
311
|
+
// registration for a week while pointing operators at their network. A failure path
|
|
312
|
+
// that fabricates a plausible diagnosis is worse than one that says nothing.
|
|
313
|
+
process.stderr.write(
|
|
314
|
+
`[agentchat] ERROR: Registration failed: ${e}\n` +
|
|
315
|
+
` No profile was written and no account exists. Nothing is running.\n`,
|
|
316
|
+
);
|
|
317
|
+
process.exit(1);
|
|
193
318
|
}
|
|
194
319
|
mkdirSync(dirname(profileFile), { recursive: true });
|
|
195
320
|
safeWriteProfile(profileFile, profile);
|
|
196
321
|
process.stderr.write(`[agentchat] Profile saved: ${profileFile}\n`);
|
|
197
322
|
}
|
|
198
323
|
|
|
199
|
-
//
|
|
200
|
-
|
|
324
|
+
// Second auto-register trigger: a loaded profile still carrying the `dev-token`
|
|
325
|
+
// placeholder. Healing it is intended for a declared identity, but on the bare
|
|
326
|
+
// shared default path it mints an anonymous account just like the first trigger.
|
|
327
|
+
if (profile.token === "dev-token" && !shouldMigrateDevToken({ source: profileSource, hasToken, registerFlag: !!cliArgs.register })) {
|
|
328
|
+
process.stderr.write(
|
|
329
|
+
`[agentchat] Profile at ${profileFile} carries a dev-token but no identity was declared — ` +
|
|
330
|
+
`refusing to auto-register. Pass --name <name> or --register to create a real agent.\n`,
|
|
331
|
+
);
|
|
332
|
+
} else if (profile.token === "dev-token") {
|
|
333
|
+
// Healing a dev-token profile registers a real account too, so it needs the same
|
|
334
|
+
// consent. Without this gate it just 400s on `accepted_terms` and leaves the
|
|
335
|
+
// placeholder in place — the dead-agent state this whole path exists to escape.
|
|
336
|
+
const migrationConsent = decideTermsConsent({
|
|
337
|
+
acceptFlag: !!cliArgs.acceptTerms,
|
|
338
|
+
acceptEnv: process.env.AGENTSCHAT_ACCEPT_TERMS,
|
|
339
|
+
});
|
|
340
|
+
if (migrationConsent.mode === "refused") {
|
|
341
|
+
process.stderr.write(
|
|
342
|
+
`[agentchat] Profile at ${profileFile} carries a placeholder dev-token and cannot authenticate.\n` +
|
|
343
|
+
` Healing it registers a real account: ${migrationConsent.message}\n`,
|
|
344
|
+
);
|
|
345
|
+
} else {
|
|
346
|
+
const terms = { accepted_terms: true, terms_version: migrationConsent.version };
|
|
201
347
|
process.stderr.write(`[agentchat] Migrating dev-token profile — registering with server...\n`);
|
|
202
348
|
try {
|
|
203
349
|
const regRes = await apiFetch(`${REST_URL}/api/account/register`, {
|
|
204
350
|
method: "POST",
|
|
205
351
|
headers: { "Content-Type": "application/json" },
|
|
206
|
-
body: JSON.stringify({ id: profile.agent_id, name: profile.display_name, type: "agent", capabilities: profile.capabilities || [] }),
|
|
352
|
+
body: JSON.stringify({ id: profile.agent_id, name: profile.display_name, type: "agent", capabilities: profile.capabilities || [], ...terms }),
|
|
207
353
|
});
|
|
208
354
|
if (regRes.ok) {
|
|
209
355
|
const data = await regRes.json() as any;
|
|
@@ -216,7 +362,7 @@ if (profile.token === "dev-token") {
|
|
|
216
362
|
const regRes2 = await apiFetch(`${REST_URL}/api/account/register`, {
|
|
217
363
|
method: "POST",
|
|
218
364
|
headers: { "Content-Type": "application/json" },
|
|
219
|
-
body: JSON.stringify({ name: profile.display_name, type: "agent", capabilities: profile.capabilities || [] }),
|
|
365
|
+
body: JSON.stringify({ name: profile.display_name, type: "agent", capabilities: profile.capabilities || [], ...terms }),
|
|
220
366
|
});
|
|
221
367
|
if (regRes2.ok) {
|
|
222
368
|
const data = await regRes2.json() as any;
|
|
@@ -224,40 +370,47 @@ if (profile.token === "dev-token") {
|
|
|
224
370
|
profile.token = data.key;
|
|
225
371
|
safeWriteProfile(profileFile, profile);
|
|
226
372
|
process.stderr.write(`[agentchat] Migrated with new ID: ${data.id}\n`);
|
|
373
|
+
} else {
|
|
374
|
+
// Both attempts refused. Say so — the profile still cannot authenticate.
|
|
375
|
+
const body = await regRes2.text().catch(() => "");
|
|
376
|
+
process.stderr.write(
|
|
377
|
+
`[agentchat] WARNING: dev-token migration refused — HTTP ${regRes2.status} ${body.slice(0, 200)}\n` +
|
|
378
|
+
` This profile still holds a placeholder token; authenticated calls will fail.\n`,
|
|
379
|
+
);
|
|
227
380
|
}
|
|
228
381
|
}
|
|
229
|
-
} catch {
|
|
382
|
+
} catch (e) {
|
|
383
|
+
// Was `catch {}`: it swallowed the same TDZ ReferenceError with no output at all.
|
|
384
|
+
process.stderr.write(`[agentchat] dev-token migration failed: ${e}\n`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
230
387
|
}
|
|
231
388
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
)
|
|
250
|
-
const headers: Record<string, string> = { ...(init.headers as Record<string, string> | undefined) };
|
|
251
|
-
if (TOKEN && !("Authorization" in headers)) headers["Authorization"] = `Bearer ${TOKEN}`;
|
|
252
|
-
const controller = new AbortController();
|
|
253
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
254
|
-
try {
|
|
255
|
-
return await nativeFetch(input as any, { ...init, headers, signal: init.signal ?? controller.signal });
|
|
256
|
-
} finally {
|
|
257
|
-
clearTimeout(timer);
|
|
258
|
-
}
|
|
389
|
+
// Single exit for every way a placeholder token survives the block above (refused
|
|
390
|
+
// consent, refused heal, no identity declared). `dev-token` cannot authenticate, so
|
|
391
|
+
// booting on it yields a server that lists its full toolset while every call 401s —
|
|
392
|
+
// the same dead-agent-in-live-clothes this release removed from the registration
|
|
393
|
+
// path, just reached down a different branch. Enforced once here rather than per
|
|
394
|
+
// branch so a future fourth path cannot reintroduce it.
|
|
395
|
+
//
|
|
396
|
+
// Anonymous mode is deliberately NOT caught: it loads no profile (profile.token is
|
|
397
|
+
// undefined), so zero-config registry introspection keeps working.
|
|
398
|
+
if (profile.token === "dev-token") {
|
|
399
|
+
process.stderr.write(
|
|
400
|
+
`[agentchat] ERROR: profile ${profileFile} holds a placeholder dev-token, which cannot authenticate.\n` +
|
|
401
|
+
` Not starting — a server that lists tools it cannot use is worse than one that fails.\n` +
|
|
402
|
+
` Heal it: --accept-terms (registers a real account for this profile)\n` +
|
|
403
|
+
` Or replace: register at https://agents-chat.com/join, then use --profile <name>\n` +
|
|
404
|
+
` or AGENTCHAT_TOKEN=<token>\n`,
|
|
405
|
+
);
|
|
406
|
+
process.exit(1);
|
|
259
407
|
}
|
|
260
408
|
|
|
409
|
+
// Now that the identity block has settled `profile`, bind the runtime identity.
|
|
410
|
+
AGENT_ID = cliArgs.id || process.env.AGENTCHAT_AGENT_ID || profile.agent_id || randomUUID();
|
|
411
|
+
TOKEN = cliArgs.token || process.env.AGENTCHAT_TOKEN || profile.token || "dev-token";
|
|
412
|
+
CAPABILITIES = cliArgs.caps?.split(",") || profile.capabilities || ["claude-code", "coding", "chat"];
|
|
413
|
+
|
|
261
414
|
// Update display name if provided via CLI
|
|
262
415
|
if (cliArgs.name && profile.display_name !== cliArgs.name) {
|
|
263
416
|
profile.display_name = cliArgs.name;
|
|
@@ -2714,7 +2867,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2714
2867
|
} catch (e: any) {
|
|
2715
2868
|
authLine = `REST auth: error (${String(e?.message || e).slice(0, 80)})`;
|
|
2716
2869
|
}
|
|
2717
|
-
return { content: [{ type: "text", text: `Profile: ${profile.display_name || AGENT_ID}\nAgent ID: ${AGENT_ID}\nServer: ${REST_URL}\nWeb chat: ${REST_URL}/chat/${encodeURIComponent(AGENT_ID)}\nWebSocket: ${wsState}${sessionId ? `\nSession: ${sessionId.slice(0, 12)}...` : ""}\n${healthLine}\n${authLine}\n${claimedLine}${claimHint ? `\n${claimHint}` : ""}\nCapabilities: ${CAPABILITIES.join(", ")}\nProfile file: ${
|
|
2870
|
+
return { content: [{ type: "text", text: `Profile: ${profile.display_name || AGENT_ID}\nAgent ID: ${AGENT_ID}\nServer: ${REST_URL}\nWeb chat: ${REST_URL}/chat/${encodeURIComponent(AGENT_ID)}\nWebSocket: ${wsState}${sessionId ? `\nSession: ${sessionId.slice(0, 12)}...` : ""}\n${healthLine}\n${authLine}\n${claimedLine}${claimHint ? `\n${claimHint}` : ""}\nCapabilities: ${CAPABILITIES.join(", ")}\nProfile file: ${activeProfileFile ?? (anonymousMode ? "(none — anonymous, no profile written)" : "(none — credentials from environment)")}` }] };
|
|
2718
2871
|
}
|
|
2719
2872
|
|
|
2720
2873
|
if (name === "list_channels") {
|
|
@@ -2907,11 +3060,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2907
3060
|
}
|
|
2908
3061
|
sessionId = null;
|
|
2909
3062
|
|
|
2910
|
-
// Update identity
|
|
3063
|
+
// Update identity. activeProfileFile must move too — whoami reports it, and a
|
|
3064
|
+
// stale value there makes the identity probe lie about which profile is live.
|
|
2911
3065
|
AGENT_ID = newProfile.agent_id;
|
|
2912
3066
|
TOKEN = newProfile.token || "dev-token";
|
|
2913
3067
|
CAPABILITIES = newProfile.capabilities || ["claude-code", "coding", "chat"];
|
|
2914
3068
|
profile = newProfile;
|
|
3069
|
+
activeProfileFile = targetFile;
|
|
3070
|
+
anonymousMode = false;
|
|
2915
3071
|
|
|
2916
3072
|
// Restart heartbeat and connect with new identity
|
|
2917
3073
|
wsReconnectAttempt = 0;
|
|
@@ -3286,15 +3442,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3286
3442
|
// Persisted to disk so reconnect/restart doesn't lose state
|
|
3287
3443
|
const mentionTsFile = join(configDir, `mention-ts-${AGENT_ID}.json`);
|
|
3288
3444
|
function loadMentionTimestamps(): Map<string, string> {
|
|
3289
|
-
|
|
3290
|
-
const raw = readFileSync(mentionTsFile, "utf-8");
|
|
3291
|
-
return new Map(Object.entries(JSON.parse(raw)));
|
|
3292
|
-
} catch { return new Map(); }
|
|
3445
|
+
return loadCursor(mentionTsFile, safeStderrWrite);
|
|
3293
3446
|
}
|
|
3447
|
+
// Re-attempted on the next mention, but a persistent failure (perms, full disk) would
|
|
3448
|
+
// retry just as silently forever — and the state is gone on restart. So it reports.
|
|
3294
3449
|
function saveMentionTimestamps(m: Map<string, string>) {
|
|
3295
|
-
|
|
3296
|
-
writeFileSync(mentionTsFile, JSON.stringify(Object.fromEntries(m)));
|
|
3297
|
-
} catch {}
|
|
3450
|
+
persistCursor(mentionTsFile, m, safeStderrWrite);
|
|
3298
3451
|
}
|
|
3299
3452
|
const lastMentionTimestamp = loadMentionTimestamps();
|
|
3300
3453
|
|
|
@@ -3315,36 +3468,29 @@ const lastMentionTimestamp = loadMentionTimestamps();
|
|
|
3315
3468
|
// connect or reconnect; if we missed anything, we find it.
|
|
3316
3469
|
const lastSeenMessageTsFile = join(configDir, `last-seen-msg-ts-${AGENT_ID}.json`);
|
|
3317
3470
|
function loadLastSeenMessageTs(): Map<string, string> {
|
|
3318
|
-
|
|
3319
|
-
const raw = readFileSync(lastSeenMessageTsFile, "utf-8");
|
|
3320
|
-
return new Map(Object.entries(JSON.parse(raw)));
|
|
3321
|
-
} catch { return new Map(); }
|
|
3322
|
-
}
|
|
3323
|
-
function saveLastSeenMessageTs(m: Map<string, string>) {
|
|
3324
|
-
try {
|
|
3325
|
-
writeFileSync(lastSeenMessageTsFile, JSON.stringify(Object.fromEntries(m)));
|
|
3326
|
-
} catch {}
|
|
3471
|
+
return loadCursor(lastSeenMessageTsFile, safeStderrWrite);
|
|
3327
3472
|
}
|
|
3328
3473
|
const lastSeenMessageTs = loadLastSeenMessageTs();
|
|
3329
3474
|
const cursorFlushIntervalMs = Math.max(
|
|
3330
3475
|
500,
|
|
3331
3476
|
Number(process.env.AGENTSCHAT_MCP_CURSOR_FLUSH_MS || 5000),
|
|
3332
3477
|
);
|
|
3333
|
-
|
|
3478
|
+
/** Dirty flag lives in an object so flushCursor can clear it only on a landed write. */
|
|
3479
|
+
const cursorState = { dirty: false };
|
|
3334
3480
|
let lastSeenMessageTsTimer: ReturnType<typeof setTimeout> | null = null;
|
|
3335
3481
|
|
|
3336
3482
|
function flushLastSeenMessageTs() {
|
|
3337
|
-
if (!
|
|
3338
|
-
lastSeenMessageTsDirty = false;
|
|
3483
|
+
if (!cursorState.dirty) return;
|
|
3339
3484
|
if (lastSeenMessageTsTimer) {
|
|
3340
3485
|
clearTimeout(lastSeenMessageTsTimer);
|
|
3341
3486
|
lastSeenMessageTsTimer = null;
|
|
3342
3487
|
}
|
|
3343
|
-
|
|
3488
|
+
// Stays dirty if the write fails, so the shutdown fallback flush actually retries.
|
|
3489
|
+
flushCursor(cursorState, () => persistCursor(lastSeenMessageTsFile, lastSeenMessageTs, safeStderrWrite));
|
|
3344
3490
|
}
|
|
3345
3491
|
|
|
3346
3492
|
function scheduleLastSeenMessageTsSave() {
|
|
3347
|
-
|
|
3493
|
+
cursorState.dirty = true;
|
|
3348
3494
|
if (lastSeenMessageTsTimer) return;
|
|
3349
3495
|
lastSeenMessageTsTimer = setTimeout(() => {
|
|
3350
3496
|
lastSeenMessageTsTimer = null;
|
|
@@ -3934,7 +4080,8 @@ function shutdownFromStdio(reason: string) {
|
|
|
3934
4080
|
if (shuttingDown) return;
|
|
3935
4081
|
shuttingDown = true;
|
|
3936
4082
|
safeStderrWrite(`[agentchat] Stdio closed (${reason}), shutting down\n`);
|
|
3937
|
-
|
|
4083
|
+
// Persisting the read cursor is a state change, not teardown: report, never swallow.
|
|
4084
|
+
try { flushLastSeenMessageTs(); } catch (e) { safeStderrWrite(`[agentchat] WARNING: read-cursor flush failed on shutdown: ${e}\n`); }
|
|
3938
4085
|
try { heartbeat.stop(); } catch {}
|
|
3939
4086
|
try { stopAllTypingHeartbeats(); } catch {}
|
|
3940
4087
|
if (reconnectTimer) {
|
|
@@ -3971,7 +4118,7 @@ function installStdioLifecycleGuards() {
|
|
|
3971
4118
|
process.stderr.on("error", handleOutputError);
|
|
3972
4119
|
process.on("SIGPIPE", () => shutdownFromStdio("SIGPIPE"));
|
|
3973
4120
|
process.on("beforeExit", () => {
|
|
3974
|
-
try { flushLastSeenMessageTs(); } catch {}
|
|
4121
|
+
try { flushLastSeenMessageTs(); } catch (e) { safeStderrWrite(`[agentchat] WARNING: read-cursor fallback flush failed: ${e}\n`); }
|
|
3975
4122
|
});
|
|
3976
4123
|
}
|
|
3977
4124
|
|
|
@@ -4004,7 +4151,13 @@ async function checkVersionStaleness(): Promise<void> {
|
|
|
4004
4151
|
// --- Start ---
|
|
4005
4152
|
async function main() {
|
|
4006
4153
|
installStdioLifecycleGuards();
|
|
4007
|
-
|
|
4154
|
+
// Anonymous = no credentials to present. Connecting would just fail auth in a
|
|
4155
|
+
// reconnect loop; stdio (initialize/tools/list) is served either way.
|
|
4156
|
+
if (anonymousMode) {
|
|
4157
|
+
process.stderr.write(`[agentchat] Anonymous — not connecting to the hub.\n`);
|
|
4158
|
+
} else {
|
|
4159
|
+
connectWS();
|
|
4160
|
+
}
|
|
4008
4161
|
|
|
4009
4162
|
// Stdio is the only supported transport. The --port HTTP SSE path was
|
|
4010
4163
|
// removed in v0.6.7 — OpenClaw users should install the native channel
|
package/src/terms.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terms-of-service consent for agent registration — pure decision logic, no I/O.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: the hub began requiring `accepted_terms` on
|
|
5
|
+
* /api/account/register. 0.30.0 did not send it, so every self-serve
|
|
6
|
+
* `npx agentschat-mcp --name X` got:
|
|
7
|
+
*
|
|
8
|
+
* 400 {"error":"accepted_terms required for agent registration"}
|
|
9
|
+
*
|
|
10
|
+
* ...and the register-failure path then wrote a `dev-token` profile and started the
|
|
11
|
+
* server anyway. The result read as success in the client (tools listed, whoami
|
|
12
|
+
* answers) while every authenticated call 401'd — a dead agent wearing a live one's
|
|
13
|
+
* clothes, with the only truth in stderr nobody reads.
|
|
14
|
+
*
|
|
15
|
+
* The obvious patch — hardcode `accepted_terms: true` — is the wrong fix. It records
|
|
16
|
+
* agreement to a legal document on behalf of an operator who was never shown it. So
|
|
17
|
+
* consent is explicit (`--accept-terms` / AGENTSCHAT_ACCEPT_TERMS) and its absence is
|
|
18
|
+
* a loud refusal that prints the document URL, not a silent degraded start.
|
|
19
|
+
*
|
|
20
|
+
* Lives outside the side-effecting entrypoint so it can be unit-tested (same reason
|
|
21
|
+
* as identity.ts).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** The published agreement an agent registration accepts. Verified live: 200. */
|
|
25
|
+
export const TERMS_URL = "https://agents-chat.com/terms";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Version string sent alongside the acceptance. The hub stamps its own
|
|
29
|
+
* `termsVersion` on the account; this records which text the operator was pointed at.
|
|
30
|
+
* If the hub ever rejects it, the register call surfaces the server's error verbatim
|
|
31
|
+
* rather than guessing — see the register call site in server.ts.
|
|
32
|
+
*/
|
|
33
|
+
export const TERMS_VERSION = "2026-05-29";
|
|
34
|
+
|
|
35
|
+
export interface TermsInputs {
|
|
36
|
+
/** `--accept-terms` was passed. */
|
|
37
|
+
acceptFlag?: boolean;
|
|
38
|
+
/** Raw value of AGENTSCHAT_ACCEPT_TERMS (for non-interactive hosts). */
|
|
39
|
+
acceptEnv?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type TermsDecision =
|
|
43
|
+
/** Operator accepted — registration may proceed and carry these fields. */
|
|
44
|
+
| { mode: "accepted"; version: string }
|
|
45
|
+
/** No explicit acceptance — refuse to register, and say what to do about it. */
|
|
46
|
+
| { mode: "refused"; message: string };
|
|
47
|
+
|
|
48
|
+
/** Only these spellings count as consent; anything else (incl. "0"/"false") does not. */
|
|
49
|
+
function isTruthy(v: string | undefined): boolean {
|
|
50
|
+
return typeof v === "string" && /^(1|true|yes)$/i.test(v.trim());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function decideTermsConsent(i: TermsInputs): TermsDecision {
|
|
54
|
+
if (i.acceptFlag || isTruthy(i.acceptEnv)) {
|
|
55
|
+
return { mode: "accepted", version: TERMS_VERSION };
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
mode: "refused",
|
|
59
|
+
message:
|
|
60
|
+
`registering an agent requires accepting the AgentsChat terms (version ${TERMS_VERSION}).\n` +
|
|
61
|
+
` Read them: ${TERMS_URL}\n` +
|
|
62
|
+
` Then re-run with: --accept-terms (or AGENTSCHAT_ACCEPT_TERMS=1)\n` +
|
|
63
|
+
` Prefer a browser? Register at https://agents-chat.com/join and pass the\n` +
|
|
64
|
+
` resulting credentials via --profile <name> or AGENTCHAT_TOKEN=<token>.\n` +
|
|
65
|
+
` Refusing to send acceptance you did not give — no account was created.`,
|
|
66
|
+
};
|
|
67
|
+
}
|