@p10i/create-nestbox 0.2.0 → 0.3.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,10 @@ COMPOSE_PROJECT_NAME=nestbox-change-me
8
8
  # directory, or .. when this installation is <workspace>/.nestbox.
9
9
  WORKSPACE_PATH=.
10
10
 
11
- # Absolute host-state directory shared only by the native runner and control.
12
- # The creator generates this value outside the mounted workspace.
13
- NESTBOX_HOST_QUEUE_PATH=
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
14
+ NESTBOX_HOST_TOKEN_FILE=
14
15
 
15
16
  # Override for rootless or nonstandard Docker Engine installations.
16
17
  DOCKER_SOCKET_PATH=/var/run/docker.sock
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - Replace the filesystem host queue with authenticated HTTP long polling managed by `control`.
6
+ - Publish only the dedicated host-runner endpoint on loopback; keep the container API internal.
7
+ - Remove queue paths, bind mounts, polling files, and stale lock/result state.
8
+ - Generate a per-installation runner token file unavailable to application containers and preserve existing MCP and PHP contracts.
9
+
3
10
  ## 0.2.0
4
11
 
5
12
  - Add a private, label-gated container control service and OpenCode MCP adapter.
package/INSTALL.md CHANGED
@@ -12,13 +12,15 @@ npm create @p10i/nestbox@latest
12
12
 
13
13
  The published npm package is `@p10i/create-nestbox`; npm maps `npm create @p10i/nestbox` to that package. The creator explains and previews one of the two default layouts before writing files. The recommended layout installs at `<workspace>/.nestbox` so Nestbox stays separate from project files. The direct layout writes Nestbox files into an empty selected directory. The creator copies `.env.example` to `.env`, copies `home/configs/opencode/instance.example.md` to `instance.md`, records npm package provenance in the instance policy, and initializes fresh installation Git history when Git is available.
14
14
 
15
- The creator does not start Docker, start the host bridge, or collect secrets. Node.js and npm are required for the bridge. After it finishes, edit `.env`, run `npm run host --` from the workspace in a separate terminal, then run from the installation directory:
15
+ The creator does not start Docker, start the host bridge, or collect secrets. Node.js and npm are required for the bridge. After it finishes, edit `.env`, then run from the installation directory:
16
16
 
17
17
  ```sh
18
18
  docker compose config --quiet
19
19
  docker compose up -d --build
20
20
  ```
21
21
 
22
+ After `control` is healthy, run `npm run host --` from the workspace in a separate terminal.
23
+
22
24
  Then run the host test suite documented in section 7.
23
25
 
24
26
  ## 1. Choose Language
@@ -42,7 +44,7 @@ Both defaults keep pages, Compose, Dockerfiles, configuration, dependencies, and
42
44
 
43
45
  Do not present vendoring, a parent-owned engine, a submodule, an external page tree, or another shared-repository arrangement as a default. These are optional adaptations discussed only when the user requests one.
44
46
 
45
- Never overwrite an existing installation. Inspect an existing target and ask whether to update, repair, reuse, or stop.
47
+ Never overwrite an existing installation. Select a different target or stop.
46
48
 
47
49
  ## 3. Check Requirements And Git
48
50
 
@@ -98,9 +100,10 @@ Configure the host bridge in the workspace root before Compose starts:
98
100
 
99
101
  - For `.nestbox` layout, create `package.json` from `.nestbox/package.host.json` when absent, or merge only its `host` and `test:host` scripts into the existing manifest.
100
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.
101
- - Never overwrite a conflicting script. Stop and ask for a different script name or an explicit migration.
102
- - Create a private host-state queue outside the workspace, record it in the ignored installation `.runtime/host-runner.json`, and set its absolute path as `NESTBOX_HOST_QUEUE_PATH` in `.env`. The npm creator performs this automatically.
103
- - Start `npm run host --` before Compose so the queue and heartbeat exist. The runner rejects queue locations inside the mounted workspace.
103
+ - Never overwrite a conflicting script. Stop and ask for a different script name.
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.
104
107
  - The workspace manifest is mounted read-only in runtime containers. Confirmed changes are written only by the native host runner.
105
108
 
106
109
  Update the new `home/configs/opencode/instance.md` with:
@@ -118,6 +121,7 @@ Then ask about:
118
121
 
119
122
  - a unique lowercase `COMPOSE_PROJECT_NAME`;
