feat(server): add OPENCODE_HOST env var for external OpenCode connections (#499)

* feat(server): add OPENCODE_HOST env var for external OpenCode connections

Allows specifying a full base URL (e.g. https://hostname:4096) for external
OpenCode connections, supporting custom hostnames and HTTPS. When set,
OPENCODE_HOST overrides OPENCODE_PORT. Malformed values are fatal at startup.

* docs: document OPENCODE_HOST env var in README and AGENTS

* Fatal error when OPENCODE_HOST has path, search or hash

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Colin Mollenhour <colin@mollenhour.com>

* fix(server): use external origin when re-probing OpenCode

---------

Signed-off-by: Colin Mollenhour <colin@mollenhour.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Colin Mollenhour
2026-02-25 19:42:32 +02:00
committed by GitHub
co-authored by Copilot
parent 7a151290be
commit e02002bc9f
5 changed files with 63 additions and 18 deletions
+1 -1
View File
@@ -79,7 +79,7 @@ All scripts are in `package.json`.
- SSE hookup: `packages/ui/src/hooks/useEventStream.ts` - SSE hookup: `packages/ui/src/hooks/useEventStream.ts`
- Web server embeds/starts OpenCode server: `packages/web/server/index.js` (`createOpencodeServer`) - Web server embeds/starts OpenCode server: `packages/web/server/index.js` (`createOpencodeServer`)
- Web runtime filesystem endpoints: search `packages/web/server/index.js` for `/api/fs/` - Web runtime filesystem endpoints: search `packages/web/server/index.js` for `/api/fs/`
- External server support: Set `OPENCODE_PORT` and `OPENCODE_SKIP_START=true` to connect to existing OpenCode instance - External server support: Set `OPENCODE_HOST` (full base URL, e.g. `http://hostname:4096`) or `OPENCODE_PORT`, plus `OPENCODE_SKIP_START=true`, to connect to existing OpenCode instance
## Key UI patterns (reference files) ## Key UI patterns (reference files)
- Settings shell: `packages/ui/src/components/views/SettingsView.tsx` - Settings shell: `packages/ui/src/components/views/SettingsView.tsx`
+2 -1
View File
@@ -110,7 +110,8 @@ openchamber --ui-password secret # Password-protect UI
openchamber --try-cf-tunnel # Create a Cloudflare Quick Tunnel for remote access openchamber --try-cf-tunnel # Create a Cloudflare Quick Tunnel for remote access
openchamber --try-cf-tunnel --tunnel-qr # Show QR code for easy mobile access openchamber --try-cf-tunnel --tunnel-qr # Show QR code for easy mobile access
openchamber --try-cf-tunnel --tunnel-password-url # Include password in URL for auto-login openchamber --try-cf-tunnel --tunnel-password-url # Include password in URL for auto-login
OPENCODE_PORT=4096 OPENCODE_SKIP_START=true openchamber # Connect to external OpenCode server OPENCODE_PORT=4096 OPENCODE_SKIP_START=true openchamber # Connect to external OpenCode server
OPENCODE_HOST=https://myhost:4096 OPENCODE_SKIP_START=true openchamber # Connect via custom host/HTTPS
openchamber stop # Stop server openchamber stop # Stop server
openchamber update # Update to latest version openchamber update # Update to latest version
``` ```
+4 -2
View File
@@ -31,15 +31,17 @@ openchamber # Start on port 3000
openchamber --port 8080 # Custom port openchamber --port 8080 # Custom port
openchamber --daemon # Background mode openchamber --daemon # Background mode
openchamber --ui-password secret # Password-protect UI openchamber --ui-password secret # Password-protect UI
OPENCODE_PORT=4096 OPENCODE_SKIP_START=true openchamber # Connect to external OpenCode server OPENCODE_PORT=4096 OPENCODE_SKIP_START=true openchamber # Connect to external OpenCode server
OPENCODE_HOST=https://myhost:4096 OPENCODE_SKIP_START=true openchamber # Connect via custom host/HTTPS
openchamber stop # Stop server openchamber stop # Stop server
openchamber update # Update to latest version openchamber update # Update to latest version
``` ```
### Environment Variables ### Environment Variables
- `OPENCODE_HOST` - Full base URL of external OpenCode server, e.g. `http://hostname:4096` or `https://hostname:4096` (overrides `OPENCODE_PORT`)
- `OPENCODE_PORT` - Port of external OpenCode server to connect to (instead of starting embedded server) - `OPENCODE_PORT` - Port of external OpenCode server to connect to (instead of starting embedded server)
- `OPENCODE_SKIP_START` - Skip starting embedded OpenCode server (use with `OPENCODE_PORT` to connect to external instance) - `OPENCODE_SKIP_START` - Skip starting embedded OpenCode server (use with `OPENCODE_HOST` or `OPENCODE_PORT` to connect to external instance)
## Prerequisites ## Prerequisites
+1
View File
@@ -177,6 +177,7 @@ OPTIONS:
ENVIRONMENT: ENVIRONMENT:
OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag
OPENCODE_HOST External OpenCode server base URL, e.g. http://hostname:4096 (overrides OPENCODE_PORT)
OPENCODE_PORT Port of external OpenCode server to connect to OPENCODE_PORT Port of external OpenCode server to connect to
OPENCODE_SKIP_START Skip starting OpenCode, use external server OPENCODE_SKIP_START Skip starting OpenCode, use external server
+55 -14
View File
@@ -2766,6 +2766,7 @@ let openCodeAuthSource =
const syncToHmrState = () => { const syncToHmrState = () => {
hmrState.openCodeProcess = openCodeProcess; hmrState.openCodeProcess = openCodeProcess;
hmrState.openCodePort = openCodePort; hmrState.openCodePort = openCodePort;
hmrState.openCodeBaseUrl = openCodeBaseUrl;
hmrState.isShuttingDown = isShuttingDown; hmrState.isShuttingDown = isShuttingDown;
hmrState.signalsAttached = signalsAttached; hmrState.signalsAttached = signalsAttached;
hmrState.openCodeWorkingDirectory = openCodeWorkingDirectory; hmrState.openCodeWorkingDirectory = openCodeWorkingDirectory;
@@ -2777,6 +2778,7 @@ const syncToHmrState = () => {
const syncFromHmrState = () => { const syncFromHmrState = () => {
openCodeProcess = hmrState.openCodeProcess; openCodeProcess = hmrState.openCodeProcess;
openCodePort = hmrState.openCodePort; openCodePort = hmrState.openCodePort;
openCodeBaseUrl = hmrState.openCodeBaseUrl ?? null;
isShuttingDown = hmrState.isShuttingDown; isShuttingDown = hmrState.isShuttingDown;
signalsAttached = hmrState.signalsAttached; signalsAttached = hmrState.signalsAttached;
openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory; openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory;
@@ -2794,6 +2796,7 @@ const syncFromHmrState = () => {
// These are synced to/from hmrState to survive HMR reloads // These are synced to/from hmrState to survive HMR reloads
let openCodeProcess = hmrState.openCodeProcess; let openCodeProcess = hmrState.openCodeProcess;
let openCodePort = hmrState.openCodePort; let openCodePort = hmrState.openCodePort;
let openCodeBaseUrl = hmrState.openCodeBaseUrl ?? null;
let isShuttingDown = hmrState.isShuttingDown; let isShuttingDown = hmrState.isShuttingDown;
let signalsAttached = hmrState.signalsAttached; let signalsAttached = hmrState.signalsAttached;
let openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory; let openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory;
@@ -2825,7 +2828,7 @@ async function isOpenCodeProcessHealthy() {
* Unlike isOpenCodeProcessHealthy(), this doesn't require openCodeProcess to be set. * Unlike isOpenCodeProcessHealthy(), this doesn't require openCodeProcess to be set.
* Used to auto-detect and connect to an existing OpenCode instance on startup. * Used to auto-detect and connect to an existing OpenCode instance on startup.
*/ */
async function probeExternalOpenCode(port) { async function probeExternalOpenCode(port, origin) {
if (!port || port <= 0) { if (!port || port <= 0) {
return false; return false;
} }
@@ -2833,7 +2836,8 @@ async function probeExternalOpenCode(port) {
try { try {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000); const timeout = setTimeout(() => controller.abort(), 3000);
const response = await fetch(`http://127.0.0.1:${port}/global/health`, { const base = origin ?? `http://127.0.0.1:${port}`;
const response = await fetch(`${base}/global/health`, {
method: 'GET', method: 'GET',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@@ -2862,6 +2866,37 @@ const ENV_CONFIGURED_OPENCODE_PORT = (() => {
return Number.isFinite(parsed) && parsed > 0 ? parsed : null; return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
})(); })();
const ENV_CONFIGURED_OPENCODE_HOST = (() => {
const raw = process.env.OPENCODE_HOST?.trim();
if (!raw) return null;
let url;
try {
url = new URL(raw);
} catch {
console.error(`[fatal] OPENCODE_HOST is not a valid URL: ${JSON.stringify(raw)}`);
process.exit(1);
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
console.error(`[fatal] OPENCODE_HOST must use http or https scheme, got: ${JSON.stringify(url.protocol)}`);
process.exit(1);
}
const port = parseInt(url.port, 10);
if (!Number.isFinite(port) || port <= 0) {
console.error(`[fatal] OPENCODE_HOST must include an explicit port (e.g. http://hostname:4096), got: ${JSON.stringify(raw)}`);
process.exit(1);
}
if (url.pathname !== '/' || url.search || url.hash) {
console.error(
`[fatal] OPENCODE_HOST must not include a path, query, or hash; got: ${JSON.stringify(raw)}`
);
process.exit(1);
}
return { origin: url.origin, port };
})();
// OPENCODE_HOST takes precedence over OPENCODE_PORT when both are set
const ENV_EFFECTIVE_PORT = ENV_CONFIGURED_OPENCODE_HOST?.port ?? ENV_CONFIGURED_OPENCODE_PORT;
const ENV_SKIP_OPENCODE_START = process.env.OPENCODE_SKIP_START === 'true' || const ENV_SKIP_OPENCODE_START = process.env.OPENCODE_SKIP_START === 'true' ||
process.env.OPENCHAMBER_SKIP_OPENCODE_START === 'true'; process.env.OPENCHAMBER_SKIP_OPENCODE_START === 'true';
const ENV_DESKTOP_NOTIFY = process.env.OPENCHAMBER_DESKTOP_NOTIFY === 'true'; const ENV_DESKTOP_NOTIFY = process.env.OPENCHAMBER_DESKTOP_NOTIFY === 'true';
@@ -3834,7 +3869,8 @@ function buildOpenCodeUrl(path, prefixOverride) {
const normalizedPath = path.startsWith('/') ? path : `/${path}`; const normalizedPath = path.startsWith('/') ? path : `/${path}`;
const prefix = normalizeApiPrefix(prefixOverride !== undefined ? prefixOverride : ''); const prefix = normalizeApiPrefix(prefixOverride !== undefined ? prefixOverride : '');
const fullPath = `${prefix}${normalizedPath}`; const fullPath = `${prefix}${normalizedPath}`;
return `http://localhost:${openCodePort}${fullPath}`; const base = openCodeBaseUrl ?? `http://localhost:${openCodePort}`;
return `${base}${fullPath}`;
} }
function parseSseDataPayload(block) { function parseSseDataPayload(block) {
@@ -4820,7 +4856,8 @@ async function restartOpenCode() {
if (isExternalOpenCode) { if (isExternalOpenCode) {
console.log('Re-probing external OpenCode server...'); console.log('Re-probing external OpenCode server...');
const probePort = openCodePort || ENV_CONFIGURED_OPENCODE_PORT || 4096; const probePort = openCodePort || ENV_CONFIGURED_OPENCODE_PORT || 4096;
const healthy = await probeExternalOpenCode(probePort); const probeOrigin = openCodeBaseUrl ?? ENV_CONFIGURED_OPENCODE_HOST?.origin;
const healthy = await probeExternalOpenCode(probePort, probeOrigin);
if (healthy) { if (healthy) {
console.log(`External OpenCode server on port ${probePort} is healthy`); console.log(`External OpenCode server on port ${probePort} is healthy`);
setOpenCodePort(probePort); setOpenCodePort(probePort);
@@ -11415,23 +11452,27 @@ async function main(options = {}) {
syncFromHmrState(); syncFromHmrState();
if (await isOpenCodeProcessHealthy()) { if (await isOpenCodeProcessHealthy()) {
console.log(`[HMR] Reusing existing OpenCode process on port ${openCodePort}`); console.log(`[HMR] Reusing existing OpenCode process on port ${openCodePort}`);
} else if (ENV_SKIP_OPENCODE_START && ENV_CONFIGURED_OPENCODE_PORT) { } else if (ENV_SKIP_OPENCODE_START && ENV_EFFECTIVE_PORT) {
console.log(`Using external OpenCode server on port ${ENV_CONFIGURED_OPENCODE_PORT} (skip-start mode)`); const label = ENV_CONFIGURED_OPENCODE_HOST ? ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${ENV_EFFECTIVE_PORT}`;
setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT); console.log(`Using external OpenCode server at ${label} (skip-start mode)`);
openCodeBaseUrl = ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null;
setOpenCodePort(ENV_EFFECTIVE_PORT);
isOpenCodeReady = true; isOpenCodeReady = true;
isExternalOpenCode = true; isExternalOpenCode = true;
lastOpenCodeError = null; lastOpenCodeError = null;
openCodeNotReadySince = 0; openCodeNotReadySince = 0;
syncToHmrState(); syncToHmrState();
} else if (ENV_CONFIGURED_OPENCODE_PORT && await probeExternalOpenCode(ENV_CONFIGURED_OPENCODE_PORT)) { } else if (ENV_EFFECTIVE_PORT && await probeExternalOpenCode(ENV_EFFECTIVE_PORT, ENV_CONFIGURED_OPENCODE_HOST?.origin)) {
console.log(`Auto-detected existing OpenCode server on port ${ENV_CONFIGURED_OPENCODE_PORT}`); const label = ENV_CONFIGURED_OPENCODE_HOST ? ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${ENV_EFFECTIVE_PORT}`;
setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT); console.log(`Auto-detected existing OpenCode server at ${label}`);
openCodeBaseUrl = ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null;
setOpenCodePort(ENV_EFFECTIVE_PORT);
isOpenCodeReady = true; isOpenCodeReady = true;
isExternalOpenCode = true; isExternalOpenCode = true;
lastOpenCodeError = null; lastOpenCodeError = null;
openCodeNotReadySince = 0; openCodeNotReadySince = 0;
syncToHmrState(); syncToHmrState();
} else if (!ENV_CONFIGURED_OPENCODE_PORT && await probeExternalOpenCode(4096)) { } else if (!ENV_EFFECTIVE_PORT && await probeExternalOpenCode(4096)) {
console.log('Auto-detected existing OpenCode server on default port 4096'); console.log('Auto-detected existing OpenCode server on default port 4096');
setOpenCodePort(4096); setOpenCodePort(4096);
isOpenCodeReady = true; isOpenCodeReady = true;
@@ -11440,9 +11481,9 @@ async function main(options = {}) {
openCodeNotReadySince = 0; openCodeNotReadySince = 0;
syncToHmrState(); syncToHmrState();
} else { } else {
if (ENV_CONFIGURED_OPENCODE_PORT) { if (ENV_EFFECTIVE_PORT) {
console.log(`Using OpenCode port from environment: ${ENV_CONFIGURED_OPENCODE_PORT}`); console.log(`Using OpenCode port from environment: ${ENV_EFFECTIVE_PORT}`);
setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT); setOpenCodePort(ENV_EFFECTIVE_PORT);
} else { } else {
openCodePort = null; openCodePort = null;
syncToHmrState(); syncToHmrState();