@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.
@@ -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.4.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 or on the native host:
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
- Do not call the control HTTP API directly from OpenCode, impersonate the native runner, invoke Docker or SSH as a substitute, or work around a missing MCP capability. PHP cannot invoke MCP, so trusted super-documents may use `nestbox_control_exec()`; default labels restrict PHP-FPM to self-exec. Only OpenCode has `nestbox.npm.allow=true` by default. Root exec requires an explicit target label.
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
- The control log is the audit source of truth and requires no observability service. The native runner uses an authenticated, loopback-only long-poll endpoint; no host listener or filesystem queue exists. Commands still operate in trusted containers and a trusted writable workspace; Docker socket access and editable host-script source mean this is a controlled capability, not a security sandbox.
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 and host-runner jobs.
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, OpenCode through host-based routing, and the authenticated `/_nestbox/host/` runner transport. Nginx container port `8080` is only the internal Nchan publisher. No other service publishes a host port.
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
- - Pinned image or build context.
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. Use a declared host npm script through MCP when one exists; otherwise give the user the narrowest host-side command.
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
 
@@ -4,7 +4,7 @@
4
4
  "hostname": "0.0.0.0",
5
5
  "port": 4096
6
6
  },
7
- "instructions": ["instructions.md", "instance.md"],
7
+ "instructions": ["/etc/opencode/instructions.md", "/etc/opencode/instance.md"],
8
8
  "mcp": {
9
9
  "control": {
10
10
  "type": "local",
@@ -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.4.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
- "host": "node host-runner.mjs",
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": {
@@ -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
- broker = control.HostBroker()
29
- headers = {"X-Nestbox-Runner-Pid": "123", "X-Nestbox-Runner-Platform": "test", "X-Nestbox-Runner-Host": "host"}
30
- broker.touch("runner-test-1234", headers)
31
- assert broker.status()["available"]
32
- job = {"jobId": "job-test", "type": "npm.run"}
33
- broker.enqueue(job)
34
- assert broker.next("runner-test-1234", headers, 1) == job
35
- broker.complete("runner-test-1234", "job-test", {"jobId": "job-test", "exitCode": 0, "stdout": "", "stderr": ""})
36
- assert broker.wait("job-test", 1)["exitCode"] == 0
37
- stale = control.HostBroker()
38
- stale.touch("runner-stale-1234", headers)
39
- stale.enqueue({"jobId": "job-stale", "type": "npm.run"})
40
- assert stale.next("runner-stale-1234", headers, 1)["jobId"] == "job-stale"
41
- control.HOST_HEARTBEAT_TTL = 0
42
- assert stale.wait("job-stale", 1)["interrupted"] is True
43
- pending = control.HostBroker()
44
- pending.touch("runner-pending-1234", headers)
45
- pending.enqueue({"jobId": "job-pending", "type": "npm.run"})
46
- assert pending.wait("job-pending", 1)["interrupted"] is True
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
- for (const name of ['docker_exec', 'npm_scripts', 'npm_run', 'npm_script_change_request', 'npm_script_change_confirm']) {
67
- if (!names.includes(name)) throw new Error(`Control MCP does not expose ${name}.`);
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', env: { ...process.env, NESTBOX_CREATOR_STATE_ROOT: join(fixture, 'state') } });
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
- await writeFile(join(nested, 'package.json'), JSON.stringify({ name: 'existing', scripts: { build: 'node build.mjs' } }));
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
- const nestedPackage = JSON.parse(await readFile(join(nested, 'package.json'), 'utf8'));
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
- const tokenPath = nestedEnv.match(/^NESTBOX_HOST_TOKEN_FILE=(.+)$/m)?.[1]?.trim();
23
- if (!/^WORKSPACE_PATH=\.\.$/m.test(nestedEnv) || !tokenPath || !/^[a-f0-9]{64}$/.test((await readFile(tokenPath, 'utf8')).trim())) throw new Error('Nested runtime authentication was not configured.');
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 directPackage = JSON.parse(await readFile(join(direct, 'package.json'), 'utf8'));
32
- if (directPackage.scripts.host !== 'node host-runner.mjs' || directPackage.scripts['test:host'] !== 'node tests/host-runner.mjs') throw new Error('Direct host scripts have the wrong paths.');
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 conflicting = join(fixture, 'conflicting');
35
- await mkdir(conflicting);
36
- await writeFile(join(conflicting, 'package.json'), JSON.stringify({ scripts: { host: 'node something-else.mjs' } }));
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 'host-runner.mjs'))) { Pass 'Control and host runner sources exist' } else { Fail 'Control or host runner source is missing' }
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 401) { Pass 'Nginx routes the authenticated host runner API' } else { Fail "Host runner gateway returned HTTP $($response.Status) instead of 401" }
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) -contains '/run/secrets/nestbox-host-token') { Pass 'Control receives the host runner token file' } else { Fail 'Control does not receive the host runner token file' }
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/host-runner.mjs" ]] && pass 'Control and host runner sources exist' || fail 'Control or host runner source is missing'
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" == "401" ]] && pass 'Nginx routes the authenticated host runner API' || fail "Host runner gateway returned HTTP $status instead of 401"
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
- grep -Fq '/run/secrets/nestbox-host-token' <<<"$control_mounts" && pass 'Control receives the host runner token file' || fail 'Control does not receive the host runner token file'
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