agent-embassy 1.4.1 → 1.5.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/CONTRIBUTING.md +61 -6
  3. package/README.md +6 -5
  4. package/README.zh-CN.md +4 -4
  5. package/SECURITY.md +128 -24
  6. package/dist/src/gateway/claude-peer.d.ts +7 -4
  7. package/dist/src/gateway/claude-peer.js +80 -36
  8. package/dist/src/gateway/claude-peer.js.map +1 -1
  9. package/dist/src/gateway/claude-runtime.d.ts +4 -0
  10. package/dist/src/gateway/claude-runtime.js +50 -39
  11. package/dist/src/gateway/claude-runtime.js.map +1 -1
  12. package/dist/src/gateway/cli-copy.d.ts +1 -1
  13. package/dist/src/gateway/cli-copy.en.d.ts +1 -1
  14. package/dist/src/gateway/cli-copy.en.js +1 -1
  15. package/dist/src/gateway/cli-copy.en.js.map +1 -1
  16. package/dist/src/gateway/cli-copy.js +1 -1
  17. package/dist/src/gateway/cli-copy.js.map +1 -1
  18. package/dist/src/gateway/cli-copy.zh-CN.d.ts +1 -1
  19. package/dist/src/gateway/cli-copy.zh-CN.js +1 -1
  20. package/dist/src/gateway/cli-copy.zh-CN.js.map +1 -1
  21. package/dist/src/gateway/cli.d.ts +1 -1
  22. package/dist/src/gateway/cli.js +3 -7
  23. package/dist/src/gateway/cli.js.map +1 -1
  24. package/dist/src/gateway/codex-app-server.d.ts +2 -1
  25. package/dist/src/gateway/codex-app-server.js +1 -1
  26. package/dist/src/gateway/codex-app-server.js.map +1 -1
  27. package/dist/src/gateway/codex-local-transport.d.ts +10 -6
  28. package/dist/src/gateway/codex-local-transport.js +101 -56
  29. package/dist/src/gateway/codex-local-transport.js.map +1 -1
  30. package/dist/src/gateway/compatibility.d.ts +11 -0
  31. package/dist/src/gateway/compatibility.js +104 -22
  32. package/dist/src/gateway/compatibility.js.map +1 -1
  33. package/dist/src/gateway/control.js +28 -30
  34. package/dist/src/gateway/control.js.map +1 -1
  35. package/dist/src/gateway/dashboard-copy.d.ts +1 -1
  36. package/dist/src/gateway/dashboard-copy.en.d.ts +36 -17
  37. package/dist/src/gateway/dashboard-copy.en.js +50 -31
  38. package/dist/src/gateway/dashboard-copy.en.js.map +1 -1
  39. package/dist/src/gateway/dashboard-copy.js +36 -17
  40. package/dist/src/gateway/dashboard-copy.js.map +1 -1
  41. package/dist/src/gateway/dashboard-copy.zh-CN.d.ts +36 -17
  42. package/dist/src/gateway/dashboard-copy.zh-CN.js +50 -31
  43. package/dist/src/gateway/dashboard-copy.zh-CN.js.map +1 -1
  44. package/dist/src/gateway/dashboard-model.d.ts +176 -8
  45. package/dist/src/gateway/dashboard-model.js +420 -70
  46. package/dist/src/gateway/dashboard-model.js.map +1 -1
  47. package/dist/src/gateway/dashboard.js +48 -135
  48. package/dist/src/gateway/dashboard.js.map +1 -1
  49. package/dist/src/gateway/live-dashboard-app/app.js +148 -247
  50. package/dist/src/gateway/live-dashboard-assets.js +2 -1
  51. package/dist/src/gateway/live-dashboard-assets.js.map +1 -1
  52. package/dist/src/gateway/live-dashboard-stream.d.ts +2 -2
  53. package/dist/src/gateway/live-dashboard-stream.js +2 -2
  54. package/dist/src/gateway/live-dashboard-stream.js.map +1 -1
  55. package/dist/src/gateway/progress-watch-machine.d.ts +50 -64
  56. package/dist/src/gateway/progress-watch-machine.js +42 -133
  57. package/dist/src/gateway/progress-watch-machine.js.map +1 -1
  58. package/dist/src/gateway/provenance-envelope.d.ts +2 -0
  59. package/dist/src/gateway/provenance-envelope.js +13 -3
  60. package/dist/src/gateway/provenance-envelope.js.map +1 -1
  61. package/dist/src/gateway/providers.d.ts +69 -15
  62. package/dist/src/gateway/providers.js +456 -109
  63. package/dist/src/gateway/providers.js.map +1 -1
  64. package/dist/src/gateway/server.d.ts +8 -5
  65. package/dist/src/gateway/server.js +94 -13
  66. package/dist/src/gateway/server.js.map +1 -1
  67. package/dist/src/gateway/service.d.ts +25 -1
  68. package/dist/src/gateway/service.js +252 -69
  69. package/dist/src/gateway/service.js.map +1 -1
  70. package/dist/src/gateway/store.d.ts +23 -28
  71. package/dist/src/gateway/store.js +479 -467
  72. package/dist/src/gateway/store.js.map +1 -1
  73. package/dist/src/gateway/types.d.ts +38 -11
  74. package/dist/src/gateway/types.js +141 -3
  75. package/dist/src/gateway/types.js.map +1 -1
  76. package/docs/CONFIGURATION.md +8 -8
  77. package/docs/CONFIGURATION.zh-CN.md +8 -8
  78. package/docs/DASHBOARD.md +29 -6
  79. package/docs/DASHBOARD.zh-CN.md +1 -1
  80. package/docs/GATEWAY-ARCHITECTURE.md +143 -57
  81. package/package.json +1 -1
  82. package/skills/embassy-peer/SKILL.md +2 -2
