@mindexec/cli 0.2.151 → 0.2.153

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindexec/cli",
3
- "version": "0.2.151",
3
+ "version": "0.2.153",
4
4
  "description": "MindExec local runtime and bridge CLI",
5
5
  "main": "server.js",
6
6
  "type": "module",
@@ -21,14 +21,15 @@
21
21
  "scripts": {
22
22
  "start": "node launch-bridge.cjs",
23
23
  "dev": "node launch-bridge.cjs --watch",
24
- "test:syntax": "node --check server.js && node --check remote-hub.js && node --check codex-runtime.js && node --check launch-bridge.cjs && node --check port-guard.cjs && node --check scripts/setup-tree-sitter-grammars.mjs && node --check scripts/auth-session-smoke.mjs && node --check scripts/remote-hub-smoke.mjs && node --check scripts/remote-hub-scale-smoke.mjs && node --check scripts/remote-fleet-render-smoke.mjs && node --check scripts/remote-http-smoke.mjs && node --check scripts/remote-frame-ws-smoke.mjs && node --check scripts/remote-agent-ws-smoke.mjs && node --check scripts/remote-agent-managed-smoke.mjs && node --check scripts/remote-registry-follower-smoke.mjs && node --check scripts/remote-agent-package-smoke.mjs && node --check scripts/remote-fast-live-rate-smoke.mjs && node --check scripts/remote-fast-mdm-browser-smoke.mjs",
24
+ "test:syntax": "node --check server.js && node --check remote-hub.js && node --check codex-runtime.js && node --check launch-bridge.cjs && node --check port-guard.cjs && node --check scripts/setup-tree-sitter-grammars.mjs && node --check scripts/auth-session-smoke.mjs && node --check scripts/remote-hub-smoke.mjs && node --check scripts/remote-hub-scale-smoke.mjs && node --check scripts/remote-fleet-render-smoke.mjs && node --check scripts/remote-http-smoke.mjs && node --check scripts/remote-frame-ws-smoke.mjs && node --check scripts/remote-input-ws-smoke.mjs && node --check scripts/remote-agent-ws-smoke.mjs && node --check scripts/remote-agent-managed-smoke.mjs && node --check scripts/remote-registry-follower-smoke.mjs && node --check scripts/remote-agent-package-smoke.mjs && node --check scripts/remote-fast-live-rate-smoke.mjs && node --check scripts/remote-fast-mdm-browser-smoke.mjs",
25
25
  "test:auth": "node scripts/auth-session-smoke.mjs",
26
26
  "test:remote": "node scripts/remote-hub-smoke.mjs",
27
27
  "test:remote:scale": "node scripts/remote-hub-scale-smoke.mjs",
28
28
  "test:remote:render": "node scripts/remote-fleet-render-smoke.mjs",
29
- "test:remote:http": "node scripts/remote-http-smoke.mjs",
30
- "test:remote:frame-ws": "node scripts/remote-frame-ws-smoke.mjs",
31
- "test:remote:agent-ws": "node scripts/remote-agent-ws-smoke.mjs",
29
+ "test:remote:http": "node scripts/remote-http-smoke.mjs",
30
+ "test:remote:frame-ws": "node scripts/remote-frame-ws-smoke.mjs",
31
+ "test:remote:input-ws": "node scripts/remote-input-ws-smoke.mjs",
32
+ "test:remote:agent-ws": "node scripts/remote-agent-ws-smoke.mjs",
32
33
  "test:remote:managed": "node scripts/remote-agent-managed-smoke.mjs",
33
34
  "test:remote:registry": "node scripts/remote-registry-follower-smoke.mjs",
34
35
  "test:remote:package": "node scripts/remote-agent-package-smoke.mjs",
@@ -528,7 +528,8 @@ async function loadCss3DManager() {
528
528
  status: 200,
529
529
  json: async () => ({
530
530
  bridgeToken: 'render-smoke-bridge-token',
531
- remoteFrameWsPath: '/api/remote/frames/ws'
531
+ remoteFrameWsPath: '/api/remote/frames/ws',
532
+ remoteInputWsPath: '/api/remote/input/ws'
532
533
  })
533
534
  };
534
535
  },
@@ -1180,21 +1181,44 @@ try {
1180
1181
  code: 'KeyA',
1181
1182
  repeat: false
1182
1183
  });
