@p10i/create-nestbox 0.2.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,9 @@ 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
+ # 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.
13
+ NESTBOX_HOST_TOKEN_FILE=
14
14
 
15
15
  # Override for rootless or nonstandard Docker Engine installations.
16
16
  DOCKER_SOCKET_PATH=/var/run/docker.sock
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
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
+
8
+ ## 0.3.0
9
+
10
+ - Replace the filesystem host queue with authenticated HTTP long polling managed by `control`.
11
+ - Publish only the dedicated host-runner endpoint on loopback; keep the container API internal.
12
+ - Remove queue paths, bind mounts, polling files, and stale lock/result state.
13
+ - Generate a per-installation runner token file unavailable to application containers and preserve existing MCP and PHP contracts.
14
+
3
15
  ## 0.2.0
4
16
 
5
17
  - 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,9 @@ 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
+ - 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.
104
106
  - The workspace manifest is mounted read-only in runtime containers. Confirmed changes are written only by the native host runner.
105
107
 
106
108
  Update the new `home/configs/opencode/instance.md` with:
@@ -271,4 +273,4 @@ When a super-document intentionally changes files in `/workspace`, follow that w
271
273
 
272
274
  ## 11. Optional Adaptations
273
275
 
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.
276
+ 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 `/_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
 
@@ -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.4"
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.4"
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", 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,8 @@ services:
114
114
  dockerfile: ./docker/control/Dockerfile
115
115
  expose:
116
116
  - "4088"
117
+ # Authenticated host-runner transport, proxied only through Nginx.
118
+ - "4089"
117
119
  volumes:
118
120
  - ${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock
119
121
  - type: bind
@@ -123,14 +125,15 @@ services:
123
125
  bind:
124
126
  create_host_path: false
125
127
  - type: bind
126
- source: ${NESTBOX_HOST_QUEUE_PATH:?NESTBOX_HOST_QUEUE_PATH must be configured}
127
- target: /host-runner
128
+ source: ${NESTBOX_HOST_TOKEN_FILE:?NESTBOX_HOST_TOKEN_FILE must be configured}
129
+ target: /run/secrets/nestbox-host-token
130
+ read_only: true
128
131
  bind:
129
132
  create_host_path: false
130
133
  environment:
131
134
  - COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}
132
135
  - NESTBOX_CONTROL_SERVICE=control
133
- - NESTBOX_HOST_RUNNER_PATH=/host-runner
136
+ - NESTBOX_HOST_TOKEN_FILE=/run/secrets/nestbox-host-token
134
137
  - NESTBOX_CONTROL_LOG_OUTPUT=${NESTBOX_CONTROL_LOG_OUTPUT:-text}
135
138
  - NESTBOX_CONTROL_LOG_MAX_BYTES=${NESTBOX_CONTROL_LOG_MAX_BYTES:-262144}
136
139
  healthcheck:
@@ -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.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.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') {