@p10i/create-nestbox 0.1.1 → 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.
- package/.dockerignore +3 -0
- package/.env.example +12 -0
- package/CHANGELOG.md +10 -0
- package/INSTALL.md +19 -3
- package/README.md +10 -2
- package/bin/create-nestbox.js +78 -0
- package/docker/control/Dockerfile +12 -0
- package/docker/control/server.py +454 -0
- package/docker/opencode/Dockerfile +1 -1
- package/docker-compose.yaml +65 -0
- package/home/code/__includes/functions.php +68 -0
- package/home/configs/opencode/control-mcp.js +102 -0
- package/home/configs/opencode/instructions.md +19 -3
- package/home/configs/opencode/opencode.json +11 -0
- package/host-runner.mjs +301 -0
- package/package.host.json +8 -0
- package/package.json +9 -1
- package/scripts/check-package.js +14 -1
- package/template.gitignore +1 -0
- package/tests/control.mjs +51 -0
- package/tests/creator.mjs +42 -0
- package/tests/host-runner.mjs +103 -0
- package/tests/nestbox.ps1 +19 -6
- package/tests/nestbox.sh +22 -5
|
@@ -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
|
package/docker-compose.yaml
CHANGED
|
@@ -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
|
{
|