1183
- await wait(20);
1184
- const inputCalls = dotNetCalls.filter(call => call.methodName === 'SendRemoteFleetInputFromJs');
1185
- assert.ok(inputCalls.length >= 3, 'control popup should send pointer and keyboard input through JS interop');
1186
- const pointerDown = inputCalls.find(call => call.args[2]?.type === 'pointerDown');
1187
- const keyDown = inputCalls.find(call => call.args[2]?.type === 'keyDown');
1184
+ controlShell.dispatchEvent({
1185
+ type: 'keyup',
1186
+ key: 'A',
1187
+ code: 'KeyA',
1188
+ repeat: false
1189
+ });
1190
+ await wait(50);
1191
+ const inputWsSends = diagnostics.webSocketSends
1192
+ .filter(send => send.url.includes('/api/remote/input/ws'))
1193
+ .map(send => {
1194
+ try {
1195
+ return JSON.parse(send.payload);
1196
+ } catch {
1197
+ return null;
1198
+ }
1199
+ })
1200
+ .filter(Boolean);
1201
+ assert.ok(inputWsSends.length >= 3, 'control popup should send pointer and keyboard input over the input WebSocket');
1202
+ const pointerDown = inputWsSends.find(message => message.input?.type === 'pointerDown');
1203
+ const keyDown = inputWsSends.find(message => message.input?.type === 'keyDown');
1204
+ const keyUp = inputWsSends.find(message => message.input?.type === 'keyUp');
1188
1205
  assert.ok(pointerDown);
1189
1206
  assert.ok(keyDown);
1190
- assert.equal(pointerDown.args[0], 'remote-fleet-render-smoke');
1191
- assert.equal(pointerDown.args[1], patchTarget.DeviceId);
1192
- assert.equal(pointerDown.args[2].button, 'left');
1193
- assert.ok(pointerDown.args[2].controlLeaseId);
1194
- assert.ok(pointerDown.args[2].normalizedX >= 0 && pointerDown.args[2].normalizedX <= 1);
1195
- assert.ok(pointerDown.args[2].normalizedY >= 0 && pointerDown.args[2].normalizedY <= 1);
1196
- assert.equal(keyDown.args[2].key, 'A');
1197
- assert.equal(keyDown.args[2].code, 'KeyA');
1207
+ assert.ok(keyUp);
1208
+ assert.equal(pointerDown.nodeId, 'remote-fleet-render-smoke');
1209
+ assert.equal(pointerDown.deviceId, patchTarget.DeviceId);
1210
+ assert.equal(pointerDown.input.button, 'left');
1211
+ assert.ok(pointerDown.input.controlLeaseId);
1212
+ assert.ok(pointerDown.input.normalizedX >= 0 && pointerDown.input.normalizedX <= 1);
1213
+ assert.ok(pointerDown.input.normalizedY >= 0 && pointerDown.input.normalizedY <= 1);
1214
+ assert.equal(keyDown.input.key, 'A');
1215
+ assert.equal(keyDown.input.code, 'KeyA');
1216
+ assert.equal(keyUp.input.key, 'A');
1217
+ assert.equal(keyUp.input.code, 'KeyA');
1218
+ assert.equal(
1219
+ dotNetCalls.filter(call => call.methodName === 'SendRemoteFleetInputFromJs').length,
1220
+ 0,
1221
+ 'WebSocket input path should not use the slower DotNet input fallback in the happy path');
1198
1222
  manager.closeRemoteFleetControlPopupForTest('render-smoke');
1199
1223
  await wait(20);
1200
1224
  assert.equal(document.body.querySelector('[data-remote-fleet-control-popup="true"]'), null);
