fix(managed-runtime): secure auth and lifecycle control across runtimes (#437)

* feat: add OpenCode server authentication with auto-generated passwords

* fix(auth): separate user env and managed OpenCode password state

* fix(auth): enforce env precedence and managed password rotation across runtimes

* fix(vscode): rotate managed auth on startup and harden webview proxy

* build: add dev icons and config for Tauri desktop development

* fix(runtime): start managed OpenCode via CLI and expose active API port

* fix(managed-runtime): control OpenCode lifecycle and surface secure diagnostics

* docs: remove VS Code plugin test runbook
This commit is contained in:
Iuliia Ivashko
2026-02-17 18:01:57 +02:00
committed by GitHub
parent 58b27fa621
commit 138772e66e
22 changed files with 717 additions and 88 deletions
+10 -1
View File
@@ -18,7 +18,16 @@ function spawnProcess(command, args, opts = {}) {
} }
async function main() { async function main() {
const tauriProcess = spawnProcess('bun', ['--cwd', desktopDir, 'tauri', 'dev', '--features', 'devtools']); const tauriProcess = spawnProcess('bun', [
'--cwd',
desktopDir,
'tauri',
'dev',
'--features',
'devtools',
'--config',
'./src-tauri/tauri.dev.conf.json',
]);
let cleaning = false; let cleaning = false;
+2
View File
@@ -1187,6 +1187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink",
] ]
[[package]] [[package]]
@@ -3411,6 +3412,7 @@ checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
"futures-channel",
"futures-core", "futures-core",
"futures-util", "futures-util",
"http", "http",
+1 -1
View File
@@ -16,7 +16,7 @@ devtools = ["tauri/devtools"]
anyhow = "1.0.86" anyhow = "1.0.86"
base64 = "0.22.1" base64 = "0.22.1"
log = "0.4.28" log = "0.4.28"
reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls"] } reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls", "blocking"] }
serde = { version = "1.0.210", features = ["derive"] } serde = { version = "1.0.210", features = ["derive"] }
serde_json = "1.0.143" serde_json = "1.0.143"
tauri = { version = "2.9.4", features = ["macos-private-api"] } tauri = { version = "2.9.4", features = ["macos-private-api"] }
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

