mirror of
https://github.com/microsoft/vscode.git
synced 2026-06-29 02:45:58 +01:00
593c7f2366
* policy: add dev mock server for copilot_internal policy endpoints Adds scripts/mock-policy-server, a standalone dev tool (npm run mock-policy-server) that mocks the Copilot policy endpoints DefaultAccountService calls: entitlements (/copilot_internal/user), token (/copilot_internal/v2/token), MCP registry (/copilot/mcp_registry) and managed settings (/copilot_internal/managed_settings). A small web GUI lets devs pick presets or edit each JSON response, and Wire/Unwire buttons point product.overrides.json at the local server (preserving the rest of defaultChatAgent, since bootstrap-meta merges overrides shallowly). The managed-settings JSON schema is loaded from --schema/MANAGED_SETTINGS_SCHEMA, defaulting to ./copilot-agent-runtime/schema/managed-settings-schema.json relative to the app cwd; web URLs and file URIs are accepted, and the GUI warns about keys not declared in the schema. The three browser/shared .js files are added to .eslint-allowed-javascript-files since the GUI loads them directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: address mock-policy-server review feedback - Scope permissive CORS to the mocked GET endpoints only; keep /api/* same-origin so a website can't drive /api/wire and rewrite product.overrides.json (CSRF). - Coerce an empty editor body to {} instead of "" so mocked responses stay JSON objects. - Build the endpoint meta line with textContent/DOM nodes instead of innerHTML. - Drop the misused tablist/tab ARIA roles; the nav now has an aria-label and the active item uses aria-current. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: document mock policy server in add-policy skill Add local-testing.md to the add-policy skill with basic steps for using the mock policy server (scripts/mock-policy-server) to exercise the account/managed-settings flow locally, and link it from SKILL.md and github-managed-settings.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: polish mock server GUI — schema validation, wiring backup, localStorage persistence * policy: auto-save, rename wiring to product.overrides.json, copy path button * mock-policy-server: convert server.js to TypeScript; add raw response diagnostics - Convert server.js → server.ts (runs via --experimental-strip-types) - Add endpoints.d.ts type declarations for the UMD endpoints module - Add managedSettingsRawResponse to IDefaultAccountProvider/IDefaultAccountService - Show raw response in Developer: Sync Account Policy output - Remove server.js from eslint allowed-javascript-files * mock-policy-server: convert all JS to TypeScript - endpoints.js → endpoints.ts with proper interfaces (replaces .d.ts) - public/app.js → public/app.ts with full type annotations - Server uses module.stripTypeScriptTypes() to serve .ts as plain JS to the browser — no build step needed - Remove all mock-policy-server entries from .eslint-allowed-javascript-files --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
438 lines
15 KiB
TypeScript
438 lines
15 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
/**
|
|
* Standalone dev tool: a local mock of the Copilot "policy" endpoints that
|
|
* `DefaultAccountService` calls (entitlements, token, MCP registry, managed
|
|
* settings), plus a small web GUI to author each response and to wire/unwire
|
|
* `product.overrides.json`.
|
|
*
|
|
* This tool is NOT part of the shipped product. Run it from sources with:
|
|
*
|
|
* npm run mock-policy-server
|
|
*
|
|
* Then open the printed URL, pick an endpoint, edit the JSON, Save, and Wire.
|
|
* Reload Code OSS and run "Developer: Sync Account Policy" +
|
|
* "Developer: Policy Diagnostics".
|
|
*/
|
|
|
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
import type { EndpointDef } from './endpoints';
|
|
|
|
const http = require('node:http') as typeof import('node:http');
|
|
const fs = require('node:fs') as typeof import('node:fs');
|
|
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');
|
|
|
|
/**
|
|
* Default location of the managed-settings JSON schema, resolved against the
|
|
* app's current working directory (i.e. where `npm run mock-policy-server` is
|
|
* invoked — normally the vscode repo root). On dev machines the schema sits at
|
|
* `./copilot-agent-runtime/schema/managed-settings-schema.json`. Override with
|
|
* `--schema <url|file-uri|path>` or the `MANAGED_SETTINGS_SCHEMA` env var; web
|
|
* (`http(s)://`) and `file://` URIs are both accepted.
|
|
*/
|
|
const DEFAULT_SCHEMA_SOURCE = 'copilot-agent-runtime/schema/managed-settings-schema.json';
|
|
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const PORT = Number(args.port || process.env.PORT || 3000);
|
|
const HOST = args.host || '127.0.0.1';
|
|
const SCHEMA_SOURCE = args.schema || process.env.MANAGED_SETTINGS_SCHEMA || DEFAULT_SCHEMA_SOURCE;
|
|
|
|
/** Path -> endpoint definition. */
|
|
const endpointByPath = new Map(endpoints.map(e => [e.path, e]));
|
|
|
|
const currentBodies: Record<string, unknown> = {};
|
|
for (const endpoint of endpoints) {
|
|
currentBodies[endpoint.id] = endpoint.presets[0] ? clone(endpoint.presets[0].body) : {};
|
|
}
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
|
const pathname = url.pathname;
|
|
|
|
try {
|
|
// Mocked Copilot endpoints. Only these get permissive CORS, so the web
|
|
// build (browser) of Code OSS can call them cross-origin. The control API
|
|
// (/api/*) and static assets stay same-origin: that avoids a CSRF surface
|
|
// where an unrelated website could drive /api/wire and rewrite the local
|
|
// product.overrides.json.
|
|
const endpoint = endpointByPath.get(pathname);
|
|
if (endpoint) {
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
if (req.method === 'GET') {
|
|
return sendJson(res, 200, currentBodies[endpoint.id]);
|
|
}
|
|
}
|
|
|
|
// GUI control API (same-origin only — no CORS).
|
|
if (pathname === '/api/state' && req.method === 'GET') {
|
|
return sendJson(res, 200, getState());
|
|
}
|
|
|
|
if (pathname === '/api/schema' && req.method === 'GET') {
|
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
const sourceParam = url.searchParams.get('source') || undefined;
|
|
return loadSchema(sourceParam)
|
|
.then(result => sendJson(res, 200, result))
|
|
.catch(e => sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) }));
|
|
}
|
|
|
|
if (pathname === '/api/state' && req.method === 'POST') {
|
|
return readBody(req, (err, raw) => {
|
|
if (err) {
|
|
return sendJson(res, 400, { error: String(err) });
|
|
}
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(raw);
|
|
} catch (e) {
|
|
return sendJson(res, 400, { error: `Invalid JSON: ${e instanceof Error ? e.message : String(e)}` });
|
|
}
|
|
const def = endpoints.find(e => e.id === payload?.endpoint);
|
|
if (!def) {
|
|
return sendJson(res, 400, { error: `Unknown endpoint "${payload?.endpoint}".` });
|
|
}
|
|
currentBodies[def.id] = payload.body;
|
|
return sendJson(res, 200, getState());
|
|
});
|
|
}
|
|
|
|
if (pathname === '/api/wire' && req.method === 'POST') {
|
|
try {
|
|
wireOverrides();
|
|
return sendJson(res, 200, getState());
|
|
} catch (e) {
|
|
return sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) });
|
|
}
|
|
}
|
|
|
|
if (pathname === '/api/unwire' && req.method === 'POST') {
|
|
try {
|
|
unwireOverrides();
|
|
return sendJson(res, 200, getState());
|
|
} catch (e) {
|
|
return sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) });
|
|
}
|
|
}
|
|
|
|
if (req.method === 'GET') {
|
|
return serveStatic(pathname, res);
|
|
}
|
|
|
|
sendJson(res, 404, { error: 'Not found' });
|
|
} catch (e) {
|
|
sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) });
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, HOST, () => {
|
|
const base = `http://${HOST}:${PORT}`;
|
|
console.log('');
|
|
console.log(' Mock Copilot policy endpoints dev server');
|
|
console.log(' ----------------------------------------');
|
|
console.log(` GUI: ${base}/`);
|
|
for (const endpoint of endpoints) {
|
|
console.log(` ${endpoint.label.padEnd(18)} ${base}${endpoint.path}`);
|
|
}
|
|
console.log('');
|
|
console.log(` Managed-settings schema source: ${SCHEMA_SOURCE}`);
|
|
console.log('');
|
|
console.log(' Open the GUI, edit the responses, Save, then Wire product.overrides.json.');
|
|
console.log(' Reload Code OSS and run "Developer: Sync Account Policy".');
|
|
console.log('');
|
|
});
|
|
|
|
/** The URL Code OSS should call for a given 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). Re-reads on every call so
|
|
* a dev can edit the schema and refresh the GUI without restarting the server.
|
|
*/
|
|
async function loadSchema(sourceOverride?: string): Promise<{ source: string; resolved: string; ok: boolean; schema?: unknown; error?: string }> {
|
|
const source = sourceOverride || 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) };
|
|
}
|
|
}
|
|
|
|
/** 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,
|
|
url: endpointUrl(e),
|
|
presets: e.presets,
|
|
body: currentBodies[e.id]
|
|
})),
|
|
wired: isWired(),
|
|
overridesPath: PRODUCT_OVERRIDES_JSON,
|
|
overridesSnippet: buildOverridesSnippet()
|
|
};
|
|
}
|
|
|
|
/** 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 a file from the public/ directory (plus the shared endpoints.js). */
|
|
/**
|
|
* Read a `.ts` source file, strip type annotations via Node's built-in
|
|
* `module.stripTypeScriptTypes()`, and serve the result as plain JavaScript.
|
|
* This lets the browser GUI stay in TypeScript without a build step.
|
|
*/
|
|
function serveTypeStripped(tsPath: string, res: ServerResponse): void {
|
|
const source = fs.readFileSync(tsPath, 'utf8');
|
|
const stripped = stripTypeScriptTypes(source);
|
|
res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8' });
|
|
res.end(stripped);
|
|
}
|
|
|
|
function serveStatic(pathname: string, res: ServerResponse): void {
|
|
// The GUI loads the shared endpoints module that lives one level up.
|
|
if (pathname === '/endpoints.js') {
|
|
return serveTypeStripped(path.join(__dirname, 'endpoints.ts'), res);
|
|
}
|
|
|
|
const rel = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '');
|
|
|
|
// Serve .ts sources as type-stripped JS when the browser requests .js.
|
|
if (rel.endsWith('.js')) {
|
|
const tsPath = path.normalize(path.join(PUBLIC_DIR, rel.replace(/\.js$/, '.ts')));
|
|
if (tsPath.startsWith(PUBLIC_DIR + path.sep) && fs.existsSync(tsPath)) {
|
|
return serveTypeStripped(tsPath, res);
|
|
}
|
|
}
|
|
|
|
const filePath = path.normalize(path.join(PUBLIC_DIR, rel));
|
|
|
|
// Guard against path traversal outside public/.
|
|
if (!filePath.startsWith(PUBLIC_DIR + path.sep) || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
|
return sendJson(res, 404, { error: 'Not found' });
|
|
}
|
|
|
|
res.writeHead(200, { 'Content-Type': contentType(filePath) });
|
|
fs.createReadStream(filePath).pipe(res);
|
|
}
|
|
|
|
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';
|
|
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' });
|
|
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 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 parseArgs(argv: string[]): Record<string, string> {
|
|
const out: Record<string, string> = {};
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a.startsWith('--')) {
|
|
const key = a.slice(2);
|
|
const next = argv[i + 1];
|
|
if (next && !next.startsWith('--')) {
|
|
out[key] = next;
|
|
i++;
|
|
} else {
|
|
out[key] = 'true';
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|