@@ -9,7 +9,7 @@ import { BridgeError } from "../errors.js";
9
9
  import { KeyedMutex } from "../mutex.js";
10
10
  import { createCodexRegistrationGeneration, isCodexRegistrationGeneration, } from "./codex-registration-generation.js";
11
11
  import { isDashboardLocale } from "./locale.js";
12
- import { sharesCompatibilityMajor } from "./compatibility.js";
12
+ import { isCompatibilityVersionEvidence, sharesCompatibilityMajor, } from "./compatibility.js";
13
13
  /**
14
14
  * This adapter intentionally pins the inspected, implementation-specific
15
15
  * local peer boundary. The feature is documented in Claude Code, but its
@@ -20,23 +20,17 @@ export const CLAUDE_PEER_COMPATIBILITY = Object.freeze({
20
20
  peerProtocol: 1,
21
21
  messageVersion: 1,
22
22
  });
23
- /** Live peer records may outlive a same-protocol Claude Code upgrade. */
24
- export const CLAUDE_PEER_COMPATIBLE_SESSION_VERSIONS = Object.freeze([
25
- "2.1.224",
26
- "2.1.225",
27
- "2.1.226",
28
- "2.1.227",
29
- ]);
23
+ const EMBASSY_ADVERTISEMENT_VERSION = 1;
30
24
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
31
25
  const ALIAS_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
