@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 +3 -3
- package/CHANGELOG.md +12 -0
- package/INSTALL.md +8 -6
- package/README.md +1 -9
- package/bin/create-nestbox.js +23 -14
- package/docker/control/server.py +254 -48
- package/docker-compose.yaml +6 -3
- package/home/configs/nginx/nginx.conf +17 -0
- package/home/configs/opencode/control-mcp.js +1 -1
- package/home/configs/opencode/instructions.md +5 -4
- package/home/configs/rollup/package-lock.json +10 -13
- package/host-runner.mjs +156 -165
- package/package.json +7 -4
- package/scripts/check-package.js +4 -0
- package/tests/control.mjs +19 -0
- package/tests/creator.mjs +2 -1
- package/tests/host-runner.mjs +87 -47
- package/tests/nestbox.ps1 +11 -3
- package/tests/nestbox.sh +9 -5
- package/home/code/scripts-bundle.css +0 -13295
- package/home/code/scripts-bundle.js +0 -6277
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
|
-
#
|
|
12
|
-
#
|
|
13
|
-
|
|
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`,
|
|
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.
|
|
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
|
|
102
|
-
-
|
|
103
|
-
- Start `npm run host --`
|
|
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
|
|
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
|
|
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
|
-
```
|
package/bin/create-nestbox.js
CHANGED
|
@@ -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
|
|
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
|
|
230
|
-
await mkdir(
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const
|
|
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();
|
package/docker/control/server.py
CHANGED
|
@@ -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
|
-
|
|
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", "
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
226
|
-
|
|
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,
|
|
361
|
+
def wait_detached(identifier, source_name, script):
|
|
249
362
|
try:
|
|
250
|
-
record_host_result(identifier, source_name, script, wait_result(identifier
|
|
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.
|
|
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
|
-
|
|
514
|
+
enqueue(job)
|
|
402
515
|
if body.get("detach"):
|
|
403
|
-
threading.Thread(target=wait_detached, args=(identifier,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/docker-compose.yaml
CHANGED
|
@@ -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: ${
|
|
127
|
-
target: /host-
|
|
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
|
-
-
|
|
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.
|
|
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') {
|