@securecoms/agent-bridge 0.15.0 → 0.16.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
@@ -359,6 +359,7 @@ pm2 start "npx @agentvault/connect-agent-bridge --credential ./agent.json" \
359
359
  - Install **verifies the daemon actually came up** and fails loudly with the log path if it didn't — no silent crash-loops.
360
360
  - 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
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).
362
+ - **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`.
362
363
 
363
364
  ---
364
365
 
@@ -132,7 +132,17 @@ class SecureComsAdapter(BasePlatformAdapter):
132
132
 
133
133
  # ── Connection lifecycle ──────────────────────────────────────────────
134
134
 
135
- async def connect(self) -> bool:
135
+ async def connect(self, *, is_reconnect: bool = False) -> bool:
136
+ """Connect to the local daemon.
137
+
138
+ ``is_reconnect`` mirrors BasePlatformAdapter.connect's signature —
139
+ gateway/run.py passes it as a keyword on EVERY call, cold boot
140
+ included, so an adapter that omits it raises TypeError and the
141
+ platform never attaches. SecureComs holds no server-side update
142
+ queue to preserve across an outage (the daemon is a local WS the
143
+ supervisor re-opens), so the flag carries no behaviour here.
144
+ """
145
+ del is_reconnect # accepted for contract parity; nothing to preserve
136
146
  self._stopped = False
137
147
 
138
148
  # BINDING-STABILITY fold: resolve bound_identity here, ONCE, for the
@@ -539,3 +539,36 @@ class TestCheckRequirementsWiring:
539
539
  monkeypatch.setenv("SECURECOMS_CONNECTION_FILE", str(tmp_path / "does-not-exist.json"))
540
540
 
541
541
  assert adapter.check_requirements() is False
542
+
543
+
544
+ # ---------------------------------------------------------------------------
545
+ # (6) connect() signature contract with BasePlatformAdapter
546
+ # ---------------------------------------------------------------------------
547
+
548
+
549
+ class TestConnectSignatureContract:
550
+ """gateway/run.py ALWAYS calls `adapter.connect(is_reconnect=...)` — on the
551
+ cold first boot as well as on every reconnect. An adapter whose connect()
552
+ does not accept that keyword raises TypeError inside the gateway's
553
+ connect helper, which the reconnect watcher reports as a platform error
554
+ and retries forever: the daemon looks connected to the SecureComs server
555
+ while the harness side never attaches and the agent never answers.
556
+ """
557
+
558
+ def test_connect_accepts_is_reconnect_keyword(self):
559
+ import inspect
560
+
561
+ sig = inspect.signature(adapter.SecureComsAdapter.connect)
562
+ param = sig.parameters.get("is_reconnect")
563
+ assert param is not None, "connect() must accept the is_reconnect keyword"
564
+ assert param.kind is inspect.Parameter.KEYWORD_ONLY
565
+ assert param.default is False
566
+
567
+ @pytest.mark.asyncio
568
+ async def test_connect_is_reconnect_true_reaches_the_unwired_path(self, monkeypatch):
569
+ a, _ = make_adapter(monkeypatch, files={}) # no binding file -> unwired
570
+ ok = await a.connect(is_reconnect=True)
571
+ assert ok is False
572
+ code, _message, retryable = a.fatal_errors[-1]
573
+ assert code == "unwired"
574
+ assert retryable is False
package/dist/bin.js CHANGED
@@ -1409,9 +1409,7 @@ var SUITE2 = "xchacha20poly1305";
1409
1409
  var AAD = new TextEncoder().encode("kekset");
1410
1410
  var enc = new TextEncoder();
1411
1411
  var dec = new TextDecoder();
1412
- function unb64(s) {
1413
- return new Uint8Array(Buffer.from(s, "base64"));
1414
- }
1412
+ var unb64 = base64ToBytes;
1415
1413
  function headerObject(h) {
1416
1414
  return {
1417
1415
  enclaveId: h.enclaveId,
@@ -11000,6 +10998,16 @@ function connectorTargetDir(home, harness) {
11000
10998
  if (harness === "openclaw") return `${home}/.openclaw/local-plugins/securecoms`;
11001
10999
  return `${home}/.hermes/plugins/securecoms`;
11002
11000
  }
11001
+ function checkBindingConflict(prev, next, allowRebind) {
11002
+ if (!prev || prev === next) return { ok: true, rebindFrom: null };
11003
+ if (allowRebind) return { ok: true, rebindFrom: prev };
11004
+ return {
11005
+ ok: false,
11006
+ message: `refusing to rebind: this harness connector is already bound to ${prev}, not ${next}. A harness gateway serves ONE agent, so this would take the harness away from ${prev} rather than add ${next} alongside it.
11007
+ To run both agents, give each one its own harness profile (its own connector dir + binding).
11008
+ To deliberately move THIS harness to ${next}, re-run with --rebind.`
11009
+ };
11010
+ }
11003
11011
  function connectorsSrcDir(importMetaUrl, harness) {
11004
11012
  const pkgRoot = dirname5(dirname5(fileURLToPath(importMetaUrl)));
11005
11013
  return join9(pkgRoot, "connectors", harness);
@@ -11061,8 +11069,18 @@ function runWire(deps) {
11061
11069
  if (!resolved) return 1;
11062
11070
  const { entry, harness } = resolved;
11063
11071
  const targetDir = connectorTargetDir(deps.home, harness);
11072
+ const guard = checkBindingConflict(
11073
+ deps.readBinding(targetDir),
11074
+ entry.identityId,
11075
+ deps.argv.includes("--rebind")
11076
+ );
11077
+ if (!guard.ok) {
11078
+ deps.log(`wire: ${guard.message}`);
11079
+ return 1;
11080
+ }
11064
11081
  deps.copyConnector(deps.connectorsSrcDir(harness), targetDir, harness);
11065
11082
  deps.writeBinding(targetDir, entry.identityId);
11083
+ if (guard.rebindFrom) deps.log(`wire: rebind: ${guard.rebindFrom} -> ${entry.identityId}`);
11066
11084
  if (harness === "hermes") {
11067
11085
  const enabled = enableHermesConfig(deps.home, deps);
11068
11086
  deps.log(`[securecoms] ${enabled.message}`);
@@ -11129,12 +11147,19 @@ function autoWireHarness(input, deps) {
11129
11147
  };
11130
11148
  }
11131
11149
  const targetDir = connectorTargetDir(input.home, "openclaw");
11150
+ const guard = checkBindingConflict(deps.readBinding(targetDir), input.identityId, input.allowRebind ?? false);
11151
+ if (!guard.ok) {
11152
+ return {
11153
+ echo: `[securecoms] the bridge service is installed, but the OpenClaw connector was NOT wired: ${guard.message}`,
11154
+ wired: false,
11155
+ error: true
11156
+ };
11157
+ }
11132
11158
  try {
11133
- const prev = deps.readBinding(targetDir);
11134
11159
  deps.copyConnector(input.connectorsSrcDir, targetDir, "openclaw");
11135
11160
  deps.writeBinding(targetDir, input.identityId);
11136
- const rebind = prev && prev !== input.identityId ? `
11137
- rebind: ${prev} \u2192 ${input.identityId}` : "";
11161
+ const rebind = guard.rebindFrom ? `
11162
+ rebind: ${guard.rebindFrom} \u2192 ${input.identityId}` : "";
11138
11163
  return {
11139
11164
  echo: `[securecoms] OpenClaw connector installed and bound to identity ${input.identityId}.${rebind}