120
123
  - 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;
121
125
  - loopback-only or network access;
122
126
  - OpenCode username and password;
123
127
  - AI providers;
@@ -271,4 +275,4 @@ When a super-document intentionally changes files in `/workspace`, follow that w
271
275
 
272
276
  ## 11. Optional Adaptations
273
277
 
274
- Retaining upstream Git history, adding an upstream remote, external page trees, parent-owned engines, submodules, vendoring, shared repositories, alternate Compose layouts, and operating-system shortcuts are optional. Explain their ownership and update consequences and obtain explicit confirmation before creating them. They must not obscure the two default installation paths.
278
+ Retaining upstream Git history, adding an upstream remote, external page trees, parent-owned engines, submodules, vendoring, shared repositories, alternate Compose layouts, and operating-system shortcuts are optional. Explain their ownership consequences and obtain explicit confirmation before creating them. They must not obscure the two default installation paths.
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 bridge exchanges typed files through creator-generated host state outside the container-mounted workspace; it opens no network listener. 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 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.
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
 
@@ -39,11 +39,3 @@ For agent-guided installation or repair work, copy and send this to an agent wit
39
39
  ```text
40
40
  Install Nestbox by following https://github.com/piontkovsk11andre1/nestbox/blob/main/INSTALL.md completely. Ask all required questions before making changes, preserve unrelated files and Git history, and do not expose secrets.
41
41
  ```
42
-
43
- ## Update
44
-
45
- Run an update request from the workspace containing Nestbox, then copy and send this prompt to an agent with filesystem, Git, Docker, and terminal access:
46
-
47
- ```text
48
- Update this Nestbox installation from https://github.com/piontkovsk11andre1/nestbox. Locate the installation, inspect its local Git history and configuration, read the current remote INSTALL.md and internal Nestbox instructions, preserve pages, instance policy, secrets, volumes, and unrelated changes, explain the proposed update, and ask before destructive or system-level operations. Validate Compose and run the Nestbox host tests after applying the update.
49
- ```
@@ -1,17 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from 'node:child_process';
3
- import { randomUUID } from 'node:crypto';
3
+ import { randomBytes, randomUUID } from 'node:crypto';
4
4
  import { homedir } from 'node:os';
5
5
  import { createInterface } from 'node:readline/promises';
6
6
  import { stdin as input, stdout as output } from 'node:process';
