@p10i/create-nestbox 0.3.0 → 0.4.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/.env.example CHANGED
@@ -8,9 +8,8 @@ COMPOSE_PROJECT_NAME=nestbox-change-me
8
8
  # directory, or .. when this installation is <workspace>/.nestbox.
9
9
  WORKSPACE_PATH=.
10
10
 
11
- # Loopback-only endpoint used by the native host runner. The creator generates
12
- # a private token; do not expose this port beyond the local host.
13
- NESTBOX_CONTROL_HOST_PORT=4089
11
+ # The native runner uses the shared WEB_PORT gateway. The creator generates a
12
+ # private token file outside the workspace and mounts it only into control.
14
13
  NESTBOX_HOST_TOKEN_FILE=
15
14
 
16
15
  # Override for rootless or nonstandard Docker Engine installations.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ - Route the authenticated host runner through `/_nestbox/host/` on the existing Nginx gateway.
6
+ - Restore the one-Nestbox, one-published-port invariant while keeping the control APIs private.
7
+
3
8
  ## 0.3.0
4
9
 
5
10
  - Replace the filesystem host queue with authenticated HTTP long polling managed by `control`.
package/INSTALL.md CHANGED
@@ -102,8 +102,7 @@ Configure the host bridge in the workspace root before Compose starts:
102
102
  - For direct layout, use `node host-runner.mjs` and `node tests/host-runner.mjs` for those scripts. A source clone's publisher-oriented `package.json` may be replaced by this installation manifest after preserving any intentional project metadata.
103
103
  - Never overwrite a conflicting script. Stop and ask for a different script name.
104
104
  - Set `NESTBOX_HOST_TOKEN_FILE` to a private file outside every container-mounted workspace. The npm creator writes a 64-character token there with owner-only permissions. Only `control` mounts the file; never paste the token into chat or expose it through a page.
105
- - Confirm that `NESTBOX_CONTROL_HOST_PORT`, normally `4089`, is available. Compose publishes this authenticated runner endpoint on host loopback only.
106
- - Start `npm run host --` after `control` is healthy. The runner authenticates, long-polls for work, sends heartbeats, and posts results without opening a host listener or sharing queue files.
105
+ - Start `npm run host --` after `control` is healthy. The runner authenticates through `/_nestbox/host/` on the existing `WEB_PORT`, long-polls for work, sends heartbeats, and posts results without opening a host listener, publishing a second port, or sharing queue files.
107
106
  - The workspace manifest is mounted read-only in runtime containers. Confirmed changes are written only by the native host runner.
108
107
 
109
108
  Update the new `home/configs/opencode/instance.md` with:
@@ -121,7 +120,6 @@ Then ask about:
121
120
 
122
121
  - a unique lowercase `COMPOSE_PROJECT_NAME`;
123
122
  - whether to use default port `4180`; verify availability and ask before choosing another port;
124
- - whether host-runner port `4089` is available; keep it loopback-only;
125
123
  - loopback-only or network access;
126
124
  - OpenCode username and password;
127
125
  - AI providers;
package/README.md CHANGED
@@ -12,7 +12,7 @@ Nestbox runs with Docker Compose using Nginx, PHP-FPM, Rollup, OpenCode, and a p
12
12
 
13
13
  The `control` service is the only container with `/var/run/docker.sock`. Its internal-only API permits exec only when caller, target, project, and root-user labels allow it. OpenCode reaches the API through the bundled control MCP and can target PHP-FPM and Rollup by default. Trusted PHP pages can use `nestbox_control_exec()` and are restricted to PHP-FPM self-exec.
14
14
 
15
- Native host commands are limited to scripts already declared in the workspace `package.json`. Keep `npm run host --` running from the workspace when host scripts are needed. The native runner authenticates with an external token file mounted only into `control`, long-polls a loopback-only endpoint, and posts results; no host server or shared filesystem queue is used. MCP can request script additions, edits, or deletions, but the host runner applies them only after the user supplies a short-lived code displayed exclusively in the host terminal.
15
+ Native host commands are limited to scripts already declared in the workspace `package.json`. Keep `npm run host --` running from the workspace when host scripts are needed. The native runner authenticates with an external token file mounted only into `control`, long-polls `/_nestbox/host/` through the shared Nginx gateway port, and posts results; no host server, second published port, or shared filesystem queue is used. MCP can request script additions, edits, or deletions, but the host runner applies them only after the user supplies a short-lived code displayed exclusively in the host terminal.
16
16
 
17
17
  Control emits bounded structured events to ordinary container logs. Use `docker compose logs control`; no telemetry collector, database, or observability dependency is installed. Docker socket access is root-equivalent on the host, and host scripts execute in the trusted writable workspace, so neither mechanism is a sandbox.
