@securecoms/agent-bridge 0.4.0 → 0.6.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 +10 -0
- package/connectors/openclaw/gate.js +41 -0
- package/connectors/openclaw/gate.test.ts +54 -0
- package/connectors/openclaw/openclaw-entry.gate.test.ts +321 -0
- package/connectors/openclaw/openclaw-entry.js +602 -0
- package/connectors/openclaw/openclaw-entry.smoke.test.ts +30 -0
- package/connectors/openclaw/openclaw.plugin.json +25 -0
- package/connectors/openclaw/package.json +18 -0
- package/dist/bin.d.ts +4 -1
- package/dist/bin.js +1216 -76
- package/dist/bridge-agent.d.ts +178 -5
- package/dist/config.d.ts +1 -0
- package/dist/index.js +478 -41
- package/dist/preflight-upgrade.d.cts +2 -0
- package/dist/preflight.cjs +31 -8
- package/dist/service/config.d.ts +1 -0
- package/dist/service/connection-file.d.ts +33 -0
- package/dist/service/fleet.d.ts +27 -0
- package/dist/service/install-prep.d.ts +69 -0
- package/dist/service/resolve-node.d.ts +13 -0
- package/dist/service/status.d.ts +25 -0
- package/dist/service/wire.d.ts +76 -0
- package/dist/ws-server.d.ts +1 -1
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -348,6 +348,16 @@ pm2 start "npx @agentvault/connect-agent-bridge --credential ./agent.json" \
|
|
|
348
348
|
--name securecoms-bridge
|
|
349
349
|
```
|
|
350
350
|
|
|
351
|
+
### Install behavior
|
|
352
|
+
|
|
353
|
+
`agent-bridge install --credential <path>` (launchd on macOS, systemd user unit on Linux) prepares and verifies the service rather than pointing it at your download:
|
|
354
|
+
|
|
355
|
+
- The credential file is validated (readable, valid JSON, all required fields) and **copied into a durable per-agent directory** `~/securecoms-agent-<slug>/` (dir `0700`, file `0600`). The service runs from that copy, so moving or deleting the original download can't break the daemon. A directory already owned by a *different* agent identity is refused, never overwritten.
|
|
356
|
+
- 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.
|
|
357
|
+
- Install **verifies the daemon actually came up** and fails loudly with the log path if it didn't — no silent crash-loops.
|
|
358
|
+
- 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.
|
|
359
|
+
- 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).
|
|
360
|
+
|
|
351
361
|
---
|
|
352
362
|
|
|
353
363
|
## Security
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// packages/connect-agent-bridge/connectors/openclaw/gate.js
|
|
2
|
+
// ESM module (package is "type":"module"). No require / module.exports (audit C1).
|
|
3
|
+
|
|
4
|
+
// bound_identity: explicit binding ONLY. `listConnections` is passed but INTENTIONALLY
|
|
5
|
+
// never consulted — bound_identity must be a positive act, never a directory census
|
|
6
|
+
// (audit C1). The seam exists so a test can prove no auto-discovery, and so the §9 C1
|
|
7
|
+
// mutation ("reintroduce auto-discovery here") has a concrete target that turns red.
|
|
8
|
+
export function resolveBoundIdentity(env, readFileText, bindingPath, listConnections) {
|
|
9
|
+
void listConnections; // never used by design
|
|
10
|
+
const connFile = env && env.SECURECOMS_CONNECTION_FILE;
|
|
11
|
+
if (connFile) {
|
|
12
|
+
try {
|
|
13
|
+
const id = JSON.parse(readFileText(connFile)).identity_id;
|
|
14
|
+
if (id) return id;
|
|
15
|
+
} catch (_) { /* fall through */ }
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const id = JSON.parse(readFileText(bindingPath)).identity_id;
|
|
19
|
+
if (id) return id;
|
|
20
|
+
} catch (_) { /* no binding */ }
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function evaluateGate(input) {
|
|
25
|
+
const { fileIdentity, healthzIdentity, boundIdentity } = input;
|
|
26
|
+
if (!boundIdentity) return { ok: false, reason: "unwired: no binding (refusing to guess)" };
|
|
27
|
+
if (fileIdentity !== healthzIdentity) {
|
|
28
|
+
return { ok: false, reason: "check 1 failed: file identity != daemon identity (stale port reuse)" };
|
|
29
|
+
}
|
|
30
|
+
if (boundIdentity !== healthzIdentity) {
|
|
31
|
+
return { ok: false, reason: "check 2 failed: bound identity != daemon identity (wrong file / compartment)" };
|
|
32
|
+
}
|
|
33
|
+
return { ok: true };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function backoffDelayMs(attempt) {
|
|
37
|
+
const base = 250;
|
|
38
|
+
const cap = 30000;
|
|
39
|
+
const exp = Math.min(cap, base * Math.pow(2, Math.max(0, attempt)));
|
|
40
|
+
return Math.floor(Math.random() * exp); // full jitter
|
|
41
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { resolveBoundIdentity, evaluateGate, backoffDelayMs } from "./gate.js";
|
|
3
|
+
|
|
4
|
+
describe("evaluateGate (two anchors)", () => {
|
|
5
|
+
it("passes when file, healthz, and bound all agree", () => {
|
|
6
|
+
expect(evaluateGate({ fileIdentity: "A", healthzIdentity: "A", boundIdentity: "A" }).ok).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
it("Check 1 fails on stale port reuse (file A, daemon reports B)", () => {
|
|
9
|
+
const r = evaluateGate({ fileIdentity: "A", healthzIdentity: "B", boundIdentity: "A" });
|
|
10
|
+
expect(r.ok).toBe(false);
|
|
11
|
+
expect(r.reason).toMatch(/check 1 failed/i);
|
|
12
|
+
});
|
|
13
|
+
it("Check 2 fails on wrong-file compartment crossing (bound B, self-consistent file A)", () => {
|
|
14
|
+
// The audit C1/wrong-file case: file(A) and daemon(A) agree, but the connector is bound to B.
|
|
15
|
+
const r = evaluateGate({ fileIdentity: "A", healthzIdentity: "A", boundIdentity: "B" });
|
|
16
|
+
expect(r.ok).toBe(false);
|
|
17
|
+
expect(r.reason).toMatch(/check 2 failed/i);
|
|
18
|
+
});
|
|
19
|
+
it("refuses when there is no binding (null) — no directory census", () => {
|
|
20
|
+
const r = evaluateGate({ fileIdentity: "A", healthzIdentity: "A", boundIdentity: null });
|
|
21
|
+
expect(r.ok).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("resolveBoundIdentity", () => {
|
|
26
|
+
const noList = () => [] as string[];
|
|
27
|
+
it("reads identity from SECURECOMS_CONNECTION_FILE", () => {
|
|
28
|
+
const read = (p: string) => p === "/conn/A.json" ? JSON.stringify({ identity_id: "A" }) : "";
|
|
29
|
+
expect(resolveBoundIdentity({ SECURECOMS_CONNECTION_FILE: "/conn/A.json" }, read, "/plugin/securecoms.binding.json", noList)).toBe("A");
|
|
30
|
+
});
|
|
31
|
+
it("reads identity from securecoms.binding.json when no env", () => {
|
|
32
|
+
const read = (p: string) => p === "/plugin/securecoms.binding.json" ? JSON.stringify({ identity_id: "B" }) : "";
|
|
33
|
+
expect(resolveBoundIdentity({}, read, "/plugin/securecoms.binding.json", noList)).toBe("B");
|
|
34
|
+
});
|
|
35
|
+
it("returns null when neither is present (connector must wait, never guess)", () => {
|
|
36
|
+
const read = () => { throw new Error("ENOENT"); };
|
|
37
|
+
expect(resolveBoundIdentity({}, read, "/plugin/securecoms.binding.json", noList)).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
it("C1 REGRESSION: refuses (null) even when the connections dir has exactly one file — NO auto-discovery (audit C1)", () => {
|
|
40
|
+
const read = () => { throw new Error("ENOENT"); }; // no env conn file, no binding
|
|
41
|
+
const listConnections = () => ["A.json"]; // a sole connection file exists
|
|
42
|
+
expect(resolveBoundIdentity({}, read, "/plugin/securecoms.binding.json", listConnections)).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("backoffDelayMs", () => {
|
|
47
|
+
it("is bounded by the cap and never negative", () => {
|
|
48
|
+
for (let i = 0; i < 20; i++) {
|
|
49
|
+
const d = backoffDelayMs(i);
|
|
50
|
+
expect(d).toBeGreaterThanOrEqual(0);
|
|
51
|
+
expect(d).toBeLessThanOrEqual(30000);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// packages/connect-agent-bridge/connectors/openclaw/openclaw-entry.gate.test.ts
|
|
2
|
+
//
|
|
3
|
+
// The security-bearing wiring test: proves the two-anchor gate (gate.js,
|
|
4
|
+
// exercised through openclaw-entry.js's real startAccount path) actually
|
|
5
|
+
// BLOCKS a WS open on an identity mismatch, and actually OPENS one on a
|
|
6
|
+
// match. gate.test.ts only exercises evaluateGate() as a pure function —
|
|
7
|
+
// nothing previously proved the connector calls it before _connect(), or
|
|
8
|
+
// that a failure really suppresses `new WebSocket(...)`.
|
|
9
|
+
//
|
|
10
|
+
// Drives the real plugin.gateway.startAccount(ctx) with a tmp $HOME
|
|
11
|
+
// containing a real (buildConnectionFile-shaped) connection file, and stubs
|
|
12
|
+
// only the two I/O seams the connector touches: global fetch (for
|
|
13
|
+
// GET /healthz) and global WebSocket.
|
|
14
|
+
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
16
|
+
import { mkdtempSync, unlinkSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { buildConnectionFile, writeConnectionFile } from "../../src/service/connection-file.js";
|
|
20
|
+
|
|
21
|
+
const BOUND_ID = "wiring-test-bound-identity";
|
|
22
|
+
const OTHER_ID = "wiring-test-other-identity";
|
|
23
|
+
const PORT = 7099;
|
|
24
|
+
const TOKEN_VALUE = "wiring-test-token-value";
|
|
25
|
+
|
|
26
|
+
const ORIGINAL_CONN_FILE_ENV = process.env.SECURECOMS_CONNECTION_FILE;
|
|
27
|
+
|
|
28
|
+
function waitFor(predicate: () => boolean, opts: { timeout?: number; interval?: number } = {}): Promise<void> {
|
|
29
|
+
const { timeout = 3000, interval = 10 } = opts;
|
|
30
|
+
const start = Date.now();
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const check = () => {
|
|
33
|
+
if (predicate()) return resolve();
|
|
34
|
+
if (Date.now() - start > timeout) return reject(new Error("waitFor: condition never became true"));
|
|
35
|
+
setTimeout(check, interval);
|
|
36
|
+
};
|
|
37
|
+
check();
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Fresh FakeWebSocket class + instance list per test, so nothing leaks
|
|
42
|
+
// between cases.
|
|
43
|
+
function makeFakeWebSocket() {
|
|
44
|
+
const instances: Array<{
|
|
45
|
+
url: string;
|
|
46
|
+
listeners: Record<string, Array<(ev?: unknown) => void>>;
|
|
47
|
+
close: () => void;
|
|
48
|
+
_emit: (type: string, ev?: unknown) => void;
|
|
49
|
+
}> = [];
|
|
50
|
+
|
|
51
|
+
class FakeWebSocket {
|
|
52
|
+
url: string;
|
|
53
|
+
listeners: Record<string, Array<(ev?: unknown) => void>> = {};
|
|
54
|
+
|
|
55
|
+
constructor(url: string) {
|
|
56
|
+
this.url = url;
|
|
57
|
+
instances.push(this);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
addEventListener(type: string, cb: (ev?: unknown) => void) {
|
|
61
|
+
(this.listeners[type] ??= []).push(cb);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
send() {
|
|
65
|
+
/* no-op */
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
close() {
|
|
69
|
+
this._emit("close");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_emit(type: string, ev?: unknown) {
|
|
73
|
+
for (const cb of this.listeners[type] ?? []) cb(ev ?? {});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { FakeWebSocket, instances };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe("openclaw-entry — gate blocks/allows the WS open (wiring)", () => {
|
|
81
|
+
let home: string;
|
|
82
|
+
let connFilePath: string;
|
|
83
|
+
let tokenFilePath: string;
|
|
84
|
+
let logs: string[];
|
|
85
|
+
let healthzIdentity: string;
|
|
86
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
87
|
+
let controller: AbortController;
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
home = mkdtempSync(join(tmpdir(), "sc-wiring-"));
|
|
91
|
+
|
|
92
|
+
tokenFilePath = join(home, "bridge-token");
|
|
93
|
+
writeFileSync(tokenFilePath, TOKEN_VALUE, { mode: 0o600 });
|
|
94
|
+
|
|
95
|
+
const doc = buildConnectionFile({
|
|
96
|
+
identityId: BOUND_ID,
|
|
97
|
+
port: PORT,
|
|
98
|
+
tokenFile: tokenFilePath,
|
|
99
|
+
slug: "wiring-test",
|
|
100
|
+
label: "Wiring Test",
|
|
101
|
+
instanceId: "wiring-test-instance",
|
|
102
|
+
now: new Date().toISOString(),
|
|
103
|
+
});
|
|
104
|
+
writeConnectionFile(home, doc);
|
|
105
|
+
// Same file doubles as the SECURECOMS_CONNECTION_FILE binding source —
|
|
106
|
+
// resolveBoundIdentity only ever reads its .identity_id field.
|
|
107
|
+
connFilePath = join(home, ".securecoms", "connections", `${BOUND_ID}.json`);
|
|
108
|
+
process.env.SECURECOMS_CONNECTION_FILE = connFilePath;
|
|
109
|
+
|
|
110
|
+
logs = [];
|
|
111
|
+
healthzIdentity = OTHER_ID; // default: mismatched, overridden per test
|
|
112
|
+
fetchMock = vi.fn(async (url: string) => {
|
|
113
|
+
if (String(url).endsWith("/healthz")) {
|
|
114
|
+
return { json: async () => ({ identity_id: healthzIdentity, joined_channels: 0 }) };
|
|
115
|
+
}
|
|
116
|
+
throw new Error(`unexpected fetch in wiring test: ${url}`);
|
|
117
|
+
});
|
|
118
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
119
|
+
|
|
120
|
+
controller = new AbortController();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
afterEach(async () => {
|
|
124
|
+
controller.abort();
|
|
125
|
+
vi.unstubAllGlobals();
|
|
126
|
+
vi.unmock("node:os");
|
|
127
|
+
if (ORIGINAL_CONN_FILE_ENV === undefined) delete process.env.SECURECOMS_CONNECTION_FILE;
|
|
128
|
+
else process.env.SECURECOMS_CONNECTION_FILE = ORIGINAL_CONN_FILE_ENV;
|
|
129
|
+
vi.resetModules();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// `homedir()` (node:os) is what openclaw-entry.js uses to locate
|
|
133
|
+
// ~/.securecoms/connections/<id>.json — mocked to point at the tmp HOME
|
|
134
|
+
// instead of the real one. (process.env.HOME alone does not reliably move
|
|
135
|
+
// Node's os.homedir() inside vitest's worker pool — verified empirically.)
|
|
136
|
+
async function loadPlugin() {
|
|
137
|
+
vi.resetModules();
|
|
138
|
+
vi.doMock("node:os", async (importOriginal) => {
|
|
139
|
+
const actual = await importOriginal<typeof import("node:os")>();
|
|
140
|
+
return { ...actual, homedir: () => home };
|
|
141
|
+
});
|
|
142
|
+
const mod = await import("./openclaw-entry.js");
|
|
143
|
+
let capturedPlugin: any;
|
|
144
|
+
mod.default.register({
|
|
145
|
+
runtime: {},
|
|
146
|
+
registerChannel: ({ plugin }: { plugin: unknown }) => {
|
|
147
|
+
capturedPlugin = plugin;
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
expect(capturedPlugin).toBeTruthy();
|
|
151
|
+
return capturedPlugin;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
it("gate REFUSES on identity mismatch: no WS is ever opened, and a reconnect is scheduled", async () => {
|
|
155
|
+
const { FakeWebSocket, instances } = makeFakeWebSocket();
|
|
156
|
+
vi.stubGlobal("WebSocket", FakeWebSocket);
|
|
157
|
+
|
|
158
|
+
const plugin = await loadPlugin();
|
|
159
|
+
const account = plugin.config.resolveAccount({}, "default");
|
|
160
|
+
expect(account.configured).toBe(true);
|
|
161
|
+
expect(account.boundIdentity).toBe(BOUND_ID);
|
|
162
|
+
|
|
163
|
+
healthzIdentity = OTHER_ID; // daemon reports a different identity than bound/file
|
|
164
|
+
|
|
165
|
+
const startPromise = plugin.gateway.startAccount({
|
|
166
|
+
account,
|
|
167
|
+
cfg: {},
|
|
168
|
+
log: (msg: string) => logs.push(String(msg)),
|
|
169
|
+
abortSignal: controller.signal,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await waitFor(() => logs.some((m) => /refusing to attach/i.test(m)));
|
|
173
|
+
|
|
174
|
+
expect(instances.length).toBe(0); // WebSocket constructor NEVER called
|
|
175
|
+
|
|
176
|
+
// Prove a reconnect was actually scheduled (not just logged): the
|
|
177
|
+
// backoff timer fires and _probeAndGate re-probes, calling fetch again.
|
|
178
|
+
await waitFor(() => fetchMock.mock.calls.length >= 2);
|
|
179
|
+
expect(instances.length).toBe(0); // still zero after the retry
|
|
180
|
+
|
|
181
|
+
controller.abort();
|
|
182
|
+
await startPromise;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("gate PASSES on identity match: the WS opens with the expected URL", async () => {
|
|
186
|
+
const { FakeWebSocket, instances } = makeFakeWebSocket();
|
|
187
|
+
vi.stubGlobal("WebSocket", FakeWebSocket);
|
|
188
|
+
|
|
189
|
+
const plugin = await loadPlugin();
|
|
190
|
+
const account = plugin.config.resolveAccount({}, "default");
|
|
191
|
+
expect(account.configured).toBe(true);
|
|
192
|
+
|
|
193
|
+
healthzIdentity = BOUND_ID; // daemon confirms the bound identity
|
|
194
|
+
|
|
195
|
+
const startPromise = plugin.gateway.startAccount({
|
|
196
|
+
account,
|
|
197
|
+
cfg: {},
|
|
198
|
+
log: (msg: string) => logs.push(String(msg)),
|
|
199
|
+
abortSignal: controller.signal,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await waitFor(() => instances.length >= 1);
|
|
203
|
+
|
|
204
|
+
expect(instances.length).toBe(1);
|
|
205
|
+
expect(instances[0].url).toBe(`ws://127.0.0.1:${PORT}/v1/ws?token=${encodeURIComponent(TOKEN_VALUE)}`);
|
|
206
|
+
|
|
207
|
+
controller.abort();
|
|
208
|
+
await startPromise;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("reconnect re-gates: after a healthy open, a mismatched identity blocks the next WS open", async () => {
|
|
212
|
+
const { FakeWebSocket, instances } = makeFakeWebSocket();
|
|
213
|
+
vi.stubGlobal("WebSocket", FakeWebSocket);
|
|
214
|
+
|
|
215
|
+
const plugin = await loadPlugin();
|
|
216
|
+
const account = plugin.config.resolveAccount({}, "default");
|
|
217
|
+
|
|
218
|
+
healthzIdentity = BOUND_ID; // start out matching
|
|
219
|
+
|
|
220
|
+
const startPromise = plugin.gateway.startAccount({
|
|
221
|
+
account,
|
|
222
|
+
cfg: {},
|
|
223
|
+
log: (msg: string) => logs.push(String(msg)),
|
|
224
|
+
abortSignal: controller.signal,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await waitFor(() => instances.length >= 1);
|
|
228
|
+
expect(instances.length).toBe(1);
|
|
229
|
+
const ws = instances[0];
|
|
230
|
+
ws._emit("open");
|
|
231
|
+
|
|
232
|
+
// Daemon identity flips (e.g. a different daemon now answers on the same
|
|
233
|
+
// port) and the WS drops — the close handler must re-probe and re-gate,
|
|
234
|
+
// not just reopen.
|
|
235
|
+
healthzIdentity = OTHER_ID;
|
|
236
|
+
const fetchCallsBeforeClose = fetchMock.mock.calls.length;
|
|
237
|
+
ws._emit("close");
|
|
238
|
+
|
|
239
|
+
await waitFor(() => fetchMock.mock.calls.length > fetchCallsBeforeClose);
|
|
240
|
+
await waitFor(() => logs.some((m) => /refusing to attach/i.test(m)));
|
|
241
|
+
|
|
242
|
+
// No second WebSocket was opened — the mismatch blocked the reconnect.
|
|
243
|
+
expect(instances.length).toBe(1);
|
|
244
|
+
|
|
245
|
+
controller.abort();
|
|
246
|
+
await startPromise;
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("connection file ABSENT: holds, never fetches healthz, never opens a WS", async () => {
|
|
250
|
+
const { FakeWebSocket, instances } = makeFakeWebSocket();
|
|
251
|
+
vi.stubGlobal("WebSocket", FakeWebSocket);
|
|
252
|
+
|
|
253
|
+
// Point SECURECOMS_CONNECTION_FILE at a standalone binding doc (just an
|
|
254
|
+
// identity_id) rather than the real connections-dir file written in
|
|
255
|
+
// beforeEach — resolveBoundIdentity only reads .identity_id from it, so
|
|
256
|
+
// binding can resolve while the ACTUAL
|
|
257
|
+
// ~/.securecoms/connections/<id>.json (what _resolveConnection reads)
|
|
258
|
+
// stays absent. This mirrors a daemon that hasn't started yet.
|
|
259
|
+
const bindingOnlyPath = join(home, "binding-only.json");
|
|
260
|
+
writeFileSync(bindingOnlyPath, JSON.stringify({ identity_id: BOUND_ID }));
|
|
261
|
+
process.env.SECURECOMS_CONNECTION_FILE = bindingOnlyPath;
|
|
262
|
+
// beforeEach wrote a full, valid connection file at this exact path
|
|
263
|
+
// (connFilePath === the real connections-dir path _resolveConnection
|
|
264
|
+
// reads) — remove it so the daemon-not-started case is genuinely absent.
|
|
265
|
+
unlinkSync(connFilePath);
|
|
266
|
+
|
|
267
|
+
const plugin = await loadPlugin();
|
|
268
|
+
const account = plugin.config.resolveAccount({}, "default");
|
|
269
|
+
expect(account.configured).toBe(true);
|
|
270
|
+
expect(account.boundIdentity).toBe(BOUND_ID);
|
|
271
|
+
// No real connection file was written at the connections-dir path, so
|
|
272
|
+
// resolveAccount could not read bridge_url from it.
|
|
273
|
+
expect(account.bridgeUrl).toBeUndefined();
|
|
274
|
+
|
|
275
|
+
const startPromise = plugin.gateway.startAccount({
|
|
276
|
+
account,
|
|
277
|
+
cfg: {},
|
|
278
|
+
log: (msg: string) => logs.push(String(msg)),
|
|
279
|
+
abortSignal: controller.signal,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await waitFor(() => logs.some((m) => /no connection file at/i.test(m)));
|
|
283
|
+
|
|
284
|
+
expect(instances.length).toBe(0); // WebSocket constructor NEVER called
|
|
285
|
+
expect(fetchMock).not.toHaveBeenCalled(); // _probeAndGate returns before the healthz fetch
|
|
286
|
+
|
|
287
|
+
controller.abort();
|
|
288
|
+
await startPromise;
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("connection file PARTIAL (missing bridge_url): holds, never opens a WS", async () => {
|
|
292
|
+
const { FakeWebSocket, instances } = makeFakeWebSocket();
|
|
293
|
+
vi.stubGlobal("WebSocket", FakeWebSocket);
|
|
294
|
+
|
|
295
|
+
// Overwrite the real connection file (written whole in beforeEach) with a
|
|
296
|
+
// partial doc: valid JSON, but no bridge_url/token_file. _resolveConnection
|
|
297
|
+
// must treat this exactly like the absent case (FIX 2) — not-ready
|
|
298
|
+
// sentinel, no TypeError dereferencing conn.bridge_url.
|
|
299
|
+
writeFileSync(connFilePath, JSON.stringify({ identity_id: BOUND_ID }));
|
|
300
|
+
|
|
301
|
+
const plugin = await loadPlugin();
|
|
302
|
+
const account = plugin.config.resolveAccount({}, "default");
|
|
303
|
+
expect(account.configured).toBe(true);
|
|
304
|
+
expect(account.boundIdentity).toBe(BOUND_ID);
|
|
305
|
+
|
|
306
|
+
const startPromise = plugin.gateway.startAccount({
|
|
307
|
+
account,
|
|
308
|
+
cfg: {},
|
|
309
|
+
log: (msg: string) => logs.push(String(msg)),
|
|
310
|
+
abortSignal: controller.signal,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
await waitFor(() => logs.some((m) => /no connection file at/i.test(m)));
|
|
314
|
+
|
|
315
|
+
expect(instances.length).toBe(0); // WebSocket constructor NEVER called
|
|
316
|
+
expect(fetchMock).not.toHaveBeenCalled(); // held before the healthz fetch, same as the absent-file branch
|
|
317
|
+
|
|
318
|
+
controller.abort();
|
|
319
|
+
await startPromise;
|
|
320
|
+
});
|
|
321
|
+
});
|