@p10i/create-nestbox 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +4 -3
- package/CHANGELOG.md +7 -0
- package/INSTALL.md +10 -6
- package/README.md +1 -9
- package/bin/create-nestbox.js +23 -14
- package/docker/control/server.py +254 -48
- package/docker-compose.yaml +8 -3
- package/home/configs/opencode/control-mcp.js +1 -1
- package/home/configs/opencode/instructions.md +3 -3
- package/home/configs/rollup/package-lock.json +10 -13
- package/host-runner.mjs +152 -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 +9 -3
- package/tests/nestbox.sh +7 -5
- package/home/code/scripts-bundle.css +0 -13295
- package/home/code/scripts-bundle.js +0 -6277
package/tests/host-runner.mjs
CHANGED
|
@@ -1,49 +1,81 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import {
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import { dirname, join, resolve } from 'node:path';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
7
|
|
|
7
8
|
const fixture = await mkdtemp(join(tmpdir(), 'nestbox-host-runner-'));
|
|
9
|
+
const tokenFixture = await mkdtemp(join(tmpdir(), 'nestbox-host-token-'));
|
|
8
10
|
const testDirectory = dirname(fileURLToPath(import.meta.url));
|
|
9
11
|
const runnerPath = resolve(testDirectory, '..', 'host-runner.mjs');
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
12
|
+
const token = 'test-token-'.padEnd(64, 'x');
|
|
13
|
+
const jobs = [];
|
|
14
|
+
const results = new Map();
|
|
15
|
+
const resultAttempts = new Map();
|
|
14
16
|
let runner;
|
|
15
|
-
let
|
|
17
|
+
let runnerStdout = '';
|
|
18
|
+
let heartbeatConflicts = 1;
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
function readBody(request) {
|
|
21
|
+
return new Promise((resolveBody, reject) => {
|
|
22
|
+
const chunks = [];
|
|
23
|
+
request.on('data', chunk => chunks.push(chunk));
|
|
24
|
+
request.on('end', () => { try { resolveBody(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); } catch (error) { reject(error); } });
|
|
25
|
+
request.on('error', reject);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const server = createServer(async (request, response) => {
|
|
30
|
+
if (request.headers.authorization !== `Bearer ${token}` || !request.headers['x-nestbox-runner-id']) {
|
|
31
|
+
response.writeHead(401).end('{"error":"unauthorized"}'); return;
|
|
32
|
+
}
|
|
33
|
+
if (request.method === 'POST' && request.url === '/host/heartbeat') {
|
|
34
|
+
if (heartbeatConflicts > 0) { heartbeatConflicts -= 1; response.writeHead(409).end('{"error":"another host runner is active"}'); return; }
|
|
35
|
+
await readBody(request); response.writeHead(200, { 'content-type': 'application/json' }).end('{"ok":true}'); return;
|
|
36
|
+
}
|
|
37
|
+
if (request.method === 'GET' && request.url.startsWith('/host/jobs/next')) {
|
|
38
|
+
const job = jobs.shift();
|
|
39
|
+
if (!job) { response.writeHead(204).end(); return; }
|
|
40
|
+
response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(job)); return;
|
|
41
|
+
}
|
|
42
|
+
const match = request.url.match(/^\/host\/jobs\/([^/]+)\/result$/);
|
|
43
|
+
if (request.method === 'POST' && match) {
|
|
44
|
+
const jobId = decodeURIComponent(match[1]);
|
|
45
|
+
const result = await readBody(request);
|
|
46
|
+
const attempt = (resultAttempts.get(jobId) || 0) + 1;
|
|
47
|
+
resultAttempts.set(jobId, attempt);
|
|
48
|
+
if (jobId === 'job-retry' && attempt === 1) { response.writeHead(503).end('{"error":"restarting"}'); return; }
|
|
49
|
+
results.set(jobId, result);
|
|
50
|
+
response.writeHead(202, { 'content-type': 'application/json' }).end('{"accepted":true}'); return;
|
|
51
|
+
}
|
|
52
|
+
response.writeHead(404).end();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
async function waitForResult(jobId, timeout = 10000) {
|
|
18
56
|
const deadline = Date.now() + timeout;
|
|
19
57
|
while (Date.now() < deadline) {
|
|
20
|
-
|
|
21
|
-
catch (error) { if (error?.code !== 'ENOENT') throw error; }
|
|
58
|
+
if (results.has(jobId)) return results.get(jobId);
|
|
22
59
|
await new Promise(resolveWait => setTimeout(resolveWait, 25));
|
|
23
60
|
}
|
|
24
|
-
throw new Error(`Timed out waiting for ${
|
|
61
|
+
throw new Error(`Timed out waiting for result ${jobId}`);
|
|
25
62
|
}
|
|
26
63
|
|
|
27
|
-
async function waitForEvent(type, requestId) {
|
|
28
|
-
const deadline = Date.now() +
|
|
64
|
+
async function waitForEvent(type, requestId, timeout = 10000) {
|
|
65
|
+
const deadline = Date.now() + timeout;
|
|
29
66
|
while (Date.now() < deadline) {
|
|
30
|
-
for (const line of
|
|
67
|
+
for (const line of runnerStdout.split(/\r?\n/).filter(Boolean)) {
|
|
31
68
|
const event = JSON.parse(line);
|
|
32
69
|
if (event.type === type && (!requestId || event.requestId === requestId)) return event;
|
|
33
70
|
}
|
|
34
71
|
await new Promise(resolveWait => setTimeout(resolveWait, 25));
|
|
35
72
|
}
|
|
36
|
-
throw new Error(`Timed out waiting for ${type}`);
|
|
73
|
+
throw new Error(`Timed out waiting for event ${type}`);
|
|
37
74
|
}
|
|
38
75
|
|
|
39
|
-
|
|
40
|
-
await writeFile(join(requests, `${job.jobId}.json`), JSON.stringify({ schemaVersion: 1, ...job }));
|
|
41
|
-
return JSON.parse(await waitFor(join(results, `${job.jobId}.json`)));
|
|
42
|
-
}
|
|
76
|
+
function submit(job) { jobs.push({ schemaVersion: 1, ...job }); return waitForResult(job.jobId); }
|
|
43
77
|
|
|
44
78
|
try {
|
|
45
|
-
await Promise.all([mkdir(requests, { recursive: true }), mkdir(running, { recursive: true })]);
|
|
46
|
-
await writeFile(join(running, 'interrupted-job.json'), JSON.stringify({ schemaVersion: 1, jobId: 'interrupted-job', type: 'npm.run', script: 'ok', args: [] }));
|
|
47
79
|
await writeFile(join(fixture, 'package.json'), JSON.stringify({
|
|
48
80
|
private: true,
|
|
49
81
|
scripts: {
|
|
@@ -52,52 +84,60 @@ try {
|
|
|
52
84
|
fail: `node -e "process.stderr.write('host-failed'); process.exit(7)"`,
|
|
53
85
|
},
|
|
54
86
|
}));
|
|
87
|
+
const tokenPath = join(tokenFixture, 'host-token');
|
|
88
|
+
await writeFile(tokenPath, `${token}\n`);
|
|
89
|
+
await new Promise((resolveListen, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolveListen); });
|
|
90
|
+
const port = server.address().port;
|
|
55
91
|
runner = spawn(process.execPath, [runnerPath, '--json'], {
|
|
56
|
-
env: { ...process.env, NESTBOX_HOST_WORKSPACE: fixture,
|
|
57
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
58
|
-
windowsHide: true,
|
|
92
|
+
env: { ...process.env, npm_execpath: '', NESTBOX_HOST_WORKSPACE: fixture, NESTBOX_CONTROL_HOST_URL: `http://127.0.0.1:${port}`, NESTBOX_HOST_TOKEN_FILE: tokenPath, NESTBOX_HOST_TOKEN: '' },
|
|
93
|
+
stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
|
|
59
94
|
});
|
|
60
|
-
let
|
|
61
|
-
runner.stdout.on('data', chunk =>
|
|
62
|
-
runner.
|
|
63
|
-
await waitFor(join(queue, 'heartbeat.json'));
|
|
64
|
-
const interrupted = JSON.parse(await waitFor(join(results, 'interrupted-job.json')));
|
|
65
|
-
if (!interrupted.interrupted || interrupted.exitCode !== 1) throw new Error('Interrupted job was replayed instead of rejected.');
|
|
95
|
+
let runnerStderr = '';
|
|
96
|
+
runner.stdout.on('data', chunk => runnerStdout += chunk); runner.stderr.on('data', chunk => runnerStderr += chunk);
|
|
97
|
+
await waitForEvent('runner.ready');
|
|
66
98
|
|
|
67
99
|
const success = await submit({ jobId: 'job-ok', type: 'npm.run', script: 'ok', args: [] });
|
|
68
100
|
const failure = await submit({ jobId: 'job-fail', type: 'npm.run', script: 'fail', args: [] });
|
|
69
101
|
if (success.exitCode !== 0 || !success.stdout.includes('host-ok') || !success.hostPlatform) throw new Error('Successful host result was not preserved.');
|
|
70
102
|
if (failure.exitCode !== 7 || !failure.stderr.includes('host-failed')) throw new Error('Failed host result was not preserved.');
|
|
103
|
+
const retried = await submit({ jobId: 'job-retry', type: 'npm.run', script: 'ok', args: [] });
|
|
104
|
+
if (retried.exitCode !== 0 || resultAttempts.get('job-retry') !== 2) throw new Error('Host result was not retried after a transient control failure.');
|
|
71
105
|
|
|
72
106
|
const requestId = 'npm-script-request-test-add';
|
|
73
|
-
const
|
|
107
|
+
const requestedPromise = submit({ jobId: requestId, type: 'npm.scripts.request', requestId, operation: 'add', name: 'approved', command: `node -e "process.stdout.write('approved')"` });
|
|
74
108
|
const event = await waitForEvent('npm.scripts.confirmation_required', requestId);
|
|
109
|
+
const requested = await requestedPromise;
|
|
75
110
|
if (requested.status !== 'confirmation_required' || Object.hasOwn(requested, 'code')) throw new Error('Script request exposed its confirmation code.');
|
|
76
111
|
const confirmed = await submit({ jobId: 'confirm-add', type: 'npm.scripts.confirm', requestId, code: event.code });
|
|
77
112
|
const changed = JSON.parse(await readFile(join(fixture, 'package.json'), 'utf8'));
|
|
78
113
|
if (confirmed.status !== 'success' || !changed.scripts.approved) throw new Error('Approved script was not added.');
|
|
79
114
|
|
|
80
|
-
const raceId = 'npm-script-request-race-test';
|
|
81
|
-
await submit({ jobId: raceId, type: 'npm.scripts.request', requestId: raceId, operation: 'edit', name: 'approved', command: 'node changed.mjs' });
|
|
82
|
-
const raceEvent = await waitForEvent('npm.scripts.confirmation_required', raceId);
|
|
83
|
-
changed.scripts.concurrent = 'node concurrent.mjs';
|
|
84
|
-
await writeFile(join(fixture, 'package.json'), JSON.stringify(changed));
|
|
85
|
-
const raced = await submit({ jobId: 'confirm-race', type: 'npm.scripts.confirm', requestId: raceId, code: raceEvent.code });
|
|
86
|
-
if (raced.exitCode !== 1 || !raced.stderr.includes('changed after approval')) throw new Error('Concurrent package change was overwritten.');
|
|
87
|
-
|
|
88
115
|
const protectedResult = await submit({ jobId: 'protected-host', type: 'npm.scripts.request', requestId: 'protected-host', operation: 'delete', name: 'host' });
|
|
89
116
|
if (protectedResult.exitCode !== 1 || !protectedResult.stderr.includes('cannot be changed')) throw new Error('Core host script was not protected.');
|
|
90
117
|
|
|
91
|
-
const exit = new Promise((resolveExit, reject) => {
|
|
92
|
-
|
|
93
|
-
|
|
118
|
+
const exit = new Promise((resolveExit, reject) => { runner.on('error', reject); runner.on('close', code => code === 0 || code === null ? resolveExit() : reject(new Error(`Runner exited ${code}: ${runnerStderr}`))); });
|
|
119
|
+
runner.kill('SIGTERM'); await exit; runner = undefined;
|
|
120
|
+
|
|
121
|
+
const onceRunner = spawn(process.execPath, [runnerPath, '--once', '--json'], {
|
|
122
|
+
env: { ...process.env, npm_execpath: '', NESTBOX_HOST_WORKSPACE: fixture, NESTBOX_CONTROL_HOST_URL: `http://127.0.0.1:${port}`, NESTBOX_HOST_TOKEN_FILE: tokenPath, NESTBOX_HOST_TOKEN: '' },
|
|
123
|
+
stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
|
|
124
|
+
});
|
|
125
|
+
const onceExit = await Promise.race([
|
|
126
|
+
new Promise(resolveExit => onceRunner.once('close', resolveExit)),
|
|
127
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('--once runner did not exit after an empty poll.')), 5000)),
|
|
128
|
+
]);
|
|
129
|
+
if (onceExit !== 0) throw new Error(`--once runner exited ${onceExit}.`);
|
|
130
|
+
|
|
131
|
+
const unsafeToken = join(fixture, 'unsafe-token');
|
|
132
|
+
await writeFile(unsafeToken, `${token}\n`);
|
|
133
|
+
const unsafe = spawnSync(process.execPath, [runnerPath, '--once'], {
|
|
134
|
+
env: { ...process.env, NESTBOX_HOST_WORKSPACE: fixture, NESTBOX_CONTROL_HOST_URL: `http://127.0.0.1:${port}`, NESTBOX_HOST_TOKEN_FILE: unsafeToken, NESTBOX_HOST_TOKEN: '' }, encoding: 'utf8', windowsHide: true,
|
|
94
135
|
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
runner = undefined;
|
|
98
|
-
console.log('Host runner tests passed.');
|
|
136
|
+
if (unsafe.status === 0 || !unsafe.stderr.includes('must be outside')) throw new Error('Runner accepted a token inside the workspace.');
|
|
137
|
+
console.log('Host runner HTTP tests passed.');
|
|
99
138
|
} finally {
|
|
100
139
|
runner?.kill('SIGTERM');
|
|
140
|
+
await new Promise(resolveClose => server.close(resolveClose));
|
|
101
141
|
await rm(fixture, { recursive: true, force: true });
|
|
102
|
-
await rm(
|
|
142
|
+
await rm(tokenFixture, { recursive: true, force: true });
|
|
103
143
|
}
|
package/tests/nestbox.ps1
CHANGED
|
@@ -91,9 +91,11 @@ function Invoke-Http([string]$Path, [string]$HostHeader = '', [string]$Method =
|
|
|
91
91
|
|
|
92
92
|
try {
|
|
93
93
|
New-Item -ItemType Directory -Path $TempDirectory | Out-Null
|
|
94
|
-
$
|
|
95
|
-
if (-not $env:
|
|
96
|
-
|
|
94
|
+
$configuredTokenFile = [regex]::Match([IO.File]::ReadAllText((Join-Path $Root '.env')), '(?m)^NESTBOX_HOST_TOKEN_FILE=(.+)$').Groups[1].Value.Trim()
|
|
95
|
+
if (-not $env:NESTBOX_HOST_TOKEN_FILE -and -not $configuredTokenFile) {
|
|
96
|
+
$env:NESTBOX_HOST_TOKEN_FILE = Join-Path $TempDirectory 'host-token'
|
|
97
|
+
[IO.File]::WriteAllText($env:NESTBOX_HOST_TOKEN_FILE, 'test-token-' + ('0' * 54))
|
|
98
|
+
}
|
|
97
99
|
|
|
98
100
|
if (Get-Command docker -ErrorAction SilentlyContinue) { Pass 'docker is available' } else { Fail 'docker is required'; throw 'Missing Docker' }
|
|
99
101
|
if (Get-Command curl.exe -ErrorAction SilentlyContinue) { Pass 'curl.exe is available' } else { Fail 'curl.exe is required'; throw 'Missing curl.exe' }
|
|
@@ -250,10 +252,14 @@ try {
|
|
|
250
252
|
$controlId = Get-ContainerId 'control'
|
|
251
253
|
$controlInspect = @(& docker inspect $controlId 2>$null | ConvertFrom-Json)[0]
|
|
252
254
|
if ($null -eq $controlInspect.NetworkSettings.Ports.'4088/tcp') { Pass 'Control API is not published on the host' } else { Fail 'Control API is published on the host' }
|
|
255
|
+
$runnerBinding = $controlInspect.NetworkSettings.Ports.'4089/tcp'
|
|
256
|
+
if ($null -ne $runnerBinding -and @($runnerBinding.HostIp) -notcontains '0.0.0.0' -and @($runnerBinding.HostIp) -notcontains '::') { Pass 'Host runner API is published on loopback only' } else { Fail 'Host runner API is not restricted to loopback' }
|
|
253
257
|
$openCodeId = Get-ContainerId 'opencode'
|
|
254
258
|
$openCodeInspect = @(& docker inspect $openCodeId 2>$null | ConvertFrom-Json)[0]
|
|
255
259
|
if (@($openCodeInspect.Mounts.Destination) -notcontains '/var/run/docker.sock') { Pass 'OpenCode does not receive the Docker socket' } else { Fail 'OpenCode receives the Docker socket' }
|
|
260
|
+
if (@($openCodeInspect.Mounts.Destination) -notcontains '/run/secrets/nestbox-host-token' -and @($openCodeInspect.Config.Env) -notmatch '^NESTBOX_HOST_TOKEN=') { Pass 'OpenCode cannot read the host runner token' } else { Fail 'OpenCode can read the host runner token' }
|
|
256
261
|
if (@($controlInspect.Mounts.Destination) -contains '/var/run/docker.sock') { Pass 'Control receives the Docker socket' } else { Fail 'Control does not receive the Docker socket' }
|
|
262
|
+
if (@($controlInspect.Mounts.Destination) -contains '/run/secrets/nestbox-host-token') { Pass 'Control receives the host runner token file' } else { Fail 'Control does not receive the host runner token file' }
|
|
257
263
|
|
|
258
264
|
if ($indexPresent) {
|
|
259
265
|
$result = Invoke-Compose @('exec', '-T', 'php-fpm', 'php', '-l', '/home/code/index.php') -Quiet
|
package/tests/nestbox.sh
CHANGED
|
@@ -141,11 +141,8 @@ else
|
|
|
141
141
|
fi
|
|
142
142
|
|
|
143
143
|
TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/nestbox-test.XXXXXX")"
|
|
144
|
-
|
|
145
|
-
if [[ -z "${
|
|
146
|
-
export NESTBOX_HOST_QUEUE_PATH="$TEMP_DIR/host-runner"
|
|
147
|
-
fi
|
|
148
|
-
if [[ -n "${NESTBOX_HOST_QUEUE_PATH:-}" ]]; then mkdir -p "$NESTBOX_HOST_QUEUE_PATH"; fi
|
|
144
|
+
configured_token_file="$(sed -n 's/^NESTBOX_HOST_TOKEN_FILE=//p' "$ROOT/.env" | tail -n 1)"
|
|
145
|
+
if [[ -z "${NESTBOX_HOST_TOKEN_FILE:-}" && -z "$configured_token_file" ]]; then export NESTBOX_HOST_TOKEN_FILE="$TEMP_DIR/host-token"; printf 'test-token-%054d\n' 0 >"$NESTBOX_HOST_TOKEN_FILE"; fi
|
|
149
146
|
|
|
150
147
|
[[ -f "$ROOT/.env" ]] && pass '.env exists' || fail '.env is missing'
|
|
151
148
|
[[ ! -d "$ROOT/home/.git" ]] && pass 'Page and configuration tree has no embedded Git repository' || fail 'home/.git must not be included in an installation'
|
|
@@ -350,11 +347,16 @@ status="$(http_request '/_nestbox/publish?topics=nestbox-test:public' '' 'POST'
|
|
|
350
347
|
control_id="$(container_id control)"
|
|
351
348
|
control_binding="$(docker inspect --format '{{json (index .NetworkSettings.Ports "4088/tcp")}}' "$control_id" 2>/dev/null || true)"
|
|
352
349
|
[[ -z "$control_binding" || "$control_binding" == "null" ]] && pass 'Control API is not published on the host' || fail 'Control API is published on the host'
|
|
350
|
+
runner_binding="$(docker inspect --format '{{json (index .NetworkSettings.Ports "4089/tcp")}}' "$control_id" 2>/dev/null || true)"
|
|
351
|
+
if [[ "$runner_binding" == *'"HostIp":"127.0.0.1"'* || "$runner_binding" == *'"HostIp":"::1"'* ]]; then pass 'Host runner API is published on loopback only'; else fail 'Host runner API is not restricted to loopback'; fi
|
|
353
352
|
opencode_id="$(container_id opencode)"
|
|
354
353
|
opencode_mounts="$(docker inspect --format '{{range .Mounts}}{{println .Source .Destination}}{{end}}' "$opencode_id" 2>/dev/null || true)"
|
|
355
354
|
grep -Fq '/var/run/docker.sock' <<<"$opencode_mounts" && fail 'OpenCode receives the Docker socket' || pass 'OpenCode does not receive the Docker socket'
|
|
355
|
+
opencode_env="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$opencode_id" 2>/dev/null || true)"
|
|
356
|
+
if ! grep -Fq '/run/secrets/nestbox-host-token' <<<"$opencode_mounts" && ! grep -Eq '^NESTBOX_HOST_TOKEN=' <<<"$opencode_env"; then pass 'OpenCode cannot read the host runner token'; else fail 'OpenCode can read the host runner token'; fi
|
|
356
357
|
control_mounts="$(docker inspect --format '{{range .Mounts}}{{println .Source .Destination}}{{end}}' "$control_id" 2>/dev/null || true)"
|
|
357
358
|
grep -Fq '/var/run/docker.sock' <<<"$control_mounts" && pass 'Control receives the Docker socket' || fail 'Control does not receive the Docker socket'
|
|
359
|
+
grep -Fq '/run/secrets/nestbox-host-token' <<<"$control_mounts" && pass 'Control receives the host runner token file' || fail 'Control does not receive the host runner token file'
|
|
358
360
|
|
|
359
361
|
if ((INDEX_PRESENT)); then
|
|
360
362
|
if compose exec -T php-fpm php -l /home/code/index.php >/dev/null 2>&1; then
|