@p10i/create-nestbox 0.1.2 → 0.2.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.
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const baseUrl = (process.env.NESTBOX_CONTROL_URL || 'http://control:4088').replace(/\/$/, '');
5
+ const timeoutMs = Number(process.env.NESTBOX_CONTROL_TIMEOUT_MS || '30000');
6
+ const tools = [
7
+ { name: 'docker_exec', description: 'Run a command in an allowed Compose service.', inputSchema: { type: 'object', properties: { machine: { type: 'string' }, workdir: { type: 'string' }, command: { type: 'array', items: { type: 'string' } }, user: { type: 'string' }, detach: { type: 'boolean' } }, required: ['machine', 'workdir', 'command'], additionalProperties: false } },
8
+ { name: 'npm_scripts', description: 'List host npm scripts from /workspace/package.json.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
9
+ { name: 'npm_run', description: 'Run an allowed npm script through the active host bridge.', inputSchema: { type: 'object', properties: { script: { type: 'string' }, args: { type: 'array', items: { type: 'string' } }, detach: { type: 'boolean' } }, required: ['script'], additionalProperties: false } },
10
+ { name: 'npm_script_change_request', description: 'Request an add, edit, or delete of an npm script. The confirmation code is displayed only by the host runner.', inputSchema: { type: 'object', properties: { operation: { type: 'string', enum: ['add', 'edit', 'delete'] }, name: { type: 'string' }, command: { type: 'string' } }, required: ['operation', 'name'], additionalProperties: false } },
11
+ { name: 'npm_script_change_confirm', description: 'Confirm a pending npm script change with the host-visible code.', inputSchema: { type: 'object', properties: { requestId: { type: 'string' }, code: { type: 'string' } }, required: ['requestId', 'code'], additionalProperties: false } },
12
+ ];
13
+
14
+ function request(method, path, body) {
15
+ const url = new URL(baseUrl + path);
16
+ const transport = require(url.protocol === 'https:' ? 'https' : 'http');
17
+ const payload = body === undefined ? undefined : JSON.stringify(body);
18
+ return new Promise((resolve, reject) => {
19
+ const headers = { accept: 'application/json' };
20
+ if (payload !== undefined) Object.assign(headers, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) });
21
+ const client = transport.request({ method, hostname: url.hostname, port: url.port || undefined, path: url.pathname + url.search, headers }, response => {
22
+ const chunks = [];
23
+ response.setEncoding('utf8');
24
+ response.on('data', chunk => chunks.push(chunk));
25
+ response.on('end', () => {
26
+ const text = chunks.join('');
27
+ let value = null;
28
+ try { value = text ? JSON.parse(text) : null; } catch { value = text; }
29
+ if (response.statusCode >= 400) reject(new Error(`Control API ${method} ${path} returned ${response.statusCode}: ${JSON.stringify(value)}`));
30
+ else resolve(value);
31
+ });
32
+ });
33
+ client.setTimeout(timeoutMs, () => client.destroy(new Error('Control API request timed out')));
34
+ client.on('error', reject);
35
+ if (payload !== undefined) client.write(payload);
36
+ client.end();
37
+ });
38
+ }
39
+
40
+ function object(value) {
41
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Tool arguments must be an object.');
42
+ return value;
43
+ }
44
+
45
+ async function call(name, raw) {
46
+ const args = object(raw || {});
47
+ if (name === 'docker_exec') {
48
+ if (typeof args.machine !== 'string' || !args.machine) throw new Error('machine must be a non-empty string.');
49
+ if (typeof args.workdir !== 'string' || !args.workdir.startsWith('/')) throw new Error('workdir must be an absolute path.');
50
+ if (!Array.isArray(args.command) || !args.command.length || !args.command.every(value => typeof value === 'string')) throw new Error('command must be a non-empty string array.');
51
+ const body = { command: args.command, workdir: args.workdir };
52
+ if (args.user) body.user = String(args.user);
53
+ if (args.detach !== undefined) body.detach = Boolean(args.detach);
54
+ return { machine: args.machine, workdir: args.workdir, command: args.command, result: await request('POST', `/containers/${encodeURIComponent(args.machine)}/exec`, body) };
55
+ }
56
+ if (name === 'npm_scripts') return request('GET', '/npm/scripts');
57
+ if (name === 'npm_run') {
58
+ if (typeof args.script !== 'string' || !args.script) throw new Error('script must be a non-empty string.');
59
+ if (args.args !== undefined && (!Array.isArray(args.args) || !args.args.every(value => typeof value === 'string'))) throw new Error('args must be a string array.');
60
+ return request('POST', '/npm/run', { script: args.script, ...(args.args === undefined ? {} : { args: args.args }), ...(args.detach === undefined ? {} : { detach: Boolean(args.detach) }) });
61
+ }
62
+ if (name === 'npm_script_change_request') {
63
+ if (!['add', 'edit', 'delete'].includes(args.operation) || typeof args.name !== 'string' || !args.name) throw new Error('operation and name are required.');
64
+ if (args.operation !== 'delete' && (typeof args.command !== 'string' || !args.command)) throw new Error('command is required for add and edit.');
65
+ if (args.operation === 'delete' && args.command !== undefined) throw new Error('command must be omitted for delete.');
66
+ return request('POST', '/npm/scripts/request', { operation: args.operation, name: args.name, ...(args.command === undefined ? {} : { command: args.command }) });
67
+ }
68
+ if (name === 'npm_script_change_confirm') {
69
+ if (typeof args.requestId !== 'string' || typeof args.code !== 'string') throw new Error('requestId and code are required.');
70
+ return request('POST', '/npm/scripts/confirm', { requestId: args.requestId, code: args.code });
71
+ }
72
+ throw new Error(`Unknown tool: ${name}`);
73
+ }
74
+
75
+ function emit(value) { process.stdout.write(`${JSON.stringify(value)}\n`); }
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.2.0' } } });
78
+ if (message.method === 'initialized') return;
79
+ if (message.method === 'tools/list') return emit({ jsonrpc: '2.0', id: message.id, result: { tools } });
80
+ if (message.method === 'tools/call') {
81
+ try {
82
+ const output = await call(message.params?.name, message.params?.arguments);
83
+ emit({ jsonrpc: '2.0', id: message.id, result: { content: [{ type: 'text', text: JSON.stringify(output, null, 2) }] } });
84
+ } catch (error) {
85
+ emit({ jsonrpc: '2.0', id: message.id, error: { code: -32603, message: error.message || 'Tool execution failed.' } });
86
+ }
87
+ return;
88
+ }
89
+ if (message.id !== undefined) emit({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: `Method not supported: ${message.method}` } });
90
+ }
91
+
92
+ let input = '';
93
+ process.stdin.on('data', chunk => {
94
+ input += chunk.toString('utf8');
95
+ let boundary;
96
+ while ((boundary = input.indexOf('\n')) >= 0) {
97
+ const line = input.slice(0, boundary).replace(/\r$/, ''); input = input.slice(boundary + 1);
98
+ if (!line) continue;
99
+ try { Promise.resolve(handle(JSON.parse(line))).catch(error => emit({ jsonrpc: '2.0', id: null, error: { code: -32603, message: error.message } })); }
100
+ catch (error) { emit({ jsonrpc: '2.0', id: null, error: { code: -32700, message: error.message } }); }
101
+ }
102
+ });
@@ -168,6 +168,20 @@ The interactive 404 page demonstrates the intended flow: create a session scoped
168
168
 
