@securecoms/agent-bridge 0.15.0 → 0.17.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 +61 -1
- package/connectors/hermes/adapter.py +11 -1
- package/connectors/hermes/tests/test_adapter_wiring.py +33 -0
- package/dist/bin.js +67 -16
- package/dist/index.js +1 -3
- package/dist/service/harness-wire.d.ts +4 -0
- package/dist/service/install-prep.d.ts +1 -0
- package/dist/service/wire.d.ts +30 -0
- package/package.json +1 -1
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,8 @@ 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|
|
|
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`.)
|
|
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`.
|
|
362
422
|
|
|
363
423
|
---
|
|
364
424
|
|
|
@@ -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
|
-
|
|
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}`);
|
|
@@ -11107,15 +11125,30 @@ function runUnwire(deps) {
|
|
|
11107
11125
|
function customFacts(input) {
|
|
11108
11126
|
const ws = `ws://127.0.0.1:${input.port ?? "<PORT>"}/v1/ws`;
|
|
11109
11127
|
return [
|
|
11110
|
-
` bridge WS:
|
|
11111
|
-
` token file:
|
|
11128
|
+
` bridge WS: ${ws}`,
|
|
11129
|
+
` token file: ${input.tokenFilePath}`,
|
|
11130
|
+
` agent id: ${input.identityId}`,
|
|
11131
|
+
` discovery: ~/.securecoms/connections/${input.identityId}.json (rewritten on every daemon start)`,
|
|
11112
11132
|
` Bring your own connector: open the WS with the bearer token read from that file.`,
|
|
11133
|
+
` Ack every message you receive; delivery is at-least-once, so dedup by message_id.`,
|
|
11134
|
+
` Before you attach, check the identity gate: the daemon's /healthz identity_id MUST equal`,
|
|
11135
|
+
` the agent id above. Skipping it means a second agent on this host can silently`,
|
|
11136
|
+
` drive the wrong daemon. See "Bring your own agent" in the agent-bridge README.`,
|
|
11113
11137
|
` Protocol reference: the "WebSocket" section of the agent-bridge README.`
|
|
11114
11138
|
].join("\n");
|
|
11115
11139
|
}
|
|
11116
11140
|
function autoWireHarness(input, deps) {
|
|
11117
11141
|
const h = input.harness;
|
|
11118
|
-
if (
|
|
11142
|
+
if (h === "none") return { echo: "", wired: false, error: false };
|
|
11143
|
+
if (!h) {
|
|
11144
|
+
return {
|
|
11145
|
+
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.
|
|
11146
|
+
Bring your own agent:
|
|
11147
|
+
${customFacts(input)}`,
|
|
11148
|
+
wired: false,
|
|
11149
|
+
error: false
|
|
11150
|
+
};
|
|
11151
|
+
}
|
|
11119
11152
|
if (h === "openclaw") {
|
|
11120
11153
|
const retry = `agent-bridge wire --harness openclaw --identity ${input.slug}`;
|
|
11121
11154
|
const parent2 = `${input.home}/.openclaw`;
|
|
@@ -11123,18 +11156,25 @@ function autoWireHarness(input, deps) {
|
|
|
11123
11156
|
return {
|
|
11124
11157
|
echo: `[securecoms] OpenClaw not found at ~/.openclaw \u2014 the bridge service is installed, but the harness was NOT wired.
|
|
11125
11158
|
Install OpenClaw, then re-run install, or run: ${retry}
|
|
11126
|
-
(or re-run install with --harness
|
|
11159
|
+
(or re-run install with --harness universal).`,
|
|
11127
11160
|
wired: false,
|
|
11128
11161
|
error: true
|
|
11129
11162
|
};
|
|
11130
11163
|
}
|
|
11131
11164
|
const targetDir = connectorTargetDir(input.home, "openclaw");
|
|
11165
|
+
const guard = checkBindingConflict(deps.readBinding(targetDir), input.identityId, input.allowRebind ?? false);
|
|
11166
|
+
if (!guard.ok) {
|
|
11167
|
+
return {
|
|
11168
|
+
echo: `[securecoms] the bridge service is installed, but the OpenClaw connector was NOT wired: ${guard.message}`,
|
|
11169
|
+
wired: false,
|
|
11170
|
+
error: true
|
|
11171
|
+
};
|
|
11172
|
+
}
|
|
11132
11173
|
try {
|
|
11133
|
-
const prev = deps.readBinding(targetDir);
|
|
11134
11174
|
deps.copyConnector(input.connectorsSrcDir, targetDir, "openclaw");
|
|
11135
11175
|
deps.writeBinding(targetDir, input.identityId);
|
|
11136
|
-
const rebind =
|
|
11137
|
-
rebind: ${
|
|
11176
|
+
const rebind = guard.rebindFrom ? `
|
|
11177
|
+
rebind: ${guard.rebindFrom} \u2192 ${input.identityId}` : "";
|
|
11138
11178
|
return {
|
|
11139
11179
|
echo: `[securecoms] OpenClaw connector installed and bound to identity ${input.identityId}.${rebind}
|
|
11140
11180
|
Now restart OpenClaw to pick up the connector.`,
|
|
@@ -11157,14 +11197,21 @@ function autoWireHarness(input, deps) {
|
|
|
11157
11197
|
return {
|
|
11158
11198
|
echo: `[securecoms] Hermes not found at ~/.hermes \u2014 the bridge service is installed, but the harness was NOT wired.
|
|
11159
11199
|
Install Hermes, then re-run install, or run: ${retry}
|
|
11160
|
-
(or re-run install with --harness
|
|
11200
|
+
(or re-run install with --harness universal).`,
|
|
11161
11201
|
wired: false,
|
|
11162
11202
|
error: true
|
|
11163
11203
|
};
|
|
11164
11204
|
}
|
|
11165
11205
|
const targetDir = connectorTargetDir(input.home, "hermes");
|
|
11206
|
+
const guard = checkBindingConflict(deps.readBinding(targetDir), input.identityId, input.allowRebind ?? false);
|
|
11207
|
+
if (!guard.ok) {
|
|
11208
|
+
return {
|
|
11209
|
+
echo: `[securecoms] the bridge service is installed, but the Hermes connector was NOT wired: ${guard.message}`,
|
|
11210
|
+
wired: false,
|
|
11211
|
+
error: true
|
|
11212
|
+
};
|
|
11213
|
+
}
|
|
11166
11214
|
try {
|
|
11167
|
-
const prev = deps.readBinding(targetDir);
|
|
11168
11215
|
deps.copyConnector(input.connectorsSrcDir, targetDir, "hermes");
|
|
11169
11216
|
deps.writeBinding(targetDir, input.identityId);
|
|
11170
11217
|
const enabled = enableHermesConfig(input.home, deps);
|
|
@@ -11176,8 +11223,8 @@ function autoWireHarness(input, deps) {
|
|
|
11176
11223
|
error: true
|
|
11177
11224
|
};
|
|
11178
11225
|
}
|
|
11179
|
-
const rebind =
|
|
11180
|
-
rebind: ${
|
|
11226
|
+
const rebind = guard.rebindFrom ? `
|
|
11227
|
+
rebind: ${guard.rebindFrom} \u2192 ${input.identityId}` : "";
|
|
11181
11228
|
return {
|
|
11182
11229
|
echo: `[securecoms] Hermes connector installed and bound to identity ${input.identityId}.${rebind}
|
|
11183
11230
|
${enabled.message}
|
|
@@ -11194,7 +11241,7 @@ function autoWireHarness(input, deps) {
|
|
|
11194
11241
|
};
|
|
11195
11242
|
}
|
|
11196
11243
|
}
|
|
11197
|
-
return { echo: `[securecoms]
|
|
11244
|
+
return { echo: `[securecoms] Universal \u2014 bring your own agent:
|
|
11198
11245
|
${customFacts(input)}`, wired: false, error: false };
|
|
11199
11246
|
}
|
|
11200
11247
|
|
|
@@ -11342,6 +11389,7 @@ async function orchestrateInstall(ctx) {
|
|
|
11342
11389
|
identityId: parsed.identityId,
|
|
11343
11390
|
slug,
|
|
11344
11391
|
port,
|
|
11392
|
+
allowRebind: ctx.allowRebind,
|
|
11345
11393
|
home: ctx.home,
|
|
11346
11394
|
tokenFilePath: result.tokenHint,
|
|
11347
11395
|
connectorsSrcDir: ctx.connectorsSrcDir
|
|
@@ -11741,6 +11789,8 @@ function realWireDeps() {
|
|
|
11741
11789
|
argv: realWireArgv(),
|
|
11742
11790
|
readFleet: realReadFleet,
|
|
11743
11791
|
copyConnector: realCopyConnector,
|
|
11792
|
+
readBinding: realReadBinding,
|
|
11793
|
+
// bin.ts:252 — feeds checkBindingConflict
|
|
11744
11794
|
writeBinding: realWriteBinding,
|
|
11745
11795
|
removeBinding: realRemoveBinding,
|
|
11746
11796
|
getHealth: realGetHealth,
|
|
@@ -11762,7 +11812,7 @@ async function runServiceVerb(verb) {
|
|
|
11762
11812
|
const label = argOrEnv("--label", "SECURECOMS_LABEL");
|
|
11763
11813
|
if (verb === "install" ? !credentialPath : !credentialPath && !label) {
|
|
11764
11814
|
const execPathUsage = verb === "install" ? " [--exec-path <path>]" : "";
|
|
11765
|
-
process.stderr.write(`usage: agent-bridge ${verb} --credential <path> [--label <name>] [--store <path>] [--port N] [--harness openclaw|hermes|
|
|
11815
|
+
process.stderr.write(`usage: agent-bridge ${verb} --credential <path> [--label <name>] [--store <path>] [--port N] [--harness openclaw|hermes|universal]${execPathUsage}
|
|
11766
11816
|
`);
|
|
11767
11817
|
process.exit(2);
|
|
11768
11818
|
}
|
|
@@ -11786,6 +11836,7 @@ async function runServiceVerb(verb) {
|
|
|
11786
11836
|
label,
|
|
11787
11837
|
explicitPort: portStr ? Number(portStr) : void 0,
|
|
11788
11838
|
harness: installHarness,
|
|
11839
|
+
allowRebind: process.argv.includes("--rebind"),
|
|
11789
11840
|
home: homedir2(),
|
|
11790
11841
|
connectorsSrcDir: connectorsSrcDir(import.meta.url, installHarness === "hermes" ? "hermes" : "openclaw"),
|
|
11791
11842
|
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
|
-
|
|
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;
|
package/dist/service/wire.d.ts
CHANGED
|
@@ -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
|