@p10i/create-nestbox 0.2.0 → 0.4.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.
@@ -178,15 +178,15 @@ OpenCode has no Docker socket. Use the control MCP as the only path for commands
178
178
  - `npm_script_change_request` requests an add, edit, or delete. The confirmation code appears only in the host runner terminal.
179
179
  - `npm_script_change_confirm` applies the pending change after the user explicitly supplies that code.
180
180
 
181
- Do not call the control HTTP API directly from OpenCode, write queue files, invoke Docker or SSH as a substitute, or work around a missing MCP capability. PHP cannot invoke MCP, so trusted super-documents may use `nestbox_control_exec()`; default labels restrict PHP-FPM to self-exec. Only OpenCode has `nestbox.npm.allow=true` by default. Root exec requires an explicit target label.
181
+ Do not call the control HTTP API directly from OpenCode, impersonate the native runner, invoke Docker or SSH as a substitute, or work around a missing MCP capability. PHP cannot invoke MCP, so trusted super-documents may use `nestbox_control_exec()`; default labels restrict PHP-FPM to self-exec. Only OpenCode has `nestbox.npm.allow=true` by default. Root exec requires an explicit target label.
182
182
 
183
- The control log is the audit source of truth and requires no observability service. Its host queue stays outside container-mounted workspace paths. Commands still operate in trusted containers and a trusted writable workspace; Docker socket access and editable host-script source mean this is a controlled capability, not a security sandbox.
183
+ The control log is the audit source of truth and requires no observability service. The native runner uses an authenticated, loopback-only long-poll endpoint; no host listener or filesystem queue exists. Commands still operate in trusted containers and a trusted writable workspace; Docker socket access and editable host-script source mean this is a controlled capability, not a security sandbox.
184
184
 
185
185
  ## Git And Commit Policy
186
186
 
187
187
  - Resolve the owning Git root before staging any path.
188
188
  - Read the separately loaded `instance.md` before committing Nestbox work. It records the installation layout, repository roots, and commit policy without exposing `.env`.
189
- - Treat the repository as installation history. Do not assume an upstream remote or upstream commit ancestry exists; use the recorded source revision when reasoning about upgrades.
189
+ - Treat the repository as installation history. Do not assume an upstream remote or upstream commit ancestry exists.
190
190
  - The policy may allow logical commits after verification, require confirmation before each commit, or require leaving changes uncommitted.
191
191
  - Reasonable Nestbox commits include super-documents, Compose, Dockerfiles, configurations, integrations, and dependency files.
192
192
  - Keep commits within one Git root. When one feature spans repositories, create separate commits and report their relationship.
@@ -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, 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.
208
208
 
209
209
  Important files:
210
210
 
@@ -226,6 +226,7 @@ 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.
229
230
  - `APP_UID` and `APP_GID`: PHP-FPM ownership for writable bind mounts.
230
231
  - `NESTBOX_URL_PREFIX`: public path prefix when an outer proxy strips that prefix before forwarding.
231
232
  - `NESTBOX_OPENCODE_PUBLIC_URL`: explicit public OpenCode origin when `agent.<nestbox-host>` cannot be derived.
@@ -1088,16 +1088,15 @@
1088
1088
  "license": "CC0-1.0"
1089
1089
  },
1090
1090
  "node_modules/nanoid": {
1091
- "version": "3.3.12",
1092
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
1093
- "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
1091
+ "version": "3.3.18",
1092
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
1093
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
1094
1094
  "funding": [
1095
1095
  {
1096
1096
  "type": "github",
1097
1097
  "url": "https://github.com/sponsors/ai"
1098
1098
  }
1099
1099
  ],
1100
- "license": "MIT",
1101
1100
  "peer": true,
1102
1101
  "bin": {
1103
1102
  "nanoid": "bin/nanoid.cjs"
@@ -1210,9 +1209,9 @@
1210
1209
  }
1211
1210
  },
1212
1211
  "node_modules/postcss": {
1213
- "version": "8.5.14",
1214
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
1215
- "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
1212
+ "version": "8.5.26",
1213
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
1214
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
1216
1215
  "funding": [
1217
1216
  {
1218
1217
  "type": "opencollective",
@@ -1227,10 +1226,9 @@
1227
1226
  "url": "https://github.com/sponsors/ai"
1228
1227
  }
1229
1228
  ],
1230
- "license": "MIT",
1231
1229
  "peer": true,
1232
1230
  "dependencies": {
1233
- "nanoid": "^3.3.11",
1231
+ "nanoid": "^3.3.17",
1234
1232
  "picocolors": "^1.1.1",
1235
1233
  "source-map-js": "^1.2.1"
1236
1234
  },
@@ -2020,10 +2018,9 @@
2020
2018
  }