169
169
  Use API-created sessions only for explicit page workflows. The shared header's Open Chat link remains the normal entry point for conversation.
170
170
 
171
+ ## Container Control API
172
+
173
+ OpenCode has no Docker socket. Use the control MCP as the only path for commands in other containers or on the native host:
174
+
175
+ - `docker_exec` accepts an allowed Compose `machine`, absolute `workdir`, positional `command` array, and optional `user` or `detach` value.
176
+ - `npm_scripts` lists scripts from `/workspace/package.json`.
177
+ - `npm_run` executes one declared script through the active native host runner. Prefer `detach: true` for work expected to exceed the MCP request timeout.
178
+ - `npm_script_change_request` requests an add, edit, or delete. The confirmation code appears only in the host runner terminal.
179
+ - `npm_script_change_confirm` applies the pending change after the user explicitly supplies that code.
180
+
181
+ Do not call the control HTTP API directly from OpenCode, write queue files, invoke Docker or SSH as a substitute, or work around a missing MCP capability. PHP cannot invoke MCP, so trusted super-documents may use `nestbox_control_exec()`; default labels restrict PHP-FPM to self-exec. Only OpenCode has `nestbox.npm.allow=true` by default. Root exec requires an explicit target label.
182
+
183
+ The control log is the audit source of truth and requires no observability service. Its host queue stays outside container-mounted workspace paths. Commands still operate in trusted containers and a trusted writable workspace; Docker socket access and editable host-script source mean this is a controlled capability, not a security sandbox.
184
+
171
185
  ## Git And Commit Policy