11140
11165
  Now restart OpenClaw to pick up the connector.`,
@@ -11163,8 +11188,15 @@ function autoWireHarness(input, deps) {
11163
11188
  };
11164
11189
  }
11165
11190
  const targetDir = connectorTargetDir(input.home, "hermes");
11191
+ const guard = checkBindingConflict(deps.readBinding(targetDir), input.identityId, input.allowRebind ?? false);
11192
+ if (!guard.ok) {
11193
+ return {
11194
+ echo: `[securecoms] the bridge service is installed, but the Hermes connector was NOT wired: ${guard.message}`,
11195
+ wired: false,
11196
+ error: true
11197
+ };
11198
+ }
11166
11199
  try {
11167
- const prev = deps.readBinding(targetDir);
11168
11200
  deps.copyConnector(input.connectorsSrcDir, targetDir, "hermes");
11169
11201
  deps.writeBinding(targetDir, input.identityId);
11170
11202
  const enabled = enableHermesConfig(input.home, deps);
@@ -11176,8 +11208,8 @@ function autoWireHarness(input, deps) {
11176
11208
  error: true
11177
11209
  };
11178
11210
  }
11179
- const rebind = prev && prev !== input.identityId ? `
11180
- rebind: ${prev} \u2192 ${input.identityId}` : "";
11211
+ const rebind = guard.rebindFrom ? `
11212
+ rebind: ${guard.rebindFrom} \u2192 ${input.identityId}` : "";
11181
11213
  return {
11182
11214
  echo: `[securecoms] Hermes connector installed and bound to identity ${input.identityId}.${rebind}
11183
11215
  ${enabled.message}
