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