diff --git a/AGENTS.md b/AGENTS.md index 6c5f32f9..ad53c1ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` diff --git a/README.md b/README.md index ff16044a..7a1dda76 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/packages/web/README.md b/packages/web/README.md index 5a9a057e..8f3b089b 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -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 diff --git a/packages/web/bin/cli.js b/packages/web/bin/cli.js index 34396026..fbc9a97f 100755 --- a/packages/web/bin/cli.js +++ b/packages/web/bin/cli.js @@ -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 diff --git a/packages/web/server/index.js b/packages/web/server/index.js index c5ee62e5..bec3b697 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -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();