@p10i/create-nestbox 0.1.2 → 0.2.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.
@@ -0,0 +1,454 @@
1
+ #!/usr/bin/env python3
2
+ import http.client
3
+ import json
4
+ import os
5
+ import re
6
+ import socket
7
+ import sys
8
+ import threading
9
+ import time
10
+ import uuid
11
+ from datetime import datetime, timezone
12
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
13
+ from urllib.parse import quote, unquote, urlparse
14
+
15
+ DOCKER_SOCKET = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")
16
+ WORKSPACE_PATH = os.environ.get("WORKSPACE_PATH", "/workspace")
17
+ PROJECT_NAME = os.environ.get("COMPOSE_PROJECT_NAME", "").strip()
18
+ SERVICE_NAME = os.environ.get("NESTBOX_CONTROL_SERVICE", "control")
19
+ HOST_ROOT = os.environ.get("NESTBOX_HOST_RUNNER_PATH", "/host-runner")
20
+ 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"))
22
+ MAX_BODY_BYTES = 128 * 1024
23
+ MAX_COMMAND_PARTS = 256
24
+ MAX_COMMAND_BYTES = 64 * 1024
25
+ LOG_OUTPUT = os.environ.get("NESTBOX_CONTROL_LOG_OUTPUT", "text").lower()
26
+ LOG_MAX_BYTES = int(os.environ.get("NESTBOX_CONTROL_LOG_MAX_BYTES", str(256 * 1024)))
27
+ LOG_LOCK = threading.Lock()
28
+
29
+
30
+ def now_iso():
31
+ return datetime.now(timezone.utc).isoformat()
32
+
33
+
34
+ def run_id(prefix):
35
+ return f"{prefix}-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}-{uuid.uuid4().hex[:12]}"
36
+
37
+
38
+ def log_event(event, **fields):
39
+ safe = dict(fields)
40
+ command = safe.pop("command", None)
41
+ if isinstance(command, list) and command:
42
+ safe["commandName"] = os.path.basename(command[0])
43
+ safe["argsCount"] = len(command) - 1
44
+ safe.pop("args", None)
45
+ record = {
46
+ "timestamp": now_iso(),
47
+ "schemaVersion": 1,
48
+ "service": "nestbox-control",
49
+ "event": event,
50
+ **safe,
51
+ }
52
+ with LOG_LOCK:
53
+ sys.stderr.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n")
54
+ sys.stderr.flush()
55
+
56
+
57
+ def log_output(identifier, stream, content):
58
+ if LOG_OUTPUT != "text" or not content:
59
+ return
60
+ data = content if isinstance(content, bytes) else str(content).encode()
61
+ captured = data[:LOG_MAX_BYTES].decode("utf-8", "replace")
62
+ for line in captured.splitlines():
63
+ log_event("command.output", runId=identifier, stream=stream, message=line)
64
+ if len(data) > LOG_MAX_BYTES:
65
+ log_event("command.output.truncated", runId=identifier, stream=stream, capturedBytes=LOG_MAX_BYTES, totalBytes=len(data))
66
+
67
+
68
+ class UnixHTTPConnection(http.client.HTTPConnection):
69
+ def __init__(self, path):
70
+ super().__init__("localhost")
71
+ self.path = path
72
+
73
+ def connect(self):
74
+ self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
75
+ self.sock.connect(self.path)
76
+
77
+
78
+ def docker_request(method, path, body=None, max_bytes=None):
79
+ payload = json.dumps(body).encode() if body is not None else None
80
+ headers = {"Content-Type": "application/json"} if payload is not None else {}
81
+ connection = UnixHTTPConnection(DOCKER_SOCKET)
82
+ try:
83
+ connection.request(method, path, body=payload, headers=headers)
84
+ response = connection.getresponse()
85
+ if max_bytes is None:
86
+ data = response.read()
87
+ else:
88
+ chunks = []
89
+ remaining = max_bytes
90
+ total_bytes = 0
91
+ while True:
92
+ chunk = response.read(65536)
93
+ if not chunk:
94
+ break
95
+ total_bytes += len(chunk)
96
+ if remaining > 0:
97
+ chunks.append(chunk[:remaining])
98
+ remaining -= min(len(chunk), remaining)
99
+ data = b"".join(chunks)
100
+ finally:
101
+ connection.close()
102
+ if response.status >= 400:
103
+ raise RuntimeError(f"Docker API returned HTTP {response.status}: {data.decode('utf-8', 'replace')}")
104
+ if not data:
105
+ return (None, 0) if max_bytes is not None else None
106
+ value = json.loads(data) if "application/json" in response.getheader("Content-Type", "") else data
107
+ return (value, total_bytes) if max_bytes is not None else value
108
+
109
+
110
+ def project_containers():
111
+ containers = docker_request("GET", "/containers/json?all=1") or []
112
+ return [item for item in containers if (item.get("Labels") or {}).get("com.docker.compose.project") == PROJECT_NAME]
113
+
114
+
115
+ def names(container):
116
+ labels = container.get("Labels") or {}
117
+ values = {labels.get("com.docker.compose.service", ""), container.get("Id", "")}
118
+ values.update(name.strip("/") for name in container.get("Names") or [])
119
+ return {value for value in values if value}
120
+
121
+
122
+ def find_by_name(name):
123
+ return next((container for container in project_containers() if name in names(container)), None)
124
+
125
+
126
+ def find_by_ip(ip):
127
+ for container in project_containers():
128
+ details = docker_request("GET", f"/containers/{container['Id']}/json")
129
+ networks = (details.get("NetworkSettings") or {}).get("Networks") or {}
130
+ if any(ip in {network.get("IPAddress"), network.get("GlobalIPv6Address")} for network in networks.values()):
131
+ return container
132
+ return None
133
+
134
+
135
+ def truthy(labels, key):
136
+ return str(labels.get(key, "")).lower() in {"1", "true", "yes", "on"}
137
+
138
+
139
+ def service(container):
140
+ return ((container or {}).get("Labels") or {}).get("com.docker.compose.service", "")
141
+
142
+
143
+ def is_root_user(user):
144
+ account = str(user).split(":", 1)[0].strip().lower()
145
+ return account == "root" or bool(re.fullmatch(r"\+?0+", account))
146
+
147
+
148
+ def policy_error(source, target, user, configured_user=""):
149
+ if not source:
150
+ return "caller container was not found in this Compose project"
151
+ source_labels = source.get("Labels") or {}
152
+ target_labels = target.get("Labels") or {}
153
+ if service(source) == SERVICE_NAME:
154
+ return "control service cannot call itself through the API"
155
+ if truthy(source_labels, "nestbox.exec.deny-source"):
156
+ return f"source {service(source)} is denied by label"
157
+ if truthy(target_labels, "nestbox.exec.deny-target"):
158
+ return f"target {service(target)} is denied by label"
159
+ if not truthy(source_labels, "nestbox.exec.allow-source"):
160
+ return f"source {service(source)} is not allowed to call exec"
161
+ if not truthy(target_labels, "nestbox.exec.allow-target"):
162
+ return f"target {service(target)} does not allow inbound exec"
163
+ allowed = {item.strip() for item in str(source_labels.get("nestbox.exec.targets", "")).split(",") if item.strip()}
164
+ if not allowed:
165
+ return f"source {service(source)} has no allowed exec targets"
166
+ if "*" not in allowed and names(target).isdisjoint(allowed):
167
+ return f"source {service(source)} is not allowed to exec into {service(target)}"
168
+ if is_root_user(user) and not truthy(target_labels, "nestbox.exec.allow-root"):
169
+ return f"target {service(target)} does not allow root exec"
170
+ account = str(user).split(":", 1)[0].strip().lower()
171
+ configured_account = str(configured_user).split(":", 1)[0].strip().lower()
172
+ if account and not re.fullmatch(r"\+?\d+", account) and account != configured_account and not truthy(target_labels, "nestbox.exec.allow-root"):
173
+ return f"target {service(target)} does not allow named user overrides"
174
+ return ""
175
+
176
+
177
+ def allows_npm(source):
178
+ labels = (source or {}).get("Labels") or {}
179
+ return bool(source) and truthy(labels, "nestbox.npm.allow") and not truthy(labels, "nestbox.exec.deny-source")
180
+
181
+
182
+ def package_scripts():
183
+ path = os.path.join(WORKSPACE_PATH, "package.json")
184
+ if not os.path.isfile(path):
185
+ return None
186
+ with open(path, encoding="utf-8") as stream:
187
+ document = json.load(stream)
188
+ scripts = document.get("scripts", {})
189
+ if not isinstance(scripts, dict):
190
+ raise ValueError("package.json scripts must be an object")
191
+ return {str(name): str(command) for name, command in scripts.items()}
192
+
193
+
194
+ def host_paths():
195
+ return {name: os.path.join(HOST_ROOT, name) for name in ("requests", "results", "heartbeat")}
196
+
197
+
198
+ 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)
215
+
216
+
217
+ 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
223
+
224
+
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")
239
+
240
+
241
+ def record_host_result(identifier, source_name, script, result, detached):
242
+ stdout, stderr = str(result.get("stdout", "")), str(result.get("stderr", ""))
243
+ log_output(identifier, "npm.stdout", stdout)
244
+ log_output(identifier, "npm.stderr", stderr)
245
+ 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
+
247
+
248
+ def wait_detached(identifier, paths, source_name, script):
249
+ try:
250
+ record_host_result(identifier, source_name, script, wait_result(identifier, paths), True)
251
+ except Exception as error:
252
+ log_event("npm.error", runId=identifier, source=source_name, script=script, detached=True, error=str(error))
253
+
254
+
255
+ def valid_command(command):
256
+ return (isinstance(command, list) and 0 < len(command) <= MAX_COMMAND_PARTS
257
+ and all(isinstance(part, str) and "\0" not in part for part in command)
258
+ and sum(len(part.encode()) for part in command) <= MAX_COMMAND_BYTES)
259
+
260
+
261
+ class Handler(BaseHTTPRequestHandler):
262
+ server_version = "nestbox-control/0.2"
263
+
264
+ def setup(self):
265
+ super().setup()
266
+ self.connection.settimeout(30)
267
+
268
+ def log_message(self, fmt, *args):
269
+ log_event("http.access", client=self.address_string(), message=fmt % args)
270
+
271
+ def send_json(self, status, value):
272
+ payload = json.dumps(value, ensure_ascii=False).encode()
273
+ self.send_response(status)
274
+ self.send_header("Content-Type", "application/json; charset=utf-8")
275
+ self.send_header("Content-Length", str(len(payload)))
276
+ self.end_headers()
277
+ self.wfile.write(payload)
278
+
279
+ def caller(self):
280
+ return find_by_ip(self.client_address[0])
281
+
282
+ def body(self):
283
+ if self.headers.get("Transfer-Encoding"):
284
+ raise ValueError("transfer encoding is not supported")
285
+ length = int(self.headers.get("Content-Length", "0"))
286
+ if length < 0 or length > MAX_BODY_BYTES:
287
+ raise ValueError("request body is too large")
288
+ value = json.loads(self.rfile.read(length)) if length else {}
289
+ if not isinstance(value, dict):
290
+ raise ValueError("request body must be a JSON object")
291
+ return value
292
+
293
+ def do_GET(self):
294
+ path = urlparse(self.path).path
295
+ if path == "/health":
296
+ try:
297
+ docker_request("GET", "/_ping")
298
+ self.send_json(200, {"ok": True, "dockerSocketAccess": True, "workspacePackageJson": os.path.isfile(os.path.join(WORKSPACE_PATH, "package.json")), "hostRunner": host_status()})
299
+ except Exception as error:
300
+ self.send_json(503, {"ok": False, "dockerSocketAccess": False, "error": str(error), "hostRunner": host_status()})
301
+ return
302
+ if path == "/npm/scripts":
303
+ source = self.caller()
304
+ if not allows_npm(source):
305
+ self.send_json(403, {"error": "caller is not allowed to use npm"})
306
+ return
307
+ try:
308
+ scripts = package_scripts()
309
+ except Exception as error:
310
+ self.send_json(400, {"error": str(error)})
311
+ return
312
+ self.send_json(200, {"scripts": scripts}) if scripts is not None else self.send_json(404, {"error": "package.json was not found at /workspace/package.json"})
313
+ return
314
+ self.send_json(404, {"error": "not found"})
315
+
316
+ def do_POST(self):
317
+ try:
318
+ body = self.body()
319
+ except Exception as error:
320
+ self.send_json(400, {"error": str(error)})
321
+ return
322
+ path = urlparse(self.path).path
323
+ if path.startswith("/containers/") and path.endswith("/exec"):
324
+ self.container_exec(unquote(path[len("/containers/"):-len("/exec")]).strip("/"), body)
325
+ elif path == "/npm/run":
326
+ self.npm_run(body)
327
+ elif path == "/npm/scripts/request":
328
+ self.script_request(body)
329
+ elif path == "/npm/scripts/confirm":
330
+ self.script_confirm(body)
331
+ else:
332
+ self.send_json(404, {"error": "not found"})
333
+
334
+ def container_exec(self, target_name, body):
335
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", target_name):
336
+ self.send_json(400, {"error": "invalid target name"}); return
337
+ target, source = find_by_name(target_name), self.caller()
338
+ if not target:
339
+ self.send_json(404, {"error": "target container was not found in this Compose project"}); return
340
+ command, user, workdir = body.get("command"), str(body.get("user", "")), str(body.get("workdir", ""))
341
+ if not valid_command(command):
342
+ self.send_json(400, {"error": "command must be a bounded, non-empty string array"}); return
343
+ if not workdir.startswith("/") or "\0" in workdir:
344
+ self.send_json(400, {"error": "workdir must be an absolute container path"}); return
345
+ effective_user = user
346
+ details = docker_request("GET", f"/containers/{quote(target['Id'])}/json") or {}
347
+ configured_user = str((details.get("Config") or {}).get("User", ""))
348
+ if not effective_user:
349
+ effective_user = configured_user or "0"
350
+ error = policy_error(source, target, effective_user, configured_user)
351
+ if error:
352
+ self.send_json(403, {"error": error}); return
353
+ identifier, detached = run_id("exec"), bool(body.get("detach", False))
354
+ try:
355
+ created = docker_request("POST", f"/containers/{quote(target['Id'])}/exec", {"AttachStdout": True, "AttachStderr": True, "Cmd": command, "Tty": True, "User": user, "WorkingDir": workdir})
356
+ if detached:
357
+ def finish():
358
+ output, total_bytes = docker_request("POST", f"/exec/{created['Id']}/start", {"Detach": False, "Tty": True}, LOG_MAX_BYTES)
359
+ output = output or b""
360
+ info = docker_request("GET", f"/exec/{created['Id']}/json") or {}
361
+ log_output(identifier, "exec.output", output)
362
+ log_event("exec.complete", runId=identifier, source=service(source), target=service(target), command=command, detached=True, exitCode=info.get("ExitCode"), outputTruncated=total_bytes > len(output), outputTotalBytes=total_bytes)
363
+ threading.Thread(target=finish, daemon=True).start()
364
+ self.send_json(202, {"runId": identifier, "execId": created["Id"], "detached": True}); return
365
+ output, total_bytes = docker_request("POST", f"/exec/{created['Id']}/start", {"Detach": False, "Tty": True}, LOG_MAX_BYTES)
366
+ output = output or b""
367
+ info = docker_request("GET", f"/exec/{created['Id']}/json") or {}
368
+ log_output(identifier, "exec.output", output)
369
+ self.send_json(200, {"runId": identifier, "exitCode": info.get("ExitCode"), "output": output.decode("utf-8", "replace"), "outputTruncated": total_bytes > len(output), "outputTotalBytes": total_bytes})
370
+ except Exception as error:
371
+ log_event("exec.error", runId=identifier, source=service(source), target=service(target), command=command, error=str(error))
372
+ self.send_json(502, {"error": str(error)})
373
+
374
+ def npm_context(self):
375
+ source = self.caller()
376
+ if not allows_npm(source):
377
+ self.send_json(403, {"error": "caller is not allowed to use npm"})
378
+ return None
379
+ if not host_status().get("available"):
380
+ self.send_json(503, {"error": "host runner is unavailable; run `npm run host --` from the workspace"})
381
+ return None
382
+ return source
383
+
384
+ def npm_run(self, body):
385
+ source = self.npm_context()
386
+ if source is None: return
387
+ script, args = body.get("script"), body.get("args", [])
388
+ if not isinstance(script, str) or not re.fullmatch(r"[A-Za-z0-9:_@./ -]{1,128}", script) or script == "host":
389
+ self.send_json(400, {"error": "invalid or protected script name"}); return
390
+ if not isinstance(args, list) or not all(isinstance(arg, str) and "\0" not in arg for arg in args):
391
+ self.send_json(400, {"error": "args must be a string array"}); return
392
+ try:
393
+ scripts = package_scripts()
394
+ except Exception as error:
395
+ self.send_json(400, {"error": str(error)}); return
396
+ if scripts is None or script not in scripts:
397
+ self.send_json(404, {"error": "script is not defined in /workspace/package.json"}); return
398
+ identifier = run_id("npm")
399
+ job = {"schemaVersion": 1, "jobId": identifier, "type": "npm.run", "script": script, "args": args, "source": service(source), "createdAt": now_iso()}
400
+ try:
401
+ paths = enqueue(job)
402
+ if body.get("detach"):
403
+ threading.Thread(target=wait_detached, args=(identifier, paths, service(source), script), daemon=True).start()
404
+ self.send_json(202, {"runId": identifier, "detached": True, "executor": "host"}); return
405
+ result = wait_result(identifier, paths)
406
+ record_host_result(identifier, service(source), script, result, False)
407
+ 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
+ except Exception as error:
409
+ self.send_json(502, {"error": str(error)})
410
+
411
+ def script_request(self, body):
412
+ source = self.npm_context()
413
+ if source is None: return
414
+ operation, name, command = body.get("operation"), body.get("name"), body.get("command")
415
+ if operation not in {"add", "edit", "delete"} or not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9:_@./ -]{1,128}", name) or name == "host":
416
+ self.send_json(400, {"error": "invalid script change"}); return
417
+ if operation != "delete" and (not isinstance(command, str) or not command.strip() or len(command) > 8192):
418
+ self.send_json(400, {"error": "command must be a non-empty string of at most 8192 characters"}); return
419
+ if operation == "delete" and command is not None:
420
+ self.send_json(400, {"error": "delete does not accept a command"}); return
421
+ identifier = run_id("npm-script-request")
422
+ job = {"schemaVersion": 1, "jobId": identifier, "type": "npm.scripts.request", "requestId": identifier, "operation": operation, "name": name, "source": service(source), "createdAt": now_iso()}
423
+ if operation != "delete": job["command"] = command
424
+ try:
425
+ result = wait_result(identifier, enqueue(job))
426
+ if result.get("status") != "confirmation_required": raise ValueError(result.get("stderr", "host runner rejected the request"))
427
+ self.send_json(202, {"status": "confirmation_required", "requestId": result.get("requestId"), "operation": operation, "name": name, "expiresAt": result.get("expiresAt")})
428
+ except Exception as error:
429
+ self.send_json(400, {"error": str(error)})
430
+
431
+ def script_confirm(self, body):
432
+ source = self.npm_context()
433
+ if source is None: return
434
+ request_id, code = body.get("requestId"), body.get("code")
435
+ if not isinstance(request_id, str) or not re.fullmatch(r"npm-script-request-[A-Za-z0-9-]{16,80}", request_id) or not isinstance(code, str) or not re.fullmatch(r"[A-Za-z0-9_-]{4,32}", code):
436
+ self.send_json(400, {"error": "invalid request ID or confirmation code"}); return
437
+ identifier = run_id("npm-script-confirm")
438
+ job = {"schemaVersion": 1, "jobId": identifier, "type": "npm.scripts.confirm", "requestId": request_id, "code": code, "source": service(source), "createdAt": now_iso()}
439
+ try:
440
+ result = wait_result(identifier, enqueue(job))
441
+ if result.get("status") != "success": raise ValueError(result.get("stderr", "host runner rejected the confirmation"))
442
+ self.send_json(200, {"status": "success", "requestId": request_id, "operation": result.get("operation"), "name": result.get("name")})
443
+ except Exception as error:
444
+ self.send_json(400, {"error": str(error)})
445
+
446
+
447
+ def main():
448
+ if not re.fullmatch(r"[a-z0-9][a-z0-9_-]*", PROJECT_NAME):
449
+ raise SystemExit("COMPOSE_PROJECT_NAME must be a non-empty lowercase project slug")
450
+ ThreadingHTTPServer(("0.0.0.0", int(os.environ.get("NESTBOX_CONTROL_PORT", "4088"))), Handler).serve_forever()
451
+
452
+
453
+ if __name__ == "__main__":
454
+ main()
@@ -10,7 +10,7 @@ FROM ghcr.io/anomalyco/opencode:latest
10
10
  ARG APP_UID=1000
