@p10i/create-nestbox 0.5.0 → 0.6.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/.dockerignore +18 -18
- package/CHANGELOG.md +9 -0
- package/INSTALL.md +32 -24
- package/README.md +22 -4
- package/bin/create-nestbox.js +2 -60
- package/docker/control/Dockerfile +4 -5
- package/docker/control/server.py +4 -442
- package/docker/nginx/Dockerfile +11 -11
- package/docker/opencode/Dockerfile +6 -2
- package/docker/php-fpm/Dockerfile +22 -22
- package/docker-compose.yaml +1 -20
- package/home/code/__includes/footer.php +2 -2
- package/home/code/__includes/header.php +5 -5
- package/home/configs/nginx/nginx.conf +17 -17
- package/home/configs/opencode/control-mcp.js +1 -21
- package/home/configs/opencode/instructions.md +11 -11
- package/home/configs/opencode/opencode.json +1 -1
- package/home/configs/php/composer.json +8 -8
- package/home/configs/php/init.php +2 -2
- package/home/configs/php/preload.php +4 -4
- package/package.json +2 -6
- package/scripts/check-package.js +0 -8
- package/tests/control.mjs +55 -83
- package/tests/creator.mjs +13 -18
- package/tests/nestbox.ps1 +3 -3
- package/tests/nestbox.sh +3 -3
- package/docker/control/host_bridge.py +0 -205
- package/host-runner.mjs +0 -314
- package/package.host.json +0 -8
- package/tests/host-runner.mjs +0 -215
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
</article>
|
|
2
|
-
</body>
|
|
1
|
+
</article>
|
|
2
|
+
</body>
|
|
3
3
|
</html>
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="utf-8">
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
-
<title><?= htmlspecialchars($title ?? 'Nestbox', ENT_QUOTES, 'UTF-8'); ?></title>
|
|
7
|
-
<!-- Pre-built bundle of every shared dependency (Bootstrap, GitHub Markdown CSS, …): -->
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title><?= htmlspecialchars($title ?? 'Nestbox', ENT_QUOTES, 'UTF-8'); ?></title>
|
|
7
|
+
<!-- Pre-built bundle of every shared dependency (Bootstrap, GitHub Markdown CSS, …): -->
|
|
8
8
|
<link rel="stylesheet" href="<?= nestbox_escape(nestbox_url_path('scripts-bundle.css')); ?>">
|
|
9
9
|
<script defer src="<?= nestbox_escape(nestbox_url_path('scripts-bundle.js')); ?>"></script>
|
|
10
10
|
<!-- Site-wide overrides: -->
|
|
@@ -3,11 +3,11 @@ include /etc/nginx/modules/*.conf;
|
|
|
3
3
|
worker_processes 1;
|
|
4
4
|
error_log /dev/stderr notice;
|
|
5
5
|
pid /run/nginx/nginx.pid;
|
|
6
|
-
|
|
7
|
-
events {
|
|
8
|
-
worker_connections 1024;
|
|
9
|
-
}
|
|
10
|
-
|
|
6
|
+
|
|
7
|
+
events {
|
|
8
|
+
worker_connections 1024;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
11
|
http {
|
|
12
12
|
include /etc/nginx/mime.types;
|
|
13
13
|
default_type application/octet-stream;
|
|
@@ -32,17 +32,17 @@ http {
|
|
|
32
32
|
default upgrade;
|
|
33
33
|
'' close;
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
sendfile on;
|
|
37
|
-
keepalive_timeout 65;
|
|
38
|
-
|
|
39
|
-
server {
|
|
40
|
-
listen 80 default_server;
|
|
41
|
-
|
|
35
|
+
|
|
36
|
+
sendfile on;
|
|
37
|
+
keepalive_timeout 65;
|
|
38
|
+
|
|
39
|
+
server {
|
|
40
|
+
listen 80 default_server;
|
|
41
|
+
|
|
42
42
|
root /var/www/html;
|
|
43
43
|
index index.php;
|
|
44
44
|
error_page 404 /404.php;
|
|
45
|
-
|
|
45
|
+
|
|
46
46
|
# Super-documents are trusted and may contain inline scripts and styles.
|
|
47
47
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'self'; frame-ancestors 'self'" always;
|
|
48
48
|
add_header X-Content-Type-Options "nosniff" always;
|
|
@@ -112,7 +112,7 @@ http {
|
|
|
112
112
|
try_files $uri @php;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
# Never serve raw PHP files from nginx
|
|
115
|
+
# Never serve raw PHP files from nginx
|
|
116
116
|
location ~ \.php$ {
|
|
117
117
|
return 404;
|
|
118
118
|
}
|
|
@@ -128,8 +128,8 @@ http {
|
|
|
128
128
|
fastcgi_param REQUEST_URI /404;
|
|
129
129
|
fastcgi_param NESTBOX_ORIGINAL_URI $request_uri;
|
|
130
130
|
}
|
|
131
|
-
|
|
132
|
-
# Named location for PHP handling
|
|
131
|
+
|
|
132
|
+
# Named location for PHP handling
|
|
133
133
|
location @php {
|
|
134
134
|
include /etc/nginx/fastcgi_params;
|
|
135
135
|
fastcgi_pass php-fpm:9000;
|
|
@@ -137,7 +137,7 @@ http {
|
|
|
137
137
|
error_page 404 /404.php;
|
|
138
138
|
fastcgi_param SCRIPT_FILENAME /sources/php/index.php;
|
|
139
139
|
}
|
|
140
|
-
|
|
140
|
+
|
|
141
141
|
# Deny access to any file or directory that starts with a dot (.)
|
|
142
142
|
location ~ /\. {
|
|
143
143
|
deny all;
|
|
@@ -5,10 +5,6 @@ const baseUrl = (process.env.NESTBOX_CONTROL_URL || 'http://control:4088').repla
|
|
|
5
5
|
const timeoutMs = Number(process.env.NESTBOX_CONTROL_TIMEOUT_MS || '30000');
|
|
6
6
|
const tools = [
|
|
7
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
8
|
];
|
|
13
9
|
|
|
14
10
|
function request(method, path, body) {
|
|
@@ -53,28 +49,12 @@ async function call(name, raw) {
|
|
|
53
49
|
if (args.detach !== undefined) body.detach = Boolean(args.detach);
|
|
54
50
|
return { machine: args.machine, workdir: args.workdir, command: args.command, result: await request('POST', `/containers/${encodeURIComponent(args.machine)}/exec`, body) };
|
|
55
51
|
}
|
|
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
52
|
throw new Error(`Unknown tool: ${name}`);
|
|
73
53
|
}
|
|
74
54
|
|
|
75
55
|
function emit(value) { process.stdout.write(`${JSON.stringify(value)}\n`); }
|
|
76
56
|
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.
|
|
57
|
+
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.6.0' } } });
|
|
78
58
|
if (message.method === 'initialized') return;
|
|
79
59
|
if (message.method === 'tools/list') return emit({ jsonrpc: '2.0', id: message.id, result: { tools } });
|
|
80
60
|
if (message.method === 'tools/call') {
|
|
@@ -170,17 +170,15 @@ Use API-created sessions only for explicit page workflows. The shared header's O
|
|
|
170
170
|
|
|
171
171
|
## Container Control API
|
|
172
172
|
|
|
173
|
-
OpenCode has no Docker socket. Use the control MCP as the only path for commands in other containers
|
|
173
|
+
OpenCode has no Docker socket. Use the control MCP as the only path for commands in other containers:
|
|
174
174
|
|
|
175
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
176
|
|
|
181
|
-
|
|
177
|
+
Run project commands, including npm scripts, in the appropriate container. Use `detach: true` for commands expected to exceed the MCP request timeout. Native host execution is not provided; hand host-side activation to the user or external maintainer.
|
|
182
178
|
|
|
183
|
-
|
|
179
|
+
Do not call the control HTTP API directly from OpenCode, 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. Root exec requires an explicit target label.
|
|
180
|
+
|
|
181
|
+
The control log is the audit source of truth and requires no observability service. Commands operate in trusted containers and a trusted writable workspace; Docker socket access means this is a controlled capability, not a security sandbox.
|
|
184
182
|
|
|
185
183
|
## Git And Commit Policy
|
|
186
184
|
|
|
@@ -196,15 +194,17 @@ The control log is the audit source of truth and requires no observability servi
|
|
|
196
194
|
|
|
197
195
|
## Container Architecture
|
|
198
196
|
|
|
197
|
+
The package defaults to latest-stable container images and the latest system packages available in their configured repositories. Preserve this rolling policy unless the user explicitly requests pinning. Refresh images and package-install layers with `docker compose build --pull --no-cache` during host-side updates; running containers do not update themselves. Keep application library lockfiles authoritative. Verify the resulting PHP CLI and FPM versions, since Alpine packaging can lag upstream PHP.
|
|
198
|
+
|
|
199
199
|
The default Compose application has five services:
|
|
200
200
|
|
|
201
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.
|
|
202
202
|
- `php-fpm`: executes HTTP and CLI directions. It mounts the page tree and workspace writable because trusted pages may intentionally modify them.
|
|
203
203
|
- `rollup`: watches `scripts.js` and browser dependencies and writes local JS/CSS bundles into the page tree.
|
|
204
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
|
|
205
|
+
- `control`: owns the Docker socket and privately brokers label-gated container exec.
|
|
206
206
|
|
|
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.
|
|
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. No other service publishes a host port, and no background host runner is required.
|
|
208
208
|
|
|
209
209
|
Important files:
|
|
210
210
|
|
|
@@ -269,7 +269,7 @@ Choose one explicit integration mode:
|
|
|
269
269
|
For every added service, decide and document:
|
|
270
270
|
|
|
271
271
|
- Lifecycle owner.
|
|
272
|
-
-
|
|
272
|
+
- Image tag or build context, following the package's latest-stable default unless pinning is explicitly requested.
|
|
273
273
|
- Command and working directory.
|
|
274
274
|
- Non-root user and host UID/GID behavior where practical.
|
|
275
275
|
- Read-only and writable mounts.
|
|
@@ -293,7 +293,7 @@ You may edit Compose files, Dockerfiles, Nginx configuration, PHP configuration,
|
|
|
293
293
|
|
|
294
294
|
1. Validate every part that can be checked inside this container.
|
|
295
295
|
2. Identify the exact affected services.
|
|
296
|
-
3.
|
|
296
|
+
3. Give the user the narrowest host-side command for activation.
|
|
297
297
|
4. State that activation and host health checks remain pending.
|
|
298
298
|
5. Tell the user to reconnect if OpenCode itself must restart.
|
|
299
299
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "nestbox/site",
|
|
3
|
-
"description": "Nestbox containerized website stack — PHP dependencies",
|
|
4
|
-
"type": "project",
|
|
5
|
-
"license": "MIT",
|
|
6
|
-
"require": {
|
|
1
|
+
{
|
|
2
|
+
"name": "nestbox/site",
|
|
3
|
+
"description": "Nestbox containerized website stack — PHP dependencies",
|
|
4
|
+
"type": "project",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"require": {
|
|
7
7
|
"league/commonmark": "^2.5",
|
|
8
8
|
"symfony/yaml": "^7.1"
|
|
9
|
-
}
|
|
10
|
-
}
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
<?php
|
|
2
|
-
// Per-request bootstrap. Loaded once by the router.
|
|
1
|
+
<?php
|
|
2
|
+
// Per-request bootstrap. Loaded once by the router.
|
|
3
3
|
require_once '/vendor/autoload.php';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
<?php
|
|
2
|
-
// OPcache preload. Runs once when PHP-FPM starts so the autoloader and
|
|
3
|
-
// the league/commonmark classes are warm in shared memory across every
|
|
4
|
-
// request.
|
|
1
|
+
<?php
|
|
2
|
+
// OPcache preload. Runs once when PHP-FPM starts so the autoloader and
|
|
3
|
+
// the league/commonmark classes are warm in shared memory across every
|
|
4
|
+
// request.
|
|
5
5
|
require_once '/vendor/autoload.php';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@p10i/create-nestbox",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Create Nestbox projects.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -13,8 +13,6 @@
|
|
|
13
13
|
"docker/",
|
|
14
14
|
"home/",
|
|
15
15
|
"tests/",
|
|
16
|
-
"host-runner.mjs",
|
|
17
|
-
"package.host.json",
|
|
18
16
|
"docker-compose.yaml",
|
|
19
17
|
".dockerignore",
|
|
20
18
|
".env.example",
|
|
@@ -31,9 +29,7 @@
|
|
|
31
29
|
"!home/code/scripts-bundle.css"
|
|
32
30
|
],
|
|
33
31
|
"scripts": {
|
|
34
|
-
"
|
|
35
|
-
"test:host": "node tests/host-runner.mjs",
|
|
36
|
-
"test": "node tests/control.mjs && node tests/host-runner.mjs && node tests/creator.mjs",
|
|
32
|
+
"test": "node tests/control.mjs && node tests/creator.mjs",
|
|
37
33
|
"prepack": "node scripts/check-package.js"
|
|
38
34
|
},
|
|
39
35
|
"engines": {
|
package/scripts/check-package.js
CHANGED
|
@@ -11,9 +11,6 @@ const requiredPaths = [
|
|
|
11
11
|
'docker/rollup/Dockerfile',
|
|
12
12
|
'docker/control/Dockerfile',
|
|
13
13
|
'docker/control/server.py',
|
|
14
|
-
'docker/control/host_bridge.py',
|
|
15
|
-
'host-runner.mjs',
|
|
16
|
-
'package.host.json',
|
|
17
14
|
'CHANGELOG.md',
|
|
18
15
|
'home/configs/opencode/control-mcp.js',
|
|
19
16
|
'home/configs/opencode/instance.example.md',
|
|
@@ -21,7 +18,6 @@ const requiredPaths = [
|
|
|
21
18
|
'tests/nestbox.sh',
|
|
22
19
|
'tests/nestbox.ps1',
|
|
23
20
|
'tests/control.mjs',
|
|
24
|
-
'tests/host-runner.mjs',
|
|
25
21
|
'tests/creator.mjs'
|
|
26
22
|
];
|
|
27
23
|
|
|
@@ -38,10 +34,6 @@ if (!packageJson.files || packageJson.files.includes('.env')) {
|
|
|
38
34
|
throw new Error('package.json files must not include .env.');
|
|
39
35
|
}
|
|
40
36
|
|
|
41
|
-
for (const path of ['host-runner.mjs', 'package.host.json']) {
|
|
42
|
-
if (!packageJson.files.includes(path)) throw new Error(`package.json files must include ${path}.`);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
37
|
for (const path of ['!**/node_modules/', '!home/code/scripts-bundle.js', '!home/code/scripts-bundle.css']) {
|
|
46
38
|
if (!packageJson.files.includes(path)) throw new Error(`package.json files must include ${path}.`);
|
|
47
39
|
}
|
package/tests/control.mjs
CHANGED
|
@@ -3,9 +3,12 @@ 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');
|
|
7
6
|
const policyTest = String.raw`
|
|
8
7
|
import importlib.util
|
|
8
|
+
import http.client
|
|
9
|
+
import json
|
|
10
|
+
import threading
|
|
11
|
+
from http.server import ThreadingHTTPServer
|
|
9
12
|
spec = importlib.util.spec_from_file_location("control", r"${server}")
|
|
10
13
|
control = importlib.util.module_from_spec(spec)
|
|
11
14
|
spec.loader.exec_module(control)
|
|
@@ -26,92 +29,61 @@ target["Labels"]["com.docker.compose.service"] = "rollup"
|
|
|
26
29
|
assert "not allowed" in control.policy_error(source, target, "1000")
|
|
27
30
|
source["Labels"].pop("nestbox.exec.targets")
|
|
28
31
|
assert "no allowed" in control.policy_error(source, target, "1000")
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
except FileNotFoundError:
|
|
58
|
-
pass
|
|
59
|
-
original_ttl = control.HOST_HEARTBEAT_TTL
|
|
60
|
-
control.HOST_HEARTBEAT_TTL = 0
|
|
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"})
|
|
32
|
+
source["Labels"]["nestbox.exec.targets"] = "php-fpm"
|
|
33
|
+
target["Labels"]["com.docker.compose.service"] = "php-fpm"
|
|
34
|
+
target["Labels"].pop("nestbox.exec.allow-root")
|
|
35
|
+
|
|
36
|
+
# Exercise the public control contract without requiring a real Docker socket.
|
|
37
|
+
calls = []
|
|
38
|
+
def docker_request(method, path, body=None, max_bytes=None):
|
|
39
|
+
calls.append((method, path, body))
|
|
40
|
+
if path == "/_ping": return b"OK"
|
|
41
|
+
if path == "/containers/abc/json": return {"Config": {"User": "1000:1000"}}
|
|
42
|
+
if path == "/containers/abc/exec": return {"Id": "exec-test"}
|
|
43
|
+
if path == "/exec/exec-test/start": return (b"hello\n", 6)
|
|
44
|
+
if path == "/exec/exec-test/json": return {"ExitCode": 0}
|
|
45
|
+
raise AssertionError(path)
|
|
46
|
+
control.docker_request = docker_request
|
|
47
|
+
control.find_by_ip = lambda ip: source
|
|
48
|
+
control.find_by_name = lambda name: target if name == "php-fpm" else None
|
|
49
|
+
httpd = ThreadingHTTPServer(("127.0.0.1", 0), control.Handler)
|
|
50
|
+
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
|
51
|
+
thread.start()
|
|
52
|
+
def request(method, path, body=None):
|
|
53
|
+
connection = http.client.HTTPConnection(*httpd.server_address, timeout=5)
|
|
54
|
+
try:
|
|
55
|
+
connection.request(method, path, json.dumps(body) if body is not None else None)
|
|
56
|
+
response = connection.getresponse()
|
|
57
|
+
return response.status, json.loads(response.read())
|
|
58
|
+
finally:
|
|
59
|
+
connection.close()
|
|
100
60
|
try:
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
61
|
+
status, health = request("GET", "/health")
|
|
62
|
+
assert status == 200 and health == {"ok": True, "dockerSocketAccess": True}
|
|
63
|
+
for method, path in [("GET", "/npm/scripts"), ("POST", "/npm/run"), ("POST", "/npm/scripts/request"), ("POST", "/npm/scripts/confirm"), ("POST", "/host/connect"), ("GET", "/host/jobs/next")]:
|
|
64
|
+
assert request(method, path, {} if method == "POST" else None)[0] == 404
|
|
65
|
+
body = {"workdir": "/workspace", "command": ["php", "--version"]}
|
|
66
|
+
status, result = request("POST", "/containers/php-fpm/exec", body)
|
|
67
|
+
assert status == 200 and result["exitCode"] == 0 and result["output"] == "hello\n"
|
|
68
|
+
assert result["outputTotalBytes"] == 6 and result["outputTruncated"] is False
|
|
69
|
+
created = next(call[2] for call in calls if call[1] == "/containers/abc/exec")
|
|
70
|
+
assert created["Cmd"] == body["command"] and created["WorkingDir"] == "/workspace"
|
|
71
|
+
calls.clear()
|
|
72
|
+
assert request("POST", "/containers/php-fpm/exec", {**body, "user": "0"})[0] == 403
|
|
73
|
+
assert not any(path.endswith("/exec") for _, path, _ in calls)
|
|
74
|
+
assert request("POST", "/containers/php-fpm/exec", {**body, "command": "php --version"})[0] == 400
|
|
75
|
+
assert request("POST", "/containers/php-fpm/exec", {**body, "workdir": "relative"})[0] == 400
|
|
76
|
+
finally:
|
|
77
|
+
httpd.shutdown()
|
|
78
|
+
httpd.server_close()
|
|
79
|
+
thread.join()
|
|
105
80
|
`;
|
|
106
81
|
|
|
107
82
|
let result = spawnSync(python, ['-c', policyTest], {
|
|
108
83
|
encoding: 'utf8',
|
|
109
84
|
env: { ...process.env, COMPOSE_PROJECT_NAME: 'nestbox-test', PYTHONDONTWRITEBYTECODE: '1' },
|
|
110
85
|
});
|
|
111
|
-
if (result.status !== 0) throw new Error(`Control policy test failed: ${result.stderr || result.stdout}`);
|
|
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}`);
|
|
86
|
+
if (result.status !== 0) throw new Error(`Control policy/API test failed: ${result.error?.message || result.stderr || result.stdout}`);
|
|
115
87
|
|
|
116
88
|
result = spawnSync(python, [server], { encoding: 'utf8', env: { ...process.env, COMPOSE_PROJECT_NAME: '', PYTHONDONTWRITEBYTECODE: '1' } });
|
|
117
89
|
if (result.status === 0 || !result.stderr.includes('COMPOSE_PROJECT_NAME')) throw new Error('Control did not fail closed without a project name.');
|
|
@@ -119,13 +91,13 @@ if (result.status === 0 || !result.stderr.includes('COMPOSE_PROJECT_NAME')) thro
|
|
|
119
91
|
const messages = [
|
|
120
92
|
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
|
121
93
|
{ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} },
|
|
94
|
+
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'npm_run', arguments: { script: 'build' } } },
|
|
122
95
|
].map(value => JSON.stringify(value)).join('\n') + '\n';
|
|
123
96
|
result = spawnSync(process.execPath, [resolve('home/configs/opencode/control-mcp.js')], { input: messages, encoding: 'utf8' });
|
|
124
97
|
if (result.status !== 0) throw new Error(`Control MCP test failed: ${result.stderr}`);
|
|
125
98
|
const responses = result.stdout.trim().split(/\r?\n/).map(line => JSON.parse(line));
|
|
126
99
|
const names = responses.find(value => value.id === 2)?.result?.tools?.map(tool => tool.name) || [];
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
100
|
+
if (names.length !== 1 || names[0] !== 'docker_exec') throw new Error('Control MCP must expose only docker_exec.');
|
|
101
|
+
if (!responses.find(value => value.id === 3)?.error?.message.includes('Unknown tool')) throw new Error('Control MCP accepted a removed host tool.');
|
|
130
102
|
|
|
131
103
|
console.log('Control tests passed.');
|
package/tests/creator.mjs
CHANGED
|
@@ -6,36 +6,31 @@ import { join, resolve } from 'node:path';
|
|
|
6
6
|
const fixture = await mkdtemp(join(tmpdir(), 'nestbox-creator-'));
|
|
7
7
|
|
|
8
8
|
function create(target, layout) {
|
|
9
|
-
const result = spawnSync(process.execPath, [resolve('bin/create-nestbox.js'), target, '--layout', layout, '--yes', '--no-git'], { encoding: 'utf8'
|
|
9
|
+
const result = spawnSync(process.execPath, [resolve('bin/create-nestbox.js'), target, '--layout', layout, '--yes', '--no-git'], { encoding: 'utf8' });
|
|
10
10
|
if (result.status !== 0) throw new Error(`Creator failed for ${layout}: ${result.stderr || result.stdout}`);
|
|
11
|
+
if (/npm run host|test:host/.test(result.stdout)) throw new Error('Creator still asks for a host runner.');
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
try {
|
|
14
15
|
const nested = join(fixture, 'nested');
|
|
15
16
|
await mkdir(nested);
|
|
16
|
-
|
|
17
|
+
const manifest = '{"name":"existing","scripts":{"host":"node app.mjs","build":"node build.mjs"}}\n';
|
|
18
|
+
await writeFile(join(nested, 'package.json'), manifest);
|
|
17
19
|
create(nested, 'nestbox');
|
|
18
|
-
|
|
19
|
-
if (nestedPackage.name !== 'existing' || nestedPackage.scripts.build !== 'node build.mjs') throw new Error('Creator replaced existing package metadata.');
|
|
20
|
-
if (nestedPackage.scripts.host !== 'node .nestbox/host-runner.mjs') throw new Error('Nested host script has the wrong path.');
|
|
20
|
+
if (await readFile(join(nested, 'package.json'), 'utf8') !== manifest) throw new Error('Creator modified the workspace package.json.');
|
|
21
21
|
const nestedEnv = await readFile(join(nested, '.nestbox', '.env'), 'utf8');
|
|
22
|
-
if (!/^WORKSPACE_PATH=\.\.$/m.test(nestedEnv)
|
|
23
|
-
if ((await readdir(fixture)).includes('state')) throw new Error('Creator wrote external host state.');
|
|
24
|
-
const npmCommand = process.env.npm_execpath ? process.execPath : (process.platform === 'win32' ? 'npm.cmd' : 'npm');
|
|
25
|
-
const npmArgs = process.env.npm_execpath ? [process.env.npm_execpath, 'run', 'test:host'] : ['run', 'test:host'];
|
|
26
|
-
const nestedHostTest = spawnSync(npmCommand, npmArgs, { cwd: nested, encoding: 'utf8' });
|
|
27
|
-
if (nestedHostTest.status !== 0) throw new Error(`Nested host test script failed: ${nestedHostTest.error?.message || nestedHostTest.stderr || nestedHostTest.stdout}`);
|
|
22
|
+
if (!/^WORKSPACE_PATH=\.\.$/m.test(nestedEnv)) throw new Error('Nested workspace path is incorrect.');
|
|
28
23
|
|
|
29
24
|
const direct = join(fixture, 'direct');
|
|
30
25
|
create(direct, 'current');
|
|
31
|
-
const
|
|
32
|
-
|
|
26
|
+
const directEntries = await readdir(direct);
|
|
27
|
+
for (const entry of ['package.json', 'package.host.json', 'host-runner.mjs']) {
|
|
28
|
+
if (directEntries.includes(entry)) throw new Error(`Creator installed obsolete host file: ${entry}`);
|
|
29
|
+
}
|
|
33
30
|
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
await
|
|
37
|
-
const conflict = spawnSync(process.execPath, [resolve('bin/create-nestbox.js'), conflicting, '--layout', 'nestbox', '--yes', '--no-git'], { encoding: 'utf8', env: { ...process.env, NESTBOX_CREATOR_STATE_ROOT: join(fixture, 'state') } });
|
|
38
|
-
if (conflict.status === 0 || !conflict.stderr.includes('Refusing to overwrite')) throw new Error('Creator accepted a conflicting host script.');
|
|
31
|
+
const empty = join(fixture, 'empty');
|
|
32
|
+
create(empty, 'nestbox');
|
|
33
|
+
if ((await readdir(empty)).includes('package.json')) throw new Error('Creator created a workspace manifest.');
|
|
39
34
|
|
|
40
35
|
console.log('Creator tests passed.');
|
|
41
36
|
} finally {
|
package/tests/nestbox.ps1
CHANGED
|
@@ -119,9 +119,9 @@ try {
|
|
|
119
119
|
if ($openCodeInstance.Contains('Commit policy:')) { Pass 'OpenCode instance policy records commit behavior' } else { Fail 'OpenCode instance policy omits commit behavior' }
|
|
120
120
|
if ($openCodeInstance.Contains('Communication language:')) { Pass 'OpenCode instance policy records communication language' } else { Fail 'OpenCode instance policy omits communication language' }
|
|
121
121
|
$openCodeConfig = [IO.File]::ReadAllText((Join-Path $Root 'home\configs\opencode\opencode.json'))
|
|
122
|
-
if ($openCodeConfig.Contains('"instance.md"')) { Pass 'OpenCode loads instance policy directly' } else { Fail 'OpenCode does not load instance policy' }
|
|
122
|
+
if ($openCodeConfig.Contains('"/etc/opencode/instance.md"')) { Pass 'OpenCode loads instance policy directly' } else { Fail 'OpenCode does not load instance policy' }
|
|
123
123
|
if ($openCodeConfig.Contains('"control"')) { Pass 'OpenCode registers the control MCP' } else { Fail 'OpenCode does not register the control MCP' }
|
|
124
|
-
if ((Test-Path -LiteralPath (Join-Path $Root 'docker\control\server.py')) -and (Test-Path -LiteralPath (Join-Path $Root '
|
|
124
|
+
if ((Test-Path -LiteralPath (Join-Path $Root 'docker\control\server.py')) -and (Test-Path -LiteralPath (Join-Path $Root 'home\configs\opencode\control-mcp.js'))) { Pass 'Container control sources exist' } else { Fail 'Container control source is missing' }
|
|
125
125
|
|
|
126
126
|
$result = Invoke-Compose @('config', '--quiet') -Quiet
|
|
127
127
|
if ($result.Code -eq 0) { Pass 'Compose configuration is valid' } else { Fail 'Compose configuration is invalid' }
|
|
@@ -137,7 +137,7 @@ try {
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
if ($Start) {
|
|
140
|
-
$result = Invoke-Compose @('build', 'nginx', 'php-fpm', 'rollup', 'control', 'opencode')
|
|
140
|
+
$result = Invoke-Compose @('build', '--pull', '--no-cache', 'nginx', 'php-fpm', 'rollup', 'control', 'opencode')
|
|
141
141
|
if ($result.Code -eq 0) { Pass 'All service images build' } else { Fail 'One or more service images failed to build' }
|
|
142
142
|
|
|
143
143
|
# Do not start the bind-mounted Rollup watcher: it intentionally writes
|
package/tests/nestbox.sh
CHANGED
|
@@ -159,9 +159,9 @@ grep -Fq 'deliberately has no Docker socket' "$ROOT/home/configs/opencode/instru
|
|
|
159
159
|
[[ -f "$ROOT/home/configs/opencode/instance.md" ]] && pass 'Installed OpenCode instance policy exists' || fail 'Copy instance.example.md to instance.md before startup'
|
|
160
160
|
grep -Fq 'Commit policy:' "$ROOT/home/configs/opencode/instance.md" && pass 'OpenCode instance policy records commit behavior' || fail 'OpenCode instance policy omits commit behavior'
|
|
161
161
|
grep -Fq 'Communication language:' "$ROOT/home/configs/opencode/instance.md" && pass 'OpenCode instance policy records communication language' || fail 'OpenCode instance policy omits communication language'
|
|
162
|
-
grep -Fq '"instance.md"' "$ROOT/home/configs/opencode/opencode.json" && pass 'OpenCode loads instance policy directly' || fail 'OpenCode does not load instance policy'
|
|
162
|
+
grep -Fq '"/etc/opencode/instance.md"' "$ROOT/home/configs/opencode/opencode.json" && pass 'OpenCode loads instance policy directly' || fail 'OpenCode does not load instance policy'
|
|
163
163
|
grep -Fq '"control"' "$ROOT/home/configs/opencode/opencode.json" && pass 'OpenCode registers the control MCP' || fail 'OpenCode does not register the control MCP'
|
|
164
|
-
[[ -f "$ROOT/docker/control/server.py" && -f "$ROOT/
|
|
164
|
+
[[ -f "$ROOT/docker/control/server.py" && -f "$ROOT/home/configs/opencode/control-mcp.js" ]] && pass 'Container control sources exist' || fail 'Container control source is missing'
|
|
165
165
|
|
|
166
166
|
if compose config --quiet; then
|
|
167
167
|
pass 'Compose configuration is valid'
|
|
@@ -186,7 +186,7 @@ while IFS= read -r service; do
|
|
|
186
186
|
done <<<"$running_services"
|
|
187
187
|
|
|
188
188
|
if ((START)); then
|
|
189
|
-
if compose build nginx php-fpm rollup control opencode; then
|
|
189
|
+
if compose build --pull --no-cache nginx php-fpm rollup control opencode; then
|
|
190
190
|
pass 'All service images build'
|
|
191
191
|
else
|
|
192
192
|
fail 'One or more service images failed to build'
|