@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.
@@ -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.5.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 and OpenCode through host-based routing. Nginx container port `8080` is only the internal Nchan publisher. The native runner enters `control` with `docker compose exec -T` and uses a versioned stdio bridge to its loopback-only API. 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
 
@@ -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
- - Pinned image or build context.
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. 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.
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
 
@@ -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.5.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,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
- broker = control.HostBroker()
30
- metadata = {"pid": 123, "platform": "test", "hostname": "host"}
31
- broker.connect("runner-test-1234", "session-test-1234", metadata)
32
- assert broker.status()["available"]
33
- job = {"jobId": "job-test", "type": "npm.run"}
34
- broker.enqueue(job)
35
- assert broker.next("runner-test-1234", "session-test-1234", 1) == job
36
- # A new exec session for the same stable runner recovers the assignment and supersedes the old helper.
37
- broker.connect("runner-test-1234", "session-test-5678", metadata)
38
- assert broker.next("runner-test-1234", "session-test-5678", 1) == job
39
- try:
40
- broker.touch("runner-test-1234", "session-test-1234")
41
- raise AssertionError("stale session was accepted")
42
- except PermissionError:
43
- pass
44
- result = {"jobId": "job-test", "exitCode": 0, "stdout": "", "stderr": ""}
45
- assert broker.complete("runner-test-1234", "session-test-5678", "job-test", result) is False
46
- assert broker.complete("runner-test-1234", "session-test-5678", "job-test", result) is True
47
- try:
48
- broker.complete("runner-test-1234", "session-test-5678", "job-test", {**result, "stdout": "different"})
49
- raise AssertionError("conflicting replay was accepted")
50
- except RuntimeError:
51
- pass
52
- assert broker.wait("job-test", 1)["exitCode"] == 0
53
- assert broker.complete("runner-test-1234", "session-test-5678", "job-test", result) is True
54
- try:
55
- broker.complete("runner-test-1234", "session-test-5678", "job-missing", {"jobId": "job-missing", "exitCode": 0, "stdout": "", "stderr": ""})
56
- raise AssertionError("missing job result was accepted")
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
- host_bridge.deliver_result("runner-test", "session-test", {"jobId": "job-session"})
102
- raise AssertionError("superseded session was treated as terminal")
103
- except RuntimeError:
104
- pass
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
- for (const name of ['docker_exec', 'npm_scripts', 'npm_run', 'npm_script_change_request', 'npm_script_change_confirm']) {
128
- if (!names.includes(name)) throw new Error(`Control MCP does not expose ${name}.`);
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', 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
- if (!/^WORKSPACE_PATH=\.\.$/m.test(nestedEnv) || /NESTBOX_HOST_TOKEN/.test(nestedEnv)) throw new Error('Nested runtime configuration still contains host token state.');
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 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
@@ -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 'docker\control\host_bridge.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' }
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/docker/control/host_bridge.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'
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'