11
11
  ARG APP_GID=1000
12
12
 
13
- RUN apk add --no-cache git php85 php85-dom php85-mbstring php85-openssl php85-phar php85-tokenizer php85-xml php85-xmlwriter su-exec && \
13
+ RUN apk add --no-cache git nodejs php85 php85-dom php85-mbstring php85-openssl php85-phar php85-tokenizer php85-xml php85-xmlwriter su-exec && \
14
14
  ln -s /usr/bin/php85 /usr/local/bin/php && \
15
15
  addgroup -g "${APP_GID}" -S nestbox && \
16
16
  adduser -u "${APP_UID}" -G nestbox -S -h /home/nestbox nestbox
@@ -37,6 +37,9 @@ services:
37
37
  # Executes super-document HTTP and CLI directions. Trusted pages may update
38
38
  # page state and intentionally operate on the mounted workspace.
39
39
  php-fpm:
40
+ depends_on:
41
+ control:
42
+ condition: service_healthy
40
43
  build:
41
44
  context: .
42
45
  dockerfile: ./docker/php-fpm/Dockerfile
@@ -56,6 +59,12 @@ services:
56
59
  target: /workspace
57
60
  bind:
58
61
  create_host_path: false
62
+ - type: bind
63
+ source: ${WORKSPACE_PATH:-.}/package.json
64
+ target: /workspace/package.json
65
+ read_only: true
66
+ bind:
67
+ create_host_path: false
59
68
  environment:
