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:
@@ -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, {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user