@p10i/create-nestbox 0.1.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 +34 -0
- package/.env.example +35 -0
- package/.gitattributes +10 -0
- package/INSTALL.md +258 -0
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/bin/create-nestbox.js +184 -0
- package/docker/nginx/Dockerfile +31 -0
- package/docker/opencode/Dockerfile +36 -0
- package/docker/opencode/entrypoint.sh +9 -0
- package/docker/php-fpm/Dockerfile +43 -0
- package/docker/rollup/Dockerfile +16 -0
- package/docker-compose.yaml +139 -0
- package/home/code/.user.ini +1 -0
- package/home/code/404.php +104 -0
- package/home/code/__data/.gitignore +2 -0
- package/home/code/__includes/footer.php +3 -0
- package/home/code/__includes/functions.php +325 -0
- package/home/code/__includes/header.php +20 -0
- package/home/code/__templates/super-document.php +47 -0
- package/home/code/scripts-bundle.css +13295 -0
- package/home/code/scripts-bundle.js +6277 -0
- package/home/code/scripts.js +3 -0
- package/home/code/styles.css +36 -0
- package/home/configs/nginx/nginx.conf +199 -0
- package/home/configs/opencode/instance.example.md +16 -0
- package/home/configs/opencode/instructions.md +308 -0
- package/home/configs/opencode/opencode.json +15 -0
- package/home/configs/php/cli.php +33 -0
- package/home/configs/php/composer.json +10 -0
- package/home/configs/php/composer.lock +805 -0
- package/home/configs/php/index.php +105 -0
- package/home/configs/php/init.php +3 -0
- package/home/configs/php/php.ini +5 -0
- package/home/configs/php/preload.php +5 -0
- package/home/configs/rollup/package-lock.json +2089 -0
- package/home/configs/rollup/package.json +16 -0
- package/home/configs/rollup/rollup.config.js +28 -0
- package/package.json +42 -0
- package/scripts/check-package.js +31 -0
- package/template.gitignore +20 -0
- package/tests/nestbox.ps1 +322 -0
- package/tests/nestbox.sh +438 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1.7
|
|
2
|
+
|
|
3
|
+
# ---- Stage 1: install PHP dependencies with Composer ----
|
|
4
|
+
FROM composer:latest AS vendor
|
|
5
|
+
|
|
6
|
+
WORKDIR /build
|
|
7
|
+
COPY ./home/configs/php/composer.json ./composer.json
|
|
8
|
+
COPY ./home/configs/php/composer.lock ./composer.lock
|
|
9
|
+
RUN composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev
|
|
10
|
+
|
|
11
|
+
# ---- Stage 2: runtime ----
|
|
12
|
+
FROM php:fpm-alpine
|
|
13
|
+
|
|
14
|
+
ARG APP_UID=1000
|
|
15
|
+
ARG APP_GID=1000
|
|
16
|
+
|
|
17
|
+
# Required system + PHP extensions.
|
|
18
|
+
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS libzip-dev && \
|
|
19
|
+
apk add --no-cache libzip && \
|
|
20
|
+
docker-php-ext-install zip && \
|
|
21
|
+
apk del .build-deps
|
|
22
|
+
|
|
23
|
+
# PHP config.
|
|
24
|
+
COPY ./home/configs/php/php.ini /usr/local/etc/php/conf.d/custom.ini
|
|
25
|
+
|
|
26
|
+
# Vendor dir from stage 1.
|
|
27
|
+
COPY --from=vendor /build/vendor /vendor
|
|
28
|
+
COPY ./home/configs/php/composer.json /composer.json
|
|
29
|
+
COPY ./home/configs/php/composer.lock /composer.lock
|
|
30
|
+
|
|
31
|
+
# Application source.
|
|
32
|
+
COPY ./home/configs/php /sources/php
|
|
33
|
+
COPY ./home/code /home/code
|
|
34
|
+
|
|
35
|
+
# Run as a non-root, non-default user. UID/GID are passed as build args so
|
|
36
|
+
# they can match the host user when bind-mounting in development, avoiding
|
|
37
|
+
# the classic "files owned by root after compose down" annoyance on Linux.
|
|
38
|
+
RUN addgroup -g "${APP_GID}" -S nestbox && \
|
|
39
|
+
adduser -u "${APP_UID}" -G nestbox -S -H nestbox && \
|
|
40
|
+
sed -i "s/^user = .*/user = nestbox/; s/^group = .*/group = nestbox/" /usr/local/etc/php-fpm.d/www.conf && \
|
|
41
|
+
chown -R nestbox:nestbox /home/code /sources/php /vendor
|
|
42
|
+
|
|
43
|
+
USER nestbox
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
FROM node:alpine
|
|
2
|
+
|
|
3
|
+
ARG APP_UID=1000
|
|
4
|
+
ARG APP_GID=1000
|
|
5
|
+
|
|
6
|
+
RUN apk add --no-cache shadow && \
|
|
7
|
+
if [ "$(id -u node)" != "$APP_UID" ]; then usermod -u "$APP_UID" node; fi && \
|
|
8
|
+
if [ "$(id -g node)" != "$APP_GID" ]; then groupmod -g "$APP_GID" node; fi
|
|
9
|
+
|
|
10
|
+
COPY ./home/code /home/code
|
|
11
|
+
COPY ./home/configs/rollup /sources/rollup
|
|
12
|
+
|
|
13
|
+
WORKDIR /sources/rollup
|
|
14
|
+
RUN npm ci --no-audit --no-fund && chown -R node:node /home/code /sources/rollup
|
|
15
|
+
|
|
16
|
+
USER node
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Complete local Nestbox runtime. The installer configures paths, ownership,
|
|
2
|
+
# credentials, and the shared browser port through .env.
|
|
3
|
+
services:
|
|
4
|
+
# Single browser gateway. The ordinary host serves Nestbox, while agent.*
|
|
5
|
+
# virtual hosts proxy the internal OpenCode server.
|
|
6
|
+
nginx:
|
|
7
|
+
build:
|
|
8
|
+
context: .
|
|
9
|
+
dockerfile: ./docker/nginx/Dockerfile
|
|
10
|
+
depends_on:
|
|
11
|
+
- php-fpm
|
|
12
|
+
- opencode
|
|
13
|
+
ports:
|
|
14
|
+
# Nestbox and OpenCode share one confirmed host port.
|
|
15
|
+
- "${BIND_ADDRESS:-127.0.0.1}:${WEB_PORT:-4180}:80"
|
|
16
|
+
expose:
|
|
17
|
+
# Internal Nchan publisher only; never publish this port on the host.
|
|
18
|
+
- "8080"
|
|
19
|
+
volumes:
|
|
20
|
+
# Nginx only reads pages and generated browser assets.
|
|
21
|
+
- type: bind
|
|
22
|
+
source: ${NESTBOX_PAGE_PATH:-./home/code}
|
|
23
|
+
target: /var/www/html
|
|
24
|
+
read_only: true
|
|
25
|
+
bind:
|
|
26
|
+
create_host_path: false
|
|
27
|
+
- ./home/configs/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
|
28
|
+
healthcheck:
|
|
29
|
+
# The endpoint passes through PHP, checking both Nginx and PHP-FPM.
|
|
30
|
+
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/_nestbox/health"]
|
|
31
|
+
interval: 10s
|
|
32
|
+
timeout: 3s
|
|
33
|
+
retries: 5
|
|
34
|
+
start_period: 10s
|
|
35
|
+
restart: unless-stopped
|
|
36
|
+
|
|
37
|
+
# Executes super-document HTTP and CLI directions. Trusted pages may update
|
|
38
|
+
# page state and intentionally operate on the mounted workspace.
|
|
39
|
+
php-fpm:
|
|
40
|
+
build:
|
|
41
|
+
context: .
|
|
42
|
+
dockerfile: ./docker/php-fpm/Dockerfile
|
|
43
|
+
args:
|
|
44
|
+
APP_UID: ${APP_UID:-1000}
|
|
45
|
+
APP_GID: ${APP_GID:-1000}
|
|
46
|
+
volumes:
|
|
47
|
+
# Routers and PHP configuration are source, not writable state.
|
|
48
|
+
- ./home/configs/php:/sources/php:ro
|
|
49
|
+
- type: bind
|
|
50
|
+
source: ${NESTBOX_PAGE_PATH:-./home/code}
|
|
51
|
+
target: /home/code
|
|
52
|
+
bind:
|
|
53
|
+
create_host_path: false
|
|
54
|
+
- type: bind
|
|
55
|
+
source: ${WORKSPACE_PATH:-.}
|
|
56
|
+
target: /workspace
|
|
57
|
+
bind:
|
|
58
|
+
create_host_path: false
|
|
59
|
+
environment:
|
|
60
|
+
# Event publication and OpenCode automation stay inside Compose.
|
|
61
|
+
- NESTBOX_BUS_PUBLISH_URL=http://nginx:8080/_nestbox/publish
|
|
62
|
+
- NESTBOX_URL_PREFIX=${NESTBOX_URL_PREFIX:-}
|
|
63
|
+
- NESTBOX_OPENCODE_URL=http://opencode:4096
|
|
64
|
+
- NESTBOX_OPENCODE_PUBLIC_URL=${NESTBOX_OPENCODE_PUBLIC_URL:-}
|
|
65
|
+
- OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode}
|
|
66
|
+
- OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:-}
|
|
67
|
+
restart: unless-stopped
|
|
68
|
+
|
|
69
|
+
# Watches shared browser source and refreshes tracked bundle baselines.
|
|
70
|
+
# Dependencies are installed in the image, so startup does not need npm ci.
|
|
71
|
+
rollup:
|
|
72
|
+
build:
|
|
73
|
+
context: .
|
|
74
|
+
dockerfile: ./docker/rollup/Dockerfile
|
|
75
|
+
args:
|
|
76
|
+
APP_UID: ${APP_UID:-1000}
|
|
77
|
+
APP_GID: ${APP_GID:-1000}
|
|
78
|
+
user: "${APP_UID:-1000}:${APP_GID:-1000}"
|
|
79
|
+
working_dir: /sources/rollup
|
|
80
|
+
command: npm run rollup -- --watch
|
|
81
|
+
volumes:
|
|
82
|
+
# Configuration changes are valid agent work but require an image rebuild.
|
|
83
|
+
- ./home/configs/rollup:/sources/rollup
|
|
84
|
+
- type: bind
|
|
85
|
+
source: ${NESTBOX_PAGE_PATH:-./home/code}
|
|
86
|
+
target: /home/code
|
|
87
|
+
bind:
|
|
88
|
+
create_host_path: false
|
|
89
|
+
# Keep dependencies out of the host page tree.
|
|
90
|
+
- rollup-node-modules:/sources/rollup/node_modules
|
|
91
|
+
restart: unless-stopped
|
|
92
|
+
|
|
93
|
+
# Internal implementation agent. It can edit Nestbox and the workspace but
|
|
94
|
+
# deliberately receives no Docker socket or direct host control.
|
|
95
|
+
opencode:
|
|
96
|
+
build:
|
|
97
|
+
context: .
|
|
98
|
+
dockerfile: ./docker/opencode/Dockerfile
|
|
99
|
+
args:
|
|
100
|
+
APP_UID: ${APP_UID:-1000}
|
|
101
|
+
APP_GID: ${APP_GID:-1000}
|
|
102
|
+
env_file:
|
|
103
|
+
# Provider and OpenCode credentials are supplied to this service.
|
|
104
|
+
- .env
|
|
105
|
+
volumes:
|
|
106
|
+
# Agent and PHP tooling configuration remain read-only at runtime.
|
|
107
|
+
- ./home/configs/opencode:/etc/opencode:ro
|
|
108
|
+
- ./home/configs/php:/sources/php:ro
|
|
109
|
+
# Read-only page view used by PHP tooling inside the agent container.
|
|
110
|
+
- type: bind
|
|
111
|
+
source: ${NESTBOX_PAGE_PATH:-./home/code}
|
|
112
|
+
target: /home/code
|
|
113
|
+
read_only: true
|
|
114
|
+
bind:
|
|
115
|
+
create_host_path: false
|
|
116
|
+
# Writable installation and stable page-authoring paths.
|
|
117
|
+
- .:/nestbox
|
|
118
|
+
- type: bind
|
|
119
|
+
source: ${NESTBOX_PAGE_PATH:-./home/code}
|
|
120
|
+
target: /nestbox/home/code
|
|
121
|
+
bind:
|
|
122
|
+
create_host_path: false
|
|
123
|
+
- type: bind
|
|
124
|
+
source: ${WORKSPACE_PATH:-.}
|
|
125
|
+
target: /workspace
|
|
126
|
+
bind:
|
|
127
|
+
create_host_path: false
|
|
128
|
+
# The entrypoint fixes this volume's ownership before dropping privileges.
|
|
129
|
+
- opencode-data:/home/nestbox/.local/share/opencode
|
|
130
|
+
working_dir: /workspace
|
|
131
|
+
environment:
|
|
132
|
+
- NESTBOX_BUS_PUBLISH_URL=http://nginx:8080/_nestbox/publish
|
|
133
|
+
restart: unless-stopped
|
|
134
|
+
|
|
135
|
+
volumes:
|
|
136
|
+
# Disposable dependency cache reconstructed from package-lock.json.
|
|
137
|
+
rollup-node-modules:
|
|
138
|
+
# OpenCode sessions retained across container recreation.
|
|
139
|
+
opencode-data:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
auto_prepend_file = /sources/php/init.php
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
declare(strict_types=1);
|
|
3
|
+
|
|
4
|
+
// HTTP direction: preserve and validate the unresolved URI across the form POST.
|
|
5
|
+
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
|
6
|
+
$rawRequestedUri = $method === 'POST'
|
|
7
|
+
? (string) ($_POST['requested_uri'] ?? '')
|
|
8
|
+
: (string) ($_SERVER['NESTBOX_ORIGINAL_URI'] ?? $_SERVER['REQUEST_URI'] ?? '/');
|
|
9
|
+
$pageContext = nestbox_page_context($rawRequestedUri);
|
|
10
|
+
$requestedUri = $pageContext['requestedUri'] ?? '';
|
|
11
|
+
$requestedPath = $pageContext['requestedPath'] ?? '';
|
|
12
|
+
$agentDirectory = $pageContext['agentDirectory'] ?? '';
|
|
13
|
+
|
|
14
|
+
if (PHP_SAPI === 'cli') {
|
|
15
|
+
echo "Implementation sessions are managed by OpenCode.\n";
|
|
16
|
+
exit;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
$error = '';
|
|
20
|
+
$submittedPrompt = '';
|
|
21
|
+
|
|
22
|
+
if ($method === 'POST') {
|
|
23
|
+
if ($pageContext === null) {
|
|
24
|
+
http_response_code(422);
|
|
25
|
+
$error = 'This URI cannot map to a Nestbox super-document.';
|
|
26
|
+
} elseif (($_POST['action'] ?? '') !== 'start') {
|
|
27
|
+
http_response_code(400);
|
|
28
|
+
$error = 'Unknown form action.';
|
|
29
|
+
} else {
|
|
30
|
+
$submittedPrompt = trim((string) ($_POST['prompt'] ?? ''));
|
|
31
|
+
$singleLinePrompt = preg_replace('/\s+/u', ' ', $submittedPrompt);
|
|
32
|
+
$submittedPrompt = is_string($singleLinePrompt) ? $singleLinePrompt : '';
|
|
33
|
+
|
|
34
|
+
if (!is_string($singleLinePrompt)
|
|
35
|
+
|| $singleLinePrompt === ''
|
|
36
|
+
|| strlen($singleLinePrompt) > 4000
|
|
37
|
+
|| preg_match('//u', $singleLinePrompt) !== 1
|
|
38
|
+
) {
|
|
39
|
+
http_response_code(422);
|
|
40
|
+
$error = 'Enter a valid one-line UTF-8 request between 1 and 4,000 bytes.';
|
|
41
|
+
} else {
|
|
42
|
+
try {
|
|
43
|
+
// OpenCode scopes the new session to the directory where the
|
|
44
|
+
// requested super-document will live.
|
|
45
|
+
$localDirectory = '/home/code' . substr($agentDirectory, strlen('/nestbox/home/code'));
|
|
46
|
+
if (!is_dir($localDirectory) && !mkdir($localDirectory, 0775, true) && !is_dir($localDirectory)) {
|
|
47
|
+
throw new RuntimeException('Nestbox could not create the super-document directory.');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
$title = preg_match('/^.{0,72}/us', $singleLinePrompt, $titleMatch) === 1
|
|
51
|
+
? $titleMatch[0]
|
|
52
|
+
: 'New page';
|
|
53
|
+
$session = nestbox_opencode_create_session('Nestbox ' . $requestedPath . ': ' . $title, $agentDirectory);
|
|
54
|
+
if (isset($session['directory'])
|
|
55
|
+
&& (!is_string($session['directory']) || $session['directory'] !== $agentDirectory)
|
|
56
|
+
) {
|
|
57
|
+
throw new RuntimeException('OpenCode created the session in an unexpected directory.');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
$sessionId = (string) $session['id'];
|
|
61
|
+
$agentPrompt = 'Requested Nestbox page URI: ' . $requestedUri
|
|
62
|
+
. '. Implement this route as a cohesive super-document under /nestbox/home/code, following /nestbox/home/configs/opencode/instructions.md. Do not modify unrelated projects under /workspace. Request: '
|
|
63
|
+
. $singleLinePrompt;
|
|
64
|
+
nestbox_opencode_prompt_async($sessionId, $agentPrompt, '', $agentDirectory);
|
|
65
|
+
|
|
66
|
+
header('Location: ' . nestbox_opencode_session_url($sessionId, $agentDirectory), true, 303);
|
|
67
|
+
exit;
|
|
68
|
+
} catch (Throwable $exception) {
|
|
69
|
+
http_response_code(502);
|
|
70
|
+
$error = $exception->getMessage();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
http_response_code(404);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
$formAction = nestbox_url_path('404');
|
|
79
|
+
?>
|
|
80
|
+
---
|
|
81
|
+
title: Page not found
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
# Page not found
|
|
85
|
+
|
|
86
|
+
Nestbox could not resolve `<?= nestbox_escape($requestedUri); ?>`.
|
|
87
|
+
|
|
88
|
+
<?php if ($error !== ''): ?>
|
|
89
|
+
<div class="alert alert-danger" role="alert"><?= nestbox_escape($error); ?></div>
|
|
90
|
+
<?php endif; ?>
|
|
91
|
+
|
|
92
|
+
<?php if ($pageContext !== null): ?>
|
|
93
|
+
<form class="card card-body" method="post" action="<?= nestbox_escape($formAction); ?>" target="nestbox-opencode">
|
|
94
|
+
<input type="hidden" name="action" value="start">
|
|
95
|
+
<input type="hidden" name="requested_uri" value="<?= nestbox_escape($requestedUri); ?>">
|
|
96
|
+
<label class="form-label" for="prompt">Ask OpenCode to implement this page</label>
|
|
97
|
+
<input class="form-control" id="prompt" name="prompt" type="text" maxlength="4000" value="<?= nestbox_escape($submittedPrompt); ?>" required>
|
|
98
|
+
<div class="mt-3">
|
|
99
|
+
<button class="btn btn-primary" type="submit">Create and open session</button>
|
|
100
|
+
</div>
|
|
101
|
+
</form>
|
|
102
|
+
<?php else: ?>
|
|
103
|
+
<div class="alert alert-secondary" role="status">This URI cannot map to a Nestbox super-document.</div>
|
|
104
|
+
<?php endif; ?>
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
declare(strict_types=1);
|
|
3
|
+
|
|
4
|
+
/** Escape a value for HTML text or attribute output. */
|
|
5
|
+
function nestbox_escape(mixed $value): string
|
|
6
|
+
{
|
|
7
|
+
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Encode a PHP value as a JSON literal that cannot terminate an inline script. */
|
|
11
|
+
function nestbox_script_json(mixed $value): string
|
|
12
|
+
{
|
|
13
|
+
return json_encode(
|
|
14
|
+
$value,
|
|
15
|
+
JSON_THROW_ON_ERROR | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Return the configured normalized public URL prefix. */
|
|
20
|
+
function nestbox_url_prefix(): string
|
|
21
|
+
{
|
|
22
|
+
$value = getenv('NESTBOX_URL_PREFIX') ?: '';
|
|
23
|
+
$prefix = '/' . trim((string) $value, '/');
|
|
24
|
+
|
|
25
|
+
if ($prefix === '/') {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
if (!preg_match('#^/(?:[A-Za-z0-9._~-]+(?:/[A-Za-z0-9._~-]+)*)$#', $prefix)) {
|
|
29
|
+
throw new UnexpectedValueException('NESTBOX_URL_PREFIX must contain only URL-safe path segments.');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return $prefix;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Build a prefix-aware URL path by encoding each path segment independently. */
|
|
36
|
+
function nestbox_url_path(string ...$segments): string
|
|
37
|
+
{
|
|
38
|
+
$path = implode('/', array_map('rawurlencode', $segments));
|
|
39
|
+
return nestbox_url_prefix() . '/' . $path;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Map a public URI to its page data and agent directory. */
|
|
43
|
+
function nestbox_page_context(string $requestUri): ?array
|
|
44
|
+
{
|
|
45
|
+
if ($requestUri === '' || preg_match('/[\r\n]/', $requestUri)) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
$path = parse_url($requestUri, PHP_URL_PATH);
|
|
49
|
+
$query = parse_url($requestUri, PHP_URL_QUERY);
|
|
50
|
+
$path = is_string($path) ? preg_replace('#/+#', '/', $path) : null;
|
|
51
|
+
if (!is_string($path) || !str_starts_with($path, '/') || str_starts_with($path, '//')) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
$route = trim($path, '/');
|
|
56
|
+
if ($route === '') {
|
|
57
|
+
$route = 'index';
|
|
58
|
+
}
|
|
59
|
+
$markdownDirection = str_ends_with(strtolower($route), '.md');
|
|
60
|
+
if ($markdownDirection) {
|
|
61
|
+
$route = substr($route, 0, -3);
|
|
62
|
+
}
|
|
63
|
+
if (!preg_match('#^[A-Za-z0-9/_-]+$#', $route)) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
foreach (explode('/', $route) as $segment) {
|
|
67
|
+
if (str_starts_with($segment, '__')) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
$routeDirectory = dirname($route);
|
|
73
|
+
$relativeDirectory = $routeDirectory === '.' ? '' : '/' . $routeDirectory;
|
|
74
|
+
return [
|
|
75
|
+
'requestedUri' => $path . (is_string($query) ? '?' . $query : ''),
|
|
76
|
+
'requestedPath' => $path,
|
|
77
|
+
'route' => $route,
|
|
78
|
+
'statePath' => '/home/code' . $relativeDirectory . '/__data/' . basename($route) . '.json',
|
|
79
|
+
'agentDirectory' => '/nestbox/home/code' . $relativeDirectory,
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Return the public OpenCode origin that corresponds to the current Nestbox host. */
|
|
84
|
+
function nestbox_opencode_origin(): string
|
|
85
|
+
{
|
|
86
|
+
$configuredOrigin = rtrim((string) getenv('NESTBOX_OPENCODE_PUBLIC_URL'), '/');
|
|
87
|
+
if ($configuredOrigin !== '') {
|
|
88
|
+
$parts = parse_url($configuredOrigin);
|
|
89
|
+
if (!is_array($parts)
|
|
90
|
+
|| !isset($parts['scheme'], $parts['host'])
|
|
91
|
+
|| !in_array(strtolower((string) $parts['scheme']), ['http', 'https'], true)
|
|
92
|
+
|| isset($parts['user'])
|
|
93
|
+
|| isset($parts['pass'])
|
|
94
|
+
|| isset($parts['query'])
|
|
95
|
+
|| isset($parts['fragment'])
|
|
96
|
+
|| (isset($parts['path']) && $parts['path'] !== '')
|
|
97
|
+
) {
|
|
98
|
+
throw new UnexpectedValueException('NESTBOX_OPENCODE_PUBLIC_URL must be an HTTP origin without credentials or a path.');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return $configuredOrigin;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
$scheme = strtolower((string) ($_SERVER['REQUEST_SCHEME'] ?? 'http'));
|
|
105
|
+
if (!in_array($scheme, ['http', 'https'], true)) {
|
|
106
|
+
throw new UnexpectedValueException('The request scheme is not valid for an OpenCode URL.');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
$host = trim((string) ($_SERVER['HTTP_HOST'] ?? 'localhost'));
|
|
110
|
+
if (!preg_match('#^([A-Za-z0-9.-]+)(:\d{1,5})?$#', $host, $matches)) {
|
|
111
|
+
throw new UnexpectedValueException('The request host is not valid for an OpenCode URL.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
$hostname = strtolower($matches[1]);
|
|
115
|
+
$port = $matches[2] ?? '';
|
|
116
|
+
if (in_array($hostname, ['localhost', '127.0.0.1'], true)) {
|
|
117
|
+
$hostname = 'agent.localhost';
|
|
118
|
+
} elseif (!str_starts_with($hostname, 'agent.')) {
|
|
119
|
+
$hostname = 'agent.' . $hostname;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return "$scheme://$hostname$port";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Build the current OpenCode web UI's server-scoped session deep link. */
|
|
126
|
+
function nestbox_opencode_session_url(string $sessionId, string $directory): string
|
|
127
|
+
{
|
|
128
|
+
if (!preg_match('#^[A-Za-z0-9_-]{1,128}$#', $sessionId)) {
|
|
129
|
+
throw new InvalidArgumentException('Invalid OpenCode session ID.');
|
|
130
|
+
}
|
|
131
|
+
if (!str_starts_with($directory, '/') || str_contains($directory, "\0")) {
|
|
132
|
+
throw new InvalidArgumentException('OpenCode session directories must be absolute paths.');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
$origin = nestbox_opencode_origin();
|
|
136
|
+
$serverKey = rtrim(strtr(base64_encode($origin), '+/', '-_'), '=');
|
|
137
|
+
return $origin . '/server/' . $serverKey . '/session/' . rawurlencode($sessionId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Send a JSON request to the internal OpenCode server for one mounted directory. */
|
|
141
|
+
function nestbox_opencode_request(string $method, string $path, array $body, string $directory): mixed
|
|
142
|
+
{
|
|
143
|
+
$baseUrl = rtrim((string) getenv('NESTBOX_OPENCODE_URL'), '/');
|
|
144
|
+
if ($baseUrl === '') {
|
|
145
|
+
throw new RuntimeException('NESTBOX_OPENCODE_URL is not configured.');
|
|
146
|
+
}
|
|
147
|
+
if (!str_starts_with($path, '/')
|
|
148
|
+
|| !str_starts_with($directory, '/')
|
|
149
|
+
|| preg_match('/[\0\r\n]/', $path . $directory)
|
|
150
|
+
) {
|
|
151
|
+
throw new InvalidArgumentException('Invalid OpenCode request path or directory.');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// OpenCode scopes every non-global API operation with the documented
|
|
155
|
+
// directory query parameter. Older custom headers are ignored by the SDK.
|
|
156
|
+
$requestUrl = $baseUrl . $path
|
|
157
|
+
. (str_contains($path, '?') ? '&' : '?')
|
|
158
|
+
. 'directory=' . rawurlencode($directory);
|
|
159
|
+
$payload = json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
|
160
|
+
$headers = [
|
|
161
|
+
'Accept: application/json',
|
|
162
|
+
'Content-Type: application/json',
|
|
163
|
+
'Content-Length: ' . strlen($payload),
|
|
164
|
+
];
|
|
165
|
+
$password = (string) getenv('OPENCODE_SERVER_PASSWORD');
|
|
166
|
+
if ($password !== '') {
|
|
167
|
+
$username = (string) (getenv('OPENCODE_SERVER_USERNAME') ?: 'opencode');
|
|
168
|
+
$headers[] = 'Authorization: Basic ' . base64_encode("$username:$password");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
$context = stream_context_create([
|
|
172
|
+
'http' => [
|
|
173
|
+
'method' => strtoupper($method),
|
|
174
|
+
'header' => implode("\r\n", $headers),
|
|
175
|
+
'content' => $payload,
|
|
176
|
+
'ignore_errors' => true,
|
|
177
|
+
'timeout' => 10,
|
|
178
|
+
],
|
|
179
|
+
]);
|
|
180
|
+
$response = @file_get_contents($requestUrl, false, $context);
|
|
181
|
+
$statusLine = $http_response_header[0] ?? '';
|
|
182
|
+
preg_match('#^HTTP/\S+\s+(\d{3})#', $statusLine, $matches);
|
|
183
|
+
$status = isset($matches[1]) ? (int) $matches[1] : 0;
|
|
184
|
+
|
|
185
|
+
if ($response === false || $status < 200 || $status >= 300) {
|
|
186
|
+
throw new RuntimeException("OpenCode request failed with HTTP status $status.");
|
|
187
|
+
}
|
|
188
|
+
if ($response === '' || $status === 204) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return json_decode($response, true, flags: JSON_THROW_ON_ERROR);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Create an OpenCode session synchronously so its ID is available immediately. */
|
|
196
|
+
function nestbox_opencode_create_session(string $title, string $directory = '/workspace'): array
|
|
197
|
+
{
|
|
198
|
+
$session = nestbox_opencode_request('POST', '/session', ['title' => $title], $directory);
|
|
199
|
+
if (!is_array($session)
|
|
200
|
+
|| !isset($session['id'])
|
|
201
|
+
|| !is_string($session['id'])
|
|
202
|
+
|| !preg_match('#^[A-Za-z0-9_-]{1,128}$#', $session['id'])
|
|
203
|
+
) {
|
|
204
|
+
throw new RuntimeException('OpenCode did not return a session ID.');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return $session;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Queue a prompt, with optional system context, without waiting for the response. */
|
|
211
|
+
function nestbox_opencode_prompt_async(
|
|
212
|
+
string $sessionId,
|
|
213
|
+
string $prompt,
|
|
214
|
+
string $system,
|
|
215
|
+
string $directory = '/workspace',
|
|
216
|
+
): void {
|
|
217
|
+
if (!preg_match('#^[A-Za-z0-9_-]{1,128}$#', $sessionId)) {
|
|
218
|
+
throw new InvalidArgumentException('Invalid OpenCode session ID.');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
$payload = [
|
|
222
|
+
'parts' => [[
|
|
223
|
+
'type' => 'text',
|
|
224
|
+
'text' => $prompt,
|
|
225
|
+
]],
|
|
226
|
+
];
|
|
227
|
+
if ($system !== '') {
|
|
228
|
+
$payload['system'] = $system;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
nestbox_opencode_request('POST', '/session/' . rawurlencode($sessionId) . '/prompt_async', $payload, $directory);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Validate and normalize an exact or suffix-wildcard event topic. */
|
|
235
|
+
function nestbox_bus_topic(string $topic, bool $allowWildcard = true): string
|
|
236
|
+
{
|
|
237
|
+
$topic = trim($topic);
|
|
238
|
+
$segment = '[A-Za-z0-9][A-Za-z0-9._/-]{0,63}';
|
|
239
|
+
$pattern = $allowWildcard
|
|
240
|
+
? "#^(?:$segment)(?::$segment)*(?::\\*)?$#"
|
|
241
|
+
: "#^(?:$segment)(?::$segment)*$#";
|
|
242
|
+
|
|
243
|
+
if (strlen($topic) > 128 || !preg_match($pattern, $topic)) {
|
|
244
|
+
throw new InvalidArgumentException("Invalid Nestbox event topic: $topic");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return $topic;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Build a same-origin, prefix-aware EventSource URL for one or more topics. */
|
|
251
|
+
function nestbox_bus_url(string ...$topics): string
|
|
252
|
+
{
|
|
253
|
+
if ($topics === []) {
|
|
254
|
+
throw new InvalidArgumentException('At least one Nestbox event topic is required.');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
$topics = array_values(array_unique(array_map(
|
|
258
|
+
static fn (string $topic): string => nestbox_bus_topic($topic),
|
|
259
|
+
$topics,
|
|
260
|
+
)));
|
|
261
|
+
if (count($topics) > 16) {
|
|
262
|
+
throw new LengthException('A Nestbox EventSource can subscribe to at most 16 topics.');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return nestbox_url_path('_nestbox', 'events') . '?topics=' . implode(',', $topics);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Best-effort publication to an exact topic and its suffix wildcard. */
|
|
269
|
+
function nestbox_bus_publish(string $topic, string $event = 'changed', array $data = []): bool
|
|
270
|
+
{
|
|
271
|
+
$topic = nestbox_bus_topic($topic, false);
|
|
272
|
+
if (!preg_match('#^[A-Za-z][A-Za-z0-9_.-]{0,63}$#', $event)) {
|
|
273
|
+
throw new InvalidArgumentException("Invalid Nestbox event name: $event");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
$topics = [$topic];
|
|
277
|
+
$separator = strrpos($topic, ':');
|
|
278
|
+
if ($separator !== false) {
|
|
279
|
+
$topics[] = substr($topic, 0, $separator + 1) . '*';
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
$payload = json_encode([
|
|
283
|
+
'topic' => $topic,
|
|
284
|
+
'event' => $event,
|
|
285
|
+
'data' => $data,
|
|
286
|
+
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
|
287
|
+
|
|
288
|
+
if (strlen($payload) > 64 * 1024) {
|
|
289
|
+
throw new LengthException('Nestbox event payloads cannot exceed 64 KiB.');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
$publisher = getenv('NESTBOX_BUS_PUBLISH_URL');
|
|
293
|
+
if (!$publisher) {
|
|
294
|
+
error_log('Nestbox event publication skipped: NESTBOX_BUS_PUBLISH_URL is not configured.');
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
$url = $publisher . (str_contains($publisher, '?') ? '&' : '?')
|
|
299
|
+
. 'topics=' . implode(',', $topics);
|
|
300
|
+
$context = stream_context_create([
|
|
301
|
+
'http' => [
|
|
302
|
+
'method' => 'POST',
|
|
303
|
+
'header' => implode("\r\n", [
|
|
304
|
+
'Content-Type: application/json',
|
|
305
|
+
'X-EventSource-Event: ' . $event,
|
|
306
|
+
'Content-Length: ' . strlen($payload),
|
|
307
|
+
]),
|
|
308
|
+
'content' => $payload,
|
|
309
|
+
'ignore_errors' => true,
|
|
310
|
+
'timeout' => 2,
|
|
311
|
+
],
|
|
312
|
+
]);
|
|
313
|
+
|
|
314
|
+
$response = @file_get_contents($url, false, $context);
|
|
315
|
+
$statusLine = $http_response_header[0] ?? '';
|
|
316
|
+
preg_match('#^HTTP/\S+\s+(\d{3})#', $statusLine, $matches);
|
|
317
|
+
$status = isset($matches[1]) ? (int) $matches[1] : 0;
|
|
318
|
+
|
|
319
|
+
if ($response === false || !in_array($status, [201, 202], true)) {
|
|
320
|
+
error_log("Nestbox event publication failed with HTTP status $status.");
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title><?= htmlspecialchars($title ?? 'Nestbox', ENT_QUOTES, 'UTF-8'); ?></title>
|
|
7
|
+
<!-- Pre-built bundle of every shared dependency (Bootstrap, GitHub Markdown CSS, …): -->
|
|
8
|
+
<link rel="stylesheet" href="<?= nestbox_escape(nestbox_url_path('scripts-bundle.css')); ?>">
|
|
9
|
+
<script defer src="<?= nestbox_escape(nestbox_url_path('scripts-bundle.js')); ?>"></script>
|
|
10
|
+
<!-- Site-wide overrides: -->
|
|
11
|
+
<link rel="stylesheet" href="<?= nestbox_escape(nestbox_url_path('styles.css')); ?>">
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<header class="nestbox-toolbar navbar fixed-top bg-body-tertiary border-bottom px-3" aria-label="Page tools">
|
|
15
|
+
<a class="navbar-brand fs-6 fw-semibold mb-0" href="<?= nestbox_escape(nestbox_url_path()); ?>">Nestbox</a>
|
|
16
|
+
<div class="d-flex align-items-center gap-2 ms-auto">
|
|
17
|
+
<a class="btn btn-sm btn-secondary" href="<?= nestbox_escape(nestbox_opencode_origin()); ?>" target="nestbox-opencode">Open Chat</a>
|
|
18
|
+
</div>
|
|
19
|
+
</header>
|
|
20
|
+
<article class="markdown-body">
|