18
18
 
@@ -372,7 +372,7 @@ def valid_command(command):
372
372
 
373
373
 
374
374
  class Handler(BaseHTTPRequestHandler):
375
- server_version = "nestbox-control/0.3"
375
+ server_version = "nestbox-control/0.4"
376
376
 
377
377
  def setup(self):
378
378
  super().setup()
@@ -560,7 +560,7 @@ class Handler(BaseHTTPRequestHandler):
560
560
 
561
561
 
562
562
  class HostHandler(BaseHTTPRequestHandler):
563
- server_version = "nestbox-host-control/0.3"
563
+ server_version = "nestbox-host-control/0.4"
564
564
 
565
565
  def setup(self):
566
566
  super().setup()
@@ -651,7 +651,7 @@ def main():
651
651
  raise SystemExit("COMPOSE_PROJECT_NAME must be a non-empty lowercase project slug")
652
652
  if len(HOST_TOKEN) < 32:
653
653
  raise SystemExit("NESTBOX_HOST_TOKEN_FILE must contain a token of at least 32 characters")
654
- host_server = ThreadingHTTPServer(("0.0.0.0", int(os.environ.get("NESTBOX_CONTROL_HOST_PORT", "4089"))), HostHandler)
654
+ host_server = ThreadingHTTPServer(("0.0.0.0", 4089), HostHandler)
655
655
  threading.Thread(target=host_server.serve_forever, daemon=True).start()
656
656
  ThreadingHTTPServer(("0.0.0.0", int(os.environ.get("NESTBOX_CONTROL_PORT", "4088"))), Handler).serve_forever()
657
657
 
@@ -114,9 +114,8 @@ services:
114
114
  dockerfile: ./docker/control/Dockerfile
115
115
  expose:
116
116
  - "4088"
117
- ports:
118
- # Authenticated native runner transport; never bind beyond loopback.
119
- - "127.0.0.1:${NESTBOX_CONTROL_HOST_PORT:-4089}:4089"
117
+ # Authenticated host-runner transport, proxied only through Nginx.
118
+ - "4089"
120
119
  volumes:
121
120
  - ${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock
122
121
  - type: bind
@@ -134,7 +133,6 @@ services:
134
133
  environment:
135
134
  - COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}
136
135
  - NESTBOX_CONTROL_SERVICE=control
137
- - NESTBOX_CONTROL_HOST_PORT=4089
138
136
  - NESTBOX_HOST_TOKEN_FILE=/run/secrets/nestbox-host-token
139
137
  - NESTBOX_CONTROL_LOG_OUTPUT=${NESTBOX_CONTROL_LOG_OUTPUT:-text}
140
138
  - NESTBOX_CONTROL_LOG_MAX_BYTES=${NESTBOX_CONTROL_LOG_MAX_BYTES:-262144}
@@ -74,6 +74,23 @@ 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
+
77
94
  location ^~ /_nestbox/ {
78
95
  return 404;
79
96
  }
@@ -74,7 +74,7 @@ async function call(name, raw) {
74
74
 
75
75
  function emit(value) { process.stdout.write(`${JSON.stringify(value)}\n`); }
76
76
  async function handle(message) {
77
- if (message.method === 'initialize') return emit({ jsonrpc: '2.0', id: message.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'nestbox-control-mcp', version: '0.3.0' } } });
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' } } });
78
78
  if (message.method === 'initialized') return;
79
79
  if (message.method === 'tools/list') return emit({ jsonrpc: '2.0', id: message.id, result: { tools } });
