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:
+236
-46
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user