172
186
 
173
187
  - Resolve the owning Git root before staging any path.
@@ -182,12 +196,13 @@ Use API-created sessions only for explicit page workflows. The shared header's O
182
196
 
183
197
  ## Container Architecture
184
198
 
185
- The default Compose application has four services:
199
+ The default Compose application has five services:
186
200
 
187
201
  - `nginx`: the only host-published gateway. It serves static page files, routes super-documents to PHP-FPM, proxies the OpenCode virtual host, and hosts Nchan.
188
202
  - `php-fpm`: executes HTTP and CLI directions. It mounts the page tree and workspace writable because trusted pages may intentionally modify them.
189
203
  - `rollup`: watches `scripts.js` and browser dependencies and writes local JS/CSS bundles into the page tree.
190
204
  - `opencode`: runs this agent, mounts the engine and workspace, and edits the page tree through `/nestbox/home/code`.
205
+ - `control`: owns the Docker socket and privately brokers label-gated container exec and host-runner jobs.
191
206
 
192
207
  The default host port is `4180` after installer confirmation. Nginx container port `80` serves both Nestbox and OpenCode through host-based routing. Nginx container port `8080` is only the internal Nchan publisher.
193
208
 
@@ -214,6 +229,7 @@ The engine's untracked `/nestbox/.env` controls runtime paths, exposure, authent
214
229
  - `APP_UID` and `APP_GID`: PHP-FPM ownership for writable bind mounts.
215
230
  - `NESTBOX_URL_PREFIX`: public path prefix when an outer proxy strips that prefix before forwarding.
216
231
  - `NESTBOX_OPENCODE_PUBLIC_URL`: explicit public OpenCode origin when `agent.<nestbox-host>` cannot be derived.
232
+ - `NESTBOX_CONTROL_URL`: internal control API URL, normally `http://control:4088`.
217
233
  - `OPENCODE_SERVER_USERNAME` and `OPENCODE_SERVER_PASSWORD`: OpenCode HTTP authentication shared with PHP's internal API client.
218
234
 
219
235
  Nginx publishes one host port and distinguishes two browser origins:
@@ -271,13 +287,13 @@ Mount every directory needed by both PHP and OpenCode at the same container path
271
287
 
272
288
  ## Container Boundary And Host Handoff
273
289
 
274
- This OpenCode container deliberately has no Docker socket and may not be able to run Docker CLI commands. Never claim to build, pull, start, stop, restart, recreate, inspect, or remove host containers from this session.
290
+ This OpenCode container deliberately has no Docker socket. Use control MCP only for its declared, label-gated operations. Never claim a broader Docker or operating-system capability.
275
291
 
276
292
  You may edit Compose files, Dockerfiles, Nginx configuration, PHP configuration, dependency files, mount definitions, and OpenCode configuration. After an infrastructure edit:
277
293
 
278
294
  1. Validate every part that can be checked inside this container.
279
295
  2. Identify the exact affected services.
280
- 3. Give the user the narrowest host-side command to run from the engine directory.
296
+ 3. Use a declared host npm script through MCP when one exists; otherwise give the user the narrowest host-side command.
281
297
  4. State that activation and host health checks remain pending.
282
298
  5. Tell the user to reconnect if OpenCode itself must restart.
283
299
 
@@ -5,6 +5,17 @@
5
5
  "port": 4096
6
6
  },
7
7
  "instructions": ["instructions.md", "instance.md"],
