@p10i/create-nestbox 0.4.0 → 0.5.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.
@@ -74,23 +74,6 @@ http {
74
74
  nchan_eventsource_ping_interval 20s;
75
75
  }
76
76
 
77
- # Authenticated native host-runner transport. Control remains private;
78
- # this route shares the one published Nestbox gateway port.
79
- location ^~ /_nestbox/host/ {
80
- resolver 127.0.0.11 ipv6=off valid=10s;
81
- set $nestbox_host_control http://control:4089;
82
- rewrite ^/_nestbox(/host/.*)$ $1 break;
83
- proxy_pass $nestbox_host_control;
84
- proxy_http_version 1.1;
85
- proxy_set_header Host $host;
86
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
87
- proxy_set_header X-Forwarded-Proto $scheme;
88
- proxy_buffering off;
89
- proxy_request_buffering off;
90
- proxy_read_timeout 40s;
91
- client_max_body_size 64m;
92
- }
93
-
94
77
  location ^~ /_nestbox/ {
95
78
  return 404;
96
79
  }
@@ -74,7 +74,7 @@ async function call(name, raw) {
74
74
 
75
75
  function emit(value) { process.stdout.write(`${JSON.stringify(value)}\n`); }
76
76
  async function handle(message) {
77
- if (message.method === 'initialize') return emit({ jsonrpc: '2.0', id: message.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'nestbox-control-mcp', version: '0.4.0' } } });
77
+ if (message.method === 'initialize') return emit({ jsonrpc: '2.0', id: message.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'nestbox-control-mcp', version: '0.5.0' } } });
78
78
  if (message.method === 'initialized') return;
79
79
  if (message.method === 'tools/list') return emit({ jsonrpc: '2.0', id: message.id, result: { tools } });
80
80
  if (message.method === 'tools/call') {
@@ -204,7 +204,7 @@ The default Compose application has five services:
204
204
  - `opencode`: runs this agent, mounts the engine and workspace, and edits the page tree through `/nestbox/home/code`.
205
205
  - `control`: owns the Docker socket and privately brokers label-gated container exec and host-runner jobs.
206
206
 
207
- The default host port is `4180` after installer confirmation. Nginx container port `80` serves Nestbox, OpenCode through host-based routing, and the authenticated `/_nestbox/host/` runner transport. Nginx container port `8080` is only the internal Nchan publisher. No other service publishes a host port.
207
+ The default host port is `4180` after installer confirmation. Nginx container port `80` serves Nestbox and OpenCode through host-based routing. Nginx container port `8080` is only the internal Nchan publisher. The native runner enters `control` with `docker compose exec -T` and uses a versioned stdio bridge to its loopback-only API. No other service publishes a host port.
208
208
 
209
209
  Important files:
210
210
 
@@ -226,7 +226,6 @@ The engine's untracked `/nestbox/.env` controls runtime paths, exposure, authent
226
226
  - `COMPOSE_PROJECT_NAME`: unique installation identifier; collisions can mix unrelated containers and networks.
227
227
  - `BIND_ADDRESS`: loopback for local use or an explicitly approved network bind.
228
228
  - `WEB_PORT`: confirmed host gateway port, normally `4180`.
229
- - `NESTBOX_HOST_TOKEN_FILE`: creator-managed private runner token outside the mounted workspace; only `control` receives it.
230
229
  - `APP_UID` and `APP_GID`: PHP-FPM ownership for writable bind mounts.
231
230
  - `NESTBOX_URL_PREFIX`: public path prefix when an outer proxy strips that prefix before forwarding.
232
231
  - `NESTBOX_OPENCODE_PUBLIC_URL`: explicit public OpenCode origin when `agent.<nestbox-host>` cannot be derived.
package/host-runner.mjs CHANGED
@@ -1,68 +1,41 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createHash, randomBytes, randomUUID } from 'node:crypto';
3
3
  import { hostname, platform } from 'node:os';
