@p10i/create-nestbox 0.3.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.
@@ -114,9 +114,6 @@ services:
114
114
  dockerfile: ./docker/control/Dockerfile
115
115
  expose:
116
116
  - "4088"
117
- ports:
118
- # Authenticated native runner transport; never bind beyond loopback.
119
- - "127.0.0.1:${NESTBOX_CONTROL_HOST_PORT:-4089}:4089"
120
117
  volumes:
121
118
  - ${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock
122
119
  - type: bind
@@ -125,17 +122,9 @@ services:
125
122
  read_only: true
126
123
  bind:
127
124
  create_host_path: false
128
- - type: bind
129
- source: ${NESTBOX_HOST_TOKEN_FILE:?NESTBOX_HOST_TOKEN_FILE must be configured}
130
- target: /run/secrets/nestbox-host-token
131
- read_only: true
132
- bind:
133
- create_host_path: false
134
125
  environment:
135
126
  - COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}
136
127
  - NESTBOX_CONTROL_SERVICE=control
137
- - NESTBOX_CONTROL_HOST_PORT=4089
138
- - NESTBOX_HOST_TOKEN_FILE=/run/secrets/nestbox-host-token
139
128
  - NESTBOX_CONTROL_LOG_OUTPUT=${NESTBOX_CONTROL_LOG_OUTPUT:-text}
140
129
  - NESTBOX_CONTROL_LOG_MAX_BYTES=${NESTBOX_CONTROL_LOG_MAX_BYTES:-262144}
141
130
  healthcheck:
@@ -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.3.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 both Nestbox and OpenCode through host-based routing. Nginx container port `8080` is only the internal Nchan publisher.
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
 
package/host-runner.mjs CHANGED
@@ -1,64 +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 controlUrl = (process.env.NESTBOX_CONTROL_HOST_URL || fileEnv.NESTBOX_CONTROL_HOST_URL || `http://127.0.0.1:${process.env.NESTBOX_CONTROL_HOST_PORT || fileEnv.NESTBOX_CONTROL_HOST_PORT || '4089'}`).replace(/\/$/, '');
28
- const tokenPath = process.env.NESTBOX_HOST_TOKEN_FILE || fileEnv.NESTBOX_HOST_TOKEN_FILE || '';
29
- const resolvedTokenPath = tokenPath ? await realpath(resolve(tokenPath)).catch(() => resolve(tokenPath)) : '';
30
- function containsPath(root, target) {
31
- const child = relative(resolve(root), target);
32
- return child === '' || (!child.startsWith(`..${sep}`) && child !== '..' && !isAbsolute(child));
33
- }
34
- if (resolvedTokenPath && (containsPath(canonicalWorkspace, resolvedTokenPath) || containsPath(canonicalInstallDirectory, resolvedTokenPath))) {
35
- throw new Error('NESTBOX_HOST_TOKEN_FILE must be outside the workspace and Nestbox installation.');
36
- }
37
- const fileToken = resolvedTokenPath ? await readFile(resolvedTokenPath, 'utf8').then(value => value.trim(), () => '') : '';
38
- const hostToken = fileToken;
39
- if (hostToken.length < 32) throw new Error('NESTBOX_HOST_TOKEN_FILE must contain a token of at least 32 characters.');
40
-
41
- 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()}`;
42
15
  const once = process.argv.slice(2).includes('--once');
43
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.');
44
20
  const npmExecutable = process.env.NESTBOX_HOST_NPM || 'npm';
45
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') : '');
46
22
  const maximumOutputBytes = Number(process.env.NESTBOX_HOST_MAX_OUTPUT_BYTES || 4 * 1024 * 1024);
47
23
  const approvalTtlMs = Number(process.env.NESTBOX_HOST_APPROVAL_TTL_MS || 5 * 60 * 1000);
48
24
  const approvalMaximumAttempts = Number(process.env.NESTBOX_HOST_APPROVAL_ATTEMPTS || 3);
49
25
  const maximumPendingApprovals = Number(process.env.NESTBOX_HOST_APPROVAL_MAX_PENDING || 20);
50
- const hostPlatform = platform();
51
- const hostName = hostname();
52
26
  const approvals = new Map();
53
27
  const activeChildren = new Set();
54
- let heartbeatTimer;
28
+ let activeBridge;
55
29
  let stopping = false;
56
- let shutdownDeadline = 0;
30
+ let announced = false;
31
+ let activeJobId;
32
+ let activeJobPromise;
33
+ let retainedResult;
57
34
 
58
35
  function emit(type, fields = {}) {
59
36
  const event = { ...fields, type, timestamp: new Date().toISOString() };
60
37
  if (jsonOutput) return process.stdout.write(`${JSON.stringify(event)}\n`);
61
- 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}`);
62
39
  else if (type === 'npm.scripts.confirmation_required') {
63
40
  console.log(`\nApproval ${event.requestId}: ${event.operation} npm script "${event.name}"`);
64
41
  if (event.command !== undefined) console.log(`Command: ${event.command}`);
@@ -67,40 +44,6 @@ function emit(type, fields = {}) {
67
44
  else if (type === 'runner.error') console.error(event.error);
68
45
  }
69
46
 
70
- function headers(json = false) {
71
- return {
72
- Authorization: `Bearer ${hostToken}`,
73
- 'X-Nestbox-Runner-Id': runnerId,
74
- 'X-Nestbox-Runner-Pid': String(process.pid),
75
- 'X-Nestbox-Runner-Platform': hostPlatform,
76
- 'X-Nestbox-Runner-Host': hostName,
77
- ...(json ? { 'Content-Type': 'application/json' } : {}),
78
- };
79
- }
80
-
81
- async function controlRequest(method, path, body, timeoutMs = 35000) {
82
- const controller = new AbortController();
83
- const timer = setTimeout(() => controller.abort(), timeoutMs);
84
- try {
85
- const response = await fetch(controlUrl + path, {
86
- method,
87
- headers: headers(body !== undefined),
88
- body: body === undefined ? undefined : JSON.stringify(body),
89
- signal: controller.signal,
90
- });
91
- const text = await response.text();
92
- const value = text ? JSON.parse(text) : null;
93
- if (!response.ok) {
94
- const error = new Error(`Control ${method} ${path} returned ${response.status}: ${JSON.stringify(value)}`);
95
- error.status = response.status;
96
- throw error;
97
- }
98
- return { status: response.status, value };
99
- } finally {
100
- clearTimeout(timer);
101
- }
102
- }
103
-
104
47
  async function packageText() { return readFile(join(workspace, 'package.json'), 'utf8'); }
105
48
  function parsePackageDocument(text) {
106
49
  const document = JSON.parse(text);
@@ -232,57 +175,140 @@ async function stopChildren() {
232
175
  })));
