agent-embassy 1.4.0 → 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 +32 -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 +8 -5
  7. package/dist/src/gateway/claude-peer.js +81 -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,33 +9,28 @@ 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
16
16
  * registry and NDJSON formats are not a stable public integration contract.
17
17
  */
18
18
  export const CLAUDE_PEER_COMPATIBILITY = Object.freeze({
19
- claudeCodeVersion: "2.1.226",
19
+ claudeCodeVersion: "2.1.227",
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
- ]);
23
+ const EMBASSY_ADVERTISEMENT_VERSION = 1;
29
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;
30
25
  const ALIAS_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
31
26
  const CLAUDE_PEER_NOTICE_COPY = {
32
27
  en: {
33
- stall: "The local gateway is still waiting to deliver the preceding message. Run `embassy status` or inspect the dashboard for details.",
34
- 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.",
35
30
  },
36
31
  "zh-CN": {
37
- stall: "本地网关仍在等待投递前一条消息。运行 `embassy status` 或查看仪表盘了解详情。",
38
- diagnostic: "本地网关无法投递前一条消息。运行 `embassy status` 或查看仪表盘了解详情。",
32
+ stall: "本地网关仍在等待投递前一条消息。运行 `embassy status` 或查看仪表盘了解详情。排队邮件会在忙碌接收方的当前轮次结束后到达。",
33
+ diagnostic: "本地网关无法投递前一条消息。运行 `embassy status` 或查看仪表盘了解详情。排队邮件会在忙碌接收方的当前轮次结束后到达。",
39
34
  },
40
35
  };
41
36
  const REGISTRY_FILE_PATTERN = /^([1-9][0-9]{0,9})\.json$/;
@@ -205,9 +200,25 @@ function parseRegistryRecord(value, expectedPid) {
205
200
  "name",
206
201
  "updatedAt",
207
202
  ];
208
- const optional = ["nameSource", "status", "statusUpdatedAt"];
209
- if (!hasExactKeys(value, required, optional))
203
+ if (!required.every((key) => Object.hasOwn(value, key)))
210
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));
211
222
  if (value.pid !== expectedPid)
212
223
  return undefined;