4
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
+ import { basename, dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { readFile, realpath, rename, unlink, writeFile } from 'node:fs/promises';
6
+ import { readFile, rename, unlink, writeFile } from 'node:fs/promises';
7
7
 
8
8
  const installDirectory = dirname(fileURLToPath(import.meta.url));
9
9
  const defaultWorkspace = basename(installDirectory) === '.nestbox' ? dirname(installDirectory) : installDirectory;
10
10
  const workspace = resolve(process.env.NESTBOX_HOST_WORKSPACE || defaultWorkspace);
11
- const canonicalInstallDirectory = await realpath(installDirectory).catch(() => resolve(installDirectory));
12
- const canonicalWorkspace = await realpath(workspace).catch(() => workspace);
13
-
14
- function parseEnv(contents) {
15
- const values = {};
16
- for (const line of contents.split(/\r?\n/)) {
17
- const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
18
- if (!match) continue;
19
- let value = match[2].trim();
20
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
21
- values[match[1]] = value;
22
- }
23
- return values;
24
- }
25
-
26
- const fileEnv = await readFile(join(installDirectory, '.env'), 'utf8').then(parseEnv, error => error?.code === 'ENOENT' ? {} : Promise.reject(error));
27
- const bindAddress = process.env.BIND_ADDRESS || fileEnv.BIND_ADDRESS || '127.0.0.1';
28
- const gatewayAddress = bindAddress === '0.0.0.0' ? '127.0.0.1' : bindAddress === '::' ? '::1' : bindAddress;
29
- const gatewayHost = gatewayAddress.includes(':') && !gatewayAddress.startsWith('[') ? `[${gatewayAddress}]` : gatewayAddress;
30
- const webPort = process.env.WEB_PORT || fileEnv.WEB_PORT || '4180';
31
- const controlUrl = (process.env.NESTBOX_CONTROL_HOST_URL || fileEnv.NESTBOX_CONTROL_HOST_URL || `http://${gatewayHost}:${webPort}/_nestbox`).replace(/\/$/, '');
32
- const tokenPath = process.env.NESTBOX_HOST_TOKEN_FILE || fileEnv.NESTBOX_HOST_TOKEN_FILE || '';
33
- const resolvedTokenPath = tokenPath ? await realpath(resolve(tokenPath)).catch(() => resolve(tokenPath)) : '';
34
- function containsPath(root, target) {
35
- const child = relative(resolve(root), target);
36
- return child === '' || (!child.startsWith(`..${sep}`) && child !== '..' && !isAbsolute(child));
37
- }
38
- if (resolvedTokenPath && (containsPath(canonicalWorkspace, resolvedTokenPath) || containsPath(canonicalInstallDirectory, resolvedTokenPath))) {
39
- throw new Error('NESTBOX_HOST_TOKEN_FILE must be outside the workspace and Nestbox installation.');
40
- }
41
- const fileToken = resolvedTokenPath ? await readFile(resolvedTokenPath, 'utf8').then(value => value.trim(), () => '') : '';
42
- const hostToken = fileToken;
43
- if (hostToken.length < 32) throw new Error('NESTBOX_HOST_TOKEN_FILE must contain a token of at least 32 characters.');
44
-
45
- const runnerId = randomUUID();
11
+ const hostPlatform = platform();
12
+ const hostName = hostname();
13
+ // Stable for this runner process; each Docker Compose exec gets a new session ID.
14
+ const runnerId = `runner-${randomUUID()}`;
46
15
  const once = process.argv.slice(2).includes('--once');
47
16
  const jsonOutput = process.argv.slice(2).includes('--json');
17
+ const dockerExecutable = process.env.NESTBOX_HOST_DOCKER || 'docker';
18
+ const dockerPrefixArgs = process.env.NESTBOX_HOST_DOCKER_ARGS ? JSON.parse(process.env.NESTBOX_HOST_DOCKER_ARGS) : [];
19
+ if (!Array.isArray(dockerPrefixArgs) || !dockerPrefixArgs.every(value => typeof value === 'string')) throw new Error('NESTBOX_HOST_DOCKER_ARGS must be a JSON string array.');
48
20
  const npmExecutable = process.env.NESTBOX_HOST_NPM || 'npm';
49
21
  const npmCli = process.env.npm_execpath || (!process.env.NESTBOX_HOST_NPM && process.platform === 'win32' ? join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js') : '');
50
22
  const maximumOutputBytes = Number(process.env.NESTBOX_HOST_MAX_OUTPUT_BYTES || 4 * 1024 * 1024);
51
23
  const approvalTtlMs = Number(process.env.NESTBOX_HOST_APPROVAL_TTL_MS || 5 * 60 * 1000);
52
24
  const approvalMaximumAttempts = Number(process.env.NESTBOX_HOST_APPROVAL_ATTEMPTS || 3);
53
25
  const maximumPendingApprovals = Number(process.env.NESTBOX_HOST_APPROVAL_MAX_PENDING || 20);
54
- const hostPlatform = platform();
55
- const hostName = hostname();
56
26
  const approvals = new Map();
57
27
  const activeChildren = new Set();
58
- let heartbeatTimer;
28
+ let activeBridge;
59
29
  let stopping = false;
60
- let shutdownDeadline = 0;
30
+ let announced = false;
31
+ let activeJobId;
32
+ let activeJobPromise;
33
+ let retainedResult;
61
34
 
62
35
  function emit(type, fields = {}) {
63
36
  const event = { ...fields, type, timestamp: new Date().toISOString() };
64
37
  if (jsonOutput) return process.stdout.write(`${JSON.stringify(event)}\n`);
65
- if (type === 'runner.ready') console.log(`Nestbox host runner connected to ${controlUrl} for ${workspace}`);
38
+ if (type === 'runner.ready') console.log(`Nestbox host runner connected through Docker Compose for ${workspace}`);
66
39
  else if (type === 'npm.scripts.confirmation_required') {
67
40
  console.log(`\nApproval ${event.requestId}: ${event.operation} npm script "${event.name}"`);
68
41
  if (event.command !== undefined) console.log(`Command: ${event.command}`);
@@ -71,40 +44,6 @@ function emit(type, fields = {}) {
71
44
  else if (type === 'runner.error') console.error(event.error);
72
45
  }
73
46
 
74
- function headers(json = false) {
75
- return {
76
- Authorization: `Bearer ${hostToken}`,
77
- 'X-Nestbox-Runner-Id': runnerId,
78
- 'X-Nestbox-Runner-Pid': String(process.pid),
79
- 'X-Nestbox-Runner-Platform': hostPlatform,
80
- 'X-Nestbox-Runner-Host': hostName,
81
- ...(json ? { 'Content-Type': 'application/json' } : {}),
82
- };
83
- }
84
-
85
- async function controlRequest(method, path, body, timeoutMs = 35000) {
86
- const controller = new AbortController();
87
- const timer = setTimeout(() => controller.abort(), timeoutMs);
88
- try {
89
- const response = await fetch(controlUrl + path, {
90
- method,
91
- headers: headers(body !== undefined),
92
- body: body === undefined ? undefined : JSON.stringify(body),
93
- signal: controller.signal,
94
- });
95
- const text = await response.text();
96
- const value = text ? JSON.parse(text) : null;
97
- if (!response.ok) {
98
- const error = new Error(`Control ${method} ${path} returned ${response.status}: ${JSON.stringify(value)}`);
99
- error.status = response.status;
100
- throw error;
101
- }
102
- return { status: response.status, value };
103
- } finally {
104
- clearTimeout(timer);
105
- }
106
- }
107
-
108
47
  async function packageText() { return readFile(join(workspace, 'package.json'), 'utf8'); }
109
48
  function parsePackageDocument(text) {
110
49
  const document = JSON.parse(text);
@@ -236,57 +175,140 @@ async function stopChildren() {
236
175
  })));
237
176
  }
238
177
 
178
+ function sendBridge(child, message) {
179
+ if (!child.stdin.writable) throw new Error('The Docker Compose bridge input closed unexpectedly.');
180
+ child.stdin.write(`${JSON.stringify({ protocolVersion: 1, ...message })}\n`);
181
+ }
182
+
183
+ function bridgeSession() {
184
+ const sessionId = randomUUID();
185
+ const args = [...dockerPrefixArgs, 'compose', 'exec', '-T', 'control', '/usr/local/bin/nestbox-host-bridge'];
186
+ const child = spawn(dockerExecutable, args, { cwd: installDirectory, env: process.env, shell: false, windowsHide: true, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] });
187
+ activeBridge = child;
188
+ let diagnostics = '', buffer = '', settled = false;
189
+ const finish = (resolveSession, rejectSession, error, value) => {
190
+ if (settled) return;
191
+ settled = true;
192
+ if (activeBridge === child) activeBridge = undefined;
193
+ error ? rejectSession(error) : resolveSession(value);
194
+ };
195
+ return new Promise((resolveSession, rejectSession) => {
196
+ child.stdin.on('error', () => {});
197
+ child.stderr.on('data', chunk => {
198
+ diagnostics = (diagnostics + chunk.toString('utf8')).slice(-65536);
199
+ if (!jsonOutput) process.stderr.write(chunk);
200
+ });
201
+ child.stdout.on('data', chunk => {
202
+ buffer += chunk.toString('utf8');
203
+ if (Buffer.byteLength(buffer) > 64 * 1024 * 1024) {
204
+ child.stdin.end();
205
+ finish(resolveSession, rejectSession, new Error('The host bridge protocol line exceeded 64 MiB.'));
206
+ return;
207
+ }
208
+ let newline;
209
+ while ((newline = buffer.indexOf('\n')) >= 0) {
210
+ const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
211
+ if (!line) continue;
212
+ let message;
213
+ try { message = JSON.parse(line); }
214
+ catch { finish(resolveSession, rejectSession, new Error('The host bridge returned invalid JSON.')); child.stdin.end(); return; }
215
+ if (message?.protocolVersion !== 1 || typeof message.type !== 'string') {
216
+ finish(resolveSession, rejectSession, new Error('The host bridge returned an unsupported protocol message.')); child.stdin.end(); return;
217
+ }
218
+ if (message.type === 'ready') {
219
+ if (!announced) {
220
+ announced = true;
221
+ emit('runner.ready', { workspace, pid: process.pid, platform: hostPlatform, hostname: hostName });
222
+ }
223
+ } else if (message.type === 'idle' && once && !retainedResult && !activeJobPromise) {
224
+ child.stdin.end();
225
+ finish(resolveSession, rejectSession, null, { done: true });
226
+ } else if (message.type === 'job') {
227
+ const job = message.job;
228
+ if (retainedResult) {
229
+ if (job?.jobId !== retainedResult.jobId) { finish(resolveSession, rejectSession, new Error('The bridge assigned new work before acknowledging the previous result.')); child.stdin.end(); }
230
+ else sendBridge(child, { type: 'result', result: retainedResult });
231
+ continue;
232
+ }
233
+ if (activeJobPromise && activeJobId !== job?.jobId) {
234
+ finish(resolveSession, rejectSession, new Error('The bridge assigned new work while another host job was still running.')); child.stdin.end(); continue;
235
+ }
236
+ if (!activeJobPromise) {
237
+ activeJobId = job?.jobId;
238
+ activeJobPromise = Promise.resolve().then(async () => {
239
+ if (stopping) {
240
+ const timestamp = new Date().toISOString();
241
+ return { jobId: job?.jobId, exitCode: 1, stdout: '', stderr: 'The host runner stopped before executing the assigned job.', pid: null, hostPlatform, hostName, interrupted: true, startedAt: timestamp, completedAt: timestamp };
242
+ }
243
+ try { return await execute(job); }
244
+ catch (error) { return { jobId: job?.jobId, exitCode: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), pid: null, hostPlatform, hostName, startedAt: new Date().toISOString(), completedAt: new Date().toISOString() }; }
245
+ }).then(result => {
246
+ retainedResult = result;
247
+ activeJobId = undefined;
248
+ activeJobPromise = undefined;
249
+ return result;
250
+ });
251
+ }
252
+ activeJobPromise.then(result => { if (!settled) sendBridge(child, { type: 'result', result }); }).catch(error => {
253
+ child.stdin.end();
254
+ finish(resolveSession, rejectSession, error);
255
+ });
256
+ } else if (message.type === 'result.ack') {
257
+ if (!retainedResult || message.jobId !== retainedResult.jobId) { finish(resolveSession, rejectSession, new Error('The bridge acknowledged an unknown result.')); child.stdin.end(); return; }
258
+ retainedResult = undefined;
259
+ if (once) { child.stdin.end(); finish(resolveSession, rejectSession, null, { done: true }); }
260
+ } else if (message.type === 'gone') {
261
+ if (retainedResult?.jobId === message.jobId) retainedResult = undefined;
262
+ emit('runner.error', { jobId: message.jobId, error: message.error || 'The assigned host job is no longer available.' });
263
+ if (once) { child.stdin.end(); finish(resolveSession, rejectSession, null, { done: true }); }
264
+ } else if (message.type === 'result.rejected') {
265
+ if (retainedResult?.jobId === message.jobId) retainedResult = undefined;
266
+ emit('runner.error', { jobId: message.jobId, error: message.error || 'Control rejected the retained host result.' });
267
+ if (once) { child.stdin.end(); finish(resolveSession, rejectSession, null, { done: true }); }
268
+ }
269
+ }
270
+ });
271
+ child.once('error', error => finish(resolveSession, rejectSession, new Error(`Unable to start Docker Compose: ${error.message}`)));
272
+ child.once('close', code => {
273
+ if (settled) return;
274
+ const stale = /nestbox-host-bridge|not found|no such file/i.test(diagnostics) || code === 126 || code === 127;
275
+ const message = stale
276
+ ? 'The control image is stale or missing the Nestbox host bridge. Run `docker compose build control` and `docker compose up -d control`, then retry.'
277
+ : `Docker Compose host bridge exited with code ${code}${diagnostics.trim() ? `: ${diagnostics.trim()}` : ''}`;
278
+ const error = new Error(message);
279
+ if (stale) error.code = 'STALE_CONTROL_IMAGE';
280
+ finish(resolveSession, rejectSession, stopping ? null : error, { done: stopping });
281
+ });
282
+ sendBridge(child, { type: 'hello', runnerId, sessionId, metadata: { pid: process.pid, platform: hostPlatform, hostname: hostName }, ...(retainedResult ? { retainedResult } : {}) });
283
+ });
284
+ }
285
+
239
286
  async function main() {
240
- let announced = false;
241
287
  let backoffMs = 500;
242
288
  while (!stopping) {
243
289
  try {
244
- await controlRequest('POST', '/host/heartbeat', {});
245
- if (!announced) {
246
- announced = true;
247
- heartbeatTimer = setInterval(() => controlRequest('POST', '/host/heartbeat', {}).catch(error => emit('runner.error', { error: error.message })), 2000);
248
- emit('runner.ready', { workspace, controlUrl, pid: process.pid, platform: hostPlatform, hostname: hostName });
249
- }
250
- const response = await controlRequest('GET', '/host/jobs/next?timeout=25', undefined, 35000);
290
+ const session = bridgeSession();
291
+ const outcome = await session;
251
292
  backoffMs = 500;
252
- if (response.status === 204) {
253
- if (once) break;
254
- continue;
255
- }
256
- const job = response.value;
257
- let result;
258
- if (stopping) {
259
- const timestamp = new Date().toISOString();
260
- result = { jobId: job?.jobId, exitCode: 1, stdout: '', stderr: 'The host runner stopped before executing the assigned job.', pid: null, hostPlatform, hostName, interrupted: true, startedAt: timestamp, completedAt: timestamp };
261
- }
262
- else try { result = await execute(job); }
263
- catch (error) { const message = error instanceof Error ? error.message : String(error); result = { jobId: job?.jobId, exitCode: 1, stdout: '', stderr: message, pid: null, hostPlatform, hostName, startedAt: new Date().toISOString(), completedAt: new Date().toISOString() }; }
264
- do {
265
- try {
266
- await controlRequest('POST', `/host/jobs/${encodeURIComponent(result.jobId)}/result`, result);
267
- break;
268
- } catch (error) {
269
- if (error.status && error.status < 500) { emit('runner.error', { error: error.message }); break; }
270
- await new Promise(resolveWait => setTimeout(resolveWait, backoffMs));
271
- backoffMs = Math.min(10000, backoffMs * 2);
272
- }
273
- } while (!stopping || Date.now() < shutdownDeadline);
274
- if (once) break;
293
+ if (outcome?.done) break;
275
294
  } catch (error) {
276
- if (error.status === 401) throw error;
295
+ if (error.code === 'STALE_CONTROL_IMAGE') throw error;
277
296
  emit('runner.error', { error: `${error.message}; reconnecting` });
278
297
  await new Promise(resolveWait => setTimeout(resolveWait, backoffMs));
279
298
  backoffMs = Math.min(10000, backoffMs * 2);
280
299
  }
281
300
  }
282
- clearInterval(heartbeatTimer);
283
301
  }
