@p10i/create-nestbox 0.1.2 → 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/.dockerignore +3 -0
- package/.env.example +13 -0
- package/CHANGELOG.md +17 -0
- package/INSTALL.md +25 -5
- package/README.md +10 -10
- package/bin/create-nestbox.js +89 -2
- package/docker/control/Dockerfile +12 -0
- package/docker/control/server.py +660 -0
- package/docker/opencode/Dockerfile +1 -1
- package/docker-compose.yaml +70 -0
- package/home/code/__includes/functions.php +68 -0
- package/home/configs/opencode/control-mcp.js +102 -0
- package/home/configs/opencode/instructions.md +20 -4
- package/home/configs/opencode/opencode.json +11 -0
- package/home/configs/rollup/package-lock.json +10 -13
- package/host-runner.mjs +288 -0
- package/package.host.json +8 -0
- package/package.json +13 -2
- package/scripts/check-package.js +18 -1
- package/template.gitignore +1 -0
- package/tests/control.mjs +70 -0
- package/tests/creator.mjs +43 -0
- package/tests/host-runner.mjs +143 -0
- package/tests/nestbox.ps1 +25 -6
- package/tests/nestbox.sh +24 -5
- package/home/code/scripts-bundle.css +0 -13295
- package/home/code/scripts-bundle.js +0 -6277
package/docker-compose.yaml
CHANGED
|
@@ -37,6 +37,9 @@ services:
|
|
|
37
37
|
# Executes super-document HTTP and CLI directions. Trusted pages may update
|
|
38
38
|
# page state and intentionally operate on the mounted workspace.
|
|
39
39
|
php-fpm:
|
|
40
|
+
depends_on:
|
|
41
|
+
control:
|
|
42
|
+
condition: service_healthy
|
|
40
43
|
build:
|
|
41
44
|
context: .
|
|
42
45
|
dockerfile: ./docker/php-fpm/Dockerfile
|
|
@@ -56,6 +59,12 @@ services:
|
|
|
56
59
|
target: /workspace
|
|
57
60
|
bind:
|
|
58
61
|
create_host_path: false
|
|
62
|
+
- type: bind
|
|
63
|
+
source: ${WORKSPACE_PATH:-.}/package.json
|
|
64
|
+
target: /workspace/package.json
|
|
65
|
+
read_only: true
|
|
66
|
+
bind:
|
|
67
|
+
create_host_path: false
|
|
59
68
|
environment:
|
|
60
69
|
# Event publication and OpenCode automation stay inside Compose.
|
|
61
70
|
- NESTBOX_BUS_PUBLISH_URL=http://nginx:8080/_nestbox/publish
|
|
@@ -64,6 +73,11 @@ services:
|
|
|
64
73
|
- NESTBOX_OPENCODE_PUBLIC_URL=${NESTBOX_OPENCODE_PUBLIC_URL:-}
|
|
65
74
|
- OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode}
|
|
66
75
|
- OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:-}
|
|
76
|
+
- NESTBOX_CONTROL_URL=http://control:4088
|
|
77
|
+
labels:
|
|
78
|
+
- nestbox.exec.allow-target=true
|
|
79
|
+
- nestbox.exec.allow-source=true
|
|
80
|
+
- nestbox.exec.targets=php-fpm
|
|
67
81
|
restart: unless-stopped
|
|
68
82
|
|
|
69
83
|
# Watches shared browser source and refreshes tracked bundle baselines.
|
|
@@ -88,11 +102,56 @@ services:
|
|
|
88
102
|
create_host_path: false
|
|
89
103
|
# Keep dependencies out of the host page tree.
|
|
90
104
|
- rollup-node-modules:/sources/rollup/node_modules
|
|
105
|
+
labels:
|
|
106
|
+
- nestbox.exec.allow-target=true
|
|
107
|
+
restart: unless-stopped
|
|
108
|
+
|
|
109
|
+
# Private, label-gated bridge for container exec and native host npm jobs.
|
|
110
|
+
# This is the only Nestbox service with access to the host Docker socket.
|
|
111
|
+
control:
|
|
112
|
+
build:
|
|
113
|
+
context: .
|
|
114
|
+
dockerfile: ./docker/control/Dockerfile
|
|
115
|
+
expose:
|
|
116
|
+
- "4088"
|
|
117
|
+
ports:
|
|
118
|
+
# Authenticated native runner transport; never bind beyond loopback.
|
|
119
|
+
- "127.0.0.1:${NESTBOX_CONTROL_HOST_PORT:-4089}:4089"
|
|
120
|
+
volumes:
|
|
121
|
+
- ${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock
|
|
122
|
+
- type: bind
|
|
123
|
+
source: ${WORKSPACE_PATH:-.}
|
|
124
|
+
target: /workspace
|
|
125
|
+
read_only: true
|
|
126
|
+
bind:
|
|
127
|
+
create_host_path: false
|
|
128
|
+
- type: bind
|
|
129
|
+
source: ${NESTBOX_HOST_TOKEN_FILE:?NESTBOX_HOST_TOKEN_FILE must be configured}
|
|
130
|
+
target: /run/secrets/nestbox-host-token
|
|
131
|
+
read_only: true
|
|
132
|
+
bind:
|
|
133
|
+
create_host_path: false
|
|
134
|
+
environment:
|
|
135
|
+
- COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}
|
|
136
|
+
- NESTBOX_CONTROL_SERVICE=control
|
|
137
|
+
- NESTBOX_CONTROL_HOST_PORT=4089
|
|
138
|
+
- NESTBOX_HOST_TOKEN_FILE=/run/secrets/nestbox-host-token
|
|
139
|
+
- NESTBOX_CONTROL_LOG_OUTPUT=${NESTBOX_CONTROL_LOG_OUTPUT:-text}
|
|
140
|
+
- NESTBOX_CONTROL_LOG_MAX_BYTES=${NESTBOX_CONTROL_LOG_MAX_BYTES:-262144}
|
|
141
|
+
healthcheck:
|
|
142
|
+
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:4088/health"]
|
|
143
|
+
interval: 10s
|
|
144
|
+
timeout: 3s
|
|
145
|
+
retries: 5
|
|
146
|
+
start_period: 5s
|
|
91
147
|
restart: unless-stopped
|
|
92
148
|
|
|
93
149
|
# Internal implementation agent. It can edit Nestbox and the workspace but
|
|
94
150
|
# deliberately receives no Docker socket or direct host control.
|
|
95
151
|
opencode:
|
|
152
|
+
depends_on:
|
|
153
|
+
control:
|
|
154
|
+
condition: service_healthy
|
|
96
155
|
build:
|
|
97
156
|
context: .
|
|
98
157
|
dockerfile: ./docker/opencode/Dockerfile
|
|
@@ -125,11 +184,22 @@ services:
|
|
|
125
184
|
target: /workspace
|
|
126
185
|
bind:
|
|
127
186
|
create_host_path: false
|
|
187
|
+
- type: bind
|
|
188
|
+
source: ${WORKSPACE_PATH:-.}/package.json
|
|
189
|
+
target: /workspace/package.json
|
|
190
|
+
read_only: true
|
|
191
|
+
bind:
|
|
192
|
+
create_host_path: false
|
|
128
193
|
# The entrypoint fixes this volume's ownership before dropping privileges.
|
|
129
194
|
- opencode-data:/home/nestbox/.local/share/opencode
|
|
130
195
|
working_dir: /workspace
|
|
131
196
|
environment:
|
|
132
197
|
- NESTBOX_BUS_PUBLISH_URL=http://nginx:8080/_nestbox/publish
|
|
198
|
+
- NESTBOX_CONTROL_URL=http://control:4088
|
|
199
|
+
labels:
|
|
200
|
+
- nestbox.exec.allow-source=true
|
|
201
|
+
- nestbox.exec.targets=php-fpm,rollup
|
|
202
|
+
- nestbox.npm.allow=true
|
|
133
203
|
restart: unless-stopped
|
|
134
204
|
|
|
135
205
|
volumes:
|
|
@@ -192,6 +192,74 @@ function nestbox_opencode_request(string $method, string $path, array $body, str
|
|
|
192
192
|
return json_decode($response, true, flags: JSON_THROW_ON_ERROR);
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
/** Send a bounded JSON request to the private, label-gated control service. */
|
|
196
|
+
function nestbox_control_request(string $method, string $path, array $body = []): mixed
|
|
197
|
+
{
|
|
198
|
+
$baseUrl = rtrim((string) getenv('NESTBOX_CONTROL_URL'), '/');
|
|
199
|
+
if ($baseUrl === '') {
|
|
200
|
+
throw new RuntimeException('NESTBOX_CONTROL_URL is not configured.');
|
|
201
|
+
}
|
|
202
|
+
if (!str_starts_with($path, '/') || preg_match('/[\0\r\n]/', $path)) {
|
|
203
|
+
throw new InvalidArgumentException('Invalid control request path.');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
$payload = json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
|
207
|
+
$context = stream_context_create([
|
|
208
|
+
'http' => [
|
|
209
|
+
'method' => strtoupper($method),
|
|
210
|
+
'header' => implode("\r\n", [
|
|
211
|
+
'Accept: application/json',
|
|
212
|
+
'Content-Type: application/json',
|
|
213
|
+
'Content-Length: ' . strlen($payload),
|
|
214
|
+
]),
|
|
215
|
+
'content' => $payload,
|
|
216
|
+
'ignore_errors' => true,
|
|
217
|
+
'timeout' => 30,
|
|
218
|
+
],
|
|
219
|
+
]);
|
|
220
|
+
$response = @file_get_contents($baseUrl . $path, false, $context, 0, 1024 * 1024);
|
|
221
|
+
$statusLine = $http_response_header[0] ?? '';
|
|
222
|
+
preg_match('#^HTTP/\S+\s+(\d{3})#', $statusLine, $matches);
|
|
223
|
+
$status = isset($matches[1]) ? (int) $matches[1] : 0;
|
|
224
|
+
if ($response === false || $status < 200 || $status >= 300) {
|
|
225
|
+
throw new RuntimeException("Control request failed with HTTP status $status.");
|
|
226
|
+
}
|
|
227
|
+
return $response === '' ? null : json_decode($response, true, flags: JSON_THROW_ON_ERROR);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Execute one explicit argument array in an allowed Compose service. */
|
|
231
|
+
function nestbox_control_exec(
|
|
232
|
+
string $service,
|
|
233
|
+
string $workdir,
|
|
234
|
+
array $command,
|
|
235
|
+
bool $detach = false,
|
|
236
|
+
string $user = '',
|
|
237
|
+
): array {
|
|
238
|
+
if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/', $service)) {
|
|
239
|
+
throw new InvalidArgumentException('Invalid control target service.');
|
|
240
|
+
}
|
|
241
|
+
if (!str_starts_with($workdir, '/') || str_contains($workdir, "\0")) {
|
|
242
|
+
throw new InvalidArgumentException('Control workdir must be an absolute container path.');
|
|
243
|
+
}
|
|
244
|
+
if ($command === [] || count($command) > 256) {
|
|
245
|
+
throw new InvalidArgumentException('Control command must be a non-empty bounded argument array.');
|
|
246
|
+
}
|
|
247
|
+
foreach ($command as $part) {
|
|
248
|
+
if (!is_string($part) || str_contains($part, "\0")) {
|
|
249
|
+
throw new InvalidArgumentException('Every control command argument must be a string.');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
$body = ['command' => array_values($command), 'workdir' => $workdir, 'detach' => $detach];
|
|
253
|
+
if ($user !== '') {
|
|
254
|
+
$body['user'] = $user;
|
|
255
|
+
}
|
|
256
|
+
$result = nestbox_control_request('POST', '/containers/' . rawurlencode($service) . '/exec', $body);
|
|
257
|
+
if (!is_array($result) || !isset($result['runId']) || !is_string($result['runId'])) {
|
|
258
|
+
throw new RuntimeException('Control service returned an invalid exec response.');
|
|
259
|
+
}
|
|
260
|
+
return $result;
|
|
261
|
+
}
|
|
262
|
+
|
|
195
263
|
/** Create an OpenCode session synchronously so its ID is available immediately. */
|
|
196
264
|
function nestbox_opencode_create_session(string $title, string $directory = '/workspace'): array
|
|
197
265
|
{
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const baseUrl = (process.env.NESTBOX_CONTROL_URL || 'http://control:4088').replace(/\/$/, '');
|
|
5
|
+
const timeoutMs = Number(process.env.NESTBOX_CONTROL_TIMEOUT_MS || '30000');
|
|
6
|
+
const tools = [
|
|
7
|
+
{ name: 'docker_exec', description: 'Run a command in an allowed Compose service.', inputSchema: { type: 'object', properties: { machine: { type: 'string' }, workdir: { type: 'string' }, command: { type: 'array', items: { type: 'string' } }, user: { type: 'string' }, detach: { type: 'boolean' } }, required: ['machine', 'workdir', 'command'], additionalProperties: false } },
|
|
8
|
+
{ name: 'npm_scripts', description: 'List host npm scripts from /workspace/package.json.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
|
|
9
|
+
{ name: 'npm_run', description: 'Run an allowed npm script through the active host bridge.', inputSchema: { type: 'object', properties: { script: { type: 'string' }, args: { type: 'array', items: { type: 'string' } }, detach: { type: 'boolean' } }, required: ['script'], additionalProperties: false } },
|
|
10
|
+
{ name: 'npm_script_change_request', description: 'Request an add, edit, or delete of an npm script. The confirmation code is displayed only by the host runner.', inputSchema: { type: 'object', properties: { operation: { type: 'string', enum: ['add', 'edit', 'delete'] }, name: { type: 'string' }, command: { type: 'string' } }, required: ['operation', 'name'], additionalProperties: false } },
|
|
11
|
+
{ name: 'npm_script_change_confirm', description: 'Confirm a pending npm script change with the host-visible code.', inputSchema: { type: 'object', properties: { requestId: { type: 'string' }, code: { type: 'string' } }, required: ['requestId', 'code'], additionalProperties: false } },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function request(method, path, body) {
|
|
15
|
+
const url = new URL(baseUrl + path);
|
|
16
|
+
const transport = require(url.protocol === 'https:' ? 'https' : 'http');
|
|
17
|
+
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const headers = { accept: 'application/json' };
|
|
20
|
+
if (payload !== undefined) Object.assign(headers, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) });
|
|
21
|
+
const client = transport.request({ method, hostname: url.hostname, port: url.port || undefined, path: url.pathname + url.search, headers }, response => {
|
|
22
|
+
const chunks = [];
|
|
23
|
+
response.setEncoding('utf8');
|
|
24
|
+
response.on('data', chunk => chunks.push(chunk));
|
|
25
|
+
response.on('end', () => {
|
|
26
|
+
const text = chunks.join('');
|
|
27
|
+
let value = null;
|
|
28
|
+
try { value = text ? JSON.parse(text) : null; } catch { value = text; }
|
|
29
|
+
if (response.statusCode >= 400) reject(new Error(`Control API ${method} ${path} returned ${response.statusCode}: ${JSON.stringify(value)}`));
|
|
30
|
+
else resolve(value);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
client.setTimeout(timeoutMs, () => client.destroy(new Error('Control API request timed out')));
|
|
34
|
+
client.on('error', reject);
|
|
35
|
+
if (payload !== undefined) client.write(payload);
|
|
36
|
+
client.end();
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function object(value) {
|
|
41
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Tool arguments must be an object.');
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function call(name, raw) {
|
|
46
|
+
const args = object(raw || {});
|
|
47
|
+
if (name === 'docker_exec') {
|
|
48
|
+
if (typeof args.machine !== 'string' || !args.machine) throw new Error('machine must be a non-empty string.');
|
|
49
|
+
if (typeof args.workdir !== 'string' || !args.workdir.startsWith('/')) throw new Error('workdir must be an absolute path.');
|
|
50
|
+
if (!Array.isArray(args.command) || !args.command.length || !args.command.every(value => typeof value === 'string')) throw new Error('command must be a non-empty string array.');
|
|
51
|
+
const body = { command: args.command, workdir: args.workdir };
|
|
52
|
+
if (args.user) body.user = String(args.user);
|
|
53
|
+
if (args.detach !== undefined) body.detach = Boolean(args.detach);
|
|
54
|
+
return { machine: args.machine, workdir: args.workdir, command: args.command, result: await request('POST', `/containers/${encodeURIComponent(args.machine)}/exec`, body) };
|
|
55
|
+
}
|
|
56
|
+
if (name === 'npm_scripts') return request('GET', '/npm/scripts');
|
|
57
|
+
if (name === 'npm_run') {
|
|
58
|
+
if (typeof args.script !== 'string' || !args.script) throw new Error('script must be a non-empty string.');
|
|
59
|
+
if (args.args !== undefined && (!Array.isArray(args.args) || !args.args.every(value => typeof value === 'string'))) throw new Error('args must be a string array.');
|
|
60
|
+
return request('POST', '/npm/run', { script: args.script, ...(args.args === undefined ? {} : { args: args.args }), ...(args.detach === undefined ? {} : { detach: Boolean(args.detach) }) });
|
|
61
|
+
}
|
|
62
|
+
if (name === 'npm_script_change_request') {
|
|
63
|
+
if (!['add', 'edit', 'delete'].includes(args.operation) || typeof args.name !== 'string' || !args.name) throw new Error('operation and name are required.');
|
|
64
|
+
if (args.operation !== 'delete' && (typeof args.command !== 'string' || !args.command)) throw new Error('command is required for add and edit.');
|
|
65
|
+
if (args.operation === 'delete' && args.command !== undefined) throw new Error('command must be omitted for delete.');
|
|
66
|
+
return request('POST', '/npm/scripts/request', { operation: args.operation, name: args.name, ...(args.command === undefined ? {} : { command: args.command }) });
|
|
67
|
+
}
|
|
68
|
+
if (name === 'npm_script_change_confirm') {
|
|
69
|
+
if (typeof args.requestId !== 'string' || typeof args.code !== 'string') throw new Error('requestId and code are required.');
|
|
70
|
+
return request('POST', '/npm/scripts/confirm', { requestId: args.requestId, code: args.code });
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function emit(value) { process.stdout.write(`${JSON.stringify(value)}\n`); }
|
|
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.3.0' } } });
|
|
78
|
+
if (message.method === 'initialized') return;
|
|
79
|
+
if (message.method === 'tools/list') return emit({ jsonrpc: '2.0', id: message.id, result: { tools } });
|
|
80
|
+
if (message.method === 'tools/call') {
|
|
81
|
+
try {
|
|
82
|
+
const output = await call(message.params?.name, message.params?.arguments);
|
|
83
|
+
emit({ jsonrpc: '2.0', id: message.id, result: { content: [{ type: 'text', text: JSON.stringify(output, null, 2) }] } });
|
|
84
|
+
} catch (error) {
|
|
85
|
+
emit({ jsonrpc: '2.0', id: message.id, error: { code: -32603, message: error.message || 'Tool execution failed.' } });
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (message.id !== undefined) emit({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: `Method not supported: ${message.method}` } });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let input = '';
|
|
93
|
+
process.stdin.on('data', chunk => {
|
|
94
|
+
input += chunk.toString('utf8');
|
|
95
|
+
let boundary;
|
|
96
|
+
while ((boundary = input.indexOf('\n')) >= 0) {
|
|
97
|
+
const line = input.slice(0, boundary).replace(/\r$/, ''); input = input.slice(boundary + 1);
|
|
98
|
+
if (!line) continue;
|
|
99
|
+
try { Promise.resolve(handle(JSON.parse(line))).catch(error => emit({ jsonrpc: '2.0', id: null, error: { code: -32603, message: error.message } })); }
|
|
100
|
+
catch (error) { emit({ jsonrpc: '2.0', id: null, error: { code: -32700, message: error.message } }); }
|
|
101
|
+
}
|
|
102
|
+
});
|
|
@@ -168,11 +168,25 @@ The interactive 404 page demonstrates the intended flow: create a session scoped
|
|
|
168
168
|
|
|
169
169
|
Use API-created sessions only for explicit page workflows. The shared header's Open Chat link remains the normal entry point for conversation.
|
|
170
170
|
|
|
171
|
+
## Container Control API
|
|
172
|
+
|
|
173
|
+
OpenCode has no Docker socket. Use the control MCP as the only path for commands in other containers or on the native host:
|
|
174
|
+
|
|
175
|
+
- `docker_exec` accepts an allowed Compose `machine`, absolute `workdir`, positional `command` array, and optional `user` or `detach` value.
|
|
176
|
+
- `npm_scripts` lists scripts from `/workspace/package.json`.
|
|
177
|
+
- `npm_run` executes one declared script through the active native host runner. Prefer `detach: true` for work expected to exceed the MCP request timeout.
|
|
178
|
+
- `npm_script_change_request` requests an add, edit, or delete. The confirmation code appears only in the host runner terminal.
|
|
179
|
+
- `npm_script_change_confirm` applies the pending change after the user explicitly supplies that code.
|
|
180
|
+
|
|
181
|
+
Do not call the control HTTP API directly from OpenCode, impersonate the native runner, invoke Docker or SSH as a substitute, or work around a missing MCP capability. PHP cannot invoke MCP, so trusted super-documents may use `nestbox_control_exec()`; default labels restrict PHP-FPM to self-exec. Only OpenCode has `nestbox.npm.allow=true` by default. Root exec requires an explicit target label.
|
|
182
|
+
|
|
183
|
+
The control log is the audit source of truth and requires no observability service. The native runner uses an authenticated, loopback-only long-poll endpoint; no host listener or filesystem queue exists. Commands still operate in trusted containers and a trusted writable workspace; Docker socket access and editable host-script source mean this is a controlled capability, not a security sandbox.
|
|
184
|
+
|
|
171
185
|
## Git And Commit Policy
|
|
172
186
|
|
|
173
187
|
- Resolve the owning Git root before staging any path.
|
|
174
188
|
- Read the separately loaded `instance.md` before committing Nestbox work. It records the installation layout, repository roots, and commit policy without exposing `.env`.
|
|
175
|
-
- Treat the repository as installation history. Do not assume an upstream remote or upstream commit ancestry exists
|
|
189
|
+
- Treat the repository as installation history. Do not assume an upstream remote or upstream commit ancestry exists.
|
|
176
190
|
- The policy may allow logical commits after verification, require confirmation before each commit, or require leaving changes uncommitted.
|
|
177
191
|
- Reasonable Nestbox commits include super-documents, Compose, Dockerfiles, configurations, integrations, and dependency files.
|
|
178
192
|
- Keep commits within one Git root. When one feature spans repositories, create separate commits and report their relationship.
|
|
@@ -182,12 +196,13 @@ Use API-created sessions only for explicit page workflows. The shared header's O
|
|
|
182
196
|
|
|
183
197
|
## Container Architecture
|
|
184
198
|
|
|
185
|
-
The default Compose application has
|
|
199
|
+
The default Compose application has five services:
|
|
186
200
|
|
|
187
201
|
- `nginx`: the only host-published gateway. It serves static page files, routes super-documents to PHP-FPM, proxies the OpenCode virtual host, and hosts Nchan.
|
|
188
202
|
- `php-fpm`: executes HTTP and CLI directions. It mounts the page tree and workspace writable because trusted pages may intentionally modify them.
|
|
189
203
|
- `rollup`: watches `scripts.js` and browser dependencies and writes local JS/CSS bundles into the page tree.
|
|
190
204
|
- `opencode`: runs this agent, mounts the engine and workspace, and edits the page tree through `/nestbox/home/code`.
|
|
205
|
+
- `control`: owns the Docker socket and privately brokers label-gated container exec and host-runner jobs.
|
|
191
206
|
|
|
192
207
|
The default host port is `4180` after installer confirmation. Nginx container port `80` serves both Nestbox and OpenCode through host-based routing. Nginx container port `8080` is only the internal Nchan publisher.
|
|
193
208
|
|
|
@@ -214,6 +229,7 @@ The engine's untracked `/nestbox/.env` controls runtime paths, exposure, authent
|
|
|
214
229
|
- `APP_UID` and `APP_GID`: PHP-FPM ownership for writable bind mounts.
|
|
215
230
|
- `NESTBOX_URL_PREFIX`: public path prefix when an outer proxy strips that prefix before forwarding.
|
|
216
231
|
- `NESTBOX_OPENCODE_PUBLIC_URL`: explicit public OpenCode origin when `agent.<nestbox-host>` cannot be derived.
|
|
232
|
+
- `NESTBOX_CONTROL_URL`: internal control API URL, normally `http://control:4088`.
|
|
217
233
|
- `OPENCODE_SERVER_USERNAME` and `OPENCODE_SERVER_PASSWORD`: OpenCode HTTP authentication shared with PHP's internal API client.
|
|
218
234
|
|
|
219
235
|
Nginx publishes one host port and distinguishes two browser origins:
|
|
@@ -271,13 +287,13 @@ Mount every directory needed by both PHP and OpenCode at the same container path
|
|
|
271
287
|
|
|
272
288
|
## Container Boundary And Host Handoff
|
|
273
289
|
|
|
274
|
-
This OpenCode container deliberately has no Docker socket
|
|
290
|
+
This OpenCode container deliberately has no Docker socket. Use control MCP only for its declared, label-gated operations. Never claim a broader Docker or operating-system capability.
|
|
275
291
|
|
|
276
292
|
You may edit Compose files, Dockerfiles, Nginx configuration, PHP configuration, dependency files, mount definitions, and OpenCode configuration. After an infrastructure edit:
|
|
277
293
|
|
|
278
294
|
1. Validate every part that can be checked inside this container.
|
|
279
295
|
2. Identify the exact affected services.
|
|
280
|
-
3.
|
|
296
|
+
3. Use a declared host npm script through MCP when one exists; otherwise give the user the narrowest host-side command.
|
|
281
297
|
4. State that activation and host health checks remain pending.
|
|
282
298
|
5. Tell the user to reconnect if OpenCode itself must restart.
|
|
283
299
|
|
|
@@ -5,6 +5,17 @@
|
|
|
5
5
|
"port": 4096
|
|
6
6
|
},
|
|
7
7
|
"instructions": ["instructions.md", "instance.md"],
|
|
8
|
+
"mcp": {
|
|
9
|
+
"control": {
|
|
10
|
+
"type": "local",
|
|
11
|
+
"command": ["node", "/etc/opencode/control-mcp.js"],
|
|
12
|
+
"enabled": true,
|
|
13
|
+
"environment": {
|
|
14
|
+
"NESTBOX_CONTROL_URL": "{env:NESTBOX_CONTROL_URL}",
|
|
15
|
+
"NESTBOX_CONTROL_TIMEOUT_MS": "30000"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
},
|
|
8
19
|
"permission": {
|
|
9
20
|
"external_directory": {
|
|
10
21
|
"*": "ask",
|
|
@@ -1088,16 +1088,15 @@
|
|
|
1088
1088
|
"license": "CC0-1.0"
|
|
1089
1089
|
},
|
|
1090
1090
|
"node_modules/nanoid": {
|
|
1091
|
-
"version": "3.3.
|
|
1092
|
-
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.
|
|
1093
|
-
"integrity": "sha512-
|
|
1091
|
+
"version": "3.3.18",
|
|
1092
|
+
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
|
1093
|
+
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
|
1094
1094
|
"funding": [
|
|
1095
1095
|
{
|
|
1096
1096
|
"type": "github",
|
|
1097
1097
|
"url": "https://github.com/sponsors/ai"
|
|
1098
1098
|
}
|
|
1099
1099
|
],
|
|
1100
|
-
"license": "MIT",
|
|
1101
1100
|
"peer": true,
|
|
1102
1101
|
"bin": {
|
|
1103
1102
|
"nanoid": "bin/nanoid.cjs"
|
|
@@ -1210,9 +1209,9 @@
|
|
|
1210
1209
|
}
|
|
1211
1210
|
},
|
|
1212
1211
|
"node_modules/postcss": {
|
|
1213
|
-
"version": "8.5.
|
|
1214
|
-
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.
|
|
1215
|
-
"integrity": "sha512-
|
|
1212
|
+
"version": "8.5.26",
|
|
1213
|
+
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
|
1214
|
+
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
|
1216
1215
|
"funding": [
|
|
1217
1216
|
{
|
|
1218
1217
|
"type": "opencollective",
|
|
@@ -1227,10 +1226,9 @@
|
|
|
1227
1226
|
"url": "https://github.com/sponsors/ai"
|
|
1228
1227
|
}
|
|
1229
1228
|
],
|
|
1230
|
-
"license": "MIT",
|
|
1231
1229
|
"peer": true,
|
|
1232
1230
|
"dependencies": {
|
|
1233
|
-
"nanoid": "^3.3.
|
|
1231
|
+
"nanoid": "^3.3.17",
|
|
1234
1232
|
"picocolors": "^1.1.1",
|
|
1235
1233
|
"source-map-js": "^1.2.1"
|
|
1236
1234
|
},
|
|
@@ -2020,10 +2018,9 @@
|
|
|
2020
2018
|
}
|
|
2021
2019
|
},
|
|
2022
2020
|
"node_modules/svgo": {
|
|
2023
|
-
"version": "2.8.
|
|
2024
|
-
"resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.
|
|
2025
|
-
"integrity": "sha512-
|
|
2026
|
-
"license": "MIT",
|
|
2021
|
+
"version": "2.8.3",
|
|
2022
|
+
"resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.3.tgz",
|
|
2023
|
+
"integrity": "sha512-5EZD0pafXX6PphdwOGCiVLDSaV1xyuQao2blHajHLsPxr07q4mmEjdtXEWgG07ae2mIz8Ex2CDXNCTiXhy3Khw==",
|
|
2027
2024
|
"dependencies": {
|
|
2028
2025
|
"commander": "^7.2.0",
|
|
2029
2026
|
"css-select": "^4.1.3",
|