@@ -0,0 +1,263 @@
1
+ #!/usr/bin/env node
2
+
3
+ import assert from 'node:assert/strict';
4
+ import net from 'node:net';
5
+ import { spawn } from 'node:child_process';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { WebSocket } from 'ws';
9
+
10
+ const BRIDGE_TOKEN = 'remote-input-ws-smoke-token';
11
+ const PAIR_TOKEN = 'remote-input-ws-pair-token';
12
+ const DEVICE_ID = 'remote-input-ws-device';
13
+
14
+ function wait(ms) {
15
+ return new Promise(resolve => setTimeout(resolve, ms));
16
+ }
17
+
18
+ async function waitFor(predicate, timeoutMs = 10000, label = 'condition') {
19
+ const startedAt = Date.now();
20
+ while (Date.now() - startedAt < timeoutMs) {
21
+ const value = await predicate();
22
+ if (value) {
23
+ return value;
24
+ }
25
+ await wait(50);
26
+ }
27
+
28
+ throw new Error(`Timed out waiting for ${label}.`);
29
+ }
30
+
31
+ async function findFreePort() {
32
+ return await new Promise((resolve, reject) => {
33
+ const server = net.createServer();
34
+ server.unref();
35
+ server.once('error', reject);
36
+ server.listen(0, '127.0.0.1', () => {
37
+ const address = server.address();
38
+ const port = typeof address === 'object' && address ? address.port : 0;
39
+ server.close(() => resolve(port));
40
+ });
41
+ });
42
+ }
43
+
44
+ async function fetchJson(url, options = {}) {
45
+ const response = await fetch(url, {
46
+ ...options,
47
+ headers: {
48
+ ...(options.body ? { 'Content-Type': 'application/json' } : {}),
49
+ 'X-Bridge-Token': BRIDGE_TOKEN,
50
+ ...(options.headers || {})
51
+ }
52
+ });
53
+ const payload = await response.json().catch(() => null);
54
+ return { ok: response.ok, status: response.status, payload };
55
+ }
56
+
57
+ async function waitForBridge(baseUrl, details) {
58
+ const startedAt = Date.now();
59
+ while (Date.now() - startedAt < 30000) {
60
+ try {
61
+ const result = await fetchJson(`${baseUrl}/api/remote/status`);
62
+ if (result.ok && result.payload?.started === true) {
63
+ return result.payload;
64
+ }
65
+ } catch {
66
+ // Server is still starting.
67
+ }
68
+ await wait(100);
69
+ }
70
+
71
+ throw new Error(`Timed out waiting for LocalBridge.\n${details()}`);
72
+ }
73
+
74
+ function spawnBridge({ bridgePort, remoteHubPort }) {
75
+ const bridgeRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
76
+ const env = {
77
+ ...process.env,
78
+ BRIDGE_PORT: String(bridgePort),
79
+ BRIDGE_TOKEN,
80
+ BRIDGE_REQUIRE_TOKEN: '1',
81
+ MINDEXEC_REMOTE_HUB: '1',
82
+ REMOTE_HUB_HOST: '127.0.0.1',
83
+ REMOTE_HUB_PORT: String(remoteHubPort),
84
+ REMOTE_HUB_PAIR_TOKEN: PAIR_TOKEN,
85
+ NO_COLOR: '1'
86
+ };
87
+
88
+ const child = spawn(process.execPath, ['server.js'], {
89
+ cwd: bridgeRoot,
90
+ stdio: ['ignore', 'pipe', 'pipe'],
91
+ windowsHide: true,
92
+ env
93
+ });
94
+
95
+ let stdout = '';
96
+ let stderr = '';
97
+ child.stdout.on('data', chunk => {
98
+ stdout += chunk.toString();
99
+ });
100
+ child.stderr.on('data', chunk => {
101
+ stderr += chunk.toString();
102
+ });
103
+
104
+ const exitPromise = new Promise(resolve => child.once('exit', resolve));
105
+ const stop = async () => {
106
+ if (child.exitCode === null && !child.killed) {
107
+ child.kill('SIGTERM');
108
+ await Promise.race([exitPromise, wait(5000)]);
109
+ if (child.exitCode === null && !child.killed) {
110
+ child.kill('SIGKILL');
111
+ }
112
+ }
113
+ };
114
+
115
+ return {
116
+ baseUrl: `http://127.0.0.1:${bridgePort}`,
117
+ details: () => `stdout=${stdout}\nstderr=${stderr}`,
118
+ stop
119
+ };
120
+ }
121
+
122
+ async function openWebSocket(url) {
123
+ const ws = new WebSocket(url);
124
+ await new Promise((resolve, reject) => {
125
+ ws.once('open', resolve);
126
+ ws.once('error', reject);
127
+ });
128
+ return ws;
129
+ }
130
+
131
+ async function main() {
132
+ const bridgePort = await findFreePort();
133
+ const remoteHubPort = await findFreePort();
134
+ const bridge = spawnBridge({ bridgePort, remoteHubPort });
135
+ let agentWs = null;
136
+ let inputWs = null;
137
+
138
+ try {
139
+ await waitForBridge(bridge.baseUrl, bridge.details);
140
+
141
+ const receivedCommands = [];
142
+ agentWs = await openWebSocket(`ws://127.0.0.1:${remoteHubPort}/remote-agent`);
143
+ agentWs.on('message', data => {
144
+ const text = Buffer.isBuffer(data) ? data.toString('utf8') : String(data || '');
145
+ if (!text.trim().startsWith('{')) {
146
+ return;
147
+ }
148
+
149
+ const message = JSON.parse(text);
150
+ if (message.command === 'input.control') {
151
+ receivedCommands.push(message);
152
+ }
153
+ });
154
+ agentWs.send(JSON.stringify({
155
+ type: 'hello',
156
+ pairToken: PAIR_TOKEN,
157
+ deviceId: DEVICE_ID,
158
+ deviceName: 'Remote Input WS Smoke',
159
+ hostname: 'remote-input-ws-smoke',
160
+ platform: process.platform,
161
+ arch: process.arch,
162
+ pid: process.pid,
163
+ agentVersion: '0.0.0-input-ws-smoke',
164
+ runtime: 'node-smoke',
165
+ capabilities: {
166
+ status: true,
167
+ thumbnail: true,
168
+ liveStream: true,
169
+ binaryFrames: true,
170
+ control: true,
171
+ computerAgent: true,
172
+ taskDispatch: true,
173
+ aiAssist: false
174
+ }
175
+ }));
176
+ agentWs.send(JSON.stringify({
177
+ type: 'status',
178
+ status: {
179
+ uptimeSec: 1,
180
+ totalMem: 2,
181
+ freeMem: 1,
182
+ timestamp: new Date().toISOString()
183
+ }
184
+ }));
185
+
186
+ await waitFor(async () => {
187
+ const result = await fetchJson(`${bridge.baseUrl}/api/remote/devices`);
188
+ return result.payload?.devices?.find(device => device.deviceId === DEVICE_ID && device.connected === true);
189
+ }, 10000, 'device registration');
190
+
191
+ inputWs = await openWebSocket(`${bridge.baseUrl.replace(/^http:/, 'ws:')}/api/remote/input/ws?token=${encodeURIComponent(BRIDGE_TOKEN)}`);
192
+ inputWs.send(JSON.stringify({
193
+ type: 'input',
194
+ deviceId: DEVICE_ID,
195
+ requestId: 'pointer-move',
196
+ input: {
197
+ type: 'pointerMove',
198
+ normalizedX: 0.42,
199
+ normalizedY: 0.54,
200
+ controlLeaseId: 'input-ws-smoke-lease'
201
+ }
202
+ }));
203
+ inputWs.send(JSON.stringify({
204
+ type: 'input',
205
+ deviceId: DEVICE_ID,
206
+ requestId: 'key-down',
207
+ input: {
208
+ type: 'keyDown',
209
+ key: 'A',
210
+ code: 'KeyA',
211
+ repeat: false,
212
+ controlLeaseId: 'input-ws-smoke-lease'
213
+ }
214
+ }));
215
+ inputWs.send(JSON.stringify({
216
+ type: 'input',
217
+ deviceId: DEVICE_ID,
218
+ requestId: 'key-up',
219
+ input: {
220
+ type: 'keyUp',
221
+ key: 'A',
222
+ code: 'KeyA',
223
+ repeat: false,
224
+ controlLeaseId: 'input-ws-smoke-lease'
225
+ }
226
+ }));
227
+
228
+ await waitFor(() => receivedCommands.length >= 3, 10000, 'input.control commands');
229
+ const commandTypes = receivedCommands.map(command => command.payload?.type);
230
+ assert.deepEqual(commandTypes.slice(0, 3), ['pointerMove', 'keyDown', 'keyUp']);
231
+ assert.equal(receivedCommands[0].payload.normalizedX, 0.42);
232
+ assert.equal(receivedCommands[0].payload.normalizedY, 0.54);
233
+ assert.equal(receivedCommands[1].payload.key, 'A');
234
+ assert.equal(receivedCommands[1].payload.code, 'KeyA');
235
+ assert.equal(receivedCommands[2].payload.key, 'A');
236
+ assert.equal(receivedCommands[2].payload.code, 'KeyA');
237
+
238
+ const status = await fetchJson(`${bridge.baseUrl}/api/status?remoteInput=ws`);
239
+ assert.equal(status.ok, true);
240
+ assert.equal(status.payload?.remoteInputWsPath, '/api/remote/input/ws');
241
+ assert.ok(status.payload?.remoteInputWs?.clientCount >= 1, JSON.stringify(status.payload?.remoteInputWs));
242
+ assert.ok(status.payload?.remoteInputWs?.inputsQueued >= 3, JSON.stringify(status.payload?.remoteInputWs));
243
+ assert.ok(status.payload?.remoteInputWs?.pointerMovesQueued >= 1, JSON.stringify(status.payload?.remoteInputWs));
244
+ assert.ok(status.payload?.remoteInputWs?.keyEventsQueued >= 2, JSON.stringify(status.payload?.remoteInputWs));
245
+ assert.equal(status.payload?.remoteInputWs?.lastDeviceId, DEVICE_ID);
246
+ assert.equal(status.payload?.remoteInputWs?.lastInputType, 'keyUp');
247
+
248
+ console.log('Remote input WebSocket smoke OK');
249
+ } finally {
250
+ if (inputWs && inputWs.readyState === WebSocket.OPEN) {
251
+ inputWs.close();
252
+ }
253
+ if (agentWs && agentWs.readyState === WebSocket.OPEN) {
254
+ agentWs.close();
255
+ }
256
+ await bridge.stop();
257
+ }
258
+ }
259
+
260
+ main().catch(error => {
261
+ console.error(error);
262
+ process.exitCode = 1;
263
+ });
@@ -83,10 +83,17 @@ function createRegistryTarget({ endpoint, endpointCandidates, leaseId, active =
83
83
  };
84
84
  }