284
302
 
285
303
  for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => {
286
304
  if (stopping) return;
287
305
  stopping = true;
288
- shutdownDeadline = Date.now() + 5000;
289
- clearInterval(heartbeatTimer);
306
+ activeBridge?.stdin.end();
307
+ const bridge = activeBridge;
308
+ if (bridge?.pid) setTimeout(() => {
309
+ if (process.platform === 'win32') spawn('taskkill', ['/pid', String(bridge.pid), '/t', '/f'], { windowsHide: true, stdio: 'ignore' });
310
+ else try { process.kill(-bridge.pid, 'SIGTERM'); } catch {}
311
+ }, 2000).unref();
290
312
  stopChildren().catch(error => emit('runner.error', { error: error.message || String(error) }));
291
313
  });
292
- main().catch(error => { emit('runner.error', { error: error.message || String(error) }); clearInterval(heartbeatTimer); stopChildren().finally(() => process.exit(1)); });
314
+ main().catch(error => { emit('runner.error', { error: error.message || String(error) }); stopChildren().finally(() => process.exit(1)); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@p10i/create-nestbox",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Create Nestbox projects.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -11,6 +11,7 @@ const requiredPaths = [
11
11
  'docker/rollup/Dockerfile',
12
12
  'docker/control/Dockerfile',
13
13
  'docker/control/server.py',
14
+ 'docker/control/host_bridge.py',
14
15
  'host-runner.mjs',
15
16
  'package.host.json',
16
17
  'CHANGELOG.md',
package/tests/control.mjs CHANGED
@@ -3,6 +3,7 @@ import { resolve } from 'node:path';
3
3
 
4
4
  const python = process.env.PYTHON || (process.platform === 'win32' ? 'python' : 'python3');
5
5
  const server = resolve('docker/control/server.py');
6
+ const bridge = resolve('docker/control/host_bridge.py');
6
7
  const policyTest = String.raw`
7
8
  import importlib.util
8
9
  spec = importlib.util.spec_from_file_location("control", r"${server}")
@@ -26,24 +27,81 @@ assert "not allowed" in control.policy_error(source, target, "1000")
26
27
  source["Labels"].pop("nestbox.exec.targets")
27
28
  assert "no allowed" in control.policy_error(source, target, "1000")
28
29
  broker = control.HostBroker()
29
- headers = {"X-Nestbox-Runner-Pid": "123", "X-Nestbox-Runner-Platform": "test", "X-Nestbox-Runner-Host": "host"}
30
- broker.touch("runner-test-1234", headers)
30
+ metadata = {"pid": 123, "platform": "test", "hostname": "host"}
31
+ broker.connect("runner-test-1234", "session-test-1234", metadata)
31
32
  assert broker.status()["available"]
32
33
  job = {"jobId": "job-test", "type": "npm.run"}
33
34
  broker.enqueue(job)
34
- assert broker.next("runner-test-1234", headers, 1) == job
35
- broker.complete("runner-test-1234", "job-test", {"jobId": "job-test", "exitCode": 0, "stdout": "", "stderr": ""})
35
+ assert broker.next("runner-test-1234", "session-test-1234", 1) == job
36
+ # A new exec session for the same stable runner recovers the assignment and supersedes the old helper.
37
+ broker.connect("runner-test-1234", "session-test-5678", metadata)
38
+ assert broker.next("runner-test-1234", "session-test-5678", 1) == job
39
+ try:
40
+ broker.touch("runner-test-1234", "session-test-1234")
41
+ raise AssertionError("stale session was accepted")
42
+ except PermissionError:
43
+ pass
44
+ result = {"jobId": "job-test", "exitCode": 0, "stdout": "", "stderr": ""}
45
+ assert broker.complete("runner-test-1234", "session-test-5678", "job-test", result) is False
46
+ assert broker.complete("runner-test-1234", "session-test-5678", "job-test", result) is True
47
+ try:
48
+ broker.complete("runner-test-1234", "session-test-5678", "job-test", {**result, "stdout": "different"})
49
+ raise AssertionError("conflicting replay was accepted")
50
+ except RuntimeError:
51
+ pass
36
52
  assert broker.wait("job-test", 1)["exitCode"] == 0
37
- stale = control.HostBroker()
38
- stale.touch("runner-stale-1234", headers)
39
- stale.enqueue({"jobId": "job-stale", "type": "npm.run"})
40
- assert stale.next("runner-stale-1234", headers, 1)["jobId"] == "job-stale"
53
+ assert broker.complete("runner-test-1234", "session-test-5678", "job-test", result) is True
54
+ try:
55
+ broker.complete("runner-test-1234", "session-test-5678", "job-missing", {"jobId": "job-missing", "exitCode": 0, "stdout": "", "stderr": ""})
56
+ raise AssertionError("missing job result was accepted")
57
+ except FileNotFoundError:
58
+ pass
59
+ original_ttl = control.HOST_HEARTBEAT_TTL
41
60
  control.HOST_HEARTBEAT_TTL = 0
42
- assert stale.wait("job-stale", 1)["interrupted"] is True
43
- pending = control.HostBroker()
44
- pending.touch("runner-pending-1234", headers)
45
- pending.enqueue({"jobId": "job-pending", "type": "npm.run"})
46
- assert pending.wait("job-pending", 1)["interrupted"] is True
61
+ stale_assigned = control.HostBroker()
62
+ stale_assigned.connect("runner-stale-1234", "session-stale-1234", metadata)
63
+ stale_assigned.enqueue({"jobId": "job-stale-assigned", "type": "npm.run"})
64
+ assert stale_assigned.next("runner-stale-1234", "session-stale-1234", 1)["jobId"] == "job-stale-assigned"
65
+ assert stale_assigned.wait("job-stale-assigned", 1)["interrupted"] is True
66
+ stale_pending = control.HostBroker()
67
+ stale_pending.connect("runner-pending-1234", "session-pending-1234", metadata)
68
+ stale_pending.enqueue({"jobId": "job-stale-pending", "type": "npm.run"})
69
+ assert stale_pending.wait("job-stale-pending", 1)["interrupted"] is True
70
+ control.HOST_HEARTBEAT_TTL = original_ttl
71
+ expired = control.HostBroker()
72
+ expired.connect("runner-expired-1234", "session-expired-1234", metadata)
73
+ expired.enqueue({"jobId": "job-expired", "type": "npm.run"})
74
+ assert expired.next("runner-expired-1234", "session-expired-1234", 1)["jobId"] == "job-expired"
75
+ try:
76
+ expired.wait("job-expired", 0)
77
+ raise AssertionError("expired job returned")
78
+ except TimeoutError:
79
+ pass
80
+ try:
81
+ expired.complete("runner-expired-1234", "session-expired-1234", "job-expired", {"jobId": "job-expired", "exitCode": 0, "stdout": "", "stderr": ""})
82
+ raise AssertionError("expired result was accepted")
83
+ except FileNotFoundError:
84
+ pass
85
+ bridge_spec = importlib.util.spec_from_file_location("host_bridge", r"${bridge}")
86
+ host_bridge = importlib.util.module_from_spec(bridge_spec)
87
+ bridge_spec.loader.exec_module(host_bridge)
88
+ sent = []
89
+ host_bridge.send = sent.append
90
+ host_bridge.request = lambda *args, **kwargs: (409, {"error": "conflicting host result replay"})
91
+ host_bridge.deliver_result("runner-test", "session-test", {"jobId": "job-conflict"})
92
+ assert sent[-1]["type"] == "result.rejected"
93
+ host_bridge.request = lambda *args, **kwargs: (400, {"error": "invalid npm run result"})
94
+ host_bridge.deliver_result("runner-test", "session-test", {"jobId": "job-invalid"})
95
+ assert sent[-1]["type"] == "result.rejected"
96
+ host_bridge.request = lambda *args, **kwargs: (410, {"error": "host job expired"})
97
+ host_bridge.deliver_result("runner-test", "session-test", {"jobId": "job-gone"})
98
+ assert sent[-1]["type"] == "gone"
99
+ host_bridge.request = lambda *args, **kwargs: (409, {"error": "host runner session was superseded"})
100
+ try:
101
+ host_bridge.deliver_result("runner-test", "session-test", {"jobId": "job-session"})
102
+ raise AssertionError("superseded session was treated as terminal")
103
+ except RuntimeError:
104
+ pass
47
105
  `;
48
106
 
49
107
  let result = spawnSync(python, ['-c', policyTest], {
@@ -52,6 +110,9 @@ let result = spawnSync(python, ['-c', policyTest], {
52
110
  });
53
111
  if (result.status !== 0) throw new Error(`Control policy test failed: ${result.stderr || result.stdout}`);
54
112
 
113
+ result = spawnSync(python, [bridge], { input: '', encoding: 'utf8', timeout: 2000, env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } });
114
+ if (result.status !== 0 || result.error?.code === 'ETIMEDOUT') throw new Error(`Host bridge did not close cleanly on stdin EOF: ${result.error?.message || result.stderr}`);
115
+
55
116
  result = spawnSync(python, [server], { encoding: 'utf8', env: { ...process.env, COMPOSE_PROJECT_NAME: '', PYTHONDONTWRITEBYTECODE: '1' } });
56
117
  if (result.status === 0 || !result.stderr.includes('COMPOSE_PROJECT_NAME')) throw new Error('Control did not fail closed without a project name.');
57
118
 
package/tests/creator.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join, resolve } from 'node:path';
5
5
 
@@ -19,8 +19,8 @@ try {
19
19
  if (nestedPackage.name !== 'existing' || nestedPackage.scripts.build !== 'node build.mjs') throw new Error('Creator replaced existing package metadata.');
20
20
  if (nestedPackage.scripts.host !== 'node .nestbox/host-runner.mjs') throw new Error('Nested host script has the wrong path.');
21
21
  const nestedEnv = await readFile(join(nested, '.nestbox', '.env'), 'utf8');
22
- const tokenPath = nestedEnv.match(/^NESTBOX_HOST_TOKEN_FILE=(.+)$/m)?.[1]?.trim();
23
- if (!/^WORKSPACE_PATH=\.\.$/m.test(nestedEnv) || !tokenPath || !/^[a-f0-9]{64}$/.test((await readFile(tokenPath, 'utf8')).trim())) throw new Error('Nested runtime authentication was not configured.');
22
+ if (!/^WORKSPACE_PATH=\.\.$/m.test(nestedEnv) || /NESTBOX_HOST_TOKEN/.test(nestedEnv)) throw new Error('Nested runtime configuration still contains host token state.');
23
+ if ((await readdir(fixture)).includes('state')) throw new Error('Creator wrote external host state.');
24
24
  const npmCommand = process.env.npm_execpath ? process.execPath : (process.platform === 'win32' ? 'npm.cmd' : 'npm');
25
25
  const npmArgs = process.env.npm_execpath ? [process.env.npm_execpath, 'run', 'test:host'] : ['run', 'test:host'];
26
26
  const nestedHostTest = spawnSync(npmCommand, npmArgs, { cwd: nested, encoding: 'utf8' });