60
69
  # Event publication and OpenCode automation stay inside Compose.
61
70
  - NESTBOX_BUS_PUBLISH_URL=http://nginx:8080/_nestbox/publish
@@ -64,6 +73,11 @@ services:
64
73
  - NESTBOX_OPENCODE_PUBLIC_URL=${NESTBOX_OPENCODE_PUBLIC_URL:-}
65
74
  - OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode}
66
75
  - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:-}
76
+ - NESTBOX_CONTROL_URL=http://control:4088
77
+ labels:
78
+ - nestbox.exec.allow-target=true
79
+ - nestbox.exec.allow-source=true
80
+ - nestbox.exec.targets=php-fpm
67
81
  restart: unless-stopped
68
82
 
69
83
  # Watches shared browser source and refreshes tracked bundle baselines.
@@ -88,11 +102,51 @@ services:
88
102
  create_host_path: false
89
103
  # Keep dependencies out of the host page tree.
90
104
  - rollup-node-modules:/sources/rollup/node_modules
105
+ labels:
106
+ - nestbox.exec.allow-target=true
107
+ restart: unless-stopped
108
+
109
+ # Private, label-gated bridge for container exec and native host npm jobs.
110
+ # This is the only Nestbox service with access to the host Docker socket.
111
+ control:
112
+ build:
113
+ context: .
114
+ dockerfile: ./docker/control/Dockerfile
115
+ expose:
116
+ - "4088"
117
+ volumes:
118
+ - ${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock
119
+ - type: bind
120
+ source: ${WORKSPACE_PATH:-.}
121
+ target: /workspace
122
+ read_only: true
123
+ bind:
124
+ create_host_path: false
125
+ - type: bind
126
+ source: ${NESTBOX_HOST_QUEUE_PATH:?NESTBOX_HOST_QUEUE_PATH must be configured}
127
+ target: /host-runner
128
+ bind:
129
+ create_host_path: false
130
+ environment:
131
+ - COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}
132
+ - NESTBOX_CONTROL_SERVICE=control
133
+ - NESTBOX_HOST_RUNNER_PATH=/host-runner
134
+ - NESTBOX_CONTROL_LOG_OUTPUT=${NESTBOX_CONTROL_LOG_OUTPUT:-text}
135
+ - NESTBOX_CONTROL_LOG_MAX_BYTES=${NESTBOX_CONTROL_LOG_MAX_BYTES:-262144}
136
+ healthcheck:
137
+ test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:4088/health"]
138
+ interval: 10s
139
+ timeout: 3s
140
+ retries: 5
141
+ start_period: 5s
91
142
  restart: unless-stopped
