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() {
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;
+2
View File
@@ -1187,6 +1187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -3411,6 +3412,7 @@ checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http",
+1 -1
View File
@@ -16,7 +16,7 @@ devtools = ["tauri/devtools"]
anyhow = "1.0.86"
base64 = "0.22.1"
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_json = "1.0.143"
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;
};
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");
if let Some(child) = guard.take() {
let _ = child.kill();
}
*state.url.lock().expect("sidecar url mutex") = None;
}
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()])
.env("OPENCHAMBER_HOST", "127.0.0.1")
.env("OPENCHAMBER_DIST_DIR", dist_dir.clone())
.env("OPENCHAMBER_RUNTIME", "desktop")
.env("OPENCHAMBER_DESKTOP_NOTIFY", "true")
.env("PATH", augmented_path.clone())
.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() {
Ok(v) => v,
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';
}, [currentSessionId, sessions]);
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 [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>(
() => (typeof window !== 'undefined'
@@ -129,10 +142,37 @@ export const VSCodeLayout: React.FC = () => {
return;
}
if (!currentSessionId && !newSessionDraftOpen && currentView === 'chat') {
setCurrentView('sessions');
if (currentView !== 'chat') {
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(() => {
setCurrentView('sessions');
+1 -1
View File
@@ -486,7 +486,7 @@ export const useEventStream = () => {
// Note: needs_attention logic is now handled by the server
// Server maintains authoritative state based on view tracking and message events
if (prevType !== nextType) {
if (process.env.NODE_ENV === 'development' && prevType !== nextType) {
try {
console.info('[SESSION-STATUS]', {
sessionId,
+19 -1
View File
@@ -264,11 +264,29 @@ export const debugUtils = {
const resp = await fetch('/api/health');
const contentType = resp.headers.get('content-type') || '';
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 = {
status: resp.status,
ok: resp.ok,
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,
};
} catch (error) {
+27
View File
@@ -13,6 +13,8 @@ type ProbeResult = {
type OpenChamberHealthSnapshot = {
openCodePort?: unknown;
openCodeRunning?: unknown;
openCodeSecureConnection?: unknown;
openCodeAuthSource?: unknown;
isOpenCodeReady?: unknown;
lastOpenCodeError?: 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> => {
const now = new Date();
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(`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') {
const injected = (window as unknown as { __OPENCHAMBER_MACOS_MAJOR__?: unknown }).__OPENCHAMBER_MACOS_MAJOR__;
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) {
console.info("[MESSAGE-DEBUG] updateMessageInfo: messageIndex === -1", {
sessionId,
messageId,
messageInfo,
existingCount: normalizedSessionMessages.length,
});
if (process.env.NODE_ENV === 'development') {
console.info("[MESSAGE-DEBUG] updateMessageInfo: messageIndex === -1", {
sessionId,
messageId,
messageInfo,
existingCount: normalizedSessionMessages.length,
});
}
if (normalizedSessionMessages.length > 0) {
const firstMessage = normalizedSessionMessages[0];
@@ -176,7 +176,10 @@ export class AgentManagerPanelProvider {
let response: Response;
let wrapAsGlobal = false;
const requestHeaders = this._buildSseHeaders(headers || {});
const requestHeaders = this._buildSseHeaders({
...(headers || {}),
...(this._openCodeManager?.getOpenCodeAuthHeaders() || {}),
});
try {
response = await fetch(targetUrl, {
+4 -1
View File
@@ -204,7 +204,10 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
let response: Response;
let wrapAsGlobal = false;
const requestHeaders = this._buildSseHeaders(headers || {});
const requestHeaders = this._buildSseHeaders({
...(headers || {}),
...(this._openCodeManager?.getOpenCodeAuthHeaders() || {}),
});
try {
response = await fetch(targetUrl, {
@@ -199,7 +199,10 @@ export class SessionEditorPanelProvider {
let response: Response;
let wrapAsGlobal = false;
const requestHeaders = this._buildSseHeaders(headers || {});
const requestHeaders = this._buildSseHeaders({
...(headers || {}),
...(this._openCodeManager?.getOpenCodeAuthHeaders() || {}),
});
try {
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 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.
if (normalizedPath === '/event' || normalizedPath === '/global/event') {
@@ -875,7 +878,10 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
const base = `${apiUrl.replace(/\/+$/, '')}/`;
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 {
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 timeout = setTimeout(() => controller.abort(), timeoutMs);
const startedAt = Date.now();
const openCodeAuthHeaders = openCodeManager?.getOpenCodeAuthHeaders() || {};
try {
const resp = await fetch(input, {
method: 'GET',
headers: { Accept: 'application/json' },
headers: { Accept: 'application/json', ...openCodeAuthHeaders },
signal: controller.signal,
});
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: (unknown)`,
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)`,
debug
? `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 path from 'path';
import * as fs from 'fs';
import * as net from 'net';
import { execSync } 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;
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
export type OpenCodeDebugInfo = {
@@ -32,6 +33,8 @@ export type OpenCodeDebugInfo = {
lastReadyAttempts: number | null;
lastStartAttempts: number | null;
version: string | null;
secureConnection: boolean;
authSource: 'user-env' | 'generated' | 'rotated' | null;
};
export interface OpenCodeManager {
@@ -41,12 +44,43 @@ export interface OpenCodeManager {
setWorkingDirectory(path: string): Promise<{ success: boolean; restarted: boolean; path: string }>;
getStatus(): ConnectionStatus;
getApiUrl(): string | null;
getOpenCodeAuthHeaders(): Record<string, string>;
getWorkingDirectory(): string;
isCliAvailable(): boolean;
getDebugInfo(): OpenCodeDebugInfo;
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 {
try {
const parsed = new URL(url);
@@ -110,13 +144,8 @@ function resolveOpencodeCliPath(): string | null {
const sharedFromOpenChamber = (() => {
try {
const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
const raw = fs.readFileSync(settingsPath, 'utf8');
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;
const settings = readOpenChamberSettings();
const candidate = settings.opencodeBinary;
if (typeof candidate !== 'string') {
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 start = Date.now();
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 res = await fetch(url.toString(), {
method: 'GET',
headers: { Accept: 'application/json' },
headers: { Accept: 'application/json', ...authHeaders },
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 };
}
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 {
// Discard unused parameter - reserved for future use (state persistence, subscriptions)
void _context;
let server: { url: string; close: () => void } | 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 lastError: string | undefined;
const listeners = new Set<(status: ConnectionStatus, error?: string) => void>();
@@ -375,7 +518,48 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
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;
setStatus('connecting');
lastStartAt = Date.now();
@@ -418,18 +602,19 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
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.
// Some OpenCode endpoints behave differently based on server process cwd,
// so ensure we start it from the workspace directory.
const originalCwd = process.cwd();
try {
process.chdir(workingDirectory);
server = await createOpencodeServer({
hostname: '127.0.0.1',
port: 0,
timeout: READY_CHECK_TIMEOUT_MS,
signal: undefined,
});
const port = await allocateManagedOpenCodePort();
server = await spawnManagedOpenCodeServer(workingDirectory, port, READY_CHECK_TIMEOUT_MS);
} finally {
try {
process.chdir(originalCwd);
@@ -440,7 +625,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
if (server && server.url) {
// 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;
lastReadyAttempts = ready.attempts;
if (ready.ok) {
@@ -496,7 +681,6 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
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.
if (portToKill) {
try {
@@ -530,7 +714,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
restartCount += 1;
await stopInternal();
await new Promise(r => setTimeout(r, 250));
await startInternal();
await startInternal(undefined, { rotateManaged: true });
}
async function start(workdir?: string): Promise<void> {
@@ -541,7 +725,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
}
}
lastStartAttempts = 1;
pendingOperation = startInternal(workdir);
pendingOperation = startInternal(workdir, { rotateManaged: true });
try {
await pendingOperation;
} finally {
@@ -603,31 +787,37 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
setWorkingDirectory,
getStatus: () => status,
getApiUrl,
getOpenCodeAuthHeaders,
getWorkingDirectory: () => workingDirectory,
isCliAvailable: () => !cliMissing,
getDebugInfo: () => ({
mode: useConfiguredUrl && configuredApiUrl ? 'external' : 'managed',
status,
lastError,
workingDirectory,
cliAvailable: !cliMissing,
cliPath,
configuredApiUrl: useConfiguredUrl && configuredApiUrl ? configuredApiUrl.replace(/\/+$/, '') : null,
configuredPort,
detectedPort,
apiPrefix: '',
apiPrefixDetected: true,
startCount,
restartCount,
lastStartAt,
lastConnectedAt,
lastExitCode,
serverUrl: getApiUrl(),
lastReadyElapsedMs,
lastReadyAttempts,
lastStartAttempts,
version,
}),
getDebugInfo: () => {
const secureConnection = Boolean(getOpenCodeAuthHeaders().Authorization);
return {
mode: useConfiguredUrl && configuredApiUrl ? 'external' : 'managed',
status,
lastError,
workingDirectory,
cliAvailable: !cliMissing,
cliPath,
configuredApiUrl: useConfiguredUrl && configuredApiUrl ? configuredApiUrl.replace(/\/+$/, '') : null,
configuredPort,
detectedPort,
apiPrefix: '',
apiPrefixDetected: true,
startCount,
restartCount,
lastStartAt,
lastConnectedAt,
lastExitCode,
serverUrl: getApiUrl(),
lastReadyElapsedMs,
lastReadyAttempts,
lastStartAttempts,
version,
secureConnection,
authSource: managedPasswordSource || (userProvidedEnvPassword ? 'user-env' : null),
};
},
onStatusChange(callback) {
listeners.add(callback);
callback(status, lastError);
@@ -191,11 +191,13 @@ export const startGlobalEventWatcher = async (
}
const url = buildOpenCodeUrl('/global/event', baseUrl);
const authHeaders = manager.getOpenCodeAuthHeaders();
upstream = await fetch(url, {
headers: {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
...authHeaders,
},
signal,
});
+56 -4
View File
@@ -303,6 +303,19 @@ const decodeBase64 = (value: string): Uint8Array => {
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 CHUNK = 0x8000;
let binary = '';
@@ -375,6 +388,47 @@ const isSessionMessageApiPath = (pathname: string) => /^\/api\/session\/[^/]+\/m
const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
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
if (pathname === '/health' || pathname === '/api/health') {
@@ -792,8 +846,7 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
if (method === 'POST' && isSessionMessageApiPath(targetUrl.pathname)) {
const bodyText = await extractBodyText(input, init, method);
const proxied = await proxySessionMessageRequest({ path: suffixPath, headers, bodyText });
const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array();
const response = new Response(body, { status: proxied.status, headers: proxied.headers });
const response = buildProxiedResponse(proxied);
recordBootstrapFetch(targetUrl.pathname, response.ok);
maybeHideLoadingOverlay();
return response;
@@ -801,8 +854,7 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const bodyBase64 = await extractBodyBase64(input, init, method);
const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64 });
const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array();
const response = new Response(body, { status: proxied.status, headers: proxied.headers });
const response = buildProxiedResponse(proxied);
recordBootstrapFetch(targetUrl.pathname, response.ok);
maybeHideLoadingOverlay();
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 = {
async serve(options) {
options.port = await resolveAvailablePort(options.port);
@@ -675,6 +692,7 @@ const commands = {
console.log(`Stopping OpenChamber (PID: ${targetInstance.pid}, Port: ${targetInstance.port})...`);
try {
await requestServerShutdown(targetInstance.port);
process.kill(targetInstance.pid, 'SIGTERM');
let attempts = 0;
@@ -709,6 +727,7 @@ const commands = {
console.log(` Stopping instance on port ${instance.port} (PID: ${instance.pid})...`);
try {
await requestServerShutdown(instance.port);
process.kill(instance.pid, 'SIGTERM');
let attempts = 0;
@@ -814,6 +833,7 @@ const commands = {
// Stop the instance
try {
await requestServerShutdown(instance.port);
process.kill(instance.pid, 'SIGTERM');
// Wait for it to stop
let attempts = 0;
@@ -970,6 +990,7 @@ const commands = {
console.log(`\nStopping ${runningInstances.length} running instance(s) before update...`);
for (const instance of runningInstances) {
try {
await requestServerShutdown(instance.port);
process.kill(instance.pid, 'SIGTERM');
let attempts = 0;
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 fs from 'fs';
import http from 'http';
import net from 'net';
import { WebSocketServer } from 'ws';
import { fileURLToPath } from 'url';
import os from 'os';
@@ -20,7 +21,6 @@ import {
pruneRebindTimestamps,
readTerminalInputWsControlFrame,
} from './lib/terminal-input-ws-protocol.js';
import { createOpencodeServer } from '@opencode-ai/sdk/server';
import webPush from 'web-push';
const __filename = fileURLToPath(import.meta.url);
@@ -2734,15 +2734,30 @@ const getHmrState = () => {
globalThis[HMR_STATE_KEY] = {
openCodeProcess: null,
openCodePort: null,
openCodeWorkingDirectory: os.homedir(),
isShuttingDown: false,
signalsAttached: false,
};
openCodeWorkingDirectory: os.homedir(),
isShuttingDown: false,
signalsAttached: false,
userProvidedOpenCodePassword: undefined,
openCodeAuthPassword: null,
openCodeAuthSource: null,
};
}
return globalThis[HMR_STATE_KEY];
};
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)
let healthCheckInterval = null;
let server = null;
@@ -2762,6 +2777,18 @@ let exitOnShutdown = true;
let uiAuthController = null;
let cloudflareTunnelController = 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
const syncToHmrState = () => {
@@ -2770,6 +2797,8 @@ const syncToHmrState = () => {
hmrState.isShuttingDown = isShuttingDown;
hmrState.signalsAttached = signalsAttached;
hmrState.openCodeWorkingDirectory = openCodeWorkingDirectory;
hmrState.openCodeAuthPassword = openCodeAuthPassword;
hmrState.openCodeAuthSource = openCodeAuthSource;
};
// Sync helper - call to restore state from HMR (e.g., on module reload)
@@ -2779,6 +2808,14 @@ const syncFromHmrState = () => {
isShuttingDown = hmrState.isShuttingDown;
signalsAttached = hmrState.signalsAttached;
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
@@ -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';
// 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.
* Uses Basic Auth with username "opencode" and the password from the env variable.
*/
function getOpenCodeAuthHeaders() {
// Re-read from env each time in case it wasn't set at module load (HMR issue)
const password = ENV_OPENCODE_SERVER_PASSWORD || process.env.OPENCODE_SERVER_PASSWORD;
const password = normalizeOpenCodePassword(openCodeAuthPassword || process.env.OPENCODE_SERVER_PASSWORD || '');
if (!password) {
return {};
@@ -2879,6 +2911,60 @@ function getOpenCodeAuthHeaders() {
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(
process.env.OPENCODE_API_PREFIX || process.env.OPENCHAMBER_API_PREFIX || ''
);
@@ -4422,7 +4508,6 @@ function parseArgs(argv = process.argv.slice(2)) {
function killProcessOnPort(port) {
if (!port) return;
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.
const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 5000 });
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() {
const desiredPort = ENV_CONFIGURED_OPENCODE_PORT ?? 0;
const spawnPort = await resolveManagedOpenCodePort(desiredPort);
console.log(
desiredPort > 0
? `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();
ensureOpencodeCliEnv();
const openCodePassword = await ensureLocalOpenCodeServerPassword({
rotateManaged: true,
});
try {
const serverInstance = await createOpencodeServer({
const serverInstance = await createManagedOpenCodeServerProcess({
hostname: '127.0.0.1',
port: desiredPort,
port: spawnPort,
timeout: 30000,
cwd: openCodeWorkingDirectory,
env: {
...process.env,
// Pass minimal config to avoid pollution, but inherit PATH etc
}
OPENCODE_SERVER_PASSWORD: openCodePassword,
},
});
if (!serverInstance || !serverInstance.url) {
@@ -5350,6 +5551,8 @@ async function main(options = {}) {
timestamp: new Date().toISOString(),
openCodePort: openCodePort,
openCodeRunning: Boolean(openCodePort && isOpenCodeReady && !isRestartingOpenCode),
openCodeSecureConnection: isOpenCodeConnectionSecure(),
openCodeAuthSource: openCodeAuthSource || null,
openCodeApiPrefix: '',
openCodeApiPrefixDetected: true,
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) => {
if (
req.path.startsWith('/api/config/agents') ||