+25
View File
@@ -1476,10 +1476,27 @@ fn kill_sidecar(app: tauri::AppHandle) {
return; return;
}; };
let sidecar_url = state.url.lock().expect("sidecar url mutex").clone();
if let Some(url) = sidecar_url {
let shutdown_url = format!("{}/api/system/shutdown", url.trim_end_matches('/'));
if let Ok(client) = reqwest::blocking::Client::builder()
.no_proxy()
.timeout(Duration::from_millis(1500))
.build()
{
if let Ok(resp) = client.post(shutdown_url).send() {
if resp.status().is_success() {
std::thread::sleep(Duration::from_millis(100));
}
}
}
}
let mut guard = state.child.lock().expect("sidecar mutex"); let mut guard = state.child.lock().expect("sidecar mutex");
if let Some(child) = guard.take() { if let Some(child) = guard.take() {
let _ = child.kill(); let _ = child.kill();
} }
*state.url.lock().expect("sidecar url mutex") = None;
} }
fn build_local_url(port: u16) -> String { fn build_local_url(port: u16) -> String {
@@ -1627,6 +1644,7 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result<String> {
.args(["--port", &port.to_string()]) .args(["--port", &port.to_string()])
.env("OPENCHAMBER_HOST", "127.0.0.1") .env("OPENCHAMBER_HOST", "127.0.0.1")
.env("OPENCHAMBER_DIST_DIR", dist_dir.clone()) .env("OPENCHAMBER_DIST_DIR", dist_dir.clone())
.env("OPENCHAMBER_RUNTIME", "desktop")
.env("OPENCHAMBER_DESKTOP_NOTIFY", "true") .env("OPENCHAMBER_DESKTOP_NOTIFY", "true")
.env("PATH", augmented_path.clone()) .env("PATH", augmented_path.clone())
.env("NO_PROXY", no_proxy) .env("NO_PROXY", no_proxy)
@@ -1643,6 +1661,13 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result<String> {
} }
} }
if let Ok(password) = env::var("OPENCODE_SERVER_PASSWORD") {
let trimmed = password.trim();
if !trimmed.is_empty() {
cmd = cmd.env("OPENCODE_SERVER_PASSWORD", trimmed);
}
}
let (rx, child) = match cmd.spawn() { let (rx, child) = match cmd.spawn() {
Ok(v) => v, Ok(v) => v,
Err(err) => { Err(err) => {
@@ -0,0 +1,9 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"bundle": {
"icon": [
"icons/dev-icon.icns",
"icons/dev-icon.png"
]
}
}
@@ -91,6 +91,19 @@ export const VSCodeLayout: React.FC = () => {
return sessions.find((session) => session.id === currentSessionId)?.title || 'Session'; return sessions.find((session) => session.id === currentSessionId)?.title || 'Session';
}, [currentSessionId, sessions]); }, [currentSessionId, sessions]);
const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open)); const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open));
const isSyncingMessages = useSessionStore((state) => state.isSyncing);
const hasActiveSessionWork = useSessionStore((state) => {
const statuses = state.sessionStatus;
if (!statuses || statuses.size === 0) {
return false;
}
for (const status of statuses.values()) {
if (status?.type === 'busy' || status?.type === 'retry') {
return true;
}
}
return false;
});
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
const [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>( const [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>(
() => (typeof window !== 'undefined' () => (typeof window !== 'undefined'
@@ -129,10 +142,37 @@ export const VSCodeLayout: React.FC = () => {
return; return;
} }
if (!currentSessionId && !newSessionDraftOpen && currentView === 'chat') { if (currentView !== 'chat') {
setCurrentView('sessions'); return;
} }
}, [currentSessionId, newSessionDraftOpen, currentView, viewMode]);
if (currentSessionId || newSessionDraftOpen || isSyncingMessages || hasActiveSessionWork) {
return;
}
const timeoutId = window.setTimeout(() => {
const state = useSessionStore.getState();
const stillNoSession = !state.currentSessionId;
const draftStillClosed = !state.newSessionDraft?.open;
const stillSyncing = state.isSyncing;
const stillActiveWork = (() => {
const statuses = state.sessionStatus;
if (!statuses || statuses.size === 0) return false;
for (const status of statuses.values()) {
if (status?.type === 'busy' || status?.type === 'retry') return true;
}
return false;
})();
if (stillNoSession && draftStillClosed && !stillSyncing && !stillActiveWork) {
setCurrentView('sessions');
}
}, 900);
return () => {
window.clearTimeout(timeoutId);
};
}, [currentSessionId, newSessionDraftOpen, currentView, viewMode, isSyncingMessages, hasActiveSessionWork]);
const handleBackToSessions = React.useCallback(() => { const handleBackToSessions = React.useCallback(() => {
setCurrentView('sessions'); setCurrentView('sessions');
+1 -1
View File
@@ -486,7 +486,7 @@ export const useEventStream = () => {
// Note: needs_attention logic is now handled by the server // Note: needs_attention logic is now handled by the server
// Server maintains authoritative state based on view tracking and message events // Server maintains authoritative state based on view tracking and message events
if (prevType !== nextType) { if (process.env.NODE_ENV === 'development' && prevType !== nextType) {
try { try {
console.info('[SESSION-STATUS]', { console.info('[SESSION-STATUS]', {
sessionId, sessionId,
+19 -1
View File
@@ -264,11 +264,29 @@ export const debugUtils = {
const resp = await fetch('/api/health'); const resp = await fetch('/api/health');
const contentType = resp.headers.get('content-type') || ''; const contentType = resp.headers.get('content-type') || '';
const body = await safeText(resp); const body = await safeText(resp);
const isJson = contentType.toLowerCase().includes('application/json');
let parsed: Record<string, unknown> | null = null;
if (isJson && body) {
try {
const candidate = JSON.parse(body) as unknown;
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
parsed = candidate as Record<string, unknown>;
}
} catch {
parsed = null;
}
}
opencodeHealth = { opencodeHealth = {
status: resp.status, status: resp.status,
ok: resp.ok, ok: resp.ok,
contentType, contentType,
type: contentType.includes('application/json') ? 'json' : 'html', type: isJson ? 'json' : 'html',
openCodePort: parsed?.openCodePort ?? null,
openCodeRunning: parsed?.openCodeRunning ?? null,
openCodeSecureConnection: parsed?.openCodeSecureConnection ?? null,
openCodeAuthSource: parsed?.openCodeAuthSource ?? null,
isOpenCodeReady: parsed?.isOpenCodeReady ?? null,
lastOpenCodeError: parsed?.lastOpenCodeError ?? null,
preview: body ? body.slice(0, 120) : null, preview: body ? body.slice(0, 120) : null,
}; };
} catch (error) { } catch (error) {
+27
View File
@@ -13,6 +13,8 @@ type ProbeResult = {
type OpenChamberHealthSnapshot = { type OpenChamberHealthSnapshot = {
openCodePort?: unknown; openCodePort?: unknown;
openCodeRunning?: unknown; openCodeRunning?: unknown;
openCodeSecureConnection?: unknown;
openCodeAuthSource?: unknown;
isOpenCodeReady?: unknown; isOpenCodeReady?: unknown;
lastOpenCodeError?: unknown; lastOpenCodeError?: unknown;
opencodeBinaryResolved?: unknown; opencodeBinaryResolved?: unknown;
@@ -100,6 +102,19 @@ const formatIso = (timestamp: number | null | undefined): string => {
} }
}; };
const normalizePort = (value: unknown): number | null => {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return Math.trunc(value);
}
if (typeof value === 'string') {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return null;
};
export const buildOpenCodeStatusReport = async (): Promise<string> => { export const buildOpenCodeStatusReport = async (): Promise<string> => {
const now = new Date(); const now = new Date();
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '(unknown)'; const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '(unknown)';
@@ -215,6 +230,18 @@ export const buildOpenCodeStatusReport = async (): Promise<string> => {
lines.push(`Directory: ${directory || '(none)'}`); lines.push(`Directory: ${directory || '(none)'}`);
lines.push(`Platform: ${platform}`); lines.push(`Platform: ${platform}`);
const runtimeOpenCodePort = normalizePort(openChamberHealth?.openCodePort);
lines.push(`OpenCode runtime port: ${runtimeOpenCodePort ?? '(unknown)'}`);
if (typeof openChamberHealth?.openCodeRunning === 'boolean') {
lines.push(`OpenCode runtime running: ${openChamberHealth.openCodeRunning ? 'yes' : 'no'}`);
}
if (typeof openChamberHealth?.openCodeSecureConnection === 'boolean') {
lines.push(`Secure OpenCode connection: ${openChamberHealth.openCodeSecureConnection ? 'true' : 'false'}`);
}
if (typeof openChamberHealth?.openCodeAuthSource === 'string' && openChamberHealth.openCodeAuthSource.trim()) {
lines.push(`OpenCode auth source: ${openChamberHealth.openCodeAuthSource}`);
}
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const injected = (window as unknown as { __OPENCHAMBER_MACOS_MAJOR__?: unknown }).__OPENCHAMBER_MACOS_MAJOR__; const injected = (window as unknown as { __OPENCHAMBER_MACOS_MAJOR__?: unknown }).__OPENCHAMBER_MACOS_MAJOR__;
if (typeof injected === 'number' && Number.isFinite(injected) && injected > 0) { if (typeof injected === 'number' && Number.isFinite(injected) && injected > 0) {
+8 -6
View File
@@ -1707,12 +1707,14 @@ export const useMessageStore = create<MessageStore>()(
}; };
if (messageIndex === -1) { if (messageIndex === -1) {
console.info("[MESSAGE-DEBUG] updateMessageInfo: messageIndex === -1", { if (process.env.NODE_ENV === 'development') {
sessionId, console.info("[MESSAGE-DEBUG] updateMessageInfo: messageIndex === -1", {
messageId, sessionId,
messageInfo, messageId,
existingCount: normalizedSessionMessages.length, messageInfo,
}); existingCount: normalizedSessionMessages.length,
});
}
if (normalizedSessionMessages.length > 0) { if (normalizedSessionMessages.length > 0) {
const firstMessage = normalizedSessionMessages[0]; const firstMessage = normalizedSessionMessages[0];
@@ -176,7 +176,10 @@ export class AgentManagerPanelProvider {
let response: Response; let response: Response;
let wrapAsGlobal = false; let wrapAsGlobal = false;
const requestHeaders = this._buildSseHeaders(headers || {}); const requestHeaders = this._buildSseHeaders({
...(headers || {}),
...(this._openCodeManager?.getOpenCodeAuthHeaders() || {}),
});
try { try {
response = await fetch(targetUrl, { response = await fetch(targetUrl, {
+4 -1
View File
@@ -204,7 +204,10 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
let response: Response; let response: Response;
let wrapAsGlobal = false; let wrapAsGlobal = false;
const requestHeaders = this._buildSseHeaders(headers || {}); const requestHeaders = this._buildSseHeaders({
...(headers || {}),
...(this._openCodeManager?.getOpenCodeAuthHeaders() || {}),
});
try { try {
response = await fetch(targetUrl, { response = await fetch(targetUrl, {
@@ -199,7 +199,10 @@ export class SessionEditorPanelProvider {
let response: Response; let response: Response;
let wrapAsGlobal = false; let wrapAsGlobal = false;
const requestHeaders = this._buildSseHeaders(headers || {}); const requestHeaders = this._buildSseHeaders({
...(headers || {}),
...(this._openCodeManager?.getOpenCodeAuthHeaders() || {}),
});
try { try {
response = await fetch(targetUrl, { response = await fetch(targetUrl, {
+8 -2
View File
@@ -806,7 +806,10 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
const base = `${apiUrl.replace(/\/+$/, '')}/`; const base = `${apiUrl.replace(/\/+$/, '')}/`;
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString(); const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
const requestHeaders: Record<string, string> = sanitizeForwardHeaders(headers); const requestHeaders: Record<string, string> = {
...sanitizeForwardHeaders(headers),
...ctx?.manager?.getOpenCodeAuthHeaders(),
};
// Ensure SSE requests are negotiated correctly. // Ensure SSE requests are negotiated correctly.
if (normalizedPath === '/event' || normalizedPath === '/global/event') { if (normalizedPath === '/event' || normalizedPath === '/global/event') {
@@ -875,7 +878,10 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
const base = `${apiUrl.replace(/\/+$/, '')}/`; const base = `${apiUrl.replace(/\/+$/, '')}/`;
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString(); const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
const requestHeaders: Record<string, string> = sanitizeForwardHeaders(headers); const requestHeaders: Record<string, string> = {
...sanitizeForwardHeaders(headers),
...ctx?.manager?.getOpenCodeAuthHeaders(),
};
try { try {
const response = await fetch(targetUrl, { const response = await fetch(targetUrl, {
+9 -2
View File
@@ -362,10 +362,11 @@ export async function activate(context: vscode.ExtensionContext) {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs); const timeout = setTimeout(() => controller.abort(), timeoutMs);
const startedAt = Date.now(); const startedAt = Date.now();
const openCodeAuthHeaders = openCodeManager?.getOpenCodeAuthHeaders() || {};
try { try {
const resp = await fetch(input, { const resp = await fetch(input, {
method: 'GET', method: 'GET',
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json', ...openCodeAuthHeaders },
signal: controller.signal, signal: controller.signal,
}); });
const elapsedMs = Date.now() - startedAt; const elapsedMs = Date.now() - startedAt;
@@ -465,7 +466,13 @@ export async function activate(context: vscode.ExtensionContext) {
? `OpenCode mode: ${debug.mode} (starts=${debug.startCount}, restarts=${debug.restartCount})` ? `OpenCode mode: ${debug.mode} (starts=${debug.startCount}, restarts=${debug.restartCount})`
: `OpenCode mode: (unknown)`, : `OpenCode mode: (unknown)`,
debug debug
? `OpenCode CLI path: ${debug.cliPath || '(not found - SDK manages process)'}` ? `Secure OpenCode connection: ${debug.secureConnection ? 'true' : 'false'}`
: `Secure OpenCode connection: (unknown)`,
debug
? `OpenCode auth source: ${debug.authSource ?? '(none)'}`
: `OpenCode auth source: (unknown)`,
debug
? `OpenCode CLI path: ${debug.cliPath || '(not found)'}`
: `OpenCode CLI path: (unknown)`, : `OpenCode CLI path: (unknown)`,
debug debug
? `OpenCode detected port: ${debug.detectedPort ?? '(none)'}` ? `OpenCode detected port: ${debug.detectedPort ?? '(none)'}`
+236 -46
View File
@@ -2,12 +2,13 @@ import * as vscode from 'vscode';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs'; import * as fs from 'fs';
import * as net from 'net';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import { spawnSync } from 'child_process'; import { spawnSync } from 'child_process';
import { createOpencodeServer } from '@opencode-ai/sdk/v2/server'; import { spawn } from 'child_process';
import { randomBytes } from 'crypto';
const READY_CHECK_TIMEOUT_MS = 30000; const READY_CHECK_TIMEOUT_MS = 30000;
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
export type OpenCodeDebugInfo = { export type OpenCodeDebugInfo = {
@@ -32,6 +33,8 @@ export type OpenCodeDebugInfo = {
lastReadyAttempts: number | null; lastReadyAttempts: number | null;
lastStartAttempts: number | null; lastStartAttempts: number | null;
version: string | null; version: string | null;
secureConnection: boolean;
authSource: 'user-env' | 'generated' | 'rotated' | null;
}; };
export interface OpenCodeManager { export interface OpenCodeManager {
@@ -41,12 +44,43 @@ export interface OpenCodeManager {
setWorkingDirectory(path: string): Promise<{ success: boolean; restarted: boolean; path: string }>; setWorkingDirectory(path: string): Promise<{ success: boolean; restarted: boolean; path: string }>;
getStatus(): ConnectionStatus; getStatus(): ConnectionStatus;
getApiUrl(): string | null; getApiUrl(): string | null;
getOpenCodeAuthHeaders(): Record<string, string>;
getWorkingDirectory(): string; getWorkingDirectory(): string;
isCliAvailable(): boolean; isCliAvailable(): boolean;
getDebugInfo(): OpenCodeDebugInfo; getDebugInfo(): OpenCodeDebugInfo;
onStatusChange(callback: (status: ConnectionStatus, error?: string) => void): vscode.Disposable; onStatusChange(callback: (status: ConnectionStatus, error?: string) => void): vscode.Disposable;
} }
function generateSecureOpenCodePassword(): string {
return randomBytes(32)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
function buildOpenCodeAuthHeader(password: string): string {
return `Basic ${Buffer.from(`opencode:${password}`, 'utf8').toString('base64')}`;
}
function isValidOpenCodePassword(password: string): boolean {
return typeof password === 'string' && password.trim().length > 0;
}
function readOpenChamberSettings(): Record<string, unknown> {
const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
try {
const raw = fs.readFileSync(settingsPath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
return {};
} catch {
return {};
}
}
function resolvePortFromUrl(url: string): number | null { function resolvePortFromUrl(url: string): number | null {
try { try {
const parsed = new URL(url); const parsed = new URL(url);
@@ -110,13 +144,8 @@ function resolveOpencodeCliPath(): string | null {
const sharedFromOpenChamber = (() => { const sharedFromOpenChamber = (() => {
try { try {
const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json'); const settings = readOpenChamberSettings();
const raw = fs.readFileSync(settingsPath, 'utf8'); const candidate = settings.opencodeBinary;
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null;
}
const candidate = (parsed as Record<string, unknown>).opencodeBinary;
if (typeof candidate !== 'string') { if (typeof candidate !== 'string') {
return null; return null;
} }
@@ -258,7 +287,11 @@ function getCandidateBaseUrls(serverUrl: string): string[] {
} }
} }
async function waitForReady(serverUrl: string, timeoutMs = 15000): Promise<ReadyResult> { async function waitForReady(
serverUrl: string,
timeoutMs = 15000,
authHeaders: Record<string, string> = {}
): Promise<ReadyResult> {
const outputChannel = vscode.window.createOutputChannel('OpenChamberManager'); const outputChannel = vscode.window.createOutputChannel('OpenChamberManager');
const start = Date.now(); const start = Date.now();
const candidates = getCandidateBaseUrls(serverUrl); const candidates = getCandidateBaseUrls(serverUrl);
@@ -275,7 +308,7 @@ async function waitForReady(serverUrl: string, timeoutMs = 15000): Promise<Ready
const url = new URL(`${baseUrl}/global/health`); const url = new URL(`${baseUrl}/global/health`);
const res = await fetch(url.toString(), { const res = await fetch(url.toString(), {
method: 'GET', method: 'GET',
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json', ...authHeaders },
signal: controller.signal, signal: controller.signal,
}); });
@@ -305,11 +338,121 @@ async function waitForReady(serverUrl: string, timeoutMs = 15000): Promise<Ready
return { ok: false, elapsedMs: Date.now() - start, attempts, version: null }; return { ok: false, elapsedMs: Date.now() - start, attempts, version: null };
} }
async function spawnManagedOpenCodeServer(
workingDirectory: string,
port: number,
timeoutMs: number
): Promise<{ url: string; close: () => void }> {
const binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
const args = ['serve', '--hostname', '127.0.0.1', '--port', String(port)];
const child = spawn(binary, args, {
cwd: workingDirectory,
env: { ...process.env },
stdio: ['ignore', 'pipe', 'pipe'],
});
const url = await new Promise<string>((resolve, reject) => {
let output = '';
let settled = false;
const cleanup = () => {
if (settled) return;
settled = true;
clearTimeout(timer);
child.stdout?.off('data', onStdout);
child.stderr?.off('data', onStderr);
child.off('exit', onExit);
child.off('error', onError);
};
const onStdout = (chunk: Buffer) => {
output += chunk.toString();
const lines = output.split('\n');
for (const line of lines) {
if (!line.startsWith('opencode server listening')) continue;
const match = line.match(/on\s+(https?:\/\/[^\s]+)/);
if (!match) {
cleanup();
reject(new Error(`Failed to parse server url from output: ${line}`));
return;
}
cleanup();
resolve(match[1]);
return;
}
};
const onStderr = (chunk: Buffer) => {
output += chunk.toString();
};
const onExit = (code: number | null) => {
cleanup();
reject(new Error(`OpenCode exited with code ${code}. Output: ${output}`));
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const timer = setTimeout(() => {
cleanup();
reject(new Error(`Timeout waiting for server to start after ${timeoutMs}ms`));
}, timeoutMs);
child.stdout?.on('data', onStdout);
child.stderr?.on('data', onStderr);
child.on('exit', onExit);
child.on('error', onError);
});
return {
url,
close: () => {
try {
child.kill('SIGTERM');
} catch {
// ignore
}
},
};
}
async function allocateManagedOpenCodePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', (error) => {
reject(error);
});
server.once('listening', () => {
const address = server.address();
const port = address && typeof address === 'object' ? address.port : 0;
server.close(() => {
if (port > 0) {
resolve(port);
return;
}
reject(new Error('Failed to allocate OpenCode port'));
});
});
server.listen(0, '127.0.0.1');
});
}
export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCodeManager { export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCodeManager {
// Discard unused parameter - reserved for future use (state persistence, subscriptions)
void _context; void _context;
let server: { url: string; close: () => void } | null = null; let server: { url: string; close: () => void } | null = null;
let managedApiUrlOverride: string | null = null; let managedApiUrlOverride: string | null = null;
let managedPassword: string | null = null;
let managedPasswordSource: 'user-env' | 'generated' | 'rotated' | null = null;
const userProvidedEnvPassword = (() => {
const normalized = (process.env.OPENCODE_SERVER_PASSWORD || '').trim();
return isValidOpenCodePassword(normalized) ? normalized : null;
})();
let status: ConnectionStatus = 'disconnected'; let status: ConnectionStatus = 'disconnected';
let lastError: string | undefined; let lastError: string | undefined;
const listeners = new Set<(status: ConnectionStatus, error?: string) => void>(); const listeners = new Set<(status: ConnectionStatus, error?: string) => void>();
@@ -375,7 +518,48 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
return null; return null;
}; };
async function startInternal(workdir?: string): Promise<void> { const getOpenCodeAuthHeaders = (): Record<string, string> => {
const password = (managedPassword || userProvidedEnvPassword || process.env.OPENCODE_SERVER_PASSWORD || '').trim();
if (!password) {
return {};
}
return { Authorization: buildOpenCodeAuthHeader(password) };
};
const setManagedPasswordState = (
password: string,
source: 'user-env' | 'generated' | 'rotated'
): string => {
const normalized = password.trim();
managedPassword = normalized;
managedPasswordSource = source;
process.env.OPENCODE_SERVER_PASSWORD = normalized;
return normalized;
};
const ensureManagedOpenCodeServerPassword = async ({ rotateManaged = false }: { rotateManaged?: boolean } = {}): Promise<string> => {
if (userProvidedEnvPassword) {
return setManagedPasswordState(userProvidedEnvPassword, 'user-env');
}
if (rotateManaged) {
return setManagedPasswordState(generateSecureOpenCodePassword(), 'rotated');
}
if (managedPassword && isValidOpenCodePassword(managedPassword)) {
return setManagedPasswordState(
managedPassword,
managedPasswordSource || 'generated'
);
}
return setManagedPasswordState(generateSecureOpenCodePassword(), 'generated');
};
async function startInternal(
workdir?: string,
options: { rotateManaged?: boolean } = {}
): Promise<void> {
startCount += 1; startCount += 1;
setStatus('connecting'); setStatus('connecting');
lastStartAt = Date.now(); lastStartAt = Date.now();
@@ -418,18 +602,19 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
process.env.OPENCODE_BINARY = resolvedCli; process.env.OPENCODE_BINARY = resolvedCli;
} }
const password = await ensureManagedOpenCodeServerPassword({
rotateManaged: options.rotateManaged === true,
});
process.env.OPENCODE_SERVER_PASSWORD = password;
// SDK spawns `opencode serve` in current process cwd. // SDK spawns `opencode serve` in current process cwd.
// Some OpenCode endpoints behave differently based on server process cwd, // Some OpenCode endpoints behave differently based on server process cwd,
// so ensure we start it from the workspace directory. // so ensure we start it from the workspace directory.
const originalCwd = process.cwd(); const originalCwd = process.cwd();
try { try {
process.chdir(workingDirectory); process.chdir(workingDirectory);
server = await createOpencodeServer({ const port = await allocateManagedOpenCodePort();
hostname: '127.0.0.1', server = await spawnManagedOpenCodeServer(workingDirectory, port, READY_CHECK_TIMEOUT_MS);
port: 0,
timeout: READY_CHECK_TIMEOUT_MS,
signal: undefined,
});
} finally { } finally {
try { try {
process.chdir(originalCwd); process.chdir(originalCwd);
@@ -440,7 +625,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
if (server && server.url) { if (server && server.url) {
// Validate readiness for the current workspace context. // Validate readiness for the current workspace context.
const ready = await waitForReady(server.url, READY_CHECK_TIMEOUT_MS); const ready = await waitForReady(server.url, READY_CHECK_TIMEOUT_MS, getOpenCodeAuthHeaders());
lastReadyElapsedMs = ready.elapsedMs; lastReadyElapsedMs = ready.elapsedMs;
lastReadyAttempts = ready.attempts; lastReadyAttempts = ready.attempts;
if (ready.ok) { if (ready.ok) {
@@ -496,7 +681,6 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
server = null; server = null;
} }
// SDK's proc.kill() only kills the Node wrapper, not the actual opencode binary.
// Kill any process listening on our port to clean up orphaned children. // Kill any process listening on our port to clean up orphaned children.
if (portToKill) { if (portToKill) {
try { try {
@@ -530,7 +714,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
restartCount += 1; restartCount += 1;
await stopInternal(); await stopInternal();
await new Promise(r => setTimeout(r, 250)); await new Promise(r => setTimeout(r, 250));
await startInternal(); await startInternal(undefined, { rotateManaged: true });
} }
async function start(workdir?: string): Promise<void> { async function start(workdir?: string): Promise<void> {
@@ -541,7 +725,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
} }
} }
lastStartAttempts = 1; lastStartAttempts = 1;
pendingOperation = startInternal(workdir); pendingOperation = startInternal(workdir, { rotateManaged: true });
try { try {
await pendingOperation; await pendingOperation;
} finally { } finally {
@@ -603,31 +787,37 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
setWorkingDirectory, setWorkingDirectory,
getStatus: () => status, getStatus: () => status,
getApiUrl, getApiUrl,
getOpenCodeAuthHeaders,
getWorkingDirectory: () => workingDirectory, getWorkingDirectory: () => workingDirectory,
isCliAvailable: () => !cliMissing, isCliAvailable: () => !cliMissing,
getDebugInfo: () => ({ getDebugInfo: () => {
mode: useConfiguredUrl && configuredApiUrl ? 'external' : 'managed', const secureConnection = Boolean(getOpenCodeAuthHeaders().Authorization);
status, return {
lastError, mode: useConfiguredUrl && configuredApiUrl ? 'external' : 'managed',
workingDirectory, status,
cliAvailable: !cliMissing, lastError,
cliPath, workingDirectory,
configuredApiUrl: useConfiguredUrl && configuredApiUrl ? configuredApiUrl.replace(/\/+$/, '') : null, cliAvailable: !cliMissing,
configuredPort, cliPath,
detectedPort, configuredApiUrl: useConfiguredUrl && configuredApiUrl ? configuredApiUrl.replace(/\/+$/, '') : null,
apiPrefix: '', configuredPort,
apiPrefixDetected: true, detectedPort,
startCount, apiPrefix: '',
restartCount, apiPrefixDetected: true,
lastStartAt, startCount,
lastConnectedAt, restartCount,
lastExitCode, lastStartAt,
serverUrl: getApiUrl(), lastConnectedAt,
lastReadyElapsedMs, lastExitCode,
lastReadyAttempts, serverUrl: getApiUrl(),
lastStartAttempts, lastReadyElapsedMs,
version, lastReadyAttempts,
}), lastStartAttempts,
version,
secureConnection,
authSource: managedPasswordSource || (userProvidedEnvPassword ? 'user-env' : null),
};
},
onStatusChange(callback) { onStatusChange(callback) {
listeners.add(callback); listeners.add(callback);
callback(status, lastError); callback(status, lastError);
@@ -191,11 +191,13 @@ export const startGlobalEventWatcher = async (
} }
const url = buildOpenCodeUrl('/global/event', baseUrl); const url = buildOpenCodeUrl('/global/event', baseUrl);
const authHeaders = manager.getOpenCodeAuthHeaders();
upstream = await fetch(url, { upstream = await fetch(url, {
headers: { headers: {
Accept: 'text/event-stream', Accept: 'text/event-stream',
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
Connection: 'keep-alive', Connection: 'keep-alive',
...authHeaders,
}, },
signal, signal,
}); });
+56 -4
View File
@@ -303,6 +303,19 @@ const decodeBase64 = (value: string): Uint8Array => {
return bytes; return bytes;
}; };
const isNullBodyStatus = (status: number): boolean => status === 204 || status === 205 || status === 304;
const buildProxiedResponse = (
proxied: { status: number; headers: Record<string, string>; bodyBase64?: string }
): Response => {
if (isNullBodyStatus(proxied.status)) {
return new Response(null, { status: proxied.status, headers: proxied.headers });
}
const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array();
return new Response(body, { status: proxied.status, headers: proxied.headers });
};
const encodeBase64 = (bytes: Uint8Array): string => { const encodeBase64 = (bytes: Uint8Array): string => {
const CHUNK = 0x8000; const CHUNK = 0x8000;
let binary = ''; let binary = '';
@@ -375,6 +388,47 @@ const isSessionMessageApiPath = (pathname: string) => /^\/api\/session\/[^/]+\/m
const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
const pathname = url.pathname; const pathname = url.pathname;
const normalizedPathname = pathname !== '/' ? pathname.replace(/\/+$/, '') : pathname;
const method = ((init?.method || 'GET') as string).toUpperCase();
if (normalizedPathname === '/api/sessions/snapshot' && method === 'GET') {
return new Response(JSON.stringify({
statusSessions: {},
attentionSessions: {},
serverTime: Date.now(),
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (/^\/api\/sessions\/[^/]+\/(view|unview)$/.test(normalizedPathname) && method === 'POST') {
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (normalizedPathname === '/api/tts/status' && method === 'GET') {
return new Response(JSON.stringify({ available: false }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (normalizedPathname === '/api/tts/say/status' && method === 'GET') {
return new Response(JSON.stringify({ available: false, voices: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if ((pathname === '/api/tts/speak' || pathname === '/api/tts/say/speak' || pathname === '/api/tts/summarize') && method === 'POST') {
return new Response(JSON.stringify({ error: 'TTS endpoints are not available in VS Code runtime' }), {
status: 501,
headers: { 'Content-Type': 'application/json' },
});
}
// Health endpoints: reflect actual connection status // Health endpoints: reflect actual connection status
if (pathname === '/health' || pathname === '/api/health') { if (pathname === '/health' || pathname === '/api/health') {
@@ -792,8 +846,7 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
if (method === 'POST' && isSessionMessageApiPath(targetUrl.pathname)) { if (method === 'POST' && isSessionMessageApiPath(targetUrl.pathname)) {
const bodyText = await extractBodyText(input, init, method); const bodyText = await extractBodyText(input, init, method);
const proxied = await proxySessionMessageRequest({ path: suffixPath, headers, bodyText }); const proxied = await proxySessionMessageRequest({ path: suffixPath, headers, bodyText });
const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array(); const response = buildProxiedResponse(proxied);
const response = new Response(body, { status: proxied.status, headers: proxied.headers });
recordBootstrapFetch(targetUrl.pathname, response.ok); recordBootstrapFetch(targetUrl.pathname, response.ok);
maybeHideLoadingOverlay(); maybeHideLoadingOverlay();
return response; return response;
@@ -801,8 +854,7 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const bodyBase64 = await extractBodyBase64(input, init, method); const bodyBase64 = await extractBodyBase64(input, init, method);
const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64 }); const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64 });
const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array(); const response = buildProxiedResponse(proxied);
const response = new Response(body, { status: proxied.status, headers: proxied.headers });
recordBootstrapFetch(targetUrl.pathname, response.ok); recordBootstrapFetch(targetUrl.pathname, response.ok);
maybeHideLoadingOverlay(); maybeHideLoadingOverlay();
return response; return response;
+21
View File
@@ -450,6 +450,23 @@ function isProcessRunning(pid) {
} }
} }
async function requestServerShutdown(port) {
if (!Number.isFinite(port) || port <= 0) return false;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1500);
try {
const resp = await fetch(`http://127.0.0.1:${port}/api/system/shutdown`, {
method: 'POST',
signal: controller.signal,
});
return resp.ok;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
const commands = { const commands = {
async serve(options) { async serve(options) {
options.port = await resolveAvailablePort(options.port); options.port = await resolveAvailablePort(options.port);
@@ -675,6 +692,7 @@ const commands = {
console.log(`Stopping OpenChamber (PID: ${targetInstance.pid}, Port: ${targetInstance.port})...`); console.log(`Stopping OpenChamber (PID: ${targetInstance.pid}, Port: ${targetInstance.port})...`);
try { try {
await requestServerShutdown(targetInstance.port);
process.kill(targetInstance.pid, 'SIGTERM'); process.kill(targetInstance.pid, 'SIGTERM');
let attempts = 0; let attempts = 0;
@@ -709,6 +727,7 @@ const commands = {
console.log(` Stopping instance on port ${instance.port} (PID: ${instance.pid})...`); console.log(` Stopping instance on port ${instance.port} (PID: ${instance.pid})...`);
try { try {
await requestServerShutdown(instance.port);
process.kill(instance.pid, 'SIGTERM'); process.kill(instance.pid, 'SIGTERM');
let attempts = 0; let attempts = 0;
@@ -814,6 +833,7 @@ const commands = {
// Stop the instance // Stop the instance
try { try {
await requestServerShutdown(instance.port);
process.kill(instance.pid, 'SIGTERM'); process.kill(instance.pid, 'SIGTERM');
// Wait for it to stop // Wait for it to stop
let attempts = 0; let attempts = 0;
@@ -970,6 +990,7 @@ const commands = {
console.log(`\nStopping ${runningInstances.length} running instance(s) before update...`); console.log(`\nStopping ${runningInstances.length} running instance(s) before update...`);
for (const instance of runningInstances) { for (const instance of runningInstances) {
try { try {
await requestServerShutdown(instance.port);
process.kill(instance.pid, 'SIGTERM'); process.kill(instance.pid, 'SIGTERM');
let attempts = 0; let attempts = 0;
while (isProcessRunning(instance.pid) && attempts < 20) { while (isProcessRunning(instance.pid) && attempts < 20) {
+228 -18
View File
@@ -3,6 +3,7 @@ import path from 'path';
import { spawn, spawnSync } from 'child_process'; import { spawn, spawnSync } from 'child_process';
import fs from 'fs'; import fs from 'fs';
import http from 'http'; import http from 'http';
import net from 'net';
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import os from 'os'; import os from 'os';
@@ -20,7 +21,6 @@ import {
pruneRebindTimestamps, pruneRebindTimestamps,
readTerminalInputWsControlFrame, readTerminalInputWsControlFrame,
} from './lib/terminal-input-ws-protocol.js'; } from './lib/terminal-input-ws-protocol.js';
import { createOpencodeServer } from '@opencode-ai/sdk/server';
import webPush from 'web-push'; import webPush from 'web-push';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@@ -2734,15 +2734,30 @@ const getHmrState = () => {
globalThis[HMR_STATE_KEY] = { globalThis[HMR_STATE_KEY] = {
openCodeProcess: null, openCodeProcess: null,
openCodePort: null, openCodePort: null,
openCodeWorkingDirectory: os.homedir(), openCodeWorkingDirectory: os.homedir(),
isShuttingDown: false, isShuttingDown: false,
signalsAttached: false, signalsAttached: false,
}; userProvidedOpenCodePassword: undefined,
openCodeAuthPassword: null,
openCodeAuthSource: null,
};
} }
return globalThis[HMR_STATE_KEY]; return globalThis[HMR_STATE_KEY];
}; };
const hmrState = getHmrState(); const hmrState = getHmrState();
const normalizeOpenCodePassword = (value) => {
if (typeof value !== 'string') {
return '';
}
return value.trim();
};
if (typeof hmrState.userProvidedOpenCodePassword === 'undefined') {
const initialPassword = normalizeOpenCodePassword(process.env.OPENCODE_SERVER_PASSWORD);
hmrState.userProvidedOpenCodePassword = initialPassword || null;
}
// Non-HMR state (safe to reset on reload) // Non-HMR state (safe to reset on reload)
let healthCheckInterval = null; let healthCheckInterval = null;
let server = null; let server = null;
@@ -2762,6 +2777,18 @@ let exitOnShutdown = true;
let uiAuthController = null; let uiAuthController = null;
let cloudflareTunnelController = null; let cloudflareTunnelController = null;
let terminalInputWsServer = null; let terminalInputWsServer = null;
const userProvidedOpenCodePassword =
typeof hmrState.userProvidedOpenCodePassword === 'string' && hmrState.userProvidedOpenCodePassword.length > 0
? hmrState.userProvidedOpenCodePassword
: null;
let openCodeAuthPassword =
typeof hmrState.openCodeAuthPassword === 'string' && hmrState.openCodeAuthPassword.length > 0
? hmrState.openCodeAuthPassword
: userProvidedOpenCodePassword;
let openCodeAuthSource =
typeof hmrState.openCodeAuthSource === 'string' && hmrState.openCodeAuthSource.length > 0
? hmrState.openCodeAuthSource
: (userProvidedOpenCodePassword ? 'user-env' : null);
// Sync helper - call after modifying any HMR state variable // Sync helper - call after modifying any HMR state variable
const syncToHmrState = () => { const syncToHmrState = () => {
@@ -2770,6 +2797,8 @@ const syncToHmrState = () => {
hmrState.isShuttingDown = isShuttingDown; hmrState.isShuttingDown = isShuttingDown;
hmrState.signalsAttached = signalsAttached; hmrState.signalsAttached = signalsAttached;
hmrState.openCodeWorkingDirectory = openCodeWorkingDirectory; hmrState.openCodeWorkingDirectory = openCodeWorkingDirectory;
hmrState.openCodeAuthPassword = openCodeAuthPassword;
hmrState.openCodeAuthSource = openCodeAuthSource;
}; };
// Sync helper - call to restore state from HMR (e.g., on module reload) // Sync helper - call to restore state from HMR (e.g., on module reload)
@@ -2779,6 +2808,14 @@ const syncFromHmrState = () => {
isShuttingDown = hmrState.isShuttingDown; isShuttingDown = hmrState.isShuttingDown;
signalsAttached = hmrState.signalsAttached; signalsAttached = hmrState.signalsAttached;
openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory; openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory;
openCodeAuthPassword =
typeof hmrState.openCodeAuthPassword === 'string' && hmrState.openCodeAuthPassword.length > 0
? hmrState.openCodeAuthPassword
: userProvidedOpenCodePassword;
openCodeAuthSource =
typeof hmrState.openCodeAuthSource === 'string' && hmrState.openCodeAuthSource.length > 0
? hmrState.openCodeAuthSource
: (userProvidedOpenCodePassword ? 'user-env' : null);
}; };
// Module-level variables that shadow HMR state // Module-level variables that shadow HMR state
@@ -2858,18 +2895,13 @@ const ENV_SKIP_OPENCODE_START = process.env.OPENCODE_SKIP_START === 'true' ||
const ENV_DESKTOP_NOTIFY = process.env.OPENCHAMBER_DESKTOP_NOTIFY === 'true'; const ENV_DESKTOP_NOTIFY = process.env.OPENCHAMBER_DESKTOP_NOTIFY === 'true';
// OpenCode server authentication (Basic Auth with username "opencode") // OpenCode server authentication (Basic Auth with username "opencode")
const ENV_OPENCODE_SERVER_PASSWORD = (() => {
const pwd = process.env.OPENCODE_SERVER_PASSWORD;
return typeof pwd === 'string' && pwd.length > 0 ? pwd : null;
})();
/** /**
* Returns auth headers for OpenCode server requests if OPENCODE_SERVER_PASSWORD is set. * Returns auth headers for OpenCode server requests if OPENCODE_SERVER_PASSWORD is set.
* Uses Basic Auth with username "opencode" and the password from the env variable. * Uses Basic Auth with username "opencode" and the password from the env variable.
*/ */
function getOpenCodeAuthHeaders() { function getOpenCodeAuthHeaders() {
// Re-read from env each time in case it wasn't set at module load (HMR issue) const password = normalizeOpenCodePassword(openCodeAuthPassword || process.env.OPENCODE_SERVER_PASSWORD || '');
const password = ENV_OPENCODE_SERVER_PASSWORD || process.env.OPENCODE_SERVER_PASSWORD;
if (!password) { if (!password) {
return {}; return {};
@@ -2879,6 +2911,60 @@ function getOpenCodeAuthHeaders() {
return { Authorization: `Basic ${credentials}` }; return { Authorization: `Basic ${credentials}` };
} }
function isOpenCodeConnectionSecure() {
return Object.prototype.hasOwnProperty.call(getOpenCodeAuthHeaders(), 'Authorization');
}
function generateSecureOpenCodePassword() {
return crypto
.randomBytes(32)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
function isValidOpenCodePassword(password) {
return typeof password === 'string' && password.trim().length > 0;
}
function setOpenCodeAuthState(password, source) {
const normalized = normalizeOpenCodePassword(password);
if (!isValidOpenCodePassword(normalized)) {
openCodeAuthPassword = null;
openCodeAuthSource = null;
delete process.env.OPENCODE_SERVER_PASSWORD;
syncToHmrState();
return null;
}
openCodeAuthPassword = normalized;
openCodeAuthSource = source;
process.env.OPENCODE_SERVER_PASSWORD = normalized;
syncToHmrState();
return normalized;
}
async function ensureLocalOpenCodeServerPassword({ rotateManaged = false } = {}) {
if (isValidOpenCodePassword(userProvidedOpenCodePassword)) {
return setOpenCodeAuthState(userProvidedOpenCodePassword, 'user-env');
}
if (rotateManaged) {
const rotatedPassword = setOpenCodeAuthState(generateSecureOpenCodePassword(), 'rotated');
console.log('Rotated secure password for managed local OpenCode instance');
return rotatedPassword;
}
if (isValidOpenCodePassword(openCodeAuthPassword)) {
return setOpenCodeAuthState(openCodeAuthPassword, openCodeAuthSource || 'generated');
}
const generatedPassword = setOpenCodeAuthState(generateSecureOpenCodePassword(), 'generated');
console.log('Generated secure password for managed local OpenCode instance');
return generatedPassword;
}
const ENV_CONFIGURED_API_PREFIX = normalizeApiPrefix( const ENV_CONFIGURED_API_PREFIX = normalizeApiPrefix(
process.env.OPENCODE_API_PREFIX || process.env.OPENCHAMBER_API_PREFIX || '' process.env.OPENCODE_API_PREFIX || process.env.OPENCHAMBER_API_PREFIX || ''
); );
@@ -4422,7 +4508,6 @@ function parseArgs(argv = process.argv.slice(2)) {
function killProcessOnPort(port) { function killProcessOnPort(port) {
if (!port) return; if (!port) return;
try { try {
// SDK's proc.kill() only kills the Node wrapper, not the actual opencode binary.
// Kill any process listening on our port to clean up orphaned children. // Kill any process listening on our port to clean up orphaned children.
const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 5000 }); const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 5000 });
const output = result.stdout || ''; const output = result.stdout || '';
@@ -4442,27 +4527,143 @@ function killProcessOnPort(port) {
} }
} }
async function createManagedOpenCodeServerProcess({
hostname,
port,
timeout,
cwd,
env,
}) {
const binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
const args = ['serve', '--hostname', hostname, '--port', String(port)];
const child = spawn(binary, args, {
cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
const url = await new Promise((resolve, reject) => {
let output = '';
let done = false;
const finish = (handler, value) => {
if (done) return;
done = true;
clearTimeout(timer);
child.stdout?.off('data', onStdout);
child.stderr?.off('data', onStderr);
child.off('exit', onExit);
child.off('error', onError);
handler(value);
};
const onStdout = (chunk) => {
output += chunk.toString();
const lines = output.split('\n');
for (const line of lines) {
if (!line.startsWith('opencode server listening')) continue;
const match = line.match(/on\s+(https?:\/\/[^\s]+)/);
if (!match) {
finish(reject, new Error(`Failed to parse server url from output: ${line}`));
return;
}
finish(resolve, match[1]);
return;
}
};
const onStderr = (chunk) => {
output += chunk.toString();
};
const onExit = (code) => {
finish(reject, new Error(`OpenCode exited with code ${code}. Output: ${output}`));
};
const onError = (error) => {
finish(reject, error);
};
const timer = setTimeout(() => {
finish(reject, new Error(`Timeout waiting for OpenCode to start after ${timeout}ms`));
}, timeout);
child.stdout?.on('data', onStdout);
child.stderr?.on('data', onStderr);
child.on('exit', onExit);
child.on('error', onError);
});
return {
url,
close() {
try {
child.kill('SIGTERM');
} catch {
// ignore
}
},
};
}
async function resolveManagedOpenCodePort(requestedPort) {
if (typeof requestedPort === 'number' && Number.isFinite(requestedPort) && requestedPort > 0) {
return requestedPort;
}
return await new Promise((resolve, reject) => {
const server = net.createServer();
const cleanup = () => {
server.removeAllListeners('error');
server.removeAllListeners('listening');
};
server.once('error', (error) => {
cleanup();
reject(error);
});
server.once('listening', () => {
const address = server.address();
const port = address && typeof address === 'object' ? address.port : 0;
server.close(() => {
cleanup();
if (port > 0) {
resolve(port);
return;
}
reject(new Error('Failed to allocate OpenCode port'));
});
});
server.listen(0, '127.0.0.1');
});
}
async function startOpenCode() { async function startOpenCode() {
const desiredPort = ENV_CONFIGURED_OPENCODE_PORT ?? 0; const desiredPort = ENV_CONFIGURED_OPENCODE_PORT ?? 0;
const spawnPort = await resolveManagedOpenCodePort(desiredPort);
console.log( console.log(
desiredPort > 0 desiredPort > 0
? `Starting OpenCode on requested port ${desiredPort}...` ? `Starting OpenCode on requested port ${desiredPort}...`
: 'Starting OpenCode with dynamic port assignment...' : `Starting OpenCode on allocated port ${spawnPort}...`
); );
// Note: SDK starts in current process CWD. openCodeWorkingDirectory is tracked but not used for spawn in SDK.
await applyOpencodeBinaryFromSettings(); await applyOpencodeBinaryFromSettings();
ensureOpencodeCliEnv(); ensureOpencodeCliEnv();
const openCodePassword = await ensureLocalOpenCodeServerPassword({
rotateManaged: true,
});
try { try {
const serverInstance = await createOpencodeServer({ const serverInstance = await createManagedOpenCodeServerProcess({
hostname: '127.0.0.1', hostname: '127.0.0.1',
port: desiredPort, port: spawnPort,
timeout: 30000, timeout: 30000,
cwd: openCodeWorkingDirectory,
env: { env: {
...process.env, ...process.env,
// Pass minimal config to avoid pollution, but inherit PATH etc OPENCODE_SERVER_PASSWORD: openCodePassword,
} },
}); });
if (!serverInstance || !serverInstance.url) { if (!serverInstance || !serverInstance.url) {
@@ -5350,6 +5551,8 @@ async function main(options = {}) {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
openCodePort: openCodePort, openCodePort: openCodePort,
openCodeRunning: Boolean(openCodePort && isOpenCodeReady && !isRestartingOpenCode), openCodeRunning: Boolean(openCodePort && isOpenCodeReady && !isRestartingOpenCode),
openCodeSecureConnection: isOpenCodeConnectionSecure(),
openCodeAuthSource: openCodeAuthSource || null,
openCodeApiPrefix: '', openCodeApiPrefix: '',
openCodeApiPrefixDetected: true, openCodeApiPrefixDetected: true,
isOpenCodeReady, isOpenCodeReady,
@@ -5362,6 +5565,13 @@ async function main(options = {}) {
}); });
}); });
app.post('/api/system/shutdown', (req, res) => {
res.json({ ok: true });
gracefulShutdown({ exitProcess: false }).catch((error) => {
console.error('Shutdown request failed:', error?.message || error);
});
});
app.use((req, res, next) => { app.use((req, res, next) => {
if ( if (
req.path.startsWith('/api/config/agents') || req.path.startsWith('/api/config/agents') ||