32
26
  const CLAUDE_PEER_NOTICE_COPY = {
33
27
  en: {
34
- stall: "The local gateway is still waiting to deliver the preceding message. Run `embassy status` or inspect the dashboard for details.",
35
- diagnostic: "The local gateway could not deliver the preceding message. Run `embassy status` or inspect the dashboard for details.",
28
+ stall: "The local gateway is still waiting to deliver the preceding message. Run `embassy status` or inspect the dashboard for details. Queued mail reaches a busy recipient when its turn ends.",
29
+ diagnostic: "The local gateway could not deliver the preceding message. Run `embassy status` or inspect the dashboard for details. Queued mail reaches a busy recipient when its turn ends.",
36
30
  },
37
31
  "zh-CN": {
38
- stall: "本地网关仍在等待投递前一条消息。运行 `embassy status` 或查看仪表盘了解详情。",
39
- diagnostic: "本地网关无法投递前一条消息。运行 `embassy status` 或查看仪表盘了解详情。",
32
+ stall: "本地网关仍在等待投递前一条消息。运行 `embassy status` 或查看仪表盘了解详情。排队邮件会在忙碌接收方的当前轮次结束后到达。",
33
+ diagnostic: "本地网关无法投递前一条消息。运行 `embassy status` 或查看仪表盘了解详情。排队邮件会在忙碌接收方的当前轮次结束后到达。",
40
34
  },
41
35
  };
42
36
  const REGISTRY_FILE_PATTERN = /^([1-9][0-9]{0,9})\.json$/;
@@ -206,9 +200,25 @@ function parseRegistryRecord(value, expectedPid) {
206
200
  "name",
207
201
  "updatedAt",
208
202
  ];
209
- const optional = ["nameSource", "status", "statusUpdatedAt"];
210
- if (!hasExactKeys(value, required, optional))
203
+ if (!required.every((key) => Object.hasOwn(value, key)))
211
204
  return undefined;
205
+ const embassyAdvertisement = value.embassyAdvertisementVersion === EMBASSY_ADVERTISEMENT_VERSION &&
206
+ isCompatibilityVersionEvidence(value.version) &&
207
+ typeof value.name === "string" &&
208
+ value.name.startsWith("codex-") &&
209
+ ALIAS_PATTERN.test(value.name) &&
210
+ value.kind === "interactive" &&
211
+ value.entrypoint === "cli" &&
212
+ value.peerProtocol === CLAUDE_PEER_COMPATIBILITY.peerProtocol &&
213
+ value.status !== undefined &&
214
+ value.statusUpdatedAt !== undefined;
215
+ if (value.embassyAdvertisementVersion !== undefined &&
216
+ !embassyAdvertisement) {
217
+ return undefined;
218
+ }
219
+ const versionCompatible = typeof value.version === "string" &&
220
+ (embassyAdvertisement ||
221
+ sharesCompatibilityMajor(value.version, CLAUDE_PEER_COMPATIBILITY.claudeCodeVersion));
212
222
  if (value.pid !== expectedPid)
213
223
  return undefined;
