@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 +2 -3
- package/CHANGELOG.md +5 -0
- package/INSTALL.md +1 -3
- package/README.md +1 -1
- package/docker/control/server.py +3 -3
- package/docker-compose.yaml +2 -4
- package/home/configs/nginx/nginx.conf +17 -0
- package/home/configs/opencode/control-mcp.js +1 -1
- package/home/configs/opencode/instructions.md +2 -1
- package/host-runner.mjs +5 -1
- package/package.json +1 -1
- package/tests/host-runner.mjs +6 -6
- package/tests/nestbox.ps1 +3 -1
- package/tests/nestbox.sh +3 -1
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
|
-
#
|
|
12
|
-
#
|
|
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
|
-
-
|
|
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
|
|
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
|
|
package/docker/control/server.py
CHANGED
|
@@ -372,7 +372,7 @@ def valid_command(command):
|
|
|
372
372
|
|
|
373
373
|
|
|
374
374
|
class Handler(BaseHTTPRequestHandler):
|
|
375
|
-
server_version = "nestbox-control/0.
|
|
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.
|
|
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",
|
|
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
|
|
package/docker-compose.yaml
CHANGED
|
@@ -114,9 +114,8 @@ services:
|
|
|
114
114
|
dockerfile: ./docker/control/Dockerfile
|
|
115
115
|
expose:
|
|
116
116
|
- "4088"
|
|
117
|
-
|
|
118
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
package/tests/host-runner.mjs
CHANGED
|
@@ -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 -
|
|
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
|
-
|
|
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'
|