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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/dist/bridges/user/web/server.d.ts.map +1 -1
- package/dist/bridges/user/web/server.js +44 -24
- package/dist/bridges/user/web/server.js.map +1 -1
- package/dist/bridges/user/web/test/web-assets.unit.test.d.ts +5 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.d.ts.map +1 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.js +90 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.js.map +1 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts +2 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts.map +1 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js +75 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js.map +1 -0
- package/dist/bridges/user/web/web-assets.d.ts +28 -0
- package/dist/bridges/user/web/web-assets.d.ts.map +1 -0
- package/dist/bridges/user/web/web-assets.js +67 -0
- package/dist/bridges/user/web/web-assets.js.map +1 -0
- package/dist/test/pi-manifest-packed.integration.test.d.ts +2 -0
- package/dist/test/pi-manifest-packed.integration.test.d.ts.map +1 -0
- package/dist/test/pi-manifest-packed.integration.test.js +68 -0
- package/dist/test/pi-manifest-packed.integration.test.js.map +1 -0
- package/package.json +8 -2
- package/src/account-cli.ts +195 -0
- package/src/account-join-cli.ts +191 -0
- package/src/bridges/cc-peer/approval-commands.ts +82 -0
- package/src/bridges/cc-peer/bridge.ts +138 -0
- package/src/bridges/cc-peer/default-front.ts +58 -0
- package/src/bridges/cc-peer/front-controller.ts +160 -0
- package/src/bridges/cc-peer/front-relay.ts +191 -0
- package/src/bridges/cc-peer/front-runtime.ts +167 -0
- package/src/bridges/cc-peer/front.ts +59 -0
- package/src/bridges/cc-peer/reply-aliases.ts +90 -0
- package/src/bridges/cc-peer/run.ts +100 -0
- package/src/bridges/cc-peer/shared-peer.ts +28 -0
- package/src/bridges/claude-code/channel.ts +318 -0
- package/src/bridges/codex/tool.ts +132 -0
- package/src/bridges/mcp/server.ts +137 -0
- package/src/bridges/opencode/plugin.ts +130 -0
- package/src/bridges/pi/extension.ts +330 -0
- package/src/bridges/registry.ts +17 -0
- package/src/bridges/user/cli.ts +87 -0
- package/src/bridges/user/controller.ts +345 -0
- package/src/bridges/user/tui.ts +300 -0
- package/src/bridges/user/web/actions.ts +173 -0
- package/src/bridges/user/web/contract.ts +222 -0
- package/src/bridges/user/web/dist/bundle.js +49 -0
- package/src/bridges/user/web/dist/icons/icon-192x192.svg +9 -0
- package/src/bridges/user/web/dist/icons/icon-512x512.svg +9 -0
- package/src/bridges/user/web/dist/icons/icon-96x96.svg +9 -0
- package/src/bridges/user/web/dist/index.html +21 -0
- package/src/bridges/user/web/dist/manifest.json +26 -0
- package/src/bridges/user/web/dist/mesh-worker.js +64 -0
- package/src/bridges/user/web/dist/sw.js +1 -0
- package/src/bridges/user/web/event-publisher.ts +114 -0
- package/src/bridges/user/web/port-discovery.ts +49 -0
- package/src/bridges/user/web/reads.ts +35 -0
- package/src/bridges/user/web/router.ts +158 -0
- package/src/bridges/user/web/server.ts +782 -0
- package/src/bridges/user/web/vite.config.ts +71 -0
- package/src/bridges/user/web/web-access.ts +404 -0
- package/src/bridges/user/web/web-assets.ts +90 -0
- package/src/bridges/user/web/web-beacon.ts +69 -0
- package/src/bridges/user/web/web-console-static.ts +90 -0
- package/src/cli-main.ts +635 -0
- package/src/cli.ts +13 -0
- package/src/core/account-bundle.ts +264 -0
- package/src/core/account-join.ts +289 -0
- package/src/core/account-ledger-crypto.ts +154 -0
- package/src/core/account-ledger-store.ts +66 -0
- package/src/core/account-ledger.ts +385 -0
- package/src/core/agent-details.ts +216 -0
- package/src/core/agent-list-format.ts +97 -0
- package/src/core/agent-registry.ts +282 -0
- package/src/core/agent-table.ts +58 -0
- package/src/core/atomic-file.ts +46 -0
- package/src/core/bind-retry.ts +38 -0
- package/src/core/bridge-mesh.ts +176 -0
- package/src/core/bridge.ts +718 -0
- package/src/core/capability-ask.ts +162 -0
- package/src/core/capability-bubble-up.ts +237 -0
- package/src/core/comms-store.ts +130 -0
- package/src/core/connection-approval.ts +134 -0
- package/src/core/connection-code-tool.ts +92 -0
- package/src/core/connection-code.ts +296 -0
- package/src/core/coordinator-role.ts +224 -0
- package/src/core/data-directory.ts +32 -0
- package/src/core/data-frame-routing.ts +309 -0
- package/src/core/default-command.ts +12 -0
- package/src/core/delegation-policy.ts +41 -0
- package/src/core/delivery-engine.ts +649 -0
- package/src/core/device-membership-verification.ts +125 -0
- package/src/core/device-membership.ts +130 -0
- package/src/core/directory-admission.ts +155 -0
- package/src/core/discovery-mdns.ts +247 -0
- package/src/core/discovery-tailscale.ts +224 -0
- package/src/core/discovery.ts +303 -0
- package/src/core/display-name.ts +73 -0
- package/src/core/dm-grant-actions.ts +72 -0
- package/src/core/dm-send-delegation.ts +73 -0
- package/src/core/dm-token-verification.ts +76 -0
- package/src/core/election-sessions.ts +135 -0
- package/src/core/error-reporting.ts +85 -0
- package/src/core/first-contact.ts +242 -0
- package/src/core/gateway-trust-actions.ts +184 -0
- package/src/core/gateway-trust.ts +305 -0
- package/src/core/gossip-directory.ts +276 -0
- package/src/core/gossip-extensions.ts +123 -0
- package/src/core/group-proofs.ts +205 -0
- package/src/core/handshake.ts +354 -0
- package/src/core/hub-forwarding.ts +24 -0
- package/src/core/hub-link.ts +176 -0
- package/src/core/hub-session.ts +374 -0
- package/src/core/identity-store.ts +715 -0
- package/src/core/identity.ts +489 -0
- package/src/core/index.ts +65 -0
- package/src/core/issuer-grouping.ts +165 -0
- package/src/core/issuer-identity-file.ts +187 -0
- package/src/core/lan-web-actions.ts +34 -0
- package/src/core/lan-web-uis.ts +55 -0
- package/src/core/ledger-lock.ts +81 -0
- package/src/core/listener-registry.ts +133 -0
- package/src/core/loopback-address.ts +22 -0
- package/src/core/machine-identity.ts +77 -0
- package/src/core/machine-list-groups.ts +53 -0
- package/src/core/mcp-registry-retry.ts +69 -0
- package/src/core/mcp-registry-version.ts +18 -0
- package/src/core/membership-proof.ts +139 -0
- package/src/core/membership-proofs.ts +149 -0
- package/src/core/mesh-graph-trace-tool.ts +81 -0
- package/src/core/mesh-graph.ts +89 -0
- package/src/core/mesh-network-actions.ts +183 -0
- package/src/core/mesh-store-options.ts +23 -0
- package/src/core/mesh-store-shared.ts +134 -0
- package/src/core/mesh-store.ts +1114 -0
- package/src/core/name-claim.ts +145 -0
- package/src/core/naming-actions.ts +90 -0
- package/src/core/naming.ts +138 -0
- package/src/core/nanoid.ts +20 -0
- package/src/core/network-interfaces.ts +24 -0
- package/src/core/package-version.ts +34 -0
- package/src/core/peer-lifecycle.ts +377 -0
- package/src/core/peer-link-error.ts +41 -0
- package/src/core/peer-versions.ts +98 -0
- package/src/core/pending-connection.ts +53 -0
- package/src/core/petnames.ts +121 -0
- package/src/core/pgp-keyserver.ts +32 -0
- package/src/core/pid-lock-file.ts +122 -0
- package/src/core/push-manager.ts +76 -0
- package/src/core/random-id.ts +9 -0
- package/src/core/receiver-set.ts +42 -0
- package/src/core/relay-role.ts +182 -0
- package/src/core/relay-selection.ts +120 -0
- package/src/core/relay-server.ts +198 -0
- package/src/core/relay-uplink.ts +78 -0
- package/src/core/request-timeouts.ts +37 -0
- package/src/core/room-discovery.ts +48 -0
- package/src/core/room-lifecycle.ts +838 -0
- package/src/core/room-lookup.ts +41 -0
- package/src/core/room-messaging.ts +247 -0
- package/src/core/room-notice-log.ts +61 -0
- package/src/core/room-path.ts +84 -0
- package/src/core/room-protocol.ts +893 -0
- package/src/core/room-router.ts +180 -0
- package/src/core/room-token-verification.ts +85 -0
- package/src/core/room-wire-extensions.ts +60 -0
- package/src/core/self-name-claim.ts +67 -0
- package/src/core/send-outcome.ts +69 -0
- package/src/core/shutdown-signals.ts +57 -0
- package/src/core/stale-agent-checker.ts +108 -0
- package/src/core/store.ts +704 -0
- package/src/core/token-text.ts +40 -0
- package/src/core/tool.ts +853 -0
- package/src/core/transport-events.ts +73 -0
- package/src/core/transport-queries.ts +54 -0
- package/src/core/transport.ts +367 -0
- package/src/core/types.ts +550 -0
- package/src/core/user-identity.ts +290 -0
- package/src/core/vapid.ts +81 -0
- package/src/core/version-check.ts +147 -0
- package/src/core/version-report-actions.ts +79 -0
- package/src/core/web-beacon.ts +31 -0
- package/src/core/web-push.ts +322 -0
- package/src/core/wire-mesh-core-version.ts +25 -0
- package/src/core/wire-mesh-identity.ts +36 -0
- package/src/core/wire-mesh-transport-options.ts +82 -0
- package/src/core/wire-mesh-transport.ts +1007 -0
- package/src/core/wire-protocol.ts +153 -0
- package/src/core/ws-dial.ts +191 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tailscale discovery backend — probes tailnet peers for mesh coordinators.
|
|
3
|
+
*
|
|
4
|
+
* Queries the Tailscale local API to get tailnet peers, then probes each
|
|
5
|
+
* peer's port 19876 with a TCP connection attempt (timeout 500ms).
|
|
6
|
+
* If reachable, the peer is added as a discovered mesh.
|
|
7
|
+
*
|
|
8
|
+
* No broadcasting is needed — Tailscale already knows all peers.
|
|
9
|
+
* startAdvertising() is a no-op since the coordinator already listens
|
|
10
|
+
* on the Tailscale IP.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as http from "node:http";
|
|
14
|
+
import * as net from "node:net";
|
|
15
|
+
import { execFile, type ExecException } from "node:child_process";
|
|
16
|
+
import type {
|
|
17
|
+
DiscoveredMesh,
|
|
18
|
+
DiscoveryBackend,
|
|
19
|
+
AdvertiseOptions,
|
|
20
|
+
} from "./discovery.js";
|
|
21
|
+
|
|
22
|
+
/** Runs `execFile` as a promise, resolving with its stdout/stderr rather than invoking a callback. */
|
|
23
|
+
async function execFileAsync(
|
|
24
|
+
file: string,
|
|
25
|
+
args: readonly string[],
|
|
26
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
27
|
+
const mutableArgs: string[] = [...args];
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
execFile(
|
|
30
|
+
file,
|
|
31
|
+
mutableArgs,
|
|
32
|
+
(error: ExecException | null, stdout: string, stderr: string) => {
|
|
33
|
+
if (error) {
|
|
34
|
+
reject(error instanceof Error ? error : new Error(error.message));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
resolve({ stdout, stderr });
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TCP_PROBE_TIMEOUT_MS = 500;
|
|
44
|
+
const DEFAULT_DISCOVER_TIMEOUT_MS = 5_000;
|
|
45
|
+
const COORDINATOR_PORT = 19876;
|
|
46
|
+
const TAILSCALE_API_PORT = 49156;
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Tailscale peer types (from tailscale status --json)
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
interface TailscalePeer {
|
|
53
|
+
TailscaleIPs?: string[];
|
|
54
|
+
HostName?: string;
|
|
55
|
+
DNSName?: string;
|
|
56
|
+
Online?: boolean;
|
|
57
|
+
UserID?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface TailscaleStatus {
|
|
61
|
+
Peer?: Record<string, TailscalePeer>;
|
|
62
|
+
Self?: TailscalePeer;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseTailscaleStatus(raw: unknown): TailscaleStatus {
|
|
66
|
+
if (typeof raw !== "object" || raw === null) {
|
|
67
|
+
throw new Error("Invalid TailscaleStatus: expected object");
|
|
68
|
+
}
|
|
69
|
+
return raw satisfies TailscaleStatus;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Tailscale backend
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
export class TailscaleDiscoveryBackend implements DiscoveryBackend {
|
|
77
|
+
readonly name = "tailscale";
|
|
78
|
+
|
|
79
|
+
/** No-op — the coordinator already listens on the Tailscale IP. */
|
|
80
|
+
async startAdvertising(opts: Readonly<AdvertiseOptions>): Promise<string> {
|
|
81
|
+
return Promise.resolve(`tailscale-${String(opts.port)}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** No-op — nothing to stop. */
|
|
85
|
+
async stopAdvertising(): Promise<void> {
|
|
86
|
+
return Promise.resolve();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Stop discovery — no-op since Tailscale has no persistent state to clean up. */
|
|
90
|
+
async stop(): Promise<void> {
|
|
91
|
+
// Tailscale discovery is stateless — each discover() call probes peers fresh. No persistent connections or timers to tear down.
|
|
92
|
+
return Promise.resolve();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Discover meshes on the tailnet by probing peers.
|
|
97
|
+
* Returns peers that respond to a TCP connection on port 19876.
|
|
98
|
+
*/
|
|
99
|
+
async discover(): Promise<DiscoveredMesh[]> {
|
|
100
|
+
const peers = await this.getPeers();
|
|
101
|
+
if (peers.length === 0) return [];
|
|
102
|
+
|
|
103
|
+
const probes = peers.map(async (peer) => this.probePeer(peer));
|
|
104
|
+
const results = await Promise.allSettled(probes);
|
|
105
|
+
|
|
106
|
+
const discovered: DiscoveredMesh[] = [];
|
|
107
|
+
for (const result of results) {
|
|
108
|
+
if (result.status === "fulfilled" && result.value !== undefined) {
|
|
109
|
+
discovered.push(result.value);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return discovered;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// -----------------------------------------------------------------------
|
|
117
|
+
// Internals
|
|
118
|
+
// -----------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get tailnet peers via `tailscale status --json`.
|
|
122
|
+
* Falls back to the local API at localhost:49156 if the CLI is unavailable.
|
|
123
|
+
*/
|
|
124
|
+
private async getPeers(): Promise<{ ip: string; hostname: string }[]> {
|
|
125
|
+
const status = await this.fetchTailscaleStatus();
|
|
126
|
+
if (!status?.Peer) return [];
|
|
127
|
+
|
|
128
|
+
const peers: { ip: string; hostname: string }[] = [];
|
|
129
|
+
for (const [, peer] of Object.entries(status.Peer)) {
|
|
130
|
+
if (peer.Online !== true) continue;
|
|
131
|
+
const ips = peer.TailscaleIPs;
|
|
132
|
+
if (!ips || ips.length === 0) continue;
|
|
133
|
+
// Use the first IPv4 address
|
|
134
|
+
const ip = ips.find((addr) => addr.includes(".")) ?? ips[0];
|
|
135
|
+
if (ip === undefined || ip === "") continue;
|
|
136
|
+
const hostname =
|
|
137
|
+
peer.HostName ?? peer.DNSName?.replace(/\.tailnet.*$/, "") ?? ip;
|
|
138
|
+
peers.push({ ip, hostname });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return peers;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Fetch Tailscale status. Tries the CLI first, falls back to the local API.
|
|
146
|
+
*/
|
|
147
|
+
private async fetchTailscaleStatus(): Promise<TailscaleStatus | undefined> {
|
|
148
|
+
// Try CLI first
|
|
149
|
+
try {
|
|
150
|
+
const { stdout } = await execFileAsync("tailscale", ["status", "--json"]);
|
|
151
|
+
return parseTailscaleStatus(JSON.parse(stdout));
|
|
152
|
+
} catch {
|
|
153
|
+
// CLI not available — try the local API
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Fall back to local API
|
|
157
|
+
try {
|
|
158
|
+
return await this.fetchLocalApi();
|
|
159
|
+
} catch {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Query the Tailscale local API at localhost:49156. */
|
|
165
|
+
private async fetchLocalApi(): Promise<TailscaleStatus> {
|
|
166
|
+
return new Promise((resolve, reject) => {
|
|
167
|
+
const req = http.get(
|
|
168
|
+
`http://localhost:${String(TAILSCALE_API_PORT)}/localapi/v0/status`,
|
|
169
|
+
{ timeout: DEFAULT_DISCOVER_TIMEOUT_MS },
|
|
170
|
+
(res) => {
|
|
171
|
+
const chunks: Buffer[] = [];
|
|
172
|
+
res.on("data", (chunk: Buffer) => {
|
|
173
|
+
chunks.push(chunk);
|
|
174
|
+
});
|
|
175
|
+
res.on("end", () => {
|
|
176
|
+
try {
|
|
177
|
+
resolve(
|
|
178
|
+
parseTailscaleStatus(
|
|
179
|
+
JSON.parse(Buffer.concat(chunks).toString()),
|
|
180
|
+
),
|
|
181
|
+
);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
req.on("error", reject);
|
|
189
|
+
req.on("timeout", () => {
|
|
190
|
+
req.destroy();
|
|
191
|
+
reject(new Error("Tailscale local API timeout"));
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Probe a single Tailscale peer for a listening mesh coordinator. */
|
|
197
|
+
private async probePeer(
|
|
198
|
+
peer: Readonly<{ ip: string; hostname: string }>,
|
|
199
|
+
): Promise<DiscoveredMesh | undefined> {
|
|
200
|
+
return new Promise((resolve) => {
|
|
201
|
+
const socket = new net.Socket();
|
|
202
|
+
const timer = setTimeout(() => {
|
|
203
|
+
socket.destroy();
|
|
204
|
+
resolve(undefined);
|
|
205
|
+
}, TCP_PROBE_TIMEOUT_MS);
|
|
206
|
+
|
|
207
|
+
socket.connect(COORDINATOR_PORT, peer.ip, () => {
|
|
208
|
+
clearTimeout(timer);
|
|
209
|
+
socket.destroy();
|
|
210
|
+
resolve({
|
|
211
|
+
host: peer.ip,
|
|
212
|
+
port: COORDINATOR_PORT,
|
|
213
|
+
name: peer.hostname,
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
socket.on("error", () => {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
socket.destroy();
|
|
220
|
+
resolve(undefined);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery — mesh discovery interface and manager.
|
|
3
|
+
*
|
|
4
|
+
* Defines the contract for discovery backends (mDNS, Tailscale, etc.)
|
|
5
|
+
* and a manager that routes calls to the appropriate backend.
|
|
6
|
+
* Discovery is opt-in — no broadcasting unless the agent explicitly
|
|
7
|
+
* calls mesh_advertise.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { MeshVisibility } from "./types.js";
|
|
11
|
+
import { nanoid } from "./nanoid.js";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Discovered mesh
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export interface DiscoveredMesh {
|
|
18
|
+
/** Host address of the discovered mesh coordinator. */
|
|
19
|
+
host: string;
|
|
20
|
+
/** Port the coordinator is listening on. */
|
|
21
|
+
port: number;
|
|
22
|
+
/** Human-readable mesh name (from mDNS TXT record or Tailscale hostname). */
|
|
23
|
+
name: string;
|
|
24
|
+
/** Number of agents in the mesh (if available). */
|
|
25
|
+
agentCount?: number;
|
|
26
|
+
/** Connection policies available (if advertised). */
|
|
27
|
+
policies?: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Advertise options
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
export interface AdvertiseOptions {
|
|
35
|
+
/** Service name for mDNS / identifier for the mesh. */
|
|
36
|
+
name: string;
|
|
37
|
+
/** Port to advertise. */
|
|
38
|
+
port: number;
|
|
39
|
+
/** Network adapter/address to broadcast on. */
|
|
40
|
+
adapter?: string;
|
|
41
|
+
/** What information to reveal in discovery responses. */
|
|
42
|
+
policy?: "full" | "name-only";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Discovery backend interface
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export interface DiscoveryBackend {
|
|
50
|
+
readonly name: string;
|
|
51
|
+
startAdvertising: (opts: Readonly<AdvertiseOptions>) => Promise<string>;
|
|
52
|
+
stopAdvertising: (id: string) => Promise<void>;
|
|
53
|
+
discover: (timeout?: number) => Promise<DiscoveredMesh[]>;
|
|
54
|
+
/** Stop all activity (timers, sockets) for this backend. */
|
|
55
|
+
stop: () => Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Discovery manager
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/** An advertisement DiscoveryManager currently considers live -- the caller-visible external id, which backend to it, the backend's own internal id (needed to address stopAdvertising/a future re-advertisement), and the original opts (needed to genuinely re-advertise on resume, not just forget the advertisement ever happened). */
|
|
63
|
+
interface ActiveAdvertisement {
|
|
64
|
+
backendName: string;
|
|
65
|
+
backendId: string;
|
|
66
|
+
opts: AdvertiseOptions;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class DiscoveryManager {
|
|
70
|
+
private readonly backends = new Map<string, DiscoveryBackend>();
|
|
71
|
+
/** Keyed by the stable, caller-visible external id DiscoveryManager itself mints -- deliberately never the backend's own returned id, since a backend is free to return a different id on every startAdvertising call (a real, un-mocked backend calling it deterministically is incidental, not a contract this class may rely on) and a caller must be able to keep using the same id it was given across a pause/resume cycle. */
|
|
72
|
+
private readonly activeAdvertisements = new Map<
|
|
73
|
+
string,
|
|
74
|
+
ActiveAdvertisement
|
|
75
|
+
>();
|
|
76
|
+
private meshVisibility: MeshVisibility = "discoverable";
|
|
77
|
+
private readonly perAdapterVisibility = new Map<string, MeshVisibility>();
|
|
78
|
+
/** Advertisements paused due to a visibility change, keyed by the same external id -- carries the real original opts (not a placeholder) so resuming can genuinely call startAdvertising again. */
|
|
79
|
+
private readonly pausedAdvertisements = new Map<
|
|
80
|
+
string,
|
|
81
|
+
{ backendName: string; opts: AdvertiseOptions }
|
|
82
|
+
>();
|
|
83
|
+
|
|
84
|
+
/** Register a discovery backend. */
|
|
85
|
+
registerBackend(backend: Readonly<DiscoveryBackend>): void {
|
|
86
|
+
this.backends.set(backend.name, backend);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Start advertising on a specific backend. Returns a stable external advertisement id, distinct from whatever id the backend itself returns internally. */
|
|
90
|
+
async advertise(
|
|
91
|
+
backendName: string,
|
|
92
|
+
opts: Readonly<AdvertiseOptions>,
|
|
93
|
+
): Promise<string> {
|
|
94
|
+
const backend = this.backends.get(backendName);
|
|
95
|
+
if (!backend) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Unknown discovery backend: "${backendName}". Available: ${[...this.backends.keys()].join(", ")}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const backendId = await backend.startAdvertising(opts);
|
|
101
|
+
const id = nanoid();
|
|
102
|
+
this.activeAdvertisements.set(id, { backendName, backendId, opts });
|
|
103
|
+
return id;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Set mesh-wide visibility level.
|
|
108
|
+
*
|
|
109
|
+
* - `discoverable` — normal operation, all backends active.
|
|
110
|
+
* - `quiet` — stop advertising but backends remain available for discover().
|
|
111
|
+
* - `dark` — stop all discovery activity (advertising + backend sockets).
|
|
112
|
+
*
|
|
113
|
+
* If `adapter` is specified, the visibility applies only to that backend.
|
|
114
|
+
* Otherwise it applies to the global mesh visibility.
|
|
115
|
+
*/
|
|
116
|
+
async setVisibility(level: MeshVisibility, adapter?: string): Promise<void> {
|
|
117
|
+
if (adapter !== undefined) {
|
|
118
|
+
await this.setAdapterVisibility(adapter, level);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const prev = this.meshVisibility;
|
|
123
|
+
this.meshVisibility = level;
|
|
124
|
+
|
|
125
|
+
if (prev === level) return;
|
|
126
|
+
|
|
127
|
+
if (level === "quiet") {
|
|
128
|
+
// Pause all advertisements but keep backends running
|
|
129
|
+
await this.pauseAllAdvertisements();
|
|
130
|
+
} else if (level === "dark") {
|
|
131
|
+
// Pause advertisements and stop backends entirely
|
|
132
|
+
await this.pauseAllAdvertisements();
|
|
133
|
+
await this.stopAllBackends();
|
|
134
|
+
} else {
|
|
135
|
+
// discoverable — resume previously paused advertisements and restart backends
|
|
136
|
+
await this.resumeAllAdvertisements();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Get current mesh visibility level (global or per-adapter). */
|
|
141
|
+
getVisibility(adapter?: string): MeshVisibility {
|
|
142
|
+
if (adapter !== undefined) {
|
|
143
|
+
return this.perAdapterVisibility.get(adapter) ?? this.meshVisibility;
|
|
144
|
+
}
|
|
145
|
+
return this.meshVisibility;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private async setAdapterVisibility(
|
|
149
|
+
adapter: string,
|
|
150
|
+
level: MeshVisibility,
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
const prev = this.perAdapterVisibility.get(adapter) ?? this.meshVisibility;
|
|
153
|
+
this.perAdapterVisibility.set(adapter, level);
|
|
154
|
+
|
|
155
|
+
if (prev === level) return;
|
|
156
|
+
|
|
157
|
+
if (level === "quiet" || level === "dark") {
|
|
158
|
+
// Pause advertisements for this specific backend
|
|
159
|
+
await this.pauseAdvertisementsForBackend(adapter);
|
|
160
|
+
if (level === "dark") {
|
|
161
|
+
const backend = this.backends.get(adapter);
|
|
162
|
+
if (backend) await backend.stop();
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
await this.resumeAdvertisementsForBackend(adapter);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async pauseAllAdvertisements(): Promise<void> {
|
|
170
|
+
// Capture current ads' real opts before clearing, so resume can genuinely re-advertise rather than merely forgetting the pause happened.
|
|
171
|
+
for (const [id, active] of this.activeAdvertisements) {
|
|
172
|
+
this.pausedAdvertisements.set(id, {
|
|
173
|
+
backendName: active.backendName,
|
|
174
|
+
opts: active.opts,
|
|
175
|
+
});
|
|
176
|
+
const backend = this.backends.get(active.backendName);
|
|
177
|
+
if (backend) {
|
|
178
|
+
await backend.stopAdvertising(active.backendId).catch(() => {
|
|
179
|
+
/* intentionally empty — best-effort stop */
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
this.activeAdvertisements.clear();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private async pauseAdvertisementsForBackend(
|
|
187
|
+
backendName: string,
|
|
188
|
+
): Promise<void> {
|
|
189
|
+
for (const [id, active] of this.activeAdvertisements) {
|
|
190
|
+
if (active.backendName === backendName) {
|
|
191
|
+
this.pausedAdvertisements.set(id, {
|
|
192
|
+
backendName,
|
|
193
|
+
opts: active.opts,
|
|
194
|
+
});
|
|
195
|
+
const backend = this.backends.get(backendName);
|
|
196
|
+
if (backend) {
|
|
197
|
+
await backend.stopAdvertising(active.backendId).catch(() => {
|
|
198
|
+
/* intentionally empty — best-effort stop */
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
this.activeAdvertisements.delete(id);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private async stopAllBackends(): Promise<void> {
|
|
207
|
+
for (const backend of this.backends.values()) {
|
|
208
|
+
await backend.stop().catch(() => {
|
|
209
|
+
/* intentionally empty — best-effort stop */
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private async resumeAllAdvertisements(): Promise<void> {
|
|
215
|
+
// Backends reinitialise their own sockets/timers on the next startAdvertising call -- nothing extra needed here beyond actually calling it, which is the whole fix: resuming used to just forget the pause happened rather than genuinely re-advertising.
|
|
216
|
+
for (const [id, entry] of this.pausedAdvertisements) {
|
|
217
|
+
this.pausedAdvertisements.delete(id);
|
|
218
|
+
const backend = this.backends.get(entry.backendName);
|
|
219
|
+
if (!backend) continue;
|
|
220
|
+
const backendId = await backend.startAdvertising(entry.opts);
|
|
221
|
+
this.activeAdvertisements.set(id, {
|
|
222
|
+
backendName: entry.backendName,
|
|
223
|
+
backendId,
|
|
224
|
+
opts: entry.opts,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private async resumeAdvertisementsForBackend(
|
|
230
|
+
backendName: string,
|
|
231
|
+
): Promise<void> {
|
|
232
|
+
for (const [id, entry] of this.pausedAdvertisements) {
|
|
233
|
+
if (entry.backendName !== backendName) continue;
|
|
234
|
+
this.pausedAdvertisements.delete(id);
|
|
235
|
+
const backend = this.backends.get(backendName);
|
|
236
|
+
if (!backend) continue;
|
|
237
|
+
const backendId = await backend.startAdvertising(entry.opts);
|
|
238
|
+
this.activeAdvertisements.set(id, {
|
|
239
|
+
backendName,
|
|
240
|
+
backendId,
|
|
241
|
+
opts: entry.opts,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Stop a previously started advertisement, addressed by its stable external id. */
|
|
247
|
+
async stopAdvertising(id: string): Promise<void> {
|
|
248
|
+
const active = this.activeAdvertisements.get(id);
|
|
249
|
+
if (active === undefined) return;
|
|
250
|
+
const backend = this.backends.get(active.backendName);
|
|
251
|
+
if (!backend) return;
|
|
252
|
+
await backend.stopAdvertising(active.backendId);
|
|
253
|
+
this.activeAdvertisements.delete(id);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Check whether an advertisement ID was paused (visibility change stopped it). */
|
|
257
|
+
isPaused(id: string): boolean {
|
|
258
|
+
return this.pausedAdvertisements.has(id);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Discover meshes using a specific backend (or all backends if no name given).
|
|
263
|
+
* Returns deduplicated results.
|
|
264
|
+
*
|
|
265
|
+
* Respects visibility: returns empty results for backends that are "dark".
|
|
266
|
+
*/
|
|
267
|
+
async discover(options?: {
|
|
268
|
+
backendName?: string | undefined;
|
|
269
|
+
timeout?: number | undefined;
|
|
270
|
+
}): Promise<DiscoveredMesh[]> {
|
|
271
|
+
const { backendName, timeout } = options ?? {};
|
|
272
|
+
const targets =
|
|
273
|
+
backendName !== undefined
|
|
274
|
+
? [this.backends.get(backendName)].filter(
|
|
275
|
+
(b): b is DiscoveryBackend => b !== undefined,
|
|
276
|
+
)
|
|
277
|
+
: [...this.backends.values()];
|
|
278
|
+
|
|
279
|
+
// Filter out backends that are dark (per-adapter or global)
|
|
280
|
+
const activeTargets = targets.filter((b) => {
|
|
281
|
+
const vis = this.getVisibility(b.name);
|
|
282
|
+
return vis !== "dark";
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const results = await Promise.all(
|
|
286
|
+
activeTargets.map(async (b) => b.discover(timeout)),
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
// Deduplicate by host+port
|
|
290
|
+
const seen = new Set<string>();
|
|
291
|
+
const deduped: DiscoveredMesh[] = [];
|
|
292
|
+
for (const meshes of results) {
|
|
293
|
+
for (const mesh of meshes) {
|
|
294
|
+
const key = `${mesh.host}:${String(mesh.port)}`;
|
|
295
|
+
if (!seen.has(key)) {
|
|
296
|
+
seen.add(key);
|
|
297
|
+
deduped.push(mesh);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return deduped;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one display convention for anything identified by a device-id (agent-comms#345): your own petname for it first, then the name it asserts for itself, then a short id. Every surface that shows an agent, a machine, a principal or a trusted device formats it here, so the CLI, the tool output and the dashboard read the same; wire-mesh's web console follows the same order (ExaDev/wire-mesh#295).
|
|
3
|
+
*
|
|
4
|
+
* A petname is yours: set locally, never gossiped, so it needs no trust decision and has no collision problem. A self-asserted name is whatever the subject's own key signed (an agent's registered name, a machine's name claim), so it is shown quoted, as the subject's claim rather than a fact. Where the full id is already printed beside the name, formatNames gives the names alone, since the short id would only repeat its start. The short id is the stable reference when neither name exists, and the fallback that tells two same-named subjects apart. It is a prefix of the full id, never a replacement for it: every tool action takes the full id, so a surface whose entries are acted on also prints the full id.
|
|
5
|
+
*
|
|
6
|
+
* Pure and dependency-free, so the browser bundle imports it as it is.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** How many leading hex characters of a device-id the short id keeps: 48 bits, enough that two devices a person deals with never share one by chance, and short enough to read and compare at a glance. */
|
|
10
|
+
export const SHORT_ID_LENGTH = 12;
|
|
11
|
+
|
|
12
|
+
/** Everything a display name is built from. Both names are optional; the id never is. */
|
|
13
|
+
export interface DisplayNameParts {
|
|
14
|
+
/** The full device-id (hex). */
|
|
15
|
+
id: string;
|
|
16
|
+
/** The viewer's own label for this id, if they set one. */
|
|
17
|
+
petname?: string | undefined;
|
|
18
|
+
/** The name the subject asserts for itself, if it has one. */
|
|
19
|
+
selfName?: string | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The leading SHORT_ID_LENGTH characters of id. */
|
|
23
|
+
export function shortId(id: string): string {
|
|
24
|
+
return id.slice(0, SHORT_ID_LENGTH);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The names in parts, in display order: the petname, then the self-asserted name quoted (left out when it only repeats the petname), separated by a space, or the empty string when neither is set. Each name is checked with parseDisplayName first and left out when it fails, so a name that slipped past every other check (a hand-edited file, a peer that does not validate) still never reaches a terminal; the self-asserted name is quoted as a JSON string, so a quote inside it is escaped and cannot close the quotes early to pass the rest off as a separate, unquoted label. */
|
|
28
|
+
export function formatNames(parts: Readonly<DisplayNameParts>): string {
|
|
29
|
+
const petname = usableName(parts.petname);
|
|
30
|
+
const selfName = usableName(parts.selfName);
|
|
31
|
+
const labels: string[] = [];
|
|
32
|
+
if (petname !== undefined) labels.push(petname);
|
|
33
|
+
if (selfName !== undefined && selfName !== petname) {
|
|
34
|
+
labels.push(JSON.stringify(selfName));
|
|
35
|
+
}
|
|
36
|
+
return labels.join(" ");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** parts in display order: formatNames, then the short id. */
|
|
40
|
+
export function formatDisplayName(parts: Readonly<DisplayNameParts>): string {
|
|
41
|
+
const names = formatNames(parts);
|
|
42
|
+
const id = shortId(parts.id);
|
|
43
|
+
return names === "" ? id : `${names} ${id}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function usableName(raw: string | undefined): string | undefined {
|
|
47
|
+
return raw === undefined ? undefined : parseDisplayName(raw);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The longest display name, petname or self-asserted, that is accepted: room for a descriptive label ("Joe's work laptop, office"), short enough that one never swamps a listing row. */
|
|
51
|
+
export const MAX_DISPLAY_NAME_LENGTH = 64;
|
|
52
|
+
|
|
53
|
+
/** The last C0 control character; everything at or below it, DEL, and the C1 range are refused, since a name is printed straight into terminals and a control sequence in one could rewrite what the viewer sees. */
|
|
54
|
+
const LAST_C0_CONTROL = 0x1f;
|
|
55
|
+
const DEL = 0x7f;
|
|
56
|
+
const LAST_C1_CONTROL = 0x9f;
|
|
57
|
+
|
|
58
|
+
function isControlCharacter(code: number): boolean {
|
|
59
|
+
return code <= LAST_C0_CONTROL || (code >= DEL && code <= LAST_C1_CONTROL);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** raw trimmed, when it is usable as a display name: not empty, at most MAX_DISPLAY_NAME_LENGTH characters, and free of control characters. Undefined otherwise. Applied to a petname as it is set, to an agent's name as it is registered or received in an advert, to a machine's name claim as it is verified, and once more by formatNames as any name is shown, so none can carry anything a terminal would interpret. */
|
|
63
|
+
export function parseDisplayName(raw: string): string | undefined {
|
|
64
|
+
const name = raw.trim();
|
|
65
|
+
if (name.length === 0 || name.length > MAX_DISPLAY_NAME_LENGTH) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
for (const char of name) {
|
|
69
|
+
const code = char.codePointAt(0);
|
|
70
|
+
if (code !== undefined && isControlCharacter(code)) return undefined;
|
|
71
|
+
}
|
|
72
|
+
return name;
|
|
73
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommsTool's dm_admit, dm_use_grant and dm_revoke action handlers -- the tool surface for agent-comms#162's dm:send grants, split out of tool.ts purely to keep it under the repo's max-lines cap.
|
|
3
|
+
*
|
|
4
|
+
* A grant lets one device send its first DM to another without the receiver deciding on the spot: the receiver admits the sender ahead of time (dm_admit) and hands over the grant it returns, the sender presents it (dm_use_grant), and the receiver's own user principal verifies it instead of asking a person. dm_revoke withdraws it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { CommsAction } from "./types.js";
|
|
8
|
+
import type { CommsResult, MeshOnlyFeatures } from "./tool.js";
|
|
9
|
+
import { decodeTokenText, encodeTokenText } from "./token-text.js";
|
|
10
|
+
|
|
11
|
+
/** Uniform "grants aren't available on this store" result, mirroring the other MeshOnlyFeatures action handlers. */
|
|
12
|
+
function grantsUnavailable(): CommsResult {
|
|
13
|
+
return {
|
|
14
|
+
content: "DM grants are not available on this store.",
|
|
15
|
+
isError: true,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** How many further hops a grant admitting a user principal permits: the principal delegating to one of its own devices. */
|
|
20
|
+
const PRINCIPAL_DELEGATION_DEPTH = 1;
|
|
21
|
+
|
|
22
|
+
/** Admits action.target to DM this user ahead of time: that one device, or, with action.principal, every device of that user principal. `receiverId` is this agent's own device-id, which the returned instructions name so the sender can quote them back verbatim. */
|
|
23
|
+
export async function dmAdmit(
|
|
24
|
+
store: Readonly<Pick<MeshOnlyFeatures, "admitAgentForDm">>,
|
|
25
|
+
action: CommsAction & { action: "dm_admit" },
|
|
26
|
+
receiverId: string,
|
|
27
|
+
): Promise<CommsResult> {
|
|
28
|
+
if (!store.admitAgentForDm) return grantsUnavailable();
|
|
29
|
+
const grant = await store.admitAgentForDm(
|
|
30
|
+
action.target,
|
|
31
|
+
action.principal === true ? PRINCIPAL_DELEGATION_DEPTH : 0,
|
|
32
|
+
);
|
|
33
|
+
const who =
|
|
34
|
+
action.principal === true
|
|
35
|
+
? `${action.target} and every device of that user`
|
|
36
|
+
: action.target;
|
|
37
|
+
return {
|
|
38
|
+
content: [
|
|
39
|
+
`Admitted ${who} to DM you without asking again.`,
|
|
40
|
+
`Give them this grant, and have them call dm_use_grant with target ${receiverId} and this grant:`,
|
|
41
|
+
encodeTokenText(grant),
|
|
42
|
+
].join("\n"),
|
|
43
|
+
isError: false,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Presents action.grant to action.target so this device's DMs to it are admitted without a decision. Throws INVALID_TOKEN, before contacting anyone, when the grant text is not a token. */
|
|
48
|
+
export async function dmUseGrant(
|
|
49
|
+
store: Readonly<Pick<MeshOnlyFeatures, "presentDmGrant">>,
|
|
50
|
+
action: CommsAction & { action: "dm_use_grant" },
|
|
51
|
+
): Promise<CommsResult> {
|
|
52
|
+
if (!store.presentDmGrant) return grantsUnavailable();
|
|
53
|
+
const grant = decodeTokenText(action.grant);
|
|
54
|
+
await store.presentDmGrant(action.target, grant);
|
|
55
|
+
return {
|
|
56
|
+
content: `DM access to ${action.target} granted; you can now dm it.`,
|
|
57
|
+
isError: false,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Withdraws the grant admitted for action.target, if one was ever issued. */
|
|
62
|
+
export async function dmRevoke(
|
|
63
|
+
store: Readonly<Pick<MeshOnlyFeatures, "revokeAgentDmAccess">>,
|
|
64
|
+
action: CommsAction & { action: "dm_revoke" },
|
|
65
|
+
): Promise<CommsResult> {
|
|
66
|
+
if (!store.revokeAgentDmAccess) return grantsUnavailable();
|
|
67
|
+
await store.revokeAgentDmAccess(action.target);
|
|
68
|
+
return {
|
|
69
|
+
content: `Revoked ${action.target}'s DM grant.`,
|
|
70
|
+
isError: false,
|
|
71
|
+
};
|
|
72
|
+
}
|