@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.
@@ -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 token = 'test-token-'.padEnd(64, 'x');
13
- const jobs = [];
14
- const results = new Map();
15
- const resultAttempts = new Map();
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
- 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
- });
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 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;
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
- if (request.method === 'POST' && request.url === '/_nestbox/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;
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
- if (request.method === 'GET' && request.url.startsWith('/_nestbox/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;
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
- const match = request.url.match(/^\/_nestbox\/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;
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
- response.writeHead(404).end();
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 waitForResult(jobId, timeout = 10000) {
88
+ async function waitFor(path, timeout = 10000) {
56
89
  const deadline = Date.now() + timeout;
57
90
  while (Date.now() < deadline) {
58
- if (results.has(jobId)) return results.get(jobId);
91
+ if (existsSync(path)) return;
59
92
  await new Promise(resolveWait => setTimeout(resolveWait, 25));
60
93
  }
61
- throw new Error(`Timed out waiting for result ${jobId}`);
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 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;
91
- runner = spawn(process.execPath, [runnerPath, '--json'], {
92
- 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: '' },
93
- stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
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 || resultAttempts.get('job-retry') !== 2) throw new Error('Host result was not retried after a transient control failure.');
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
- const requestedPromise = submit({ jobId: requestId, type: 'npm.scripts.request', requestId, operation: 'add', name: 'approved', command: `node -e "process.stdout.write('approved')"` });
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
- const requested = await requestedPromise;
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 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`, NESTBOX_HOST_TOKEN_FILE: unsafeToken, NESTBOX_HOST_TOKEN: '' }, encoding: 'utf8', windowsHide: true,
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 (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.');
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 401) { Pass 'Nginx routes the authenticated host runner API' } else { Fail "Host runner gateway returned HTTP $($response.Status) instead of 401" }
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) -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' }
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" == "401" ]] && pass 'Nginx routes the authenticated host runner API' || fail "Host runner gateway returned HTTP $status instead of 401"
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
- 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'
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