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`
- 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/`
- 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)
- 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 --tunnel-qr # Show QR code for easy mobile access
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 update # Update to latest version
```
+4 -2
View File
@@ -31,15 +31,17 @@ openchamber # Start on port 3000
openchamber --port 8080 # Custom port
openchamber --daemon # Background mode
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 update # Update to latest version
```
### 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_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
+1
View File
@@ -177,6 +177,7 @@ OPTIONS:
ENVIRONMENT:
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_SKIP_START Skip starting OpenCode, use external server
+55 -14
View File
@@ -2766,6 +2766,7 @@ let openCodeAuthSource =
const syncToHmrState = () => {
hmrState.openCodeProcess = openCodeProcess;
hmrState.openCodePort = openCodePort;
hmrState.openCodeBaseUrl = openCodeBaseUrl;
hmrState.isShuttingDown = isShuttingDown;
hmrState.signalsAttached = signalsAttached;
hmrState.openCodeWorkingDirectory = openCodeWorkingDirectory;
@@ -2777,6 +2778,7 @@ const syncToHmrState = () => {
const syncFromHmrState = () => {
openCodeProcess = hmrState.openCodeProcess;
openCodePort = hmrState.openCodePort;
openCodeBaseUrl = hmrState.openCodeBaseUrl ?? null;
isShuttingDown = hmrState.isShuttingDown;
signalsAttached = hmrState.signalsAttached;
openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory;
@@ -2794,6 +2796,7 @@ const syncFromHmrState = () => {
// These are synced to/from hmrState to survive HMR reloads
let openCodeProcess = hmrState.openCodeProcess;
let openCodePort = hmrState.openCodePort;
let openCodeBaseUrl = hmrState.openCodeBaseUrl ?? null;
let isShuttingDown = hmrState.isShuttingDown;
let signalsAttached = hmrState.signalsAttached;
let openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory;
@@ -2825,7 +2828,7 @@ async function isOpenCodeProcessHealthy() {
* Unlike isOpenCodeProcessHealthy(), this doesn't require openCodeProcess to be set.
* 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) {
return false;
}
@@ -2833,7 +2836,8 @@ async function probeExternalOpenCode(port) {
try {
const controller = new AbortController();
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',
headers: {
Accept: 'application/json',
@@ -2862,6 +2866,37 @@ const ENV_CONFIGURED_OPENCODE_PORT = (() => {
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' ||
process.env.OPENCHAMBER_SKIP_OPENCODE_START === '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 prefix = normalizeApiPrefix(prefixOverride !== undefined ? prefixOverride : '');
const fullPath = `${prefix}${normalizedPath}`;
return `http://localhost:${openCodePort}${fullPath}`;
const base = openCodeBaseUrl ?? `http://localhost:${openCodePort}`;
return `${base}${fullPath}`;
}
function parseSseDataPayload(block) {
@@ -4820,7 +4856,8 @@ async function restartOpenCode() {
if (isExternalOpenCode) {
console.log('Re-probing external OpenCode server...');
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) {
console.log(`External OpenCode server on port ${probePort} is healthy`);
setOpenCodePort(probePort);
@@ -11415,23 +11452,27 @@ async function main(options = {}) {
syncFromHmrState();
if (await isOpenCodeProcessHealthy()) {
console.log(`[HMR] Reusing existing OpenCode process on port ${openCodePort}`);
} else if (ENV_SKIP_OPENCODE_START && ENV_CONFIGURED_OPENCODE_PORT) {
console.log(`Using external OpenCode server on port ${ENV_CONFIGURED_OPENCODE_PORT} (skip-start mode)`);
setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT);
} else if (ENV_SKIP_OPENCODE_START && ENV_EFFECTIVE_PORT) {
const label = ENV_CONFIGURED_OPENCODE_HOST ? ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${ENV_EFFECTIVE_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;
isExternalOpenCode = true;
lastOpenCodeError = null;
openCodeNotReadySince = 0;
syncToHmrState();
} else if (ENV_CONFIGURED_OPENCODE_PORT && await probeExternalOpenCode(ENV_CONFIGURED_OPENCODE_PORT)) {
console.log(`Auto-detected existing OpenCode server on port ${ENV_CONFIGURED_OPENCODE_PORT}`);
setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT);
} else if (ENV_EFFECTIVE_PORT && await probeExternalOpenCode(ENV_EFFECTIVE_PORT, ENV_CONFIGURED_OPENCODE_HOST?.origin)) {
const label = ENV_CONFIGURED_OPENCODE_HOST ? ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${ENV_EFFECTIVE_PORT}`;
console.log(`Auto-detected existing OpenCode server at ${label}`);
openCodeBaseUrl = ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null;
setOpenCodePort(ENV_EFFECTIVE_PORT);
isOpenCodeReady = true;
isExternalOpenCode = true;
lastOpenCodeError = null;
openCodeNotReadySince = 0;
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');
setOpenCodePort(4096);
isOpenCodeReady = true;
@@ -11440,9 +11481,9 @@ async function main(options = {}) {
openCodeNotReadySince = 0;
syncToHmrState();
} else {
if (ENV_CONFIGURED_OPENCODE_PORT) {
console.log(`Using OpenCode port from environment: ${ENV_CONFIGURED_OPENCODE_PORT}`);
setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT);
if (ENV_EFFECTIVE_PORT) {
console.log(`Using OpenCode port from environment: ${ENV_EFFECTIVE_PORT}`);
setOpenCodePort(ENV_EFFECTIVE_PORT);
} else {
openCodePort = null;
syncToHmrState();