80
80
  if (message.method === 'tools/call') {
@@ -204,7 +204,7 @@ The default Compose application has five services:
204
204
  - `opencode`: runs this agent, mounts the engine and workspace, and edits the page tree through `/nestbox/home/code`.
205
205
  - `control`: owns the Docker socket and privately brokers label-gated container exec and host-runner jobs.
206
206
 
207
- The default host port is `4180` after installer confirmation. Nginx container port `80` serves both Nestbox and OpenCode through host-based routing. Nginx container port `8080` is only the internal Nchan publisher.
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.
208
208
 
209
209
  Important files:
210
210
 
@@ -226,6 +226,7 @@ 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.
229
230
  - `APP_UID` and `APP_GID`: PHP-FPM ownership for writable bind mounts.
230
231
  - `NESTBOX_URL_PREFIX`: public path prefix when an outer proxy strips that prefix before forwarding.
231
232
  - `NESTBOX_OPENCODE_PUBLIC_URL`: explicit public OpenCode origin when `agent.<nestbox-host>` cannot be derived.
package/host-runner.mjs CHANGED
@@ -24,7 +24,11 @@ function parseEnv(contents) {
24
24
  }
25
25
 
26
26
  const fileEnv = await readFile(join(installDirectory, '.env'), 'utf8').then(parseEnv, error => error?.code === 'ENOENT' ? {} : Promise.reject(error));
27
- const controlUrl = (process.env.NESTBOX_CONTROL_HOST_URL || fileEnv.NESTBOX_CONTROL_HOST_URL || `http://127.0.0.1:${process.env.NESTBOX_CONTROL_HOST_PORT || fileEnv.NESTBOX_CONTROL_HOST_PORT || '4089'}`).replace(/\/$/, '');
27
+ const bindAddress = process.env.BIND_ADDRESS || fileEnv.BIND_ADDRESS || '127.0.0.1';
28
+ const gatewayAddress = bindAddress === '0.0.0.0' ? '127.0.0.1' : bindAddress === '::' ? '::1' : bindAddress;
29
+ const gatewayHost = gatewayAddress.includes(':') && !gatewayAddress.startsWith('[') ? `[${gatewayAddress}]` : gatewayAddress;
30
+ const webPort = process.env.WEB_PORT || fileEnv.WEB_PORT || '4180';
31
+ const controlUrl = (process.env.NESTBOX_CONTROL_HOST_URL || fileEnv.NESTBOX_CONTROL_HOST_URL || `http://${gatewayHost}:${webPort}/_nestbox`).replace(/\/$/, '');
28
32
  const tokenPath = process.env.NESTBOX_HOST_TOKEN_FILE || fileEnv.NESTBOX_HOST_TOKEN_FILE || '';
29
33
  const resolvedTokenPath = tokenPath ? await realpath(resolve(tokenPath)).catch(() => resolve(tokenPath)) : '';
30
34
  function containsPath(root, target) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@p10i/create-nestbox",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Create Nestbox projects.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,16 +30,16 @@ const server = createServer(async (request, response) => {
30
30
  if (request.headers.authorization !== `Bearer ${token}` || !request.headers['x-nestbox-runner-id']) {
31
31
  response.writeHead(401).end('{"error":"unauthorized"}'); return;
32
32
  }
33
- if (request.method === 'POST' && request.url === '/host/heartbeat') {
33
+ if (request.method === 'POST' && request.url === '/_nestbox/host/heartbeat') {
34
34
  if (heartbeatConflicts > 0) { heartbeatConflicts -= 1; response.writeHead(409).end('{"error":"another host runner is active"}'); return; }
35
35
  await readBody(request); response.writeHead(200, { 'content-type': 'application/json' }).end('{"ok":true}'); return;
36
36
  }
37
- if (request.method === 'GET' && request.url.startsWith('/host/jobs/next')) {
37
+ if (request.method === 'GET' && request.url.startsWith('/_nestbox/host/jobs/next')) {
38
38
  const job = jobs.shift();
39
39
  if (!job) { response.writeHead(204).end(); return; }
40
40
  response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(job)); return;
41
41
  }
42
- const match = request.url.match(/^\/host\/jobs\/([^/]+)\/result$/);
42
+ const match = request.url.match(/^\/_nestbox\/host\/jobs\/([^/]+)\/result$/);
43
43
  if (request.method === 'POST' && match) {
44
44
  const jobId = decodeURIComponent(match[1]);
45
45
  const result = await readBody(request);
@@ -89,7 +89,7 @@ try {
89
89
  await new Promise((resolveListen, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolveListen); });
90
90
  const port = server.address().port;
91
91
  runner = spawn(process.execPath, [runnerPath, '--json'], {
92
- env: { ...process.env, npm_execpath: '', NESTBOX_HOST_WORKSPACE: fixture, NESTBOX_CONTROL_HOST_URL: `http://127.0.0.1:${port}`, NESTBOX_HOST_TOKEN_FILE: tokenPath, NESTBOX_HOST_TOKEN: '' },
92
+ env: { ...process.env, npm_execpath: '', NESTBOX_HOST_WORKSPACE: fixture, NESTBOX_CONTROL_HOST_URL: `http://127.0.0.1:${port}/_nestbox`, NESTBOX_HOST_TOKEN_FILE: tokenPath, NESTBOX_HOST_TOKEN: '' },
93
93
  stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
94
94
  });
95
95
  let runnerStderr = '';
@@ -119,7 +119,7 @@ try {
119
119
  runner.kill('SIGTERM'); await exit; runner = undefined;
120
120
 
121
121
  const onceRunner = spawn(process.execPath, [runnerPath, '--once', '--json'], {
122
- env: { ...process.env, npm_execpath: '', NESTBOX_HOST_WORKSPACE: fixture, NESTBOX_CONTROL_HOST_URL: `http://127.0.0.1:${port}`, NESTBOX_HOST_TOKEN_FILE: tokenPath, NESTBOX_HOST_TOKEN: '' },
122
+ env: { ...process.env, npm_execpath: '', NESTBOX_HOST_WORKSPACE: fixture, NESTBOX_CONTROL_HOST_URL: `http://127.0.0.1:${port}/_nestbox`, NESTBOX_HOST_TOKEN_FILE: tokenPath, NESTBOX_HOST_TOKEN: '' },
123
123
  stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
124
124
  });
125
125
  const onceExit = await Promise.race([
@@ -131,7 +131,7 @@ try {
131
131
  const unsafeToken = join(fixture, 'unsafe-token');
132
132
  await writeFile(unsafeToken, `${token}\n`);
133
133
  const unsafe = spawnSync(process.execPath, [runnerPath, '--once'], {
134
- env: { ...process.env, NESTBOX_HOST_WORKSPACE: fixture, NESTBOX_CONTROL_HOST_URL: `http://127.0.0.1:${port}`, NESTBOX_HOST_TOKEN_FILE: unsafeToken, NESTBOX_HOST_TOKEN: '' }, encoding: 'utf8', windowsHide: true,
134
+ env: { ...process.env, NESTBOX_HOST_WORKSPACE: fixture, NESTBOX_CONTROL_HOST_URL: `http://127.0.0.1:${port}/_nestbox`, NESTBOX_HOST_TOKEN_FILE: unsafeToken, NESTBOX_HOST_TOKEN: '' }, encoding: 'utf8', windowsHide: true,
135
135
  });
136
136
  if (unsafe.status === 0 || !unsafe.stderr.includes('must be outside')) throw new Error('Runner accepted a token inside the workspace.');
137
137
  console.log('Host runner HTTP tests passed.');
package/tests/nestbox.ps1 CHANGED
@@ -253,7 +253,9 @@ try {
253
253
  $controlInspect = @(& docker inspect $controlId 2>$null | ConvertFrom-Json)[0]
254
254
  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
255
  $runnerBinding = $controlInspect.NetworkSettings.Ports.'4089/tcp'
256
- if ($null -ne $runnerBinding -and @($runnerBinding.HostIp) -notcontains '0.0.0.0' -and @($runnerBinding.HostIp) -notcontains '::') { Pass 'Host runner API is published on loopback only' } else { Fail 'Host runner API is not restricted to loopback' }
256
+ if ($null -eq $runnerBinding) { Pass 'Host runner API is not published separately' } else { Fail 'Host runner API is published separately' }
257
+ $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" }
257
259
  $openCodeId = Get-ContainerId 'opencode'
258
260
  $openCodeInspect = @(& docker inspect $openCodeId 2>$null | ConvertFrom-Json)[0]
259
261
  if (@($openCodeInspect.Mounts.Destination) -notcontains '/var/run/docker.sock') { Pass 'OpenCode does not receive the Docker socket' } else { Fail 'OpenCode receives the Docker socket' }
package/tests/nestbox.sh CHANGED
@@ -348,7 +348,9 @@ control_id="$(container_id control)"
348
348
  control_binding="$(docker inspect --format '{{json (index .NetworkSettings.Ports "4088/tcp")}}' "$control_id" 2>/dev/null || true)"
349
349
  [[ -z "$control_binding" || "$control_binding" == "null" ]] && pass 'Control API is not published on the host' || fail 'Control API is published on the host'
350
350
  runner_binding="$(docker inspect --format '{{json (index .NetworkSettings.Ports "4089/tcp")}}' "$control_id" 2>/dev/null || true)"
351
- if [[ "$runner_binding" == *'"HostIp":"127.0.0.1"'* || "$runner_binding" == *'"HostIp":"::1"'* ]]; then pass 'Host runner API is published on loopback only'; else fail 'Host runner API is not restricted to loopback'; fi
351
+ [[ -z "$runner_binding" || "$runner_binding" == "null" ]] && pass 'Host runner API is not published separately' || fail 'Host runner API is published separately'
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"
352
354
  opencode_id="$(container_id opencode)"
353
355
  opencode_mounts="$(docker inspect --format '{{range .Mounts}}{{println .Source .Destination}}{{end}}' "$opencode_id" 2>/dev/null || true)"
354
356
  grep -Fq '/var/run/docker.sock' <<<"$opencode_mounts" && fail 'OpenCode receives the Docker socket' || pass 'OpenCode does not receive the Docker socket'