libp2p-mesh 2026.5.13 → 2026.5.15
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/README.md +61 -23
- package/api.ts +1 -1
- package/dist/api.d.ts +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.js +60 -24
- package/dist/runtime-setter-api.d.ts +4 -0
- package/dist/runtime-setter-api.js +19 -0
- package/dist/src/agent-tools.d.ts +63 -24
- package/dist/src/agent-tools.js +69 -34
- package/dist/src/channel.d.ts +1 -0
- package/dist/src/channel.js +20 -4
- package/dist/src/dht-registry.d.ts +38 -0
- package/dist/src/dht-registry.js +80 -0
- package/dist/src/inbound.d.ts +0 -3
- package/dist/src/inbound.js +29 -14
- package/dist/src/instance-id.d.ts +53 -0
- package/dist/src/instance-id.js +156 -0
- package/dist/src/mesh.js +310 -23
- package/dist/src/plugin.d.ts +1 -2
- package/dist/src/plugin.js +18 -30
- package/dist/src/types.d.ts +87 -0
- package/index.ts +60 -24
- package/openclaw.plugin.json +72 -33
- package/package.json +20 -8
- package/src/agent-tools.ts +69 -35
- package/src/channel.ts +25 -4
- package/src/dht-registry.ts +105 -0
- package/src/inbound.ts +35 -18
- package/src/instance-id.ts +221 -0
- package/src/mesh.ts +368 -27
- package/src/plugin.ts +25 -36
- package/src/types.ts +95 -0
- package/dist/src/agent-tools-feishu.test.d.ts +0 -1
- package/dist/src/agent-tools-feishu.test.js +0 -57
- package/dist/src/config-schema.test.d.ts +0 -1
- package/dist/src/config-schema.test.js +0 -55
- package/dist/src/feishu-channel.d.ts +0 -19
- package/dist/src/feishu-channel.js +0 -202
- package/dist/src/feishu-channel.test.d.ts +0 -1
- package/dist/src/feishu-channel.test.js +0 -166
- package/dist/src/feishu-client.d.ts +0 -27
- package/dist/src/feishu-client.js +0 -141
- package/dist/src/feishu-client.test.d.ts +0 -1
- package/dist/src/feishu-client.test.js +0 -271
- package/dist/src/feishu-e2e.test.d.ts +0 -1
- package/dist/src/feishu-e2e.test.js +0 -69
- package/dist/src/feishu-types.d.ts +0 -53
- package/dist/src/feishu-types.js +0 -1
- package/dist/src/feishu-types.test.d.ts +0 -1
- package/dist/src/feishu-types.test.js +0 -108
- package/dist/src/inbound-feishu.test.d.ts +0 -1
- package/dist/src/inbound-feishu.test.js +0 -70
- package/dist/src/index.d.ts +0 -1
- package/dist/src/index.js +0 -1
- package/dist/src/plugin-registration.test.d.ts +0 -1
- package/dist/src/plugin-registration.test.js +0 -42
- package/src/agent-tools-feishu.test.ts +0 -68
- package/src/config-schema.test.ts +0 -63
- package/src/feishu-channel.test.ts +0 -191
- package/src/feishu-channel.ts +0 -253
- package/src/feishu-client.test.ts +0 -303
- package/src/feishu-client.ts +0 -178
- package/src/feishu-e2e.test.ts +0 -90
- package/src/feishu-types.test.ts +0 -125
- package/src/feishu-types.ts +0 -51
- package/src/inbound-feishu.test.ts +0 -91
- package/src/index.ts +0 -1
- package/src/plugin-registration.test.ts +0 -60
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DHT-based public key registry for cross-instance identity verification.
|
|
3
|
+
*
|
|
4
|
+
* Each OpenClaw instance publishes its Ed25519 pubkey to the DHT under the key:
|
|
5
|
+
* openclaw:pubkey:<instanceId>
|
|
6
|
+
*
|
|
7
|
+
* Other instances can look up this pubkey to verify message signatures.
|
|
8
|
+
*/
|
|
9
|
+
const DHT_KEY_PREFIX = "openclaw:pubkey:";
|
|
10
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
11
|
+
const pubkeyCache = new Map();
|
|
12
|
+
function encodeKey(instanceId) {
|
|
13
|
+
return new TextEncoder().encode(`${DHT_KEY_PREFIX}${instanceId}`);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Register this instance's pubkey in the DHT.
|
|
17
|
+
* Other nodes can later look it up to verify signatures from this instance.
|
|
18
|
+
*/
|
|
19
|
+
export async function registerPubkey(dht, instanceId, pubkey, logger) {
|
|
20
|
+
const key = encodeKey(instanceId);
|
|
21
|
+
const value = new TextEncoder().encode(pubkey);
|
|
22
|
+
try {
|
|
23
|
+
for await (const event of dht.put(key, value)) {
|
|
24
|
+
// Drain the async iterable; put completes when the iterable ends
|
|
25
|
+
logger?.info?.(`[dht-registry] put event: ${event.name}`);
|
|
26
|
+
}
|
|
27
|
+
logger?.info?.(`[dht-registry] Registered pubkey for ${instanceId}`);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
logger?.warn?.(`[dht-registry] Failed to register pubkey: ${String(err)}`);
|
|
31
|
+
// Non-fatal: identity verification may degrade but mesh continues
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Look up a pubkey from the DHT for the given instanceId.
|
|
36
|
+
* Results are cached locally to avoid repeated DHT queries.
|
|
37
|
+
*/
|
|
38
|
+
export async function lookupPubkey(dht, instanceId, logger) {
|
|
39
|
+
// 1. Check local cache
|
|
40
|
+
const cached = pubkeyCache.get(instanceId);
|
|
41
|
+
if (cached && cached.expiry > Date.now()) {
|
|
42
|
+
logger?.debug?.(`[dht-registry] Cache hit for ${instanceId}`);
|
|
43
|
+
return cached.pubkey;
|
|
44
|
+
}
|
|
45
|
+
// 2. Query DHT
|
|
46
|
+
const key = encodeKey(instanceId);
|
|
47
|
+
try {
|
|
48
|
+
for await (const event of dht.get(key)) {
|
|
49
|
+
if (event.name === "VALUE") {
|
|
50
|
+
const pubkey = new TextDecoder().decode(event.value);
|
|
51
|
+
pubkeyCache.set(instanceId, {
|
|
52
|
+
pubkey,
|
|
53
|
+
expiry: Date.now() + CACHE_TTL_MS,
|
|
54
|
+
});
|
|
55
|
+
logger?.info?.(`[dht-registry] DHT lookup success for ${instanceId}`);
|
|
56
|
+
return pubkey;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
logger?.warn?.(`[dht-registry] DHT lookup failed for ${instanceId}: ${String(err)}`);
|
|
62
|
+
}
|
|
63
|
+
logger?.info?.(`[dht-registry] No pubkey found for ${instanceId}`);
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Clear the local pubkey cache.
|
|
68
|
+
*/
|
|
69
|
+
export function clearPubkeyCache() {
|
|
70
|
+
pubkeyCache.clear();
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Get cache stats for observability.
|
|
74
|
+
*/
|
|
75
|
+
export function getCacheStats() {
|
|
76
|
+
return {
|
|
77
|
+
size: pubkeyCache.size,
|
|
78
|
+
keys: Array.from(pubkeyCache.keys()),
|
|
79
|
+
};
|
|
80
|
+
}
|
package/dist/src/inbound.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { P2PMessage } from "./types.js";
|
|
2
|
-
import type { FeishuApiClient } from "./feishu-client.js";
|
|
3
2
|
export type InboundHandlerDeps = {
|
|
4
3
|
logger?: {
|
|
5
4
|
info?: (msg: string) => void;
|
|
@@ -7,7 +6,5 @@ export type InboundHandlerDeps = {
|
|
|
7
6
|
warn?: (msg: string) => void;
|
|
8
7
|
error?: (msg: string) => void;
|
|
9
8
|
};
|
|
10
|
-
sendToChannel?: (channelId: string, target: string, text: string) => Promise<void>;
|
|
11
|
-
feishuClient?: FeishuApiClient;
|
|
12
9
|
};
|
|
13
10
|
export declare function handleP2PInbound(msg: P2PMessage, deps: InboundHandlerDeps): void;
|
package/dist/src/inbound.js
CHANGED
|
@@ -1,20 +1,35 @@
|
|
|
1
|
+
import { verifyInstanceSignature } from "./instance-id.js";
|
|
1
2
|
export function handleP2PInbound(msg, deps) {
|
|
2
|
-
const { logger
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
const { logger } = deps;
|
|
4
|
+
const instanceTag = msg.instanceId ? ` [instance: ${msg.instanceId}]` : "";
|
|
5
|
+
const signedTag = msg.signature ? " [signed]" : "";
|
|
6
|
+
// Verify signature if present
|
|
7
|
+
if (msg.signature && msg.instanceId && msg.pubkey) {
|
|
8
|
+
const signedPayload = JSON.stringify({
|
|
9
|
+
id: msg.id,
|
|
10
|
+
type: msg.type,
|
|
11
|
+
from: msg.from,
|
|
12
|
+
to: msg.to,
|
|
13
|
+
topic: msg.topic,
|
|
14
|
+
payload: msg.payload,
|
|
15
|
+
timestamp: msg.timestamp,
|
|
16
|
+
instanceId: msg.instanceId,
|
|
17
|
+
});
|
|
18
|
+
const valid = verifyInstanceSignature({ id: msg.instanceId, name: "", pubkey: msg.pubkey, binding: "", bindingComponents: { username: "", hostname: "", platform: "" }, createdAt: 0 }, signedPayload, msg.signature);
|
|
19
|
+
if (valid) {
|
|
20
|
+
logger?.info?.(`[libp2p-mesh] Verified signature from instance ${msg.instanceId}`);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
logger?.warn?.(`[libp2p-mesh] Invalid signature from instance ${msg.instanceId}`);
|
|
24
|
+
}
|
|
5
25
|
}
|
|
6
|
-
else {
|
|
7
|
-
logger?.
|
|
26
|
+
else if (msg.signature) {
|
|
27
|
+
logger?.warn?.(`[libp2p-mesh] Message has signature but no pubkey; cannot verify`);
|
|
8
28
|
}
|
|
9
|
-
if (msg.type
|
|
10
|
-
|
|
11
|
-
sendToChannel("libp2p-mesh", msg.from, text).catch((err) => {
|
|
12
|
-
logger?.error?.(`[libp2p-mesh] Failed to forward direct message from ${msg.from}: ${err}`);
|
|
13
|
-
});
|
|
29
|
+
if (msg.type === "broadcast") {
|
|
30
|
+
logger?.info?.(`[libp2p-mesh] Broadcast from ${msg.from}${instanceTag}${signedTag} on topic ${msg.topic ?? "(none)"}: ${msg.payload}`);
|
|
14
31
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
logger?.warn?.("[libp2p-mesh] Failed to forward P2P message to Feishu");
|
|
18
|
-
});
|
|
32
|
+
else {
|
|
33
|
+
logger?.info?.(`[libp2p-mesh] Direct message from ${msg.from}${instanceTag}${signedTag}: ${msg.payload}`);
|
|
19
34
|
}
|
|
20
35
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight Instance Identity module inspired by BAID (Binding Agent ID).
|
|
3
|
+
*
|
|
4
|
+
* BAID core idea: bind multiple identity dimensions (name, code, profile, user)
|
|
5
|
+
* into a single cryptographic identity.
|
|
6
|
+
*
|
|
7
|
+
* Our lightweight adaptation:
|
|
8
|
+
* - Ed25519 keypair for self-sovereign identity (provable via signatures)
|
|
9
|
+
* - Multi-dimensional binding hash: username + hostname + platform
|
|
10
|
+
* - InstanceID format: name@<pubkey_b64url[0:12]>.<binding[0:8]>
|
|
11
|
+
* - Persistent storage in ~/.openclaw/libp2p/instance-id.json
|
|
12
|
+
*/
|
|
13
|
+
export interface InstanceIdentity {
|
|
14
|
+
/** Full InstanceID string, e.g. "alice-mac@AQIDBAUGBweI.7a3f9e2b" */
|
|
15
|
+
id: string;
|
|
16
|
+
/** Human-readable instance name */
|
|
17
|
+
name: string;
|
|
18
|
+
/** Base64url-encoded Ed25519 public key (SPKI/DER) */
|
|
19
|
+
pubkey: string;
|
|
20
|
+
/** Hex SHA-256 binding hash of environment dimensions */
|
|
21
|
+
binding: string;
|
|
22
|
+
/** Components that contributed to the binding hash */
|
|
23
|
+
bindingComponents: {
|
|
24
|
+
username: string;
|
|
25
|
+
hostname: string;
|
|
26
|
+
platform: string;
|
|
27
|
+
};
|
|
28
|
+
/** Timestamp when the identity was created */
|
|
29
|
+
createdAt: number;
|
|
30
|
+
}
|
|
31
|
+
interface PersistedIdentity extends InstanceIdentity {
|
|
32
|
+
/** Base64url-encoded Ed25519 private key (PKCS8/DER) — stored for signing */
|
|
33
|
+
privkey: string;
|
|
34
|
+
}
|
|
35
|
+
export interface InstanceIDOptions {
|
|
36
|
+
/** Custom instance name (defaults to "<username>-<hostname>") */
|
|
37
|
+
name?: string;
|
|
38
|
+
/** Custom storage path for the identity file */
|
|
39
|
+
customPath?: string;
|
|
40
|
+
}
|
|
41
|
+
export declare function generateInstanceIdentity(options?: InstanceIDOptions): PersistedIdentity;
|
|
42
|
+
export declare function loadOrCreateInstanceIdentity(options?: InstanceIDOptions): Promise<{
|
|
43
|
+
identity: InstanceIdentity;
|
|
44
|
+
signMessage: (message: string) => string;
|
|
45
|
+
}>;
|
|
46
|
+
export declare function verifyInstanceSignature(identity: InstanceIdentity, message: string, signature: string): boolean;
|
|
47
|
+
export declare function verifyInstanceIDBinding(identity: InstanceIdentity): {
|
|
48
|
+
valid: boolean;
|
|
49
|
+
currentBinding: string;
|
|
50
|
+
mismatch?: string;
|
|
51
|
+
};
|
|
52
|
+
export declare function formatInstanceIDForDisplay(identity: InstanceIdentity): string;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight Instance Identity module inspired by BAID (Binding Agent ID).
|
|
3
|
+
*
|
|
4
|
+
* BAID core idea: bind multiple identity dimensions (name, code, profile, user)
|
|
5
|
+
* into a single cryptographic identity.
|
|
6
|
+
*
|
|
7
|
+
* Our lightweight adaptation:
|
|
8
|
+
* - Ed25519 keypair for self-sovereign identity (provable via signatures)
|
|
9
|
+
* - Multi-dimensional binding hash: username + hostname + platform
|
|
10
|
+
* - InstanceID format: name@<pubkey_b64url[0:12]>.<binding[0:8]>
|
|
11
|
+
* - Persistent storage in ~/.openclaw/libp2p/instance-id.json
|
|
12
|
+
*/
|
|
13
|
+
import { createHash, generateKeyPairSync, sign, verify } from "node:crypto";
|
|
14
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
15
|
+
import { homedir, hostname, platform, userInfo } from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
function resolveInstanceIDPath(customPath) {
|
|
18
|
+
if (customPath)
|
|
19
|
+
return customPath;
|
|
20
|
+
const stateDir = process.env.OPENCLAW_STATE_DIR;
|
|
21
|
+
if (stateDir) {
|
|
22
|
+
return path.join(stateDir, "libp2p", "instance-id.json");
|
|
23
|
+
}
|
|
24
|
+
return path.join(homedir(), ".openclaw", "libp2p", "instance-id.json");
|
|
25
|
+
}
|
|
26
|
+
function getBindingComponents() {
|
|
27
|
+
let username;
|
|
28
|
+
try {
|
|
29
|
+
username = userInfo().username;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
username = process.env.USER || process.env.USERNAME || "unknown";
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
username,
|
|
36
|
+
hostname: hostname(),
|
|
37
|
+
platform: platform(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function computeBindingHash(components) {
|
|
41
|
+
const data = `${components.username}::${components.hostname}::${components.platform}`;
|
|
42
|
+
return createHash("sha256").update(data).digest("hex");
|
|
43
|
+
}
|
|
44
|
+
function getDefaultName() {
|
|
45
|
+
const { username, hostname: h } = getBindingComponents();
|
|
46
|
+
const shortHost = h.split(".")[0];
|
|
47
|
+
return `${username}-${shortHost}`;
|
|
48
|
+
}
|
|
49
|
+
function generateEd25519KeyPair() {
|
|
50
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519", {
|
|
51
|
+
publicKeyEncoding: { type: "spki", format: "der" },
|
|
52
|
+
privateKeyEncoding: { type: "pkcs8", format: "der" },
|
|
53
|
+
});
|
|
54
|
+
return { publicKey: Buffer.from(publicKey), privateKey: Buffer.from(privateKey) };
|
|
55
|
+
}
|
|
56
|
+
function pubkeyShort(pubkey) {
|
|
57
|
+
return pubkey.toString("base64url").slice(0, 12);
|
|
58
|
+
}
|
|
59
|
+
function bindingShort(binding) {
|
|
60
|
+
return binding.slice(0, 8);
|
|
61
|
+
}
|
|
62
|
+
export function generateInstanceIdentity(options = {}) {
|
|
63
|
+
const name = options.name?.trim() || getDefaultName();
|
|
64
|
+
const { publicKey, privateKey } = generateEd25519KeyPair();
|
|
65
|
+
const bindingComponents = getBindingComponents();
|
|
66
|
+
const binding = computeBindingHash(bindingComponents);
|
|
67
|
+
const id = `${name}@${pubkeyShort(publicKey)}.${bindingShort(binding)}`;
|
|
68
|
+
return {
|
|
69
|
+
id,
|
|
70
|
+
name,
|
|
71
|
+
pubkey: publicKey.toString("base64url"),
|
|
72
|
+
privkey: privateKey.toString("base64url"),
|
|
73
|
+
binding,
|
|
74
|
+
bindingComponents,
|
|
75
|
+
createdAt: Date.now(),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export async function loadOrCreateInstanceIdentity(options = {}) {
|
|
79
|
+
const filePath = resolveInstanceIDPath(options.customPath);
|
|
80
|
+
try {
|
|
81
|
+
const raw = await readFile(filePath, "utf8");
|
|
82
|
+
const persisted = JSON.parse(raw);
|
|
83
|
+
// Validate that the stored identity still matches this environment
|
|
84
|
+
const currentComponents = getBindingComponents();
|
|
85
|
+
const currentBinding = computeBindingHash(currentComponents);
|
|
86
|
+
if (persisted.binding !== currentBinding) {
|
|
87
|
+
// Environment changed (e.g. migrated to new machine) — regenerate
|
|
88
|
+
const fresh = generateInstanceIdentity(options);
|
|
89
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
90
|
+
await writeFile(filePath, JSON.stringify(fresh, null, 2));
|
|
91
|
+
return {
|
|
92
|
+
identity: stripPrivateKey(fresh),
|
|
93
|
+
signMessage: (msg) => signWithKey(Buffer.from(fresh.privkey, "base64url"), msg),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
identity: stripPrivateKey(persisted),
|
|
98
|
+
signMessage: (msg) => signWithKey(Buffer.from(persisted.privkey, "base64url"), msg),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// File doesn't exist or is corrupt — create new identity
|
|
103
|
+
const fresh = generateInstanceIdentity(options);
|
|
104
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
105
|
+
await writeFile(filePath, JSON.stringify(fresh, null, 2));
|
|
106
|
+
return {
|
|
107
|
+
identity: stripPrivateKey(fresh),
|
|
108
|
+
signMessage: (msg) => signWithKey(Buffer.from(fresh.privkey, "base64url"), msg),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function stripPrivateKey(persisted) {
|
|
113
|
+
const { privkey: _, ...identity } = persisted;
|
|
114
|
+
return identity;
|
|
115
|
+
}
|
|
116
|
+
function signWithKey(privateKey, message) {
|
|
117
|
+
const sig = sign(null, Buffer.from(message, "utf8"), {
|
|
118
|
+
key: privateKey,
|
|
119
|
+
format: "der",
|
|
120
|
+
type: "pkcs8",
|
|
121
|
+
});
|
|
122
|
+
return sig.toString("base64url");
|
|
123
|
+
}
|
|
124
|
+
export function verifyInstanceSignature(identity, message, signature) {
|
|
125
|
+
try {
|
|
126
|
+
const pubkeyBuffer = Buffer.from(identity.pubkey, "base64url");
|
|
127
|
+
return verify(null, Buffer.from(message, "utf8"), { key: pubkeyBuffer, format: "der", type: "spki" }, Buffer.from(signature, "base64url"));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
export function verifyInstanceIDBinding(identity) {
|
|
134
|
+
const currentComponents = getBindingComponents();
|
|
135
|
+
const currentBinding = computeBindingHash(currentComponents);
|
|
136
|
+
if (identity.binding !== currentBinding) {
|
|
137
|
+
return {
|
|
138
|
+
valid: false,
|
|
139
|
+
currentBinding,
|
|
140
|
+
mismatch: `Stored binding ${identity.binding.slice(0, 8)} does not match current environment ${currentBinding.slice(0, 8)}`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return { valid: true, currentBinding };
|
|
144
|
+
}
|
|
145
|
+
export function formatInstanceIDForDisplay(identity) {
|
|
146
|
+
const { bindingComponents, createdAt } = identity;
|
|
147
|
+
const date = new Date(createdAt).toLocaleString();
|
|
148
|
+
return [
|
|
149
|
+
`Instance ID: ${identity.id}`,
|
|
150
|
+
`Name: ${identity.name}`,
|
|
151
|
+
`Pubkey: ${identity.pubkey.slice(0, 24)}...`,
|
|
152
|
+
`Binding: ${identity.binding.slice(0, 16)}...`,
|
|
153
|
+
`Bound to: ${bindingComponents.username}@${bindingComponents.hostname} (${bindingComponents.platform})`,
|
|
154
|
+
`Created: ${date}`,
|
|
155
|
+
].join("\n");
|
|
156
|
+
}
|