213
224
  if (typeof value.sessionId !== "string" ||
@@ -229,8 +240,7 @@ function parseRegistryRecord(value, expectedPid) {
229
240
  !isBoundedString(value.procStart, 256) ||
230
241
  value.procStart.length === 0 ||
231
242
  value.procStart.includes("\0") ||
232
- typeof value.version !== "string" ||
233
- !sharesCompatibilityMajor(value.version, CLAUDE_PEER_COMPATIBILITY.claudeCodeVersion) ||
243
+ !versionCompatible ||
234
244
  !isBoundedString(value.entrypoint, 64) ||
235
245
  !/^[A-Za-z0-9._-]+$/.test(value.entrypoint) ||
236
246
  (value.nameSource !== undefined &&
@@ -272,6 +282,7 @@ function parseRegistryRecord(value, expectedPid) {
272
282
  // while the model turn is active. Treat that live process conservatively
273
283
  // as busy.
274
284
  status: (value.status ?? "busy"),
285
+ embassyAdvertisement,
275
286
  };
276
287
  }
277
288
  function parseFrame(line, maxFrameBytes) {
@@ -450,6 +461,7 @@ export class ClaudePeerAdapter {
450
461
  #listenerOwner = Object.freeze({});
451
462
  #registryMutex = new KeyedMutex();
452
463
  #registryPublicationHook;
464
+ #postBindHook;
453
465
  #targets = new Map();
454
466
  #workspacePolicies = new Map();
455
467
  #listeners = new Set();
@@ -457,8 +469,9 @@ export class ClaudePeerAdapter {
457
469
  if (process.platform === "win32" || process.getuid === undefined) {
458
470
  throw new BridgeError("CLAUDE_PEER_PLATFORM_UNSUPPORTED", "Claude peer sockets are supported only on macOS and Linux.");
459
471
  }
460
- if (!sharesCompatibilityMajor(options.attestedClaudeCodeVersion, CLAUDE_PEER_COMPATIBILITY.claudeCodeVersion)) {
461
- 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.");
462
475
  }
463
476
  if (options.locale !== undefined && !isDashboardLocale(options.locale)) {
464
477
  throw new BridgeError("DASHBOARD_LOCALE_UNSUPPORTED", "The Claude peer notice locale is unsupported.");
@@ -472,7 +485,7 @@ export class ClaudePeerAdapter {
472
485
  this.#deliveryNotices = options.deliveryNotices ?? "merged";
473
486
  this.#sessionsDir = assertAbsoluteConfiguredPath(options.sessionsDir, "sessionsDir");
474
487
  this.#socketDir = assertAbsoluteConfiguredPath(options.socketDir, "socketDir");
475
- this.#expectedUid = process.getuid();
488
+ this.#expectedUid = testing.expectedUid ?? process.getuid();
476
489
  if (!Number.isSafeInteger(this.#expectedUid) || this.#expectedUid < 0) {
477
490
  throw new BridgeError("INVALID_PEER_UID", "expectedUid must be a non-negative integer.");
478
491
  }
@@ -503,13 +516,23 @@ export class ClaudePeerAdapter {
503
516
  ...(testing.tempRoots ?? ["/tmp", "/private/tmp", os.tmpdir()]),
504
517
  ].map((root) => assertAbsoluteConfiguredPath(root, "tempRoot")));
505
518
  this.#registryPublicationHook = testing.registryPublicationHook;
519
+ this.#postBindHook = testing.postBindHook;
506
520
  }
507
- async #validatePrivateDirectory(directory) {
521
+ async #validateRealDirectory(directory) {
508
522
  const stat = await lstat(directory);
509
523
  if (stat.isSymbolicLink() || !stat.isDirectory()) {
510
524
  throw new BridgeError("UNSAFE_PEER_DIRECTORY", "The Claude peer directory is not an accessible real directory.");
511
525
  }
512
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
+ }
513
536
  async #readRegistryFile(registryPath) {
514
537
  const before = await lstat(registryPath);
515
538
  if (before.isSymbolicLink() || !before.isFile()) {
@@ -579,6 +602,11 @@ export class ClaudePeerAdapter {
579
602
  if (record === undefined) {
580
603
  throw new BridgeError("REGISTRY_INVALID_SCHEMA", "Registry schema is incompatible.");
581
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
+ }
582
610
  onParsed?.();
583
611
  const processIdentity = await this.#inspectProcess(expectedPid);
584
612
  if (processIdentity === undefined) {
@@ -587,9 +615,6 @@ export class ClaudePeerAdapter {
587
615
  if (processIdentity.uid !== this.#expectedUid) {
588
616
  throw new BridgeError("PID_OWNER_MISMATCH", "Registry process owner is unsafe.");
589
617
  }
590
- if (expectedPid === process.pid) {
591
- throw new BridgeError("SELF_TARGET", "The gateway cannot target itself.");
592
- }
593
618
  const socketGeneration = await this.#validateSocket(record.messagingSocketPath, expectedPid);
594
619
  return {
595
620
  // Claude's native session UUID is the logical route identity. The
@@ -607,8 +632,8 @@ export class ClaudePeerAdapter {
607
632
  }
608
633
  async discover() {
609
634
  await Promise.all([
610
- this.#validatePrivateDirectory(this.#sessionsDir),
611
- this.#validatePrivateDirectory(this.#socketDir),
635
+ this.#validateSessionsDirectory(),
636
+ this.#validateRealDirectory(this.#socketDir),
612
637
  ]);
613
638
  const previousTargets = [...this.#targets.values()];
614
639
  const nextTargets = new Map();
@@ -681,6 +706,8 @@ export class ClaudePeerAdapter {
681
706
  const code = error instanceof BridgeError
682
707
  ? error.code
683
708
  : "REGISTRY_RACED";
709
+ if (code === "SELF_TARGET")
710
+ continue;
684
711
  if (claudePeerRejectionCodes.includes(code)) {
685
712
  reject(code);
686
713
  }
@@ -707,8 +734,8 @@ export class ClaudePeerAdapter {
707
734
  }
708
735
  async #revalidateBinding(binding) {
709
736
  await Promise.all([
710
- this.#validatePrivateDirectory(this.#sessionsDir),
711
- this.#validatePrivateDirectory(this.#socketDir),
737
+ this.#validateSessionsDirectory(),
738
+ this.#validateRealDirectory(this.#socketDir),
712
739
  ]);
713
740
  if (binding.expiresAt < this.#now()) {
714
741
  this.#targets.delete(binding.targetId);
@@ -846,8 +873,8 @@ export class ClaudePeerAdapter {
846
873
  }
847
874
  async #resolveReplyAddress(address) {
848
875
  await Promise.all([
849
- this.#validatePrivateDirectory(this.#sessionsDir),
850
- this.#validatePrivateDirectory(this.#socketDir),
876
+ this.#validateSessionsDirectory(),
877
+ this.#validateRealDirectory(this.#socketDir),
851
878
  ]);
852
879
  if (!address.startsWith("uds:")) {
853
880
  throw new BridgeError("UNREGISTERED_REPLY_ADDRESS", "Only local registered UDS reply addresses are accepted.");
@@ -886,8 +913,8 @@ export class ClaudePeerAdapter {
886
913
  */
887
914
  async resolveReplyAddress(address) {
888
915
  await Promise.all([
889
- this.#validatePrivateDirectory(this.#sessionsDir),
890
- this.#validatePrivateDirectory(this.#socketDir),
916
+ this.#validateSessionsDirectory(),
917
+ this.#validateRealDirectory(this.#socketDir),
891
918
  ]);
892
919
  const binding = await this.#resolveReplyAddress(address);
893
920
  return {
@@ -900,8 +927,8 @@ export class ClaudePeerAdapter {
900
927
  }
901
928
  async #listen(options, preparedGeneration) {
902
929
  await Promise.all([
903
- this.#validatePrivateDirectory(this.#sessionsDir),
904
- this.#validatePrivateDirectory(this.#socketDir),
930
+ this.#validateSessionsDirectory(),
931
+ this.#validateRealDirectory(this.#socketDir),
905
932
  ]);
906
933
  const generation = preparedGeneration ?? this.#createGeneration();
907
934
  if (!isCodexRegistrationGeneration(generation)) {
@@ -949,6 +976,7 @@ export class ClaudePeerAdapter {
949
976
  ...(this.#registryPublicationHook === undefined
950
977
  ? {}
951
978
  : { registryPublicationHook: this.#registryPublicationHook }),
979
+ postBindHook: this.#postBindHook,
952
980
  onClosed: () => this.#listeners.delete(listener),
953
981
  });
954
982
  this.#listeners.add(listener);
@@ -1265,6 +1293,7 @@ export class ClaudePeerListener {
1265
1293
  !sameSocketGeneration({ dev: attested.dev, ino: attested.ino }, createdGeneration)) {
1266
1294
  throw new BridgeError("CLAUDE_PEER_CALLBACK_UNSAFE", "The gateway callback socket did not satisfy its ownership policy.");
1267
1295
  }
1296
+ await options.postBindHook?.(socketPath);
1268
1297
  const sessions = await lstat(options.sessionsDir);
1269
1298
  if (sessions.isSymbolicLink() ||
1270
1299
  !sessions.isDirectory() ||
@@ -1289,11 +1318,25 @@ export class ClaudePeerListener {
1289
1318
  // net.Server.close(), which deletes by pathname.
1290
1319
  }
1291
1320
  }
1321
+ let closeConfirmed = false;
1292
1322
  if (stillOwned) {
1293
- 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
+ }
1294
1336
  }
1295
- else {
1337
+ if (!closeConfirmed) {
1296
1338
  server.unref();
1339
+ throw new BridgeError("CLAUDE_PEER_CALLBACK_UNSAFE", "The newly bound callback socket could not be safely closed.");
1297
1340
  }
1298
1341
  throw error;
1299
1342
  }
@@ -1314,7 +1357,8 @@ export class ClaudePeerListener {
1314
1357
  return `${JSON.stringify(record)}\n`;
1315
1358
  }
1316
1359
  #recordBelongsToThisListener(record) {
1317
- return (record.pid === process.pid &&
1360
+ return (record.embassyAdvertisementVersion === EMBASSY_ADVERTISEMENT_VERSION &&
1361
+ record.pid === process.pid &&
1318
1362
  UUID_PATTERN.test(record.sessionId) &&
1319
1363
  record.messagingSocketPath === this.#socketPath &&
1320
1364
  record.version === this.#attestedClaudeCodeVersion &&
@@ -1485,6 +1529,7 @@ export class ClaudePeerListener {
1485
1529
  }
1486
1530
  const now = Date.now();
1487
1531
  return {
1532
+ embassyAdvertisementVersion: EMBASSY_ADVERTISEMENT_VERSION,
1488
1533
  pid: process.pid,
1489
1534
  sessionId,
1490
1535
  cwd,