@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.
- package/.env.example +0 -4
- package/CHANGELOG.md +7 -0
- package/INSTALL.md +2 -2
- package/README.md +1 -1
- package/bin/create-nestbox.js +2 -25
- package/docker/control/Dockerfile +3 -2
- package/docker/control/host_bridge.py +205 -0
- package/docker/control/server.py +113 -54
- package/docker-compose.yaml +0 -9
- package/home/configs/nginx/nginx.conf +0 -17
- package/home/configs/opencode/control-mcp.js +1 -1
- package/home/configs/opencode/instructions.md +1 -2
- package/host-runner.mjs +134 -112
- package/package.json +1 -1
- package/scripts/check-package.js +1 -0
- package/tests/control.mjs +74 -13
- package/tests/creator.mjs +3 -3
- package/tests/host-runner.mjs +137 -65
- package/tests/nestbox.ps1 +4 -9
- package/tests/nestbox.sh +6 -7
|
@@ -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.
|
|
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
|
|
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,
|
|
4
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { readFile,
|
|
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
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
28
|
+
let activeBridge;
|
|
59
29
|
let stopping = false;
|
|
60
|
-
let
|
|
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
|
|
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
|
-
|
|
245
|
-
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
289
|
-
|
|
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) });
|
|
314
|
+
main().catch(error => { emit('runner.error', { error: error.message || String(error) }); stopChildren().finally(() => process.exit(1)); });
|
package/package.json
CHANGED
package/scripts/check-package.js
CHANGED
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
|
-
|
|
30
|
-
broker.
|
|
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",
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
assert
|
|
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
|
-
|
|
23
|
-
if (
|
|
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' });
|