85
85
 
86
- function startFakeSupabase(getTarget) {
86
+ function startFakeSupabase(getTarget, setTarget = null) {
87
87
  const requests = [];
88
88
  const realtimeClients = new Set();
89
- const server = createServer((req, res) => {
89
+ const server = createServer(async (req, res) => {
90
+ const readBody = () => new Promise(resolve => {
91
+ let body = '';
92
+ req.on('data', chunk => {
93
+ body += chunk.toString();
94
+ });
95
+ req.on('end', () => resolve(body));
96
+ });
90
97
  const parsed = new URL(req.url || '/', 'http://127.0.0.1');
91
98
  requests.push({
92
99
  method: req.method,
@@ -107,6 +114,40 @@ function startFakeSupabase(getTarget) {
107
114
  return;
108
115
  }
109
116
 
117
+ if (req.method === 'POST' && parsed.pathname === '/rest/v1/rpc/set_remote_host_target') {
118
+ assert.equal(String(req.headers.apikey || ''), SUPABASE_KEY);
119
+ assert.equal(String(req.headers.authorization || ''), `Bearer ${ACCESS_TOKEN}`);
120
+ const body = JSON.parse(await readBody() || '{}');
121
+ const now = Date.now();
122
+ const existing = getTarget();
123
+ const sameLease = existing
124
+ && String(existing.lease_id || '') === String(body.p_lease_id || '')
125
+ && String(existing.host_instance_id || '') === String(body.p_host_instance_id || '');
126
+ const expired = !existing?.expires_at || Date.parse(existing.expires_at) <= now;
127
+ if (existing?.active === true && !sameLease && !expired && body.p_takeover !== true) {
128
+ res.writeHead(200, { 'Content-Type': 'application/json' });
129
+ res.end(JSON.stringify([{ ok: false, reason: 'host-target-taken' }]));
130
+ return;
131
+ }
132
+
133
+ setTarget?.({
134
+ user_id: USER_ID,
135
+ active: true,
136
+ endpoint: String(body.p_endpoint || ''),
137
+ endpoint_candidates: Array.isArray(body.p_endpoint_candidates)
138
+ ? body.p_endpoint_candidates
139
+ : [String(body.p_endpoint || '')].filter(Boolean),
140
+ pair_token: String(body.p_pair_token || ''),
141
+ lease_id: String(body.p_lease_id || ''),
142
+ node_id: String(body.p_node_id || ''),
143
+ host_instance_id: String(body.p_host_instance_id || ''),
144
+ expires_at: String(body.p_expires_at || new Date(Date.now() + 60_000).toISOString())
145
+ });
146
+ res.writeHead(200, { 'Content-Type': 'application/json' });
147
+ res.end(JSON.stringify([{ ok: true, reason: 'ok' }]));
148
+ return;
149
+ }
150
+
110
151
  res.writeHead(404, { 'Content-Type': 'application/json' });
111
152
  res.end(JSON.stringify({ error: 'not-found' }));
112
153
  });
@@ -230,7 +271,7 @@ function startFakeSupabase(getTarget) {
230
271
  });
231
272
  }
232
273
 
233
- function spawnBridge({ bridgePort, remoteHubPort, workspacePath, authRoot, label, supabaseUrl = '', follower = false }) {
274
+ function spawnBridge({ bridgePort, remoteHubPort, workspacePath, authRoot, label, supabaseUrl = '', follower = false, publicHost = '' }) {
234
275
  const child = spawn(process.execPath, ['server.js'], {
235
276
  cwd: LOCAL_BRIDGE_DIR,
236
277
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -243,6 +284,7 @@ function spawnBridge({ bridgePort, remoteHubPort, workspacePath, authRoot, label
243
284
  MINDEXEC_REMOTE_HUB: '1',
244
285
  REMOTE_HUB_HOST: '127.0.0.1',
245
286
  REMOTE_HUB_PORT: String(remoteHubPort),
287
+ REMOTE_HUB_PUBLIC_HOST: publicHost,
246
288
  REMOTE_HUB_PAIR_TOKEN: PAIR_TOKEN,
247
289
  WORKSPACE_PATH: workspacePath,
248
290
  MINDEXEC_AUTH_DATA_ROOT: authRoot,
@@ -251,6 +293,7 @@ function spawnBridge({ bridgePort, remoteHubPort, workspacePath, authRoot, label
251
293
  MINDEXEC_REMOTE_REGISTRY_FAST_RETRY_MS: '250',
252
294
  MINDEXEC_REMOTE_REGISTRY_REALTIME_RECONNECT_MS: '250',
253
295
  MINDEXEC_REMOTE_REGISTRY_REALTIME_DEBOUNCE_MS: '100',
296
+ MINDEXEC_REMOTE_HOST_TARGET_RENEW_MS: '5000',
254
297
  SUPABASE_URL: supabaseUrl,
255
298
  SUPABASE_KEY,
256
299
  NO_COLOR: '1'
@@ -353,10 +396,13 @@ function killProcess(pid) {
353
396
  async function main() {
354
397
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'mindexec-remote-registry-smoke-'));
355
398
  let fakeSupabase = null;
399
+ let renewHost = null;
356
400
  let hostA = null;
357
401
  let hostB = null;
358
402
  let client = null;
359
403
 
404
+ const renewHostBridgePort = await findFreePort();
405
+ const renewHostRemotePort = await findFreePort();
360
406
  const hostABridgePort = await findFreePort();
361
407
  const hostARemotePort = await findFreePort();
362
408
  const hostBBridgePort = await findFreePort();
@@ -364,23 +410,82 @@ async function main() {
364
410
  const clientBridgePort = await findFreePort();
365
411
  const clientRemotePort = await findFreePort();
366
412
  const stalePort = await findFreePort();
413
+ const renewPublicHost = '192.0.2.10';
414
+ const renewPublicEndpoint = `${renewPublicHost}:${renewHostRemotePort}`;
367
415
  const hostAEndpoint = `127.0.0.1:${hostARemotePort}`;
368
416
  const hostBEndpoint = `127.0.0.1:${hostBRemotePort}`;
369
417
  const staleEndpoint = `127.0.0.1:${stalePort}`;
370
- let currentTarget = createRegistryTarget({
371
- endpoint: hostAEndpoint,
372
- endpointCandidates: [staleEndpoint, hostAEndpoint],
373
- leaseId: 'lease-a'
374
- });
418
+ let currentTarget = null;
375
419
 
376
420
  try {
377
- fakeSupabase = await startFakeSupabase(() => currentTarget);
421
+ fakeSupabase = await startFakeSupabase(
422
+ () => currentTarget,
423
+ target => {
424
+ currentTarget = target;
425
+ });
378
426
 
427
+ const renewHostAuth = path.join(tempRoot, 'auth-renew-host');
379
428
  const hostAAuth = path.join(tempRoot, 'auth-host-a');
380
429
  const hostBAuth = path.join(tempRoot, 'auth-host-b');
381
430
  const clientAuth = path.join(tempRoot, 'auth-client');
431
+ await writeSession(renewHostAuth);
382
432
  await writeSession(clientAuth);
383
433
 
434
+ renewHost = spawnBridge({
435
+ bridgePort: renewHostBridgePort,
436
+ remoteHubPort: renewHostRemotePort,
437
+ workspacePath: path.join(tempRoot, 'renew-host'),
438
+ authRoot: renewHostAuth,
439
+ label: 'renew-host',
440
+ supabaseUrl: fakeSupabase.url,
441
+ follower: true,
442
+ publicHost: renewPublicHost
443
+ });
444
+ await waitForBridge(renewHost);
445
+
446
+ const renewSetHost = await fetchJson(`${renewHost.baseUrl}/api/remote/host-target`, {
447
+ method: 'POST',
448
+ token: BRIDGE_TOKEN,
449
+ body: JSON.stringify({
450
+ nodeId: 'remote-registry-renew-node',
451
+ enabled: true,
452
+ leaseMs: 60000
453
+ })
454
+ });
455
+ assert.equal(renewSetHost.ok, true, JSON.stringify(renewSetHost.payload));
456
+ assert.equal(renewSetHost.payload?.ok, true, JSON.stringify(renewSetHost.payload));
457
+ assert.equal(renewSetHost.payload?.active, true, JSON.stringify(renewSetHost.payload));
458
+ assert.equal(renewSetHost.payload?.hostTargetRenew?.status, 'active', JSON.stringify(renewSetHost.payload?.hostTargetRenew));
459
+ assert.equal(currentTarget?.endpoint, renewPublicEndpoint, JSON.stringify(currentTarget));
460
+ assert.equal(currentTarget?.node_id, 'remote-registry-renew-node', JSON.stringify(currentTarget));
461
+ await waitFor(() => {
462
+ const publishCount = fakeSupabase.requests.filter(request =>
463
+ request.method === 'POST'
464
+ && request.pathname === '/rest/v1/rpc/set_remote_host_target').length;
465
+ return publishCount >= 2 ? publishCount : null;
466
+ }, 8000, `host-target auto renew publish\n${renewHost.details()}`);
467
+ const renewStatus = await fetchJson(`${renewHost.baseUrl}/api/status`);
468
+ assert.equal(renewStatus.payload?.remoteHostTargetRenew?.status, 'active', JSON.stringify(renewStatus.payload?.remoteHostTargetRenew));
469
+ assert.equal(renewStatus.payload?.remoteHostTargetRenew?.endpoint, renewPublicEndpoint, JSON.stringify(renewStatus.payload?.remoteHostTargetRenew));
470
+
471
+ const renewClear = await fetchJson(`${renewHost.baseUrl}/api/remote/host-target`, {
472
+ method: 'DELETE',
473
+ token: BRIDGE_TOKEN,
474
+ body: JSON.stringify({
475
+ nodeId: 'remote-registry-renew-node'
476
+ })
477
+ });
478
+ assert.equal(renewClear.ok, true, JSON.stringify(renewClear.payload));
479
+ assert.equal(renewClear.payload?.ok, true, JSON.stringify(renewClear.payload));
480
+ await renewHost.stop();
481
+ renewHost = null;
482
+
483
+ currentTarget = createRegistryTarget({
484
+ endpoint: hostAEndpoint,
485
+ endpointCandidates: [staleEndpoint, hostAEndpoint],
486
+ leaseId: 'lease-a'
487
+ });
488
+
384
489
  hostA = spawnBridge({
385
490
  bridgePort: hostABridgePort,
386
491
  remoteHubPort: hostARemotePort,
@@ -465,6 +570,7 @@ async function main() {
465
570
  if (client) await client.stop();
466
571
  if (hostB) await hostB.stop();
467
572
  if (hostA) await hostA.stop();
573
+ if (renewHost) await renewHost.stop();
468
574
  if (fakeSupabase) await fakeSupabase.stop();
469
575
  await rm(tempRoot, { recursive: true, force: true });
470
576
  }