Polish mock policy server UI (#334122)

* Polish mock policy server UI

Improve schema controls, delivery setup, theming, and the editable schema source workflow.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address mock policy server review feedback

Restrict schema source mutation to loopback URLs, avoid :has state styling, and keep the schema status live region rendered.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
joshspicer
2026-09-02 23:40:22 +00:00
committed by GitHub
co-authored by Copilot
parent a285bdcf22
commit f78dd812b1
6 changed files with 680 additions and 295 deletions
@@ -58,7 +58,9 @@ both Code OSS and the mock server with the same isolated `COPILOT_CACHE_HOME`.
The managed-settings schema is auto-detected from a sibling
`copilot-agent-runtime` checkout, including when VS Code runs from a Git
worktree. Use `--schema` or `MANAGED_SETTINGS_SCHEMA` at server startup to
override it; the GUI does not reload schema sources.
override it. The GUI's **Schema source** field can load a different path, file
URI, or HTTP(S) URL for the current server process; restart the server to return
to its startup source.
See the [mock policy server README](../../../scripts/mock-policy-server/README.md)
for proxy setup, cache locations, schema loading, and server options.
+6 -1
View File
@@ -216,6 +216,7 @@ request does not appear in **Live Requests**.
| `POST` | `/api/state` | Apply and persist one update or an atomic endpoint array |
| `POST` | `/api/reset` | Restore and persist default endpoint state |
| `GET` | `/api/schema` | Read the managed-settings schema |
| `POST` | `/api/schema` | Change and reload the schema source for the current server process (loopback URL only) |
| `GET` | `/api/file-deployment` | Generate file install and removal commands |
| `GET`, `DELETE` | `/api/log` | Read or clear the request log |
| `DELETE` | `/api/cache` | Clear the managed-settings disk cache |
@@ -225,7 +226,11 @@ request does not appear in **Live Requests**.
The server auto-detects
`copilot-agent-runtime/schema/managed-settings-schema.json` beside the primary
VS Code checkout, including from a Git worktree. Override it at startup with
`--schema` or `MANAGED_SETTINGS_SCHEMA`.
`--schema` or `MANAGED_SETTINGS_SCHEMA`, or edit **Schema source** in the GUI
and select **Load Schema**. GUI changes apply to the current server process and
reset to the startup source when the server restarts. Changing the schema source
is restricted to requests made through a loopback URL because the source can be
a local file path or remote URL.
```sh
npm run mock-policy-server -- --upstream https://api.ghe.example.com
+94 -21
View File
@@ -59,6 +59,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
interface SchemaResult {
ok: boolean;
source: string;
resolved?: string;
error?: string;
schema?: JsonSchema;
@@ -107,6 +108,8 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
const responseConfiguration = $('response-configuration');
const responseStatusValidation = $('response-status-validation');
const presetSelect = $('preset') as HTMLSelectElement;
const schemaSourceInput = $('schema-source') as HTMLInputElement;
const loadSchemaButton = $('load-schema') as HTMLButtonElement;
const endpointMeta = $('endpoint-meta');
const editorStatus = $('editor-status');
const saveStateEl = $('save-state');
@@ -117,6 +120,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
const draftStoragePrefix = 'mock-policy-server.response-body.';
const synchronizedDraftStoragePrefix = 'mock-policy-server.synchronized-response-body.';
const disclosureStoragePrefix = 'mock-policy-server.expanded.';
const themeStorageKey = 'mock-policy-server.theme';
let endpoints: Endpoint[] = [];
let activeId = '';
@@ -137,6 +141,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
// tell an external change (control API, another tab) from our own edits.
let lastServerSignature = '';
let stateWritesInFlight = 0;
let followsSystemTheme = true;
const pendingSaves = new Map<string, ReturnType<typeof setTimeout>>();
let allowNextTabToMoveFocus = false;
@@ -148,6 +153,45 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
return `${draftStoragePrefix}${endpointId}`;
}
type Theme = 'light' | 'dark';
function systemTheme(): Theme {
return matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
function applyTheme(theme: Theme): void {
document.documentElement.dataset.theme = theme;
const nextTheme = theme === 'dark' ? 'light' : 'dark';
const toggle = $('theme-toggle');
toggle.dataset.theme = theme;
toggle.setAttribute('aria-label', `Switch to ${nextTheme} mode`);
toggle.title = `Switch to ${nextTheme} mode`;
}
function restoreTheme(): void {
let persistedTheme: string | null = null;
try {
persistedTheme = localStorage.getItem(themeStorageKey);
} catch (error) {
reportBrowserStorageError(error);
}
const theme = persistedTheme === 'light' || persistedTheme === 'dark' ? persistedTheme : undefined;
followsSystemTheme = theme === undefined;
applyTheme(theme ?? systemTheme());
}
function toggleTheme(): void {
const currentTheme = document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
const nextTheme = currentTheme === 'dark' ? 'light' : 'dark';
followsSystemTheme = false;
applyTheme(nextTheme);
try {
localStorage.setItem(themeStorageKey, nextTheme);
} catch (error) {
reportBrowserStorageError(error);
}
}
function synchronizedDraftStorageKey(endpointId: string): string {
return `${synchronizedDraftStoragePrefix}${endpointId}`;
}
@@ -219,15 +263,14 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
}
}
function setExpandableSectionState(detailsId: string, toggleId: string, chevronId: string, storageKey: string, expanded: boolean): void {
$(detailsId).hidden = !expanded;
$(chevronId).classList.toggle('open', expanded);
$(toggleId).setAttribute('aria-expanded', String(expanded));
persistExpandedState(storageKey, expanded);
function setSchemaSectionState(expanded: boolean): void {
$('schema-details').hidden = !expanded;
$('schema-chevron').classList.toggle('open', expanded);
$('schema-toggle').setAttribute('aria-expanded', String(expanded));
}
function restoreDisclosureState(): void {
setExpandableSectionState('schema-details', 'schema-toggle', 'schema-chevron', 'schema', readExpandedState('schema'));
setSchemaSectionState(false);
for (const details of document.querySelectorAll<HTMLDetailsElement>('details[data-persist-expanded]')) {
const key = details.dataset.persistExpanded;
if (!key) {
@@ -812,9 +855,17 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
function selectSetupMethod(method: SetupMethod): void {
for (const candidate of ['proxy', 'file'] as const) {
const selected = candidate === method;
$(`${candidate}-method`).dataset.selected = String(selected);
$(`${candidate}-method-steps`).toggleAttribute('inert', !selected);
($(`setup-method-${candidate}`) as HTMLInputElement).checked = selected;
const panel = $(`${candidate}-method`);
panel.dataset.selected = String(selected);
panel.hidden = !selected;
panel.toggleAttribute('inert', !selected);
const content = $(`${candidate}-method-content`);
content.hidden = !selected;
content.toggleAttribute('inert', !selected);
const input = $(`setup-method-${candidate}`) as HTMLInputElement;
input.checked = selected;
input.setAttribute('aria-expanded', String(selected));
$(`setup-method-${candidate}-option`).dataset.selected = String(selected);
}
}
@@ -1183,30 +1234,46 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
}, 400));
}
async function loadSchema(): Promise<void> {
async function loadSchema(source?: string): Promise<void> {
const badgeEl = $('schema-badge');
const statusEl = $('schema-source-status');
badgeEl.textContent = 'Loading\u2026';
delete badgeEl.dataset.kind;
loadSchemaButton.disabled = true;
statusEl.textContent = '';
statusEl.dataset.kind = '';
try {
const result = await api<SchemaResult>('/api/schema');
const result = await api<SchemaResult>('/api/schema', source === undefined ? undefined : {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source })
});
schemaSourceInput.value = result.source || result.resolved || source || '';
if (result.ok) {
schema = result.schema ?? null;
badgeEl.textContent = 'Loaded \u2713';
badgeEl.dataset.kind = 'ok';
badgeEl.dataset.tooltip = result.resolved || 'Schema loaded';
$('schema-source').textContent = result.resolved || '';
statusEl.textContent = source === undefined ? '' : 'Schema loaded.';
statusEl.dataset.kind = source === undefined ? '' : 'ok';
} else {
schema = null;
badgeEl.textContent = 'Not loaded';
badgeEl.dataset.kind = 'error';
badgeEl.dataset.tooltip = result.error || 'Schema unavailable';
$('schema-source').textContent = result.resolved || '';
statusEl.textContent = result.error || 'Schema unavailable.';
statusEl.dataset.kind = 'error';
}
} catch (e) {
schema = null;
badgeEl.textContent = 'Not loaded';
badgeEl.dataset.kind = 'error';
badgeEl.dataset.tooltip = e instanceof Error ? e.message : String(e);
$('schema-source').textContent = '';
statusEl.textContent = badgeEl.dataset.tooltip;
statusEl.dataset.kind = 'error';
} finally {
loadSchemaButton.disabled = false;
}
parseEditor();
}
@@ -1316,7 +1383,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
}
function toggleSchemaSection(): void {
setExpandableSectionState('schema-details', 'schema-toggle', 'schema-chevron', 'schema', Boolean($('schema-details').hidden));
setSchemaSectionState(Boolean($('schema-details').hidden));
}
function openFileDeploy(): void {
@@ -1423,7 +1490,14 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
$('proxy-settings').textContent = vscodeProxySetting;
$('macos-cache-command').textContent = macOsCacheClearCommand;
$('windows-cache-command').textContent = windowsCacheClearCommand;
restoreTheme();
restoreDisclosureState();
$('theme-toggle').addEventListener('click', toggleTheme);
matchMedia('(prefers-color-scheme: light)').addEventListener('change', event => {
if (followsSystemTheme) {
applyTheme(event.matches ? 'light' : 'dark');
}
});
editor.addEventListener('input', () => {
drafts[activeId] = editor.value;
@@ -1486,11 +1560,6 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
}
$('setup-nav').addEventListener('click', openSetupDialog);
$('close-setup').addEventListener('click', () => setupDialog.close());
$('policies-nav').addEventListener('click', () => {
if (setupDialog.open) {
setupDialog.close();
}
});
setupDialog.addEventListener('close', () => {
$('setup-nav').setAttribute('aria-expanded', 'false');
if (location.hash === '#setup') {
@@ -1499,6 +1568,10 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
});
window.addEventListener('hashchange', syncSetupDialog);
$('schema-toggle').addEventListener('click', toggleSchemaSection);
$('schema-source-form').addEventListener('submit', event => {
event.preventDefault();
void loadSchema(schemaSourceInput.value);
});
$('hydrate-schema').addEventListener('click', () => {
if (!schema) {
setStatus('Schema unavailable.', 'error');
@@ -1517,7 +1590,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
parseEditor();
renderFileDeploy();
void save();
setStatus('Generated an example from the schema.', 'ok');
setStatus('Filled all response fields from the schema.', 'ok');
});
$('clear-log').addEventListener('click', async () => {
try {
+179 -135
View File
@@ -20,7 +20,7 @@
<p>Test Copilot policy responses against a local server.</p>
</div>
<div class="header-actions">
<nav class="page-nav" aria-label="Mock policy server">
<div class="page-nav">
<div class="setup-status-group">
<button id="setup-nav" type="button" class="setup-trigger" aria-haspopup="dialog"
aria-controls="setup-dialog" aria-expanded="false">Setup</button>
@@ -29,8 +29,11 @@
<span id="global-connection-label">Checking connection…</span>
</div>
</div>
<a id="policies-nav" href="#policies" aria-current="page">Policies</a>
</nav>
</div>
<button id="theme-toggle" type="button" class="theme-toggle" aria-label="Switch to light mode">
<span class="theme-toggle-icon theme-toggle-light" aria-hidden="true">&#9788;</span>
<span class="theme-toggle-icon theme-toggle-dark" aria-hidden="true">&#9790;</span>
</button>
</div>
</div>
</header>
@@ -38,13 +41,13 @@
<main class="app-main">
<dialog id="setup-dialog" class="setup-dialog" aria-labelledby="setup-title">
<div class="dialog-actions">
<button id="close-setup" type="button" class="btn-secondary">Close Setup</button>
<button id="close-setup" type="button" class="btn-secondary btn-compact">Close</button>
</div>
<section id="setup-page" class="setup-page" aria-labelledby="setup-title">
<div class="setup-intro">
<div>
<p class="eyebrow">Connect a client</p>
<h2 id="setup-title">Route policy requests to this server</h2>
<h2 id="setup-title">Test managed settings in a client</h2>
<p class="setup-lede">
Learn about <a href="https://docs.github.com/en/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/configure-enterprise-managed-settings" target="_blank" rel="noopener">configuring GitHub Copilot managed settings</a>
and review the <a href="https://docs.github.com/en/copilot/reference/enterprise-administrators/enterprise-managed-settings" target="_blank" rel="noopener">managed settings schema and supported agent policy keys</a>.
@@ -52,23 +55,37 @@
</div>
</div>
<fieldset class="setup-method-picker">
<legend class="sr-only">Connection method</legend>
<div class="setup-methods">
<fieldset class="delivery-picker">
<legend>Delivery mechanism</legend>
<div class="delivery-options">
<label id="setup-method-proxy-option" class="delivery-option" for="setup-method-proxy" data-selected="true">
<input id="setup-method-proxy" type="radio" name="setup-method" value="proxy"
aria-expanded="true" aria-controls="proxy-method-content" checked>
<span class="delivery-option-copy">
<strong>Mock Server API</strong>
<span>Route client requests to this server through an HTTPS rewriting proxy.</span>
</span>
<span class="method-badge">Recommended</span>
</label>
<label id="setup-method-file-option" class="delivery-option" for="setup-method-file" data-selected="false">
<input id="setup-method-file" type="radio" name="setup-method" value="file"
aria-expanded="false" aria-controls="file-method-content">
<span class="delivery-option-copy">
<strong>File-based</strong>
<span>Deploy <code>managed-settings.json</code> directly to a local client.</span>
</span>
<span class="method-badge secondary">No proxy</span>
</label>
</div>
</fieldset>
<div class="setup-methods">
<article id="proxy-method" class="setup-method recommended" data-selected="true">
<div class="method-heading">
<div class="method-choice">
<input id="setup-method-proxy" type="radio" name="setup-method" value="proxy" checked>
<div class="method-copy">
<label class="method-label" for="setup-method-proxy">
<span class="method-badge">Recommended</span>
<h3>System Proxy</h3>
</label>
<span class="method-recommendation">Any HTTP debugging proxy that can rewrite HTTPS requests works. I suggest <a href="https://proxyman.com/" target="_blank" rel="noreferrer">Proxyman</a>.</span>
<label class="method-description" for="setup-method-proxy">
Routes policy requests from Code OSS, Stable, Insiders, Copilot CLI, and SDK/runtime clients. VS Code clients also require the proxy setting shown below.
</label>
</div>
<div class="method-copy">
<h3>System proxy</h3>
<span class="method-recommendation">Any HTTPS rewriting proxy works. <a href="https://proxyman.com/" target="_blank" rel="noopener noreferrer">Proxyman</a> is recommended.</span>
<p class="method-description">Routes policy requests from VS Code, Copilot CLI, and SDK clients to this server.</p>
</div>
<div class="method-status">
<div id="proxy-status" class="status-pill" data-state="checking" role="status" aria-live="polite">Checking…</div>
@@ -76,120 +93,130 @@
</div>
</div>
<ol id="proxy-method-steps" class="setup-steps">
<li>
<div class="step-number" aria-hidden="true">1</div>
<div class="step-content">
<h4>Configure the system proxy</h4>
<p>Configure an HTTPS rewrite from the managed settings URL to this local server. In Proxyman, create a Map Remote rule using these values.</p>
<div class="mapping-grid">
<div class="code-field">
<span class="label-small">Map from</span>
<code id="map-from" class="block"></code>
<div id="proxy-method-content" class="method-content">
<ol class="setup-steps">
<li>
<div class="step-number" aria-hidden="true">1</div>
<div class="step-content">
<h4>Configure the system proxy</h4>
<p>Configure an HTTPS rewrite from the managed settings URL to this local server. In Proxyman, create a Map Remote rule using these values.</p>
<div class="mapping-grid">
<div class="code-field">
<span class="label-small">Map from</span>
<code id="map-from" class="block"></code>
</div>
<div class="mapping-arrow" aria-hidden="true">→</div>
<div class="code-field">
<span class="label-small">Map to</span>
<code id="map-to" class="block"></code>
</div>
</div>
<div class="mapping-arrow" aria-hidden="true">→</div>
<div class="code-field">
<span class="label-small">Map to</span>
<code id="map-to" class="block"></code>
<button id="copy-map" type="button" class="btn-secondary">Copy Mapping</button>
<details data-persist-expanded="proxy-tips">
<summary>Proxyman tips</summary>
<ul>
<li>Keep the source and destination paths identical.</li>
<li>If HTTPS requests do not appear, install and trust Proxyman's certificate.</li>
</ul>
<div class="platform-tips">
<section>
<h5>macOS</h5>
<ul>
<li>Turn on <code>Tools &gt; macOS Proxy</code> before checking the connection.</li>
</ul>
</section>
<section>
<h5>Windows</h5>
<ul>
<li>Turn on <code>Tools &gt; Override Windows Proxy</code> before checking the connection. Proxyman still shows its listening port while this is off, but Windows leaves system proxying disabled, so clients connect directly and no requests are captured.</li>
<li>Confirm Windows picked up the change: <code>ProxyEnable</code> should be <code>1</code> and <code>ProxyServer</code> should be Proxyman's host and port under <code>HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings</code>.</li>
</ul>
</section>
</div>
</details>
<div class="required-setting">
<p><strong>Required for VS Code:</strong> add this property inside the client's <code>settings.json</code>.</p>
<div class="code-field">
<span class="label-small">VS Code settings.json property</span>
<code id="proxy-settings" class="block settings-json"></code>
</div>
<button id="copy-proxy-settings" type="button" class="btn-secondary">Copy Setting Property</button>
</div>
</div>
<button id="copy-map" type="button" class="btn-secondary">Copy Mapping</button>
<details data-persist-expanded="proxy-tips">
<summary>Proxyman tips</summary>
<ul>
<li>Keep the source and destination paths identical.</li>
<li>If HTTPS requests do not appear, install and trust Proxyman's certificate.</li>
</ul>
<div class="platform-tips">
<section>
<h5>macOS</h5>
<ul>
<li>Turn on <code>Tools &gt; macOS Proxy</code> before checking the connection.</li>
</ul>
</section>
<section>
<h5>Windows</h5>
<ul>
<li>Turn on <code>Tools &gt; Override Windows Proxy</code> before checking the connection. Proxyman still shows its listening port while this is off, but Windows leaves system proxying disabled, so clients connect directly and no requests are captured.</li>
<li>Confirm Windows picked up the change: <code>ProxyEnable</code> should be <code>1</code> and <code>ProxyServer</code> should be Proxyman's host and port under <code>HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings</code>.</li>
</ul>
</section>
</div>
</details>
<div class="required-setting">
<p><strong>Required for VS Code:</strong> add this property inside the client's <code>settings.json</code>.</p>
<div class="code-field">
<span class="label-small">VS Code settings.json property</span>
<code id="proxy-settings" class="block settings-json"></code>
</div>
<button id="copy-proxy-settings" type="button" class="btn-secondary">Copy Setting Property</button>
</li>
<li>
<div class="step-number" aria-hidden="true">2</div>
<div class="step-content">
<h4>Request fresh policy in VS Code</h4>
<p>Run <code>&gt; Developer: Sync Account Policy</code>. For Local Agent Host, also run <code>&gt; Developer: Restart Local Agent Host</code>.</p>
<p class="helper-text">If no request appears under Live Requests on the Policies page, open Troubleshooting in the right sidebar, clear the SDK policy cache for your platform, and then run the commands again.</p>
</div>
</li>
<li>
<div class="step-number" aria-hidden="true">3</div>
<div class="step-content">
<h4>Filter responses in your proxy <span class="optional-label">Optional</span></h4>
<p>Use this display filter to show only real policy requests:</p>
<code class="block setup-filter">^(?!.*mockPolicySetupProbe).*managed_settings.*</code>
<p class="helper-text">Keep probes in the Map Remote rule; filter only the displayed traffic.</p>
</div>
</li>
</ol>
<aside class="setup-warning" aria-labelledby="setup-probe-title">
<h4 id="setup-probe-title" class="setup-warning-title">
<span aria-hidden="true">&#x26A0;</span> Automatic probe requests are expected
</h4>
<div class="setup-warning-content">
<p>This page sends an unauthenticated request every five seconds:</p>
<code id="setup-probe-shape" class="block"></code>
<p>The probe uses the same proxy mapping and drives the connection status. It is excluded from Live Requests.</p>
</div>
</li>
<li>
<div class="step-number" aria-hidden="true">2</div>
<div class="step-content">
<h4>Request fresh policy in VS Code</h4>
<p>Open the Command Palette and run <code>&gt; Developer: Sync Account Policy</code>.</p>
<p>To refresh the policy used by Local Agent Host, also run <code>&gt; Developer: Restart Local Agent Host</code>.</p>
<p class="helper-text">If no request appears under Live Requests on the Policies page, open Troubleshooting in the right sidebar, clear the SDK policy cache for your platform, and then run the commands again.</p>
</div>
</li>
</ol>
<aside class="setup-warning" aria-labelledby="setup-probe-title">
<h4 id="setup-probe-title"><span aria-hidden="true">&#x26A0;</span> Automatic probe requests are expected</h4>
<p>While this page is open, it sends an unauthenticated request every five seconds with this shape:</p>
<code id="setup-probe-shape" class="block"></code>
<p>The probe must pass through the same proxy mapping as real policy traffic. The page only reports the proxy as connected when the mapped response returns this mock server's identifying header.</p>
<p>This server excludes probes from Live Requests. To also hide them in Proxyman, use the display-filter regex <code>^(?!.*mockPolicySetupProbe).*managed_settings.*</code>. Use it only as a display filter&mdash;do not exclude probes from the Map Remote rule.</p>
</aside>
</aside>
</div>
</article>
<article id="file-method" class="setup-method alternative" data-selected="false">
<article id="file-method" class="setup-method alternative" data-selected="false" hidden inert>
<div class="method-heading">
<div class="method-choice">
<input id="setup-method-file" type="radio" name="setup-method" value="file">
<label class="method-label" for="setup-method-file">
<span class="method-badge secondary">No proxy</span>
<h3>File-based settings</h3>
<p>Deploy <code>managed-settings.json</code> to the client device. It loads from disk at startup &mdash; before sign-in and with no proxy or server round trip &mdash; so these requests never reach this server. Local clients only.</p>
</label>
<div class="method-copy">
<h3>File-based settings</h3>
<p class="method-description">Deploy <code>managed-settings.json</code> directly to a local client. It loads from disk without a proxy or server request.</p>
</div>
<div class="method-status">
<p>Not detectable from here. Deploy the file, restart the client, then verify inside the client.</p>
</div>
</div>
<ol id="file-method-steps" class="setup-steps compact" inert>
<li>
<div class="step-number" aria-hidden="true">1</div>
<div class="step-content">
<h4>Write the policy file</h4>
<p>On the Policies page, edit the Managed Settings response body, then copy the command for the client platform from <strong>File Deployment</strong> and run it in a terminal.</p>
<button id="file-deploy-goto" type="button" class="btn-secondary">Go to Deploy as a File</button>
<details data-persist-expanded="file-locations">
<summary>File locations</summary>
<ul>
<li>macOS: <code>/Library/Application Support/GitHubCopilot/managed-settings.json</code></li>
<li>Windows: <code>%ProgramFiles%\GitHubCopilot\managed-settings.json</code></li>
<li>Linux: <code>/etc/github-copilot/managed-settings.json</code></li>
</ul>
<p>On macOS and Linux, Copilot CLI requires a regular file owned by <code>root</code> that is not group- or world-writable, so the copied command uses <code>sudo</code>.</p>
</details>
</div>
</li>
<li>
<div class="step-number" aria-hidden="true">2</div>
<div class="step-content">
<h4>Reload the client</h4>
<p>Restart the client so it loads the file, or in VS Code run <code>&gt; Developer: Sync Account Policy</code>. File-based settings persist across accounts and don't expire like the SDK policy cache.</p>
<p class="helper-text">Use this to avoid proxying entirely, or deploy it alongside a server-managed response to test precedence. <a href="https://docs.github.com/en/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/configure-enterprise-managed-settings#deploying-file-based-settings" target="_blank" rel="noopener">Deploying file-based settings</a></p>
</div>
</li>
</ol>
<div id="file-method-content" class="method-content" hidden inert>
<ol class="setup-steps compact">
<li>
<div class="step-number" aria-hidden="true">1</div>
<div class="step-content">
<h4>Write the policy file</h4>
<p>On the Policies page, edit the Managed Settings response body, then copy the command for the client platform from <strong>File Deployment</strong> and run it in a terminal.</p>
<button id="file-deploy-goto" type="button" class="btn-secondary">Go to Deploy as a File</button>
<details data-persist-expanded="file-locations">
<summary>File locations</summary>
<ul>
<li>macOS: <code>/Library/Application Support/GitHubCopilot/managed-settings.json</code></li>
<li>Windows: <code>%ProgramFiles%\GitHubCopilot/managed-settings.json</code></li>
<li>Linux: <code>/etc/github-copilot/managed-settings.json</code></li>
</ul>
<p>On macOS and Linux, Copilot CLI requires a regular file owned by <code>root</code> that is not group- or world-writable, so the copied command uses <code>sudo</code>.</p>
</details>
</div>
</li>
<li>
<div class="step-number" aria-hidden="true">2</div>
<div class="step-content">
<h4>Reload the client</h4>
<p>Restart the client so it loads the file, or in VS Code run <code>&gt; Developer: Sync Account Policy</code>. File-based settings persist across accounts and don't expire like the SDK policy cache.</p>
<p class="helper-text">Use this to avoid proxying entirely, or deploy it alongside a server-managed response to test precedence. <a href="https://docs.github.com/en/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/configure-enterprise-managed-settings#deploying-file-based-settings" target="_blank" rel="noopener">Deploying file-based settings</a></p>
</div>
</li>
</ol>
</div>
</article>
</div>
</fieldset>
</div>
</section>
</dialog>
@@ -228,22 +255,6 @@
<p id="response-status-validation" class="status" aria-live="polite"></p>
</div>
<div id="schema-section" class="section-block" hidden>
<h2 class="schema-heading">
<button type="button" class="section-header section-header-toggle" id="schema-toggle"
aria-expanded="false" aria-controls="schema-details">
<span class="chevron" id="schema-chevron" aria-hidden="true">&#9654;</span>
<span>Schema</span>
<span id="schema-badge" class="schema-badge" data-tooltip="">&hellip;</span>
</button>
</h2>
<div id="schema-details" class="schema-details" hidden>
<span class="label-small">Schema source</span>
<code id="schema-source" class="block"></code>
<button id="hydrate-schema" type="button" class="btn-secondary">Generate Example</button>
</div>
</div>
<div class="form-group">
<div class="label-row">
<label for="editor">Response body (JSON)</label>
@@ -264,6 +275,39 @@
</section>
<aside class="sidebar-panel">
<div id="schema-section" class="section-block" hidden>
<h2 class="schema-heading">
<button type="button" class="section-header section-header-toggle" id="schema-toggle"
aria-expanded="false" aria-controls="schema-details">
<span class="chevron" id="schema-chevron" aria-hidden="true">&#9654;</span>
<span>Schema</span>
<span id="schema-badge" class="schema-badge" data-tooltip="">&hellip;</span>
</button>
</h2>
<div id="schema-details" class="schema-details" hidden>
<form id="schema-source-form" class="schema-source-form">
<div class="schema-source-label-row">
<label class="label-small" for="schema-source">Schema source</label>
<div class="info-tooltip-container">
<button type="button" class="info-button" aria-label="About the schema source"
aria-describedby="schema-source-tooltip">i</button>
<div id="schema-source-tooltip" class="info-tooltip" role="tooltip">
Point this to <code>schema/managed-settings-schema.json</code> from the <code>github/copilot-agent-runtime</code> repository. You can use a path from a local checkout, a <code>file:</code> URI, or an HTTP(S) URL.
</div>
</div>
</div>
<div class="schema-source-controls">
<input id="schema-source" class="schema-source-input" type="text" autocomplete="off"
spellcheck="false" aria-describedby="schema-source-status">
<button id="load-schema" type="submit" class="btn-secondary btn-compact"
title="Load schema from this source">Load</button>
</div>
<p id="schema-source-status" class="status" aria-live="polite"></p>
</form>
<button id="hydrate-schema" type="button" class="btn-secondary btn-compact"
title="Replace the response body with generated values for every schema field">Fill All Schema Fields</button>
</div>
</div>
<div class="section-block">
<div class="section-header">
<h2 id="live-requests-title">Live Requests</h2>
+343 -131
View File
@@ -21,6 +21,9 @@
--focus: #4daafc;
--accent-soft: color-mix(in srgb, var(--accent) 16%, transparent);
--ok-soft: color-mix(in srgb, var(--ok) 12%, transparent);
--error-soft: color-mix(in srgb, var(--error) 12%, transparent);
--dialog-backdrop: rgba(0, 0, 0, 0.5);
--dialog-shadow: rgba(0, 0, 0, 0.28);
}
/*
@@ -44,9 +47,51 @@
--code-bg: #f0f0f0;
--code-border: #d4d4d4;
--focus: #0b5c92;
--dialog-backdrop: rgba(0, 0, 0, 0.38);
--dialog-shadow: rgba(0, 0, 0, 0.18);
}
}
:root[data-theme='light'] {
color-scheme: light;
--bg: #f7f7f7;
--surface: #ffffff;
--surface-alt: #ececec;
--border: #c8c8c8;
--text: #1f1f1f;
--text-secondary: #5c5c5c;
--accent: #0b5c92;
--accent-hover: #0a4d7a;
--ok: #0a7a5e;
--warn: #8a6100;
--error: #b3261e;
--code-bg: #f0f0f0;
--code-border: #d4d4d4;
--focus: #0b5c92;
--dialog-backdrop: rgba(0, 0, 0, 0.38);
--dialog-shadow: rgba(0, 0, 0, 0.18);
}
:root[data-theme='dark'] {
color-scheme: dark;
--bg: #1e1e1e;
--surface: #252526;
--surface-alt: #2d2d2e;
--border: #3c3c3c;
--text: #e0e0e0;
--text-secondary: #a0a0a0;
--accent: #0e639c;
--accent-hover: #1177bb;
--ok: #4ec9b0;
--warn: #d7ba7d;
--error: #f48771;
--code-bg: #1b1b1b;
--code-border: #3c3c3c;
--focus: #4daafc;
--dialog-backdrop: rgba(0, 0, 0, 0.5);
--dialog-shadow: rgba(0, 0, 0, 0.28);
}
* {
box-sizing: border-box;
}
@@ -77,8 +122,8 @@ body {
}
header {
padding: 16px 24px;
background: linear-gradient(135deg, var(--surface-alt) 0%, var(--surface) 100%);
padding: 10px 24px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
@@ -88,12 +133,19 @@ header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
gap: 16px;
flex-wrap: wrap;
}
.brand {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
h1 {
font-size: 18px;
font-size: 16px;
font-weight: 600;
margin: 0;
}
@@ -106,9 +158,10 @@ h2 {
}
.brand p {
margin: 2px 0 0;
margin: 0;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.4;
}
.header-actions,
@@ -172,7 +225,7 @@ h2 {
}
.page-nav {
padding: 2px;
padding: 0;
}
.setup-status-group {
@@ -184,7 +237,6 @@ h2 {
background: color-mix(in srgb, var(--bg) 24%, transparent);
}
.page-nav a,
.setup-trigger {
padding: 6px 12px;
border: 0;
@@ -195,12 +247,10 @@ h2 {
text-decoration: none;
}
.page-nav a:hover,
.setup-trigger:hover {
color: var(--text);
}
.page-nav a[aria-current='page'],
.setup-trigger[aria-expanded='true'] {
background: var(--surface-alt);
color: var(--text);
@@ -299,16 +349,6 @@ h2 {
.policies-layout {
grid-template-columns: 1fr;
}
.header-content,
.header-actions {
align-items: stretch;
}
.header-actions {
width: 100%;
justify-content: space-between;
}
}
.editor-panel {
@@ -354,7 +394,7 @@ h2 {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
gap: 10px;
}
.section-block[hidden] {
@@ -419,42 +459,109 @@ h2 {
/* --- Setup ----------------------------------------------------------------- */
.setup-dialog {
width: min(1280px, calc(100vw - 48px));
width: min(920px, calc(100vw - 32px));
max-width: none;
max-height: calc(100vh - 48px);
max-height: calc(100vh - 32px);
padding: 20px;
overflow: auto;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
background: var(--surface);
color: var(--text);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
box-shadow: 0 16px 48px var(--dialog-shadow);
}
.setup-dialog::backdrop {
background: rgba(0, 0, 0, 0.62);
background: var(--dialog-backdrop);
}
.dialog-actions {
position: sticky;
top: -20px;
z-index: 2;
top: 0;
z-index: 3;
display: flex;
justify-content: flex-end;
padding: 0 0 12px;
background: var(--bg);
height: 0;
}
.setup-page {
display: flex;
flex-direction: column;
gap: 20px;
max-width: 1240px;
gap: 16px;
max-width: 880px;
margin: 0 auto;
}
.setup-intro {
padding: 0 0 4px;
padding: 4px 56px 8px 4px;
}
.delivery-picker {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.delivery-picker legend {
margin-bottom: 6px;
padding: 0;
font-size: 12px;
font-weight: 600;
}
.delivery-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
overflow: hidden;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
}
.delivery-option {
display: grid;
grid-template-columns: 16px minmax(0, 1fr) auto;
gap: 8px;
align-items: start;
min-width: 0;
padding: 16px;
cursor: pointer;
transition: background 0.15s ease;
}
.delivery-option + .delivery-option {
border-left: 1px solid var(--border);
}
.delivery-option[data-selected='true'] {
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
}
.delivery-option:focus-within {
outline: 1px solid var(--focus);
outline-offset: -1px;
}
.delivery-option input {
width: 14px;
height: 14px;
margin: 2px 0 0;
accent-color: var(--accent);
cursor: pointer;
}
.delivery-option-copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.delivery-option-copy > span {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.4;
}
.eyebrow {
@@ -467,11 +574,11 @@ h2 {
}
.setup-lede {
max-width: 640px;
margin: 10px 0 0;
max-width: 680px;
margin: 8px 0 0;
color: var(--text-secondary);
font-size: 14px;
line-height: 1.5;
font-size: 13px;
line-height: 1.4;
}
.setup-lede a {
@@ -490,66 +597,25 @@ h2 {
.setup-methods {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.setup-method-picker {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
padding-bottom: 8px;
}
.setup-method {
position: relative;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
}
.setup-method[data-selected='true'] {
border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
}
.setup-method[data-selected='false'] {
background: color-mix(in srgb, var(--surface) 72%, var(--bg));
}
.setup-method[data-selected='false'] .method-choice,
.setup-method[data-selected='false'] .setup-steps,
.setup-method[data-selected='false'] .setup-warning {
opacity: 0.42;
background: transparent;
}
.method-heading {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 20px 16px;
gap: 12px;
padding: 12px 4px 16px;
border-bottom: 1px solid var(--border);
}
.method-choice {
display: grid;
grid-template-columns: 16px minmax(0, 1fr);
gap: 8px;
min-width: 0;
}
.method-choice input {
width: 14px;
height: 14px;
margin: 6px 0 0;
accent-color: var(--accent);
cursor: pointer;
}
.method-label {
min-width: 0;
cursor: pointer;
.method-content[hidden] {
display: none;
}
.method-copy {
@@ -558,20 +624,22 @@ h2 {
.method-description {
display: block;
margin-top: 8px;
cursor: pointer;
margin-top: 4px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.4;
}
.method-heading h3 {
margin: 4px 0 0;
font-size: 18px;
margin: 0;
font-size: 16px;
font-weight: 600;
line-height: 1.3;
}
.method-recommendation {
display: block;
margin-top: 2px;
margin-top: 4px;
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
@@ -583,9 +651,9 @@ h2 {
.method-heading p {
max-width: 640px;
margin: 6px 0 0;
margin: 4px 0 0;
color: var(--text-secondary);
line-height: 1.5;
line-height: 1.4;
}
.method-badge {
@@ -660,7 +728,7 @@ h2 {
flex-direction: column;
align-items: flex-end;
gap: 6px;
max-width: 280px;
max-width: 220px;
}
.method-status p {
@@ -676,20 +744,38 @@ h2 {
flex-direction: column;
gap: 0;
margin: 0;
padding: 0 20px;
padding: 20px 4px 4px;
list-style: none;
}
.setup-warning {
margin: 0 20px 20px;
padding: 12px;
border: 1px solid var(--warn);
margin: 0 4px 8px 44px;
padding: 12px 16px;
border: 1px solid var(--border);
border-left: 2px solid var(--warn);
border-radius: 6px;
background: color-mix(in srgb, var(--warn) 10%, transparent);
background: color-mix(in srgb, var(--warn) 6%, transparent);
}
.setup-warning h4 {
.setup-warning-title {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
color: var(--warn);
font-size: 12px;
font-weight: 600;
}
.setup-warning-content {
margin-top: 6px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.4;
}
.setup-warning-content p {
margin: 4px 0;
}
.setup-warning .block {
@@ -698,40 +784,74 @@ h2 {
word-break: break-word;
}
.setup-steps > li {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 12px;
padding: 20px 0;
.setup-filter {
margin: 8px 0;
white-space: pre-wrap;
word-break: break-word;
}
.setup-steps > li + li {
border-top: 1px solid var(--border);
.setup-steps > li {
position: relative;
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
gap: 12px;
padding: 0 0 28px;
}
.setup-steps > li:last-child {
padding-bottom: 20px;
}
.setup-steps > li:not(:last-child)::before {
position: absolute;
top: 32px;
bottom: 4px;
left: 13px;
width: 1px;
background: var(--border);
content: '';
}
.step-number {
display: inline-flex;
align-items: flex-start;
align-items: center;
justify-content: center;
width: 20px;
padding-top: 4px;
color: var(--text-secondary);
font-size: 11px;
width: 28px;
height: 28px;
border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border));
border-radius: 50%;
background: var(--accent-soft);
color: var(--focus);
font-size: 12px;
font-weight: 600;
}
.step-content {
min-width: 0;
max-width: 760px;
}
.step-content h4 {
margin: 2px 0 4px;
font-size: 13px;
margin: 4px 0 8px;
font-size: 14px;
font-weight: 600;
}
.optional-label {
margin-left: 4px;
padding: 2px 6px;
border-radius: 999px;
background: var(--surface-alt);
color: var(--text-secondary);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
vertical-align: 1px;
}
.step-content > p {
margin: 0 0 12px;
margin: 0 0 8px;
color: var(--text-secondary);
line-height: 1.5;
}
@@ -739,9 +859,9 @@ h2 {
.mapping-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
gap: 8px;
gap: 12px;
align-items: end;
margin-bottom: 10px;
margin: 12px 0;
}
.mapping-arrow {
@@ -757,13 +877,14 @@ h2 {
}
.code-field code.block {
min-height: 56px;
min-height: 44px;
}
.required-setting {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
padding: 16px;
border-radius: 6px;
background: var(--bg);
}
.required-setting > p {
@@ -780,17 +901,24 @@ h2 {
}
.step-content details {
margin-top: 12px;
margin-top: 10px;
color: var(--text-secondary);
}
.step-content summary {
width: fit-content;
padding: 6px 8px;
border-radius: 4px;
background: var(--bg);
cursor: pointer;
color: var(--text);
font-weight: 600;
}
.step-content summary:hover {
background: var(--surface-alt);
}
.step-content details ul {
margin: 8px 0 0;
padding-left: 20px;
@@ -806,8 +934,8 @@ h2 {
.platform-tips {
display: grid;
gap: 12px;
margin-top: 12px;
gap: 10px;
margin-top: 10px;
}
.platform-tips h5 {
@@ -870,12 +998,6 @@ button:disabled {
}
}
@media (max-width: 1000px) {
.setup-methods {
grid-template-columns: 1fr;
}
}
@media (max-width: 680px) {
header,
.app-main {
@@ -887,6 +1009,20 @@ button:disabled {
align-items: center;
}
.brand {
flex-wrap: wrap;
gap: 2px 8px;
}
.delivery-options {
grid-template-columns: 1fr;
}
.delivery-option + .delivery-option {
border-top: 1px solid var(--border);
border-left: 0;
}
.page-nav {
justify-content: flex-start;
}
@@ -898,7 +1034,7 @@ button:disabled {
}
.dialog-actions {
top: -16px;
top: 0;
}
.method-heading {
@@ -1021,6 +1157,7 @@ label {
select,
textarea,
.schema-source-input,
.status-code-input {
font-family: inherit;
font-size: inherit;
@@ -1034,12 +1171,14 @@ textarea,
select:hover,
textarea:hover,
.schema-source-input:hover,
.status-code-input:hover {
border-color: var(--border);
}
select:focus,
textarea:focus,
.schema-source-input:focus,
.status-code-input:focus {
outline: none;
border-color: var(--accent);
@@ -1105,6 +1244,46 @@ button {
filter: brightness(1.15);
}
.btn-compact {
align-self: flex-start;
padding: 4px 8px;
font-size: 12px;
}
.theme-toggle {
align-self: center;
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border-color: var(--border);
border-radius: 9999px;
background: color-mix(in srgb, var(--bg) 24%, transparent);
color: var(--text-secondary);
}
.theme-toggle:hover {
border-color: var(--text-secondary);
}
.theme-toggle-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 9999px;
font-size: 16px;
line-height: 1;
transition: background 0.15s ease, color 0.15s ease;
}
.theme-toggle[data-theme='light'] .theme-toggle-light,
.theme-toggle[data-theme='dark'] .theme-toggle-dark {
background: var(--accent-soft);
color: var(--accent);
}
.btn-warn {
background: transparent;
border-color: var(--warn);
@@ -1337,12 +1516,12 @@ code {
.schema-badge[data-kind="ok"] {
color: var(--ok);
background: rgba(78, 201, 176, 0.1);
background: var(--ok-soft);
}
.schema-badge[data-kind="error"] {
color: var(--error);
background: rgba(244, 135, 113, 0.1);
background: var(--error-soft);
}
.schema-badge:hover {
@@ -1370,7 +1549,8 @@ code {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.schema-badge:hover::after {
.schema-badge:hover::after,
.schema-heading:focus-within .schema-badge::after {
opacity: 1;
}
@@ -1381,6 +1561,38 @@ code {
margin-top: 0;
}
.schema-source-form {
display: flex;
flex-direction: column;
gap: 8px;
}
.schema-source-label-row {
display: flex;
align-items: center;
gap: 4px;
}
.schema-source-controls {
display: flex;
align-items: stretch;
gap: 6px;
}
.schema-source-input {
flex: 1;
min-width: 0;
font-family: 'SF Mono', Menlo, Consolas, monospace;
}
.schema-source-controls .btn-compact {
align-self: stretch;
}
.schema-source-form .status:empty {
min-height: 0;
}
.schema-details[hidden] {
display: none;
}
+55 -6
View File
@@ -34,7 +34,7 @@ const MOCK_SERVER_HEADER = 'X-Mock-Policy-Server';
const args = parseArgs(process.argv.slice(2));
const PORT = args.port ?? DEFAULT_PORT;
const HOST = args.host || '127.0.0.1';
const SCHEMA_SOURCE = args.schema || process.env.MANAGED_SETTINGS_SCHEMA || DEFAULT_SCHEMA_SOURCE;
let schemaSource = args.schema || process.env.MANAGED_SETTINGS_SCHEMA || DEFAULT_SCHEMA_SOURCE;
const UPSTREAM = stripTrailingSlash(args.upstream || process.env.MOCK_POLICY_UPSTREAM || DEFAULT_UPSTREAM);
const STATE_FILE = path.resolve(args.stateFile || process.env.MOCK_POLICY_STATE_FILE || DEFAULT_STATE_FILE);
@@ -88,6 +88,7 @@ const CONTROL_ROUTES = [
{ method: 'POST', path: '/api/state', purpose: 'Apply and persist one update or an atomic endpoints array.', returns: 'Updated server state.', sideEffects: 'server-state,filesystem' },
{ method: 'POST', path: '/api/reset', purpose: 'Restore and persist default endpoint state.', returns: 'Reset server state.', sideEffects: 'server-state,filesystem' },
{ method: 'GET', path: '/api/schema', purpose: 'Read the managed-settings schema.', returns: 'Schema source, resolved location, load status, and schema or error.', sideEffects: 'none' },
{ method: 'POST', path: '/api/schema', purpose: 'Change and reload the managed-settings schema source for this server process. Only accepted through a loopback URL.', returns: 'Schema source, resolved location, load status, and schema or error.', sideEffects: 'server-state' },
{ method: 'GET', path: '/api/file-deployment', purpose: 'Generate install and removal commands for the current Managed Settings body.', returns: 'Source body and per-platform paths, install commands, and removal commands.', sideEffects: 'none' },
{ method: 'GET', path: '/api/log', purpose: 'Read the request log.', returns: 'Object containing the newest-first entries array.', sideEffects: 'none' },
{ method: 'DELETE', path: '/api/log', purpose: 'Clear the request log.', returns: 'Object containing an empty entries array.', sideEffects: 'server-state' },
@@ -254,6 +255,37 @@ function handleControlApi(req: IncomingMessage, res: ServerResponse, pathname: s
.catch(e => sendJson(res, 500, { error: errorMessage(e) }));
}
if (pathname === '/api/schema' && req.method === 'POST') {
if (!isLoopbackRequest(req)) {
return sendJson(res, 403, { error: 'Changing the schema source is only allowed from a loopback URL.' });
}
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)}` });
}
if (!isRecord(payload)) {
return sendJson(res, 400, { error: 'Request body must be a JSON object.' });
}
const unknownKeys = Object.keys(payload).filter(key => key !== 'source');
if (unknownKeys.length) {
return sendJson(res, 400, { error: `Unknown field${unknownKeys.length > 1 ? 's' : ''}: ${unknownKeys.join(', ')}.` });
}
if (typeof payload.source !== 'string' || !payload.source.trim()) {
return sendJson(res, 400, { error: '"source" must be a non-empty string.' });
}
schemaSource = payload.source.trim();
return void loadSchema()
.then(result => sendJson(res, 200, result))
.catch(e => sendJson(res, 500, { error: errorMessage(e) }));
});
}
if (pathname === '/api/file-deployment' && req.method === 'GET') {
return sendJson(res, 200, getFileDeployment());
}
@@ -493,6 +525,24 @@ function isAllowedControlOrigin(req: IncomingMessage): boolean {
}
}
function isLoopbackRequest(req: IncomingMessage): boolean {
const remoteAddress = req.socket.remoteAddress;
const host = req.headers.host;
if (!remoteAddress || !host || Array.isArray(host)) {
return false;
}
try {
return isLoopbackAddress(remoteAddress) && isLoopbackAddress(new URL(`http://${host}`).hostname);
} catch {
return false;
}
}
function isLoopbackAddress(address: string): boolean {
const normalized = address.replace(/^\[|\]$/g, '').replace(/^::ffff:/, '');
return normalized === '::1' || normalized === 'localhost' || /^127(?:\.\d{1,3}){3}$/.test(normalized);
}
/**
* Forward a request to the real upstream API and stream the response back, so
* anything this server is not deliberately faking still behaves normally.
@@ -567,7 +617,7 @@ server.listen(PORT, HOST, () => {
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(` Schema ${schemaSource}`);
console.log(` State ${STATE_FILE}`);
console.log('');
});
@@ -668,13 +718,12 @@ function endpointUrl(endpoint: EndpointDef): string {
}
/**
* Resolve and load the managed-settings JSON schema from {@link SCHEMA_SOURCE}.
* Resolve and load the managed-settings JSON schema from the current 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.
* (relative paths are resolved against the app's cwd).
*/
async function loadSchema(): Promise<{ source: string; resolved: string; ok: boolean; schema?: unknown; error?: string }> {
const source = SCHEMA_SOURCE;
const source = schemaSource;
try {
if (/^https?:\/\//i.test(source)) {
const res = await fetch(source);