@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.
- package/.env.example +3 -3
- package/CHANGELOG.md +12 -0
- package/INSTALL.md +8 -6
- package/README.md +1 -9
- package/bin/create-nestbox.js +23 -14
- package/docker/control/server.py +254 -48
- package/docker-compose.yaml +6 -3
- package/home/configs/nginx/nginx.conf +17 -0
- package/home/configs/opencode/control-mcp.js +1 -1
- package/home/configs/opencode/instructions.md +5 -4
- package/home/configs/rollup/package-lock.json +10 -13
- package/host-runner.mjs +156 -165
- package/package.json +7 -4
- package/scripts/check-package.js +4 -0
- package/tests/control.mjs +19 -0
- package/tests/creator.mjs +2 -1
- package/tests/host-runner.mjs +87 -47
- package/tests/nestbox.ps1 +11 -3
- package/tests/nestbox.sh +9 -5
- package/home/code/scripts-bundle.css +0 -13295
- package/home/code/scripts-bundle.js +0 -6277
|
@@ -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,
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
1092
|
-
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.
|
|
1093
|
-
"integrity": "sha512-
|
|
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.
|
|
1214
|
-
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.
|
|
1215
|
-
"integrity": "sha512-
|
|
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.
|
|
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.
|
|
2024
|
-
"resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.
|
|
2025
|
-
"integrity": "sha512-
|
|
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 {
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
63
|
-
|
|
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
|
-
|
|
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}.${
|
|
126
|
+
const temporary = `${path}.${runnerId}.tmp`;
|
|
85
127
|
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
|
|
86
|
-
if (
|
|
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
|
-
|
|
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}.${
|
|
135
|
+
const backup = `${path}.${runnerId}.backup`;
|
|
95
136
|
await rename(path, backup);
|
|
96
|
-
try {
|
|
97
|
-
|
|
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
|
|
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
|
|
157
|
-
if (
|
|
158
|
-
|
|
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 =
|
|
193
|
-
const executableArgs =
|
|
208
|
+
const executable = npmCli ? process.execPath : npmExecutable;
|
|
209
|
+
const executableArgs = npmCli ? [npmCli, ...args] : args;
|
|
194
210
|
const startedAt = new Date().toISOString();
|
|
195
|
-
return
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
child.
|
|
202
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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, () =>
|
|
301
|
-
|
|
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.
|
|
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",
|
package/scripts/check-package.js
CHANGED
|
@@ -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
|
-
|
|
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' });
|