2021
2019
  },
2022
2020
  "node_modules/svgo": {
2023
- "version": "2.8.2",
2024
- "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.2.tgz",
2025
- "integrity": "sha512-TyzE4NVGLUFy+H/Uy4N6c3G0HEeprsVfge6Lmq+0FdQQ/zqoVYB62IsBZORsiL+o96s6ff/V6/3UQo/C0cgCAA==",
2026
- "license": "MIT",
2021
+ "version": "2.8.3",
2022
+ "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.3.tgz",
2023
+ "integrity": "sha512-5EZD0pafXX6PphdwOGCiVLDSaV1xyuQao2blHajHLsPxr07q4mmEjdtXEWgG07ae2mIz8Ex2CDXNCTiXhy3Khw==",
2027
2024
  "dependencies": {
2028
2025
  "commander": "^7.2.0",
2029
2026
  "css-select": "^4.1.3",
package/host-runner.mjs CHANGED
@@ -1,30 +1,52 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { createHash, randomBytes } from 'node:crypto';
2
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
3
3
  import { hostname, platform } from 'node:os';
4
- import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
6
+ import { readFile, realpath, 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 runnerConfig = await readFile(join(installDirectory, '.runtime', 'host-runner.json'), 'utf8')
12
- .then(value => JSON.parse(value), error => error?.code === 'ENOENT' ? {} : Promise.reject(error));
13
- const queueRoot = resolve(process.env.NESTBOX_HOST_QUEUE || runnerConfig.queue || join(installDirectory, '.runtime', 'host-runner'));
14
- const queueRelative = relative(workspace, queueRoot);
15
- if (queueRelative === '' || (!queueRelative.startsWith('..') && !isAbsolute(queueRelative))) {
16
- throw new Error('NESTBOX host queue must be outside the mounted workspace.');
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.');
17
40
  }
18
- const requestsDirectory = join(queueRoot, 'requests');
19
- const runningDirectory = join(queueRoot, 'running');
20
- const resultsDirectory = join(queueRoot, 'results');
21
- const heartbeatPath = join(queueRoot, 'heartbeat.json');
22
- const lockPath = join(queueRoot, 'runner.lock');
23
- const takeoverPath = join(queueRoot, 'takeover.lock');
24
- const instanceId = `${process.pid}-${Date.now()}`;
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();
25
46
  const once = process.argv.slice(2).includes('--once');
26
47
  const jsonOutput = process.argv.slice(2).includes('--json');
27
48
  const npmExecutable = process.env.NESTBOX_HOST_NPM || 'npm';
49
+ 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') : '');
28
50
  const maximumOutputBytes = Number(process.env.NESTBOX_HOST_MAX_OUTPUT_BYTES || 4 * 1024 * 1024);
29
51
  const approvalTtlMs = Number(process.env.NESTBOX_HOST_APPROVAL_TTL_MS || 5 * 60 * 1000);
30
52
  const approvalMaximumAttempts = Number(process.env.NESTBOX_HOST_APPROVAL_ATTEMPTS || 3);
@@ -34,11 +56,13 @@ const hostName = hostname();
34
56
  const approvals = new Map();
35
57
  const activeChildren = new Set();
36
58
  let heartbeatTimer;
59
+ let stopping = false;
60
+ let shutdownDeadline = 0;
37
61
 
38
62
  function emit(type, fields = {}) {
39
63
  const event = { ...fields, type, timestamp: new Date().toISOString() };
40
64
  if (jsonOutput) return process.stdout.write(`${JSON.stringify(event)}\n`);
41
- if (type === 'runner.ready') console.log(`Nestbox host runner listening in ${workspace}`);
65
+ if (type === 'runner.ready') console.log(`Nestbox host runner connected to ${controlUrl} for ${workspace}`);
42
66
  else if (type === 'npm.scripts.confirmation_required') {
43
67
  console.log(`\nApproval ${event.requestId}: ${event.operation} npm script "${event.name}"`);
44
68
  if (event.command !== undefined) console.log(`Command: ${event.command}`);
@@ -47,59 +71,71 @@ function emit(type, fields = {}) {
47
71
  else if (type === 'runner.error') console.error(event.error);
48
72
  }
49
73
 
50
- async function atomicJson(path, value) {
51
- const temporary = `${path}.${instanceId}.tmp`;
52
- await writeFile(temporary, `${JSON.stringify(value)}\n`, 'utf8');
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);
53
88
  try {
54
- await rename(temporary, path);
55
- } catch (error) {
56
- if (process.platform !== 'win32' || !['EEXIST', 'EPERM'].includes(error?.code)) throw error;
57
- await unlink(path).catch(() => {});
58
- await rename(temporary, path);
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);
59
105
  }
60
106
  }
61
107
 
62
- async function packageDocument() {
63
- const document = JSON.parse(await readFile(join(workspace, 'package.json'), 'utf8'));
108
+ async function packageText() { return readFile(join(workspace, 'package.json'), 'utf8'); }
109
+ function parsePackageDocument(text) {
110
+ const document = JSON.parse(text);
64
111
  if (!document || typeof document !== 'object' || Array.isArray(document)) throw new Error('package.json must contain a JSON object.');
112
+ if (document.scripts !== undefined && (!document.scripts || typeof document.scripts !== 'object' || Array.isArray(document.scripts))) throw new Error('package.json scripts must be an object.');
65
113
  return document;
66
114
  }
67
-
68
- async function packageHash() {
69
- const contents = await readFile(join(workspace, 'package.json'));
70
- return createHash('sha256').update(contents).digest('hex');
71
- }
72
-
115
+ async function packageDocument() { return parsePackageDocument(await packageText()); }
116
+ function hash(contents) { return createHash('sha256').update(contents).digest('hex'); }
73
117
  async function packageScripts() {
74
118
  const document = await packageDocument();
75
119
  if (document.scripts === undefined) return {};
76
- if (!document.scripts || typeof document.scripts !== 'object' || Array.isArray(document.scripts)) {
77
- throw new Error('package.json scripts must be an object.');
78
- }
120
+ if (!document.scripts || typeof document.scripts !== 'object' || Array.isArray(document.scripts)) throw new Error('package.json scripts must be an object.');
79
121
  return document.scripts;
80
122
  }
81
123
 
82
124
  async function writePackageJson(document, expectedHash) {
83
125
  const path = join(workspace, 'package.json');
84
- const temporary = `${path}.${instanceId}.tmp`;
126
+ const temporary = `${path}.${runnerId}.tmp`;
85
127
  await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
86
- if (expectedHash && await packageHash() !== expectedHash) {
128
+ if (hash(await packageText()) !== expectedHash) {
87
129
  await unlink(temporary).catch(() => {});
88
130
  throw new Error('package.json changed while the approved update was being prepared.');
89
131
  }
90
- try {
91
- await rename(temporary, path);
92
- } catch (error) {
132
+ try { await rename(temporary, path); }
133
+ catch (error) {
93
134
  if (process.platform !== 'win32' || !['EEXIST', 'EPERM'].includes(error?.code)) throw error;
94
- const backup = `${path}.${instanceId}.backup`;
135
+ const backup = `${path}.${runnerId}.backup`;
95
136
  await rename(path, backup);
96
- try {
97
- await rename(temporary, path);
98
- await unlink(backup);
99
- } catch (replaceError) {
100
- await rename(backup, path).catch(() => {});
101
- throw replaceError;
102
- }
137
+ try { await rename(temporary, path); await unlink(backup); }
138
+ catch (replaceError) { await rename(backup, path).catch(() => {}); throw replaceError; }
103
139
  }
104
140
  }
105
141
 
@@ -110,9 +146,7 @@ function validateScriptChange(job, scripts) {
110
146
  const exists = Object.hasOwn(scripts, job.name);
111
147
  if (job.operation === 'add' && exists) throw new Error(`npm script already exists: ${job.name}`);
112
148
  if (job.operation !== 'add' && !exists) throw new Error(`npm script is not defined: ${job.name}`);
113
- if (job.operation !== 'delete' && (typeof job.command !== 'string' || !job.command.trim() || job.command.length > 8192)) {
114
- throw new Error('A non-empty command of at most 8192 characters is required.');
115
- }
149
+ if (job.operation !== 'delete' && (typeof job.command !== 'string' || !job.command.trim() || job.command.length > 8192)) throw new Error('A non-empty command of at most 8192 characters is required.');
116
150
  if (job.operation === 'delete' && job.command !== undefined) throw new Error('Delete does not accept a command.');
117
151
  }
118
152
 
@@ -126,47 +160,31 @@ async function requestScriptChange(job) {
126
160
  pruneApprovals();
127
161
  if (approvals.has(job.requestId)) throw new Error('The request ID is already pending.');
128
162
  if (approvals.size >= maximumPendingApprovals) throw new Error('Too many npm script approvals are pending.');
129
- const scripts = await packageScripts();
163
+ const text = await packageText();
164
+ const scripts = parsePackageDocument(text).scripts || {};
130
165
  validateScriptChange(job, scripts);
131
166
  const code = randomBytes(6).toString('base64url').slice(0, 8).toUpperCase();
132
167
  const expiresAtMs = Date.now() + approvalTtlMs;
133
- approvals.set(job.requestId, {
134
- ...job,
135
- expectedCommand: Object.hasOwn(scripts, job.name) ? String(scripts[job.name]) : null,
136
- expectedPackageHash: await packageHash(),
137
- code,
138
- attempts: 0,
139
- expiresAtMs,
140
- });
168
+ approvals.set(job.requestId, { ...job, expectedCommand: Object.hasOwn(scripts, job.name) ? String(scripts[job.name]) : null, expectedPackageHash: hash(text), code, attempts: 0, expiresAtMs });
141
169
  const expiresAt = new Date(expiresAtMs).toISOString();
142
170
  emit('npm.scripts.confirmation_required', { ...job, code, expiresAt });
143
171
  return { jobId: job.jobId, status: 'confirmation_required', requestId: job.requestId, operation: job.operation, name: job.name, expiresAt };
144
172
  }
145
173
 
146
174
  async function confirmScriptChange(job) {
147
- if (typeof job.requestId !== 'string' || typeof job.code !== 'string') throw new Error('A request ID and confirmation code are required.');
148
175
  pruneApprovals();
149
176
  const approval = approvals.get(job.requestId);
150
177
  if (!approval) throw new Error('The npm script approval was not found or has expired.');
151
178
  approval.attempts += 1;
152
- if (job.code.trim().toUpperCase() !== approval.code) {
179
+ if (String(job.code).trim().toUpperCase() !== approval.code) {
153
180
  if (approval.attempts >= approvalMaximumAttempts) approvals.delete(job.requestId);
154
181
  throw new Error(approval.attempts >= approvalMaximumAttempts ? 'Invalid confirmation code; the approval has been cancelled.' : 'Invalid confirmation code.');
155
182
  }
156
- const document = await packageDocument();
157
- if (await packageHash() !== approval.expectedPackageHash) {
158
- approvals.delete(job.requestId);
159
- throw new Error('package.json changed after approval was requested; request a new approval.');
160
- }
161
- const scripts = document.scripts || {};
162
- const currentCommand = Object.hasOwn(scripts, approval.name) ? String(scripts[approval.name]) : null;
163
- if (currentCommand !== approval.expectedCommand) {
164
- approvals.delete(job.requestId);
165
- throw new Error('package.json changed after approval was requested; request a new approval.');
166
- }
183
+ const text = await packageText();
184
+ if (hash(text) !== approval.expectedPackageHash) { approvals.delete(job.requestId); throw new Error('package.json changed after approval was requested; request a new approval.'); }
185
+ const document = parsePackageDocument(text), scripts = document.scripts || {};
167
186
  validateScriptChange(approval, scripts);
168
- if (approval.operation === 'delete') delete scripts[approval.name];
169
- else scripts[approval.name] = approval.command;
187
+ if (approval.operation === 'delete') delete scripts[approval.name]; else scripts[approval.name] = approval.command;
170
188
  document.scripts = scripts;
171
189
  await writePackageJson(document, approval.expectedPackageHash);
172
190
  approvals.delete(job.requestId);
@@ -175,12 +193,10 @@ async function confirmScriptChange(job) {
175
193
  }
176
194
 
177
195
  function appendLimited(chunks, chunk, state) {
178
- const buffer = Buffer.from(chunk);
179
- state.totalBytes += buffer.length;
196
+ const buffer = Buffer.from(chunk); state.totalBytes += buffer.length;
180
197
  const available = Math.max(0, maximumOutputBytes - state.bytes);
181
198
  if (available) chunks.push(buffer.subarray(0, available));
182
- state.bytes += Math.min(buffer.length, available);
183
- if (buffer.length > available) state.truncated = true;
199
+ state.bytes += Math.min(buffer.length, available); if (buffer.length > available) state.truncated = true;
184
200
  }
185
201
 
186
202
  async function runNpm(job) {
@@ -189,24 +205,17 @@ async function runNpm(job) {
189
205
  if (job.script === 'host') throw new Error('The host bridge cannot invoke itself.');
190
206
  if (!Object.hasOwn(scripts, job.script)) throw new Error(`npm script is not defined: ${job.script}`);
191
207
  const args = ['run', job.script, ...(job.args.length ? ['--', ...job.args] : [])];
192
- const executable = process.env.npm_execpath ? process.execPath : npmExecutable;
193
- const executableArgs = process.env.npm_execpath ? [process.env.npm_execpath, ...args] : args;
208
+ const executable = npmCli ? process.execPath : npmExecutable;
209
+ const executableArgs = npmCli ? [npmCli, ...args] : args;
194
210
  const startedAt = new Date().toISOString();
195
- return await new Promise(resolveJob => {
211
+ return new Promise(resolveJob => {
196
212
  const child = spawn(executable, executableArgs, { cwd: workspace, env: process.env, shell: false, windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
197
213
  activeChildren.add(child);
198
- const stdout = [], stderr = [];
199
- const out = { bytes: 0, totalBytes: 0, truncated: false }, err = { bytes: 0, totalBytes: 0, truncated: false };
200
- child.stdout.on('data', chunk => appendLimited(stdout, chunk, out));
201
- child.stderr.on('data', chunk => appendLimited(stderr, chunk, err));
202
- const result = (exitCode, message = '') => resolveJob({
203
- jobId: job.jobId, exitCode, stdout: Buffer.concat(stdout).toString('utf8'), stderr: message || Buffer.concat(stderr).toString('utf8'),
204
- pid: child.pid ?? null, hostPlatform, hostName, stdoutTruncated: out.truncated, stderrTruncated: err.truncated,
205
- stdoutTotalBytes: out.totalBytes, stderrTotalBytes: message ? Buffer.byteLength(message) : err.totalBytes,
206
- startedAt, completedAt: new Date().toISOString(),
207
- });
208
- child.on('error', error => { activeChildren.delete(child); result(1, error.message); });
209
- child.on('close', code => { activeChildren.delete(child); result(Number.isInteger(code) ? code : 1); });
214
+ const stdout = [], stderr = [], out = { bytes: 0, totalBytes: 0, truncated: false }, err = { bytes: 0, totalBytes: 0, truncated: false };
215
+ child.stdout.on('data', chunk => appendLimited(stdout, chunk, out)); child.stderr.on('data', chunk => appendLimited(stderr, chunk, err));
216
+ const finish = (exitCode, message = '') => resolveJob({ jobId: job.jobId, exitCode, stdout: Buffer.concat(stdout).toString('utf8'), stderr: message || Buffer.concat(stderr).toString('utf8'), pid: child.pid ?? null, hostPlatform, hostName, stdoutTruncated: out.truncated, stderrTruncated: err.truncated, stdoutTotalBytes: out.totalBytes, stderrTotalBytes: message ? Buffer.byteLength(message) : err.totalBytes, startedAt, completedAt: new Date().toISOString() });
217
+ child.on('error', error => { activeChildren.delete(child); finish(1, error.message); });
218
+ child.on('close', code => { activeChildren.delete(child); finish(Number.isInteger(code) ? code : 1); });
210
219
  });
211
220
  }
212
221
 
@@ -218,84 +227,66 @@ async function execute(job) {
218
227
  throw new Error('Unsupported host job type.');
219
228
  }
220
229
 
221
- async function processRequest(path) {
222
- const name = basename(path);
223
- const runningPath = join(runningDirectory, name);
224
- try { await rename(path, runningPath); } catch (error) { if (['ENOENT', 'EACCES', 'EPERM'].includes(error?.code)) return; throw error; }
225
- const claimedJobId = name.replace(/\.json$/, '');
226
- let result;
227
- try {
228
- const job = JSON.parse(await readFile(runningPath, 'utf8'));
229
- if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(claimedJobId) || job?.jobId !== claimedJobId) throw new Error('Host job ID must match its safe queue filename.');
230
- result = await execute(job);
231
- } catch (error) {
232
- const message = error instanceof Error ? error.message : String(error);
233
- result = { jobId: claimedJobId, exitCode: 1, stdout: '', stderr: message, pid: null, hostPlatform, hostName, stdoutTruncated: false, stderrTruncated: false, stdoutTotalBytes: 0, stderrTotalBytes: Buffer.byteLength(message), startedAt: new Date().toISOString(), completedAt: new Date().toISOString() };
234
- }
235
- await atomicJson(join(resultsDirectory, `${result.jobId}.json`), result);
236
- await unlink(runningPath).catch(() => {});
237
- }
238
-
239
- async function cleanup() {
240
- clearInterval(heartbeatTimer);
230
+ async function stopChildren() {
241
231
  await Promise.all([...activeChildren].map(child => new Promise(resolveStop => {
242
232
  if (!child.pid) return resolveStop();
243
- const done = () => resolveStop();
244
- child.once('close', done);
245
- if (process.platform === 'win32') {
246
- const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { windowsHide: true, stdio: 'ignore' });
247
- killer.once('close', done); killer.once('error', done);
248
- } else {
249
- try { process.kill(-child.pid, 'SIGTERM'); } catch { return done(); }
250
- setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 2000).unref();
251
- }
233
+ const done = () => resolveStop(); child.once('close', done);
234
+ if (process.platform === 'win32') { const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { windowsHide: true, stdio: 'ignore' }); killer.once('close', done); killer.once('error', done); }
235
+ else { try { process.kill(-child.pid, 'SIGTERM'); } catch { return done(); } setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 2000).unref(); }
252
236
  })));
253
- try {
254
- if ((await readFile(lockPath, 'utf8')).trim() === instanceId) await Promise.all([unlink(lockPath).catch(() => {}), unlink(heartbeatPath).catch(() => {})]);
255
- } catch {}
256
237
  }
257
238
 
258
239
  async function main() {
259
- await Promise.all([mkdir(requestsDirectory, { recursive: true }), mkdir(runningDirectory, { recursive: true }), mkdir(resultsDirectory, { recursive: true })]);
260
- try {
261
- const lock = await open(lockPath, 'wx'); await lock.writeFile(`${instanceId}\n`); await lock.close();
262
- } catch (error) {
263
- const heartbeatStale = await stat(heartbeatPath).then(value => Date.now() - value.mtimeMs > 5000, () => null);
264
- const lockOld = await stat(lockPath).then(value => Date.now() - value.mtimeMs > 5000, () => false);
265
- const stale = heartbeatStale === true || (heartbeatStale === null && lockOld);
266
- if (error?.code !== 'EEXIST' || !stale) throw error;
267
- let takeover;
240
+ let announced = false;
241
+ let backoffMs = 500;
242
+ while (!stopping) {
268
243
  try {
269
- takeover = await open(takeoverPath, 'wx');
270
- await takeover.writeFile(`${instanceId}\n`);
271
- } catch {
272
- throw new Error('Another host runner is recovering the stale lock.');
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);
251
+ 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;
275
+ } catch (error) {
276
+ if (error.status === 401) throw error;
277
+ emit('runner.error', { error: `${error.message}; reconnecting` });
278
+ await new Promise(resolveWait => setTimeout(resolveWait, backoffMs));
279
+ backoffMs = Math.min(10000, backoffMs * 2);
273
280
  }
274
- try {
275
- await unlink(heartbeatPath).catch(() => {});
276
- await unlink(lockPath);
277
- const lock = await open(lockPath, 'wx'); await lock.writeFile(`${instanceId}\n`); await lock.close();
278
- } finally {
279
- await takeover.close();
280
- await unlink(takeoverPath).catch(() => {});
281
- }
282
- }
283
- for (const name of await readdir(runningDirectory)) {
284
- if (!name.endsWith('.json')) continue;
285
- const jobId = name.replace(/\.json$/, '');
286
- const message = 'Host runner stopped while this job was active; the job was not replayed.';
287
- await atomicJson(join(resultsDirectory, name), { jobId, exitCode: 1, stdout: '', stderr: message, pid: null, hostPlatform, hostName, interrupted: true, startedAt: new Date().toISOString(), completedAt: new Date().toISOString() });
288
- await unlink(join(runningDirectory, name));
289
281
  }
290
- const heartbeat = () => atomicJson(heartbeatPath, { schemaVersion: 1, instanceId, pid: process.pid, hostname: hostName, platform: hostPlatform, workspace, updatedAt: new Date().toISOString() });
291
- await heartbeat(); heartbeatTimer = setInterval(() => heartbeat().catch(error => emit('runner.error', { error: error.message })), 1000);
292
- emit('runner.ready', { workspace, pid: process.pid, platform: hostPlatform, hostname: hostName });
293
- do {
294
- for (const name of (await readdir(requestsDirectory)).filter(name => name.endsWith('.json')).sort()) await processRequest(join(requestsDirectory, name));
295
- if (!once) await new Promise(resolveWait => setTimeout(resolveWait, 100));
296
- } while (!once);
297
- await cleanup();
282
+ clearInterval(heartbeatTimer);
298
283
  }
299
284
 
300
- for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => cleanup().finally(() => process.exit(0)));
301
- main().catch(error => { console.error(error instanceof Error ? error.message : String(error)); cleanup().finally(() => process.exit(1)); });
285
+ for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => {
286
+ if (stopping) return;
287
+ stopping = true;
288
+ shutdownDeadline = Date.now() + 5000;
289
+ clearInterval(heartbeatTimer);
290
+ stopChildren().catch(error => emit('runner.error', { error: error.message || String(error) }));
291
+ });
292
+ main().catch(error => { emit('runner.error', { error: error.message || String(error) }); clearInterval(heartbeatTimer); stopChildren().finally(() => process.exit(1)); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@p10i/create-nestbox",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Create Nestbox projects.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -11,8 +11,6 @@
11
11
  "bin/",
12
12
  "scripts/",
13
13
  "docker/",
14
- "!docker/control/__pycache__/",
15
- "!**/*.pyc",
16
14
  "home/",
17
15
  "tests/",
18
16
  "host-runner.mjs",
@@ -25,7 +23,12 @@
25
23
  "INSTALL.md",
26
24
  "README.md",
27
25
  "CHANGELOG.md",
28
- "LICENSE"
26
+ "LICENSE",
27
+ "!docker/control/__pycache__/",
28
+ "!**/*.pyc",
29
+ "!**/node_modules/",
30
+ "!home/code/scripts-bundle.js",
31
+ "!home/code/scripts-bundle.css"
29
32
  ],
30
33
  "scripts": {
31
34
  "host": "node host-runner.mjs",
@@ -41,4 +41,8 @@ for (const path of ['host-runner.mjs', 'package.host.json']) {
41
41
  if (!packageJson.files.includes(path)) throw new Error(`package.json files must include ${path}.`);
42
42
  }
43
43
 
44
+ for (const path of ['!**/node_modules/', '!home/code/scripts-bundle.js', '!home/code/scripts-bundle.css']) {
45
+ if (!packageJson.files.includes(path)) throw new Error(`package.json files must include ${path}.`);
46
+ }
47
+
44
48
  console.log('Package checks passed.');
package/tests/control.mjs CHANGED
@@ -25,6 +25,25 @@ target["Labels"]["com.docker.compose.service"] = "rollup"
25
25
  assert "not allowed" in control.policy_error(source, target, "1000")
26
26
  source["Labels"].pop("nestbox.exec.targets")
27
27
  assert "no allowed" in control.policy_error(source, target, "1000")
28
+ 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)
31
+ assert broker.status()["available"]
32
+ job = {"jobId": "job-test", "type": "npm.run"}
33
+ 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": ""})
36
+ 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"
41
+ 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
28
47
  `;
29
48
 
30
49
  let result = spawnSync(python, ['-c', policyTest], {
package/tests/creator.mjs CHANGED
@@ -19,7 +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
- if (!/^WORKSPACE_PATH=\.\.$/m.test(nestedEnv) || !/^NESTBOX_HOST_QUEUE_PATH=.+$/m.test(nestedEnv)) throw new Error('Nested runtime paths were not configured.');
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.');
23
24
  const npmCommand = process.env.npm_execpath ? process.execPath : (process.platform === 'win32' ? 'npm.cmd' : 'npm');
24
25
  const npmArgs = process.env.npm_execpath ? [process.env.npm_execpath, 'run', 'test:host'] : ['run', 'test:host'];
25
26
  const nestedHostTest = spawnSync(npmCommand, npmArgs, { cwd: nested, encoding: 'utf8' });