@securecoms/agent-bridge 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -305,6 +305,65 @@ Pick one receive mode — WebSocket, SSE, or long-poll — and stick with it. Th
305
305
 
306
306
  ---
307
307
 
308
+ ## Bring your own agent
309
+
310
+ The daemon has no approved-framework list. It checks a bearer token and nothing else, so there is exactly one requirement:
311
+
312
+ > **You must be able to add code to a process that stays running.**
313
+
314
+ That is the whole test. If your agent is a program you control, it can hold a SecureComs channel in about fifty lines — see [`examples/ws-agent-client.mjs`](./examples/ws-agent-client.mjs) and [`ws-agent-client.py`](./examples/ws-agent-client.py), which are runnable as-is.
315
+
316
+ To check your own connector against every rule in this section, run [`examples/byo-conformance.mjs`](./examples/byo-conformance.mjs) — a zero-dependency agent that follows the section literally and prints a pass/fail line per rule:
317
+
318
+ ```sh
319
+ node examples/byo-conformance.mjs <your agent's identity_id>
320
+ ```
321
+
322
+ **This fits.** Anything you build yourself, in any language: LangChain, LangGraph, CrewAI, AutoGen, Pydantic AI, the OpenAI or Anthropic agent SDKs, LlamaIndex — or a plain script calling an LLM API with no framework at all. You already own the process; you add a socket to it.
323
+
324
+ **This does not fit.** Consumer chat products (claude.ai, ChatGPT, the Gemini app) have no extension point. Session-based coding CLIs have no always-on loop to attach to. A fully hosted agent you cannot run code beside cannot reach the daemon.
325
+
326
+ **A platform you run but did not write** (Dify, Flowise, n8n, and the like) fits only if it lets you run persistent custom code. If it does, what you are writing is a plugin for that platform — the same job the bundled `openclaw` and `hermes` connectors do.
327
+
328
+ Install with `--harness universal` to skip connector wiring and print the details your agent needs. (`--harness custom` is the old name for the same thing and still works.) Running `install` with no `--harness` at all does the same thing when neither OpenClaw nor Hermes is present.
329
+
330
+ ### Where your agent runs
331
+
332
+ Same machine by default — the daemon binds `127.0.0.1`. Another machine works too, over TLS (see **Cross-host use** under Security). One caveat: a delivered attachment gives you a **local file path** on the daemon's machine, so an off-host agent can receive text, status, vault and A2A traffic but cannot read attachment bytes.
333
+
334
+ ### The five rules
335
+
336
+ 1. **Ack everything.** Delivery is at-least-once. Send `{type:"ack", message_id}` once you have durably handled a message, and dedup by `message_id`.
337
+ 2. **One consumer per daemon.** A second `/v1/ws` closes the first. Two clients will evict each other in a loop.
338
+ 3. **Answer the pings.** The daemon pings every 30s and reaps a socket that misses a pong. Standard WebSocket clients do this for you.
339
+ 4. **Read the token from its file, never from an environment value.** An env *value* is inherited by child processes and visible in `ps eww` and crash dumps. A path is not.
340
+ 5. **Check the identity gate before you attach.** Details below.
341
+
342
+ ### The identity gate
343
+
344
+ Skip this and a single-agent host still works. The failure appears later, when a second agent is installed on the same machine, and it is silent.
345
+
346
+ The daemon rewrites `~/.securecoms/connections/<identity_id>.json` on every start, giving you its current port and token-file path — so a moved port is picked up automatically. But if your connector reads the *wrong* agent's file, everything looks consistent: it authenticates successfully to that agent's daemon, drives that agent's identity, and evicts that agent's real connector.
347
+
348
+ The bearer token does not catch this. It rejects a caller with *no* valid token; it does nothing against a caller holding a *valid but wrong* pair.
349
+
350
+ So, before opening the WebSocket:
351
+
352
+ 1. Know which agent you are meant to serve — a fixed `identity_id`, from your own config. **Never** infer it by scanning the connections directory: on a multi-agent host a slow-starting daemon can leave exactly one file present at the moment you look, and you will bind to the wrong agent.
353
+ 2. Read that agent's connection file for `bridge_url` and `token_file`.
354
+ 3. `GET /healthz` (no token needed) and require **both**: the file's `identity_id` and your own fixed `identity_id` each equal `healthz.identity_id`.
355
+ 4. Any mismatch: do not open the socket. Back off with jitter and retry from step 2 — the port may have moved.
356
+
357
+ On reconnect, re-read the connection file and the token, but **not** your identity. Re-deriving it on every reconnect lets a file appearing or disappearing silently re-bind a running connector to a different agent.
358
+
359
+ The bundled `openclaw` and `hermes` connectors implement all of this; read either one as a worked example.
360
+
361
+ ### Separate compartments need separate OS uids
362
+
363
+ The gate prevents honest mistakes. It is not a tenant boundary. The hop from daemon to agent carries plaintext, and the token and sealed store are readable by anyone sharing the uid — so any agent under the same uid can already drive another's daemon directly. **Agents in different orgs or enclaves that must not cross MUST run under different OS uids, or on different hosts.**
364
+
365
+ ---
366
+
308
367
  ## Run as a service
309
368
 
310
369
  For an autonomous agent running on a server, keep the daemon up with systemd so it restarts on failure and starts on boot. A sleeping or crashed daemon means its WebSocket/SSE connections drop and messages queue on the server until the daemon reconnects; they replay losslessly on wake. The daemon can't wake a sleeping machine.
@@ -358,7 +417,7 @@ pm2 start "npx @agentvault/connect-agent-bridge --credential ./agent.json" \
358
417
  - A **free port is auto-selected** from `7077-7276` (lowest free wins; sibling service definitions and live binds both count as taken). Pass `--port N` to override; the explicit port is honored as-is.
359
418
  - Install **verifies the daemon actually came up** and fails loudly with the log path if it didn't — no silent crash-loops.
360
419
  - Install echoes the credential's `device_id`, `issued_at`, and a redacted api-key fingerprint so you can confirm you installed the newest download. **Re-running install is the only way to update a live (e.g. rotated) credential** — replacing the downloaded file alone does nothing.
361
- - Optional `--harness openclaw|hermes|custom` prints a paste-ready config snippet with the chosen port and the token-file path (the token itself never appears in output).
420
+ - Optional `--harness openclaw|hermes|universal` prints a paste-ready config snippet with the chosen port and the token-file path (the token itself never appears in output). (`--harness custom` is an accepted alias for `universal`.)
362
421
  - **One harness gateway serves one agent.** The connector registers a single platform and resolves a single bound identity for the life of the gateway process, so a connector directory (`~/.hermes/plugins/securecoms`, `~/.openclaw/local-plugins/securecoms`) holds exactly one agent. Install and `wire` therefore **refuse** to overwrite a binding that names a different agent — doing so would take the harness away from that agent, not add one alongside it. To run two agents on one host, give each its own harness profile with its own connector directory and binding. To deliberately move a harness to a different agent, pass `--rebind`.
363
422
 
364
423
  ---
package/dist/bin.js CHANGED
@@ -4834,6 +4834,9 @@ var FRAME_PARSERS = {
4834
4834
  }),
4835
4835
  voice_settings_changed: (d) => ({
4836
4836
  voiceSettingsChanged: { identityId: str(d?.identity_id), version: Number(d?.version ?? 0) }
4837
+ }),
4838
+ runtime_config_changed: (d) => ({
4839
+ runtimeConfigChanged: { identityId: str(d?.identity_id), version: Number(d?.version ?? 0) }
4837
4840
  })
4838
4841
  };
4839
4842
  var S2C_FRAME_NAMES = new Set(Object.keys(FRAME_PARSERS));
