@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.
- package/.env.example +0 -5
- package/CHANGELOG.md +12 -0
- package/INSTALL.md +2 -4
- 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 -11
- package/home/configs/opencode/control-mcp.js +1 -1
- package/home/configs/opencode/instructions.md +1 -1
- package/host-runner.mjs +134 -108
- 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 +6 -9
- package/tests/nestbox.sh +8 -7
package/docker-compose.yaml
CHANGED
|
@@ -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.
|
|
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
|
|
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,
|
|
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 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
|
|
28
|
+
let activeBridge;
|
|
55
29
|
let stopping = false;
|
|
56
|
-
let
|
|
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
|
|
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
|
-
|
|
241
|
-
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
285
|
-
|
|
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) });
|
|
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' });
|