@securecoms/agent-bridge 0.38.0 → 0.40.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.
@@ -0,0 +1,217 @@
1
+ // packages/connect-agent-bridge/connectors/openclaw/openclaw-entry.outbound.test.ts
2
+ //
3
+ // Item A (proactive delivery): the OpenClaw connector advertises the outbound
4
+ // targets "owner" and "default". They are not channel ids. They resolve through
5
+ // the bridge's bearer-gated GET /v1/owner-dm (the owner DM from the signed
6
+ // manifest in force), and fail closed: a 404, any non-2xx, a network error or a
7
+ // malformed body posts NOTHING and returns {ok:false, error: OWNER_DM_UNKNOWN_ERROR}.
8
+ // Any other `to` keeps the explicit-channel behaviour.
9
+ //
10
+ // Drives the REAL plugin.gateway.startAccount path with a fake loopback
11
+ // WebSocket and a fake fetch (same harness shape as openclaw-entry.inbound.test.ts).
12
+
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
+ import { mkdtempSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { buildConnectionFile, writeConnectionFile } from "../../src/service/connection-file.js";
18
+
19
+ const BOUND_ID = "agent-identity";
20
+ const PORT = 7097;
21
+ const TOKEN_VALUE = "outbound-test-token";
22
+ const ORIGINAL_CONN_FILE_ENV = process.env.SECURECOMS_CONNECTION_FILE;
23
+
24
+ function waitFor(predicate: () => boolean, timeout = 3000): Promise<void> {
25
+ const start = Date.now();
26
+ return new Promise((resolve, reject) => {
27
+ const check = () => {
28
+ if (predicate()) return resolve();
29
+ if (Date.now() - start > timeout) return reject(new Error("waitFor: condition never became true"));
30
+ setTimeout(check, 5);
31
+ };
32
+ check();
33
+ });
34
+ }
35
+
36
+ function makeFakeWebSocket() {
37
+ const instances: any[] = [];
38
+ class FakeWebSocket {
39
+ listeners: Record<string, Array<(ev?: unknown) => void>> = {};
40
+ constructor(public url: string) { instances.push(this); }
41
+ addEventListener(type: string, cb: (ev?: unknown) => void) { (this.listeners[type] ??= []).push(cb); }
42
+ send() {}
43
+ close() { this._emit("close"); }
44
+ _emit(type: string, ev?: unknown) { for (const cb of this.listeners[type] ?? []) cb(ev ?? {}); }
45
+ }
46
+ return { FakeWebSocket, instances };
47
+ }
48
+
49
+ type OwnerDm =
50
+ | { kind: "ok"; body: unknown }
51
+ | { kind: "status"; status: number; body?: unknown }
52
+ | { kind: "network" }
53
+ | { kind: "badjson" };
54
+
55
+ describe("openclaw-entry: outbound owner/default targets resolve through /v1/owner-dm", () => {
56
+ let home: string;
57
+ let controller: AbortController;
58
+ let sends: Array<Record<string, unknown>>;
59
+ let ownerDmCalls: Array<{ auth: string | null }>;
60
+ let ownerDm: OwnerDm;
61
+
62
+ beforeEach(() => {
63
+ home = mkdtempSync(join(tmpdir(), "sc-outbound-"));
64
+ const tokenFile = join(home, "bridge-token");
65
+ writeFileSync(tokenFile, TOKEN_VALUE, { mode: 0o600 });
66
+ writeConnectionFile(home, buildConnectionFile({
67
+ identityId: BOUND_ID, port: PORT, tokenFile, slug: "outbound-test", label: "Outbound Test",
68
+ instanceId: "outbound-test-instance", now: new Date().toISOString(),
69
+ }));
70
+ process.env.SECURECOMS_CONNECTION_FILE = join(home, ".securecoms", "connections", `${BOUND_ID}.json`);
71
+ sends = [];
72
+ ownerDmCalls = [];
73
+ ownerDm = { kind: "ok", body: { channel_id: "dm-1" } };
74
+ vi.stubGlobal("fetch", vi.fn(async (url: string, init?: RequestInit) => {
75
+ const u = String(url);
76
+ if (u.endsWith("/healthz")) return { ok: true, json: async () => ({ identity_id: BOUND_ID, joined_channels: 1 }) };
77
+ if (u.endsWith("/v1/owner-dm")) {
78
+ const h = (init?.headers ?? {}) as Record<string, string>;
79
+ ownerDmCalls.push({ auth: h.authorization ?? null });
80
+ if (ownerDm.kind === "network") throw new Error("ECONNREFUSED");
81
+ if (ownerDm.kind === "badjson") {
82
+ return { ok: true, status: 200, json: async () => { throw new SyntaxError("bad json"); }, text: async () => "<html>" };
83
+ }
84
+ if (ownerDm.kind === "status") {
85
+ return { ok: false, status: ownerDm.status, json: async () => ownerDm.kind === "status" ? ownerDm.body ?? {} : {}, text: async () => "" };
86
+ }
87
+ return { ok: true, status: 200, json: async () => ownerDm.body };
88
+ }
89
+ if (u.endsWith("/v1/send")) {
90
+ sends.push(JSON.parse(String(init?.body)));
91
+ return { ok: true, json: async () => ({ message_id: "m-1" }) };
92
+ }
93
+ if (u.endsWith("/v1/status")) return { ok: true, json: async () => ({ ok: true }) };
94
+ throw new Error(`unexpected fetch: ${u}`);
95
+ }));
96
+ controller = new AbortController();
97
+ });
98
+
99
+ afterEach(async () => {
100
+ controller.abort();
101
+ vi.unstubAllGlobals();
102
+ vi.unmock("node:os");
103
+ if (ORIGINAL_CONN_FILE_ENV === undefined) delete process.env.SECURECOMS_CONNECTION_FILE;
104
+ else process.env.SECURECOMS_CONNECTION_FILE = ORIGINAL_CONN_FILE_ENV;
105
+ vi.resetModules();
106
+ });
107
+
108
+ async function start() {
109
+ vi.resetModules();
110
+ vi.doMock("node:os", async (importOriginal) => {
111
+ const actual = await importOriginal<typeof import("node:os")>();
112
+ return { ...actual, homedir: () => home };
113
+ });
114
+ const mod = await import("./openclaw-entry.js");
115
+ let plugin: any;
116
+ mod.default.register({
117
+ runtime: { channel: {}, error: () => {} },
118
+ on: () => {},
119
+ registerTrustedToolPolicy: () => {},
120
+ registerChannel: ({ plugin: p }: { plugin: unknown }) => { plugin = p; },
121
+ });
122
+ const { FakeWebSocket, instances } = makeFakeWebSocket();
123
+ vi.stubGlobal("WebSocket", FakeWebSocket);
124
+ const account = plugin.config.resolveAccount({}, "default");
125
+ plugin.gateway.startAccount({ account, cfg: {}, log: () => {}, abortSignal: controller.signal }).catch(() => {});
126
+ await waitFor(() => instances.length >= 1);
127
+ instances[0]._emit("open");
128
+ return { plugin, mod };
129
+ }
130
+
131
+ const advertised = ["owner", "default"] as const;
132
+
133
+ it("keeps the advertised target labels unchanged", async () => {
134
+ const { plugin } = await start();
135
+ expect(plugin.outbound.targets).toEqual([
136
+ { id: "owner", label: "SecureComs Owner", accountId: "default" },
137
+ { id: "default", label: "SecureComs Owner (default)", accountId: "default" },
138
+ ]);
139
+ });
140
+
141
+ for (const to of advertised) {
142
+ it(`(a) to="${to}": resolves via /v1/owner-dm with the bearer and sends once to the resolved channel`, async () => {
143
+ const { plugin } = await start();
144
+ const res = await plugin.outbound.sendText({ text: "x", to, accountId: "default" });
145
+ expect(res).toEqual({ ok: true });
146
+ expect(ownerDmCalls).toEqual([{ auth: `Bearer ${TOKEN_VALUE}` }]);
147
+ expect(sends).toHaveLength(1);
148
+ expect(sends[0]).toEqual({ channel_id: "dm-1", text: "x" }); // no in_reply_to, no idempotency_key
149
+ });
150
+
151
+ it(`(b) to="${to}": a 404 posts nothing and returns the fixed error`, async () => {
152
+ const { plugin, mod } = await start();
153
+ ownerDm = { kind: "status", status: 404, body: { error: "owner_dm_unknown" } };
154
+ const res = await plugin.outbound.sendText({ text: "secret body", to, accountId: "default" });
155
+ expect(sends).toHaveLength(0);
156
+ expect(res.ok).toBe(false);
157
+ expect(res.error).toBe(mod.OWNER_DM_UNKNOWN_ERROR);
158
+ expect(res.error.length).toBeGreaterThan(0);
159
+ expect(res.error).not.toContain("secret body");
160
+ });
161
+ }
162
+
163
+ const failures: Array<[string, OwnerDm]> = [
164
+ ["a 500", { kind: "status", status: 500 }],
165
+ ["a 401", { kind: "status", status: 401 }],
166
+ ["a network error", { kind: "network" }],
167
+ ["a non-JSON 200 body", { kind: "badjson" }],
168
+ ["a 200 with no channel_id", { kind: "ok", body: {} }],
169
+ ["a 200 with an empty channel_id", { kind: "ok", body: { channel_id: "" } }],
170
+ ["a 200 with a non-string channel_id", { kind: "ok", body: { channel_id: 7 } }],
171
+ ["a 200 that resolves to the literal 'owner'", { kind: "ok", body: { channel_id: "owner" } }],
172
+ ["a 200 that resolves to the literal 'default'", { kind: "ok", body: { channel_id: "default" } }],
173
+ ];
174
+ for (const [name, mode] of failures) {
175
+ it(`(b) ${name} fails closed: no send, fixed error`, async () => {
176
+ const { plugin, mod } = await start();
177
+ ownerDm = mode;
178
+ const res = await plugin.outbound.sendText({ text: "x", to: "owner", accountId: "default" });
179
+ expect(sends).toHaveLength(0);
180
+ expect(res).toEqual({ ok: false, error: mod.OWNER_DM_UNKNOWN_ERROR });
181
+ });
182
+ }
183
+
184
+ it("(c) in no case does a send carry channel_id 'owner' or 'default'", async () => {
185
+ const { plugin } = await start();
186
+ const modes: OwnerDm[] = [
187
+ { kind: "ok", body: { channel_id: "dm-1" } },
188
+ { kind: "status", status: 404, body: { error: "owner_dm_unknown" } },
189
+ { kind: "status", status: 500 },
190
+ { kind: "network" },
191
+ { kind: "badjson" },
192
+ { kind: "ok", body: {} },
193
+ ];
194
+ for (const m of modes) {
195
+ ownerDm = m;
196
+ for (const to of advertised) await plugin.outbound.sendText({ text: "x", to, accountId: "default" });
197
+ }
198
+ expect(sends.length).toBeGreaterThan(0); // vacuity control: the ok mode did send
199
+ expect(sends.some((s) => s.channel_id === "owner" || s.channel_id === "default")).toBe(false);
200
+ });
201
+
202
+ it("any other `to` keeps the explicit-channel behaviour and never calls /v1/owner-dm", async () => {
203
+ const { plugin } = await start();
204
+ const res = await plugin.outbound.sendText({ text: "x", to: "chan-uuid-1", accountId: "default" });
205
+ expect(res).toEqual({ ok: true });
206
+ expect(ownerDmCalls).toHaveLength(0);
207
+ expect(sends).toEqual([{ channel_id: "chan-uuid-1", text: "x" }]);
208
+ });
209
+
210
+ it("no `to` still errors without a send or a lookup", async () => {
211
+ const { plugin } = await start();
212
+ const res = await plugin.outbound.sendText({ text: "x", accountId: "default" });
213
+ expect(res.ok).toBe(false);
214
+ expect(sends).toHaveLength(0);
215
+ expect(ownerDmCalls).toHaveLength(0);
216
+ });
217
+ });
package/dist/bin.d.ts CHANGED
@@ -29,6 +29,8 @@ export declare function slugFromCredentialPath(credentialPath: string): string;
29
29
  export declare function chooseInstallExecPath(runningExecPath: string, override: string | undefined, deps: ResolveNodeDeps): string;
30
30
  export declare function realGetHealth(port: number): DaemonHealth | null;
31
31
  export declare function realCopyConnector(srcDir: string, dstDir: string, harness: Harness): void;
32
+ export declare function realReadHermesEnv(p: string): string | null;
33
+ export declare function realAppendHermesEnv(p: string, text: string): void;
32
34
  /** The stderr line (with newline) a failed service verb prints. */
33
35
  export declare function formatServiceError(verb: string, e: unknown): string;
34
36
  /**
package/dist/bin.js CHANGED
@@ -44763,6 +44763,16 @@ var BridgeAgent = class _BridgeAgent {
44763
44763
  async consumeUploaded(read) {
44764
44764
  if (!this.outboxPin || !await consumeOutboxFile(this.outboxPin, read)) writeServiceLog("warn", "vault_outbox.consume_failed");
44765
44765
  }
44766
+ /**
44767
+ * Item A (OQ-A1): the owner DM channel id of the signed manifest in force, else null. In force means the owner pin is
44768
+ * set (same guard as applyManifest). No channelOwner, no listChannels heuristic, no fallback.
44769
+ */
44770
+ ownerDmChannelId() {
44771
+ const pin = this.cfg.ownerGrantPin?.pin ?? null;
44772
+ const inForce = pin !== null ? this.toolGrantManifest : null;
44773
+ const id = inForce?.owner_dm_channel_id;
44774
+ return typeof id === "string" && id !== "" ? id : null;
44775
+ }
44766
44776
  /** Audit 2026-10-30 MED 3: the pinned outbox path, served only on the authenticated GET /v1/vault-outbox. */
44767
44777
  vaultOutboxPath() {
44768
44778
  return this.outboxPin?.dir ?? null;
@@ -50172,7 +50182,9 @@ function capabilities(opts) {
50172
50182
  // version this daemon parses into BridgeMessage.vault_refs (never fetched
50173
50183
  // on receipt) and opens with the R4 pins on /v1/vault-read. A daemon
50174
50184
  // without this field skips and acks a link, so the agent never sees it.
50175
- vault_ref: 1
50185
+ vault_ref: 1,
50186
+ // Item A: GET /v1/owner-dm resolves the literal `owner`/`default` cron target from the signed manifest in force.
50187
+ owner_dm_route: 1
50176
50188
  };
50177
50189
  }
50178
50190
  var SEND_CAP = 64 * 1024;
@@ -50356,6 +50368,10 @@ function createHttpServer(agent, opts) {
50356
50368
  if (method === "GET" && path === "/v1/vault-outbox") {
50357
50369
  return json(res, 200, { path: agent.vaultOutboxPath() });
50358
50370
  }
50371
+ if (method === "GET" && path === "/v1/owner-dm") {
50372
+ const channelId = agent.ownerDmChannelId();
50373
+ return channelId === null ? json(res, 404, { error: "owner_dm_unknown" }) : json(res, 200, { channel_id: channelId });
50374
+ }
50359
50375
  if (method === "POST" && path === "/v1/send-attachment") {
50360
50376
  const parsed = sendAttachmentSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
50361
50377
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
@@ -51138,7 +51154,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
51138
51154
  var LOWER_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
51139
51155
 
51140
51156
  // src/bin.ts
51141
- import { readFileSync as readFileSync17, mkdirSync as mkdirSync9, writeFileSync as writeFileSync5, rmSync as rmSync8, existsSync as existsSync7, realpathSync as realpathSync4, readdirSync as readdirSync4, statSync as statSync8, chmodSync as chmodSync7 } from "node:fs";
51157
+ import { readFileSync as readFileSync17, mkdirSync as mkdirSync9, writeFileSync as writeFileSync5, appendFileSync, rmSync as rmSync8, existsSync as existsSync7, realpathSync as realpathSync4, readdirSync as readdirSync4, statSync as statSync8, chmodSync as chmodSync7 } from "node:fs";
51142
51158
  import { join as join27, dirname as dirname14, isAbsolute as isAbsolute3, resolve as resolve4 } from "node:path";
51143
51159
  import { fileURLToPath as fileURLToPath4 } from "node:url";
51144
51160
  import { homedir as homedir5, userInfo } from "node:os";
@@ -51587,6 +51603,36 @@ function enableHermesConfig(home, deps) {
51587
51603
  message: `enabled ${HERMES_PLUGIN_NAME} in ${path} (the original was backed up alongside it as config.yaml.bak-wire-<ts>-<pid>).`
51588
51604
  };
51589
51605
  }
51606
+ var HERMES_HOME_CHANNEL_KEY = "SECURECOMS_HOME_CHANNEL";
51607
+ var HERMES_HOME_CHANNEL_LINE = `${HERMES_HOME_CHANNEL_KEY}=owner`;
51608
+ function hermesEnvPath(home) {
51609
+ return `${home}/.hermes/.env`;
51610
+ }
51611
+ var HOME_CHANNEL_ASSIGNMENT = new RegExp(`^\\s*(?:export\\s+)?${HERMES_HOME_CHANNEL_KEY}\\s*=`, "m");
51612
+ function homeChannelEnvAppendix(text) {
51613
+ const current = text ?? "";
51614
+ if (HOME_CHANNEL_ASSIGNMENT.test(current)) return null;
51615
+ const sep = current === "" || current.endsWith("\n") ? "" : "\n";
51616
+ return `${sep}${HERMES_HOME_CHANNEL_LINE}
51617
+ `;
51618
+ }
51619
+ function ensureHermesHomeChannel(home, deps) {
51620
+ const path = hermesEnvPath(home);
51621
+ try {
51622
+ const appendix = homeChannelEnvAppendix(deps.readHermesEnv(path));
51623
+ if (appendix === null) {
51624
+ deps.log(`[securecoms] ${HERMES_HOME_CHANNEL_KEY} is already set in ${path}; kept as is.`);
51625
+ return;
51626
+ }
51627
+ deps.appendHermesEnv(path, appendix);
51628
+ deps.log(`[securecoms] set ${HERMES_HOME_CHANNEL_LINE} in ${path} (scheduled messages go to the owner's DM).`);
51629
+ } catch {
51630
+ deps.log(
51631
+ `wire: could not update ${path}; scheduled messages need it. Add this line manually:
51632
+ ${HERMES_HOME_CHANNEL_LINE}`
51633
+ );
51634
+ }
51635
+ }
51590
51636
  function parseArg(argv, flag) {
51591
51637
  const i = argv.indexOf(flag);
51592
51638
  return i >= 0 ? argv[i + 1] : void 0;
@@ -51635,6 +51681,7 @@ function runWire(deps) {
51635
51681
  );
51636
51682
  return 1;
51637
51683
  }
51684
+ ensureHermesHomeChannel(deps.home, deps);
51638
51685
  }
51639
51686
  if (typeof entry.port === "number") {
51640
51687
  const h = deps.getHealth(entry.port);
@@ -52883,6 +52930,17 @@ function realBackupHermesConfig(p, originalText) {
52883
52930
  function realWriteHermesConfig(p, newText) {
52884
52931
  writeFileSync5(p, newText, "utf8");
52885
52932
  }
52933
+ function realReadHermesEnv(p) {
52934
+ try {
52935
+ return readFileSync17(p, "utf8");
52936
+ } catch (e) {
52937
+ if (e?.code === "ENOENT") return null;
52938
+ throw e;
52939
+ }
52940
+ }
52941
+ function realAppendHermesEnv(p, text) {
52942
+ appendFileSync(p, text, { encoding: "utf8", mode: 384 });
52943
+ }
52886
52944
  function realWireArgv() {
52887
52945
  if (process.argv.includes("--harness")) return process.argv;
52888
52946
  const detected = detectHarness(homedir5(), realStatDir);
@@ -52906,7 +52964,9 @@ function realWireDeps() {
52906
52964
  connectorsSrcDir: (h) => connectorsSrcDir(import.meta.url, h),
52907
52965
  readHermesConfig: realReadHermesConfig,
52908
52966
  backupHermesConfig: realBackupHermesConfig,
52909
- writeHermesConfig: realWriteHermesConfig
52967
+ writeHermesConfig: realWriteHermesConfig,
52968
+ readHermesEnv: realReadHermesEnv,
52969
+ appendHermesEnv: realAppendHermesEnv
52910
52970
  };
52911
52971
  }
52912
52972
  function runWireVerb(verb) {
@@ -53365,8 +53425,10 @@ export {
53365
53425
  formatServiceError,
53366
53426
  nodeMajorOk,
53367
53427
  parseVerb,
53428
+ realAppendHermesEnv,
53368
53429
  realCopyConnector,
53369
53430
  realGetHealth,
53431
+ realReadHermesEnv,
53370
53432
  runMode,
53371
53433
  slugFromCredentialPath,
53372
53434
  startThenServe,
@@ -2539,6 +2539,11 @@ export declare class BridgeAgent {
2539
2539
  }>;
2540
2540
  /** Audit 2026-10-30 MED 5: an uploaded file leaves the outbox (no plaintext lingers). Content-free log. */
2541
2541
  private consumeUploaded;
2542
+ /**
2543
+ * Item A (OQ-A1): the owner DM channel id of the signed manifest in force, else null. In force means the owner pin is
2544
+ * set (same guard as applyManifest). No channelOwner, no listChannels heuristic, no fallback.
2545
+ */
2546
+ ownerDmChannelId(): string | null;
2542
2547
  /** Audit 2026-10-30 MED 3: the pinned outbox path, served only on the authenticated GET /v1/vault-outbox. */
2543
2548
  vaultOutboxPath(): string | null;
2544
2549
  /** Download + verify + decrypt a vault file node. Returns the plaintext
@@ -58,6 +58,7 @@ export declare function capabilities(opts: {
58
58
  send_types: string[];
59
59
  webhook_push: boolean;
60
60
  vault_ref: number;
61
+ owner_dm_route: number;
61
62
  };
62
63
  export declare const memoryRecordSchema: z.ZodObject<{
63
64
  conversation_id: z.ZodString;
package/dist/index.js CHANGED
@@ -44477,6 +44477,16 @@ var BridgeAgent = class _BridgeAgent {
44477
44477
  async consumeUploaded(read) {
44478
44478
  if (!this.outboxPin || !await consumeOutboxFile(this.outboxPin, read)) writeServiceLog2("warn", "vault_outbox.consume_failed");
44479
44479
  }
44480
+ /**
44481
+ * Item A (OQ-A1): the owner DM channel id of the signed manifest in force, else null. In force means the owner pin is
44482
+ * set (same guard as applyManifest). No channelOwner, no listChannels heuristic, no fallback.
44483
+ */
44484
+ ownerDmChannelId() {
44485
+ const pin = this.cfg.ownerGrantPin?.pin ?? null;
44486
+ const inForce = pin !== null ? this.toolGrantManifest : null;
44487
+ const id = inForce?.owner_dm_channel_id;
44488
+ return typeof id === "string" && id !== "" ? id : null;
44489
+ }
44480
44490
  /** Audit 2026-10-30 MED 3: the pinned outbox path, served only on the authenticated GET /v1/vault-outbox. */
44481
44491
  vaultOutboxPath() {
44482
44492
  return this.outboxPin?.dir ?? null;
@@ -50032,7 +50042,9 @@ function capabilities(opts) {
50032
50042
  // version this daemon parses into BridgeMessage.vault_refs (never fetched
50033
50043
  // on receipt) and opens with the R4 pins on /v1/vault-read. A daemon
50034
50044
  // without this field skips and acks a link, so the agent never sees it.
50035
- vault_ref: 1
50045
+ vault_ref: 1,
50046
+ // Item A: GET /v1/owner-dm resolves the literal `owner`/`default` cron target from the signed manifest in force.
50047
+ owner_dm_route: 1
50036
50048
  };
50037
50049
  }
50038
50050
  var SEND_CAP = 64 * 1024;
@@ -50216,6 +50228,10 @@ function createHttpServer(agent, opts) {
50216
50228
  if (method === "GET" && path === "/v1/vault-outbox") {
50217
50229
  return json(res, 200, { path: agent.vaultOutboxPath() });
50218
50230
  }
50231
+ if (method === "GET" && path === "/v1/owner-dm") {
50232
+ const channelId = agent.ownerDmChannelId();
50233
+ return channelId === null ? json(res, 404, { error: "owner_dm_unknown" }) : json(res, 200, { channel_id: channelId });
50234
+ }
50219
50235
  if (method === "POST" && path === "/v1/send-attachment") {
50220
50236
  const parsed = sendAttachmentSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
50221
50237
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
@@ -60,6 +60,14 @@ export interface WireDeps extends HermesConfigIoDeps {
60
60
  * tests and never inspects the path for its own sake).
61
61
  */
62
62
  connectorsSrcDir: (harness: Harness) => string;
63
+ /** The Hermes profile env file's text; null when the file does not exist.
64
+ * Throws on any other read failure, so an unreadable file is never
65
+ * mistaken for an absent key (see ensureHermesHomeChannel). */
66
+ readHermesEnv: (path: string) => string | null;
67
+ /** Appends `text` to the env file (creating it 0600 when missing). An
68
+ * append never rewrites another line and never changes an existing
69
+ * file's mode. */
70
+ appendHermesEnv: (path: string, text: string) => void;
63
71
  }
64
72
  /**
65
73
  * Detects an installed harness by probing well-known home-relative dirs.
@@ -136,6 +144,22 @@ export declare function enableHermesConfig(home: string, deps: HermesConfigIoDep
136
144
  ok: boolean;
137
145
  message: string;
138
146
  };
147
+ /** The Hermes cron home-channel env var (the connector registers it as
148
+ * `cron_deliver_env_var`) and the value `wire` sets: the literal `owner`,
149
+ * which the connector resolves only through GET /v1/owner-dm (Item A). */
150
+ export declare const HERMES_HOME_CHANNEL_KEY = "SECURECOMS_HOME_CHANNEL";
151
+ export declare const HERMES_HOME_CHANNEL_LINE = "SECURECOMS_HOME_CHANNEL=owner";
152
+ /** The env file Hermes loads for the profile `wire` targets: `<HERMES_HOME>/.env`,
153
+ * with HERMES_HOME the default `~/.hermes` (the same profile scope as
154
+ * `hermesConfigPath`). */
155
+ export declare function hermesEnvPath(home: string): string;
156
+ /** The text to append so the env file sets the home channel, or null when the
157
+ * key is already assigned (any value, including an owner-set channel id or an
158
+ * empty one: `wire` never overwrites it). Pure. */
159
+ export declare function homeChannelEnvAppendix(text: string | null): string | null;
160
+ /** Sets the Hermes cron home channel when absent. Never fatal to `wire`: the
161
+ * connector is wired without it, and a failure prints the manual line. */
162
+ export declare function ensureHermesHomeChannel(home: string, deps: Pick<WireDeps, "readHermesEnv" | "appendHermesEnv" | "log">): void;
139
163
  /**
140
164
  * Installs the reference connector into the harness's plugin dir and drops
141
165
  * an explicit securecoms.binding.json naming the resolved agent's identity.
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const BRIDGE_VERSION = "0.38.0";
1
+ export declare const BRIDGE_VERSION = "0.40.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@securecoms/agent-bridge",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"