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
+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);