92
143
 
93
144
  # Internal implementation agent. It can edit Nestbox and the workspace but
94
145
  # deliberately receives no Docker socket or direct host control.
95
146
  opencode:
147
+ depends_on:
148
+ control:
149
+ condition: service_healthy
96
150
  build:
97
151
  context: .
98
152
  dockerfile: ./docker/opencode/Dockerfile
@@ -125,11 +179,22 @@ services:
125
179
  target: /workspace
126
180
  bind:
127
181
  create_host_path: false
182
+ - type: bind
183
+ source: ${WORKSPACE_PATH:-.}/package.json
184
+ target: /workspace/package.json
185
+ read_only: true
186
+ bind:
187
+ create_host_path: false
128
188
  # The entrypoint fixes this volume's ownership before dropping privileges.
129
189
  - opencode-data:/home/nestbox/.local/share/opencode
130
190
  working_dir: /workspace
131
191
  environment:
132
192
  - NESTBOX_BUS_PUBLISH_URL=http://nginx:8080/_nestbox/publish
193
+ - NESTBOX_CONTROL_URL=http://control:4088
194
+ labels:
195
+ - nestbox.exec.allow-source=true
196
+ - nestbox.exec.targets=php-fpm,rollup
197
+ - nestbox.npm.allow=true
133
198
  restart: unless-stopped
