agent-comms 8.15.9 → 8.15.11

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 (189) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +1 -1
  4. package/dist/bridges/user/web/server.d.ts.map +1 -1
  5. package/dist/bridges/user/web/server.js +44 -24
  6. package/dist/bridges/user/web/server.js.map +1 -1
  7. package/dist/bridges/user/web/test/web-assets.unit.test.d.ts +5 -0
  8. package/dist/bridges/user/web/test/web-assets.unit.test.d.ts.map +1 -0
  9. package/dist/bridges/user/web/test/web-assets.unit.test.js +90 -0
  10. package/dist/bridges/user/web/test/web-assets.unit.test.js.map +1 -0
  11. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts +2 -0
  12. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts.map +1 -0
  13. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js +75 -0
  14. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js.map +1 -0
  15. package/dist/bridges/user/web/web-assets.d.ts +28 -0
  16. package/dist/bridges/user/web/web-assets.d.ts.map +1 -0
  17. package/dist/bridges/user/web/web-assets.js +67 -0
  18. package/dist/bridges/user/web/web-assets.js.map +1 -0
  19. package/dist/test/pi-manifest-packed.integration.test.d.ts +2 -0
  20. package/dist/test/pi-manifest-packed.integration.test.d.ts.map +1 -0
  21. package/dist/test/pi-manifest-packed.integration.test.js +68 -0
  22. package/dist/test/pi-manifest-packed.integration.test.js.map +1 -0
  23. package/package.json +8 -2
  24. package/src/account-cli.ts +195 -0
  25. package/src/account-join-cli.ts +191 -0
  26. package/src/bridges/cc-peer/approval-commands.ts +82 -0
  27. package/src/bridges/cc-peer/bridge.ts +138 -0
  28. package/src/bridges/cc-peer/default-front.ts +58 -0
  29. package/src/bridges/cc-peer/front-controller.ts +160 -0
  30. package/src/bridges/cc-peer/front-relay.ts +191 -0
  31. package/src/bridges/cc-peer/front-runtime.ts +167 -0
  32. package/src/bridges/cc-peer/front.ts +59 -0
  33. package/src/bridges/cc-peer/reply-aliases.ts +90 -0
  34. package/src/bridges/cc-peer/run.ts +100 -0
  35. package/src/bridges/cc-peer/shared-peer.ts +28 -0
  36. package/src/bridges/claude-code/channel.ts +318 -0
  37. package/src/bridges/codex/tool.ts +132 -0
  38. package/src/bridges/mcp/server.ts +137 -0
  39. package/src/bridges/opencode/plugin.ts +130 -0
  40. package/src/bridges/pi/extension.ts +330 -0
  41. package/src/bridges/registry.ts +17 -0
  42. package/src/bridges/user/cli.ts +87 -0
  43. package/src/bridges/user/controller.ts +345 -0
  44. package/src/bridges/user/tui.ts +300 -0
  45. package/src/bridges/user/web/actions.ts +173 -0
  46. package/src/bridges/user/web/contract.ts +222 -0
  47. package/src/bridges/user/web/dist/bundle.js +49 -0
  48. package/src/bridges/user/web/dist/icons/icon-192x192.svg +9 -0
  49. package/src/bridges/user/web/dist/icons/icon-512x512.svg +9 -0
  50. package/src/bridges/user/web/dist/icons/icon-96x96.svg +9 -0
  51. package/src/bridges/user/web/dist/index.html +21 -0
  52. package/src/bridges/user/web/dist/manifest.json +26 -0
  53. package/src/bridges/user/web/dist/mesh-worker.js +64 -0
  54. package/src/bridges/user/web/dist/sw.js +1 -0
  55. package/src/bridges/user/web/event-publisher.ts +114 -0
  56. package/src/bridges/user/web/port-discovery.ts +49 -0
  57. package/src/bridges/user/web/reads.ts +35 -0
  58. package/src/bridges/user/web/router.ts +158 -0
  59. package/src/bridges/user/web/server.ts +782 -0
  60. package/src/bridges/user/web/vite.config.ts +71 -0
  61. package/src/bridges/user/web/web-access.ts +404 -0
  62. package/src/bridges/user/web/web-assets.ts +90 -0
  63. package/src/bridges/user/web/web-beacon.ts +69 -0
  64. package/src/bridges/user/web/web-console-static.ts +90 -0
  65. package/src/cli-main.ts +635 -0
  66. package/src/cli.ts +13 -0
  67. package/src/core/account-bundle.ts +264 -0
  68. package/src/core/account-join.ts +289 -0
  69. package/src/core/account-ledger-crypto.ts +154 -0
  70. package/src/core/account-ledger-store.ts +66 -0
  71. package/src/core/account-ledger.ts +385 -0
  72. package/src/core/agent-details.ts +216 -0
  73. package/src/core/agent-list-format.ts +97 -0
  74. package/src/core/agent-registry.ts +282 -0
  75. package/src/core/agent-table.ts +58 -0
  76. package/src/core/atomic-file.ts +46 -0
  77. package/src/core/bind-retry.ts +38 -0
  78. package/src/core/bridge-mesh.ts +176 -0
  79. package/src/core/bridge.ts +718 -0
  80. package/src/core/capability-ask.ts +162 -0
  81. package/src/core/capability-bubble-up.ts +237 -0
  82. package/src/core/comms-store.ts +130 -0
  83. package/src/core/connection-approval.ts +134 -0
  84. package/src/core/connection-code-tool.ts +92 -0
  85. package/src/core/connection-code.ts +296 -0
  86. package/src/core/coordinator-role.ts +224 -0
  87. package/src/core/data-directory.ts +32 -0
  88. package/src/core/data-frame-routing.ts +309 -0
  89. package/src/core/default-command.ts +12 -0
  90. package/src/core/delegation-policy.ts +41 -0
  91. package/src/core/delivery-engine.ts +649 -0
  92. package/src/core/device-membership-verification.ts +125 -0
  93. package/src/core/device-membership.ts +130 -0
  94. package/src/core/directory-admission.ts +155 -0
  95. package/src/core/discovery-mdns.ts +247 -0
  96. package/src/core/discovery-tailscale.ts +224 -0
  97. package/src/core/discovery.ts +303 -0
  98. package/src/core/display-name.ts +73 -0
  99. package/src/core/dm-grant-actions.ts +72 -0
  100. package/src/core/dm-send-delegation.ts +73 -0
  101. package/src/core/dm-token-verification.ts +76 -0
  102. package/src/core/election-sessions.ts +135 -0
  103. package/src/core/error-reporting.ts +85 -0
  104. package/src/core/first-contact.ts +242 -0
  105. package/src/core/gateway-trust-actions.ts +184 -0
  106. package/src/core/gateway-trust.ts +305 -0
  107. package/src/core/gossip-directory.ts +276 -0
  108. package/src/core/gossip-extensions.ts +123 -0
  109. package/src/core/group-proofs.ts +205 -0
  110. package/src/core/handshake.ts +354 -0
  111. package/src/core/hub-forwarding.ts +24 -0
  112. package/src/core/hub-link.ts +176 -0
  113. package/src/core/hub-session.ts +374 -0
  114. package/src/core/identity-store.ts +715 -0
  115. package/src/core/identity.ts +489 -0
  116. package/src/core/index.ts +65 -0
  117. package/src/core/issuer-grouping.ts +165 -0
  118. package/src/core/issuer-identity-file.ts +187 -0
  119. package/src/core/lan-web-actions.ts +34 -0
  120. package/src/core/lan-web-uis.ts +55 -0
  121. package/src/core/ledger-lock.ts +81 -0
  122. package/src/core/listener-registry.ts +133 -0
  123. package/src/core/loopback-address.ts +22 -0
  124. package/src/core/machine-identity.ts +77 -0
  125. package/src/core/machine-list-groups.ts +53 -0
  126. package/src/core/mcp-registry-retry.ts +69 -0
  127. package/src/core/mcp-registry-version.ts +18 -0
  128. package/src/core/membership-proof.ts +139 -0
  129. package/src/core/membership-proofs.ts +149 -0
  130. package/src/core/mesh-graph-trace-tool.ts +81 -0
  131. package/src/core/mesh-graph.ts +89 -0
  132. package/src/core/mesh-network-actions.ts +183 -0
  133. package/src/core/mesh-store-options.ts +23 -0
  134. package/src/core/mesh-store-shared.ts +134 -0
  135. package/src/core/mesh-store.ts +1114 -0
  136. package/src/core/name-claim.ts +145 -0
  137. package/src/core/naming-actions.ts +90 -0
  138. package/src/core/naming.ts +138 -0
  139. package/src/core/nanoid.ts +20 -0
  140. package/src/core/network-interfaces.ts +24 -0
  141. package/src/core/package-version.ts +34 -0
  142. package/src/core/peer-lifecycle.ts +377 -0
  143. package/src/core/peer-link-error.ts +41 -0
  144. package/src/core/peer-versions.ts +98 -0
  145. package/src/core/pending-connection.ts +53 -0
  146. package/src/core/petnames.ts +121 -0
  147. package/src/core/pgp-keyserver.ts +32 -0
  148. package/src/core/pid-lock-file.ts +122 -0
  149. package/src/core/push-manager.ts +76 -0
  150. package/src/core/random-id.ts +9 -0
  151. package/src/core/receiver-set.ts +42 -0
  152. package/src/core/relay-role.ts +182 -0
  153. package/src/core/relay-selection.ts +120 -0
  154. package/src/core/relay-server.ts +198 -0
  155. package/src/core/relay-uplink.ts +78 -0
  156. package/src/core/request-timeouts.ts +37 -0
  157. package/src/core/room-discovery.ts +48 -0
  158. package/src/core/room-lifecycle.ts +838 -0
  159. package/src/core/room-lookup.ts +41 -0
  160. package/src/core/room-messaging.ts +247 -0
  161. package/src/core/room-notice-log.ts +61 -0
  162. package/src/core/room-path.ts +84 -0
  163. package/src/core/room-protocol.ts +893 -0
  164. package/src/core/room-router.ts +180 -0
  165. package/src/core/room-token-verification.ts +85 -0
  166. package/src/core/room-wire-extensions.ts +60 -0
  167. package/src/core/self-name-claim.ts +67 -0
  168. package/src/core/send-outcome.ts +69 -0
  169. package/src/core/shutdown-signals.ts +57 -0
  170. package/src/core/stale-agent-checker.ts +108 -0
  171. package/src/core/store.ts +704 -0
  172. package/src/core/token-text.ts +40 -0
  173. package/src/core/tool.ts +853 -0
  174. package/src/core/transport-events.ts +73 -0
  175. package/src/core/transport-queries.ts +54 -0
  176. package/src/core/transport.ts +367 -0
  177. package/src/core/types.ts +550 -0
  178. package/src/core/user-identity.ts +290 -0
  179. package/src/core/vapid.ts +81 -0
  180. package/src/core/version-check.ts +147 -0
  181. package/src/core/version-report-actions.ts +79 -0
  182. package/src/core/web-beacon.ts +31 -0
  183. package/src/core/web-push.ts +322 -0
  184. package/src/core/wire-mesh-core-version.ts +25 -0
  185. package/src/core/wire-mesh-identity.ts +36 -0
  186. package/src/core/wire-mesh-transport-options.ts +82 -0
  187. package/src/core/wire-mesh-transport.ts +1007 -0
  188. package/src/core/wire-protocol.ts +153 -0
  189. package/src/core/ws-dial.ts +191 -0
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Every extension the `pi` manifest in package.json names must ship in the published package. `pi install npm:agent-comms` reported success and then could not load the extension when the manifest pointed at `src/`, which `files` did not publish (ExaDev/agent-comms#412, #440); nothing in the suite noticed, because the manifest and the tarball were never compared.
3
+ *
4
+ * The tarball contents come from `npm pack --dry-run`, the same file selection a publish uses, so a `files` entry or an ignore rule that drops the manifest's path fails here and not in a user's install.
5
+ */
6
+ import { spawnSync } from "node:child_process";
7
+ import * as fs from "node:fs";
8
+ import path from "node:path";
9
+ import { describe, expect, it } from "vitest";
10
+ const packageRoot = path.resolve(import.meta.dirname, "../..");
11
+ function isRecord(value) {
12
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13
+ }
14
+ function isStringArray(value) {
15
+ return (Array.isArray(value) && value.every((item) => typeof item === "string"));
16
+ }
17
+ function readJson(file) {
18
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
19
+ }
20
+ function manifestExtensions() {
21
+ const manifest = readJson(path.join(packageRoot, "package.json"));
22
+ if (!isRecord(manifest) || !isRecord(manifest.pi)) {
23
+ throw new Error("package.json has no pi manifest");
24
+ }
25
+ const { extensions } = manifest.pi;
26
+ if (!isStringArray(extensions)) {
27
+ throw new Error("package.json pi.extensions is not a list of paths");
28
+ }
29
+ return extensions;
30
+ }
31
+ function packedPaths() {
32
+ // --ignore-scripts keeps prepack and prepare out of a listing that only needs the file selection.
33
+ const result = spawnSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { cwd: packageRoot, encoding: "utf-8" });
34
+ if (result.status !== 0) {
35
+ throw new Error(`npm pack --dry-run failed: ${result.stderr}`);
36
+ }
37
+ const listing = JSON.parse(result.stdout);
38
+ if (!Array.isArray(listing) || !isRecord(listing[0])) {
39
+ throw new Error("npm pack --dry-run printed no package listing");
40
+ }
41
+ const { files } = listing[0];
42
+ if (!Array.isArray(files)) {
43
+ throw new Error("npm pack --dry-run listing has no files");
44
+ }
45
+ return files.map((file) => {
46
+ if (!isRecord(file) || typeof file.path !== "string") {
47
+ throw new Error("npm pack --dry-run listed a file without a path");
48
+ }
49
+ return file.path;
50
+ });
51
+ }
52
+ describe("the pi manifest", () => {
53
+ it("names at least one extension, so the loop below cannot pass by checking nothing", () => {
54
+ expect(manifestExtensions().length).toBeGreaterThan(0);
55
+ });
56
+ it("names only extensions that exist in the repository", () => {
57
+ for (const extension of manifestExtensions()) {
58
+ expect(fs.existsSync(path.join(packageRoot, extension)), extension).toBe(true);
59
+ }
60
+ });
61
+ it("names only extensions the published package contains", () => {
62
+ const packed = new Set(packedPaths());
63
+ for (const extension of manifestExtensions()) {
64
+ expect(packed.has(path.posix.normalize(extension)), extension).toBe(true);
65
+ }
66
+ });
67
+ });
68
+ //# sourceMappingURL=pi-manifest-packed.integration.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pi-manifest-packed.integration.test.js","sourceRoot":"","sources":["../../src/test/pi-manifest-packed.integration.test.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-comms",
3
3
  "description": "Cross-harness communication mesh for LLM agents — rooms, DMs, presence, and real-time push delivery over TCP",