214
224
  if (typeof value.sessionId !== "string" ||
@@ -230,8 +240,7 @@ function parseRegistryRecord(value, expectedPid) {
230
240
  !isBoundedString(value.procStart, 256) ||
231
241
  value.procStart.length === 0 ||
232
242
  value.procStart.includes("\0") ||
233
- typeof value.version !== "string" ||
234
- !sharesCompatibilityMajor(value.version, CLAUDE_PEER_COMPATIBILITY.claudeCodeVersion) ||
243
+ !versionCompatible ||
235
244
  !isBoundedString(value.entrypoint, 64) ||
236
245
  !/^[A-Za-z0-9._-]+$/.test(value.entrypoint) ||
237
246
  (value.nameSource !== undefined &&
@@ -273,6 +282,7 @@ function parseRegistryRecord(value, expectedPid) {
273
282
  // while the model turn is active. Treat that live process conservatively
274
283
  // as busy.
275
284
  status: (value.status ?? "busy"),
285
+ embassyAdvertisement,
276
286
  };
277
287
  }
278
288
  function parseFrame(line, maxFrameBytes) {
@@ -451,6 +461,7 @@ export class ClaudePeerAdapter {
451
461
  #listenerOwner = Object.freeze({});
452
462
  #registryMutex = new KeyedMutex();
453
463
  #registryPublicationHook;
464
+ #postBindHook;
454
465
  #targets = new Map();
455
466
  #workspacePolicies = new Map();
456
467
  #listeners = new Set();
@@ -458,8 +469,9 @@ export class ClaudePeerAdapter {
458
469
  if (process.platform === "win32" || process.getuid === undefined) {
459
470
  throw new BridgeError("CLAUDE_PEER_PLATFORM_UNSUPPORTED", "Claude peer sockets are supported only on macOS and Linux.");
460
471
  }
461
- if (!sharesCompatibilityMajor(options.attestedClaudeCodeVersion, CLAUDE_PEER_COMPATIBILITY.claudeCodeVersion)) {
462
- throw new BridgeError("CLAUDE_PEER_VERSION_UNSUPPORTED", `Claude peer compatibility is pinned to Claude Code ${CLAUDE_PEER_COMPATIBILITY.claudeCodeVersion}.`);
472
+ if (options.attestedClaudeCodeVersion !== "unknown" &&
473
+ !sharesCompatibilityMajor(options.attestedClaudeCodeVersion, CLAUDE_PEER_COMPATIBILITY.claudeCodeVersion)) {
474
+ throw new BridgeError("CLAUDE_PEER_VERSION_UNSUPPORTED", "Claude peer transport does not support this Claude Code major.");
463
475
  }
464
476
  if (options.locale !== undefined && !isDashboardLocale(options.locale)) {
465
477
  throw new BridgeError("DASHBOARD_LOCALE_UNSUPPORTED", "The Claude peer notice locale is unsupported.");
@@ -473,7 +485,7 @@ export class ClaudePeerAdapter {
473
485
  this.#deliveryNotices = options.deliveryNotices ?? "merged";
474
486
  this.#sessionsDir = assertAbsoluteConfiguredPath(options.sessionsDir, "sessionsDir");
475
487
  this.#socketDir = assertAbsoluteConfiguredPath(options.socketDir, "socketDir");
476
- this.#expectedUid = process.getuid();
488
+ this.#expectedUid = testing.expectedUid ?? process.getuid();
477
489
  if (!Number.isSafeInteger(this.#expectedUid) || this.#expectedUid < 0) {
478
490
  throw new BridgeError("INVALID_PEER_UID", "expectedUid must be a non-negative integer.");
479
491
  }
@@ -504,13 +516,23 @@ export class ClaudePeerAdapter {
504
516
  ...(testing.tempRoots ?? ["/tmp", "/private/tmp", os.tmpdir()]),
505
517
  ].map((root) => assertAbsoluteConfiguredPath(root, "tempRoot")));
506
518
  this.#registryPublicationHook = testing.registryPublicationHook;
519
+ this.#postBindHook = testing.postBindHook;
507
520
  }
508
- async #validatePrivateDirectory(directory) {
521
+ async #validateRealDirectory(directory) {
509
522
  const stat = await lstat(directory);
510
523
  if (stat.isSymbolicLink() || !stat.isDirectory()) {
511
524
  throw new BridgeError("UNSAFE_PEER_DIRECTORY", "The Claude peer directory is not an accessible real directory.");
512
525
  }
513
526
  }
527
+ async #validateSessionsDirectory() {
528
+ const stat = await lstat(this.#sessionsDir);
529
+ if (stat.isSymbolicLink() ||
530
+ !stat.isDirectory() ||
531
+ stat.uid !== this.#expectedUid ||
532
+ exactMode(stat.mode) !== 0o700) {
533
+ throw new BridgeError("UNSAFE_PEER_DIRECTORY", "The Claude sessions directory failed its exact owner and mode policy.");
534
+ }
535
+ }
514
536
  async #readRegistryFile(registryPath) {
515
537
  const before = await lstat(registryPath);
516
538
  if (before.isSymbolicLink() || !before.isFile()) {
@@ -580,6 +602,11 @@ export class ClaudePeerAdapter {
580
602
  if (record === undefined) {
581
603
  throw new BridgeError("REGISTRY_INVALID_SCHEMA", "Registry schema is incompatible.");
582
604
  }
605
+ // Embassy advertisements are addressable by Claude, never Claude delivery
606
+ // targets or evidence that a Claude record parsed.
607
+ if (record.embassyAdvertisement || expectedPid === process.pid) {
608
+ throw new BridgeError("SELF_TARGET", "The gateway cannot target itself.");
609
+ }
583
610
  onParsed?.();
584
611
  const processIdentity = await this.#inspectProcess(expectedPid);
585
612
  if (processIdentity === undefined) {
@@ -588,9 +615,6 @@ export class ClaudePeerAdapter {
588
615
  if (processIdentity.uid !== this.#expectedUid) {
589
616
  throw new BridgeError("PID_OWNER_MISMATCH", "Registry process owner is unsafe.");
590
617
  }
591
- if (expectedPid === process.pid) {
592
- throw new BridgeError("SELF_TARGET", "The gateway cannot target itself.");
593
- }
594
618
  const socketGeneration = await this.#validateSocket(record.messagingSocketPath, expectedPid);
595
619
  return {
596
620
  // Claude's native session UUID is the logical route identity. The
@@ -608,8 +632,8 @@ export class ClaudePeerAdapter {
608
632
  }
609
633
  async discover() {
610
634
  await Promise.all([
611
- this.#validatePrivateDirectory(this.#sessionsDir),
612
- this.#validatePrivateDirectory(this.#socketDir),
635
+ this.#validateSessionsDirectory(),
636
+ this.#validateRealDirectory(this.#socketDir),
613
637
  ]);
614
638
  const previousTargets = [...this.#targets.values()];
615
639
  const nextTargets = new Map();
@@ -682,6 +706,8 @@ export class ClaudePeerAdapter {
682
706
  const code = error instanceof BridgeError
683
707
  ? error.code
684
708
  : "REGISTRY_RACED";
709
+ if (code === "SELF_TARGET")
710
+ continue;
685
711
  if (claudePeerRejectionCodes.includes(code)) {
686
712
  reject(code);
687
713
  }
@@ -708,8 +734,8 @@ export class ClaudePeerAdapter {
708
734
  }
709
735
  async #revalidateBinding(binding) {
710
736
  await Promise.all([
711
- this.#validatePrivateDirectory(this.#sessionsDir),
712
- this.#validatePrivateDirectory(this.#socketDir),
737
+ this.#validateSessionsDirectory(),
738
+ this.#validateRealDirectory(this.#socketDir),
713
739
  ]);
714
740
  if (binding.expiresAt < this.#now()) {
715
741
  this.#targets.delete(binding.targetId);
@@ -847,8 +873,8 @@ export class ClaudePeerAdapter {
847
873
  }
848
874
  async #resolveReplyAddress(address) {
849
875
  await Promise.all([
850
- this.#validatePrivateDirectory(this.#sessionsDir),
851
- this.#validatePrivateDirectory(this.#socketDir),
876
+ this.#validateSessionsDirectory(),
877
+ this.#validateRealDirectory(this.#socketDir),
852
878
  ]);
853
879
  if (!address.startsWith("uds:")) {
854
880
  throw new BridgeError("UNREGISTERED_REPLY_ADDRESS", "Only local registered UDS reply addresses are accepted.");
@@ -887,8 +913,8 @@ export class ClaudePeerAdapter {
887
913
  */
888
914
  async resolveReplyAddress(address) {
889
915
  await Promise.all([
890
- this.#validatePrivateDirectory(this.#sessionsDir),
891
- this.#validatePrivateDirectory(this.#socketDir),
916
+ this.#validateSessionsDirectory(),
917
+ this.#validateRealDirectory(this.#socketDir),
892
918
  ]);
893
919
  const binding = await this.#resolveReplyAddress(address);
894
920
  return {
@@ -901,8 +927,8 @@ export class ClaudePeerAdapter {
901
927
  }
902
928
  async #listen(options, preparedGeneration) {
903
929
  await Promise.all([
904
- this.#validatePrivateDirectory(this.#sessionsDir),
905
- this.#validatePrivateDirectory(this.#socketDir),
930
+ this.#validateSessionsDirectory(),
931
+ this.#validateRealDirectory(this.#socketDir),
906
932
  ]);
907
933
  const generation = preparedGeneration ?? this.#createGeneration();
908
934
  if (!isCodexRegistrationGeneration(generation)) {
@@ -950,6 +976,7 @@ export class ClaudePeerAdapter {
950
976
  ...(this.#registryPublicationHook === undefined
951
977
  ? {}
952
978
  : { registryPublicationHook: this.#registryPublicationHook }),
979
+ postBindHook: this.#postBindHook,
953
980
  onClosed: () => this.#listeners.delete(listener),
954
981
  });
955
982
  this.#listeners.add(listener);
@@ -1266,6 +1293,7 @@ export class ClaudePeerListener {
1266
1293
  !sameSocketGeneration({ dev: attested.dev, ino: attested.ino }, createdGeneration)) {
1267
1294
  throw new BridgeError("CLAUDE_PEER_CALLBACK_UNSAFE", "The gateway callback socket did not satisfy its ownership policy.");
1268
1295
  }
1296
+ await options.postBindHook?.(socketPath);
1269
1297
  const sessions = await lstat(options.sessionsDir);
1270
1298
  if (sessions.isSymbolicLink() ||
1271
1299
  !sessions.isDirectory() ||
@@ -1290,11 +1318,25 @@ export class ClaudePeerListener {
1290
1318
  // net.Server.close(), which deletes by pathname.
1291
1319
  }
1292
1320
  }
1321
+ let closeConfirmed = false;
1293
1322
  if (stillOwned) {
1294
- await new Promise((resolve) => server.close(() => resolve()));
1323
+ try {
1324
+ await new Promise((resolve, reject) => server.close((closeError) => closeError === undefined ? resolve() : reject(closeError)));
1325
+ try {
1326
+ await lstat(socketPath);
1327
+ }
1328
+ catch (closeError) {
1329
+ closeConfirmed =
1330
+ closeError.code === "ENOENT";
1331
+ }
1332
+ }
1333
+ catch {
1334
+ // An unconfirmed close cannot safely downgrade a callback failure.
1335
+ }
1295
1336
  }
1296
- else {
1337
+ if (!closeConfirmed) {
1297
1338
  server.unref();
1339
+ throw new BridgeError("CLAUDE_PEER_CALLBACK_UNSAFE", "The newly bound callback socket could not be safely closed.");
1298
1340
  }
1299
1341
  throw error;
1300
1342
  }
@@ -1315,7 +1357,8 @@ export class ClaudePeerListener {
1315
1357
  return `${JSON.stringify(record)}\n`;
1316
1358
  }
1317
1359
  #recordBelongsToThisListener(record) {
1318
- return (record.pid === process.pid &&
1360
+ return (record.embassyAdvertisementVersion === EMBASSY_ADVERTISEMENT_VERSION &&
1361
+ record.pid === process.pid &&
1319
1362
  UUID_PATTERN.test(record.sessionId) &&
1320
1363
  record.messagingSocketPath === this.#socketPath &&
1321
1364
  record.version === this.#attestedClaudeCodeVersion &&
@@ -1486,6 +1529,7 @@ export class ClaudePeerListener {
1486
1529
  }
1487
1530
  const now = Date.now();
1488
1531
  return {
1532
+ embassyAdvertisementVersion: EMBASSY_ADVERTISEMENT_VERSION,
1489
1533
  pid: process.pid,
1490
1534
  sessionId,
1491
1535
  cwd,