7
- import { dirname, join, resolve } from 'node:path';
7
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
- import { cp, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
9
+ import { cp, mkdir, readdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
10
10
  import { constants } from 'node:fs';
11
11
 
12
12
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
13
13
  const packageJson = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8'));
14
14
 
15
+ function containsPath(root, target) {
16
+ const child = relative(resolve(root), resolve(target));
17
+ return child === '' || (!child.startsWith(`..${sep}`) && child !== '..' && !isAbsolute(child));
18
+ }
19
+
15
20
  const copyEntries = [
16
21
  'docker',
17
22
  'home',
@@ -147,7 +152,7 @@ async function configureInstance(installDir, workspaceDir, layout) {
147
152
  });
148
153
  const envPath = join(installDir, '.env');
149
154
  const workspaceValue = layout === 'nestbox' ? '..' : '.';
150
- const configuredEnv = (await readFile(envPath, 'utf8')).replace(/^WORKSPACE_PATH=.*$/m, `WORKSPACE_PATH=${workspaceValue}`);
155
+ const configuredEnv = (await readFile(envPath, 'utf8')).replace(/^WORKSPACE_PATH=.*\r?$/m, `WORKSPACE_PATH=${workspaceValue}`);
151
156
  await writeFile(envPath, configuredEnv, 'utf8');
152
157
 
153
158
  const instanceExample = join(installDir, 'home', 'configs', 'opencode', 'instance.example.md');
@@ -222,18 +227,21 @@ async function configureHostPackage(installDir, workspaceDir, layout) {
222
227
  private: true,
223
228
  scripts
224
229
  }, null, 2)}\n`, 'utf8');
230
+ const envPath = join(installDir, '.env');
231
+ const token = randomBytes(32).toString('hex');
225
232
  const stateBase = process.env.NESTBOX_CREATOR_STATE_ROOT
226
233
  || process.env.LOCALAPPDATA
227
234
  || process.env.XDG_STATE_HOME
228
235
  || (process.platform === 'darwin' ? join(homedir(), 'Library', 'Application Support') : join(homedir(), '.local', 'state'));
229
- const queue = join(stateBase, 'nestbox', 'host-runner', randomUUID());
230
- await mkdir(queue, { recursive: true });
231
- const runtime = join(installDir, '.runtime');
232
- await mkdir(runtime, { recursive: true });
233
- await writeFile(join(runtime, 'host-runner.json'), `${JSON.stringify({ queue }, null, 2)}\n`, 'utf8');
234
- const envPath = join(installDir, '.env');
235
- const composeQueue = queue.replaceAll('\\', '/');
236
- const configuredEnv = (await readFile(envPath, 'utf8')).replace(/^NESTBOX_HOST_QUEUE_PATH=.*$/m, () => `NESTBOX_HOST_QUEUE_PATH=${composeQueue}`);
236
+ const tokenDirectory = join(stateBase, 'nestbox', 'host-control');
237
+ await mkdir(tokenDirectory, { recursive: true });
238
+ const tokenPath = join(await realpath(tokenDirectory), `${randomUUID()}.token`);
239
+ const canonicalWorkspace = await realpath(workspaceDir);
240
+ const canonicalInstall = await realpath(installDir);
241
+ if (containsPath(canonicalWorkspace, tokenPath) || containsPath(canonicalInstall, tokenPath)) throw new Error('Host token state directory must be outside the workspace and Nestbox installation.');
242
+ await writeFile(tokenPath, `${token}\n`, { encoding: 'utf8', mode: 0o600 });
243
+ const composeTokenPath = tokenPath.replaceAll('\\', '/');
244
+ const configuredEnv = (await readFile(envPath, 'utf8')).replace(/^NESTBOX_HOST_TOKEN_FILE=.*\r?$/m, () => `NESTBOX_HOST_TOKEN_FILE=${composeTokenPath}`);
237
245
  await writeFile(envPath, configuredEnv, 'utf8');
238
246
  }
239
247
 
@@ -286,12 +294,13 @@ async function main() {
286
294
  console.log(`Workspace: ${workspaceDir}`);
287
295
  console.log(`Git initialized: ${gitInitialized ? 'yes' : 'no'}`);
288
296
  console.log('\nNext steps:');
289
- console.log(` cd ${workspaceDir}`);
290
- console.log(' npm run host --');
291
297
  console.log(` cd ${installDir}`);
292
298
  console.log(' Edit .env and set COMPOSE_PROJECT_NAME, WEB_PORT, OpenCode credentials, and provider keys.');
293
299
  console.log(' docker compose config --quiet');
294
300
  console.log(' docker compose up -d --build');
301
+ console.log(` cd ${workspaceDir}`);
302
+ console.log(' npm run host --');
303
+ console.log(` cd ${installDir}`);
295
304
  console.log(process.platform === 'win32' ? ' .\\tests\\nestbox.ps1' : ' bash tests/nestbox.sh');
296
305
  } finally {
297
306
  rl.close();
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
  import http.client
3
+ import hmac
3
4
  import json
4
5
  import os
5
6
  import re
@@ -10,16 +11,24 @@ import time
10
11
  import uuid
11
12
  from datetime import datetime, timezone
12
13
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
13
- from urllib.parse import quote, unquote, urlparse
14
+ from urllib.parse import parse_qs, quote, unquote, urlparse
14
15
 
15
16
  DOCKER_SOCKET = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")
16
17
  WORKSPACE_PATH = os.environ.get("WORKSPACE_PATH", "/workspace")
17
18
  PROJECT_NAME = os.environ.get("COMPOSE_PROJECT_NAME", "").strip()
18
19
  SERVICE_NAME = os.environ.get("NESTBOX_CONTROL_SERVICE", "control")
19
- HOST_ROOT = os.environ.get("NESTBOX_HOST_RUNNER_PATH", "/host-runner")
20
+ HOST_TOKEN_FILE = os.environ.get("NESTBOX_HOST_TOKEN_FILE", "/run/secrets/nestbox-host-token")
21
+ try:
22
+ with open(HOST_TOKEN_FILE, encoding="utf-8") as token_stream:
23
+ HOST_TOKEN = token_stream.read().strip()
24
+ except OSError:
25
+ HOST_TOKEN = ""
20
26
  HOST_TIMEOUT = int(os.environ.get("NESTBOX_HOST_RUNNER_TIMEOUT", "3600"))
21
- HOST_HEARTBEAT_TTL = int(os.environ.get("NESTBOX_HOST_RUNNER_HEARTBEAT_TTL", "5"))
27
+ HOST_HEARTBEAT_TTL = int(os.environ.get("NESTBOX_HOST_RUNNER_HEARTBEAT_TTL", "10"))
28
+ HOST_LONG_POLL_TIMEOUT = int(os.environ.get("NESTBOX_HOST_LONG_POLL_TIMEOUT", "25"))
29
+ MAX_HOST_JOBS = int(os.environ.get("NESTBOX_MAX_HOST_JOBS", "100"))
22
30
  MAX_BODY_BYTES = 128 * 1024
31
+ MAX_HOST_BODY_BYTES = 64 * 1024 * 1024
23
32
  MAX_COMMAND_PARTS = 256
24
33
  MAX_COMMAND_BYTES = 64 * 1024
25
34
  LOG_OUTPUT = os.environ.get("NESTBOX_CONTROL_LOG_OUTPUT", "text").lower()
@@ -191,51 +200,155 @@ def package_scripts():
191
200
  return {str(name): str(command) for name, command in scripts.items()}
192
201
 
193
202
 
194
- def host_paths():
195
- return {name: os.path.join(HOST_ROOT, name) for name in ("requests", "results", "heartbeat")}
203
+ class HostBroker:
204
+ def __init__(self):
205
+ self.condition = threading.Condition()
206
+ self.pending = []
207
+ self.jobs = {}
208
+ self.runner = {}
209
+
210
+ def touch(self, runner_id, headers):
211
+ if not re.fullmatch(r"[A-Za-z0-9._:-]{8,128}", runner_id):
212
+ raise ValueError("invalid runner ID")
213
+ now = time.monotonic()
214
+ with self.condition:
215
+ active_id = self.runner.get("id")
216
+ active_age = now - self.runner.get("seen", 0)
217
+ if active_id and active_id != runner_id and active_age <= HOST_HEARTBEAT_TTL:
218
+ raise PermissionError("another host runner is active")
219
+ if active_id and active_id != runner_id:
220
+ for record in self.jobs.values():
221
+ if record.get("runnerId") == active_id and record.get("result") is None:
222
+ record["result"] = {
223
+ "jobId": record["job"]["jobId"],
224
+ "exitCode": 1,
225
+ "stdout": "",
226
+ "stderr": "The assigned host runner disconnected before returning a result.",
227
+ "interrupted": True,
228
+ }
229
+ self.runner = {
230
+ "id": runner_id,
231
+ "seen": now,
232
+ "pid": headers.get("X-Nestbox-Runner-Pid"),
233
+ "platform": headers.get("X-Nestbox-Runner-Platform"),
234
+ "hostname": headers.get("X-Nestbox-Runner-Host"),
235
+ }
236
+ self.condition.notify_all()
237
+
238
+ def status(self):
239
+ with self.condition:
240
+ if not self.runner:
241
+ return {"available": False}
242
+ age = max(0, time.monotonic() - self.runner["seen"])
243
+ return {
244
+ "available": age <= HOST_HEARTBEAT_TTL,
245
+ "ageSeconds": round(age, 3),
246
+ "pid": self.runner.get("pid"),
247
+ "platform": self.runner.get("platform"),
248
+ "hostname": self.runner.get("hostname"),
249
+ }
250
+
251
+ def enqueue(self, job):
252
+ with self.condition:
253
+ if len(self.jobs) >= MAX_HOST_JOBS:
254
+ raise RuntimeError("too many host jobs are pending")
255
+ self.jobs[job["jobId"]] = {"job": job, "result": None, "runnerId": None}
256
+ self.pending.append(job["jobId"])
257
+ self.condition.notify_all()
258
+
259
+ def next(self, runner_id, headers, timeout):
260
+ deadline = time.monotonic() + timeout
261
+ with self.condition:
262
+ while True:
263
+ if self.runner.get("id") != runner_id:
264
+ raise PermissionError("another host runner is active")
265
+ if time.monotonic() - self.runner.get("seen", 0) > HOST_HEARTBEAT_TTL:
266
+ return None
267
+ if self.pending:
268
+ identifier = self.pending.pop(0)
269
+ record = self.jobs.get(identifier)
270
+ if record:
271
+ record["runnerId"] = runner_id
272
+ return record["job"]
273
+ remaining = deadline - time.monotonic()
274
+ if remaining <= 0:
275
+ return None
276
+ self.condition.wait(min(1, remaining))
277
+
278
+ def complete(self, runner_id, identifier, result):
279
+ with self.condition:
280
+ record = self.jobs.get(identifier)
281
+ if not record or record.get("runnerId") != runner_id:
282
+ raise ValueError("host job is unknown or belongs to another runner")
283
+ if record.get("result") is not None:
284
+ raise ValueError("host job already has a result")
285
+ if result.get("jobId") != identifier:
286
+ raise ValueError("host result contains a mismatched job ID")
287
+ job = record["job"]
288
+ if job["type"] == "npm.run":
289
+ if not isinstance(result.get("exitCode"), int) or not isinstance(result.get("stdout"), str) or not isinstance(result.get("stderr"), str):
290
+ raise ValueError("invalid npm run result")
291
+ elif job["type"] == "npm.scripts.request":
292
+ if result.get("status") != "confirmation_required" or result.get("requestId") != job.get("requestId") or result.get("operation") != job.get("operation") or result.get("name") != job.get("name"):
293
+ if not isinstance(result.get("exitCode"), int):
294
+ raise ValueError("invalid script change request result")
295
+ elif job["type"] == "npm.scripts.confirm":
296
+ if result.get("status") == "success" and result.get("requestId") != job.get("requestId"):
297
+ raise ValueError("invalid script confirmation result")
298
+ if result.get("status") != "success" and not isinstance(result.get("exitCode"), int):
299
+ raise ValueError("invalid script confirmation result")
300
+ record["result"] = result
301
+ self.condition.notify_all()
302
+
303
+ def wait(self, identifier, timeout=HOST_TIMEOUT):
304
+ deadline = time.monotonic() + timeout
305
+ with self.condition:
306
+ while True:
307
+ record = self.jobs.get(identifier)
308
+ if record and record.get("runnerId") and record.get("result") is None:
309
+ owner = record["runnerId"]
310
+ if self.runner.get("id") != owner or time.monotonic() - self.runner.get("seen", 0) > HOST_HEARTBEAT_TTL:
311
+ record["result"] = {
312
+ "jobId": identifier,
313
+ "exitCode": 1,
314
+ "stdout": "",
315
+ "stderr": "The assigned host runner disconnected before returning a result.",
316
+ "interrupted": True,
317
+ }
318
+ elif record and record.get("result") is None and self.runner and time.monotonic() - self.runner.get("seen", 0) > HOST_HEARTBEAT_TTL:
319
+ record["result"] = {
320
+ "jobId": identifier,
321
+ "exitCode": 1,
322
+ "stdout": "",
323
+ "stderr": "The host runner disconnected before accepting the job.",
324
+ "interrupted": True,
325
+ }
326
+ if record and record.get("result") is not None:
327
+ result = record["result"]
328
+ del self.jobs[identifier]
329
+ return result
330
+ remaining = deadline - time.monotonic()
331
+ if remaining <= 0:
332
+ self.jobs.pop(identifier, None)
333
+ if identifier in self.pending:
334
+ self.pending.remove(identifier)
335
+ raise TimeoutError(f"host runner did not finish within {timeout} seconds")
336
+ self.condition.wait(min(1, remaining))
337
+
338
+
339
+ HOST_BROKER = HostBroker()
196
340
 
197
341
 
198
342
  def host_status():
199
- paths = host_paths()
200
- try:
201
- age = max(0, time.time() - os.path.getmtime(paths["heartbeat"] + ".json"))
202
- with open(paths["heartbeat"] + ".json", encoding="utf-8") as stream:
203
- heartbeat = json.load(stream)
204
- return {"available": age <= HOST_HEARTBEAT_TTL, "ageSeconds": round(age, 3), "pid": heartbeat.get("pid"), "platform": heartbeat.get("platform"), "hostname": heartbeat.get("hostname")}
205
- except (OSError, ValueError, TypeError):
206
- return {"available": False}
207
-
208
-
209
- def atomic_json(path, value):
210
- temporary = f"{path}.{uuid.uuid4().hex}.tmp"
211
- with open(temporary, "w", encoding="utf-8") as stream:
212
- json.dump(value, stream, sort_keys=True)
213
- stream.write("\n")
214
- os.replace(temporary, path)
343
+ return HOST_BROKER.status()
215
344
 
216
345
 
217
346
  def enqueue(job):
218
- paths = host_paths()
219
- os.makedirs(paths["requests"], exist_ok=True)
220
- os.makedirs(paths["results"], exist_ok=True)
221
- atomic_json(os.path.join(paths["requests"], f"{job['jobId']}.json"), job)
222
- return paths
347
+ HOST_BROKER.enqueue(job)
223
348
 
224
349
 
225
- def wait_result(identifier, paths, timeout=HOST_TIMEOUT):
226
- path = os.path.join(paths["results"], f"{identifier}.json")
227
- deadline = time.monotonic() + timeout
228
- while time.monotonic() < deadline:
229
- try:
230
- with open(path, encoding="utf-8") as stream:
231
- result = json.load(stream)
232
- os.unlink(path)
233
- if result.get("jobId") != identifier:
234
- raise ValueError("host runner returned a mismatched job ID")
235
- return result
236
- except FileNotFoundError:
237
- time.sleep(0.1)
238
- raise TimeoutError(f"host runner did not finish within {timeout} seconds")
350
+ def wait_result(identifier, timeout=HOST_TIMEOUT):
351
+ return HOST_BROKER.wait(identifier, timeout)
239
352
 
240
353
 
241
354
  def record_host_result(identifier, source_name, script, result, detached):
@@ -245,9 +358,9 @@ def record_host_result(identifier, source_name, script, result, detached):
245
358
  log_event("npm.complete", runId=identifier, source=source_name, script=script, detached=detached, executor="host", exitCode=result.get("exitCode"), hostPlatform=result.get("hostPlatform"), hostName=result.get("hostName"), stdoutTruncated=bool(result.get("stdoutTruncated")), stderrTruncated=bool(result.get("stderrTruncated")))
246
359
 
247
360
 
248
- def wait_detached(identifier, paths, source_name, script):
361
+ def wait_detached(identifier, source_name, script):
249
362
  try:
250
- record_host_result(identifier, source_name, script, wait_result(identifier, paths), True)
363
+ record_host_result(identifier, source_name, script, wait_result(identifier), True)
251
364
  except Exception as error:
252
365
  log_event("npm.error", runId=identifier, source=source_name, script=script, detached=True, error=str(error))
253
366
 
@@ -259,7 +372,7 @@ def valid_command(command):
259
372
 
260
373
 
261
374
  class Handler(BaseHTTPRequestHandler):
262
- server_version = "nestbox-control/0.2"
375
+ server_version = "nestbox-control/0.3"
263
376
 
264
377
  def setup(self):
265
378
  super().setup()
@@ -398,11 +511,11 @@ class Handler(BaseHTTPRequestHandler):
398
511
  identifier = run_id("npm")
399
512
  job = {"schemaVersion": 1, "jobId": identifier, "type": "npm.run", "script": script, "args": args, "source": service(source), "createdAt": now_iso()}
400
513
  try:
401
- paths = enqueue(job)
514
+ enqueue(job)
402
515
  if body.get("detach"):
403
- threading.Thread(target=wait_detached, args=(identifier, paths, service(source), script), daemon=True).start()
516
+ threading.Thread(target=wait_detached, args=(identifier, service(source), script), daemon=True).start()
404
517
  self.send_json(202, {"runId": identifier, "detached": True, "executor": "host"}); return
405
- result = wait_result(identifier, paths)
518
+ result = wait_result(identifier)
406
519
  record_host_result(identifier, service(source), script, result, False)
407
520
  self.send_json(200, {"runId": identifier, "exitCode": result.get("exitCode"), "stdout": str(result.get("stdout", "")), "stderr": str(result.get("stderr", "")), "pid": result.get("pid"), "executor": "host"})
408
521
  except Exception as error:
@@ -422,7 +535,8 @@ class Handler(BaseHTTPRequestHandler):
422
535
  job = {"schemaVersion": 1, "jobId": identifier, "type": "npm.scripts.request", "requestId": identifier, "operation": operation, "name": name, "source": service(source), "createdAt": now_iso()}
423
536
  if operation != "delete": job["command"] = command
424
537
  try:
425
- result = wait_result(identifier, enqueue(job))
538
+ enqueue(job)
539
+ result = wait_result(identifier)
426
540
  if result.get("status") != "confirmation_required": raise ValueError(result.get("stderr", "host runner rejected the request"))
427
541
  self.send_json(202, {"status": "confirmation_required", "requestId": result.get("requestId"), "operation": operation, "name": name, "expiresAt": result.get("expiresAt")})
428
542
  except Exception as error:
@@ -437,16 +551,108 @@ class Handler(BaseHTTPRequestHandler):
437
551
  identifier = run_id("npm-script-confirm")
438
552
  job = {"schemaVersion": 1, "jobId": identifier, "type": "npm.scripts.confirm", "requestId": request_id, "code": code, "source": service(source), "createdAt": now_iso()}
439
553
  try:
440
- result = wait_result(identifier, enqueue(job))
554
+ enqueue(job)
555
+ result = wait_result(identifier)
441
556
  if result.get("status") != "success": raise ValueError(result.get("stderr", "host runner rejected the confirmation"))
442
557
  self.send_json(200, {"status": "success", "requestId": request_id, "operation": result.get("operation"), "name": result.get("name")})
443
558
  except Exception as error:
444
559
  self.send_json(400, {"error": str(error)})
445
560
 
446
561
 
562
+ class HostHandler(BaseHTTPRequestHandler):
563
+ server_version = "nestbox-host-control/0.3"
564
+
565
+ def setup(self):
566
+ super().setup()
567
+ self.connection.settimeout(HOST_LONG_POLL_TIMEOUT + 10)
568
+
569
+ def log_message(self, fmt, *args):
570
+ log_event("host.http.access", client=self.address_string(), message=fmt % args)
571
+
572
+ def send_json(self, status, value):
573
+ payload = json.dumps(value, ensure_ascii=False).encode()
574
+ self.send_response(status)
575
+ self.send_header("Content-Type", "application/json; charset=utf-8")
576
+ self.send_header("Content-Length", str(len(payload)))
577
+ self.end_headers()
578
+ self.wfile.write(payload)
579
+
580
+ def authorized(self):
581
+ supplied = self.headers.get("Authorization", "")
582
+ expected = f"Bearer {HOST_TOKEN}"
583
+ return hmac.compare_digest(supplied.encode(), expected.encode())
584
+
585
+ def runner(self):
586
+ return self.headers.get("X-Nestbox-Runner-Id", "")
587
+
588
+ def body(self):
589
+ if self.headers.get("Transfer-Encoding"):
590
+ raise ValueError("transfer encoding is not supported")
591
+ length = int(self.headers.get("Content-Length", "0"))
592
+ if length < 0 or length > MAX_HOST_BODY_BYTES:
593
+ raise ValueError("request body is too large")
594
+ value = json.loads(self.rfile.read(length)) if length else {}
595
+ if not isinstance(value, dict):
596
+ raise ValueError("request body must be a JSON object")
597
+ return value
598
+
599
+ def authenticate(self):
600
+ if not self.authorized():
601
+ self.send_json(401, {"error": "invalid host runner token"})
602
+ return False
603
+ try:
604
+ HOST_BROKER.touch(self.runner(), self.headers)
605
+ return True
606
+ except PermissionError as error:
607
+ self.send_json(409, {"error": str(error)})
608
+ except ValueError as error:
609
+ self.send_json(400, {"error": str(error)})
610
+ return False
611
+
612
+ def do_GET(self):
613
+ parsed = urlparse(self.path)
614
+ if parsed.path == "/host/health":
615
+ if not self.authenticate(): return
616
+ self.send_json(200, {"ok": True, "hostRunner": host_status()})
617
+ return
618
+ if parsed.path == "/host/jobs/next":
619
+ if not self.authenticate(): return
620
+ try:
621
+ requested = int((parse_qs(parsed.query).get("timeout") or [HOST_LONG_POLL_TIMEOUT])[0])
622
+ timeout = max(1, min(HOST_LONG_POLL_TIMEOUT, requested))
623
+ job = HOST_BROKER.next(self.runner(), self.headers, timeout)
624
+ if job is None:
625
+ self.send_response(204); self.end_headers(); return
626
+ self.send_json(200, job)
627
+ except (TypeError, ValueError) as error:
628
+ self.send_json(400, {"error": str(error)})
629
+ return
630
+ self.send_json(404, {"error": "not found"})
631
+
632
+ def do_POST(self):
633
+ if not self.authenticate(): return
634
+ path = urlparse(self.path).path
635
+ if path == "/host/heartbeat":
636
+ self.send_json(200, {"ok": True})
637
+ return
638
+ match = re.fullmatch(r"/host/jobs/([A-Za-z0-9._:-]{1,128})/result", path)
639
+ if not match:
640
+ self.send_json(404, {"error": "not found"}); return
641
+ try:
642
+ result = self.body()
643
+ HOST_BROKER.complete(self.runner(), match.group(1), result)
644
+ self.send_json(202, {"accepted": True})
645
+ except ValueError as error:
646
+ self.send_json(400, {"error": str(error)})
647
+
648
+
447
649
  def main():
448
650
  if not re.fullmatch(r"[a-z0-9][a-z0-9_-]*", PROJECT_NAME):
449
651
  raise SystemExit("COMPOSE_PROJECT_NAME must be a non-empty lowercase project slug")
652
+ if len(HOST_TOKEN) < 32:
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)
655
+ threading.Thread(target=host_server.serve_forever, daemon=True).start()
450
656
  ThreadingHTTPServer(("0.0.0.0", int(os.environ.get("NESTBOX_CONTROL_PORT", "4088"))), Handler).serve_forever()
451
657
 
452
658
 
@@ -114,6 +114,9 @@ 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
120
  volumes:
118
121
  - ${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock
119
122
  - type: bind
@@ -123,14 +126,16 @@ services:
123
126
  bind:
124
127
  create_host_path: false
125
128
  - type: bind
126
- source: ${NESTBOX_HOST_QUEUE_PATH:?NESTBOX_HOST_QUEUE_PATH must be configured}
127
- target: /host-runner
129
+ source: ${NESTBOX_HOST_TOKEN_FILE:?NESTBOX_HOST_TOKEN_FILE must be configured}
130
+ target: /run/secrets/nestbox-host-token
131
+ read_only: true
128
132
  bind:
129
133
  create_host_path: false
130
134
  environment:
131
135
  - COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}
132
136
  - NESTBOX_CONTROL_SERVICE=control
133
- - NESTBOX_HOST_RUNNER_PATH=/host-runner
137
+ - NESTBOX_CONTROL_HOST_PORT=4089
138
+ - NESTBOX_HOST_TOKEN_FILE=/run/secrets/nestbox-host-token
134
139
  - NESTBOX_CONTROL_LOG_OUTPUT=${NESTBOX_CONTROL_LOG_OUTPUT:-text}
135
140
  - NESTBOX_CONTROL_LOG_MAX_BYTES=${NESTBOX_CONTROL_LOG_MAX_BYTES:-262144}
136
141
  healthcheck:
@@ -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.2.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.3.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') {
@@ -178,15 +178,15 @@ OpenCode has no Docker socket. Use the control MCP as the only path for commands
178
178
  - `npm_script_change_request` requests an add, edit, or delete. The confirmation code appears only in the host runner terminal.
179
179
  - `npm_script_change_confirm` applies the pending change after the user explicitly supplies that code.
180
180
 
181
- Do not call the control HTTP API directly from OpenCode, write queue files, 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.
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.
182
182
 
183
- The control log is the audit source of truth and requires no observability service. Its host queue stays outside container-mounted workspace paths. 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.
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.
184
184
 
185
185
  ## Git And Commit Policy
186
186
 
187
187
  - Resolve the owning Git root before staging any path.
188
188
  - Read the separately loaded `instance.md` before committing Nestbox work. It records the installation layout, repository roots, and commit policy without exposing `.env`.
189
- - Treat the repository as installation history. Do not assume an upstream remote or upstream commit ancestry exists; use the recorded source revision when reasoning about upgrades.
189
+ - Treat the repository as installation history. Do not assume an upstream remote or upstream commit ancestry exists.
190
190
  - The policy may allow logical commits after verification, require confirmation before each commit, or require leaving changes uncommitted.
191
191
  - Reasonable Nestbox commits include super-documents, Compose, Dockerfiles, configurations, integrations, and dependency files.
192
192
  - Keep commits within one Git root. When one feature spans repositories, create separate commits and report their relationship.