233
176
  }
234
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
+
235
286
  async function main() {
236
- let announced = false;
237
287
  let backoffMs = 500;
238
288
  while (!stopping) {
239
289
  try {
240
- await controlRequest('POST', '/host/heartbeat', {});
241
- if (!announced) {
242
- announced = true;
243
- heartbeatTimer = setInterval(() => controlRequest('POST', '/host/heartbeat', {}).catch(error => emit('runner.error', { error: error.message })), 2000);
244
- emit('runner.ready', { workspace, controlUrl, pid: process.pid, platform: hostPlatform, hostname: hostName });
245
- }
246
- const response = await controlRequest('GET', '/host/jobs/next?timeout=25', undefined, 35000);
290
+ const session = bridgeSession();
291
+ const outcome = await session;
247
292
  backoffMs = 500;
248
- if (response.status === 204) {
249
- if (once) break;
250
- continue;
251
- }
252
- const job = response.value;
253
- let result;
254
- if (stopping) {
255
- const timestamp = new Date().toISOString();
256
- 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 };
257
- }
258
- else try { result = await execute(job); }
259
- 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() }; }
260
- do {
261
- try {
262
- await controlRequest('POST', `/host/jobs/${encodeURIComponent(result.jobId)}/result`, result);
263
- break;
264
- } catch (error) {
265
- if (error.status && error.status < 500) { emit('runner.error', { error: error.message }); break; }
266
- await new Promise(resolveWait => setTimeout(resolveWait, backoffMs));
267
- backoffMs = Math.min(10000, backoffMs * 2);
268
- }
269
- } while (!stopping || Date.now() < shutdownDeadline);
270
- if (once) break;
293
+ if (outcome?.done) break;
271
294
  } catch (error) {
272
- if (error.status === 401) throw error;
295
+ if (error.code === 'STALE_CONTROL_IMAGE') throw error;
273
296
  emit('runner.error', { error: `${error.message}; reconnecting` });
274
297
  await new Promise(resolveWait => setTimeout(resolveWait, backoffMs));
275
298
  backoffMs = Math.min(10000, backoffMs * 2);
276
299
  }
277
300
  }
278
- clearInterval(heartbeatTimer);
279
301
  }
280
302
 
281
303
  for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => {
282
304
  if (stopping) return;
283
305
  stopping = true;
284
- shutdownDeadline = Date.now() + 5000;
285
- 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();
286
312
  stopChildren().catch(error => emit('runner.error', { error: error.message || String(error) }));
287
313
  });
288
- 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.3.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' });