@p10i/create-nestbox 0.4.0 → 0.5.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 +0 -4
- package/CHANGELOG.md +7 -0
- package/INSTALL.md +2 -2
- package/README.md +1 -1
- package/bin/create-nestbox.js +2 -25
- package/docker/control/Dockerfile +3 -2
- package/docker/control/host_bridge.py +205 -0
- package/docker/control/server.py +113 -54
- package/docker-compose.yaml +0 -9
- package/home/configs/nginx/nginx.conf +0 -17
- package/home/configs/opencode/control-mcp.js +1 -1
- package/home/configs/opencode/instructions.md +1 -2
- package/host-runner.mjs +134 -112
- package/package.json +1 -1
- package/scripts/check-package.js +1 -0
- package/tests/control.mjs +74 -13
- package/tests/creator.mjs +3 -3
- package/tests/host-runner.mjs +137 -65
- package/tests/nestbox.ps1 +4 -9
- package/tests/nestbox.sh +6 -7
package/tests/host-runner.mjs
CHANGED
|
@@ -1,64 +1,111 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
-
import { createServer } from 'node:http';
|
|
3
2
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { dirname, join, resolve } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
|
|
8
8
|
const fixture = await mkdtemp(join(tmpdir(), 'nestbox-host-runner-'));
|
|
9
|
-
const tokenFixture = await mkdtemp(join(tmpdir(), 'nestbox-host-token-'));
|
|
10
9
|
const testDirectory = dirname(fileURLToPath(import.meta.url));
|
|
11
10
|
const runnerPath = resolve(testDirectory, '..', 'host-runner.mjs');
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
11
|
+
const fakeBridge = join(fixture, 'fake-docker.mjs');
|
|
12
|
+
const jobPath = join(fixture, 'bridge-job.json');
|
|
13
|
+
const resultPath = join(fixture, 'bridge-result.json');
|
|
14
|
+
const invocationPath = join(fixture, 'invocations.jsonl');
|
|
15
|
+
const ackPath = join(fixture, 'acked-marker');
|
|
16
16
|
let runner;
|
|
17
17
|
let runnerStdout = '';
|
|
18
|
-
let heartbeatConflicts = 1;
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
19
|
+
const fakeSource = String.raw`
|
|
20
|
+
import { appendFileSync, existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
21
|
+
appendFileSync(process.env.FAKE_INVOCATIONS, JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd() }) + '\n');
|
|
22
|
+
const messages = [];
|
|
23
|
+
let waiter, ended = false, input = '';
|
|
24
|
+
process.stdin.setEncoding('utf8');
|
|
25
|
+
process.stdin.on('data', chunk => {
|
|
26
|
+
input += chunk;
|
|
27
|
+
let newline;
|
|
28
|
+
while ((newline = input.indexOf('\n')) >= 0) {
|
|
29
|
+
const line = input.slice(0, newline); input = input.slice(newline + 1);
|
|
30
|
+
if (line) messages.push(JSON.parse(line));
|
|
31
|
+
if (waiter) { const resolve = waiter; waiter = null; resolve(); }
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
process.stdin.on('end', () => { ended = true; if (waiter) waiter(); });
|
|
35
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
36
|
+
async function receive() {
|
|
37
|
+
while (!messages.length && !ended) await new Promise(resolve => waiter = resolve);
|
|
38
|
+
if (!messages.length) return null;
|
|
39
|
+
return messages.shift();
|
|
27
40
|
}
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
41
|
+
function send(value) { process.stdout.write(JSON.stringify({ protocolVersion: 1, ...value }) + '\n'); }
|
|
42
|
+
const hello = await receive();
|
|
43
|
+
if (hello?.type !== 'hello') process.exit(2);
|
|
44
|
+
if (hello.retainedResult) {
|
|
45
|
+
if (hello.retainedResult.jobId === 'job-rejected') {
|
|
46
|
+
writeFileSync(process.env.FAKE_REJECTED, 'terminal');
|
|
47
|
+
send({ type: 'result.rejected', jobId: hello.retainedResult.jobId, error: 'conflicting host result replay' });
|
|
48
|
+
} else {
|
|
49
|
+
if (existsSync(process.env.FAKE_JOB)) unlinkSync(process.env.FAKE_JOB);
|
|
50
|
+
writeFileSync(process.env.FAKE_RESULT, JSON.stringify(hello.retainedResult));
|
|
51
|
+
send({ type: 'result.ack', jobId: hello.retainedResult.jobId, duplicate: true });
|
|
52
|
+
await sleep(25);
|
|
53
|
+
writeFileSync(process.env.FAKE_RECONCILED, hello.retainedResult.jobId);
|
|
54
|
+
writeFileSync(process.env.FAKE_ACKED + '.' + hello.retainedResult.jobId, hello.retainedResult.jobId);
|
|
32
55
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
56
|
+
}
|
|
57
|
+
send({ type: 'ready' });
|
|
58
|
+
while (!ended) {
|
|
59
|
+
if (!existsSync(process.env.FAKE_JOB)) { send({ type: 'idle' }); await sleep(50); continue; }
|
|
60
|
+
const job = JSON.parse(readFileSync(process.env.FAKE_JOB, 'utf8'));
|
|
61
|
+
send({ type: 'job', job });
|
|
62
|
+
if (job.jobId === 'job-inflight' && !existsSync(process.env.FAKE_INFLIGHT)) {
|
|
63
|
+
writeFileSync(process.env.FAKE_INFLIGHT, 'disconnected');
|
|
64
|
+
process.exit(76);
|
|
36
65
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
66
|
+
const message = await receive();
|
|
67
|
+
if (!message) break;
|
|
68
|
+
if (message.type !== 'result' || message.result?.jobId !== job.jobId) process.exit(3);
|
|
69
|
+
if (job.jobId === 'job-retry' && !existsSync(process.env.FAKE_RETRY)) {
|
|
70
|
+
writeFileSync(process.env.FAKE_RETRY, 'attempted');
|
|
71
|
+
writeFileSync(process.env.FAKE_RESULT, JSON.stringify(message.result));
|
|
72
|
+
unlinkSync(process.env.FAKE_JOB);
|
|
73
|
+
process.exit(75);
|
|
41
74
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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;
|
|
75
|
+
if (job.jobId === 'job-rejected' && !existsSync(process.env.FAKE_REJECT)) {
|
|
76
|
+
writeFileSync(process.env.FAKE_REJECT, 'accepted-without-ack');
|
|
77
|
+
unlinkSync(process.env.FAKE_JOB);
|
|
78
|
+
process.exit(77);
|
|
51
79
|
}
|
|
52
|
-
|
|
53
|
-
|
|
80
|
+
unlinkSync(process.env.FAKE_JOB);
|
|
81
|
+
writeFileSync(process.env.FAKE_RESULT, JSON.stringify(message.result));
|
|
82
|
+
send({ type: 'result.ack', jobId: job.jobId });
|
|
83
|
+
await sleep(25);
|
|
84
|
+
writeFileSync(process.env.FAKE_ACKED + '.' + job.jobId, job.jobId);
|
|
85
|
+
}
|
|
86
|
+
`;
|
|
54
87
|
|
|
55
|
-
async function
|
|
88
|
+
async function waitFor(path, timeout = 10000) {
|
|
56
89
|
const deadline = Date.now() + timeout;
|
|
57
90
|
while (Date.now() < deadline) {
|
|
58
|
-
if (
|
|
91
|
+
if (existsSync(path)) return;
|
|
59
92
|
await new Promise(resolveWait => setTimeout(resolveWait, 25));
|
|
60
93
|
}
|
|
61
|
-
throw new Error(`Timed out waiting for
|
|
94
|
+
throw new Error(`Timed out waiting for ${path}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function submit(job) {
|
|
98
|
+
const jobAckPath = `${ackPath}.${job.jobId}`;
|
|
99
|
+
await rm(resultPath, { force: true });
|
|
100
|
+
await rm(jobAckPath, { force: true });
|
|
101
|
+
await writeFile(jobPath, JSON.stringify({ schemaVersion: 1, ...job }));
|
|
102
|
+
await waitFor(resultPath);
|
|
103
|
+
const result = JSON.parse(await readFile(resultPath, 'utf8'));
|
|
104
|
+
if (job.jobId !== 'job-retry') {
|
|
105
|
+
await waitFor(jobAckPath);
|
|
106
|
+
if (await readFile(jobAckPath, 'utf8') !== job.jobId) throw new Error(`Unexpected acknowledgement for ${job.jobId}.`);
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
62
109
|
}
|
|
63
110
|
|
|
64
111
|
async function waitForEvent(type, requestId, timeout = 10000) {
|
|
@@ -66,32 +113,41 @@ async function waitForEvent(type, requestId, timeout = 10000) {
|
|
|
66
113
|
while (Date.now() < deadline) {
|
|
67
114
|
for (const line of runnerStdout.split(/\r?\n/).filter(Boolean)) {
|
|
68
115
|
const event = JSON.parse(line);
|
|
69
|
-
if (event.type === type && (!requestId || event.requestId === requestId)) return event;
|
|
116
|
+
if (event.type === type && (!requestId || event.requestId === requestId || event.jobId === requestId)) return event;
|
|
70
117
|
}
|
|
71
118
|
await new Promise(resolveWait => setTimeout(resolveWait, 25));
|
|
72
119
|
}
|
|
73
120
|
throw new Error(`Timed out waiting for event ${type}`);
|
|
74
121
|
}
|
|
75
122
|
|
|
76
|
-
function submit(job) { jobs.push({ schemaVersion: 1, ...job }); return waitForResult(job.jobId); }
|
|
77
|
-
|
|
78
123
|
try {
|
|
124
|
+
await writeFile(fakeBridge, fakeSource);
|
|
79
125
|
await writeFile(join(fixture, 'package.json'), JSON.stringify({
|
|
80
126
|
private: true,
|
|
81
127
|
scripts: {
|
|
82
128
|
host: 'node host-runner.mjs',
|
|
83
129
|
ok: `node -e "process.stdout.write('host-ok')"`,
|
|
84
130
|
fail: `node -e "process.stderr.write('host-failed'); process.exit(7)"`,
|
|
131
|
+
delayed: `node -e "setTimeout(() => require('fs').appendFileSync('executions', 'x'), 300)"`,
|
|
85
132
|
},
|
|
86
133
|
}));
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
134
|
+
const environment = {
|
|
135
|
+
...process.env,
|
|
136
|
+
npm_execpath: '',
|
|
137
|
+
NESTBOX_HOST_WORKSPACE: fixture,
|
|
138
|
+
NESTBOX_HOST_DOCKER: process.execPath,
|
|
139
|
+
NESTBOX_HOST_DOCKER_ARGS: JSON.stringify([fakeBridge]),
|
|
140
|
+
FAKE_JOB: jobPath,
|
|
141
|
+
FAKE_RESULT: resultPath,
|
|
142
|
+
FAKE_RETRY: join(fixture, 'retry-marker'),
|
|
143
|
+
FAKE_RECONCILED: join(fixture, 'reconciled-marker'),
|
|
144
|
+
FAKE_REJECT: join(fixture, 'reject-marker'),
|
|
145
|
+
FAKE_REJECTED: join(fixture, 'rejected-marker'),
|
|
146
|
+
FAKE_ACKED: ackPath,
|
|
147
|
+
FAKE_INFLIGHT: join(fixture, 'inflight-marker'),
|
|
148
|
+
FAKE_INVOCATIONS: invocationPath,
|
|
149
|
+
};
|
|
150
|
+
runner = spawn(process.execPath, [runnerPath, '--json'], { env: environment, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
95
151
|
let runnerStderr = '';
|
|
96
152
|
runner.stdout.on('data', chunk => runnerStdout += chunk); runner.stderr.on('data', chunk => runnerStderr += chunk);
|
|
97
153
|
await waitForEvent('runner.ready');
|
|
@@ -101,12 +157,27 @@ try {
|
|
|
101
157
|
if (success.exitCode !== 0 || !success.stdout.includes('host-ok') || !success.hostPlatform) throw new Error('Successful host result was not preserved.');
|
|
102
158
|
if (failure.exitCode !== 7 || !failure.stderr.includes('host-failed')) throw new Error('Failed host result was not preserved.');
|
|
103
159
|
const retried = await submit({ jobId: 'job-retry', type: 'npm.run', script: 'ok', args: [] });
|
|
104
|
-
if (retried.exitCode !== 0
|
|
160
|
+
if (retried.exitCode !== 0) throw new Error('Pending result was not replayed after bridge disconnect.');
|
|
161
|
+
await waitFor(environment.FAKE_RECONCILED);
|
|
162
|
+
if (await readFile(environment.FAKE_RECONCILED, 'utf8') !== 'job-retry') throw new Error('Ack-lost result was not reconciled before polling.');
|
|
163
|
+
const inFlight = await submit({ jobId: 'job-inflight', type: 'npm.run', script: 'delayed', args: [] });
|
|
164
|
+
const executions = await readFile(join(fixture, 'executions'), 'utf8');
|
|
165
|
+
if (inFlight.exitCode !== 0 || executions !== 'x') throw new Error(`In-flight host job was duplicated across bridge reconnect: exit=${inFlight.exitCode} executions=${executions}.`);
|
|
166
|
+
|
|
167
|
+
await writeFile(jobPath, JSON.stringify({ schemaVersion: 1, jobId: 'job-rejected', type: 'npm.run', script: 'ok', args: [] }));
|
|
168
|
+
try { await waitFor(environment.FAKE_REJECTED); }
|
|
169
|
+
catch (error) { throw new Error(`${error.message}; accepted=${existsSync(environment.FAKE_REJECT)} job=${existsSync(jobPath)} result=${existsSync(resultPath) ? await readFile(resultPath, 'utf8') : 'none'}\n${runnerStdout}\n${runnerStderr}`); }
|
|
170
|
+
const rejected = await waitForEvent('runner.error', 'job-rejected');
|
|
171
|
+
if (!rejected.error.includes('conflicting host result replay')) throw new Error('Terminal result rejection was not reported.');
|
|
172
|
+
const afterRejected = await submit({ jobId: 'job-after-rejected', type: 'npm.run', script: 'ok', args: [] });
|
|
173
|
+
if (afterRejected.exitCode !== 0) throw new Error('Terminal result rejection was retried instead of cleared.');
|
|
105
174
|
|
|
106
175
|
const requestId = 'npm-script-request-test-add';
|
|
107
|
-
|
|
176
|
+
await rm(resultPath, { force: true });
|
|
177
|
+
await writeFile(jobPath, JSON.stringify({ schemaVersion: 1, jobId: requestId, type: 'npm.scripts.request', requestId, operation: 'add', name: 'approved', command: `node -e "process.stdout.write('approved')"` }));
|
|
108
178
|
const event = await waitForEvent('npm.scripts.confirmation_required', requestId);
|
|
109
|
-
|
|
179
|
+
await waitFor(resultPath);
|
|
180
|
+
const requested = JSON.parse(await readFile(resultPath, 'utf8'));
|
|
110
181
|
if (requested.status !== 'confirmation_required' || Object.hasOwn(requested, 'code')) throw new Error('Script request exposed its confirmation code.');
|
|
111
182
|
const confirmed = await submit({ jobId: 'confirm-add', type: 'npm.scripts.confirm', requestId, code: event.code });
|
|
112
183
|
const changed = JSON.parse(await readFile(join(fixture, 'package.json'), 'utf8'));
|
|
@@ -115,29 +186,30 @@ try {
|
|
|
115
186
|
const protectedResult = await submit({ jobId: 'protected-host', type: 'npm.scripts.request', requestId: 'protected-host', operation: 'delete', name: 'host' });
|
|
116
187
|
if (protectedResult.exitCode !== 1 || !protectedResult.stderr.includes('cannot be changed')) throw new Error('Core host script was not protected.');
|
|
117
188
|
|
|
189
|
+
const invocations = (await readFile(invocationPath, 'utf8')).trim().split(/\r?\n/).map(JSON.parse);
|
|
190
|
+
if (invocations.length < 2 || invocations.some(value => value.cwd !== dirname(runnerPath))) throw new Error('Docker CLI did not run from the installation directory.');
|
|
191
|
+
const expected = ['compose', 'exec', '-T', 'control', '/usr/local/bin/nestbox-host-bridge'];
|
|
192
|
+
if (!invocations.every(value => expected.every((part, index) => value.args[index] === part))) throw new Error('Docker Compose bridge arguments were incorrect.');
|
|
193
|
+
|
|
118
194
|
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
195
|
runner.kill('SIGTERM'); await exit; runner = undefined;
|
|
120
196
|
|
|
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`, NESTBOX_HOST_TOKEN_FILE: tokenPath, NESTBOX_HOST_TOKEN: '' },
|
|
123
|
-
stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
|
|
124
|
-
});
|
|
197
|
+
const onceRunner = spawn(process.execPath, [runnerPath, '--once', '--json'], { env: environment, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
125
198
|
const onceExit = await Promise.race([
|
|
126
199
|
new Promise(resolveExit => onceRunner.once('close', resolveExit)),
|
|
127
200
|
new Promise((_, reject) => setTimeout(() => reject(new Error('--once runner did not exit after an empty poll.')), 5000)),
|
|
128
201
|
]);
|
|
129
202
|
if (onceExit !== 0) throw new Error(`--once runner exited ${onceExit}.`);
|
|
130
203
|
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
204
|
+
const stale = spawnSync(process.execPath, [runnerPath, '--once'], {
|
|
205
|
+
env: { ...environment, NESTBOX_HOST_DOCKER_ARGS: JSON.stringify(['-e', `process.stderr.write('nestbox-host-bridge: not found\\n'); process.exit(127)`]) },
|
|
206
|
+
encoding: 'utf8',
|
|
207
|
+
windowsHide: true,
|
|
208
|
+
timeout: 5000,
|
|
135
209
|
});
|
|
136
|
-
if (
|
|
137
|
-
console.log('Host runner
|
|
210
|
+
if (stale.status !== 1 || !stale.stderr.includes('docker compose build control')) throw new Error('Stale control image error was not actionable or terminal.');
|
|
211
|
+
console.log('Host runner stdio transport tests passed.');
|
|
138
212
|
} finally {
|
|
139
213
|
runner?.kill('SIGTERM');
|
|
140
|
-
await new Promise(resolveClose => server.close(resolveClose));
|
|
141
214
|
await rm(fixture, { recursive: true, force: true });
|
|
142
|
-
await rm(tokenFixture, { recursive: true, force: true });
|
|
143
215
|
}
|
package/tests/nestbox.ps1
CHANGED
|
@@ -91,11 +91,6 @@ 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
|
-
$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
|
-
}
|
|
99
94
|
|
|
100
95
|
if (Get-Command docker -ErrorAction SilentlyContinue) { Pass 'docker is available' } else { Fail 'docker is required'; throw 'Missing Docker' }
|
|
101
96
|
if (Get-Command curl.exe -ErrorAction SilentlyContinue) { Pass 'curl.exe is available' } else { Fail 'curl.exe is required'; throw 'Missing curl.exe' }
|
|
@@ -126,7 +121,7 @@ try {
|
|
|
126
121
|
$openCodeConfig = [IO.File]::ReadAllText((Join-Path $Root 'home\configs\opencode\opencode.json'))
|
|
127
122
|
if ($openCodeConfig.Contains('"instance.md"')) { Pass 'OpenCode loads instance policy directly' } else { Fail 'OpenCode does not load instance policy' }
|
|
128
123
|
if ($openCodeConfig.Contains('"control"')) { Pass 'OpenCode registers the control MCP' } else { Fail 'OpenCode does not register the control MCP' }
|
|
129
|
-
if ((Test-Path -LiteralPath (Join-Path $Root 'docker\control\server.py')) -and (Test-Path -LiteralPath (Join-Path $Root 'host-runner.mjs'))) { Pass 'Control and host runner sources exist' } else { Fail 'Control or host runner source is missing' }
|
|
124
|
+
if ((Test-Path -LiteralPath (Join-Path $Root 'docker\control\server.py')) -and (Test-Path -LiteralPath (Join-Path $Root 'docker\control\host_bridge.py')) -and (Test-Path -LiteralPath (Join-Path $Root 'host-runner.mjs'))) { Pass 'Control and host runner sources exist' } else { Fail 'Control or host runner source is missing' }
|
|
130
125
|
|
|
131
126
|
$result = Invoke-Compose @('config', '--quiet') -Quiet
|
|
132
127
|
if ($result.Code -eq 0) { Pass 'Compose configuration is valid' } else { Fail 'Compose configuration is invalid' }
|
|
@@ -254,14 +249,14 @@ try {
|
|
|
254
249
|
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
250
|
$runnerBinding = $controlInspect.NetworkSettings.Ports.'4089/tcp'
|
|
256
251
|
if ($null -eq $runnerBinding) { Pass 'Host runner API is not published separately' } else { Fail 'Host runner API is published separately' }
|
|
252
|
+
if ($null -eq $controlInspect.Config.ExposedPorts.'4089/tcp') { Pass 'Host runner API is not exposed by the control image' } else { Fail 'Host runner API is exposed by the control image' }
|
|
257
253
|
$response = Invoke-Http '/_nestbox/host/health'
|
|
258
|
-
if ($response.Status -eq
|
|
254
|
+
if ($response.Status -eq 404) { Pass 'Public host runner path is unavailable' } else { Fail "Host runner gateway returned HTTP $($response.Status) instead of 404" }
|
|
259
255
|
$openCodeId = Get-ContainerId 'opencode'
|
|
260
256
|
$openCodeInspect = @(& docker inspect $openCodeId 2>$null | ConvertFrom-Json)[0]
|
|
261
257
|
if (@($openCodeInspect.Mounts.Destination) -notcontains '/var/run/docker.sock') { Pass 'OpenCode does not receive the Docker socket' } else { Fail 'OpenCode receives the Docker socket' }
|
|
262
|
-
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' }
|
|
263
258
|
if (@($controlInspect.Mounts.Destination) -contains '/var/run/docker.sock') { Pass 'Control receives the Docker socket' } else { Fail 'Control does not receive the Docker socket' }
|
|
264
|
-
if (@($controlInspect.Mounts.Destination) -
|
|
259
|
+
if (@($controlInspect.Mounts.Destination) -notcontains '/run/secrets/nestbox-host-token' -and @($controlInspect.Config.Env) -notmatch '^NESTBOX_HOST_TOKEN') { Pass 'Control has no host token mount or environment' } else { Fail 'Control still has host token configuration' }
|
|
265
260
|
|
|
266
261
|
if ($indexPresent) {
|
|
267
262
|
$result = Invoke-Compose @('exec', '-T', 'php-fpm', 'php', '-l', '/home/code/index.php') -Quiet
|
package/tests/nestbox.sh
CHANGED
|
@@ -141,8 +141,6 @@ else
|
|
|
141
141
|
fi
|
|
142
142
|
|
|
143
143
|
TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/nestbox-test.XXXXXX")"
|
|
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
|
|
146
144
|
|
|
147
145
|
[[ -f "$ROOT/.env" ]] && pass '.env exists' || fail '.env is missing'
|
|
148
146
|
[[ ! -d "$ROOT/home/.git" ]] && pass 'Page and configuration tree has no embedded Git repository' || fail 'home/.git must not be included in an installation'
|
|
@@ -163,7 +161,7 @@ grep -Fq 'Commit policy:' "$ROOT/home/configs/opencode/instance.md" && pass 'Ope
|
|
|
163
161
|
grep -Fq 'Communication language:' "$ROOT/home/configs/opencode/instance.md" && pass 'OpenCode instance policy records communication language' || fail 'OpenCode instance policy omits communication language'
|
|
164
162
|
grep -Fq '"instance.md"' "$ROOT/home/configs/opencode/opencode.json" && pass 'OpenCode loads instance policy directly' || fail 'OpenCode does not load instance policy'
|
|
165
163
|
grep -Fq '"control"' "$ROOT/home/configs/opencode/opencode.json" && pass 'OpenCode registers the control MCP' || fail 'OpenCode does not register the control MCP'
|
|
166
|
-
[[ -f "$ROOT/docker/control/server.py" && -f "$ROOT/host-runner.mjs" ]] && pass 'Control and host runner sources exist' || fail 'Control or host runner source is missing'
|
|
164
|
+
[[ -f "$ROOT/docker/control/server.py" && -f "$ROOT/docker/control/host_bridge.py" && -f "$ROOT/host-runner.mjs" ]] && pass 'Control and host runner sources exist' || fail 'Control or host runner source is missing'
|
|
167
165
|
|
|
168
166
|
if compose config --quiet; then
|
|
169
167
|
pass 'Compose configuration is valid'
|
|
@@ -349,16 +347,17 @@ control_binding="$(docker inspect --format '{{json (index .NetworkSettings.Ports
|
|
|
349
347
|
[[ -z "$control_binding" || "$control_binding" == "null" ]] && pass 'Control API is not published on the host' || fail 'Control API is published on the host'
|
|
350
348
|
runner_binding="$(docker inspect --format '{{json (index .NetworkSettings.Ports "4089/tcp")}}' "$control_id" 2>/dev/null || true)"
|
|
351
349
|
[[ -z "$runner_binding" || "$runner_binding" == "null" ]] && pass 'Host runner API is not published separately' || fail 'Host runner API is published separately'
|
|
350
|
+
runner_exposed="$(docker inspect --format '{{json (index .Config.ExposedPorts "4089/tcp")}}' "$control_id" 2>/dev/null || true)"
|
|
351
|
+
[[ -z "$runner_exposed" || "$runner_exposed" == "null" ]] && pass 'Host runner API is not exposed by the control image' || fail 'Host runner API is exposed by the control image'
|
|
352
352
|
status="$(http_request '/_nestbox/host/health' 2>/dev/null || printf '000')"
|
|
353
|
-
[[ "$status" == "
|
|
353
|
+
[[ "$status" == "404" ]] && pass 'Public host runner path is unavailable' || fail "Host runner gateway returned HTTP $status instead of 404"
|
|
354
354
|
opencode_id="$(container_id opencode)"
|
|
355
355
|
opencode_mounts="$(docker inspect --format '{{range .Mounts}}{{println .Source .Destination}}{{end}}' "$opencode_id" 2>/dev/null || true)"
|
|
356
356
|
grep -Fq '/var/run/docker.sock' <<<"$opencode_mounts" && fail 'OpenCode receives the Docker socket' || pass 'OpenCode does not receive the Docker socket'
|
|
357
|
-
opencode_env="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$opencode_id" 2>/dev/null || true)"
|
|
358
|
-
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
|
|
359
357
|
control_mounts="$(docker inspect --format '{{range .Mounts}}{{println .Source .Destination}}{{end}}' "$control_id" 2>/dev/null || true)"
|
|
360
358
|
grep -Fq '/var/run/docker.sock' <<<"$control_mounts" && pass 'Control receives the Docker socket' || fail 'Control does not receive the Docker socket'
|
|
361
|
-
|
|
359
|
+
control_env="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$control_id" 2>/dev/null || true)"
|
|
360
|
+
if ! grep -Fq '/run/secrets/nestbox-host-token' <<<"$control_mounts" && ! grep -Eq '^NESTBOX_HOST_TOKEN' <<<"$control_env"; then pass 'Control has no host token mount or environment'; else fail 'Control still has host token configuration'; fi
|
|
362
361
|
|
|
363
362
|
if ((INDEX_PRESENT)); then
|
|
364
363
|
if compose exec -T php-fpm php -l /home/code/index.php >/dev/null 2>&1; then
|