134
199
 
135
200
  volumes:
@@ -192,6 +192,74 @@ function nestbox_opencode_request(string $method, string $path, array $body, str
192
192
  return json_decode($response, true, flags: JSON_THROW_ON_ERROR);
193
193
  }
194
194
 
195
+ /** Send a bounded JSON request to the private, label-gated control service. */
196
+ function nestbox_control_request(string $method, string $path, array $body = []): mixed
197
+ {
198
+ $baseUrl = rtrim((string) getenv('NESTBOX_CONTROL_URL'), '/');
199
+ if ($baseUrl === '') {
200
+ throw new RuntimeException('NESTBOX_CONTROL_URL is not configured.');
201
+ }
202
+ if (!str_starts_with($path, '/') || preg_match('/[\0\r\n]/', $path)) {
203
+ throw new InvalidArgumentException('Invalid control request path.');
204
+ }
205
+
206
+ $payload = json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
207
+ $context = stream_context_create([
208
+ 'http' => [
209
+ 'method' => strtoupper($method),
210
+ 'header' => implode("\r\n", [
211
+ 'Accept: application/json',
212
+ 'Content-Type: application/json',
213
+ 'Content-Length: ' . strlen($payload),
214
+ ]),
215
+ 'content' => $payload,
216
+ 'ignore_errors' => true,
217
+ 'timeout' => 30,
218
+ ],
219
+ ]);
220
+ $response = @file_get_contents($baseUrl . $path, false, $context, 0, 1024 * 1024);
221
+ $statusLine = $http_response_header[0] ?? '';
222
+ preg_match('#^HTTP/\S+\s+(\d{3})#', $statusLine, $matches);
223
+ $status = isset($matches[1]) ? (int) $matches[1] : 0;
224
+ if ($response === false || $status < 200 || $status >= 300) {
225
+ throw new RuntimeException("Control request failed with HTTP status $status.");
226
+ }
227
+ return $response === '' ? null : json_decode($response, true, flags: JSON_THROW_ON_ERROR);
228
+ }
229
+
230
+ /** Execute one explicit argument array in an allowed Compose service. */
231
+ function nestbox_control_exec(
232
+ string $service,
233
+ string $workdir,
234
+ array $command,
235
+ bool $detach = false,
236
+ string $user = '',
237
+ ): array {
238
+ if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/', $service)) {
239
+ throw new InvalidArgumentException('Invalid control target service.');
240
+ }
241
+ if (!str_starts_with($workdir, '/') || str_contains($workdir, "\0")) {
242
+ throw new InvalidArgumentException('Control workdir must be an absolute container path.');
243
+ }
244
+ if ($command === [] || count($command) > 256) {
245
+ throw new InvalidArgumentException('Control command must be a non-empty bounded argument array.');
246
+ }
247
+ foreach ($command as $part) {
248
+ if (!is_string($part) || str_contains($part, "\0")) {
249
+ throw new InvalidArgumentException('Every control command argument must be a string.');
250
+ }
251
+ }
252
+ $body = ['command' => array_values($command), 'workdir' => $workdir, 'detach' => $detach];
253
+ if ($user !== '') {
254
+ $body['user'] = $user;
255
+ }
256
+ $result = nestbox_control_request('POST', '/containers/' . rawurlencode($service) . '/exec', $body);
257
+ if (!is_array($result) || !isset($result['runId']) || !is_string($result['runId'])) {
258
+ throw new RuntimeException('Control service returned an invalid exec response.');
259
+ }
260
+ return $result;
261
+ }
262
+
195
263
  /** Create an OpenCode session synchronously so its ID is available immediately. */
196
264
  function nestbox_opencode_create_session(string $title, string $directory = '/workspace'): array
197
265
  {