8
+ "mcp": {
9
+ "control": {
10
+ "type": "local",
11
+ "command": ["node", "/etc/opencode/control-mcp.js"],
12
+ "enabled": true,
13
+ "environment": {
14
+ "NESTBOX_CONTROL_URL": "{env:NESTBOX_CONTROL_URL}",
15
+ "NESTBOX_CONTROL_TIMEOUT_MS": "30000"
16
+ }
17
+ }
18
+ },
8
19
  "permission": {
9
20
  "external_directory": {
10
21
  "*": "ask",
@@ -0,0 +1,301 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { hostname, platform } from 'node:os';
4
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
7
+
8
+ const installDirectory = dirname(fileURLToPath(import.meta.url));
9
+ const defaultWorkspace = basename(installDirectory) === '.nestbox' ? dirname(installDirectory) : installDirectory;
10
+ const workspace = resolve(process.env.NESTBOX_HOST_WORKSPACE || defaultWorkspace);
11
+ const runnerConfig = await readFile(join(installDirectory, '.runtime', 'host-runner.json'), 'utf8')
12
+ .then(value => JSON.parse(value), error => error?.code === 'ENOENT' ? {} : Promise.reject(error));
13
+ const queueRoot = resolve(process.env.NESTBOX_HOST_QUEUE || runnerConfig.queue || join(installDirectory, '.runtime', 'host-runner'));
14
+ const queueRelative = relative(workspace, queueRoot);
15
+ if (queueRelative === '' || (!queueRelative.startsWith('..') && !isAbsolute(queueRelative))) {
16
+ throw new Error('NESTBOX host queue must be outside the mounted workspace.');
17
+ }
18
+ const requestsDirectory = join(queueRoot, 'requests');
19
+ const runningDirectory = join(queueRoot, 'running');
20
+ const resultsDirectory = join(queueRoot, 'results');
21
+ const heartbeatPath = join(queueRoot, 'heartbeat.json');
22
+ const lockPath = join(queueRoot, 'runner.lock');
23
+ const takeoverPath = join(queueRoot, 'takeover.lock');
24
+ const instanceId = `${process.pid}-${Date.now()}`;
25
+ const once = process.argv.slice(2).includes('--once');
26
+ const jsonOutput = process.argv.slice(2).includes('--json');
27
+ const npmExecutable = process.env.NESTBOX_HOST_NPM || 'npm';
28
+ const maximumOutputBytes = Number(process.env.NESTBOX_HOST_MAX_OUTPUT_BYTES || 4 * 1024 * 1024);
29
+ const approvalTtlMs = Number(process.env.NESTBOX_HOST_APPROVAL_TTL_MS || 5 * 60 * 1000);
30
+ const approvalMaximumAttempts = Number(process.env.NESTBOX_HOST_APPROVAL_ATTEMPTS || 3);
31
+ const maximumPendingApprovals = Number(process.env.NESTBOX_HOST_APPROVAL_MAX_PENDING || 20);
32
+ const hostPlatform = platform();
33
+ const hostName = hostname();
34
+ const approvals = new Map();
35
+ const activeChildren = new Set();
36
+ let heartbeatTimer;
37
+
38
+ function emit(type, fields = {}) {
39
+ const event = { ...fields, type, timestamp: new Date().toISOString() };
40
+ if (jsonOutput) return process.stdout.write(`${JSON.stringify(event)}\n`);
41
+ if (type === 'runner.ready') console.log(`Nestbox host runner listening in ${workspace}`);
42
+ else if (type === 'npm.scripts.confirmation_required') {
43
+ console.log(`\nApproval ${event.requestId}: ${event.operation} npm script "${event.name}"`);
44
+ if (event.command !== undefined) console.log(`Command: ${event.command}`);
45
+ console.log(`Confirmation code: ${event.code} (expires ${event.expiresAt})\n`);
46
+ } else if (type === 'npm.scripts.changed') console.log(`Approved ${event.operation} for npm script "${event.name}".`);
47
+ else if (type === 'runner.error') console.error(event.error);
48
+ }
49
+
50
+ async function atomicJson(path, value) {
51
+ const temporary = `${path}.${instanceId}.tmp`;
52
+ await writeFile(temporary, `${JSON.stringify(value)}\n`, 'utf8');
53
+ try {
54
+ await rename(temporary, path);
55
+ } catch (error) {
56
+ if (process.platform !== 'win32' || !['EEXIST', 'EPERM'].includes(error?.code)) throw error;
57
+ await unlink(path).catch(() => {});
58
+ await rename(temporary, path);
59
+ }
60
+ }
61
+
62
+ async function packageDocument() {
63
+ const document = JSON.parse(await readFile(join(workspace, 'package.json'), 'utf8'));
64
+ if (!document || typeof document !== 'object' || Array.isArray(document)) throw new Error('package.json must contain a JSON object.');
65
+ return document;
66
+ }
67
+
68
+ async function packageHash() {
69
+ const contents = await readFile(join(workspace, 'package.json'));
70
+ return createHash('sha256').update(contents).digest('hex');
71
+ }
72
+
73
+ async function packageScripts() {
74
+ const document = await packageDocument();
75
+ 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
+ }
79
+ return document.scripts;
80
+ }
81
+
82
+ async function writePackageJson(document, expectedHash) {
83
+ const path = join(workspace, 'package.json');
84
+ const temporary = `${path}.${instanceId}.tmp`;
85
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
86
+ if (expectedHash && await packageHash() !== expectedHash) {
87
+ await unlink(temporary).catch(() => {});
88
+ throw new Error('package.json changed while the approved update was being prepared.');
89
+ }
90
+ try {
91
+ await rename(temporary, path);
92
+ } catch (error) {
93
+ if (process.platform !== 'win32' || !['EEXIST', 'EPERM'].includes(error?.code)) throw error;
94
+ const backup = `${path}.${instanceId}.backup`;
95
+ await rename(path, backup);
96
+ try {
97
+ await rename(temporary, path);
98
+ await unlink(backup);
99
+ } catch (replaceError) {
100
+ await rename(backup, path).catch(() => {});
101
+ throw replaceError;
102
+ }
103
+ }
104
+ }
105
+
106
+ function validateScriptChange(job, scripts) {
107
+ if (!['add', 'edit', 'delete'].includes(job.operation)) throw new Error('Unsupported npm script operation.');
108
+ if (typeof job.name !== 'string' || !/^[A-Za-z0-9:_@./ -]{1,128}$/.test(job.name)) throw new Error('Invalid npm script name.');
109
+ if (job.name === 'host') throw new Error('The core host script cannot be changed.');
110
+ const exists = Object.hasOwn(scripts, job.name);
111
+ if (job.operation === 'add' && exists) throw new Error(`npm script already exists: ${job.name}`);
112
+ 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
+ }
116
+ if (job.operation === 'delete' && job.command !== undefined) throw new Error('Delete does not accept a command.');
117
+ }
118
+
119
+ function pruneApprovals() {
120
+ const now = Date.now();
121
+ for (const [requestId, approval] of approvals) if (approval.expiresAtMs <= now) approvals.delete(requestId);
122
+ }
123
+
124
+ async function requestScriptChange(job) {
125
+ if (typeof job.requestId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/.test(job.requestId)) throw new Error('A valid request ID is required.');
126
+ pruneApprovals();
127
+ if (approvals.has(job.requestId)) throw new Error('The request ID is already pending.');
128
+ if (approvals.size >= maximumPendingApprovals) throw new Error('Too many npm script approvals are pending.');
129
+ const scripts = await packageScripts();
130
+ validateScriptChange(job, scripts);
131
+ const code = randomBytes(6).toString('base64url').slice(0, 8).toUpperCase();
132
+ 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
+ });
141
+ const expiresAt = new Date(expiresAtMs).toISOString();
142
+ emit('npm.scripts.confirmation_required', { ...job, code, expiresAt });
143
+ return { jobId: job.jobId, status: 'confirmation_required', requestId: job.requestId, operation: job.operation, name: job.name, expiresAt };
144
+ }
145
+
146
+ 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
+ pruneApprovals();
149
+ const approval = approvals.get(job.requestId);
150
+ if (!approval) throw new Error('The npm script approval was not found or has expired.');
151
+ approval.attempts += 1;
152
+ if (job.code.trim().toUpperCase() !== approval.code) {
153
+ if (approval.attempts >= approvalMaximumAttempts) approvals.delete(job.requestId);
154
+ throw new Error(approval.attempts >= approvalMaximumAttempts ? 'Invalid confirmation code; the approval has been cancelled.' : 'Invalid confirmation code.');
155
+ }
156
+ const document = await packageDocument();
157
+ if (await packageHash() !== approval.expectedPackageHash) {
158
+ approvals.delete(job.requestId);
159
+ throw new Error('package.json changed after approval was requested; request a new approval.');
160
+ }
161
+ const scripts = document.scripts || {};
162
+ const currentCommand = Object.hasOwn(scripts, approval.name) ? String(scripts[approval.name]) : null;
163
+ if (currentCommand !== approval.expectedCommand) {
164
+ approvals.delete(job.requestId);
165
+ throw new Error('package.json changed after approval was requested; request a new approval.');
166
+ }
167
+ validateScriptChange(approval, scripts);
168
+ if (approval.operation === 'delete') delete scripts[approval.name];
169
+ else scripts[approval.name] = approval.command;
170
+ document.scripts = scripts;
171
+ await writePackageJson(document, approval.expectedPackageHash);
172
+ approvals.delete(job.requestId);
173
+ emit('npm.scripts.changed', approval);
174
+ return { jobId: job.jobId, status: 'success', requestId: approval.requestId, operation: approval.operation, name: approval.name };
175
+ }
176
+
177
+ function appendLimited(chunks, chunk, state) {
178
+ const buffer = Buffer.from(chunk);
179
+ state.totalBytes += buffer.length;
180
+ const available = Math.max(0, maximumOutputBytes - state.bytes);
181
+ if (available) chunks.push(buffer.subarray(0, available));
182
+ state.bytes += Math.min(buffer.length, available);
183
+ if (buffer.length > available) state.truncated = true;
184
+ }
185
+
186
+ async function runNpm(job) {
187
+ const scripts = await packageScripts();
188
+ if (typeof job.script !== 'string' || !Array.isArray(job.args) || !job.args.every(value => typeof value === 'string')) throw new Error('Invalid host npm job.');
189
+ if (job.script === 'host') throw new Error('The host bridge cannot invoke itself.');
190
+ if (!Object.hasOwn(scripts, job.script)) throw new Error(`npm script is not defined: ${job.script}`);
191
+ const args = ['run', job.script, ...(job.args.length ? ['--', ...job.args] : [])];
192
+ const executable = process.env.npm_execpath ? process.execPath : npmExecutable;
193
+ const executableArgs = process.env.npm_execpath ? [process.env.npm_execpath, ...args] : args;
194
+ const startedAt = new Date().toISOString();
195
+ return await new Promise(resolveJob => {
196
+ const child = spawn(executable, executableArgs, { cwd: workspace, env: process.env, shell: false, windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
197
+ activeChildren.add(child);
198
+ const stdout = [], stderr = [];
199
+ const out = { bytes: 0, totalBytes: 0, truncated: false }, err = { bytes: 0, totalBytes: 0, truncated: false };
200
+ child.stdout.on('data', chunk => appendLimited(stdout, chunk, out));
201
+ child.stderr.on('data', chunk => appendLimited(stderr, chunk, err));
202
+ const result = (exitCode, message = '') => resolveJob({
203
+ jobId: job.jobId, exitCode, stdout: Buffer.concat(stdout).toString('utf8'), stderr: message || Buffer.concat(stderr).toString('utf8'),
204
+ pid: child.pid ?? null, hostPlatform, hostName, stdoutTruncated: out.truncated, stderrTruncated: err.truncated,
205
+ stdoutTotalBytes: out.totalBytes, stderrTotalBytes: message ? Buffer.byteLength(message) : err.totalBytes,
206
+ startedAt, completedAt: new Date().toISOString(),
207
+ });
208
+ child.on('error', error => { activeChildren.delete(child); result(1, error.message); });
209
+ child.on('close', code => { activeChildren.delete(child); result(Number.isInteger(code) ? code : 1); });
210
+ });
211
+ }
212
+
213
+ async function execute(job) {
214
+ if (job?.schemaVersion !== 1 || typeof job?.jobId !== 'string') throw new Error('Unsupported host job schema.');
215
+ if (job.type === 'npm.scripts.request') return requestScriptChange(job);
216
+ if (job.type === 'npm.scripts.confirm') return confirmScriptChange(job);
217
+ if (job.type === 'npm.run') return runNpm(job);
218
+ throw new Error('Unsupported host job type.');
219
+ }
220
+
221
+ async function processRequest(path) {
222
+ const name = basename(path);
223
+ const runningPath = join(runningDirectory, name);
224
+ try { await rename(path, runningPath); } catch (error) { if (['ENOENT', 'EACCES', 'EPERM'].includes(error?.code)) return; throw error; }
225
+ const claimedJobId = name.replace(/\.json$/, '');
226
+ let result;
227
+ try {
228
+ const job = JSON.parse(await readFile(runningPath, 'utf8'));
229
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(claimedJobId) || job?.jobId !== claimedJobId) throw new Error('Host job ID must match its safe queue filename.');
230
+ result = await execute(job);
231
+ } catch (error) {
232
+ const message = error instanceof Error ? error.message : String(error);
233
+ result = { jobId: claimedJobId, exitCode: 1, stdout: '', stderr: message, pid: null, hostPlatform, hostName, stdoutTruncated: false, stderrTruncated: false, stdoutTotalBytes: 0, stderrTotalBytes: Buffer.byteLength(message), startedAt: new Date().toISOString(), completedAt: new Date().toISOString() };
234
+ }
235
+ await atomicJson(join(resultsDirectory, `${result.jobId}.json`), result);
236
+ await unlink(runningPath).catch(() => {});
237
+ }
238
+
239
+ async function cleanup() {
240
+ clearInterval(heartbeatTimer);
241
+ await Promise.all([...activeChildren].map(child => new Promise(resolveStop => {
242
+ if (!child.pid) return resolveStop();
243
+ const done = () => resolveStop();
244
+ child.once('close', done);
245
+ if (process.platform === 'win32') {
246
+ const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { windowsHide: true, stdio: 'ignore' });
247
+ killer.once('close', done); killer.once('error', done);
248
+ } else {
249
+ try { process.kill(-child.pid, 'SIGTERM'); } catch { return done(); }
250
+ setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 2000).unref();
251
+ }
252
+ })));
253
+ try {
254
+ if ((await readFile(lockPath, 'utf8')).trim() === instanceId) await Promise.all([unlink(lockPath).catch(() => {}), unlink(heartbeatPath).catch(() => {})]);
255
+ } catch {}
256
+ }
257
+
258
+ async function main() {
259
+ await Promise.all([mkdir(requestsDirectory, { recursive: true }), mkdir(runningDirectory, { recursive: true }), mkdir(resultsDirectory, { recursive: true })]);
260
+ try {
261
+ const lock = await open(lockPath, 'wx'); await lock.writeFile(`${instanceId}\n`); await lock.close();
262
+ } catch (error) {
263
+ const heartbeatStale = await stat(heartbeatPath).then(value => Date.now() - value.mtimeMs > 5000, () => null);
264
+ const lockOld = await stat(lockPath).then(value => Date.now() - value.mtimeMs > 5000, () => false);
265
+ const stale = heartbeatStale === true || (heartbeatStale === null && lockOld);
266
+ if (error?.code !== 'EEXIST' || !stale) throw error;
267
+ let takeover;
268
+ try {
269
+ takeover = await open(takeoverPath, 'wx');
270
+ await takeover.writeFile(`${instanceId}\n`);
271
+ } catch {
272
+ throw new Error('Another host runner is recovering the stale lock.');
273
+ }
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
+ }
290
+ const heartbeat = () => atomicJson(heartbeatPath, { schemaVersion: 1, instanceId, pid: process.pid, hostname: hostName, platform: hostPlatform, workspace, updatedAt: new Date().toISOString() });
291
+ await heartbeat(); heartbeatTimer = setInterval(() => heartbeat().catch(error => emit('runner.error', { error: error.message })), 1000);
292
+ emit('runner.ready', { workspace, pid: process.pid, platform: hostPlatform, hostname: hostName });
293
+ do {
294
+ for (const name of (await readdir(requestsDirectory)).filter(name => name.endsWith('.json')).sort()) await processRequest(join(requestsDirectory, name));
295
+ if (!once) await new Promise(resolveWait => setTimeout(resolveWait, 100));
296
+ } while (!once);
297
+ await cleanup();
298
+ }
299
+
300
+ for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => cleanup().finally(() => process.exit(0)));
301
+ main().catch(error => { console.error(error instanceof Error ? error.message : String(error)); cleanup().finally(() => process.exit(1)); });
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "nestbox-workspace",
3
+ "private": true,
4
+ "scripts": {
5
+ "host": "node .nestbox/host-runner.mjs",
6
+ "test:host": "node .nestbox/tests/host-runner.mjs"
7
+ }
8
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@p10i/create-nestbox",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Create Nestbox projects.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -11,8 +11,12 @@
11
11
  "bin/",
