mirror of
https://github.com/microsoft/vscode.git
synced 2026-08-18 21:36:52 +01:00
* Mock policy server: upstream passthrough, per-endpoint mocking, request log The mock policy server only worked via product.overrides.json, which limits it to Code OSS running from sources, requires a reload after every change, and cannot exercise a stable/Insiders build or the CLI. Support a system HTTP proxy as a second wiring path, keeping product.overrides.json as the default. - Proxy anything not explicitly mocked to the real API (--upstream, default https://api.github.com) and stream it back, so a blanket proxy rule is safe: only endpoints deliberately switched on are faked. Rewrites Host, strips hop-by-hop headers and accept-encoding, forwards Authorization untouched, and reports upstream failures as 502. - Add a per-endpoint mock/passthrough toggle. Only managed settings is mocked by default; applying a preset implicitly switches mocking on. - Add managed-settings disk cache clearing. A cache entry under an hour old makes the runtime skip the network entirely, so an override is never even requested. Paths verified against managed_settings_cache.rs and path_helpers.rs, including the COPILOT_CACHE_HOME override. - Add a rolling request log (GET/DELETE /api/log) surfaced in the GUI, so it is obvious whether the client actually reached the server. - Add realistic managed-settings presets, each validated against the schema, and branch-point presets for the other endpoints. - Only warn about unknown schema keys on 2xx, and re-validate on status change: a 404/466/500 body is an error payload, not a policy document. - Route GUI assets from an explicit allowlist instead of probing public/ for anything that looks like a file, which would otherwise shadow proxied paths. UX: - Make save semantics consistent: everything auto-saves, with a pill showing whether the editor matches what is being served. - Surface mocked vs proxied via tab dots, a checkbox, and reactive help text. - Add a light palette; the dark-only one declared color-scheme: light dark, so UA form controls rendered light on a dark page. - Make the schema disclosure a real button with aria-expanded, add focus-visible styles, and expose tab state to screen readers. - Build the validation table from DOM nodes rather than innerHTML. - Surface save and wire failures instead of failing silently, and fall back to the shared endpoint definitions when the control API is unreachable. - Answer the GUI's own favicon request so it stops appearing in the log as a proxied 404. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Polish mock policy server workflows Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Route runtime policy diagnostics through proxy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Minimize runtime proxy integration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Move runtime proxy fix to separate PR Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address mock policy server review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
884 lines
30 KiB
TypeScript
884 lines
30 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
/** Local mock and passthrough proxy for the Copilot policy endpoints. */
|
|
|
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
import type { EndpointDef } from './endpoints';
|
|
|
|
const http = require('node:http') as typeof import('node:http');
|
|
const https = require('node:https') as typeof import('node:https');
|
|
const fs = require('node:fs') as typeof import('node:fs');
|
|
const os = require('node:os') as typeof import('node:os');
|
|
const path = require('node:path') as typeof import('node:path');
|
|
const { fileURLToPath } = require('node:url') as typeof import('node:url');
|
|
const { stripTypeScriptTypes } = require('node:module') as typeof import('node:module');
|
|
|
|
const endpoints: EndpointDef[] = require('./endpoints.ts');
|
|
|
|
const ROOT = path.resolve(__dirname, '..', '..');
|
|
const PRODUCT_JSON = path.join(ROOT, 'product.json');
|
|
const PRODUCT_OVERRIDES_JSON = path.join(ROOT, 'product.overrides.json');
|
|
const PRODUCT_OVERRIDES_BACKUP = path.join(ROOT, 'product.overrides.json.pre-mock-server');
|
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
|
|
const DEFAULT_SCHEMA_RELATIVE_PATH = 'copilot-agent-runtime/schema/managed-settings-schema.json';
|
|
const DEFAULT_SCHEMA_SOURCE = resolveDefaultSchemaSource();
|
|
|
|
/** Real API that un-mocked requests are forwarded to. */
|
|
const DEFAULT_UPSTREAM = 'https://api.github.com';
|
|
const PORT = 3000;
|
|
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const HOST = args.host || '127.0.0.1';
|
|
const SCHEMA_SOURCE = args.schema || process.env.MANAGED_SETTINGS_SCHEMA || DEFAULT_SCHEMA_SOURCE;
|
|
const UPSTREAM = stripTrailingSlash(args.upstream || process.env.MOCK_POLICY_UPSTREAM || DEFAULT_UPSTREAM);
|
|
|
|
if (args.help) {
|
|
printHelp();
|
|
process.exit(0);
|
|
}
|
|
|
|
/** Explicit assets ensure GUI routing cannot shadow proxied API paths. */
|
|
const GUI_ASSETS = new Map<string, string>([
|
|
['/', path.join(PUBLIC_DIR, 'index.html')],
|
|
['/index.html', path.join(PUBLIC_DIR, 'index.html')],
|
|
['/style.css', path.join(PUBLIC_DIR, 'style.css')],
|
|
['/app.js', path.join(PUBLIC_DIR, 'app.ts')],
|
|
// Shared with the server, so it lives one level up.
|
|
['/endpoints.js', path.join(__dirname, 'endpoints.ts')]
|
|
]);
|
|
|
|
interface EndpointState {
|
|
status: number;
|
|
body: unknown;
|
|
/** When false the endpoint is proxied upstream instead of mocked. */
|
|
active: boolean;
|
|
}
|
|
|
|
const state = new Map<string, EndpointState>();
|
|
resetEndpointState();
|
|
|
|
interface EndpointUpdate {
|
|
endpoint: string;
|
|
preset?: string;
|
|
status?: number;
|
|
body?: unknown;
|
|
active?: boolean;
|
|
}
|
|
|
|
interface LogEntry {
|
|
at: number;
|
|
method: string;
|
|
path: string;
|
|
outcome: 'mocked' | 'passthrough' | 'upstream-error';
|
|
status: number;
|
|
}
|
|
|
|
/** Rolling log of what this server has served, newest first. Shown in the GUI. */
|
|
let requestLog: LogEntry[] = [];
|
|
const REQUEST_LOG_LIMIT = 200;
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
|
const pathname = url.pathname;
|
|
|
|
try {
|
|
// Control API and GUI assets are same-origin only — no CORS headers — so
|
|
// an unrelated website cannot drive /api/wire and rewrite the local
|
|
// product.overrides.json from the user's browser.
|
|
if (pathname === '/api' || pathname.startsWith('/api/')) {
|
|
if (!isAllowedControlOrigin(req)) {
|
|
return sendJson(res, 403, { error: 'Cross-origin control API requests are not allowed.' });
|
|
}
|
|
return handleControlApi(req, res, pathname);
|
|
}
|
|
|
|
if (req.method === 'GET' && GUI_ASSETS.has(pathname)) {
|
|
return serveAsset(GUI_ASSETS.get(pathname)!, res);
|
|
}
|
|
|
|
// The GUI page triggers an automatic favicon request. Answering it here
|
|
// keeps it out of both the upstream proxy and the request log, which
|
|
// otherwise shows a confusing 404 the user never asked for.
|
|
if (pathname === '/favicon.ico') {
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
// Mocked Copilot endpoints. Only these get permissive CORS, so the web
|
|
// build (browser) of Code OSS can call them cross-origin.
|
|
const endpoint = endpoints.find(endpoint => pathname === endpoint.path);
|
|
if (endpoint && state.get(endpoint.id)?.active) {
|
|
const entry = state.get(endpoint.id)!;
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Editor-Version, Copilot-Runtime-Version');
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
if (req.method === 'GET') {
|
|
record(req, pathname, 'mocked', entry.status);
|
|
return sendJson(res, entry.status, entry.body);
|
|
}
|
|
}
|
|
|
|
// Everything else — including endpoints left in passthrough — goes to the
|
|
// real API so a blanket proxy rule stays safe.
|
|
return passthrough(req, res, url);
|
|
} catch (e) {
|
|
sendJson(res, 500, { error: errorMessage(e) });
|
|
}
|
|
});
|
|
|
|
/** Same-origin control API used by the GUI. */
|
|
function handleControlApi(req: IncomingMessage, res: ServerResponse, pathname: string): void {
|
|
if (pathname === '/api/state' && req.method === 'GET') {
|
|
return sendJson(res, 200, getState());
|
|
}
|
|
|
|
if ((pathname === '/api' || pathname === '/api/') && req.method === 'GET') {
|
|
return sendJson(res, 200, {
|
|
name: 'Mock Policy Server Control API',
|
|
stateUpdate: {
|
|
single: { endpoint: 'managedSettings', preset: 'empty', status: 200, body: {}, active: true },
|
|
bulk: { endpoints: [{ endpoint: 'managedSettings', active: true }, { endpoint: 'entitlements', active: false }] }
|
|
},
|
|
routes: [
|
|
{ method: 'GET', path: '/api/state', purpose: 'Read endpoint definitions, presets, and current state.' },
|
|
{ method: 'POST', path: '/api/state', purpose: 'Apply one update or an atomic endpoints array.' },
|
|
{ method: 'POST', path: '/api/reset', purpose: 'Restore startup endpoint state.' },
|
|
{ method: 'GET', path: '/api/schema', purpose: 'Read the managed-settings schema.' },
|
|
{ method: 'GET', path: '/api/log', purpose: 'Read the request log.' },
|
|
{ method: 'DELETE', path: '/api/log', purpose: 'Clear the request log.' },
|
|
{ method: 'DELETE', path: '/api/cache', purpose: 'Clear the managed-settings disk cache.' },
|
|
{ method: 'POST', path: '/api/wire', purpose: 'Apply product.overrides.json.' },
|
|
{ method: 'POST', path: '/api/unwire', purpose: 'Restore product.overrides.json.' }
|
|
]
|
|
});
|
|
}
|
|
|
|
if (pathname === '/api/state' && req.method === 'POST') {
|
|
return readBody(req, (err, raw) => {
|
|
if (err) {
|
|
return sendJson(res, 400, { error: String(err) });
|
|
}
|
|
let payload: unknown;
|
|
try {
|
|
payload = JSON.parse(raw);
|
|
} catch (e) {
|
|
return sendJson(res, 400, { error: `Invalid JSON: ${errorMessage(e)}` });
|
|
}
|
|
const result = applyEndpointUpdates(payload);
|
|
if (!result.ok) {
|
|
return sendJson(res, 400, { error: result.error });
|
|
}
|
|
return sendJson(res, 200, getState());
|
|
});
|
|
}
|
|
|
|
if (pathname === '/api/reset' && req.method === 'POST') {
|
|
resetEndpointState();
|
|
return sendJson(res, 200, getState());
|
|
}
|
|
|
|
if (pathname === '/api/schema' && req.method === 'GET') {
|
|
return void loadSchema()
|
|
.then(result => sendJson(res, 200, result))
|
|
.catch(e => sendJson(res, 500, { error: errorMessage(e) }));
|
|
}
|
|
|
|
if (pathname === '/api/cache' && req.method === 'DELETE') {
|
|
try {
|
|
return sendJson(res, 200, clearManagedSettingsCache());
|
|
} catch (e) {
|
|
return sendJson(res, 500, { error: errorMessage(e) });
|
|
}
|
|
}
|
|
|
|
if (pathname === '/api/log' && req.method === 'GET') {
|
|
return sendJson(res, 200, { entries: requestLog });
|
|
}
|
|
|
|
if (pathname === '/api/log' && req.method === 'DELETE') {
|
|
requestLog = [];
|
|
return sendJson(res, 200, { entries: requestLog });
|
|
}
|
|
|
|
if (pathname === '/api/wire' && req.method === 'POST') {
|
|
try {
|
|
wireOverrides();
|
|
return sendJson(res, 200, getState());
|
|
} catch (e) {
|
|
return sendJson(res, 500, { error: errorMessage(e) });
|
|
}
|
|
}
|
|
|
|
if (pathname === '/api/unwire' && req.method === 'POST') {
|
|
try {
|
|
unwireOverrides();
|
|
return sendJson(res, 200, getState());
|
|
} catch (e) {
|
|
return sendJson(res, 500, { error: errorMessage(e) });
|
|
}
|
|
}
|
|
|
|
return sendJson(res, 404, { error: 'Not found' });
|
|
}
|
|
|
|
function applyEndpointUpdates(payload: unknown): { ok: true } | { ok: false; error: string } {
|
|
if (!isRecord(payload)) {
|
|
return { ok: false, error: 'Request body must be a JSON object.' };
|
|
}
|
|
|
|
let rawUpdates: unknown[];
|
|
if (Object.hasOwn(payload, 'endpoints')) {
|
|
const unknownKeys = Object.keys(payload).filter(key => key !== 'endpoints');
|
|
if (unknownKeys.length) {
|
|
return { ok: false, error: `Unknown top-level field${unknownKeys.length > 1 ? 's' : ''}: ${unknownKeys.join(', ')}.` };
|
|
}
|
|
if (!Array.isArray(payload.endpoints) || payload.endpoints.length === 0) {
|
|
return { ok: false, error: '"endpoints" must be a non-empty array.' };
|
|
}
|
|
rawUpdates = payload.endpoints;
|
|
} else {
|
|
rawUpdates = [payload];
|
|
}
|
|
|
|
const updates: EndpointUpdate[] = [];
|
|
const seen = new Set<string>();
|
|
for (const [index, rawUpdate] of rawUpdates.entries()) {
|
|
const prefix = rawUpdates.length > 1 ? `endpoints[${index}]` : 'Update';
|
|
if (!isRecord(rawUpdate)) {
|
|
return { ok: false, error: `${prefix} must be a JSON object.` };
|
|
}
|
|
|
|
const unknownKeys = Object.keys(rawUpdate).filter(key => !['endpoint', 'preset', 'status', 'body', 'active'].includes(key));
|
|
if (unknownKeys.length) {
|
|
return { ok: false, error: `${prefix} has unknown field${unknownKeys.length > 1 ? 's' : ''}: ${unknownKeys.join(', ')}.` };
|
|
}
|
|
if (typeof rawUpdate.endpoint !== 'string') {
|
|
return { ok: false, error: `${prefix}.endpoint must be a string.` };
|
|
}
|
|
if (seen.has(rawUpdate.endpoint)) {
|
|
return { ok: false, error: `Endpoint "${rawUpdate.endpoint}" appears more than once.` };
|
|
}
|
|
seen.add(rawUpdate.endpoint);
|
|
|
|
const def = endpoints.find(endpoint => endpoint.id === rawUpdate.endpoint);
|
|
if (!def) {
|
|
return { ok: false, error: `Unknown endpoint "${rawUpdate.endpoint}". Valid endpoints: ${endpoints.map(endpoint => endpoint.id).join(', ')}.` };
|
|
}
|
|
if (!['preset', 'status', 'body', 'active'].some(key => Object.hasOwn(rawUpdate, key))) {
|
|
return { ok: false, error: `${prefix} must include preset, status, body, or active.` };
|
|
}
|
|
if (Object.hasOwn(rawUpdate, 'preset')) {
|
|
if (typeof rawUpdate.preset !== 'string') {
|
|
return { ok: false, error: `${prefix}.preset must be a string.` };
|
|
}
|
|
if (!def.presets.some(preset => preset.id === rawUpdate.preset)) {
|
|
return { ok: false, error: `Unknown preset "${rawUpdate.preset}" for "${def.id}". Valid presets: ${def.presets.map(preset => preset.id).join(', ')}.` };
|
|
}
|
|
}
|
|
if (Object.hasOwn(rawUpdate, 'status')) {
|
|
if (typeof rawUpdate.status !== 'number' || !Number.isInteger(rawUpdate.status) || rawUpdate.status < 200 || rawUpdate.status > 599) {
|
|
return { ok: false, error: `${prefix}.status must be an integer from 200 to 599.` };
|
|
}
|
|
}
|
|
if (Object.hasOwn(rawUpdate, 'active') && typeof rawUpdate.active !== 'boolean') {
|
|
return { ok: false, error: `${prefix}.active must be a boolean.` };
|
|
}
|
|
|
|
const update: EndpointUpdate = { endpoint: rawUpdate.endpoint };
|
|
if (typeof rawUpdate.preset === 'string') {
|
|
update.preset = rawUpdate.preset;
|
|
}
|
|
if (typeof rawUpdate.status === 'number') {
|
|
update.status = rawUpdate.status;
|
|
}
|
|
if (Object.hasOwn(rawUpdate, 'body')) {
|
|
update.body = rawUpdate.body;
|
|
}
|
|
if (typeof rawUpdate.active === 'boolean') {
|
|
update.active = rawUpdate.active;
|
|
}
|
|
updates.push(update);
|
|
}
|
|
|
|
const nextState = new Map<string, EndpointState>();
|
|
for (const [id, entry] of state) {
|
|
nextState.set(id, { status: entry.status, body: clone(entry.body), active: entry.active });
|
|
}
|
|
|
|
for (const update of updates) {
|
|
const def = endpoints.find(endpoint => endpoint.id === update.endpoint)!;
|
|
const entry = nextState.get(update.endpoint)!;
|
|
if (update.preset !== undefined) {
|
|
const preset = def.presets.find(candidate => candidate.id === update.preset)!;
|
|
entry.status = preset.status ?? 200;
|
|
entry.body = clone(preset.body);
|
|
entry.active = true;
|
|
}
|
|
if (update.status !== undefined) {
|
|
entry.status = update.status;
|
|
}
|
|
if (Object.hasOwn(update, 'body')) {
|
|
entry.body = clone(update.body);
|
|
}
|
|
if (update.active !== undefined) {
|
|
entry.active = update.active;
|
|
}
|
|
}
|
|
|
|
for (const [id, entry] of nextState) {
|
|
state.set(id, entry);
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
function resetEndpointState(): void {
|
|
state.clear();
|
|
for (const endpoint of endpoints) {
|
|
const preset = endpoint.presets[0];
|
|
state.set(endpoint.id, {
|
|
status: preset?.status ?? 200,
|
|
body: preset ? clone(preset.body) : {},
|
|
active: endpoint.mockedByDefault === true
|
|
});
|
|
}
|
|
}
|
|
|
|
function isAllowedControlOrigin(req: IncomingMessage): boolean {
|
|
const origin = req.headers.origin;
|
|
if (!origin) {
|
|
return true;
|
|
}
|
|
if (Array.isArray(origin) || !req.headers.host) {
|
|
return false;
|
|
}
|
|
try {
|
|
return new URL(origin).origin === new URL(`http://${req.headers.host}`).origin;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Forward a request to the real upstream API and stream the response back, so
|
|
* anything this server is not deliberately faking still behaves normally.
|
|
*/
|
|
function passthrough(req: IncomingMessage, res: ServerResponse, url: URL): void {
|
|
const target = new URL(url.pathname + url.search, UPSTREAM);
|
|
const transport = target.protocol === 'http:' ? http : https;
|
|
|
|
const headers = { ...req.headers };
|
|
// Rewrite Host so upstream TLS/vhost routing works, and drop hop-by-hop
|
|
// headers that only described the connection to this server. The client's
|
|
// Authorization header is forwarded untouched so the response is real.
|
|
headers.host = target.host;
|
|
const connectionHeaders = Array.isArray(headers.connection) ? headers.connection : [headers.connection ?? ''];
|
|
const hopByHopHeaders = [
|
|
'connection',
|
|
'keep-alive',
|
|
'proxy-authenticate',
|
|
'proxy-authorization',
|
|
'proxy-connection',
|
|
'te',
|
|
'trailer',
|
|
'transfer-encoding',
|
|
'upgrade',
|
|
...connectionHeaders.flatMap(value => value.split(',')).map(value => value.trim().toLowerCase()).filter(Boolean),
|
|
];
|
|
for (const header of hopByHopHeaders) {
|
|
delete headers[header];
|
|
}
|
|
// Never negotiate compression on the client's behalf: identity keeps the
|
|
// streamed bytes readable in a proxy UI.
|
|
delete headers['accept-encoding'];
|
|
|
|
const upstreamReq = transport.request(target, { method: req.method, headers }, upstreamRes => {
|
|
record(req, url.pathname, 'passthrough', upstreamRes.statusCode ?? 0);
|
|
upstreamRes.on('error', error => {
|
|
record(req, url.pathname, 'upstream-error', 502);
|
|
res.destroy(error);
|
|
});
|
|
res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
|
|
upstreamRes.pipe(res);
|
|
});
|
|
|
|
upstreamReq.on('error', e => {
|
|
record(req, url.pathname, 'upstream-error', 502);
|
|
if (res.headersSent) {
|
|
res.end();
|
|
} else {
|
|
sendJson(res, 502, { error: `Upstream request to ${target.host} failed: ${errorMessage(e)}` });
|
|
}
|
|
});
|
|
|
|
req.pipe(upstreamReq);
|
|
}
|
|
|
|
server.on('error', error => {
|
|
if (Reflect.get(error, 'code') === 'EADDRINUSE') {
|
|
console.error(`\n Error: server already running at http://${HOST}:${PORT}/\n`);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
throw error;
|
|
});
|
|
|
|
server.listen(PORT, HOST, () => {
|
|
const base = `http://${HOST}:${PORT}`;
|
|
console.log('');
|
|
console.log(' Mock Copilot policy endpoints dev server');
|
|
console.log(' ----------------------------------------');
|
|
console.log('');
|
|
console.log(` Open the GUI: ${base}/`);
|
|
console.log(' Configure endpoint mocking and client routing in the GUI.');
|
|
console.log('');
|
|
console.log(` Upstream ${UPSTREAM} (anything not mocked is proxied here)`);
|
|
console.log(` Schema ${SCHEMA_SOURCE}`);
|
|
console.log('');
|
|
});
|
|
|
|
function printHelp(): void {
|
|
console.log('');
|
|
console.log(' Mock Copilot policy endpoints dev server.');
|
|
console.log('');
|
|
console.log(' Usage:');
|
|
console.log(' npm run mock-policy-server [-- <options>]');
|
|
console.log('');
|
|
console.log(' Options:');
|
|
console.log(' --host <addr> Address to bind (default 127.0.0.1)');
|
|
console.log(' --upstream <url> Real API that un-mocked requests are proxied to');
|
|
console.log(` (default ${DEFAULT_UPSTREAM}, env MOCK_POLICY_UPSTREAM)`);
|
|
console.log(' --schema <src> Managed-settings schema path, file: URI, or URL');
|
|
console.log(` (default ${DEFAULT_SCHEMA_SOURCE}, env MANAGED_SETTINGS_SCHEMA)`);
|
|
console.log(' --help Show this message');
|
|
console.log('');
|
|
}
|
|
|
|
|
|
/**
|
|
* Directories the Copilot runtime may use for its managed-settings disk cache.
|
|
*
|
|
* Mirrors `path_helpers::copilot_cache_home` +
|
|
* `managed_settings_cache::CACHE_SUBDIR` in copilot-agent-runtime:
|
|
* `COPILOT_CACHE_HOME` wins outright, otherwise the platform cache base is
|
|
* `~/Library/Caches` (macOS), `%LOCALAPPDATA%` (Windows) or
|
|
* `${XDG_CACHE_HOME:-~/.cache}` (Linux), with `copilot/managed-settings`
|
|
* beneath it.
|
|
*
|
|
* This matters because a *fresh* entry (< 1 hour) makes the runtime skip the
|
|
* network entirely, so a policy change here is never even requested.
|
|
*/
|
|
function managedSettingsCacheDirs(): string[] {
|
|
const dirs: string[] = [];
|
|
const add = (base: string | undefined, ...rest: string[]) => {
|
|
if (base) {
|
|
dirs.push(path.join(base, ...rest));
|
|
}
|
|
};
|
|
|
|
// COPILOT_CACHE_HOME replaces the whole platform cache base, so
|
|
// `copilot` is not appended beneath it.
|
|
add(process.env.COPILOT_CACHE_HOME, 'managed-settings');
|
|
|
|
if (process.platform === 'darwin') {
|
|
add(path.join(os.homedir(), 'Library', 'Caches'), 'copilot', 'managed-settings');
|
|
} else if (process.platform === 'win32') {
|
|
add(process.env.LOCALAPPDATA || path.join(os.homedir(), '.cache'), 'copilot', 'managed-settings');
|
|
} else {
|
|
add(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), 'copilot', 'managed-settings');
|
|
}
|
|
|
|
return [...new Set(dirs)];
|
|
}
|
|
|
|
/**
|
|
* Delete every managed-settings cache entry, reporting how many files were
|
|
* removed per directory so the GUI can say something more useful than "done".
|
|
* A missing directory is the normal case, not an error.
|
|
*/
|
|
function clearManagedSettingsCache(): { cleared: { dir: string; files: number }[]; missing: string[] } {
|
|
const cleared: { dir: string; files: number }[] = [];
|
|
const missing: string[] = [];
|
|
for (const dir of managedSettingsCacheDirs()) {
|
|
let entries: string[];
|
|
try {
|
|
entries = fs.readdirSync(dir);
|
|
} catch (error) {
|
|
if (Reflect.get(error, 'code') === 'ENOENT') {
|
|
missing.push(dir);
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
cleared.push({ dir, files: entries.length });
|
|
}
|
|
return { cleared, missing };
|
|
}
|
|
|
|
/** Append to the rolling request log, newest first. */
|
|
function record(req: IncomingMessage, pathname: string, outcome: LogEntry['outcome'], status: number): void {
|
|
requestLog.unshift({ at: Date.now(), method: req.method ?? 'GET', path: pathname, outcome, status });
|
|
if (requestLog.length > REQUEST_LOG_LIMIT) {
|
|
requestLog.length = REQUEST_LOG_LIMIT;
|
|
}
|
|
}
|
|
|
|
/** Local URL for an endpoint. */
|
|
function endpointUrl(endpoint: EndpointDef): string {
|
|
return `http://${HOST}:${PORT}${endpoint.path}`;
|
|
}
|
|
|
|
/**
|
|
* Resolve and load the managed-settings JSON schema from {@link SCHEMA_SOURCE}.
|
|
* Accepts a web URL (`http(s)://`), a `file://` URI, or a filesystem path
|
|
* (relative paths are resolved against the app's cwd). The GUI loads it once
|
|
* during initialization.
|
|
*/
|
|
async function loadSchema(): Promise<{ source: string; resolved: string; ok: boolean; schema?: unknown; error?: string }> {
|
|
const source = SCHEMA_SOURCE;
|
|
try {
|
|
if (/^https?:\/\//i.test(source)) {
|
|
const res = await fetch(source);
|
|
if (!res.ok) {
|
|
return { source, resolved: source, ok: false, error: `HTTP ${res.status} ${res.statusText}` };
|
|
}
|
|
return { source, resolved: source, ok: true, schema: await res.json() };
|
|
}
|
|
|
|
const filePath = source.startsWith('file://')
|
|
? fileURLToPath(source)
|
|
: path.resolve(process.cwd(), source);
|
|
|
|
// Guard against relative path traversal.
|
|
if (!path.isAbsolute(source) && filePath.includes('..')) {
|
|
return { source, resolved: filePath, ok: false, error: 'Relative paths must not contain ".."' };
|
|
}
|
|
|
|
if (!fs.existsSync(filePath)) {
|
|
return { source, resolved: filePath, ok: false, error: `Schema file not found at ${filePath}` };
|
|
}
|
|
const schema = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
return { source, resolved: filePath, ok: true, schema };
|
|
} catch (e) {
|
|
return { source, resolved: source, ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Prefer the schema checkout beside the primary VS Code checkout. Git worktrees
|
|
* point their `.git` file back to that checkout, so this also finds repositories
|
|
* such as `/Users/name/git/copilot-agent-runtime` when VS Code is running from a
|
|
* nested worktree.
|
|
*/
|
|
function resolveDefaultSchemaSource(): string {
|
|
const candidates: string[] = [];
|
|
const primaryCheckout = primaryCheckoutRoot();
|
|
if (primaryCheckout) {
|
|
candidates.push(path.join(path.dirname(primaryCheckout), DEFAULT_SCHEMA_RELATIVE_PATH));
|
|
}
|
|
candidates.push(
|
|
path.resolve(process.cwd(), DEFAULT_SCHEMA_RELATIVE_PATH),
|
|
path.resolve(ROOT, DEFAULT_SCHEMA_RELATIVE_PATH)
|
|
);
|
|
|
|
return candidates.find(candidate => fs.existsSync(candidate)) ?? candidates[0];
|
|
}
|
|
|
|
function primaryCheckoutRoot(): string | undefined {
|
|
const dotGit = path.join(ROOT, '.git');
|
|
try {
|
|
if (fs.statSync(dotGit).isDirectory()) {
|
|
return ROOT;
|
|
}
|
|
|
|
const match = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dotGit, 'utf8'));
|
|
if (!match) {
|
|
return undefined;
|
|
}
|
|
|
|
let gitDir = path.resolve(ROOT, match[1].trim());
|
|
while (path.dirname(gitDir) !== gitDir) {
|
|
if (path.basename(gitDir) === '.git') {
|
|
return path.dirname(gitDir);
|
|
}
|
|
gitDir = path.dirname(gitDir);
|
|
}
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** Build the state object the GUI renders. */
|
|
function getState() {
|
|
return {
|
|
endpoints: endpoints.map(e => ({
|
|
id: e.id,
|
|
label: e.label,
|
|
path: e.path,
|
|
productKey: e.productKey,
|
|
description: e.description,
|
|
schema: e.schema === true,
|
|
url: endpointUrl(e),
|
|
presets: e.presets,
|
|
status: state.get(e.id)!.status,
|
|
body: state.get(e.id)!.body,
|
|
active: state.get(e.id)!.active
|
|
})),
|
|
wired: isWired(),
|
|
overridesPath: PRODUCT_OVERRIDES_JSON,
|
|
overridesSnippet: buildOverridesSnippet(),
|
|
baseUrl: `http://${HOST}:${PORT}`,
|
|
upstream: UPSTREAM,
|
|
cacheDirs: managedSettingsCacheDirs()
|
|
};
|
|
}
|
|
|
|
/** Build the full overrides JSON a user would paste into product.overrides.json. */
|
|
function buildOverridesSnippet() {
|
|
const product = JSON.parse(fs.readFileSync(PRODUCT_JSON, 'utf8'));
|
|
const baseAgent = product?.defaultChatAgent ?? {};
|
|
return JSON.stringify({ defaultChatAgent: { ...baseAgent, ...overrideUrls() } }, null, '\t');
|
|
}
|
|
|
|
/** The `defaultChatAgent` URL overrides this server provides. */
|
|
function overrideUrls(): Record<string, string> {
|
|
const urls: Record<string, string> = {};
|
|
for (const endpoint of endpoints) {
|
|
urls[endpoint.productKey] = endpointUrl(endpoint);
|
|
}
|
|
return urls;
|
|
}
|
|
|
|
/** Whether `product.overrides.json` currently points every endpoint at this server. */
|
|
function isWired(): boolean {
|
|
let overrides;
|
|
try {
|
|
overrides = JSON.parse(fs.readFileSync(PRODUCT_OVERRIDES_JSON, 'utf8'));
|
|
} catch {
|
|
return false;
|
|
}
|
|
const agent = overrides?.defaultChatAgent;
|
|
if (!agent) {
|
|
return false;
|
|
}
|
|
const urls = overrideUrls();
|
|
return Object.keys(urls).every(key => agent[key] === urls[key]);
|
|
}
|
|
|
|
/**
|
|
* Write `product.overrides.json` so Code OSS calls this server for every policy
|
|
* endpoint.
|
|
*
|
|
* `src/bootstrap-meta.ts` merges overrides via `Object.assign` (shallow,
|
|
* top-level), so overriding nested keys requires writing back the whole
|
|
* `defaultChatAgent` object. We seed it from `product.json` and flip only the
|
|
* endpoint URLs, preserving every other key. Any other top-level overrides
|
|
* already present are kept untouched.
|
|
*/
|
|
function wireOverrides(): void {
|
|
const product = JSON.parse(fs.readFileSync(PRODUCT_JSON, 'utf8'));
|
|
const baseAgent = product?.defaultChatAgent ?? {};
|
|
|
|
// Back up existing overrides before touching them.
|
|
if (fs.existsSync(PRODUCT_OVERRIDES_JSON)) {
|
|
fs.copyFileSync(PRODUCT_OVERRIDES_JSON, PRODUCT_OVERRIDES_BACKUP);
|
|
console.log(` Backed up ${PRODUCT_OVERRIDES_JSON} -> ${PRODUCT_OVERRIDES_BACKUP}`);
|
|
}
|
|
|
|
let overrides = {};
|
|
try {
|
|
overrides = JSON.parse(fs.readFileSync(PRODUCT_OVERRIDES_JSON, 'utf8'));
|
|
} catch {
|
|
overrides = {};
|
|
}
|
|
|
|
const existingAgent = overrides.defaultChatAgent ?? baseAgent;
|
|
overrides.defaultChatAgent = {
|
|
...baseAgent,
|
|
...existingAgent,
|
|
...overrideUrls()
|
|
};
|
|
|
|
fs.writeFileSync(PRODUCT_OVERRIDES_JSON, JSON.stringify(overrides, null, '\t') + '\n');
|
|
console.log(` Wired ${PRODUCT_OVERRIDES_JSON} -> ${HOST}:${PORT}`);
|
|
}
|
|
|
|
/**
|
|
* Revert the endpoint overrides: restore each URL to its `product.json` value
|
|
* (or drop the key if absent). If `defaultChatAgent` ends up identical to
|
|
* `product.json`, drop it; if the overrides file ends up empty, remove it.
|
|
*/
|
|
function unwireOverrides(): void {
|
|
// If we have a backup, restore it wholesale instead of surgically reverting.
|
|
if (fs.existsSync(PRODUCT_OVERRIDES_BACKUP)) {
|
|
fs.copyFileSync(PRODUCT_OVERRIDES_BACKUP, PRODUCT_OVERRIDES_JSON);
|
|
fs.rmSync(PRODUCT_OVERRIDES_BACKUP, { force: true });
|
|
console.log(` Restored ${PRODUCT_OVERRIDES_JSON} from backup`);
|
|
return;
|
|
}
|
|
|
|
let overrides;
|
|
try {
|
|
overrides = JSON.parse(fs.readFileSync(PRODUCT_OVERRIDES_JSON, 'utf8'));
|
|
} catch {
|
|
return; // nothing to unwire
|
|
}
|
|
if (!overrides.defaultChatAgent) {
|
|
return;
|
|
}
|
|
|
|
const product = JSON.parse(fs.readFileSync(PRODUCT_JSON, 'utf8'));
|
|
const baseAgent = product?.defaultChatAgent ?? {};
|
|
|
|
const agent = { ...overrides.defaultChatAgent };
|
|
for (const endpoint of endpoints) {
|
|
if (baseAgent[endpoint.productKey] === undefined) {
|
|
delete agent[endpoint.productKey];
|
|
} else {
|
|
agent[endpoint.productKey] = baseAgent[endpoint.productKey];
|
|
}
|
|
}
|
|
|
|
if (shallowEqual(agent, baseAgent)) {
|
|
delete overrides.defaultChatAgent;
|
|
} else {
|
|
overrides.defaultChatAgent = agent;
|
|
}
|
|
|
|
if (Object.keys(overrides).length === 0) {
|
|
fs.rmSync(PRODUCT_OVERRIDES_JSON, { force: true });
|
|
console.log(` Removed ${PRODUCT_OVERRIDES_JSON} (no overrides left)`);
|
|
} else {
|
|
fs.writeFileSync(PRODUCT_OVERRIDES_JSON, JSON.stringify(overrides, null, '\t') + '\n');
|
|
console.log(` Unwired ${PRODUCT_OVERRIDES_JSON}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Serve one allowlisted GUI asset. `.ts` sources are served as plain JavaScript
|
|
* via Node's built-in `module.stripTypeScriptTypes()`, which lets the browser
|
|
* GUI stay in TypeScript with no build step.
|
|
*
|
|
* `filePath` always comes from {@link GUI_ASSETS}, so there is no
|
|
* user-controlled path to traverse out of and no way for a static read to
|
|
* shadow an API path that should be proxied upstream.
|
|
*/
|
|
function serveAsset(filePath: string, res: ServerResponse): void {
|
|
let source: Buffer | string;
|
|
try {
|
|
source = fs.readFileSync(filePath);
|
|
} catch {
|
|
return sendJson(res, 404, { error: `Missing GUI asset ${path.basename(filePath)}` });
|
|
}
|
|
|
|
if (filePath.endsWith('.ts')) {
|
|
source = stripTypeScriptTypes(source.toString('utf8'));
|
|
}
|
|
|
|
res.writeHead(200, { 'Content-Type': contentType(filePath), 'Cache-Control': 'no-store' });
|
|
res.end(source);
|
|
}
|
|
|
|
function contentType(filePath: string): string {
|
|
switch (path.extname(filePath)) {
|
|
case '.html': return 'text/html; charset=utf-8';
|
|
case '.js': return 'text/javascript; charset=utf-8';
|
|
// `.ts` assets are served type-stripped, so they are JavaScript by the
|
|
// time they reach the browser.
|
|
case '.ts': return 'text/javascript; charset=utf-8';
|
|
case '.css': return 'text/css; charset=utf-8';
|
|
case '.json': return 'application/json; charset=utf-8';
|
|
default: return 'application/octet-stream';
|
|
}
|
|
}
|
|
|
|
function sendJson(res: ServerResponse, status: number, obj: unknown): void {
|
|
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
res.end(JSON.stringify(obj, null, 2));
|
|
}
|
|
|
|
function readBody(req: IncomingMessage, cb: (err: Error | null, raw: string) => void): void {
|
|
let raw = '';
|
|
req.on('data', chunk => { raw += chunk; if (raw.length > 1_000_000) { req.destroy(); } });
|
|
req.on('end', () => cb(null, raw));
|
|
req.on('error', err => cb(err, ''));
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function shallowEqual(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
|
|
const ak = Object.keys(a);
|
|
const bk = Object.keys(b);
|
|
if (ak.length !== bk.length) {
|
|
return false;
|
|
}
|
|
return ak.every(k => JSON.stringify(a[k]) === JSON.stringify(b[k]));
|
|
}
|
|
|
|
function clone(value: unknown): unknown {
|
|
return JSON.parse(JSON.stringify(value));
|
|
}
|
|
|
|
function errorMessage(e: unknown): string {
|
|
return e instanceof Error ? e.message : String(e);
|
|
}
|
|
|
|
function stripTrailingSlash(value: string): string {
|
|
return value.endsWith('/') ? value.slice(0, -1) : value;
|
|
}
|
|
|
|
interface ServerArgs {
|
|
host?: string;
|
|
schema?: string;
|
|
upstream?: string;
|
|
help: boolean;
|
|
}
|
|
|
|
function parseArgs(argv: string[]): ServerArgs {
|
|
const out: ServerArgs = { help: false };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const argument = argv[i];
|
|
if (argument === '--help') {
|
|
out.help = true;
|
|
continue;
|
|
}
|
|
if (!argument.startsWith('--')) {
|
|
failArgument(`Unexpected argument "${argument}".`);
|
|
}
|
|
|
|
const [key, inline] = argument.slice(2).split('=', 2);
|
|
if (key !== 'host' && key !== 'schema' && key !== 'upstream') {
|
|
failArgument(`Unknown option "--${key}".`);
|
|
}
|
|
|
|
const next = argv[i + 1];
|
|
const value = inline ?? (next && !next.startsWith('--') ? next : undefined);
|
|
if (!value) {
|
|
failArgument(`Option "--${key}" requires a value.`);
|
|
}
|
|
out[key] = value;
|
|
if (inline === undefined) {
|
|
i++;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function failArgument(message: string): never {
|
|
console.error(`\n Error: ${message} Run with --help for usage.\n`);
|
|
process.exit(1);
|
|
}
|