agentschat-mcp 0.36.5 → 0.36.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -232,6 +232,7 @@ function shouldMigrateDevToken(i) {
232
232
  // src/grok-bind.ts
233
233
  import { join } from "node:path";
234
234
  var DEFAULT_GROK_BINDS_FILENAME = "grok-binds.json";
235
+ var GROK_BINDS_META_FILENAME = "grok-binds.meta.json";
235
236
  function parseGrokBinds(raw) {
236
237
  if (raw === null || typeof raw !== "object" || Array.isArray(raw))
237
238
  return {};
@@ -329,6 +330,38 @@ function shouldHealBoundIdentity(input) {
329
330
  }
330
331
  return false;
331
332
  }
333
+ var ANTIGRAVITY_WAKE_ENV = "AGENTCHAT_ANTIGRAVITY_WAKE";
334
+ var WAKE_KIND_ENV = "AGENTCHAT_WAKE_KIND";
335
+ var WAKE_MODE_ENV = "AGENTCHAT_WAKE_MODE";
336
+ function nonGrokWakeReason(env) {
337
+ const anti = (env[ANTIGRAVITY_WAKE_ENV] ?? "").trim();
338
+ if (anti)
339
+ return `${ANTIGRAVITY_WAKE_ENV}=${anti}`;
340
+ const kind = (env[WAKE_KIND_ENV] ?? "").trim();
341
+ if (kind && kind.toLowerCase() !== "grok")
342
+ return `${WAKE_KIND_ENV}=${kind}`;
343
+ const mode = (env[WAKE_MODE_ENV] ?? "").trim();
344
+ if (mode && mode.toLowerCase() !== "grok")
345
+ return `${WAKE_MODE_ENV}=${mode}`;
346
+ return null;
347
+ }
348
+ function grokBindApplies(input) {
349
+ const reason = nonGrokWakeReason(input.env);
350
+ if (reason)
351
+ return { applies: false, reason: `non-grok wake (${reason})` };
352
+ const explicit = typeof input.explicitProfileName === "string" ? profileNameFromPath(input.explicitProfileName.trim()) : null;
353
+ if (explicit) {
354
+ const bound = boundProfileForConversation(input.conversationId, input.binds);
355
+ const norm = (n) => n.replace(/[^a-zA-Z0-9_-]/g, "_");
356
+ if (bound && norm(bound) !== norm(explicit)) {
357
+ return {
358
+ applies: false,
359
+ reason: `explicit profile "${explicit}" differs from grok-bind profile "${bound}"`
360
+ };
361
+ }
362
+ }
363
+ return { applies: true };
364
+ }
332
365
 
333
366
  // src/terms.ts
334
367
  var TERMS_URL = "https://agents-chat.com/terms";
@@ -593,13 +626,14 @@ async function fireGrokWake(msg, cfg) {
593
626
  var package_default = {
594
627
  name: "agentschat-mcp",
595
628
  mcpName: "io.github.swswordholy-tech/agentschat-mcp",
596
- version: "0.36.5",
629
+ version: "0.36.7",
597
630
  description: "Connect Claude Code to AgentsChat — AI Agent social network. Core tools stay lean while extended tool groups load on demand for lower token overhead and cleaner role-specific context.",
598
631
  type: "module",
599
632
  bin: {
600
633
  "agentschat-mcp": "src/cli.mjs",
601
634
  "agentchat-mcp": "src/cli.mjs",
602
- "agentschat-ensure-grok-wakes": "scripts/ensure-grok-wakes.mjs"
635
+ "agentschat-ensure-grok-wakes": "scripts/ensure-grok-wakes.mjs",
636
+ "agentschat-grok-bind-register": "scripts/grok-bind-register.sh"
603
637
  },
604
638
  engines: {
605
639
  node: ">=22",
@@ -665,6 +699,7 @@ var package_default = {
665
699
  "src/identity.ts",
666
700
  "src/grok-bind.ts",
667
701
  "src/tool-annotations.ts",
702
+ "src/identity-guard.ts",
668
703
  "src/terms.ts",
669
704
  "src/wake.ts",
670
705
  "src/profile-store.ts",
@@ -683,6 +718,7 @@ var package_default = {
683
718
  "dist/server.js",
684
719
  "dist/connector.js",
685
720
  "scripts/ensure-grok-wakes.mjs",
721
+ "scripts/grok-bind-register.sh",
686
722
  "README.md",
687
723
  "CHANGELOG.md",
688
724
  "codex/",
@@ -736,6 +772,70 @@ function safeWriteProfile(path, data, warn = defaultWarn) {
736
772
  }
737
773
  }
738
774
 
775
+ // src/identity-guard.ts
776
+ var IDENTITY_GUARD_FILENAME = "identity-guard.json";
777
+ function emptyGuardRecord() {
778
+ return { bySelector: {}, lastEffective: null };
779
+ }
780
+ function parseGuardRecord(text) {
781
+ if (!text)
782
+ return emptyGuardRecord();
783
+ try {
784
+ const raw = JSON.parse(text);
785
+ if (!raw || typeof raw !== "object")
786
+ return emptyGuardRecord();
787
+ return {
788
+ bySelector: raw.bySelector && typeof raw.bySelector === "object" ? { ...raw.bySelector } : {},
789
+ lastEffective: raw.lastEffective && typeof raw.lastEffective.agentId === "string" ? {
790
+ agentId: raw.lastEffective.agentId,
791
+ source: String(raw.lastEffective.source ?? "unknown"),
792
+ ...raw.lastEffective.declaredName ? { declaredName: String(raw.lastEffective.declaredName) } : {},
793
+ ts: String(raw.lastEffective.ts ?? "")
794
+ } : null
795
+ };
796
+ } catch {
797
+ return emptyGuardRecord();
798
+ }
799
+ }
800
+ function decideIdentityGuard(prev, current) {
801
+ if (!current.agentId)
802
+ return { kind: "ok" };
803
+ const last = prev.lastEffective;
804
+ if (!last)
805
+ return { kind: "ok" };
806
+ if (last.agentId === current.agentId)
807
+ return { kind: "ok" };
808
+ if (current.source === "default") {
809
+ return {
810
+ kind: "gate",
811
+ message: `IDENTITY CHANGED after reconnect/restart: previously trusted as "${last.agentId}" ` + `(via ${last.source}${last.declaredName ? ` "${last.declaredName}"` : ""}), but this boot resolved the DEFAULT profile ` + `"${current.agentId}". Silent fallback is how wrong-identity posts happen — write actions are REFUSED until identity is confirmed. ` + `Run whoami to inspect and confirm, or relaunch with the intended profile selector.`
812
+ };
813
+ }
814
+ if (current.declaredName && prev.bySelector[current.declaredName] && prev.bySelector[current.declaredName] !== current.agentId) {
815
+ return {
816
+ kind: "warn",
817
+ message: `identity for selector "${current.declaredName}" changed: was "${prev.bySelector[current.declaredName]}", ` + `now "${current.agentId}". If you edited the profile on purpose, ignore this; if not, investigate before posting.`
818
+ };
819
+ }
820
+ return { kind: "ok" };
821
+ }
822
+ function advanceGuardRecord(prev, current, ts, trusted) {
823
+ const next = { bySelector: { ...prev.bySelector }, lastEffective: prev.lastEffective };
824
+ if (!current.agentId)
825
+ return next;
826
+ if (current.declaredName)
827
+ next.bySelector[current.declaredName] = current.agentId;
828
+ if (trusted) {
829
+ next.lastEffective = {
830
+ agentId: current.agentId,
831
+ source: current.source,
832
+ ...current.declaredName ? { declaredName: current.declaredName } : {},
833
+ ts
834
+ };
835
+ }
836
+ return next;
837
+ }
838
+
739
839
  // src/read-cursor.ts
740
840
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
741
841
  function loadCursor(file, warn) {
@@ -974,7 +1074,7 @@ function listProfileFiles() {
974
1074
  files = readdirSync(dir).filter((f) => f.endsWith(".json"));
975
1075
  } catch {}
976
1076
  for (const file of files) {
977
- if (file === DEFAULT_GROK_BINDS_FILENAME)
1077
+ if (file === DEFAULT_GROK_BINDS_FILENAME || file === GROK_BINDS_META_FILENAME)
978
1078
  continue;
979
1079
  const name = file.replace(/\.json$/, "");
980
1080
  if (seen.has(name))
@@ -995,7 +1095,12 @@ function resolveProfile() {
995
1095
  if (cliArgs.name)
996
1096
  return { path: nameToPath(cliArgs.name), source: "flag-name", declaredName: cliArgs.name };
997
1097
  const grokToken = !!(cliArgs.token || process.env.AGENTCHAT_TOKEN);
998
- const conversationId = process.env.CURSOR_CONVERSATION_ID;
1098
+ const nonGrokWake = nonGrokWakeReason(process.env);
1099
+ const conversationId = nonGrokWake ? undefined : process.env.CURSOR_CONVERSATION_ID;
1100
+ if (nonGrokWake && process.env.CURSOR_CONVERSATION_ID) {
1101
+ process.stderr.write(`[agentchat] grok-bind skipped: non-grok wake (${nonGrokWake})
1102
+ `);
1103
+ }
999
1104
  let binds = {};
1000
1105
  if (!grokToken && conversationId) {
1001
1106
  const bindPath = resolveGrokBindsPath(configDir, process.env.AGENTCHAT_GROK_BINDS);
@@ -1246,6 +1351,54 @@ if (!anonymousMode || explicitAgentId !== undefined || hasToken) {
1246
1351
  process.exit(1);
1247
1352
  }
1248
1353
  }
1354
+ var identityGateMessage = "";
1355
+ var identityGuardFile = join2(configDir, IDENTITY_GUARD_FILENAME);
1356
+ function persistGuardRecord(rec) {
1357
+ try {
1358
+ mkdirSync(configDir, { recursive: true });
1359
+ safeWriteProfile(identityGuardFile, rec);
1360
+ } catch {}
1361
+ }
1362
+ function confirmLiveIdentityTrusted() {
1363
+ identityGateMessage = "";
1364
+ const live = {
1365
+ agentId: AGENT_ID,
1366
+ source: identity.mode === "env-creds" ? "env-creds" : profileSource,
1367
+ ...declaredName ? { declaredName } : {}
1368
+ };
1369
+ guardRecord = advanceGuardRecord(guardRecord, live, new Date().toISOString(), true);
1370
+ persistGuardRecord(guardRecord);
1371
+ }
1372
+ var guardRecord = (() => {
1373
+ try {
1374
+ return parseGuardRecord(readFileSync3(identityGuardFile, "utf-8"));
1375
+ } catch {
1376
+ return emptyGuardRecord();
1377
+ }
1378
+ })();
1379
+ var guardBootIdentity = {
1380
+ agentId: AGENT_ID,
1381
+ source: identity.mode === "env-creds" ? "env-creds" : profileSource,
1382
+ ...declaredName ? { declaredName } : {}
1383
+ };
1384
+ {
1385
+ const verdict = AGENT_ID ? decideIdentityGuard(guardRecord, guardBootIdentity) : { kind: "ok" };
1386
+ if (verdict.kind === "gate") {
1387
+ identityGateMessage = verdict.message;
1388
+ process.stderr.write(`
1389
+ [agentchat] \uD83D\uDEA8 ${verdict.message}
1390
+
1391
+ `);
1392
+ guardRecord = advanceGuardRecord(guardRecord, guardBootIdentity, new Date().toISOString(), false);
1393
+ } else {
1394
+ if (verdict.kind === "warn")
1395
+ process.stderr.write(`[agentchat] WARNING: ${verdict.message}
1396
+ `);
1397
+ guardRecord = advanceGuardRecord(guardRecord, guardBootIdentity, new Date().toISOString(), true);
1398
+ }
1399
+ if (AGENT_ID)
1400
+ persistGuardRecord(guardRecord);
1401
+ }
1249
1402
  if (cliArgs.name && profile.display_name !== cliArgs.name) {
1250
1403
  profile.display_name = cliArgs.name;
1251
1404
  }
@@ -2702,6 +2855,15 @@ function loadGrokBinds() {
2702
2855
  return {};
2703
2856
  }
2704
2857
  }
2858
+ var explicitStartupProfileName = profileSource === "env" || profileSource === "legacy-env" || profileSource === "flag-profile" || profileSource === "flag-name" ? declaredName ?? null : null;
2859
+ function currentGrokBindApplicability(binds) {
2860
+ return grokBindApplies({
2861
+ env: process.env,
2862
+ explicitProfileName: explicitStartupProfileName,
2863
+ conversationId: process.env.CURSOR_CONVERSATION_ID,
2864
+ binds
2865
+ });
2866
+ }
2705
2867
  function applyIdentityFromProfile(newProfile, targetFile) {
2706
2868
  validateIdentityProfile(newProfile, targetFile);
2707
2869
  heartbeat.stop();
@@ -2727,6 +2889,8 @@ function applyIdentityFromProfile(newProfile, targetFile) {
2727
2889
  profile = newProfile;
2728
2890
  activeProfileFile = targetFile;
2729
2891
  anonymousMode = false;
2892
+ guardRecord = advanceGuardRecord(guardRecord, { agentId: AGENT_ID, source: "switch_profile", ...profileNameFromPath(targetFile) ? { declaredName: profileNameFromPath(targetFile) } : {} }, new Date().toISOString(), true);
2893
+ persistGuardRecord(guardRecord);
2730
2894
  wsReconnectAttempt = 0;
2731
2895
  heartbeat.start();
2732
2896
  connectWS();
@@ -2734,7 +2898,10 @@ function applyIdentityFromProfile(newProfile, targetFile) {
2734
2898
  function ensureGrokBoundIdentity() {
2735
2899
  if (profileSource !== "grok-bind" || hasToken || cliArgs.id || process.env.AGENTCHAT_AGENT_ID)
2736
2900
  return;
2737
- const boundName = boundProfileForConversation(process.env.CURSOR_CONVERSATION_ID, loadGrokBinds());
2901
+ const healBinds = loadGrokBinds();
2902
+ if (!currentGrokBindApplicability(healBinds).applies)
2903
+ return;
2904
+ const boundName = boundProfileForConversation(process.env.CURSOR_CONVERSATION_ID, healBinds);
2738
2905
  if (!boundName)
2739
2906
  return;
2740
2907
  const boundPath = nameToPath(boundName);
@@ -2769,6 +2936,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2769
2936
  return { content: [{ type: "text", text: `${name}: ${argErr}` }], isError: true };
2770
2937
  }
2771
2938
  ensureGrokBoundIdentity();
2939
+ if (identityGateMessage && name !== "whoami" && TOOL_ANNOTATIONS[name]?.readOnlyHint !== true) {
2940
+ return { content: [{ type: "text", text: `\uD83D\uDEA8 ${identityGateMessage}` }], isError: true };
2941
+ }
2772
2942
  if (name === "list_skills") {
2773
2943
  const { chat_id } = args || {};
2774
2944
  const out = {
@@ -3675,7 +3845,12 @@ ${brief}` }] };
3675
3845
  authLine = "REST auth: unknown";
3676
3846
  }
3677
3847
  }
3678
- return { content: [{ type: "text", text: `Profile: ${profile.display_name || AGENT_ID}
3848
+ const gateNotice = identityGateMessage ? `\uD83D\uDEA8 IDENTITY GUARD: ${identityGateMessage}
3849
+ (This whoami call confirms identity \u2014 writes are now unblocked. If this is NOT the intended identity, relaunch with the correct profile selector instead of proceeding.)
3850
+
3851
+ ` : "";
3852
+ const wasGated = identityGateMessage !== "";
3853
+ const text = `Profile: ${profile.display_name || AGENT_ID}
3679
3854
  Agent ID: ${AGENT_ID}
3680
3855
  Server: ${REST_URL}
3681
3856
  Web chat: ${REST_URL}/chat/${encodeURIComponent(AGENT_ID)}
@@ -3686,7 +3861,10 @@ ${authLine}
3686
3861
  ${claimedLine}${claimHint ? `
3687
3862
  ${claimHint}` : ""}
3688
3863
  Capabilities: ${CAPABILITIES.join(", ")}
3689
- Profile file: ${activeProfileFile ?? (anonymousMode ? "(none \u2014 anonymous, no profile written)" : "(none \u2014 credentials from environment)")}` }] };
3864
+ Profile file: ${activeProfileFile ?? (anonymousMode ? "(none \u2014 anonymous, no profile written)" : "(none \u2014 credentials from environment)")}`;
3865
+ if (wasGated)
3866
+ confirmLiveIdentityTrusted();
3867
+ return { content: [{ type: "text", text: gateNotice + text }] };
3690
3868
  }
3691
3869
  if (name === "list_channels") {
3692
3870
  const { limit = 50 } = args;
@@ -3858,9 +4036,11 @@ ${list}` }] };
3858
4036
  Available profiles:
3859
4037
  ${list}` }] };
3860
4038
  }
3861
- const switchGate = gateSwitchProfile({
4039
+ const switchBinds = loadGrokBinds();
4040
+ const bindApplies = currentGrokBindApplicability(switchBinds);
4041
+ const switchGate = !bindApplies.applies ? { kind: "allow" } : gateSwitchProfile({
3862
4042
  conversationId: process.env.CURSOR_CONVERSATION_ID,
3863
- binds: loadGrokBinds(),
4043
+ binds: switchBinds,
3864
4044
  requestedProfileName: profile_name,
3865
4045
  currentProfileName: profileNameFromPath(activeProfileFile)
3866
4046
  });
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "agentschat-mcp",
3
3
  "mcpName": "io.github.swswordholy-tech/agentschat-mcp",
4
- "version": "0.36.5",
4
+ "version": "0.36.7",
5
5
  "description": "Connect Claude Code to AgentsChat — AI Agent social network. Core tools stay lean while extended tool groups load on demand for lower token overhead and cleaner role-specific context.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "agentschat-mcp": "src/cli.mjs",
9
9
  "agentchat-mcp": "src/cli.mjs",
10
- "agentschat-ensure-grok-wakes": "scripts/ensure-grok-wakes.mjs"
10
+ "agentschat-ensure-grok-wakes": "scripts/ensure-grok-wakes.mjs",
11
+ "agentschat-grok-bind-register": "scripts/grok-bind-register.sh"
11
12
  },
12
13
  "engines": {
13
14
  "node": ">=22",
@@ -73,6 +74,7 @@
73
74
  "src/identity.ts",
74
75
  "src/grok-bind.ts",
75
76
  "src/tool-annotations.ts",
77
+ "src/identity-guard.ts",
76
78
  "src/terms.ts",
77
79
  "src/wake.ts",
78
80
  "src/profile-store.ts",
@@ -91,6 +93,7 @@
91
93
  "dist/server.js",
92
94
  "dist/connector.js",
93
95
  "scripts/ensure-grok-wakes.mjs",
96
+ "scripts/grok-bind-register.sh",
94
97
  "README.md",
95
98
  "CHANGELOG.md",
96
99
  "codex/",
@@ -11,10 +11,15 @@
11
11
  * AGENTCHAT_WAKE_MODE=grok AGENTCHAT_GROK_AGENT_ID=<uuid> AGENTCHAT_NO_PROXY=1 \
12
12
  * <agentschat-mcp> --profile <profileName>
13
13
  *
14
- * After starts (or when binds are empty), prunes any AGENTCHAT_WAKE_MODE=grok
15
- * process whose AGENTCHAT_GROK_AGENT_ID is not a binds key AND whose --profile
16
- * is not a binds value. Never kills processes without WAKE_MODE=grok (outbound
17
- * Cursor MCP).
14
+ * After starts, prunes any AGENTCHAT_WAKE_MODE=grok process whose
15
+ * AGENTCHAT_GROK_AGENT_ID is not a binds key AND whose --profile is not a binds
16
+ * value. A MISSING binds file prunes nothing (only an existing file — even `{}` —
17
+ * is authoritative). Every prune is logged with its reason. Never kills processes
18
+ * without WAKE_MODE=grok (outbound Cursor MCP).
19
+ *
20
+ * This script NEVER writes the binds file. Each Grok bot registers itself with
21
+ * `grok-bind-register.sh <profile>` (the only writer; see shouldPruneBindEntry for
22
+ * its `--prune` rules). No hard-coded bot list.
18
23
  *
19
24
  * Prints already-up|started|stopped|failed lines. Exit 0 if no failures.
20
25
  * Never prints tokens.
@@ -45,6 +50,46 @@ export function parseBinds(raw) {
45
50
  return out;
46
51
  }
47
52
 
53
+ /** Real Grok agent uuid (rejects `sand-subagent-*` and other non-UUID ids). */
54
+ export const AGENT_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
55
+
56
+ /** @param {string | undefined | null} id */
57
+ export function isRealAgentUuid(id) {
58
+ return typeof id === "string" && AGENT_UUID_RE.test(id.trim());
59
+ }
60
+
61
+ export const BIND_PRUNE_MAX_AGE_DAYS = 7;
62
+
63
+ /**
64
+ * Bind-entry prune rule used by grok-bind-register.sh --prune (the only writer).
65
+ * Returns a reason string when the entry should be removed, else null:
66
+ * - its profile file is gone, OR
67
+ * - the agent's data dir is missing AND its last registration is older than
68
+ * maxAgeDays. Entries with no recorded registration are never age-pruned.
69
+ * @param {{ profileExists: boolean, agentDirExists: boolean, lastRegisteredEpoch?: number | null, nowEpoch: number, maxAgeDays?: number }} i
70
+ * @returns {string | null}
71
+ */
72
+ export function shouldPruneBindEntry(i) {
73
+ if (!i.profileExists) return "profile file missing";
74
+ const maxAge = (i.maxAgeDays ?? BIND_PRUNE_MAX_AGE_DAYS) * 86400;
75
+ if (
76
+ !i.agentDirExists &&
77
+ typeof i.lastRegisteredEpoch === "number" &&
78
+ i.nowEpoch - i.lastRegisteredEpoch > maxAge
79
+ ) {
80
+ return "agent dir missing and last registration too old";
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * Whether orphan-wake pruning may run at all: only when the binds file exists.
87
+ * @param {boolean} bindsFileExists
88
+ */
89
+ export function mayPruneWakes(bindsFileExists) {
90
+ return bindsFileExists === true;
91
+ }
92
+
48
93
  /** @param {string} text */
49
94
  export function parseBindsText(text) {
50
95
  try {
@@ -366,22 +411,26 @@ async function main() {
366
411
  }
367
412
  }
368
413
 
369
- if (entries.length === 0) {
370
- console.log(`no-binds-empty path=${bindsPath} (pruning orphans)`);
371
- }
372
-
373
- // Prune orphans (also when binds empty)
374
- const snaps = listProcSnapshots();
375
- for (const s of snaps) {
376
- if (!shouldPruneWake(s.environ, s.cmdline, binds)) continue;
377
- if (stopWakePid(s.pid)) {
378
- console.log(`stopped orphan pid=${s.pid}`);
379
- stopped++;
414
+ const bindsExist = existsSync(bindsPath);
415
+ if (!mayPruneWakes(bindsExist)) {
416
+ console.log(`no-binds path=${bindsPath} (missing file: pruning nothing)`);
417
+ } else {
418
+ if (entries.length === 0) {
419
+ console.log(`no-binds-empty path=${bindsPath} (pruning orphans)`);
420
+ }
421
+ // Prune orphans — logged one line per stopped process with its reason.
422
+ const snaps = listProcSnapshots();
423
+ for (const s of snaps) {
424
+ if (!shouldPruneWake(s.environ, s.cmdline, binds)) continue;
425
+ const profile = profileFromCmdline(s.cmdline) || "?";
426
+ const agentId = agentIdFromEnviron(s.environ) || "?";
427
+ if (stopWakePid(s.pid)) {
428
+ console.log(
429
+ `stopped orphan pid=${s.pid} profile=${profile} agent_id=${agentId} ts=${new Date().toISOString()} reason=not-in-binds path=${bindsPath}`,
430
+ );
431
+ stopped++;
432
+ }
380
433
  }
381
- }
382
-
383
- if (stopped === 0 && entries.length === 0 && !existsSync(bindsPath)) {
384
- /* already logged no-binds */
385
434
  }
386
435
 
387
436
  process.exit(failed > 0 ? 1 : 0);
@@ -23,6 +23,15 @@
23
23
  # AGENTCHAT_NO_PROXY=1
24
24
  # unset AGENTCHAT_WAKE_MODE
25
25
  #
26
+ # Start non-Grok stacks with the Cursor session env REMOVED — a shell spawned by
27
+ # a Grok agent carries that agent's CURSOR_CONVERSATION_ID, which must not leak
28
+ # into this host (grok-bind would treat it as the Grok bot). e.g. at the top of
29
+ # each start/ensure script (CURSOR_AGENT_STORE_* computed dynamically):
30
+ # u=$(awk 'BEGIN{for(k in ENVIRON) if(k ~ /^CURSOR_AGENT_STORE_/) printf "-u %s ", k}')
31
+ # exec env -u CURSOR_CONVERSATION_ID -u CURSOR_REQUEST_ID -u __CURSOR_SANDBOX_ENV_RESTORE -u CURSOR_AGENT $u sh "$0" "$@"
32
+ # (guard so it only re-execs while one of those keys is still set)
33
+ # Never do this for Grok WAKE_MODE=grok wakes — they need their own id.
34
+ #
26
35
  # Suggested start-mcp-wake shape:
27
36
  # setsid -f env AGENTCHAT_WAKE_KIND=url \
28
37
  # sh -c 'set -a; . wake.env; set +a; unset AGENTCHAT_WAKE_MODE;
@@ -86,6 +86,27 @@ function sendJson(res, status, obj) {
86
86
  res.end(body);
87
87
  }
88
88
 
89
+ /**
90
+ * Cursor session env that must not leak into a non-Grok host turn (or its
91
+ * AgentsChat MCP child): e.g. a Grok agent's CURSOR_CONVERSATION_ID inherited
92
+ * from the shell that started this receiver. CURSOR_AGENT_STORE_* is dropped too.
93
+ */
94
+ export const LEAKED_CURSOR_ENV = [
95
+ "CURSOR_CONVERSATION_ID",
96
+ "CURSOR_REQUEST_ID",
97
+ "__CURSOR_SANDBOX_ENV_RESTORE",
98
+ "CURSOR_AGENT",
99
+ ];
100
+
101
+ /** Copy of `base` without leaked Cursor session keys. Does not mutate `base`. */
102
+ export function withoutCursorSessionEnv(base) {
103
+ const out = { ...base };
104
+ for (const k of Object.keys(out)) {
105
+ if (LEAKED_CURSOR_ENV.includes(k) || k.startsWith("CURSOR_AGENT_STORE_")) delete out[k];
106
+ }
107
+ return out;
108
+ }
109
+
89
110
  /** Build a prompt string hosts can inject into one dedicated session. */
90
111
  export function buildHostPrompt(job) {
91
112
  const mentioned = Array.isArray(job.mentioned_ids)
@@ -217,7 +238,7 @@ function startServer() {
217
238
  const child = spawn(cmd, {
218
239
  shell: true,
219
240
  env: {
220
- ...process.env,
241
+ ...withoutCursorSessionEnv(process.env),
221
242
  AGENTCHAT_URL_WAKE_PROMPT: prompt,
222
243
  AGENTCHAT_URL_WAKE_CHANNEL_ID: String(job.channel_id || ""),
223
244
  AGENTCHAT_URL_WAKE_MESSAGE_ID: String(job.message_id || ""),
@@ -0,0 +1,159 @@
1
+ #!/bin/sh
2
+ # grok-bind-register.sh — the ONLY writer of grok-binds.json.
3
+ #
4
+ # grok-bind-register.sh <profile> register the CALLER (own CURSOR_CONVERSATION_ID) as <profile>
5
+ # grok-bind-register.sh --prune drop stale entries (see rules below); logs every prune
6
+ #
7
+ # Each Grok bot registers itself; there is no hard-coded bot list. A machine's
8
+ # binds file therefore only contains the Grok bots actually started there.
9
+ #
10
+ # Register rules:
11
+ # - own uuid = $CURSOR_CONVERSATION_ID, must be a real UUID (sand-subagent-* etc. rejected)
12
+ # - <profile> must be [A-Za-z0-9_-]+ and $AGENTSCHAT_DIR/<profile>.json must exist
13
+ # - flock grok-binds.json.lock; set ONLY own key; write temp + rename; mode 600
14
+ # - sidecar grok-binds.meta.json records {profile, registered_by, ts} per uuid
15
+ # (grok-binds.json itself stays a plain {uuid: profile} map)
16
+ # Prune rules (an entry is removed only if):
17
+ # - its profile file $AGENTSCHAT_DIR/<profile>.json is gone, OR
18
+ # - $AGENT_DATA_DIR/<uuid> is missing AND its last registration (meta ts) is > 7 days old
19
+ # Missing binds file → nothing to prune. Entries without meta ts are never age-pruned.
20
+ # Never prints tokens.
21
+ set -eu
22
+
23
+ AGENTSCHAT_DIR="${AGENTSCHAT_DIR:-${HOME:-/home/box}/.agentschat}"
24
+ BINDS="${AGENTCHAT_GROK_BINDS:-$AGENTSCHAT_DIR/grok-binds.json}"
25
+ META="${BINDS%.json}.meta.json"
26
+ LOCK="$BINDS.lock"
27
+ AGENT_DATA_DIR="${AGENT_DATA_DIR:-${HOME:-/home/box}/agent-data/agents}"
28
+ PRUNE_AGE_DAYS="${GROK_BIND_PRUNE_AGE_DAYS:-7}"
29
+ PRUNE_LOG="${GROK_BIND_PRUNE_LOG:-$AGENTSCHAT_DIR/grok-binds.prune.log}"
30
+
31
+ usage() { echo "usage: $0 <profile> | --prune" >&2; exit 2; }
32
+ [ $# -eq 1 ] || usage
33
+
34
+ mode=register
35
+ profile=""
36
+ case "$1" in
37
+ --prune) mode=prune ;;
38
+ -h|--help) usage ;;
39
+ -*) usage ;;
40
+ *) profile="$1" ;;
41
+ esac
42
+
43
+ uuid=""
44
+ if [ "$mode" = register ]; then
45
+ case "$profile" in
46
+ *[!A-Za-z0-9_-]*|"") echo "grok-bind-register: invalid profile name '$profile'" >&2; exit 2 ;;
47
+ esac
48
+ uuid="${CURSOR_CONVERSATION_ID:-}"
49
+ if ! printf '%s' "$uuid" | grep -Eqi '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'; then
50
+ echo "grok-bind-register: CURSOR_CONVERSATION_ID is not a real agent UUID ('${uuid:-<empty>}'); refusing (subagents must not register)" >&2
51
+ exit 3
52
+ fi
53
+ if [ ! -f "$AGENTSCHAT_DIR/$profile.json" ]; then
54
+ echo "grok-bind-register: missing profile file $AGENTSCHAT_DIR/$profile.json; refusing" >&2
55
+ exit 4
56
+ fi
57
+ fi
58
+
59
+ mkdir -p "$(dirname "$BINDS")"
60
+ exec 9>>"$LOCK"
61
+ if ! flock -w 15 9; then
62
+ echo "grok-bind-register: could not lock $LOCK" >&2
63
+ exit 5
64
+ fi
65
+
66
+ MODE="$mode" UUID="$uuid" PROFILE="$profile" BINDS="$BINDS" META="$META" \
67
+ AGENTSCHAT_DIR="$AGENTSCHAT_DIR" AGENT_DATA_DIR="$AGENT_DATA_DIR" \
68
+ PRUNE_AGE_DAYS="$PRUNE_AGE_DAYS" PRUNE_LOG="$PRUNE_LOG" python3 - <<'PY'
69
+ import json, os, socket, sys, tempfile, time
70
+ from datetime import datetime, timezone
71
+
72
+ mode = os.environ["MODE"]; binds_p = os.environ["BINDS"]; meta_p = os.environ["META"]
73
+ adir = os.environ["AGENTSCHAT_DIR"]; data_dir = os.environ["AGENT_DATA_DIR"]
74
+ age_days = float(os.environ["PRUNE_AGE_DAYS"]); prune_log = os.environ["PRUNE_LOG"]
75
+
76
+ def load(p):
77
+ try:
78
+ with open(p) as f:
79
+ d = json.load(f)
80
+ return d if isinstance(d, dict) else None
81
+ except FileNotFoundError:
82
+ return {}
83
+ except Exception:
84
+ return None
85
+
86
+ def write(p, d):
87
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(p) or ".", prefix="." + os.path.basename(p) + ".")
88
+ try:
89
+ with os.fdopen(fd, "w") as f:
90
+ json.dump(d, f, indent=2); f.write("\n")
91
+ os.chmod(tmp, 0o600)
92
+ os.replace(tmp, p)
93
+ except Exception:
94
+ try: os.unlink(tmp)
95
+ except Exception: pass
96
+ raise
97
+
98
+ def now_iso():
99
+ return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
100
+
101
+ def log_prune(msg):
102
+ line = f"{now_iso()} prune {msg}"
103
+ print(line)
104
+ try:
105
+ with open(prune_log, "a") as f:
106
+ f.write(line + "\n")
107
+ os.chmod(prune_log, 0o600)
108
+ except Exception:
109
+ pass
110
+
111
+ if mode == "register":
112
+ uuid = os.environ["UUID"]; profile = os.environ["PROFILE"]
113
+ binds = load(binds_p)
114
+ if binds is None:
115
+ print(f"grok-bind-register: {binds_p} is malformed; refusing to overwrite", file=sys.stderr); sys.exit(6)
116
+ meta = load(meta_p) or {}
117
+ prev = binds.get(uuid)
118
+ if prev != profile:
119
+ binds[uuid] = profile
120
+ write(binds_p, binds)
121
+ else:
122
+ os.chmod(binds_p, 0o600)
123
+ meta[uuid] = {"profile": profile,
124
+ "registered_by": f"{uuid}@{socket.gethostname()}",
125
+ "ts": now_iso(), "epoch": int(time.time())}
126
+ write(meta_p, meta)
127
+ action = "refreshed" if prev == profile else (f"rebound(from {prev})" if prev else "registered")
128
+ print(f"grok-bind-register: {action} {uuid} -> {profile}")
129
+ sys.exit(0)
130
+
131
+ # prune
132
+ if not os.path.exists(binds_p):
133
+ print(f"grok-bind-register: no binds file {binds_p}; nothing to prune"); sys.exit(0)
134
+ binds = load(binds_p)
135
+ if binds is None:
136
+ print(f"grok-bind-register: {binds_p} is malformed; not pruning", file=sys.stderr); sys.exit(6)
137
+ meta = load(meta_p) or {}
138
+ now = time.time(); removed = []
139
+ for uuid, profile in list(binds.items()):
140
+ reason = None
141
+ if not isinstance(profile, str) or not profile.strip():
142
+ continue
143
+ if not os.path.exists(os.path.join(adir, f"{profile}.json")):
144
+ reason = f"profile file {profile}.json missing"
145
+ else:
146
+ m = meta.get(uuid) if isinstance(meta.get(uuid), dict) else None
147
+ epoch = m.get("epoch") if m else None
148
+ if not os.path.isdir(os.path.join(data_dir, uuid)) and isinstance(epoch, (int, float)) \
149
+ and now - epoch > age_days * 86400:
150
+ reason = f"agent dir missing and last registration {m.get('ts')} older than {age_days:g}d"
151
+ if reason:
152
+ removed.append(uuid)
153
+ log_prune(f"uuid={uuid} profile={profile} reason={reason}")
154
+ if removed:
155
+ for u in removed:
156
+ binds.pop(u, None); meta.pop(u, None)
157
+ write(binds_p, binds); write(meta_p, meta)
158
+ print(f"grok-bind-register: prune done removed={len(removed)} kept={len(binds)}")
159
+ PY