@@ -6388,7 +6391,7 @@ var SqliteMlsStateStore = class {
6388
6391
 
6389
6392
  // src/bridge-agent.ts
6390
6393
  import { decryptFile as decryptFile2, encryptFile as encryptFile2, computeFileDigest as computeFileDigest2, base64ToBytes as base64ToBytes4, bytesToBase64 as bytesToBase644 } from "@agentvault/crypto";
6391
- import { createHash } from "node:crypto";
6394
+ import { createHash as createHash2 } from "node:crypto";
6392
6395
 
6393
6396
  // src/service/resolve-bin.ts
6394
6397
  import { join as join2, dirname as dirname2 } from "node:path";
@@ -6441,7 +6444,7 @@ function loadPackageMeta(importMetaUrl) {
6441
6444
 
6442
6445
  // src/bridge-agent.ts
6443
6446
  import { promises as fsp } from "node:fs";
6444
- import { join as join8, dirname as dirname3 } from "node:path";
6447
+ import { join as join9, dirname as dirname3 } from "node:path";
6445
6448
 
6446
6449
  // src/latency.ts
6447
6450
  var DEFAULT_CAPACITY = 1024;
@@ -7461,7 +7464,135 @@ async function runSummarize(opts) {
7461
7464
  }
7462
7465
  }
7463
7466
 
7467
+ // src/service/runtime-config-dir.ts
7468
+ import { createHash, randomUUID } from "node:crypto";
7469
+ import {
7470
+ chmodSync,
7471
+ existsSync,
7472
+ mkdirSync,
7473
+ readFileSync as readFileSync6,
7474
+ renameSync,
7475
+ statSync as statSync3,
7476
+ unlinkSync,
7477
+ writeFileSync
7478
+ } from "node:fs";
7479
+ import { basename as basename2, join as join8 } from "node:path";
7480
+ var MEMORY_FILE = "MEMORY.md";
7481
+ var REAL_FS = {
7482
+ mkdir: (p, mode) => mkdirSync(p, { recursive: true, mode }),
7483
+ chmod: (p, mode) => chmodSync(p, mode),
7484
+ writeFile: (p, data, mode) => writeFileSync(p, data, { mode }),
7485
+ rename: (a, b) => renameSync(a, b),
7486
+ readFile: (p) => readFileSync6(p, "utf8"),
7487
+ unlink: (p) => unlinkSync(p)
7488
+ };
7489
+ function runtimeConfigDir(home, identityId) {
7490
+ return join8(home, ".securecoms", "runtime-config", identityId);
7491
+ }
7492
+ function assertManagedName(name) {
7493
+ if (!name || name.includes("/") || name.includes("\\") || name === "." || name === ".." || name !== basename2(name)) {
7494
+ throw new Error(`refusing managed config file name ${JSON.stringify(name)}: not a plain filename`);
7495
+ }
7496
+ }
7497
+ function atomicWrite(path, body, deps) {
7498
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
7499
+ deps.writeFile(tmp, body, 384);
7500
+ deps.chmod(tmp, 384);
7501
+ try {
7502
+ deps.rename(tmp, path);
7503
+ } catch (err) {
7504
+ try {
7505
+ deps.unlink(tmp);
7506
+ } catch {
7507
+ }
7508
+ throw err;
7509
+ }
7510
+ }
7511
+ function writeManagedFiles(home, identityId, files, deps = REAL_FS) {
7512
+ const entries = Object.entries(files);
7513
+ for (const [name] of entries) assertManagedName(name);
7514
+ const parent2 = join8(home, ".securecoms", "runtime-config");
7515
+ const dir = runtimeConfigDir(home, identityId);
7516
+ for (const d of [join8(home, ".securecoms"), parent2, dir]) {
7517
+ deps.mkdir(d, 448);
7518
+ deps.chmod(d, 448);
7519
+ }
7520
+ for (const [name, body] of entries) atomicWrite(join8(dir, name), body, deps);
7521
+ }
7522
+ function checkConfiguredPaths(skillsJson, statFn = (p) => {
7523
+ const s = statSync3(p);
7524
+ return { mode: s.mode, size: s.size };
7525
+ }) {
7526
+ const paths = [];
7527
+ const endpoints = skillsJson.endpoints ?? {};
7528
+ for (const e of Object.values(endpoints)) if (e?.headersFile) paths.push(e.headersFile);
7529
+ const databases = skillsJson.databases ?? {};
7530
+ for (const d of Object.values(databases)) {
7531
+ if (d?.sqlite) paths.push(d.sqlite);
7532
+ if (d?.postgresUrlFile) paths.push(d.postgresUrlFile);
7533
+ }
7534
+ return paths.map((path) => {
7535
+ let st;
7536
+ try {
7537
+ st = statFn(path);
7538
+ } catch {
7539
+ return { path, ok: false, why: "not found" };
7540
+ }
7541
+ if ((st.mode & 63) !== 0) {
7542
+ return { path, ok: false, why: "readable by other users on this machine \u2014 tighten its permissions to owner-only" };
7543
+ }
7544
+ if (st.size === 0) return { path, ok: false, why: "the file is empty" };
7545
+ return { path, ok: true, why: "" };
7546
+ });
7547
+ }
7548
+ function lineHash(line) {
7549
+ return createHash("sha256").update(line.trim(), "utf8").digest("hex");
7550
+ }
7551
+ var LOCAL_REPORT_FILE = "local-config.json";
7552
+ function readLocalReport(home, identityId) {
7553
+ const p = join8(runtimeConfigDir(home, identityId), LOCAL_REPORT_FILE);
7554
+ if (!existsSync(p)) return null;
7555
+ let doc;
7556
+ try {
7557
+ doc = JSON.parse(readFileSync6(p, "utf8"));
7558
+ } catch {
7559
+ return null;
7560
+ }
7561
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
7562
+ const hasSearchKey = doc.has_search_key === true;
7563
+ const raw = doc.persona;
7564
+ const persona = raw && typeof raw === "object" && !Array.isArray(raw) ? Object.fromEntries(
7565
+ Object.entries(raw).filter(([, v]) => typeof v === "string")
7566
+ ) : {};
7567
+ return { persona, has_search_key: hasSearchKey };
7568
+ }
7569
+ function readManagedMemory(home, identityId) {
7570
+ const p = join8(runtimeConfigDir(home, identityId), MEMORY_FILE);
7571
+ if (!existsSync(p)) return null;
7572
+ try {
7573
+ const body = readFileSync6(p, "utf8").trim();
7574
+ return body || null;
7575
+ } catch {
7576
+ return null;
7577
+ }
7578
+ }
7579
+ function pruneManagedMemory(home, identityId, hashes, deps = REAL_FS) {
7580
+ const dir = runtimeConfigDir(home, identityId);
7581
+ const path = join8(dir, MEMORY_FILE);
7582
+ if (!existsSync(path)) return 0;
7583
+ const drop = new Set(hashes);
7584
+ const before = deps.readFile(path).split("\n");
7585
+ const kept = before.filter((l) => l.trim() === "" || !drop.has(lineHash(l)));
7586
+ const removed = before.filter((l) => l.trim() !== "" && drop.has(lineHash(l))).length;
7587
+ if (removed === 0) return 0;
7588
+ const body = kept.filter((l, i) => l.trim() !== "" || i < kept.length - 1).join("\n").trimEnd();
7589
+ atomicWrite(path, body ? `${body}
7590
+ ` : "", deps);
7591
+ return removed;
7592
+ }
7593
+
7464
7594
  // src/bridge-agent.ts
7595
+ import { homedir as homedir2 } from "node:os";
7465
7596
  var VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
7466
7597
  var FAST_PATH_SPOKEN_GUIDANCE = "A one-sentence spoken acknowledgement has already been sent for this turn. Reply only with information it did not contain, in one or two short sentences; if there is nothing to add, send nothing.";
7467
7598
  var ROOM_VOICE_GUIDANCE_MENTION_ONLY = "You are in a group voice room. Reply ONLY when a message @mentions your display name. Keep replies to one short sentence. When you should not reply, send nothing.";
@@ -7516,7 +7647,7 @@ function resolveMessageId(messageId) {
7516
7647
  return messageId;
7517
7648
  }
7518
7649
  function fastReplyId(messageId) {
7519
- const h = createHash("sha256").update(`fast:${messageId}`).digest();
7650
+ const h = createHash2("sha256").update(`fast:${messageId}`).digest();
7520
7651
  h[6] = h[6] & 15 | 64;
7521
7652
  h[8] = h[8] & 63 | 128;
7522
7653
  const x = h.subarray(0, 16).toString("hex");
@@ -7646,6 +7777,7 @@ var BridgeAgent = class _BridgeAgent {
7646
7777
  onSupersedeUnopenable: (channelId, groupId) => this.healSupersedeUnopenable(channelId, groupId)
7647
7778
  });
7648
7779
  this.fetchImpl = deps.fetchImpl ?? fetch;
7780
+ this.home = deps.home ?? homedir2();
7649
7781
  this.injectedFastLlm = !!deps.fastLlm;
7650
7782
  this.fileVoiceConfig = cfg.voice;
7651
7783
  this.fastLlm = deps.fastLlm ?? (cfg.voice?.fastPath.enabled ? new FastLlmClient(cfg.voice.fastPath, this.fetchImpl) : void 0);
@@ -7678,8 +7810,8 @@ var BridgeAgent = class _BridgeAgent {
7678
7810
  const j = await res.json();
7679
7811
  return String(j.attachment_id);
7680
7812
  };
7681
- this.attachmentsDirImpl = deps.attachmentsDir ?? join8(dirname3(cfg.storePath), "attachments");
7682
- this.vaultOwnerStoreImpl = deps.vaultOwnerStore ?? new FileOwnerAuthorityStore(join8(dirname3(cfg.storePath), "vault-owner-pins.json"));
7813
+ this.attachmentsDirImpl = deps.attachmentsDir ?? join9(dirname3(cfg.storePath), "attachments");
7814
+ this.vaultOwnerStoreImpl = deps.vaultOwnerStore ?? new FileOwnerAuthorityStore(join9(dirname3(cfg.storePath), "vault-owner-pins.json"));
7683
7815
  this.action = {
7684
7816
  begin: (channelId) => this.actionBegin(channelId),
7685
7817
  start: (opts) => this.actionStart(opts),
@@ -7695,6 +7827,8 @@ var BridgeAgent = class _BridgeAgent {
7695
7827
  // stored for peek-then-join in ingestOnce
7696
7828
  custody;
7697
7829
  fetchImpl;
7830
+ /** Round 5: home for the managed runtime-config directory (test seam). */
7831
+ home;
7698
7832
  joinState = /* @__PURE__ */ new Map();
7699
7833
  /**
7700
7834
  * Phase 2: lazy channel-metadata cache (channel_id → { is_dm, name, at }) used
@@ -8036,6 +8170,84 @@ var BridgeAgent = class _BridgeAgent {
8036
8170
  if (this.voiceSettingsSource === "backend") return;
8037
8171
  process.stderr.write(
8038
8172
  `[bridge] voice settings unavailable (${e.message}); using ${this.voiceSettingsSource}
8173
+ `
8174
+ );
8175
+ }
8176
+ }
8177
+ /**
8178
+ * Fetch this agent's runtime config and apply it to the managed directory the
8179
+ * runtime reads (spec §8). Safe to call any time — at start(), or from the
8180
+ * receive pump on a `runtime_config_changed` ping — and never throws: a save
8181
+ * the owner already made must not be undone by a network blip here, and no
8182
+ * shape the backend can send may take the daemon down.
8183
+ *
8184
+ * Order matters. Prunes are applied BEFORE the memory is read back, so the
8185
+ * report the owner sees next is the file as it now stands.
8186
+ *
8187
+ * Every cycle rewrites, even at an unchanged version: directory permissions
8188
+ * are repaired only on a write, so skipping would leave a loosened directory
8189
+ * loose until the next save.
8190
+ */
8191
+ async refetchRuntimeConfig() {
8192
+ const base = `${this.cfg.apiBase}/api/v1/agents/${encodeURIComponent(this.cfg.identityId)}/runtime-config`;
8193
+ try {
8194
+ const res = await this.fetchImpl(`${base}/files`, {
8195
+ headers: { authorization: `Bearer ${this.cfg.apiKey}` }
8196
+ });
8197
+ if (!res.ok) throw new Error(`runtime-config ${res.status}`);
8198
+ const payload = await res.json();
8199
+ if (!payload || !payload.version) {
8200
+ process.stderr.write(
8201
+ `[bridge] runtime config not applied (version ${JSON.stringify(payload?.version ?? null)} \u2014 no saved config)
8202
+ `
8203
+ );
8204
+ return;
8205
+ }
8206
+ const skillsJson = payload.skills_json ?? {};
8207
+ writeManagedFiles(this.home, this.cfg.identityId, {
8208
+ ...payload.files ?? {},
8209
+ "skills.json": `${JSON.stringify(skillsJson, null, 2)}
8210
+ `
8211
+ });
8212
+ if (payload.memory_prunes?.length) {
8213
+ pruneManagedMemory(this.home, this.cfg.identityId, payload.memory_prunes);
8214
+ }
8215
+ const local = readLocalReport(this.home, this.cfg.identityId);
8216
+ const report = {
8217
+ applied_version: payload.version,
8218
+ paths: checkConfiguredPaths(skillsJson),
8219
+ // Read AFTER the prunes above, so the owner's next view of memory is
8220
+ // the file as it now stands, not the one they just pruned from.
8221
+ memory: readManagedMemory(this.home, this.cfg.identityId),
8222
+ // E13: pass through what the runtime reported. The daemon does not
8223
+ // interpret any of it.
8224
+ local_persona: local?.persona ?? null,
8225
+ // Only the runtime knows whether a search key file is configured — it
8226
+ // is an environment value on that process, invisible to the daemon and
8227
+ // to the backend. The backend needs it to generate a truthful tool
8228
+ // paragraph, so it rides along here.
8229
+ has_search_key: local?.has_search_key ?? false
8230
+ };
8231
+ const posted = await this.fetchImpl(`${base}/report`, {
8232
+ method: "POST",
8233
+ headers: {
8234
+ authorization: `Bearer ${this.cfg.apiKey}`,
8235
+ "content-type": "application/json"
8236
+ },
8237
+ body: JSON.stringify(report)
8238
+ });
8239
+ if (!posted.ok) {
8240
+ process.stderr.write(
8241
+ `[bridge] runtime config v${payload.version} written, but reporting it failed (${posted.status}) \u2014 the console will keep showing it as not applied until the next cycle
8242
+ `
8243
+ );
8244
+ return;
8245
+ }
8246
+ process.stderr.write(`[bridge] runtime config v${payload.version} applied
8247
+ `);
8248
+ } catch (e) {
8249
+ process.stderr.write(
8250
+ `[bridge] runtime config refetch failed: ${e?.message ?? e}
8039
8251
  `
8040
8252
  );
8041
8253
  }
@@ -8067,6 +8279,7 @@ var BridgeAgent = class _BridgeAgent {
8067
8279
  }
8068
8280
  await this.provider.publishKeyPackage();
8069
8281
  await this.refetchVoiceSettings();
8282
+ await this.refetchRuntimeConfig();
8070
8283
  this.startReceivePump();
8071
8284
  if (this.selfHealSweepEnabled) {
8072
8285
  this.bootSweepTimer = setTimeout(
@@ -9551,11 +9764,16 @@ var BridgeAgent = class _BridgeAgent {
9551
9764
  this.pingsReceived++;
9552
9765
  if (ping?.reconnected) {
9553
9766
  process.stderr.write("[bridge] delivery-ping WS (re)connected \u2014 push inbound active\n");
9767
+ await this.refetchRuntimeConfig();
9554
9768
  }
9555
9769
  if (ping?.voiceSettingsChanged) {
9556
9770
  await this.refetchVoiceSettings();
9557
9771
  continue;
9558
9772
  }
9773
+ if (ping?.runtimeConfigChanged) {
9774
+ await this.refetchRuntimeConfig();
9775
+ continue;
9776
+ }
9559
9777
  await this.ingestOnce();
9560
9778
  }
9561
9779
  } catch {
@@ -9994,13 +10212,13 @@ var BridgeAgent = class _BridgeAgent {
9994
10212
 
9995
10213
  // src/auth.ts
9996
10214
  import { randomBytes as randomBytes3, timingSafeEqual } from "node:crypto";
9997
- import { existsSync, readFileSync as readFileSync6, writeFileSync } from "node:fs";
10215
+ import { existsSync as existsSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "node:fs";
9998
10216
  function resolveToken(opts) {
9999
10217
  if (opts.token) return opts.token;
10000
10218
  const f = opts.tokenFile;
10001
- if (f && existsSync(f)) return readFileSync6(f, "utf8").trim();
10219
+ if (f && existsSync2(f)) return readFileSync7(f, "utf8").trim();
10002
10220
  const tok = randomBytes3(32).toString("base64url");
10003
- if (f) writeFileSync(f, tok, { mode: 384 });
10221
+ if (f) writeFileSync2(f, tok, { mode: 384 });
10004
10222
  return tok;
10005
10223
  }
10006
10224
  function tokenMatches(provided, expected) {
@@ -10621,17 +10839,17 @@ async function handleInbound(sock, agent, data, inflight) {
10621
10839
  }
10622
10840
 
10623
10841
  // src/bin.ts
10624
- import { readFileSync as readFileSync8, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3, rmSync, existsSync as existsSync2, realpathSync, readdirSync as readdirSync2, statSync as statSync3 } from "node:fs";
10625
- import { join as join12, dirname as dirname7, isAbsolute as isAbsolute2, resolve as resolve2 } from "node:path";
10842
+ import { readFileSync as readFileSync9, mkdirSync as mkdirSync3, writeFileSync as writeFileSync4, rmSync, existsSync as existsSync3, realpathSync, readdirSync as readdirSync2, statSync as statSync4 } from "node:fs";
10843
+ import { join as join13, dirname as dirname7, isAbsolute as isAbsolute2, resolve as resolve2 } from "node:path";
10626
10844
  import { fileURLToPath as fileURLToPath2 } from "node:url";
10627
- import { homedir as homedir2, userInfo } from "node:os";
10845
+ import { homedir as homedir3, userInfo } from "node:os";
10628
10846
  import { spawnSync } from "node:child_process";
10629
10847
 
10630
10848
  // src/service/install.ts
10631
10849
  import { dirname as dirname4 } from "node:path";
10632
10850
 
10633
10851
  // src/service/config.ts
10634
- import { basename as basename2, extname, isAbsolute, resolve } from "node:path";
10852
+ import { basename as basename3, extname, isAbsolute, resolve } from "node:path";
10635
10853
 
10636
10854
  // src/service/resolve-node.ts
10637
10855
  var REQUIRED_NODE_MAJOR = 24;
@@ -10709,7 +10927,7 @@ function deriveServiceConfig(input) {
10709
10927
  `install: refusing to bake a version-embedded Node path into the service definition: ${input.execPath}. Re-run install under a system/Homebrew Node, or pass --exec-path <stable-path>.`
10710
10928
  );
10711
10929
  }
10712
- const stem = input.label ?? basename2(input.credentialPath, extname(input.credentialPath));
10930
+ const stem = input.label ?? basename3(input.credentialPath, extname(input.credentialPath));
10713
10931
  const slug = slugify(stem);
10714
10932
  const cred = isAbsolute(input.credentialPath) ? input.credentialPath : resolve(input.credentialPath);
10715
10933
  const args2 = ["--credential", cred];
@@ -10831,10 +11049,10 @@ function uninstallService(input, deps) {
10831
11049
  }
10832
11050
 
10833
11051
  // src/service/install-prep.ts
10834
- import { join as join10 } from "node:path";
11052
+ import { join as join11 } from "node:path";
10835
11053
 
10836
11054
  // src/service/wire.ts
10837
- import { join as join9, dirname as dirname5 } from "node:path";
11055
+ import { join as join10, dirname as dirname5 } from "node:path";
10838
11056
  import { fileURLToPath } from "node:url";
10839
11057
 
10840
11058
  // src/service/hermes-config.ts
@@ -11010,7 +11228,7 @@ function checkBindingConflict(prev, next, allowRebind) {
11010
11228
  }
11011
11229
  function connectorsSrcDir(importMetaUrl, harness) {
11012
11230
  const pkgRoot = dirname5(dirname5(fileURLToPath(importMetaUrl)));
11013
- return join9(pkgRoot, "connectors", harness);
11231
+ return join10(pkgRoot, "connectors", harness);
11014
11232
  }
11015
11233
  function hermesConfigPath(home) {
11016
11234
  return `${home}/.hermes/config.yaml`;
@@ -11125,15 +11343,30 @@ function runUnwire(deps) {
11125
11343
  function customFacts(input) {
11126
11344
  const ws = `ws://127.0.0.1:${input.port ?? "<PORT>"}/v1/ws`;
11127
11345
  return [
11128
- ` bridge WS: ${ws}`,
11129
- ` token file: ${input.tokenFilePath}`,
11346
+ ` bridge WS: ${ws}`,
11347
+ ` token file: ${input.tokenFilePath}`,
11348
+ ` agent id: ${input.identityId}`,
11349
+ ` discovery: ~/.securecoms/connections/${input.identityId}.json (rewritten on every daemon start)`,
11130
11350
  ` Bring your own connector: open the WS with the bearer token read from that file.`,
11351
+ ` Ack every message you receive; delivery is at-least-once, so dedup by message_id.`,
11352
+ ` Before you attach, check the identity gate: the daemon's /healthz identity_id MUST equal`,
11353
+ ` the agent id above. Skipping it means a second agent on this host can silently`,
11354
+ ` drive the wrong daemon. See "Bring your own agent" in the agent-bridge README.`,
11131
11355
  ` Protocol reference: the "WebSocket" section of the agent-bridge README.`
11132
11356
  ].join("\n");
11133
11357
  }
11134
11358
  function autoWireHarness(input, deps) {
11135
11359
  const h = input.harness;
11136
- if (!h || h === "none") return { echo: "", wired: false, error: false };
11360
+ if (h === "none") return { echo: "", wired: false, error: false };
11361
+ if (!h) {
11362
+ return {
11363
+ echo: `[securecoms] No agent harness detected (no ~/.openclaw, no ~/.hermes) \u2014 the bridge service is installed and running, but nothing is attached to it yet.
11364
+ Bring your own agent:
11365
+ ${customFacts(input)}`,
11366
+ wired: false,
11367
+ error: false
11368
+ };
11369
+ }
11137
11370
  if (h === "openclaw") {
11138
11371
  const retry = `agent-bridge wire --harness openclaw --identity ${input.slug}`;
11139
11372
  const parent2 = `${input.home}/.openclaw`;
@@ -11141,7 +11374,7 @@ function autoWireHarness(input, deps) {
11141
11374
  return {
11142
11375
  echo: `[securecoms] OpenClaw not found at ~/.openclaw \u2014 the bridge service is installed, but the harness was NOT wired.
11143
11376
  Install OpenClaw, then re-run install, or run: ${retry}
11144
- (or re-run install with --harness custom).`,
11377
+ (or re-run install with --harness universal).`,
11145
11378
  wired: false,
11146
11379
  error: true
11147
11380
  };
@@ -11182,7 +11415,7 @@ function autoWireHarness(input, deps) {
11182
11415
  return {
11183
11416
  echo: `[securecoms] Hermes not found at ~/.hermes \u2014 the bridge service is installed, but the harness was NOT wired.
11184
11417
  Install Hermes, then re-run install, or run: ${retry}
11185
- (or re-run install with --harness custom).`,
11418
+ (or re-run install with --harness universal).`,
11186
11419
  wired: false,
11187
11420
  error: true
11188
11421
  };
@@ -11226,7 +11459,7 @@ function autoWireHarness(input, deps) {
11226
11459
  };
11227
11460
  }
11228
11461
  }
11229
- return { echo: `[securecoms] Custom harness \u2014 bring your own connector:
11462
+ return { echo: `[securecoms] Universal \u2014 bring your own agent:
11230
11463
  ${customFacts(input)}`, wired: false, error: false };
11231
11464
  }
11232
11465
 
@@ -11253,10 +11486,10 @@ function assertReadableCredential(absPath, deps) {
11253
11486
  return { deviceId: json2.device_id, identityId: json2.identity_id, issuedAt };
11254
11487
  }
11255
11488
  function durableDir(home, slug) {
11256
- return join10(home, `securecoms-agent-${slug}`);
11489
+ return join11(home, `securecoms-agent-${slug}`);
11257
11490
  }
11258
11491
  function durableCredentialPath(home, slug) {
11259
- return join10(durableDir(home, slug), `${slug}.json`);
11492
+ return join11(durableDir(home, slug), `${slug}.json`);
11260
11493
  }
11261
11494
  function takeCustody(args2, deps) {
11262
11495
  const dir = durableDir(args2.home, args2.slug);
@@ -11303,7 +11536,7 @@ function readSiblingDefs(dir, prefix, deps) {
11303
11536
  for (const n of names) {
11304
11537
  if (!n.startsWith(prefix)) continue;
11305
11538
  try {
11306
- out.push(deps.readFile(join10(dir, n)));
11539
+ out.push(deps.readFile(join11(dir, n)));
11307
11540
  } catch {
11308
11541
  }
11309
11542
  }
@@ -11498,34 +11731,34 @@ function renderStatusTable(rows) {
11498
11731
 
11499
11732
  // src/service/connection-file.ts
11500
11733
  import {
11501
- chmodSync,
11502
- mkdirSync,
11503
- readFileSync as readFileSync7,
11504
- renameSync,
11505
- unlinkSync,
11506
- writeFileSync as writeFileSync2
11734
+ chmodSync as chmodSync2,
11735
+ mkdirSync as mkdirSync2,
11736
+ readFileSync as readFileSync8,
11737
+ renameSync as renameSync2,
11738
+ unlinkSync as unlinkSync2,
11739
+ writeFileSync as writeFileSync3
11507
11740
  } from "node:fs";
11508
- import { randomUUID } from "node:crypto";
11509
- import { join as join11 } from "node:path";
11510
- var REAL_FS = {
11511
- mkdir: (p, mode) => mkdirSync(p, { recursive: true, mode }),
11512
- chmod: (p, mode) => chmodSync(p, mode),
11513
- writeFile: (p, data, mode) => writeFileSync2(p, data, { mode }),
11514
- rename: (a, b) => renameSync(a, b),
11515
- readFile: (p) => readFileSync7(p, "utf8"),
11516
- unlink: (p) => unlinkSync(p)
11741
+ import { randomUUID as randomUUID2 } from "node:crypto";
11742
+ import { join as join12 } from "node:path";
11743
+ var REAL_FS2 = {
11744
+ mkdir: (p, mode) => mkdirSync2(p, { recursive: true, mode }),
11745
+ chmod: (p, mode) => chmodSync2(p, mode),
11746
+ writeFile: (p, data, mode) => writeFileSync3(p, data, { mode }),
11747
+ rename: (a, b) => renameSync2(a, b),
11748
+ readFile: (p) => readFileSync8(p, "utf8"),
11749
+ unlink: (p) => unlinkSync2(p)
11517
11750
  };
11518
11751
  function securecomsDir(home) {
11519
- return join11(home, ".securecoms");
11752
+ return join12(home, ".securecoms");
11520
11753
  }
11521
11754
  function connectionsDir(home) {
11522
- return join11(securecomsDir(home), "connections");
11755
+ return join12(securecomsDir(home), "connections");
11523
11756
  }
11524
11757
  function connectionFilePath(home, identityId) {
11525
- return join11(connectionsDir(home), `${identityId}.json`);
11758
+ return join12(connectionsDir(home), `${identityId}.json`);
11526
11759
  }
11527
11760
  function newInstanceId() {
11528
- return randomUUID();
11761
+ return randomUUID2();
11529
11762
  }
11530
11763
  function buildConnectionFile(args2) {
11531
11764
  return {
@@ -11539,18 +11772,18 @@ function buildConnectionFile(args2) {
11539
11772
  written_at: args2.now
11540
11773
  };
11541
11774
  }
11542
- function writeConnectionFile(home, doc, deps = REAL_FS) {
11775
+ function writeConnectionFile(home, doc, deps = REAL_FS2) {
11543
11776
  deps.mkdir(securecomsDir(home), 448);
11544
11777
  deps.chmod(securecomsDir(home), 448);
11545
11778
  deps.mkdir(connectionsDir(home), 448);
11546
11779
  deps.chmod(connectionsDir(home), 448);
11547
11780
  const path = connectionFilePath(home, doc.identity_id);
11548
- const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
11781
+ const tmp = `${path}.${process.pid}.${randomUUID2()}.tmp`;
11549
11782
  deps.writeFile(tmp, JSON.stringify(doc, null, 2) + "\n", 384);
11550
11783
  deps.chmod(tmp, 384);
11551
11784
  deps.rename(tmp, path);
11552
11785
  }
11553
- function removeConnectionFileIfOwned(home, identityId, instanceId, deps = REAL_FS) {
11786
+ function removeConnectionFileIfOwned(home, identityId, instanceId, deps = REAL_FS2) {
11554
11787
  const path = connectionFilePath(home, identityId);
11555
11788
  try {
11556
11789
  const cur = JSON.parse(deps.readFile(path));
@@ -11591,10 +11824,10 @@ function realServiceDeps() {
11591
11824
  const r = spawnSync(cmd, args2, { encoding: "utf8" });
11592
11825
  return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
11593
11826
  },
11594
- writeFile: (p, data) => writeFileSync3(p, data, { mode: 420 }),
11827
+ writeFile: (p, data) => writeFileSync4(p, data, { mode: 420 }),
11595
11828
  removeFile: (p) => rmSync(p, { force: true }),
11596
- mkdirp: (p) => mkdirSync2(p, { recursive: true }),
11597
- fileExists: (p) => existsSync2(p),
11829
+ mkdirp: (p) => mkdirSync3(p, { recursive: true }),
11830
+ fileExists: (p) => existsSync3(p),
11598
11831
  user: userInfo().username
11599
11832
  };
11600
11833
  }
@@ -11617,7 +11850,7 @@ function realResolveNodeDeps() {
11617
11850
  };
11618
11851
  return {
11619
11852
  realpath: (p) => realpathSync(p),
11620
- fileExists: (p) => existsSync2(p),
11853
+ fileExists: (p) => existsSync3(p),
11621
11854
  nodeMajorOf: majorOf,
11622
11855
  arch: () => process.arch,
11623
11856
  archOf: (p) => {
@@ -11632,17 +11865,17 @@ function realResolveDeps() {
11632
11865
  const r = spawnSync(cmd, args2, { encoding: "utf8" });
11633
11866
  return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
11634
11867
  },
11635
- fileExists: (p) => existsSync2(p)
11868
+ fileExists: (p) => existsSync3(p)
11636
11869
  };
11637
11870
  }
11638
11871
  function realPrepDeps() {
11639
11872
  return {
11640
- readFile: (p) => readFileSync8(p, "utf8"),
11641
- writeFileMode: (p, data, mode) => writeFileSync3(p, data, { mode }),
11873
+ readFile: (p) => readFileSync9(p, "utf8"),
11874
+ writeFileMode: (p, data, mode) => writeFileSync4(p, data, { mode }),
11642
11875
  mkdirp: (p, mode) => {
11643
- mkdirSync2(p, { recursive: true, mode: mode ?? 493 });
11876
+ mkdirSync3(p, { recursive: true, mode: mode ?? 493 });
11644
11877
  },
11645
- fileExists: (p) => existsSync2(p),
11878
+ fileExists: (p) => existsSync3(p),
11646
11879
  realpath: (p) => realpathSync(p),
11647
11880
  listDir: (p) => {
11648
11881
  try {
@@ -11685,7 +11918,7 @@ function realGetHealth(port) {
11685
11918
  }
11686
11919
  function realReadFleet() {
11687
11920
  return readFleet({
11688
- home: homedir2(),
11921
+ home: homedir3(),
11689
11922
  listDir: (p) => {
11690
11923
  try {
11691
11924
  return readdirSync2(p);
@@ -11693,7 +11926,7 @@ function realReadFleet() {
11693
11926
  return [];
11694
11927
  }
11695
11928
  },
11696
- readFile: (p) => readFileSync8(p, "utf8")
11929
+ readFile: (p) => readFileSync9(p, "utf8")
11697
11930
  });
11698
11931
  }
11699
11932
  function runStatus() {
@@ -11715,62 +11948,62 @@ function runStatus() {
11715
11948
  }
11716
11949
  function realCopyConnector(srcDir, dstDir, harness) {
11717
11950
  const allowedExt = CONNECTOR_FILE_EXTENSIONS[harness];
11718
- mkdirSync2(dstDir, { recursive: true });
11951
+ mkdirSync3(dstDir, { recursive: true });
11719
11952
  for (const name of readdirSync2(srcDir)) {
11720
- const srcPath = join12(srcDir, name);
11721
- if (statSync3(srcPath).isDirectory()) {
11953
+ const srcPath = join13(srcDir, name);
11954
+ if (statSync4(srcPath).isDirectory()) {
11722
11955
  if (CONNECTOR_SKIP_DIRS.has(name)) continue;
11723
- realCopyConnector(srcPath, join12(dstDir, name), harness);
11956
+ realCopyConnector(srcPath, join13(dstDir, name), harness);
11724
11957
  continue;
11725
11958
  }
11726
11959
  if (name.includes(".test.")) continue;
11727
11960
  if (!allowedExt.some((ext) => name.endsWith(ext))) continue;
11728
- writeFileSync3(join12(dstDir, name), readFileSync8(srcPath));
11961
+ writeFileSync4(join13(dstDir, name), readFileSync9(srcPath));
11729
11962
  }
11730
11963
  }
11731
11964
  function realWriteBinding(dstDir, identityId) {
11732
- mkdirSync2(dstDir, { recursive: true });
11733
- writeFileSync3(join12(dstDir, "securecoms.binding.json"), JSON.stringify({ identity_id: identityId }) + "\n", { mode: 384 });
11965
+ mkdirSync3(dstDir, { recursive: true });
11966
+ writeFileSync4(join13(dstDir, "securecoms.binding.json"), JSON.stringify({ identity_id: identityId }) + "\n", { mode: 384 });
11734
11967
  }
11735
11968
  function realReadBinding(dstDir) {
11736
11969
  try {
11737
- const parsed = JSON.parse(readFileSync8(join12(dstDir, "securecoms.binding.json"), "utf8"));
11970
+ const parsed = JSON.parse(readFileSync9(join13(dstDir, "securecoms.binding.json"), "utf8"));
11738
11971
  return parsed.identity_id ?? null;
11739
11972
  } catch {
11740
11973
  return null;
11741
11974
  }
11742
11975
  }
11743
11976
  function realRemoveBinding(dstDir) {
11744
- rmSync(join12(dstDir, "securecoms.binding.json"), { force: true });
11977
+ rmSync(join13(dstDir, "securecoms.binding.json"), { force: true });
11745
11978
  }
11746
11979
  function realStatDir(p) {
11747
11980
  try {
11748
- return statSync3(p).isDirectory();
11981
+ return statSync4(p).isDirectory();
11749
11982
  } catch {
11750
11983
  return false;
11751
11984
  }
11752
11985
  }
11753
11986
  function realReadHermesConfig(p) {
11754
11987
  try {
11755
- return readFileSync8(p, "utf8");
11988
+ return readFileSync9(p, "utf8");
11756
11989
  } catch {
11757
11990
  return null;
11758
11991
  }
11759
11992
  }
11760
11993
  function realBackupHermesConfig(p, originalText) {
11761
- writeFileSync3(hermesConfigBackupPath(p, Date.now(), process.pid), originalText, "utf8");
11994
+ writeFileSync4(hermesConfigBackupPath(p, Date.now(), process.pid), originalText, "utf8");
11762
11995
  }
11763
11996
  function realWriteHermesConfig(p, newText) {
11764
- writeFileSync3(p, newText, "utf8");
11997
+ writeFileSync4(p, newText, "utf8");
11765
11998
  }
11766
11999
  function realWireArgv() {
11767
12000
  if (process.argv.includes("--harness")) return process.argv;
11768
- const detected = detectHarness(homedir2(), realStatDir);
12001
+ const detected = detectHarness(homedir3(), realStatDir);
11769
12002
  return detected ? [...process.argv, "--harness", detected] : process.argv;
11770
12003
  }
11771
12004
  function realWireDeps() {
11772
12005
  return {
11773
- home: homedir2(),
12006
+ home: homedir3(),
11774
12007
  argv: realWireArgv(),
11775
12008
  readFleet: realReadFleet,
11776
12009
  copyConnector: realCopyConnector,
@@ -11797,7 +12030,7 @@ async function runServiceVerb(verb) {
11797
12030
  const label = argOrEnv("--label", "SECURECOMS_LABEL");
11798
12031
  if (verb === "install" ? !credentialPath : !credentialPath && !label) {
11799
12032
  const execPathUsage = verb === "install" ? " [--exec-path <path>]" : "";
11800
- process.stderr.write(`usage: agent-bridge ${verb} --credential <path> [--label <name>] [--store <path>] [--port N] [--harness openclaw|hermes|custom]${execPathUsage}
12033
+ process.stderr.write(`usage: agent-bridge ${verb} --credential <path> [--label <name>] [--store <path>] [--port N] [--harness openclaw|hermes|universal]${execPathUsage}
11801
12034
  `);
11802
12035
  process.exit(2);
11803
12036
  }
@@ -11822,7 +12055,7 @@ async function runServiceVerb(verb) {
11822
12055
  explicitPort: portStr ? Number(portStr) : void 0,
11823
12056
  harness: installHarness,
11824
12057
  allowRebind: process.argv.includes("--rebind"),
11825
- home: homedir2(),
12058
+ home: homedir3(),
11826
12059
  connectorsSrcDir: connectorsSrcDir(import.meta.url, installHarness === "hermes" ? "hermes" : "openclaw"),
11827
12060
  autoWireDeps: {
11828
12061
  statDir: realStatDir,
@@ -11849,7 +12082,7 @@ async function runServiceVerb(verb) {
11849
12082
  label,
11850
12083
  execPath: installExecPath,
11851
12084
  binScript,
11852
- home: homedir2(),
12085
+ home: homedir3(),
11853
12086
  uid: userInfo().uid
11854
12087
  })
11855
12088
  });
@@ -11863,7 +12096,7 @@ async function runServiceVerb(verb) {
11863
12096
  label,
11864
12097
  execPath: process.execPath,
11865
12098
  binScript,
11866
- home: homedir2(),
12099
+ home: homedir3(),
11867
12100
  uid: userInfo().uid
11868
12101
  };
11869
12102
  const r = uninstallService(input, realServiceDeps());
@@ -11914,11 +12147,11 @@ async function main() {
11914
12147
  `);
11915
12148
  process.exit(1);
11916
12149
  }
11917
- const tokenFile = daemon.tokenFile ?? join12(dirname7(credentialPath), "bridge-token");
12150
+ const tokenFile = daemon.tokenFile ?? join13(dirname7(credentialPath), "bridge-token");
11918
12151
  const token = resolveToken({ tokenFile, token: daemon.token });
11919
12152
  const agent = new BridgeAgent(cfg);
11920
12153
  await agent.start();
11921
- const tls = daemon.tls ? { cert: readFileSync8(daemon.tlsCert, "utf8"), key: readFileSync8(daemon.tlsKey, "utf8") } : void 0;
12154
+ const tls = daemon.tls ? { cert: readFileSync9(daemon.tlsCert, "utf8"), key: readFileSync9(daemon.tlsKey, "utf8") } : void 0;
11922
12155
  const { server, closeAllSseStreams } = createHttpServer(agent, { token, allowOrigins: daemon.allowOrigins, tls, websocket: true });
11923
12156
  const agentWs = attachAgentWebSocket(server, agent, { token, allowOrigins: daemon.allowOrigins });
11924
12157
  let down = false;
@@ -11940,7 +12173,7 @@ async function main() {
11940
12173
  const instanceId = newInstanceId();
11941
12174
  const identityId = agent.health().identity_id;
11942
12175
  const slug = slugFromCredentialPath(credentialPath);
11943
- process.once("exit", () => removeConnectionFileIfOwned(homedir2(), identityId, instanceId));
12176
+ process.once("exit", () => removeConnectionFileIfOwned(homedir3(), identityId, instanceId));
11944
12177
  server.listen(daemon.port, daemon.bind, () => {
11945
12178
  const scheme = tls ? "https" : "http";
11946
12179
  const wsScheme = tls ? "wss" : "ws";
@@ -11951,7 +12184,7 @@ async function main() {
11951
12184
  const addr = server.address();
11952
12185
  const boundPort = typeof addr === "object" && addr ? addr.port : daemon.port;
11953
12186
  try {
11954
- writeConnectionFile(homedir2(), buildConnectionFile({
12187
+ writeConnectionFile(homedir3(), buildConnectionFile({
11955
12188
  identityId,
11956
12189
  port: boundPort,
11957
12190
  tokenFile,
@@ -144,6 +144,13 @@ export interface BridgeAgentDeps {
144
144
  transport?: any;
145
145
  backend?: any;
146
146
  fetchImpl?: typeof fetch;
147
+ /**
148
+ * Round 5 (runtime config delivery): the home directory under which the
149
+ * managed runtime-config directory lives. Injected only by tests, so they can
150
+ * write into a temp dir instead of the developer's real ~; production leaves
151
+ * it undefined and gets homedir().
152
+ */
153
+ home?: string;
147
154
  /** Injectable so tests can assert recorded stages; defaults to a fresh one. */
148
155
  latency?: LatencyRecorder;
149
156
  /**
@@ -341,6 +348,8 @@ export declare class BridgeAgent {
341
348
  private readonly backend;
342
349
  private readonly custody;
343
350
  private readonly fetchImpl;
351
+ /** Round 5: home for the managed runtime-config directory (test seam). */
352
+ private readonly home;
344
353
  private readonly joinState;
345
354
  /**
346
355
  * Phase 2: lazy channel-metadata cache (channel_id → { is_dm, name, at }) used
@@ -642,6 +651,21 @@ export declare class BridgeAgent {
642
651
  * `voice_settings_changed` ping); never throws.
643
652
  */
644
653
  refetchVoiceSettings(): Promise<void>;
654
+ /**
655
+ * Fetch this agent's runtime config and apply it to the managed directory the
656
+ * runtime reads (spec §8). Safe to call any time — at start(), or from the
657
+ * receive pump on a `runtime_config_changed` ping — and never throws: a save
658
+ * the owner already made must not be undone by a network blip here, and no
659
+ * shape the backend can send may take the daemon down.
660
+ *
661
+ * Order matters. Prunes are applied BEFORE the memory is read back, so the
662
+ * report the owner sees next is the file as it now stands.
663
+ *
664
+ * Every cycle rewrites, even at an unchanged version: directory permissions
665
+ * are repaired only on a write, so skipping would leave a loosened directory
666
+ * loose until the next save.
667
+ */
668
+ refetchRuntimeConfig(): Promise<void>;
645
669
  /**
646
670
  * Enforce the inbound-buffer cap with a DROP-OLDEST policy.
647
671
  *
package/dist/index.js CHANGED
@@ -4546,6 +4546,9 @@ var FRAME_PARSERS = {
4546
4546
  }),
4547
4547
  voice_settings_changed: (d) => ({
4548
4548
  voiceSettingsChanged: { identityId: str(d?.identity_id), version: Number(d?.version ?? 0) }
4549
+ }),
4550
+ runtime_config_changed: (d) => ({
4551
+ runtimeConfigChanged: { identityId: str(d?.identity_id), version: Number(d?.version ?? 0) }
4549
4552
  })
4550
4553
  };
4551
4554
  var S2C_FRAME_NAMES = new Set(Object.keys(FRAME_PARSERS));
@@ -6100,7 +6103,7 @@ var SqliteMlsStateStore = class {
6100
6103
 
6101
6104
  // src/bridge-agent.ts
6102
6105
  import { decryptFile as decryptFile2, encryptFile as encryptFile2, computeFileDigest as computeFileDigest2, base64ToBytes as base64ToBytes4, bytesToBase64 as bytesToBase644 } from "@agentvault/crypto";
6103
- import { createHash } from "node:crypto";
6106
+ import { createHash as createHash2 } from "node:crypto";
6104
6107
 
6105
6108
  // src/service/resolve-bin.ts
6106
6109
  import { createRequire } from "node:module";
@@ -6112,7 +6115,7 @@ function loadPackageMeta(importMetaUrl) {
6112
6115
 
6113
6116
  // src/bridge-agent.ts
6114
6117
  import { promises as fsp } from "node:fs";
6115
- import { join as join6, dirname } from "node:path";
6118
+ import { join as join7, dirname } from "node:path";
6116
6119
 
6117
6120
  // src/latency.ts
6118
6121
  var DEFAULT_CAPACITY = 1024;
@@ -7308,7 +7311,135 @@ async function runSummarize(opts) {
7308
7311
  }
7309
7312
  }
7310
7313
 
7314
+ // src/service/runtime-config-dir.ts
7315
+ import { createHash, randomUUID } from "node:crypto";
7316
+ import {
7317
+ chmodSync,
7318
+ existsSync,
7319
+ mkdirSync,
7320
+ readFileSync as readFileSync5,
7321
+ renameSync,
7322
+ statSync as statSync2,
7323
+ unlinkSync,
7324
+ writeFileSync
7325
+ } from "node:fs";
7326
+ import { basename as basename2, join as join6 } from "node:path";
7327
+ var MEMORY_FILE = "MEMORY.md";
7328
+ var REAL_FS = {
7329
+ mkdir: (p, mode) => mkdirSync(p, { recursive: true, mode }),
7330
+ chmod: (p, mode) => chmodSync(p, mode),
7331
+ writeFile: (p, data, mode) => writeFileSync(p, data, { mode }),
7332
+ rename: (a, b) => renameSync(a, b),
7333
+ readFile: (p) => readFileSync5(p, "utf8"),
7334
+ unlink: (p) => unlinkSync(p)
7335
+ };
7336
+ function runtimeConfigDir(home, identityId) {
7337
+ return join6(home, ".securecoms", "runtime-config", identityId);
7338
+ }
7339
+ function assertManagedName(name) {
7340
+ if (!name || name.includes("/") || name.includes("\\") || name === "." || name === ".." || name !== basename2(name)) {
7341
+ throw new Error(`refusing managed config file name ${JSON.stringify(name)}: not a plain filename`);
7342
+ }
7343
+ }
7344
+ function atomicWrite(path, body, deps) {
7345
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
7346
+ deps.writeFile(tmp, body, 384);
7347
+ deps.chmod(tmp, 384);
7348
+ try {
7349
+ deps.rename(tmp, path);
7350
+ } catch (err) {
7351
+ try {
7352
+ deps.unlink(tmp);
7353
+ } catch {
7354
+ }
7355
+ throw err;
7356
+ }
7357
+ }
7358
+ function writeManagedFiles(home, identityId, files, deps = REAL_FS) {
7359
+ const entries = Object.entries(files);
7360
+ for (const [name] of entries) assertManagedName(name);
7361
+ const parent2 = join6(home, ".securecoms", "runtime-config");
7362
+ const dir = runtimeConfigDir(home, identityId);
7363
+ for (const d of [join6(home, ".securecoms"), parent2, dir]) {
7364
+ deps.mkdir(d, 448);
7365
+ deps.chmod(d, 448);
7366
+ }
7367
+ for (const [name, body] of entries) atomicWrite(join6(dir, name), body, deps);
7368
+ }
7369
+ function checkConfiguredPaths(skillsJson, statFn = (p) => {
7370
+ const s = statSync2(p);
7371
+ return { mode: s.mode, size: s.size };
7372
+ }) {
7373
+ const paths = [];
7374
+ const endpoints = skillsJson.endpoints ?? {};
7375
+ for (const e of Object.values(endpoints)) if (e?.headersFile) paths.push(e.headersFile);
7376
+ const databases = skillsJson.databases ?? {};
7377
+ for (const d of Object.values(databases)) {
7378
+ if (d?.sqlite) paths.push(d.sqlite);
7379
+ if (d?.postgresUrlFile) paths.push(d.postgresUrlFile);
7380
+ }
7381
+ return paths.map((path) => {
7382
+ let st;
7383
+ try {
7384
+ st = statFn(path);
7385
+ } catch {
7386
+ return { path, ok: false, why: "not found" };
7387
+ }
7388
+ if ((st.mode & 63) !== 0) {
7389
+ return { path, ok: false, why: "readable by other users on this machine \u2014 tighten its permissions to owner-only" };
7390
+ }
7391
+ if (st.size === 0) return { path, ok: false, why: "the file is empty" };
7392
+ return { path, ok: true, why: "" };
7393
+ });
7394
+ }
7395
+ function lineHash(line) {
7396
+ return createHash("sha256").update(line.trim(), "utf8").digest("hex");
7397
+ }
7398
+ var LOCAL_REPORT_FILE = "local-config.json";
7399
+ function readLocalReport(home, identityId) {
7400
+ const p = join6(runtimeConfigDir(home, identityId), LOCAL_REPORT_FILE);
7401
+ if (!existsSync(p)) return null;
7402
+ let doc;
7403
+ try {
7404
+ doc = JSON.parse(readFileSync5(p, "utf8"));
7405
+ } catch {
7406
+ return null;
7407
+ }
7408
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
7409
+ const hasSearchKey = doc.has_search_key === true;
7410
+ const raw = doc.persona;
7411
+ const persona = raw && typeof raw === "object" && !Array.isArray(raw) ? Object.fromEntries(
7412
+ Object.entries(raw).filter(([, v]) => typeof v === "string")
7413
+ ) : {};
7414
+ return { persona, has_search_key: hasSearchKey };
7415
+ }
7416
+ function readManagedMemory(home, identityId) {
7417
+ const p = join6(runtimeConfigDir(home, identityId), MEMORY_FILE);
7418
+ if (!existsSync(p)) return null;
7419
+ try {
7420
+ const body = readFileSync5(p, "utf8").trim();
7421
+ return body || null;
7422
+ } catch {
7423
+ return null;
7424
+ }
7425
+ }
7426
+ function pruneManagedMemory(home, identityId, hashes, deps = REAL_FS) {
7427
+ const dir = runtimeConfigDir(home, identityId);
7428
+ const path = join6(dir, MEMORY_FILE);
7429
+ if (!existsSync(path)) return 0;
7430
+ const drop = new Set(hashes);
7431
+ const before = deps.readFile(path).split("\n");
7432
+ const kept = before.filter((l) => l.trim() === "" || !drop.has(lineHash(l)));
7433
+ const removed = before.filter((l) => l.trim() !== "" && drop.has(lineHash(l))).length;
7434
+ if (removed === 0) return 0;
7435
+ const body = kept.filter((l, i) => l.trim() !== "" || i < kept.length - 1).join("\n").trimEnd();
7436
+ atomicWrite(path, body ? `${body}
7437
+ ` : "", deps);
7438
+ return removed;
7439
+ }
7440
+
7311
7441
  // src/bridge-agent.ts
7442
+ import { homedir as homedir2 } from "node:os";
7312
7443
  var VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
7313
7444
  var FAST_PATH_SPOKEN_GUIDANCE = "A one-sentence spoken acknowledgement has already been sent for this turn. Reply only with information it did not contain, in one or two short sentences; if there is nothing to add, send nothing.";
7314
7445
  var ROOM_VOICE_GUIDANCE_MENTION_ONLY = "You are in a group voice room. Reply ONLY when a message @mentions your display name. Keep replies to one short sentence. When you should not reply, send nothing.";
@@ -7363,7 +7494,7 @@ function resolveMessageId(messageId) {
7363
7494
  return messageId;
7364
7495
  }
7365
7496
  function fastReplyId(messageId) {
7366
- const h = createHash("sha256").update(`fast:${messageId}`).digest();
7497
+ const h = createHash2("sha256").update(`fast:${messageId}`).digest();
7367
7498
  h[6] = h[6] & 15 | 64;
7368
7499
  h[8] = h[8] & 63 | 128;
7369
7500
  const x = h.subarray(0, 16).toString("hex");
@@ -7493,6 +7624,7 @@ var BridgeAgent = class _BridgeAgent {
7493
7624
  onSupersedeUnopenable: (channelId, groupId) => this.healSupersedeUnopenable(channelId, groupId)
7494
7625
  });
7495
7626
  this.fetchImpl = deps.fetchImpl ?? fetch;
7627
+ this.home = deps.home ?? homedir2();
7496
7628
  this.injectedFastLlm = !!deps.fastLlm;
7497
7629
  this.fileVoiceConfig = cfg.voice;
7498
7630
  this.fastLlm = deps.fastLlm ?? (cfg.voice?.fastPath.enabled ? new FastLlmClient(cfg.voice.fastPath, this.fetchImpl) : void 0);
@@ -7525,8 +7657,8 @@ var BridgeAgent = class _BridgeAgent {
7525
7657
  const j = await res.json();
7526
7658
  return String(j.attachment_id);
7527
7659
  };
7528
- this.attachmentsDirImpl = deps.attachmentsDir ?? join6(dirname(cfg.storePath), "attachments");
7529
- this.vaultOwnerStoreImpl = deps.vaultOwnerStore ?? new FileOwnerAuthorityStore(join6(dirname(cfg.storePath), "vault-owner-pins.json"));
7660
+ this.attachmentsDirImpl = deps.attachmentsDir ?? join7(dirname(cfg.storePath), "attachments");
7661
+ this.vaultOwnerStoreImpl = deps.vaultOwnerStore ?? new FileOwnerAuthorityStore(join7(dirname(cfg.storePath), "vault-owner-pins.json"));
7530
7662
  this.action = {
7531
7663
  begin: (channelId) => this.actionBegin(channelId),
7532
7664
  start: (opts) => this.actionStart(opts),
@@ -7542,6 +7674,8 @@ var BridgeAgent = class _BridgeAgent {
7542
7674
  // stored for peek-then-join in ingestOnce
7543
7675
  custody;
7544
7676
  fetchImpl;
7677
+ /** Round 5: home for the managed runtime-config directory (test seam). */
7678
+ home;
7545
7679
  joinState = /* @__PURE__ */ new Map();
7546
7680
  /**
7547
7681
  * Phase 2: lazy channel-metadata cache (channel_id → { is_dm, name, at }) used
@@ -7883,6 +8017,84 @@ var BridgeAgent = class _BridgeAgent {
7883
8017
  if (this.voiceSettingsSource === "backend") return;
7884
8018
  process.stderr.write(
7885
8019
  `[bridge] voice settings unavailable (${e.message}); using ${this.voiceSettingsSource}
8020
+ `
8021
+ );
8022
+ }
8023
+ }
8024
+ /**
8025
+ * Fetch this agent's runtime config and apply it to the managed directory the
8026
+ * runtime reads (spec §8). Safe to call any time — at start(), or from the
8027
+ * receive pump on a `runtime_config_changed` ping — and never throws: a save
8028
+ * the owner already made must not be undone by a network blip here, and no
8029
+ * shape the backend can send may take the daemon down.
8030
+ *
8031
+ * Order matters. Prunes are applied BEFORE the memory is read back, so the
8032
+ * report the owner sees next is the file as it now stands.
8033
+ *
8034
+ * Every cycle rewrites, even at an unchanged version: directory permissions
8035
+ * are repaired only on a write, so skipping would leave a loosened directory
8036
+ * loose until the next save.
8037
+ */
8038
+ async refetchRuntimeConfig() {
8039
+ const base = `${this.cfg.apiBase}/api/v1/agents/${encodeURIComponent(this.cfg.identityId)}/runtime-config`;
8040
+ try {
8041
+ const res = await this.fetchImpl(`${base}/files`, {
8042
+ headers: { authorization: `Bearer ${this.cfg.apiKey}` }
8043
+ });
8044
+ if (!res.ok) throw new Error(`runtime-config ${res.status}`);
8045
+ const payload = await res.json();
8046
+ if (!payload || !payload.version) {
8047
+ process.stderr.write(
8048
+ `[bridge] runtime config not applied (version ${JSON.stringify(payload?.version ?? null)} \u2014 no saved config)
8049
+ `
8050
+ );
8051
+ return;
8052
+ }
8053
+ const skillsJson = payload.skills_json ?? {};
8054
+ writeManagedFiles(this.home, this.cfg.identityId, {
8055
+ ...payload.files ?? {},
8056
+ "skills.json": `${JSON.stringify(skillsJson, null, 2)}
8057
+ `
8058
+ });
8059
+ if (payload.memory_prunes?.length) {
8060
+ pruneManagedMemory(this.home, this.cfg.identityId, payload.memory_prunes);
8061
+ }
8062
+ const local = readLocalReport(this.home, this.cfg.identityId);
8063
+ const report = {
8064
+ applied_version: payload.version,
8065
+ paths: checkConfiguredPaths(skillsJson),
8066
+ // Read AFTER the prunes above, so the owner's next view of memory is
8067
+ // the file as it now stands, not the one they just pruned from.
8068
+ memory: readManagedMemory(this.home, this.cfg.identityId),
8069
+ // E13: pass through what the runtime reported. The daemon does not
8070
+ // interpret any of it.
8071
+ local_persona: local?.persona ?? null,
8072
+ // Only the runtime knows whether a search key file is configured — it
8073
+ // is an environment value on that process, invisible to the daemon and
8074
+ // to the backend. The backend needs it to generate a truthful tool
8075
+ // paragraph, so it rides along here.
8076
+ has_search_key: local?.has_search_key ?? false
8077
+ };
8078
+ const posted = await this.fetchImpl(`${base}/report`, {
8079
+ method: "POST",
8080
+ headers: {
8081
+ authorization: `Bearer ${this.cfg.apiKey}`,
8082
+ "content-type": "application/json"
8083
+ },
8084
+ body: JSON.stringify(report)
8085
+ });
8086
+ if (!posted.ok) {
8087
+ process.stderr.write(
8088
+ `[bridge] runtime config v${payload.version} written, but reporting it failed (${posted.status}) \u2014 the console will keep showing it as not applied until the next cycle
8089
+ `
8090
+ );
8091
+ return;
8092
+ }
8093
+ process.stderr.write(`[bridge] runtime config v${payload.version} applied
8094
+ `);
8095
+ } catch (e) {
8096
+ process.stderr.write(
8097
+ `[bridge] runtime config refetch failed: ${e?.message ?? e}
7886
8098
  `
7887
8099
  );
7888
8100
  }
@@ -7914,6 +8126,7 @@ var BridgeAgent = class _BridgeAgent {
7914
8126
  }
7915
8127
  await this.provider.publishKeyPackage();
7916
8128
  await this.refetchVoiceSettings();
8129
+ await this.refetchRuntimeConfig();
7917
8130
  this.startReceivePump();
7918
8131
  if (this.selfHealSweepEnabled) {
7919
8132
  this.bootSweepTimer = setTimeout(
@@ -9398,11 +9611,16 @@ var BridgeAgent = class _BridgeAgent {
9398
9611
  this.pingsReceived++;
9399
9612
  if (ping?.reconnected) {
9400
9613
  process.stderr.write("[bridge] delivery-ping WS (re)connected \u2014 push inbound active\n");
9614
+ await this.refetchRuntimeConfig();
9401
9615
  }
9402
9616
  if (ping?.voiceSettingsChanged) {
9403
9617
  await this.refetchVoiceSettings();
9404
9618
  continue;
9405
9619
  }
9620
+ if (ping?.runtimeConfigChanged) {
9621
+ await this.refetchRuntimeConfig();
9622
+ continue;
9623
+ }
9406
9624
  await this.ingestOnce();
9407
9625
  }
9408
9626
  } catch {
@@ -9840,13 +10058,13 @@ var BridgeAgent = class _BridgeAgent {
9840
10058
  };
9841
10059
 
9842
10060
  // src/config.ts
9843
- import { readFileSync as readFileSync5, statSync as statSync2 } from "node:fs";
9844
- import { dirname as dirname2, join as join7 } from "node:path";
10061
+ import { readFileSync as readFileSync6, statSync as statSync3 } from "node:fs";
10062
+ import { dirname as dirname2, join as join8 } from "node:path";
9845
10063
  var REQUIRED = ["api_key", "identity_id", "device_id", "signing_private_key", "enclave_id", "api_base"];
9846
10064
  function loadConfig(opts) {
9847
- const raw = readFileSync5(opts.credentialPath, "utf8");
10065
+ const raw = readFileSync6(opts.credentialPath, "utf8");
9848
10066
  try {
9849
- const mode = statSync2(opts.credentialPath).mode & 511;
10067
+ const mode = statSync3(opts.credentialPath).mode & 511;
9850
10068
  if (mode & 63) process.stderr.write(`[bridge] WARNING: credential file ${opts.credentialPath} permission ${mode.toString(8)} is not 0600 \u2014 tighten it (chmod 600).
9851
10069
  `);
9852
10070
  } catch {
@@ -9858,7 +10076,7 @@ function loadConfig(opts) {
9858
10076
  throw new Error("credential file is not valid JSON");
9859
10077
  }
9860
10078
  for (const k of REQUIRED) if (typeof json2[k] !== "string" || !json2[k]) throw new Error(`credential file missing required field: ${k}`);
9861
- const storePath = opts.storePath ?? join7(dirname2(opts.credentialPath), "bridge-state.db");
10079
+ const storePath = opts.storePath ?? join8(dirname2(opts.credentialPath), "bridge-state.db");
9862
10080
  const agentName = typeof json2.agent_name === "string" && json2.agent_name ? json2.agent_name : typeof json2.display_name === "string" && json2.display_name ? json2.display_name : void 0;
9863
10081
  const rateLimitCapacity = typeof json2.rate_limit_capacity === "number" ? json2.rate_limit_capacity : 20;
9864
10082
  const rateLimitRefillPerMinute = typeof json2.rate_limit_refill_per_minute === "number" ? json2.rate_limit_refill_per_minute : 20;
@@ -9954,13 +10172,13 @@ import { z as z3 } from "zod";
9954
10172
 
9955
10173
  // src/auth.ts
9956
10174
  import { randomBytes as randomBytes3, timingSafeEqual } from "node:crypto";
9957
- import { existsSync, readFileSync as readFileSync6, writeFileSync } from "node:fs";
10175
+ import { existsSync as existsSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "node:fs";
9958
10176
  function resolveToken(opts) {
9959
10177
  if (opts.token) return opts.token;
9960
10178
  const f = opts.tokenFile;
9961
- if (f && existsSync(f)) return readFileSync6(f, "utf8").trim();
10179
+ if (f && existsSync2(f)) return readFileSync7(f, "utf8").trim();
9962
10180
  const tok = randomBytes3(32).toString("base64url");
9963
- if (f) writeFileSync(f, tok, { mode: 384 });
10181
+ if (f) writeFileSync2(f, tok, { mode: 384 });
9964
10182
  return tok;
9965
10183
  }
9966
10184
  function tokenMatches(provided, expected) {
@@ -0,0 +1,44 @@
1
+ export declare const MEMORY_FILE = "MEMORY.md";
2
+ export interface PathCheck {
3
+ path: string;
4
+ ok: boolean;
5
+ why: string;
6
+ }
7
+ export interface FsDeps {
8
+ mkdir: (p: string, mode: number) => void;
9
+ chmod: (p: string, mode: number) => void;
10
+ writeFile: (p: string, data: string, mode: number) => void;
11
+ rename: (a: string, b: string) => void;
12
+ readFile: (p: string) => string;
13
+ unlink: (p: string) => void;
14
+ }
15
+ export declare function runtimeConfigDir(home: string, identityId: string): string;
16
+ export declare function writeManagedFiles(home: string, identityId: string, files: Record<string, string>, deps?: FsDeps): void;
17
+ /** Every path skills.json names, checked on disk. Contents are never read. */
18
+ export declare function checkConfiguredPaths(skillsJson: Record<string, unknown>, statFn?: (p: string) => {
19
+ mode: number;
20
+ size: number;
21
+ }): PathCheck[];
22
+ export declare function lineHash(line: string): string;
23
+ export declare const LOCAL_REPORT_FILE = "local-config.json";
24
+ /**
25
+ * What the runtime says its OWN persona files currently contain (spec E13).
26
+ *
27
+ * The runtime writes this; the daemon only reads it. It exists because the
28
+ * daemon cannot see the runtime's data dir, so without it an owner's
29
+ * hand-written persona would be invisible in the web app and the first save
30
+ * would replace it sight unseen. It is inert: nothing here decides behaviour,
31
+ * and it never affects which file the runtime actually reads.
32
+ */
33
+ export interface LocalReport {
34
+ persona: Record<string, string>;
35
+ has_search_key: boolean;
36
+ }
37
+ export declare function readLocalReport(home: string, identityId: string): LocalReport | null;
38
+ export declare function readManagedMemory(home: string, identityId: string): string | null;
39
+ /**
40
+ * Remove exactly the named lines. Line-level, never a whole-file replace: the
41
+ * agent may append a fact between the owner reading and pruning, and that fact
42
+ * must survive (spec §7).
43
+ */
44
+ export declare function pruneManagedMemory(home: string, identityId: string, hashes: string[], deps?: FsDeps): number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@securecoms/agent-bridge",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",