@@ -11342,6 +11374,7 @@ async function orchestrateInstall(ctx) {
11342
11374
  identityId: parsed.identityId,
11343
11375
  slug,
11344
11376
  port,
11377
+ allowRebind: ctx.allowRebind,
11345
11378
  home: ctx.home,
11346
11379
  tokenFilePath: result.tokenHint,
11347
11380
  connectorsSrcDir: ctx.connectorsSrcDir
@@ -11741,6 +11774,8 @@ function realWireDeps() {
11741
11774
  argv: realWireArgv(),
11742
11775
  readFleet: realReadFleet,
11743
11776
  copyConnector: realCopyConnector,
11777
+ readBinding: realReadBinding,
11778
+ // bin.ts:252 — feeds checkBindingConflict
11744
11779
  writeBinding: realWriteBinding,
11745
11780
  removeBinding: realRemoveBinding,
11746
11781
  getHealth: realGetHealth,
@@ -11786,6 +11821,7 @@ async function runServiceVerb(verb) {
11786
11821
  label,
11787
11822
  explicitPort: portStr ? Number(portStr) : void 0,
11788
11823
  harness: installHarness,
11824
+ allowRebind: process.argv.includes("--rebind"),
11789
11825
  home: homedir2(),
11790
11826
  connectorsSrcDir: connectorsSrcDir(import.meta.url, installHarness === "hermes" ? "hermes" : "openclaw"),
11791
11827
  autoWireDeps: {
package/dist/index.js CHANGED
@@ -1121,9 +1121,7 @@ var SUITE2 = "xchacha20poly1305";
1121
1121
  var AAD = new TextEncoder().encode("kekset");
1122
1122
  var enc = new TextEncoder();
1123
1123
  var dec = new TextDecoder();
1124
- function unb64(s) {
1125
- return new Uint8Array(Buffer.from(s, "base64"));
1126
- }
1124
+ var unb64 = base64ToBytes;
1127
1125
  function headerObject(h) {
1128
1126
  return {
1129
1127
  enclaveId: h.enclaveId,
@@ -7,6 +7,10 @@ export interface AutoWireInput {
7
7
  home: string;
8
8
  tokenFilePath: string;
9
9
  connectorsSrcDir: string;
10
+ /** Deliberate consent to move an already-bound harness to this identity
11
+ * (`install --rebind`). Absent/false, a conflicting binding REFUSES —
12
+ * see checkBindingConflict in wire.ts for why. */
13
+ allowRebind?: boolean;
10
14
  }
11
15
  export interface AutoWireDeps extends HermesConfigIoDeps {
12
16
  statDir: (p: string) => boolean;
@@ -43,6 +43,7 @@ export interface OrchestrateInstallCtx {
43
43
  label?: string;
44
44
  explicitPort?: number;
45
45
  harness?: string;
46
+ allowRebind?: boolean;
46
47
  home: string;
47
48
  launchAgentsDir?: string;
48
49
  systemdDir?: string;
@@ -38,6 +38,10 @@ export interface WireDeps extends HermesConfigIoDeps {
38
38
  argv: string[];
39
39
  readFleet: () => AgentFleetEntry[];
40
40
  copyConnector: (srcDir: string, dstDir: string, harness: Harness) => void;
41
+ /** The identity the target dir is ALREADY bound to, or null when nothing
42
+ * is bound / the file is missing or malformed. Read BEFORE any write so
43
+ * `checkBindingConflict` can refuse without having touched disk. */
44
+ readBinding: (dstDir: string) => string | null;
41
45
  writeBinding: (dstDir: string, identityId: string) => void;
42
46
  removeBinding: (dstDir: string) => void;
43
47
  getHealth: (port: number) => {
@@ -72,6 +76,32 @@ export declare function detectHarness(home: string, statDir: (p: string) => bool
72
76
  * placeholder value, never the real one).
73
77
  */
74
78
  export declare function connectorTargetDir(home: string, harness: Harness): string;
79
+ /**
80
+ * The binding guard, shared by `wire` and by `install`'s auto-wire.
81
+ *
82
+ * A harness connector registers ONE platform and resolves ONE bound identity
83
+ * for the life of the gateway process (connectors/hermes/adapter.py's
84
+ * `register` + `_bound_identity`), so a connector directory holds exactly one
85
+ * agent. Writing a different identity over an existing binding therefore does
86
+ * not ADD an agent — it silently takes the harness away from whichever agent
87
+ * was already using it, leaving that agent dark while two gateways race for
88
+ * one daemon socket. Observed in the field 2026-09-08: installing a second
89
+ * Hermes agent rebound `~/.hermes/plugins/securecoms` away from the agent the
90
+ * default-profile gateway was serving.
91
+ *
92
+ * So an overwrite must be a deliberate act (`--rebind`), exactly like the
93
+ * binding itself is a deliberate act (audit C1). A first wire and an
94
+ * idempotent same-identity re-wire are always allowed.
95
+ *
96
+ * Pure: callers do the reading and the refusing.
97
+ */
98
+ export declare function checkBindingConflict(prev: string | null, next: string, allowRebind: boolean): {
99
+ ok: true;
100
+ rebindFrom: string | null;
101
+ } | {
102
+ ok: false;
103
+ message: string;
104
+ };
75
105
  /**
76
106
  * Resolves a connector's SOURCE dir at runtime, keyed by harness (`connectors/
77
107
  * <harness>`) — `connectors/` is shipped SOURCE (not compiled — H2), so this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@securecoms/agent-bridge",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",