4
- "version": "8.15.9",
4
+ "version": "8.15.11",
5
5
  "bin": {
6
6
  "agent-comms": "dist/cli.js"
7
7
  },
@@ -84,6 +84,12 @@
84
84
  },
85
85
  "files": [
86
86
  "dist",
87
+ "src",
88
+ "!src/test",
89
+ "!src/**/*.test.ts",
90
+ "!src/**/*.test.tsx",
91
+ "!src/bridges/user/web/e2e",
92
+ "!src/bridges/user/web/frontend",
87
93
  ".claude-plugin/",
88
94
  "commands/",
89
95
  "hooks/"
@@ -134,7 +140,7 @@
134
140
  "generate:server-json": "tsx scripts/sync-release-metadata.ts",
135
141
  "lint": "turbo run _lint",
136
142
  "mutation": "stryker run stryker.config.ts",
137
- "prepare": "husky",
143
+ "prepare": "husky || true",
138
144
  "prepush": "turbo run _prepush",
139
145
  "publish:mcp-registry": "tsx scripts/publish-mcp-registry.ts",
140
146
  "test": "turbo run _test",
@@ -0,0 +1,195 @@
1
+ /**
2
+ * `agent-comms account <export|import|invite|join>` (agent-comms#344): the deliberate, consented ways the account key that lets one user principal span machines leaves or reaches a machine. Export and import carry the key as a passphrase-sealed bundle (account-bundle.ts), written to a new owner-only file and never printed; the passphrase comes from AGENT_COMMS_ACCOUNT_PASSPHRASE when set, else from a prompt that does not echo. Invite and join carry it over the mesh (account-join-cli.ts). All four are commands a person runs, never agent_comms tool actions, so nothing an agent is told can move the account and no key or invite lands in an agent's transcript.
3
+ */
4
+
5
+ import * as fs from "node:fs";
6
+ import { deviceIdToHex } from "wire-mesh-core/domain/device-id";
7
+ import {
8
+ inviteToAccount,
9
+ joinAccount,
10
+ type AccountCliStore,
11
+ } from "./account-join-cli.js";
12
+ import { openAccountBundle, sealAccountBundle } from "./core/account-bundle.js";
13
+ import {
14
+ importAccountKey,
15
+ loadOrCreateUserIdentity,
16
+ readAccountContents,
17
+ type UserIdentityOptions,
18
+ } from "./core/user-identity.js";
19
+
20
+ /** Where a script supplies the passphrase instead of typing it. */
21
+ export const ACCOUNT_PASSPHRASE_ENV = "AGENT_COMMS_ACCOUNT_PASSPHRASE";
22
+
23
+ /** A bundle holds the account key, so its file is readable by its owner alone. */
24
+ const BUNDLE_FILE_MODE = 0o600;
25
+
26
+ export const ACCOUNT_USAGE = [
27
+ "Usage: agent-comms account export <file>",
28
+ " agent-comms account import <file>",
29
+ " agent-comms account invite [--ttl-minutes <n>] [--sign-key <file>]",
30
+ " agent-comms account join [--public-key <file>] [--fingerprint <hex>] [--label <name>]",
31
+ ].join("\n");
32
+
33
+ export interface AccountCliIo {
34
+ /** Reads a secret (a passphrase, an invite) without echoing it. */
35
+ readSecret: (prompt: string) => Promise<string>;
36
+ log: (line: string) => void;
37
+ env: Readonly<Record<string, string | undefined>>;
38
+ /** Starts a store on this machine's mesh for invite and join, which need one to reach the other machine. */
39
+ openStore: () => Promise<AccountCliStore>;
40
+ }
41
+
42
+ async function passphrase(
43
+ io: Readonly<AccountCliIo>,
44
+ confirm: boolean,
45
+ ): Promise<string> {
46
+ const fromEnv = io.env[ACCOUNT_PASSPHRASE_ENV];
47
+ if (fromEnv !== undefined) return fromEnv;
48
+ const first = await io.readSecret("Account bundle passphrase: ");
49
+ if (!confirm) return first;
50
+ const second = await io.readSecret("Repeat the passphrase: ");
51
+ if (first !== second) throw new Error("The passphrases do not match");
52
+ return first;
53
+ }
54
+
55
+ function principalOf(identity: Readonly<{ deviceId: Uint8Array }>): string {
56
+ return deviceIdToHex(Uint8Array.from(identity.deviceId));
57
+ }
58
+
59
+ /** Runs one `account` subcommand. Throws with a message fit to show the person on any failure: an unknown subcommand or option, a file that already exists (an export never overwrites), a weak or wrong passphrase, a file that is not a bundle, or an invite that is malformed or refused. */
60
+ export async function runAccountCommand(
61
+ args: readonly string[],
62
+ io: Readonly<AccountCliIo>,
63
+ userIdentityOptions: Readonly<UserIdentityOptions> = {},
64
+ ): Promise<void> {
65
+ const [subcommand, ...rest] = args;
66
+ if (subcommand === "invite") {
67
+ await inviteToAccount(rest, io);
68
+ return;
69
+ }
70
+ if (subcommand === "join") {
71
+ await joinAccount(rest, io);
72
+ return;
73
+ }
74
+ const [file, ...extra] = rest;
75
+ if (file === undefined || extra.length > 0) throw new Error(ACCOUNT_USAGE);
76
+ if (subcommand === "export") {
77
+ const identity = loadOrCreateUserIdentity(userIdentityOptions);
78
+ const bundle = sealAccountBundle(
79
+ readAccountContents(userIdentityOptions),
80
+ await passphrase(io, true),
81
+ );
82
+ fs.writeFileSync(file, `${bundle}\n`, {
83
+ encoding: "utf-8",
84
+ mode: BUNDLE_FILE_MODE,
85
+ flag: "wx",
86
+ });
87
+ io.log(
88
+ `Exported account ${principalOf(identity)} to ${file}. Anyone with this file and its passphrase holds the account; import it on your other machine with \`agent-comms account import ${file}\`, then delete it.`,
89
+ );
90
+ return;
91
+ }
92
+ if (subcommand === "import") {
93
+ const contents = openAccountBundle(
94
+ fs.readFileSync(file, "utf-8"),
95
+ await passphrase(io, false),
96
+ );
97
+ const { identity, replacedFile } = importAccountKey(
98
+ userIdentityOptions,
99
+ contents,
100
+ );
101
+ io.log(`This machine now holds account ${principalOf(identity)}.`);
102
+ if (replacedFile !== undefined) {
103
+ io.log(
104
+ `The account it held before was moved to ${replacedFile}; it is still the only key that can revoke what that account issued.`,
105
+ );
106
+ }
107
+ io.log("Restart running agent-comms bridges so they use it.");
108
+ return;
109
+ }
110
+ throw new Error(ACCOUNT_USAGE);
111
+ }
112
+
113
+ /** What one keypress does to a hidden line being typed. */
114
+ export type HiddenLineStep =
115
+ | { kind: "typing"; value: string }
116
+ | { kind: "done"; value: string }
117
+ | { kind: "failed"; message: string };
118
+
119
+ const END_OF_TEXT = "\u0003";
120
+ const END_OF_TRANSMISSION = "\u0004";
121
+ const BACKSPACE = "\b";
122
+ const DELETE = "\u007f";
123
+ /** Every character ordered before this one is a C0 control character, none of which a passphrase can hold. */
124
+ const FIRST_PRINTABLE = " ";
125
+
126
+ /** Applies one character typed at a hidden prompt to the line so far. Enter, or Ctrl-D on a line with something on it, ends the line; Ctrl-C, and Ctrl-D on an empty line, cancel it. Backspace and Delete remove the last character. An escape sequence (an arrow or function key) or any other control character is refused rather than kept, since it would end up in the value invisibly and could never be typed the same way again. */
127
+ export function hiddenLineStep(
128
+ value: string,
129
+ character: string,
130
+ ): HiddenLineStep {
131
+ if (character === "\r" || character === "\n") return { kind: "done", value };
132
+ if (character === END_OF_TEXT)
133
+ return { kind: "failed", message: "Cancelled" };
134
+ if (character === END_OF_TRANSMISSION) {
135
+ return value === ""
136
+ ? { kind: "failed", message: "Cancelled: no input before end of input" }
137
+ : { kind: "done", value };
138
+ }
139
+ if (character === BACKSPACE || character === DELETE) {
140
+ // A terminal's backspace removes one visible character, which may be several code points.
141
+ const graphemes = Array.from(
142
+ new Intl.Segmenter().segment(value),
143
+ ({ segment }) => segment,
144
+ );
145
+ return { kind: "typing", value: graphemes.slice(0, -1).join("") };
146
+ }
147
+ // Escape, which starts every arrow and function key's sequence, is one of these.
148
+ if (character < FIRST_PRINTABLE) {
149
+ return {
150
+ kind: "failed",
151
+ message:
152
+ "Arrow keys, Escape and other control keys cannot be used at this prompt, since they would be kept in it invisibly; start again and type it plainly",
153
+ };
154
+ }
155
+ return { kind: "typing", value: value + character };
156
+ }
157
+
158
+ /** Reads one line from a terminal with echo off, keystroke by keystroke as hiddenLineStep describes. Throws when stdin is not a terminal, since there is nowhere to prompt; a bundle passphrase can then come from ACCOUNT_PASSPHRASE_ENV instead. */
159
+ export async function readHiddenLine(prompt: string): Promise<string> {
160
+ const { stdin, stderr } = process;
161
+ if (!stdin.isTTY) {
162
+ throw new Error(
163
+ `No terminal to ask on (${prompt.trim()}); a bundle passphrase can be set in ${ACCOUNT_PASSPHRASE_ENV} instead`,
164
+ );
165
+ }
166
+ stderr.write(prompt);
167
+ stdin.setRawMode(true);
168
+ stdin.setEncoding("utf-8");
169
+ stdin.resume();
170
+ return new Promise((resolve, reject) => {
171
+ let value = "";
172
+ // Replaced once the listener exists, so the listener can detach itself.
173
+ let finish = (): void => undefined;
174
+ const onData = (chunk: string): void => {
175
+ for (const character of chunk) {
176
+ const step = hiddenLineStep(value, character);
177
+ if (step.kind === "typing") {
178
+ value = step.value;
179
+ continue;
180
+ }
181
+ finish();
182
+ if (step.kind === "done") resolve(step.value);
183
+ else reject(new Error(step.message));
184
+ return;
185
+ }
186
+ };
187
+ finish = (): void => {
188
+ stdin.off("data", onData);
189
+ stdin.setRawMode(false);
190
+ stdin.pause();
191
+ stderr.write("\n");
192
+ };
193
+ stdin.on("data", onData);
194
+ });
195
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * `agent-comms account invite` and `agent-comms account join` (agent-comms#344): joining a new machine to the account over the mesh (see core/account-join.ts for the flow). Each runs a store of its own for as long as the command runs. An invite is printed to this terminal only, and the command keeps its store up until the invite is redeemed or expires, since invites live only in the issuing store's memory. A join reads the invite at a prompt that does not echo, so it never sits in shell history.
3
+ */
4
+
5
+ import * as fs from "node:fs";
6
+ import type { AccountJoin } from "./core/account-join.js";
7
+ import { createBridgeMesh } from "./core/bridge-mesh.js";
8
+ import { createMeshErrorReporter } from "./core/error-reporting.js";
9
+ import {
10
+ releaseIdentityLock,
11
+ type IdentitySlot,
12
+ } from "./core/identity-store.js";
13
+ import type { GenerateConnectionCodeOptions } from "./core/connection-code.js";
14
+ import type { RedeemConnectionCodeOptions } from "./core/connection-code.js";
15
+ import { ConnectionCodeSchema, type ConnectionCode } from "./core/types.js";
16
+ import { ACCOUNT_USAGE, type AccountCliIo } from "./account-cli.js";
17
+
18
+ /** A store started for one invite or join, and its shutdown. */
19
+ export interface AccountCliStore {
20
+ account: Pick<AccountJoin, "invite" | "join">;
21
+ close: () => Promise<void>;
22
+ }
23
+
24
+ /** The identity slot harness these commands' own store runs under, apart from every bridge's. */
25
+ const ACCOUNT_CLI_HARNESS = "account";
26
+
27
+ /** Starts a store on this machine's mesh, holding the machine's account, with one hidden agent: that is what gives it a hub session once it trusts the other machine, and a hidden agent is reachable by device id without being listed. */
28
+ export async function openMachineAccountStore(): Promise<AccountCliStore> {
29
+ const slot: IdentitySlot = {
30
+ harness: ACCOUNT_CLI_HARNESS,
31
+ cwd: process.cwd(),
32
+ };
33
+ const { store } = await createBridgeMesh(slot);
34
+ store.onError = createMeshErrorReporter();
35
+ await store.init();
36
+ const agent = await store.registerAgent({
37
+ name: ACCOUNT_CLI_HARNESS,
38
+ harness: ACCOUNT_CLI_HARNESS,
39
+ cwd: slot.cwd,
40
+ pid: process.pid,
41
+ visibility: "hidden",
42
+ tags: [],
43
+ });
44
+ return {
45
+ account: store.account,
46
+ close: async () => {
47
+ await store.setAgentOffline(agent.id);
48
+ await store.shutdown();
49
+ releaseIdentityLock(slot);
50
+ },
51
+ };
52
+ }
53
+
54
+ const MS_PER_SECOND = 1000;
55
+ const SECONDS_PER_MINUTE = 60;
56
+ const MS_PER_MINUTE = SECONDS_PER_MINUTE * MS_PER_SECOND;
57
+
58
+ /** Parses `--name value` pairs, allowing only `allowed` names, each at most once. */
59
+ function parseOptions(
60
+ args: readonly string[],
61
+ allowed: readonly string[],
62
+ ): Map<string, string> {
63
+ const options = new Map<string, string>();
64
+ for (let i = 0; i < args.length; i += 2) {
65
+ const name = args[i];
66
+ const value = args[i + 1];
67
+ if (
68
+ name === undefined ||
69
+ !name.startsWith("--") ||
70
+ !allowed.includes(name.slice(2)) ||
71
+ options.has(name.slice(2)) ||
72
+ value === undefined
73
+ ) {
74
+ throw new Error(ACCOUNT_USAGE);
75
+ }
76
+ options.set(name.slice(2), value);
77
+ }
78
+ return options;
79
+ }
80
+
81
+ function ttlMsFrom(minutes: string | undefined): number | undefined {
82
+ if (minutes === undefined) return undefined;
83
+ const parsed = Number(minutes);
84
+ if (!Number.isInteger(parsed) || parsed <= 0) {
85
+ throw new Error(
86
+ `--ttl-minutes must be a whole number of minutes above zero, not ${minutes}`,
87
+ );
88
+ }
89
+ return parsed * MS_PER_MINUTE;
90
+ }
91
+
92
+ async function signingOptions(
93
+ keyFile: string | undefined,
94
+ io: Readonly<AccountCliIo>,
95
+ ): Promise<
96
+ Pick<GenerateConnectionCodeOptions, "privateKeyArmored" | "passphrase">
97
+ > {
98
+ if (keyFile === undefined) return {};
99
+ const privateKeyArmored = fs.readFileSync(keyFile, "utf-8");
100
+ const passphrase = await io.readSecret(
101
+ "PGP key passphrase (press Enter if it has none): ",
102
+ );
103
+ return passphrase === ""
104
+ ? { privateKeyArmored }
105
+ : { privateKeyArmored, passphrase };
106
+ }
107
+
108
+ /** Issues an invite, prints it, and returns once it has been redeemed or has expired. */
109
+ export async function inviteToAccount(
110
+ args: readonly string[],
111
+ io: Readonly<AccountCliIo>,
112
+ ): Promise<void> {
113
+ const options = parseOptions(args, ["ttl-minutes", "sign-key"]);
114
+ const ttlMs = ttlMsFrom(options.get("ttl-minutes"));
115
+ const signing = await signingOptions(options.get("sign-key"), io);
116
+ const store = await io.openStore();
117
+ try {
118
+ const { code, settled } = await store.account.invite({
119
+ ...(ttlMs !== undefined && { ttlMs }),
120
+ ...signing,
121
+ });
122
+ io.log(
123
+ `Account invite, valid until ${code.expiresAt} and for one use. Whoever redeems it takes a full copy of this account, so give it only to your own new machine, out of band, and run \`agent-comms account join\` there. Keep this command running until then.`,
124
+ );
125
+ io.log(JSON.stringify(code));
126
+ const outcome = await settled;
127
+ io.log(
128
+ outcome === "redeemed"
129
+ ? "The invite was redeemed: the new machine now holds this account."
130
+ : "The invite expired unused.",
131
+ );
132
+ } finally {
133
+ await store.close();
134
+ }
135
+ }
136
+
137
+ const NOT_AN_INVITE =
138
+ "That is not an account invite: paste the whole line `agent-comms account invite` printed";
139
+
140
+ function parseInvite(raw: string): ConnectionCode {
141
+ let parsed: unknown;
142
+ try {
143
+ parsed = JSON.parse(raw);
144
+ } catch {
145
+ throw new Error(NOT_AN_INVITE);
146
+ }
147
+ if (!ConnectionCodeSchema.is(parsed)) {
148
+ throw new Error(NOT_AN_INVITE);
149
+ }
150
+ return parsed;
151
+ }
152
+
153
+ function verifyingOptions(
154
+ options: ReadonlyMap<string, string>,
155
+ ): RedeemConnectionCodeOptions {
156
+ const keyFile = options.get("public-key");
157
+ const fingerprint = options.get("fingerprint");
158
+ return {
159
+ ...(keyFile !== undefined && {
160
+ publicKeyArmored: fs.readFileSync(keyFile, "utf-8"),
161
+ }),
162
+ ...(fingerprint !== undefined && { expectedFingerprint: fingerprint }),
163
+ };
164
+ }
165
+
166
+ /** Reads an invite at a prompt and makes this machine hold the account it came from. With --label, the issuing machine keeps the label as its own name for this one. */
167
+ export async function joinAccount(
168
+ args: readonly string[],
169
+ io: Readonly<AccountCliIo>,
170
+ ): Promise<void> {
171
+ const options = parseOptions(args, ["public-key", "fingerprint", "label"]);
172
+ const label = options.get("label");
173
+ const joinOptions = {
174
+ ...verifyingOptions(options),
175
+ ...(label !== undefined && { label }),
176
+ };
177
+ const invite = parseInvite(await io.readSecret("Account invite: "));
178
+ const store = await io.openStore();
179
+ try {
180
+ const result = await store.account.join(invite, joinOptions);
181
+ io.log(`This machine now holds account ${result.principal}.`);
182
+ if (result.replacedFile !== undefined) {
183
+ io.log(
184
+ `The account it held before was moved to ${result.replacedFile}; it is still the only key that can revoke what that account issued.`,
185
+ );
186
+ }
187
+ io.log("Restart running agent-comms bridges so they use it.");
188
+ } finally {
189
+ await store.close();
190
+ }
191
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Lets a cc-peer session answer a room join or first-contact DM request. A session reached only through cc-peer (no agent-comms tool of its own) cannot call room_accept or room_reject, so the relayed request tells it exactly what to message back, and the relay runs the same tool action when that message arrives. The person at the session decides: only a message from that session's own socket ever reaches its relay.
3
+ */
4
+
5
+ import { buildAction, formatDeliveryEvent } from "../../core/bridge.js";
6
+ import type { CommsContext, CommsTool } from "../../core/tool.js";
7
+ import type { DeliveryEvent } from "../../core/types.js";
8
+
9
+ /** A device-id is a 64-character lowercase hex SHA-256 digest. */
10
+ const DEVICE_ID_HEX = "[0-9a-f]{64}";
11
+
12
+ /** `accept <room> <requester>` or `reject <room> <requester> [reason]`, the whole message and nothing else, so an ordinary message that merely mentions the word is never mistaken for a decision. */
13
+ const APPROVAL_COMMAND = new RegExp(
14
+ `^\\s*(accept|reject)\\s+(\\S+)\\s+(${DEVICE_ID_HEX})(?:\\s+(\\S[\\s\\S]*?))?\\s*$`,
15
+ "i",
16
+ );
17
+
18
+ export type ApprovalCommand =
19
+ | { kind: "accept"; room: string; requesterId: string }
20
+ | { kind: "reject"; room: string; requesterId: string; reason?: string };
21
+
22
+ /** The command a message body is, or undefined when it is not a well-formed one. */
23
+ export function parseApprovalCommand(
24
+ body: string,
25
+ ): ApprovalCommand | undefined {
26
+ const match = APPROVAL_COMMAND.exec(body);
27
+ if (match === null) return undefined;
28
+ const [, verb, room, requesterId, reason] = match;
29
+ if (verb === undefined || room === undefined || requesterId === undefined) {
30
+ return undefined;
31
+ }
32
+ if (verb.toLowerCase() === "accept") {
33
+ return { kind: "accept", room, requesterId: requesterId.toLowerCase() };
34
+ }
35
+ return {
36
+ kind: "reject",
37
+ room,
38
+ requesterId: requesterId.toLowerCase(),
39
+ ...(reason !== undefined ? { reason } : {}),
40
+ };
41
+ }
42
+
43
+ /** Renders a delivery event for a cc-peer session. A join request carries the instructions for answering it through `peerName`, the peer the session should message; every other event renders as the shared formatter does. */
44
+ export function formatCcPeerDelivery(
45
+ event: DeliveryEvent,
46
+ peerName: string,
47
+ ): string {
48
+ if (event.type !== "room_join_request") return formatDeliveryEvent(event);
49
+ return [
50
+ `${event.requesterId} is asking to join ${event.room}.`,
51
+ `To answer, send the peer "${peerName}" one of these messages:`,
52
+ `accept ${event.room} ${event.requesterId}`,
53
+ `reject ${event.room} ${event.requesterId} <optional reason>`,
54
+ ].join("\n");
55
+ }
56
+
57
+ export interface AnswerJoinRequestDeps {
58
+ tool: Pick<CommsTool, "handle">;
59
+ ctx: Readonly<CommsContext>;
60
+ }
61
+
62
+ /** Runs the room_accept or room_reject the command asks for as this session's own agent, and returns the tool's own text so the session learns what happened, including why a decision failed. */
63
+ export async function answerJoinRequest(
64
+ deps: Readonly<AnswerJoinRequestDeps>,
65
+ command: Readonly<ApprovalCommand>,
66
+ ): Promise<string> {
67
+ const action =
68
+ command.kind === "accept"
69
+ ? buildAction({
70
+ action: "room_accept",
71
+ room: command.room,
72
+ requesterId: command.requesterId,
73
+ })
74
+ : buildAction({
75
+ action: "room_reject",
76
+ room: command.room,
77
+ requesterId: command.requesterId,
78
+ ...(command.reason !== undefined ? { reason: command.reason } : {}),
79
+ });
80
+ const result = await deps.tool.handle(deps.ctx, action);
81
+ return result.content;
82
+ }