@p10i/create-nestbox 0.4.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/.env.example +0 -4
- package/CHANGELOG.md +16 -0
- package/INSTALL.md +32 -24
- package/README.md +22 -4
- package/bin/create-nestbox.js +4 -85
- package/docker/control/Dockerfile +2 -2
- package/docker/control/server.py +4 -383
- package/docker/nginx/Dockerfile +11 -11
- package/docker/opencode/Dockerfile +6 -2
- package/docker/php-fpm/Dockerfile +22 -22
- package/docker-compose.yaml +1 -29
- package/home/code/__includes/footer.php +2 -2
- package/home/code/__includes/header.php +5 -5
- package/home/configs/nginx/nginx.conf +17 -34
- package/home/configs/opencode/control-mcp.js +1 -21
- package/home/configs/opencode/instructions.md +11 -12
- 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 -7
- package/tests/control.mjs +56 -23
- package/tests/creator.mjs +14 -19
- package/tests/nestbox.ps1 +6 -11
- package/tests/nestbox.sh +8 -9
- package/host-runner.mjs +0 -292
- package/package.host.json +0 -8
- package/tests/host-runner.mjs +0 -143
|
@@ -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;
|
|
@@ -74,23 +74,6 @@ http {
|
|
|
74
74
|
nchan_eventsource_ping_interval 20s;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
# Authenticated native host-runner transport. Control remains private;
|
|
78
|
-
# this route shares the one published Nestbox gateway port.
|
|
79
|
-
location ^~ /_nestbox/host/ {
|
|
80
|
-
resolver 127.0.0.11 ipv6=off valid=10s;
|
|
81
|
-
set $nestbox_host_control http://control:4089;
|
|
82
|
-
rewrite ^/_nestbox(/host/.*)$ $1 break;
|
|
83
|
-
proxy_pass $nestbox_host_control;
|
|
84
|
-
proxy_http_version 1.1;
|
|
85
|
-
proxy_set_header Host $host;
|
|
86
|
-
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
87
|
-
proxy_set_header X-Forwarded-Proto $scheme;
|
|
88
|
-
proxy_buffering off;
|
|
89
|
-
proxy_request_buffering off;
|
|
90
|
-
proxy_read_timeout 40s;
|
|
91
|
-
client_max_body_size 64m;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
77
|
location ^~ /_nestbox/ {
|
|
95
78
|
return 404;
|
|
96
79
|
}
|
|
@@ -129,7 +112,7 @@ http {
|
|
|
129
112
|
try_files $uri @php;
|
|
130
113
|
}
|
|
131
114
|
|
|
132
|
-
# Never serve raw PHP files from nginx
|
|
115
|
+
# Never serve raw PHP files from nginx
|
|
133
116
|
location ~ \.php$ {
|
|
134
117
|
return 404;
|
|
135
118
|
}
|
|
@@ -145,8 +128,8 @@ http {
|
|
|
145
128
|
fastcgi_param REQUEST_URI /404;
|
|
146
129
|
fastcgi_param NESTBOX_ORIGINAL_URI $request_uri;
|
|
147
130
|
}
|
|
148
|
-
|
|
149
|
-
# Named location for PHP handling
|
|
131
|
+
|
|
132
|
+
# Named location for PHP handling
|
|
150
133
|
location @php {
|
|
151
134
|
include /etc/nginx/fastcgi_params;
|
|
152
135
|
fastcgi_pass php-fpm:9000;
|
|
@@ -154,7 +137,7 @@ http {
|
|
|
154
137
|
error_page 404 /404.php;
|
|
155
138
|
fastcgi_param SCRIPT_FILENAME /sources/php/index.php;
|
|
156
139
|
}
|
|
157
|
-
|
|
140
|
+
|
|
158
141
|
# Deny access to any file or directory that starts with a dot (.)
|
|
159
142
|
location ~ /\. {
|
|
160
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
|
|
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
|
|
|
@@ -226,7 +226,6 @@ The engine's untracked `/nestbox/.env` controls runtime paths, exposure, authent
|
|
|
226
226
|
- `COMPOSE_PROJECT_NAME`: unique installation identifier; collisions can mix unrelated containers and networks.
|
|
227
227
|
- `BIND_ADDRESS`: loopback for local use or an explicitly approved network bind.
|
|
228
228
|
- `WEB_PORT`: confirmed host gateway port, normally `4180`.
|
|
229
|
-
- `NESTBOX_HOST_TOKEN_FILE`: creator-managed private runner token outside the mounted workspace; only `control` receives it.
|
|
230
229
|
- `APP_UID` and `APP_GID`: PHP-FPM ownership for writable bind mounts.
|
|
231
230
|
- `NESTBOX_URL_PREFIX`: public path prefix when an outer proxy strips that prefix before forwarding.
|
|
232
231
|
- `NESTBOX_OPENCODE_PUBLIC_URL`: explicit public OpenCode origin when `agent.<nestbox-host>` cannot be derived.
|
|
@@ -270,7 +269,7 @@ Choose one explicit integration mode:
|
|
|
270
269
|
For every added service, decide and document:
|
|
271
270
|
|
|
272
271
|
- Lifecycle owner.
|
|
273
|
-
-
|
|
272
|
+
- Image tag or build context, following the package's latest-stable default unless pinning is explicitly requested.
|
|
274
273
|
- Command and working directory.
|
|
275
274
|
- Non-root user and host UID/GID behavior where practical.
|
|
276
275
|
- Read-only and writable mounts.
|
|
@@ -294,7 +293,7 @@ You may edit Compose files, Dockerfiles, Nginx configuration, PHP configuration,
|
|
|
294
293
|
|
|
295
294
|
1. Validate every part that can be checked inside this container.
|
|
296
295
|
2. Identify the exact affected services.
|
|
297
|
-
3.
|
|
296
|
+
3. Give the user the narrowest host-side command for activation.
|
|
298
297
|
4. State that activation and host health checks remain pending.
|
|
299
298
|
5. Tell the user to reconnect if OpenCode itself must restart.
|
|
300
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,8 +11,6 @@ const requiredPaths = [
|
|
|
11
11
|
'docker/rollup/Dockerfile',
|
|
12
12
|
'docker/control/Dockerfile',
|
|
13
13
|
'docker/control/server.py',
|
|
14
|
-
'host-runner.mjs',
|
|
15
|
-
'package.host.json',
|
|
16
14
|
'CHANGELOG.md',
|
|
17
15
|
'home/configs/opencode/control-mcp.js',
|
|
18
16
|
'home/configs/opencode/instance.example.md',
|
|
@@ -20,7 +18,6 @@ const requiredPaths = [
|
|
|
20
18
|
'tests/nestbox.sh',
|
|
21
19
|
'tests/nestbox.ps1',
|
|
22
20
|
'tests/control.mjs',
|
|
23
|
-
'tests/host-runner.mjs',
|
|
24
21
|
'tests/creator.mjs'
|
|
25
22
|
];
|
|
26
23
|
|
|
@@ -37,10 +34,6 @@ if (!packageJson.files || packageJson.files.includes('.env')) {
|
|
|
37
34
|
throw new Error('package.json files must not include .env.');
|
|
38
35
|
}
|
|
39
36
|
|
|
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
|
-
|
|
44
37
|
for (const path of ['!**/node_modules/', '!home/code/scripts-bundle.js', '!home/code/scripts-bundle.css']) {
|
|
45
38
|
if (!packageJson.files.includes(path)) throw new Error(`package.json files must include ${path}.`);
|
|
46
39
|
}
|
package/tests/control.mjs
CHANGED
|
@@ -5,6 +5,10 @@ const python = process.env.PYTHON || (process.platform === 'win32' ? 'python' :
|
|
|
5
5
|
const server = resolve('docker/control/server.py');
|
|
6
6
|
const policyTest = String.raw`
|
|
7
7
|
import importlib.util
|
|
8
|
+
import http.client
|
|
9
|
+
import json
|
|
10
|
+
import threading
|
|
11
|
+
from http.server import ThreadingHTTPServer
|
|
8
12
|
spec = importlib.util.spec_from_file_location("control", r"${server}")
|
|
9
13
|
control = importlib.util.module_from_spec(spec)
|
|
10
14
|
spec.loader.exec_module(control)
|
|
@@ -25,32 +29,61 @@ target["Labels"]["com.docker.compose.service"] = "rollup"
|
|
|
25
29
|
assert "not allowed" in control.policy_error(source, target, "1000")
|
|
26
30
|
source["Labels"].pop("nestbox.exec.targets")
|
|
27
31
|
assert "no allowed" in control.policy_error(source, target, "1000")
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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()
|
|
60
|
+
try:
|
|
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()
|
|
47
80
|
`;
|
|
48
81
|
|
|
49
82
|
let result = spawnSync(python, ['-c', policyTest], {
|
|
50
83
|
encoding: 'utf8',
|
|
51
84
|
env: { ...process.env, COMPOSE_PROJECT_NAME: 'nestbox-test', PYTHONDONTWRITEBYTECODE: '1' },
|
|
52
85
|
});
|
|
53
|
-
if (result.status !== 0) throw new Error(`Control policy test failed: ${result.stderr || result.stdout}`);
|
|
86
|
+
if (result.status !== 0) throw new Error(`Control policy/API test failed: ${result.error?.message || result.stderr || result.stdout}`);
|
|
54
87
|
|
|
55
88
|
result = spawnSync(python, [server], { encoding: 'utf8', env: { ...process.env, COMPOSE_PROJECT_NAME: '', PYTHONDONTWRITEBYTECODE: '1' } });
|
|
56
89
|
if (result.status === 0 || !result.stderr.includes('COMPOSE_PROJECT_NAME')) throw new Error('Control did not fail closed without a project name.');
|
|
@@ -58,13 +91,13 @@ if (result.status === 0 || !result.stderr.includes('COMPOSE_PROJECT_NAME')) thro
|
|
|
58
91
|
const messages = [
|
|
59
92
|
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
|
60
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' } } },
|
|
61
95
|
].map(value => JSON.stringify(value)).join('\n') + '\n';
|
|
62
96
|
result = spawnSync(process.execPath, [resolve('home/configs/opencode/control-mcp.js')], { input: messages, encoding: 'utf8' });
|
|
63
97
|
if (result.status !== 0) throw new Error(`Control MCP test failed: ${result.stderr}`);
|
|
64
98
|
const responses = result.stdout.trim().split(/\r?\n/).map(line => JSON.parse(line));
|
|
65
99
|
const names = responses.find(value => value.id === 2)?.result?.tools?.map(tool => tool.name) || [];
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
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.');
|
|
69
102
|
|
|
70
103
|
console.log('Control tests passed.');
|
package/tests/creator.mjs
CHANGED
|
@@ -1,41 +1,36 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
5
5
|
|
|
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
|
-
|
|
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.');
|
|
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
|
@@ -91,11 +91,6 @@ function Invoke-Http([string]$Path, [string]$HostHeader = '', [string]$Method =
|
|
|
91
91
|
|
|
92
92
|
try {
|
|
93
93
|
New-Item -ItemType Directory -Path $TempDirectory | Out-Null
|
|
94
|
-
$configuredTokenFile = [regex]::Match([IO.File]::ReadAllText((Join-Path $Root '.env')), '(?m)^NESTBOX_HOST_TOKEN_FILE=(.+)$').Groups[1].Value.Trim()
|
|
95
|
-
if (-not $env:NESTBOX_HOST_TOKEN_FILE -and -not $configuredTokenFile) {
|
|
96
|
-
$env:NESTBOX_HOST_TOKEN_FILE = Join-Path $TempDirectory 'host-token'
|
|
97
|
-
[IO.File]::WriteAllText($env:NESTBOX_HOST_TOKEN_FILE, 'test-token-' + ('0' * 54))
|
|
98
|
-
}
|
|
99
94
|
|
|
100
95
|
if (Get-Command docker -ErrorAction SilentlyContinue) { Pass 'docker is available' } else { Fail 'docker is required'; throw 'Missing Docker' }
|
|
101
96
|
if (Get-Command curl.exe -ErrorAction SilentlyContinue) { Pass 'curl.exe is available' } else { Fail 'curl.exe is required'; throw 'Missing curl.exe' }
|
|
@@ -124,9 +119,9 @@ try {
|
|
|
124
119
|
if ($openCodeInstance.Contains('Commit policy:')) { Pass 'OpenCode instance policy records commit behavior' } else { Fail 'OpenCode instance policy omits commit behavior' }
|
|
125
120
|
if ($openCodeInstance.Contains('Communication language:')) { Pass 'OpenCode instance policy records communication language' } else { Fail 'OpenCode instance policy omits communication language' }
|
|
126
121
|
$openCodeConfig = [IO.File]::ReadAllText((Join-Path $Root 'home\configs\opencode\opencode.json'))
|
|
127
|
-
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' }
|
|
128
123
|
if ($openCodeConfig.Contains('"control"')) { Pass 'OpenCode registers the control MCP' } else { Fail 'OpenCode does not register the control MCP' }
|
|
129
|
-
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' }
|
|
130
125
|
|
|
131
126
|
$result = Invoke-Compose @('config', '--quiet') -Quiet
|
|
132
127
|
if ($result.Code -eq 0) { Pass 'Compose configuration is valid' } else { Fail 'Compose configuration is invalid' }
|
|
@@ -142,7 +137,7 @@ try {
|
|
|
142
137
|
}
|
|
143
138
|
|
|
144
139
|
if ($Start) {
|
|
145
|
-
$result = Invoke-Compose @('build', 'nginx', 'php-fpm', 'rollup', 'control', 'opencode')
|
|
140
|
+
$result = Invoke-Compose @('build', '--pull', '--no-cache', 'nginx', 'php-fpm', 'rollup', 'control', 'opencode')
|
|
146
141
|
if ($result.Code -eq 0) { Pass 'All service images build' } else { Fail 'One or more service images failed to build' }
|
|
147
142
|
|
|
148
143
|
# Do not start the bind-mounted Rollup watcher: it intentionally writes
|
|
@@ -254,14 +249,14 @@ try {
|
|
|
254
249
|
if ($null -eq $controlInspect.NetworkSettings.Ports.'4088/tcp') { Pass 'Control API is not published on the host' } else { Fail 'Control API is published on the host' }
|
|
255
250
|
$runnerBinding = $controlInspect.NetworkSettings.Ports.'4089/tcp'
|
|
256
251
|
if ($null -eq $runnerBinding) { Pass 'Host runner API is not published separately' } else { Fail 'Host runner API is published separately' }
|
|
252
|
+
if ($null -eq $controlInspect.Config.ExposedPorts.'4089/tcp') { Pass 'Host runner API is not exposed by the control image' } else { Fail 'Host runner API is exposed by the control image' }
|
|
257
253
|
$response = Invoke-Http '/_nestbox/host/health'
|
|
258
|
-
if ($response.Status -eq
|
|
254
|
+
if ($response.Status -eq 404) { Pass 'Public host runner path is unavailable' } else { Fail "Host runner gateway returned HTTP $($response.Status) instead of 404" }
|
|
259
255
|
$openCodeId = Get-ContainerId 'opencode'
|
|
260
256
|
$openCodeInspect = @(& docker inspect $openCodeId 2>$null | ConvertFrom-Json)[0]
|
|
261
257
|
if (@($openCodeInspect.Mounts.Destination) -notcontains '/var/run/docker.sock') { Pass 'OpenCode does not receive the Docker socket' } else { Fail 'OpenCode receives the Docker socket' }
|
|
262
|
-
if (@($openCodeInspect.Mounts.Destination) -notcontains '/run/secrets/nestbox-host-token' -and @($openCodeInspect.Config.Env) -notmatch '^NESTBOX_HOST_TOKEN=') { Pass 'OpenCode cannot read the host runner token' } else { Fail 'OpenCode can read the host runner token' }
|
|
263
258
|
if (@($controlInspect.Mounts.Destination) -contains '/var/run/docker.sock') { Pass 'Control receives the Docker socket' } else { Fail 'Control does not receive the Docker socket' }
|
|
264
|
-
if (@($controlInspect.Mounts.Destination) -
|
|
259
|
+
if (@($controlInspect.Mounts.Destination) -notcontains '/run/secrets/nestbox-host-token' -and @($controlInspect.Config.Env) -notmatch '^NESTBOX_HOST_TOKEN') { Pass 'Control has no host token mount or environment' } else { Fail 'Control still has host token configuration' }
|
|
265
260
|
|
|
266
261
|
if ($indexPresent) {
|
|
267
262
|
$result = Invoke-Compose @('exec', '-T', 'php-fpm', 'php', '-l', '/home/code/index.php') -Quiet
|
package/tests/nestbox.sh
CHANGED
|
@@ -141,8 +141,6 @@ else
|
|
|
141
141
|
fi
|
|
142
142
|
|
|
143
143
|
TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/nestbox-test.XXXXXX")"
|
|
144
|
-
configured_token_file="$(sed -n 's/^NESTBOX_HOST_TOKEN_FILE=//p' "$ROOT/.env" | tail -n 1)"
|
|
145
|
-
if [[ -z "${NESTBOX_HOST_TOKEN_FILE:-}" && -z "$configured_token_file" ]]; then export NESTBOX_HOST_TOKEN_FILE="$TEMP_DIR/host-token"; printf 'test-token-%054d\n' 0 >"$NESTBOX_HOST_TOKEN_FILE"; fi
|
|
146
144
|
|
|
147
145
|
[[ -f "$ROOT/.env" ]] && pass '.env exists' || fail '.env is missing'
|
|
148
146
|
[[ ! -d "$ROOT/home/.git" ]] && pass 'Page and configuration tree has no embedded Git repository' || fail 'home/.git must not be included in an installation'
|
|
@@ -161,9 +159,9 @@ grep -Fq 'deliberately has no Docker socket' "$ROOT/home/configs/opencode/instru
|
|
|
161
159
|
[[ -f "$ROOT/home/configs/opencode/instance.md" ]] && pass 'Installed OpenCode instance policy exists' || fail 'Copy instance.example.md to instance.md before startup'
|
|
162
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'
|
|
163
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'
|
|
164
|
-
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'
|
|
165
163
|
grep -Fq '"control"' "$ROOT/home/configs/opencode/opencode.json" && pass 'OpenCode registers the control MCP' || fail 'OpenCode does not register the control MCP'
|
|
166
|
-
[[ -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'
|
|
167
165
|
|
|
168
166
|
if compose config --quiet; then
|
|
169
167
|
pass 'Compose configuration is valid'
|
|
@@ -188,7 +186,7 @@ while IFS= read -r service; do
|
|
|
188
186
|
done <<<"$running_services"
|
|
189
187
|
|
|
190
188
|
if ((START)); then
|
|
191
|
-
if compose build nginx php-fpm rollup control opencode; then
|
|
189
|
+
if compose build --pull --no-cache nginx php-fpm rollup control opencode; then
|
|
192
190
|
pass 'All service images build'
|
|
193
191
|
else
|
|
194
192
|
fail 'One or more service images failed to build'
|
|
@@ -349,16 +347,17 @@ control_binding="$(docker inspect --format '{{json (index .NetworkSettings.Ports
|
|
|
349
347
|
[[ -z "$control_binding" || "$control_binding" == "null" ]] && pass 'Control API is not published on the host' || fail 'Control API is published on the host'
|
|
350
348
|
runner_binding="$(docker inspect --format '{{json (index .NetworkSettings.Ports "4089/tcp")}}' "$control_id" 2>/dev/null || true)"
|
|
351
349
|
[[ -z "$runner_binding" || "$runner_binding" == "null" ]] && pass 'Host runner API is not published separately' || fail 'Host runner API is published separately'
|
|
350
|
+
runner_exposed="$(docker inspect --format '{{json (index .Config.ExposedPorts "4089/tcp")}}' "$control_id" 2>/dev/null || true)"
|
|
351
|
+
[[ -z "$runner_exposed" || "$runner_exposed" == "null" ]] && pass 'Host runner API is not exposed by the control image' || fail 'Host runner API is exposed by the control image'
|
|
352
352
|
status="$(http_request '/_nestbox/host/health' 2>/dev/null || printf '000')"
|
|
353
|
-
[[ "$status" == "
|
|
353
|
+
[[ "$status" == "404" ]] && pass 'Public host runner path is unavailable' || fail "Host runner gateway returned HTTP $status instead of 404"
|
|
354
354
|
opencode_id="$(container_id opencode)"
|
|
355
355
|
opencode_mounts="$(docker inspect --format '{{range .Mounts}}{{println .Source .Destination}}{{end}}' "$opencode_id" 2>/dev/null || true)"
|
|
356
356
|
grep -Fq '/var/run/docker.sock' <<<"$opencode_mounts" && fail 'OpenCode receives the Docker socket' || pass 'OpenCode does not receive the Docker socket'
|
|
357
|
-
opencode_env="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$opencode_id" 2>/dev/null || true)"
|
|
358
|
-
if ! grep -Fq '/run/secrets/nestbox-host-token' <<<"$opencode_mounts" && ! grep -Eq '^NESTBOX_HOST_TOKEN=' <<<"$opencode_env"; then pass 'OpenCode cannot read the host runner token'; else fail 'OpenCode can read the host runner token'; fi
|
|
359
357
|
control_mounts="$(docker inspect --format '{{range .Mounts}}{{println .Source .Destination}}{{end}}' "$control_id" 2>/dev/null || true)"
|
|
360
358
|
grep -Fq '/var/run/docker.sock' <<<"$control_mounts" && pass 'Control receives the Docker socket' || fail 'Control does not receive the Docker socket'
|
|
361
|
-
|
|
359
|
+
control_env="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$control_id" 2>/dev/null || true)"
|
|
360
|
+
if ! grep -Fq '/run/secrets/nestbox-host-token' <<<"$control_mounts" && ! grep -Eq '^NESTBOX_HOST_TOKEN' <<<"$control_env"; then pass 'Control has no host token mount or environment'; else fail 'Control still has host token configuration'; fi
|
|
362
361
|
|
|
363
362
|
if ((INDEX_PRESENT)); then
|
|
364
363
|
if compose exec -T php-fpm php -l /home/code/index.php >/dev/null 2>&1; then
|