12
12
  "scripts/",
13
13
  "docker/",
14
+ "!docker/control/__pycache__/",
15
+ "!**/*.pyc",
14
16
  "home/",
15
17
  "tests/",
18
+ "host-runner.mjs",
19
+ "package.host.json",
16
20
  "docker-compose.yaml",
17
21
  ".dockerignore",
18
22
  ".env.example",
@@ -20,9 +24,13 @@
20
24
  "template.gitignore",
21
25
  "INSTALL.md",
22
26
  "README.md",
27
+ "CHANGELOG.md",
23
28
  "LICENSE"
24
29
  ],
25
30
  "scripts": {
31
+ "host": "node host-runner.mjs",
32
+ "test:host": "node tests/host-runner.mjs",
33
+ "test": "node tests/control.mjs && node tests/host-runner.mjs && node tests/creator.mjs",
26
34
  "prepack": "node scripts/check-package.js"
27
35
  },
28
36
  "engines": {
@@ -9,10 +9,19 @@ const requiredPaths = [
9
9
  'docker/php-fpm/Dockerfile',
10
10
  'docker/opencode/Dockerfile',
11
11
  'docker/rollup/Dockerfile',
12
+ 'docker/control/Dockerfile',
13
+ 'docker/control/server.py',
14
+ 'host-runner.mjs',
15
+ 'package.host.json',
16
+ 'CHANGELOG.md',
17
+ 'home/configs/opencode/control-mcp.js',
12
18
  'home/configs/opencode/instance.example.md',
13
19
  'home/code/404.php',
14
20
  'tests/nestbox.sh',
15
- 'tests/nestbox.ps1'
21
+ 'tests/nestbox.ps1',
22
+ 'tests/control.mjs',
23
+ 'tests/host-runner.mjs',
24
+ 'tests/creator.mjs'
16
25
  ];
17
26
 
18
27
  for (const path of requiredPaths) {
@@ -28,4 +37,8 @@ if (!packageJson.files || packageJson.files.includes('.env')) {
28
37
  throw new Error('package.json files must not include .env.');
29
38
  }
30
39
 
40
+ for (const path of ['host-runner.mjs', 'package.host.json']) {
41
+ if (!packageJson.files.includes(path)) throw new Error(`package.json files must include ${path}.`);
42
+ }
43
+
31
44
  console.log('Package checks passed.');
@@ -6,6 +6,7 @@
6
6
  .vscode/
7
7
  node_modules/
8
8
  vendor/
9
+ .runtime/
9
10
 
10
11
  # Generated by the Rollup watcher.
11
12
  home/code/scripts-bundle.js
@@ -0,0 +1,51 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { resolve } from 'node:path';
3
+
4
+ const python = process.env.PYTHON || (process.platform === 'win32' ? 'python' : 'python3');
5
+ const server = resolve('docker/control/server.py');
6
+ const policyTest = String.raw`
7
+ import importlib.util
8
+ spec = importlib.util.spec_from_file_location("control", r"${server}")
9
+ control = importlib.util.module_from_spec(spec)
10
+ spec.loader.exec_module(control)
11
+ assert control.is_root_user("root")
12
+ assert control.is_root_user("root:root")
13
+ assert control.is_root_user("0:0")
14
+ assert control.is_root_user("00:0")
15
+ assert control.is_root_user("+0")
16
+ assert not control.is_root_user("1000:1000")
17
+ source = {"Labels": {"com.docker.compose.service": "opencode", "nestbox.exec.allow-source": "true", "nestbox.exec.targets": "php-fpm"}}
18
+ target = {"Id": "abc", "Names": ["/project-php-fpm-1"], "Labels": {"com.docker.compose.service": "php-fpm", "nestbox.exec.allow-target": "true"}}
19
+ assert control.policy_error(source, target, "1000") == ""
20
+ assert "root" in control.policy_error(source, target, "0:0")
21
+ assert "named user" in control.policy_error(source, target, "root-alias", "nestbox")
22
+ target["Labels"]["nestbox.exec.allow-root"] = "true"
23
+ assert control.policy_error(source, target, "root:root") == ""
24
+ target["Labels"]["com.docker.compose.service"] = "rollup"
25
+ assert "not allowed" in control.policy_error(source, target, "1000")
26
+ source["Labels"].pop("nestbox.exec.targets")
27
+ assert "no allowed" in control.policy_error(source, target, "1000")
28
+ `;
29
+
30
+ let result = spawnSync(python, ['-c', policyTest], {
31
+ encoding: 'utf8',
32
+ env: { ...process.env, COMPOSE_PROJECT_NAME: 'nestbox-test', PYTHONDONTWRITEBYTECODE: '1' },
33
+ });
34
+ if (result.status !== 0) throw new Error(`Control policy test failed: ${result.stderr || result.stdout}`);
35
+
36
+ result = spawnSync(python, [server], { encoding: 'utf8', env: { ...process.env, COMPOSE_PROJECT_NAME: '', PYTHONDONTWRITEBYTECODE: '1' } });
37
+ if (result.status === 0 || !result.stderr.includes('COMPOSE_PROJECT_NAME')) throw new Error('Control did not fail closed without a project name.');
38
+
39
+ const messages = [
40
+ { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
41
+ { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} },
42
+ ].map(value => JSON.stringify(value)).join('\n') + '\n';
43
+ result = spawnSync(process.execPath, [resolve('home/configs/opencode/control-mcp.js')], { input: messages, encoding: 'utf8' });
44
+ if (result.status !== 0) throw new Error(`Control MCP test failed: ${result.stderr}`);
45
+ const responses = result.stdout.trim().split(/\r?\n/).map(line => JSON.parse(line));
46
+ const names = responses.find(value => value.id === 2)?.result?.tools?.map(tool => tool.name) || [];
47
+ for (const name of ['docker_exec', 'npm_scripts', 'npm_run', 'npm_script_change_request', 'npm_script_change_confirm']) {
48
+ if (!names.includes(name)) throw new Error(`Control MCP does not expose ${name}.`);
49
+ }
50
+
51
+ console.log('Control tests passed.');