mirror of
https://github.com/microsoft/vscode.git
synced 2026-09-28 13:24:24 +08:00
Improve mock policy server troubleshooting (#333930)
* Improve mock policy server troubleshooting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix mock policy draft reconciliation 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:
@@ -14,19 +14,24 @@ Open `http://127.0.0.1:3000`. Managed settings is mocked by default. Use the
|
||||
switch beside each endpoint tab to choose mock or passthrough. Presets apply
|
||||
immediately; response behavior, status, and JSON edits auto-save.
|
||||
|
||||
Valid endpoint bodies and response configuration are stored redundantly: the
|
||||
browser keeps each response-body draft in `localStorage`, and the server
|
||||
atomically writes its complete state to `~/.mock-policy-server/state.json`.
|
||||
Both copies survive server restarts. Invalid JSON remains in browser storage
|
||||
until corrected because the server cannot serve it. Use `--state-file` or
|
||||
`MOCK_POLICY_STATE_FILE` to select a different server-side state file.
|
||||
|
||||
The GUI opens on the **Policies** workspace. Select **Setup** in the header to
|
||||
open a modal that guides you through any of these connection methods:
|
||||
|
||||
- **System proxy (recommended):** works with Code OSS, Stable, Insiders, Copilot
|
||||
CLI, and SDK/runtime clients. The page recommends Proxyman on macOS and Windows
|
||||
and provides a **Map Remote** rule, along with the per-platform toggle that
|
||||
routes system traffic through Proxyman (**Tools > macOS Proxy** on macOS,
|
||||
CLI, and SDK/runtime clients. Any HTTP debugging proxy that can rewrite HTTPS
|
||||
requests works; [Proxyman](https://proxyman.com/) is the suggested option on
|
||||
macOS and Windows. The page provides a **Map Remote** rule, along with the
|
||||
per-platform toggle that routes system traffic through Proxyman (**Tools > macOS Proxy** on macOS,
|
||||
**Tools > Override Windows Proxy** on Windows). VS Code clients must also add
|
||||
the displayed `http.proxy` property to `settings.json`; the copy action copies
|
||||
only the property, without surrounding object braces.
|
||||
- **Code OSS overrides:** the quicker option for Code OSS from this checkout.
|
||||
Select **Apply Overrides**, reload, and sign in. This option does not redirect
|
||||
SDK/runtime requests.
|
||||
- **File-based settings:** skip proxying altogether by writing the enterprise
|
||||
`managed-settings.json` to the client device. The client reads it from disk at
|
||||
startup — before sign-in and with no server round trip — so these requests never
|
||||
@@ -38,13 +43,14 @@ After connecting, open the VS Code Command Palette and run **> Developer: Sync
|
||||
Account Policy**. To refresh the policy used by Local Agent Host, also run
|
||||
**> Developer: Restart Local Agent Host**.
|
||||
|
||||
For file-based settings there is nothing to connect: expand **Deploy as a file**
|
||||
under the Managed Settings response body on the Policies page. It builds a
|
||||
per-platform one-liner (macOS, Windows PowerShell, Linux) that writes the current
|
||||
response body to `managed-settings.json` at its documented location. Copy it, run
|
||||
it on the client device, and restart the client. On macOS and Linux the command
|
||||
uses `sudo` so the file is the root-owned, non-writable regular file Copilot CLI
|
||||
requires.
|
||||
For file-based settings there is nothing to connect: use **File Deployment** in
|
||||
the right sidebar on the Policies page. It provides per-platform commands
|
||||
(macOS, Windows PowerShell, Linux) that write the current response body to
|
||||
`managed-settings.json` at its documented location. Copy one, run it on the
|
||||
client device, and restart the client. On macOS and Linux the command uses
|
||||
`sudo` so the file is the root-owned, non-writable regular file Copilot CLI
|
||||
requires. Each platform section also provides a removal command to unset the
|
||||
file-based policy.
|
||||
|
||||
| Operating system | `managed-settings.json` location |
|
||||
| --- | --- |
|
||||
@@ -52,16 +58,29 @@ requires.
|
||||
| Windows | `%ProgramFiles%\GitHubCopilot\managed-settings.json` |
|
||||
| Linux | `/etc/github-copilot/managed-settings.json` |
|
||||
|
||||
The Setup dialog checks Code OSS overrides directly. It tests the system proxy by
|
||||
sending a request without credentials to the managed settings URL and confirming
|
||||
that the response came from this local server. It does not inspect Proxyman or
|
||||
the operating system's proxy configuration. The test runs automatically, and the
|
||||
global header always shows a green or red connection indicator.
|
||||
The Setup dialog tests the system proxy every five seconds by sending an
|
||||
unauthenticated request shaped like
|
||||
`GET <configured upstream>/copilot_internal/managed_settings?mockPolicySetupProbe=<random UUID>`
|
||||
(`https://api.github.com` is the default upstream).
|
||||
The probe must traverse the same mapping as real policy traffic: the page only
|
||||
reports a successful connection when the mapped response carries this mock
|
||||
server's identifying header. It does not inspect Proxyman or the operating
|
||||
system's proxy configuration. The global header always shows a green or red
|
||||
connection indicator.
|
||||
|
||||
If no real request appears in **Live Requests**, open **Clear SDK Policy Cache**,
|
||||
expand the section for the client platform, and run the copied command in a
|
||||
terminal. A fresh managed-settings cache entry can prevent the client from making
|
||||
a request for up to one hour. Then run the commands above again.
|
||||
To keep those automatic probes out of Proxyman's traffic list, use the display
|
||||
filter regex `^(?!.*mockPolicySetupProbe).*managed_settings.*`. This should only
|
||||
filter the displayed traffic; keep the probe included in the Map Remote rule so
|
||||
the connection check can reach this server. The mock server recognizes the probe
|
||||
query parameter and excludes those requests from **Live Requests**.
|
||||
|
||||
If no real request appears in **Live Requests**, open **Troubleshooting** in the
|
||||
right sidebar, expand the client platform under **Clear SDK policy cache**, and run
|
||||
the copied command in a terminal. The Copilot SDK maintains this cache outside of
|
||||
VS Code; these commands delete the SDK cache file for the selected platform so a
|
||||
fresh managed-settings request can be made. Without clearing it, a fresh cache
|
||||
entry can prevent the client from making a request for up to one hour. Then run
|
||||
the commands above again.
|
||||
|
||||
macOS:
|
||||
```sh
|
||||
@@ -98,6 +117,11 @@ curl "$BASE/api/state"
|
||||
`GET /api/state` returns endpoint IDs, presets, current bodies, statuses, and
|
||||
mock/passthrough state.
|
||||
|
||||
`GET /api` is intended for agents as well as humans. It documents update field
|
||||
types, valid response modes, atomic update semantics, route side effects, and
|
||||
the common JSON error shape. Unknown routes point back to this discovery
|
||||
document, and unsupported methods return `405` with an `Allow` header.
|
||||
|
||||
Apply a known preset:
|
||||
|
||||
```sh
|
||||
@@ -133,6 +157,31 @@ in the same update. Explicit `status`, `body`, or `active` values override the
|
||||
preset. Invalid requests are rejected before any endpoint changes. Supported
|
||||
response modes are `json`, `malformed-json`, `disconnect`, and `timeout`.
|
||||
|
||||
Generate file-based Managed Settings commands from the current
|
||||
`managedSettings` response:
|
||||
|
||||
```sh
|
||||
curl "$BASE/api/file-deployment"
|
||||
```
|
||||
|
||||
The response includes the destination path plus install and removal commands
|
||||
for macOS, Windows, and Linux. The server does not run these commands; run one
|
||||
on the client machine and restart the client. Run the corresponding removal
|
||||
command, or delete `managed-settings.json`, to unset file-based Managed Settings.
|
||||
|
||||
Other control operations:
|
||||
|
||||
```sh
|
||||
curl "$BASE/api/schema"
|
||||
curl "$BASE/api/log"
|
||||
curl -X DELETE "$BASE/api/log"
|
||||
curl -X DELETE "$BASE/api/cache"
|
||||
curl -X POST "$BASE/api/reset"
|
||||
```
|
||||
|
||||
`DELETE /api/cache` modifies files on the machine running the server. Inspect
|
||||
each route's `sideEffects` value in `GET /api` before invoking it.
|
||||
|
||||
### Test fail-closed managed-settings refresh
|
||||
|
||||
First serve a successful policy that enables the forced-refresh requirement and
|
||||
@@ -164,13 +213,12 @@ request does not appear in **Live Requests**.
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api` | Discover request shapes and routes |
|
||||
| `GET` | `/api/state` | Read definitions, presets, and current state |
|
||||
| `POST` | `/api/state` | Apply one update or an atomic endpoint array |
|
||||
| `POST` | `/api/reset` | Restore startup endpoint state |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `POST` | `/api/wire` | Apply `product.overrides.json` |
|
||||
| `POST` | `/api/unwire` | Restore `product.overrides.json` |
|
||||
|
||||
## Schema and options
|
||||
|
||||
@@ -182,11 +230,15 @@ VS Code checkout, including from a Git worktree. Override it at startup with
|
||||
```sh
|
||||
npm run mock-policy-server -- --upstream https://api.ghe.example.com
|
||||
npm run mock-policy-server -- --schema /path/to/managed-settings-schema.json
|
||||
npm run mock-policy-server -- --port 3001
|
||||
npm run mock-policy-server -- --state-file /path/to/mock-policy-state.json
|
||||
npm run mock-policy-server -- --help
|
||||
```
|
||||
|
||||
| Flag | Environment variable | Default |
|
||||
| --- | --- | --- |
|
||||
| `--host` | — | `127.0.0.1` |
|
||||
| `--port` | — | `3000` |
|
||||
| `--upstream` | `MOCK_POLICY_UPSTREAM` | `https://api.github.com` |
|
||||
| `--schema` | `MANAGED_SETTINGS_SCHEMA` | Auto-detected sibling checkout |
|
||||
| `--state-file` | `MOCK_POLICY_STATE_FILE` | `~/.mock-policy-server/state.json` |
|
||||
|
||||
@@ -12,10 +12,9 @@
|
||||
* environments without a build step.
|
||||
*
|
||||
* For the default (github.com) provider these URLs are read verbatim from
|
||||
* `product.json` -> `defaultChatAgent.<productKey>`, so pointing all of them at
|
||||
* a local server via `product.overrides.json` lets a dev exercise the whole
|
||||
* policy pipeline offline. The same paths are also served under a system proxy
|
||||
* rule, which is how a stable/Insiders build or the CLI reaches this server.
|
||||
* `product.json` -> `defaultChatAgent.<productKey>`. These paths are served
|
||||
* under a system proxy rule so Code OSS, Stable/Insiders, the CLI, and SDK
|
||||
* clients all exercise the same policy delivery path.
|
||||
*
|
||||
* Endpoints not marked `mockedByDefault` start in passthrough: the server
|
||||
* forwards them to the real API so a blanket proxy rule stays safe.
|
||||
|
||||
@@ -29,10 +29,24 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
|
||||
interface ServerState {
|
||||
endpoints: Endpoint[];
|
||||
wired: boolean;
|
||||
baseUrl?: string;
|
||||
upstream?: string;
|
||||
overridesPath?: string;
|
||||
stateFile?: string;
|
||||
hasPersistedState?: boolean;
|
||||
}
|
||||
|
||||
interface SynchronizedDraft {
|
||||
stateFile: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function isSynchronizedDraft(value: unknown): value is SynchronizedDraft {
|
||||
return typeof value === 'object'
|
||||
&& value !== null
|
||||
&& 'stateFile' in value
|
||||
&& typeof value.stateFile === 'string'
|
||||
&& 'value' in value
|
||||
&& typeof value.value === 'string';
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
@@ -83,7 +97,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
editorText: string;
|
||||
}
|
||||
|
||||
type SetupMethod = 'proxy' | 'overrides' | 'file';
|
||||
type SetupMethod = 'proxy' | 'file';
|
||||
|
||||
const $ = (id: string): HTMLElement => document.getElementById(id)!;
|
||||
const tabs = $('tabs');
|
||||
@@ -100,15 +114,21 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
const vscodeProxySetting = '"http.proxy": "http://localhost:9090"';
|
||||
const macOsCacheClearCommand = 'rm -rf -- "${COPILOT_CACHE_HOME:-$HOME/Library/Caches/copilot}/managed-settings"';
|
||||
const windowsCacheClearCommand = '$root = if ($env:COPILOT_CACHE_HOME) { $env:COPILOT_CACHE_HOME } elseif ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA \'copilot\' } else { Join-Path $HOME \'.cache\\copilot\' }; $path = Join-Path $root \'managed-settings\'; if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force }';
|
||||
const draftStoragePrefix = 'mock-policy-server.response-body.';
|
||||
const synchronizedDraftStoragePrefix = 'mock-policy-server.synchronized-response-body.';
|
||||
const disclosureStoragePrefix = 'mock-policy-server.expanded.';
|
||||
|
||||
let endpoints: Endpoint[] = [];
|
||||
let activeId = '';
|
||||
const drafts: Record<string, string> = {};
|
||||
const dirtyDrafts = new Set<string>();
|
||||
let draftStorageErrorShown = false;
|
||||
let schema: JsonSchema | null = null;
|
||||
let overridesWired = false;
|
||||
let proxyVerified = false;
|
||||
let proxyBaseUrl = '';
|
||||
let proxyUpstream = '';
|
||||
let serverStateFile = '';
|
||||
let serverHasPersistedState = true;
|
||||
let proxyCheckInFlight = false;
|
||||
let renderedLogSignature = '';
|
||||
let stateUpdateQueue: Promise<void> = Promise.resolve();
|
||||
@@ -118,11 +138,106 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
let lastServerSignature = '';
|
||||
let stateWritesInFlight = 0;
|
||||
const pendingSaves = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let allowNextTabToMoveFocus = false;
|
||||
|
||||
function activeEndpoint(): Endpoint | undefined {
|
||||
return endpoints.find(e => e.id === activeId);
|
||||
}
|
||||
|
||||
function draftStorageKey(endpointId: string): string {
|
||||
return `${draftStoragePrefix}${endpointId}`;
|
||||
}
|
||||
|
||||
function synchronizedDraftStorageKey(endpointId: string): string {
|
||||
return `${synchronizedDraftStoragePrefix}${endpointId}`;
|
||||
}
|
||||
|
||||
function reportBrowserStorageError(error: unknown): void {
|
||||
console.error('Failed to access browser storage for mock policy server state.', error);
|
||||
if (!draftStorageErrorShown) {
|
||||
draftStorageErrorShown = true;
|
||||
toast('Drafts and expanded sections cannot be stored in this browser session.', true);
|
||||
}
|
||||
}
|
||||
|
||||
function readPersistedDraft(endpointId: string): string | undefined {
|
||||
try {
|
||||
return localStorage.getItem(draftStorageKey(endpointId)) ?? undefined;
|
||||
} catch (error) {
|
||||
reportBrowserStorageError(error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function persistDraft(endpointId: string, value: string): void {
|
||||
if (!endpointId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(draftStorageKey(endpointId), value);
|
||||
} catch (error) {
|
||||
reportBrowserStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function readSynchronizedDraft(endpointId: string): SynchronizedDraft | undefined {
|
||||
try {
|
||||
const stored = localStorage.getItem(synchronizedDraftStorageKey(endpointId));
|
||||
if (stored === null) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed: unknown = JSON.parse(stored);
|
||||
return isSynchronizedDraft(parsed) ? parsed : undefined;
|
||||
} catch (error) {
|
||||
reportBrowserStorageError(error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function persistSynchronizedDraft(endpointId: string, value: string, stateFile = serverStateFile): void {
|
||||
try {
|
||||
localStorage.setItem(synchronizedDraftStorageKey(endpointId), JSON.stringify({ stateFile, value }));
|
||||
} catch (error) {
|
||||
reportBrowserStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function readExpandedState(key: string): boolean {
|
||||
try {
|
||||
return localStorage.getItem(`${disclosureStoragePrefix}${key}`) === 'true';
|
||||
} catch (error) {
|
||||
reportBrowserStorageError(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function persistExpandedState(key: string, expanded: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(`${disclosureStoragePrefix}${key}`, String(expanded));
|
||||
} catch (error) {
|
||||
reportBrowserStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
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 restoreDisclosureState(): void {
|
||||
setExpandableSectionState('schema-details', 'schema-toggle', 'schema-chevron', 'schema', readExpandedState('schema'));
|
||||
for (const details of document.querySelectorAll<HTMLDetailsElement>('details[data-persist-expanded]')) {
|
||||
const key = details.dataset.persistExpanded;
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
details.open = readExpandedState(key);
|
||||
details.addEventListener('toggle', () => persistExpandedState(key, details.open));
|
||||
}
|
||||
}
|
||||
|
||||
// File-based managed settings: instead of proxying, a client can read
|
||||
// managed-settings.json straight off disk. These build a per-platform
|
||||
// one-liner that writes the current Managed Settings body to that file so a
|
||||
@@ -139,6 +254,14 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
return `sudo mkdir -p /etc/github-copilot && sudo tee ${linuxManagedSettingsPath} >/dev/null <<'JSON'\n${body}\nJSON`;
|
||||
}
|
||||
|
||||
function macOsFileRemoveCommand(): string {
|
||||
return `sudo rm -f -- "${macOsManagedSettingsPath}"`;
|
||||
}
|
||||
|
||||
function linuxFileRemoveCommand(): string {
|
||||
return `sudo rm -f -- ${linuxManagedSettingsPath}`;
|
||||
}
|
||||
|
||||
function windowsFileDeployCommand(body: string): string {
|
||||
// A PowerShell here-string keeps the JSON literal; WriteAllText writes
|
||||
// UTF-8 without a BOM on both Windows PowerShell 5.1 and PowerShell 7.
|
||||
@@ -152,6 +275,10 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function windowsFileRemoveCommand(): string {
|
||||
return '$path = Join-Path $env:ProgramFiles \'GitHubCopilot\\managed-settings.json\'; Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue';
|
||||
}
|
||||
|
||||
function currentManagedSettingsBody(): string | null {
|
||||
const raw = editor.value.trim();
|
||||
if (raw === '') {
|
||||
@@ -168,6 +295,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
// File-based deployment only maps to the managed-settings.json document.
|
||||
const applies = activeEndpoint()?.id === 'managedSettings';
|
||||
$('file-deploy-section').hidden = !applies;
|
||||
$('troubleshooting-section').hidden = !applies;
|
||||
if (!applies) {
|
||||
return;
|
||||
}
|
||||
@@ -181,6 +309,9 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
$('macos-file-command').textContent = macOsFileDeployCommand(body);
|
||||
$('windows-file-command').textContent = windowsFileDeployCommand(body);
|
||||
$('linux-file-command').textContent = linuxFileDeployCommand(body);
|
||||
$('macos-file-remove-command').textContent = macOsFileRemoveCommand();
|
||||
$('windows-file-remove-command').textContent = windowsFileRemoveCommand();
|
||||
$('linux-file-remove-command').textContent = linuxFileRemoveCommand();
|
||||
}
|
||||
|
||||
function setStatus(message: string, kind?: string): void {
|
||||
@@ -188,6 +319,98 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
editorStatus.dataset.kind = kind || '';
|
||||
}
|
||||
|
||||
function notifyEditorChanged(): void {
|
||||
editor.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function formatEditor(): void {
|
||||
const raw = editor.value.trim();
|
||||
try {
|
||||
editor.value = JSON.stringify(raw === '' ? {} : JSON.parse(raw), null, '\t');
|
||||
notifyEditorChanged();
|
||||
setStatus('Formatted JSON.', 'ok');
|
||||
} catch (error) {
|
||||
setStatus(`Cannot format invalid JSON: ${error instanceof Error ? error.message : String(error)}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function indentEditorSelection(outdent: boolean): void {
|
||||
const value = editor.value;
|
||||
const selectionStart = editor.selectionStart;
|
||||
const selectionEnd = editor.selectionEnd;
|
||||
if (!outdent && selectionStart === selectionEnd) {
|
||||
editor.setRangeText('\t', selectionStart, selectionEnd, 'end');
|
||||
notifyEditorChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
const blockStart = value.lastIndexOf('\n', selectionStart - 1) + 1;
|
||||
const selectionLastCharacter = selectionEnd > selectionStart && value[selectionEnd - 1] === '\n'
|
||||
? selectionEnd - 1
|
||||
: selectionEnd;
|
||||
const nextLineBreak = value.indexOf('\n', selectionLastCharacter);
|
||||
const blockEnd = nextLineBreak === -1 ? value.length : nextLineBreak;
|
||||
const block = value.slice(blockStart, blockEnd);
|
||||
const updated = block.split('\n').map(line => {
|
||||
if (!outdent) {
|
||||
return `\t${line}`;
|
||||
}
|
||||
return line.startsWith('\t') ? line.slice(1) : line.replace(/^ {1,4}/, '');
|
||||
}).join('\n');
|
||||
editor.setRangeText(updated, blockStart, blockEnd, 'select');
|
||||
notifyEditorChanged();
|
||||
}
|
||||
|
||||
function insertEditorNewLine(): void {
|
||||
const value = editor.value;
|
||||
const selectionStart = editor.selectionStart;
|
||||
const selectionEnd = editor.selectionEnd;
|
||||
const lineStart = value.lastIndexOf('\n', selectionStart - 1) + 1;
|
||||
const indentation = /^\s*/.exec(value.slice(lineStart, selectionStart))?.[0] ?? '';
|
||||
const before = value.slice(lineStart, selectionStart).trimEnd();
|
||||
const after = value.slice(selectionEnd).split('\n', 1)[0].trimStart();
|
||||
const opensObject = before.endsWith('{') && after.startsWith('}');
|
||||
const opensArray = before.endsWith('[') && after.startsWith(']');
|
||||
const opensBlock = before.endsWith('{') || before.endsWith('[');
|
||||
const innerIndentation = opensBlock ? `${indentation}\t` : indentation;
|
||||
|
||||
if (opensObject || opensArray) {
|
||||
const replacement = `\n${innerIndentation}\n${indentation}`;
|
||||
editor.setRangeText(replacement, selectionStart, selectionEnd, 'end');
|
||||
editor.selectionStart = editor.selectionEnd = selectionStart + innerIndentation.length + 1;
|
||||
} else {
|
||||
editor.setRangeText(`\n${innerIndentation}`, selectionStart, selectionEnd, 'end');
|
||||
}
|
||||
notifyEditorChanged();
|
||||
}
|
||||
|
||||
function handleEditorKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
allowNextTabToMoveFocus = true;
|
||||
setStatus('Press Tab to move focus out of the editor.');
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Tab') {
|
||||
if (allowNextTabToMoveFocus) {
|
||||
allowNextTabToMoveFocus = false;
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
indentEditorSelection(event.shiftKey);
|
||||
return;
|
||||
}
|
||||
allowNextTabToMoveFocus = false;
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
insertEditorNewLine();
|
||||
return;
|
||||
}
|
||||
if (event.altKey && event.shiftKey && event.key.toLowerCase() === 'f') {
|
||||
event.preventDefault();
|
||||
formatEditor();
|
||||
}
|
||||
}
|
||||
|
||||
function setSaveState(kind: 'live' | 'pending' | 'error', message: string): void {
|
||||
saveStateEl.textContent = message;
|
||||
saveStateEl.dataset.kind = kind;
|
||||
@@ -431,13 +654,20 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.checked = endpoint.active === true;
|
||||
checkbox.setAttribute('aria-label', `Mock ${endpoint.label}`);
|
||||
const tooltipId = `${endpoint.id}-toggle-tooltip`;
|
||||
checkbox.setAttribute('aria-describedby', tooltipId);
|
||||
checkbox.addEventListener('change', () => {
|
||||
void setEndpointActive(endpoint, checkbox.checked);
|
||||
});
|
||||
const track = document.createElement('span');
|
||||
track.className = 'tab-toggle-track';
|
||||
track.setAttribute('aria-hidden', 'true');
|
||||
toggle.append(checkbox, track);
|
||||
const tooltip = document.createElement('span');
|
||||
tooltip.id = tooltipId;
|
||||
tooltip.className = 'tab-toggle-tooltip';
|
||||
tooltip.role = 'tooltip';
|
||||
tooltip.textContent = `On: this server returns the ${endpoint.label} response JSON configured below, or the selected failure behavior. Off: requests pass through to ${proxyUpstream || 'the configured upstream'}.`;
|
||||
toggle.append(checkbox, track, tooltip);
|
||||
|
||||
item.append(tab, toggle);
|
||||
tabs.appendChild(item);
|
||||
@@ -448,6 +678,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
// Stash the current draft before switching.
|
||||
if (activeId) {
|
||||
drafts[activeId] = editor.value;
|
||||
persistDraft(activeId, editor.value);
|
||||
}
|
||||
activeId = id;
|
||||
const endpoint = activeEndpoint();
|
||||
@@ -474,7 +705,26 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
responseModeSelect.value = endpoint.mode ?? 'json';
|
||||
updateResponseConfigurationVisibility();
|
||||
parseResponseStatus();
|
||||
editor.value = drafts[id] ?? JSON.stringify(endpoint.body ?? {}, null, '\t');
|
||||
const serverText = JSON.stringify(endpoint.body ?? {}, null, '\t');
|
||||
const hasMemoryDraft = Object.prototype.hasOwnProperty.call(drafts, id);
|
||||
const persistedDraft = hasMemoryDraft ? undefined : readPersistedDraft(id);
|
||||
const synchronizedDraft = hasMemoryDraft ? undefined : readSynchronizedDraft(id);
|
||||
const synchronizedWithThisServer = synchronizedDraft?.stateFile === serverStateFile;
|
||||
const shouldRecoverPersistedDraft = persistedDraft !== undefined && (
|
||||
(synchronizedWithThisServer && (!serverHasPersistedState || persistedDraft !== synchronizedDraft.value))
|
||||
|| (synchronizedDraft === undefined && !serverHasPersistedState)
|
||||
);
|
||||
const shouldRecoverDraft = hasMemoryDraft ? dirtyDrafts.has(id) : shouldRecoverPersistedDraft;
|
||||
const recoverableDraft = hasMemoryDraft ? drafts[id] : persistedDraft;
|
||||
editor.value = shouldRecoverDraft && recoverableDraft !== undefined ? recoverableDraft : serverText;
|
||||
drafts[id] = editor.value;
|
||||
persistDraft(id, editor.value);
|
||||
if (shouldRecoverDraft) {
|
||||
dirtyDrafts.add(id);
|
||||
} else {
|
||||
dirtyDrafts.delete(id);
|
||||
persistSynchronizedDraft(id, serverText);
|
||||
}
|
||||
renderTabs();
|
||||
renderPresets();
|
||||
renderProxy();
|
||||
@@ -484,6 +734,9 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
parseEditor();
|
||||
renderFileDeploy();
|
||||
renderSaveState();
|
||||
if (shouldRecoverDraft) {
|
||||
debouncedSave();
|
||||
}
|
||||
}
|
||||
|
||||
function renderPresets(): void {
|
||||
@@ -511,23 +764,14 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
parseResponseStatus();
|
||||
editor.value = JSON.stringify(preset.body, null, '\t');
|
||||
drafts[activeId] = editor.value;
|
||||
dirtyDrafts.add(activeId);
|
||||
persistDraft(activeId, editor.value);
|
||||
parseEditor();
|
||||
renderFileDeploy();
|
||||
clearPendingSave(activeId);
|
||||
void save();
|
||||
}
|
||||
|
||||
function renderWired(state: ServerState): void {
|
||||
overridesWired = state.wired;
|
||||
const status = $('override-status');
|
||||
status.textContent = state.wired ? 'Applied \u2713' : 'Not applied';
|
||||
status.dataset.state = state.wired ? 'ready' : 'pending';
|
||||
const action = $('overrides-action');
|
||||
action.textContent = state.wired ? 'Restore Original' : 'Apply Overrides';
|
||||
action.className = state.wired ? 'btn-secondary' : 'btn-primary';
|
||||
updateReadiness();
|
||||
}
|
||||
|
||||
function updateResponseConfigurationVisibility(): void {
|
||||
responseConfiguration.hidden = responseModeSelect.value !== 'json';
|
||||
}
|
||||
@@ -560,10 +804,13 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
const endpoint = endpoints.find(candidate => candidate.id === 'managedSettings') ?? endpoints[0];
|
||||
$('map-from').textContent = endpoint && proxyUpstream ? `${proxyUpstream}${endpoint.path}` : '';
|
||||
$('map-to').textContent = endpoint && proxyBaseUrl ? `${proxyBaseUrl}${endpoint.path}` : '';
|
||||
$('setup-probe-shape').textContent = endpoint && proxyUpstream
|
||||
? `GET ${proxyUpstream}${endpoint.path}?mockPolicySetupProbe=<random UUID>`
|
||||
: '';
|
||||
}
|
||||
|
||||
function selectSetupMethod(method: SetupMethod): void {
|
||||
for (const candidate of ['proxy', 'overrides', 'file'] as const) {
|
||||
for (const candidate of ['proxy', 'file'] as const) {
|
||||
const selected = candidate === method;
|
||||
$(`${candidate}-method`).dataset.selected = String(selected);
|
||||
$(`${candidate}-method-steps`).toggleAttribute('inert', !selected);
|
||||
@@ -572,12 +819,11 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
}
|
||||
|
||||
function updateReadiness(): void {
|
||||
const connectionReady = proxyVerified || overridesWired;
|
||||
const globalStatus = $('global-connection-status');
|
||||
globalStatus.dataset.state = connectionReady ? 'ready' : proxyCheckInFlight ? 'checking' : 'error';
|
||||
globalStatus.dataset.state = proxyVerified ? 'ready' : proxyCheckInFlight ? 'checking' : 'error';
|
||||
$('global-connection-label').textContent = proxyVerified
|
||||
? 'System proxy connected'
|
||||
: overridesWired ? 'Code OSS overrides active' : proxyCheckInFlight ? 'Checking connection\u2026' : 'No connection detected';
|
||||
: proxyCheckInFlight ? 'Checking connection\u2026' : 'No connection detected';
|
||||
}
|
||||
|
||||
function renderProxyStatus(state: 'checking' | 'ready' | 'pending', message: string, detail: string): void {
|
||||
@@ -656,12 +902,66 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
endpoints = state.endpoints;
|
||||
proxyBaseUrl = state.baseUrl ?? '';
|
||||
proxyUpstream = state.upstream ?? '';
|
||||
renderWired(state);
|
||||
serverStateFile = state.stateFile ?? '';
|
||||
serverHasPersistedState = state.hasPersistedState !== false;
|
||||
renderProxy();
|
||||
renderTabs();
|
||||
lastServerSignature = stateSignature(state);
|
||||
}
|
||||
|
||||
async function restoreBrowserDrafts(state: ServerState, allowLegacyDrafts = false): Promise<ServerState> {
|
||||
if (state.hasPersistedState !== false) {
|
||||
return state;
|
||||
}
|
||||
const updates: Array<{ endpoint: string; body: unknown }> = [];
|
||||
const synchronizedUpdates = new Set<string>();
|
||||
for (const endpoint of state.endpoints) {
|
||||
const persistedDraft = readPersistedDraft(endpoint.id);
|
||||
if (persistedDraft === undefined) {
|
||||
continue;
|
||||
}
|
||||
const synchronizedDraft = readSynchronizedDraft(endpoint.id);
|
||||
const synchronizedWithThisServer = synchronizedDraft?.stateFile === (state.stateFile ?? '');
|
||||
if (synchronizedDraft !== undefined && !synchronizedWithThisServer) {
|
||||
delete drafts[endpoint.id];
|
||||
dirtyDrafts.delete(endpoint.id);
|
||||
continue;
|
||||
}
|
||||
if (synchronizedDraft === undefined && !allowLegacyDrafts) {
|
||||
delete drafts[endpoint.id];
|
||||
dirtyDrafts.delete(endpoint.id);
|
||||
continue;
|
||||
}
|
||||
drafts[endpoint.id] = persistedDraft;
|
||||
try {
|
||||
updates.push({ endpoint: endpoint.id, body: JSON.parse(persistedDraft) });
|
||||
synchronizedUpdates.add(endpoint.id);
|
||||
} catch {
|
||||
dirtyDrafts.add(endpoint.id);
|
||||
if (synchronizedWithThisServer) {
|
||||
try {
|
||||
updates.push({ endpoint: endpoint.id, body: JSON.parse(synchronizedDraft.value) });
|
||||
} catch (error) {
|
||||
console.warn(`Ignoring invalid synchronized browser state for ${endpoint.id}.`, error);
|
||||
}
|
||||
} else {
|
||||
persistSynchronizedDraft(endpoint.id, JSON.stringify(endpoint.body ?? {}, null, '\t'), state.stateFile ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (updates.length === 0) {
|
||||
return state;
|
||||
}
|
||||
const restoredState = await updateState({ endpoints: updates });
|
||||
for (const update of updates) {
|
||||
if (synchronizedUpdates.has(update.endpoint)) {
|
||||
persistSynchronizedDraft(update.endpoint, drafts[update.endpoint], state.stateFile ?? '');
|
||||
dirtyDrafts.delete(update.endpoint);
|
||||
}
|
||||
}
|
||||
return restoredState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical fingerprint of the mutable server state. Compared against
|
||||
* `lastServerSignature` so the background poll only reacts to changes it did
|
||||
@@ -671,7 +971,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
const endpoints = (state.endpoints ?? []).map(e => ({
|
||||
id: e.id, status: e.status, mode: e.mode, active: e.active, body: e.body
|
||||
}));
|
||||
return JSON.stringify({ endpoints, wired: state.wired });
|
||||
return JSON.stringify({ stateFile: state.stateFile ?? '', endpoints });
|
||||
}
|
||||
|
||||
function isInteractingWithEditor(): boolean {
|
||||
@@ -679,14 +979,24 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
return el === editor || el === responseStatusInput || el === responseModeSelect || el === presetSelect;
|
||||
}
|
||||
|
||||
function discardDraftsFromOtherStateFile(state: ServerState): void {
|
||||
const stateFile = state.stateFile ?? '';
|
||||
for (const endpoint of state.endpoints) {
|
||||
if (readSynchronizedDraft(endpoint.id)?.stateFile !== stateFile) {
|
||||
delete drafts[endpoint.id];
|
||||
dirtyDrafts.delete(endpoint.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll the server state and reconcile the UI when it changed underneath us —
|
||||
* e.g. the control API or another tab edited a response body. Skipped while
|
||||
* the user is editing or our own writes are settling so we never clobber an
|
||||
* in-progress edit; the next poll catches up once they finish.
|
||||
* e.g. the control API or another tab edited a response body. Same-server
|
||||
* changes wait for editing and writes to settle, while a different state-file
|
||||
* namespace is applied immediately so an old draft cannot cross into it.
|
||||
*/
|
||||
async function refreshState(): Promise<void> {
|
||||
if (stateWritesInFlight > 0 || pendingSaves.size > 0 || isInteractingWithEditor()) {
|
||||
if (stateWritesInFlight > 0 || pendingSaves.size > 0) {
|
||||
return;
|
||||
}
|
||||
let state: ServerState;
|
||||
@@ -696,9 +1006,24 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
return; // the next poll will retry
|
||||
}
|
||||
// Re-check after the await: a save may have started meanwhile.
|
||||
if (stateWritesInFlight > 0 || pendingSaves.size > 0 || isInteractingWithEditor()) {
|
||||
if (stateWritesInFlight > 0 || pendingSaves.size > 0) {
|
||||
return;
|
||||
}
|
||||
const stateFileChanged = serverStateFile !== '' && (state.stateFile ?? '') !== serverStateFile;
|
||||
if (isInteractingWithEditor() && !stateFileChanged) {
|
||||
return;
|
||||
}
|
||||
if (stateFileChanged) {
|
||||
discardDraftsFromOtherStateFile(state);
|
||||
}
|
||||
if (state.hasPersistedState === false) {
|
||||
try {
|
||||
state = await restoreBrowserDrafts(state);
|
||||
} catch (e) {
|
||||
toast(`Failed to restore browser drafts: ${e instanceof Error ? e.message : String(e)}`, true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (stateSignature(state) === lastServerSignature) {
|
||||
return;
|
||||
}
|
||||
@@ -706,11 +1031,14 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
}
|
||||
|
||||
function applyExternalState(state: ServerState): void {
|
||||
// Only reached when the user is not editing, so replacing the editor
|
||||
// contents and drafts from the new server truth is safe.
|
||||
// Keep local drafts that have not reached the server yet.
|
||||
applyState(state);
|
||||
for (const endpoint of endpoints) {
|
||||
drafts[endpoint.id] = JSON.stringify(endpoint.body ?? {}, null, '\t');
|
||||
if (!dirtyDrafts.has(endpoint.id)) {
|
||||
drafts[endpoint.id] = JSON.stringify(endpoint.body ?? {}, null, '\t');
|
||||
persistDraft(endpoint.id, drafts[endpoint.id]);
|
||||
persistSynchronizedDraft(endpoint.id, drafts[endpoint.id]);
|
||||
}
|
||||
}
|
||||
const endpoint = activeEndpoint();
|
||||
if (!endpoint) {
|
||||
@@ -805,7 +1133,13 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
active: snapshot.active
|
||||
});
|
||||
applyState(state);
|
||||
drafts[snapshot.endpoint] = snapshot.editorText;
|
||||
const currentDraft = drafts[snapshot.endpoint];
|
||||
if (currentDraft === undefined || currentDraft === snapshot.editorText) {
|
||||
drafts[snapshot.endpoint] = snapshot.editorText;
|
||||
persistDraft(snapshot.endpoint, snapshot.editorText);
|
||||
persistSynchronizedDraft(snapshot.endpoint, snapshot.editorText);
|
||||
dirtyDrafts.delete(snapshot.endpoint);
|
||||
}
|
||||
if (snapshot.endpoint === activeId && editor.value === snapshot.editorText && !pendingSaves.has(snapshot.endpoint)) {
|
||||
setLiveSaveState();
|
||||
}
|
||||
@@ -849,16 +1183,6 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
}, 400));
|
||||
}
|
||||
|
||||
async function wire(wireIt: boolean): Promise<void> {
|
||||
try {
|
||||
const state = await api<ServerState>(wireIt ? '/api/wire' : '/api/unwire', { method: 'POST' });
|
||||
applyState(state);
|
||||
toast(wireIt ? 'Overrides applied — reload Code OSS' : 'Original file restored — reload Code OSS');
|
||||
} catch (e) {
|
||||
toast(`${wireIt ? 'Apply' : 'Restore'} failed: ${e instanceof Error ? e.message : String(e)}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSchema(): Promise<void> {
|
||||
const badgeEl = $('schema-badge');
|
||||
|
||||
@@ -890,6 +1214,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
function renderValidationResults(parsed: Record<string, unknown>): void {
|
||||
const container = $('validation-results');
|
||||
const rows = schema ? validationRows(schema, parsed) : [];
|
||||
const wasOpen = (container.querySelector('.validation-details') as HTMLDetailsElement | null)?.open ?? readExpandedState('schema-keys');
|
||||
|
||||
if (rows.length === 0) {
|
||||
container.hidden = true;
|
||||
@@ -964,34 +1289,34 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
const schemaRows = rows.filter(row => row.inSchema && !row.dynamic);
|
||||
const presentCount = schemaRows.filter(row => row.inBody).length;
|
||||
const unknownCount = rows.filter(row => row.inBody && !row.inSchema).length;
|
||||
const summary = document.createElement('p');
|
||||
summary.className = 'validation-summary';
|
||||
const summary = document.createElement('span');
|
||||
summary.textContent = `${presentCount} of ${schemaRows.length} schema keys set`
|
||||
+ (unknownCount ? `, ${unknownCount} unknown` : '');
|
||||
if (unknownCount) {
|
||||
summary.classList.add('validation-warn');
|
||||
}
|
||||
|
||||
container.replaceChildren(tableContainer, summary);
|
||||
const details = document.createElement('details');
|
||||
details.className = 'validation-details';
|
||||
details.open = wasOpen;
|
||||
details.addEventListener('toggle', () => persistExpandedState('schema-keys', details.open));
|
||||
const detailsSummary = document.createElement('summary');
|
||||
const chevron = document.createElement('span');
|
||||
chevron.className = 'validation-details-chevron';
|
||||
chevron.setAttribute('aria-hidden', 'true');
|
||||
chevron.textContent = '\u25B6';
|
||||
detailsSummary.append(chevron, 'Schema keys');
|
||||
summary.classList.add('validation-details-summary');
|
||||
detailsSummary.appendChild(summary);
|
||||
details.append(detailsSummary, tableContainer);
|
||||
|
||||
container.replaceChildren(details);
|
||||
container.hidden = false;
|
||||
setStatus(unknownCount ? `${unknownCount} key${unknownCount > 1 ? 's' : ''} not in schema.` : '', unknownCount ? 'warn' : '');
|
||||
}
|
||||
|
||||
function toggleSchemaSection(): void {
|
||||
const detailsEl = $('schema-details');
|
||||
// `hidden` can also be the string 'until-found', so normalize to boolean.
|
||||
const willOpen = Boolean(detailsEl.hidden);
|
||||
detailsEl.hidden = !willOpen;
|
||||
$('schema-chevron').classList.toggle('open', willOpen);
|
||||
$('schema-toggle').setAttribute('aria-expanded', String(willOpen));
|
||||
}
|
||||
|
||||
function toggleFileDeploySection(): void {
|
||||
const detailsEl = $('file-deploy-details');
|
||||
const willOpen = Boolean(detailsEl.hidden);
|
||||
detailsEl.hidden = !willOpen;
|
||||
$('file-deploy-chevron').classList.toggle('open', willOpen);
|
||||
$('file-deploy-toggle').setAttribute('aria-expanded', String(willOpen));
|
||||
setExpandableSectionState('schema-details', 'schema-toggle', 'schema-chevron', 'schema', Boolean($('schema-details').hidden));
|
||||
}
|
||||
|
||||
function openFileDeploy(): void {
|
||||
@@ -1001,9 +1326,6 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
selectEndpoint('managedSettings');
|
||||
}
|
||||
setupDialog.close();
|
||||
if ($('file-deploy-details').hidden) {
|
||||
toggleFileDeploySection();
|
||||
}
|
||||
$('file-deploy-section').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
@@ -1101,13 +1423,19 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
$('proxy-settings').textContent = vscodeProxySetting;
|
||||
$('macos-cache-command').textContent = macOsCacheClearCommand;
|
||||
$('windows-cache-command').textContent = windowsCacheClearCommand;
|
||||
restoreDisclosureState();
|
||||
|
||||
editor.addEventListener('input', () => {
|
||||
drafts[activeId] = editor.value;
|
||||
dirtyDrafts.add(activeId);
|
||||
persistDraft(activeId, editor.value);
|
||||
parseEditor();
|
||||
renderFileDeploy();
|
||||
debouncedSave();
|
||||
});
|
||||
editor.addEventListener('keydown', handleEditorKeyDown);
|
||||
editor.addEventListener('blur', () => allowNextTabToMoveFocus = false);
|
||||
$('format-editor').addEventListener('click', formatEditor);
|
||||
responseStatusInput.addEventListener('input', () => {
|
||||
// Re-run validation on status change too: schema warnings only apply
|
||||
// to 2xx bodies, so the status decides whether they are shown.
|
||||
@@ -1122,7 +1450,6 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
void setResponseMode(endpoint, responseModeSelect.value as EndpointResponseMode);
|
||||
});
|
||||
presetSelect.addEventListener('change', applyPreset);
|
||||
$('overrides-action').addEventListener('click', () => wire(!overridesWired));
|
||||
$('copy-map').addEventListener('click', e => {
|
||||
copy(`${$('map-from').textContent}\n${$('map-to').textContent}`, e.currentTarget as HTMLElement);
|
||||
});
|
||||
@@ -1144,8 +1471,17 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
$('copy-linux-file-command').addEventListener('click', e => {
|
||||
copy($('linux-file-command').textContent ?? '', e.currentTarget as HTMLElement);
|
||||
});
|
||||
$('copy-macos-file-remove-command').addEventListener('click', e => {
|
||||
copy($('macos-file-remove-command').textContent ?? '', e.currentTarget as HTMLElement);
|
||||
});
|
||||
$('copy-windows-file-remove-command').addEventListener('click', e => {
|
||||
copy($('windows-file-remove-command').textContent ?? '', e.currentTarget as HTMLElement);
|
||||
});
|
||||
$('copy-linux-file-remove-command').addEventListener('click', e => {
|
||||
copy($('linux-file-remove-command').textContent ?? '', e.currentTarget as HTMLElement);
|
||||
});
|
||||
$('file-deploy-goto').addEventListener('click', openFileDeploy);
|
||||
for (const method of ['proxy', 'overrides', 'file'] as const) {
|
||||
for (const method of ['proxy', 'file'] as const) {
|
||||
$(`setup-method-${method}`).addEventListener('change', () => selectSetupMethod(method));
|
||||
}
|
||||
$('setup-nav').addEventListener('click', openSetupDialog);
|
||||
@@ -1163,7 +1499,6 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
});
|
||||
window.addEventListener('hashchange', syncSetupDialog);
|
||||
$('schema-toggle').addEventListener('click', toggleSchemaSection);
|
||||
$('file-deploy-toggle').addEventListener('click', toggleFileDeploySection);
|
||||
$('hydrate-schema').addEventListener('click', () => {
|
||||
if (!schema) {
|
||||
setStatus('Schema unavailable.', 'error');
|
||||
@@ -1177,6 +1512,8 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
parseResponseStatus();
|
||||
editor.value = JSON.stringify(hydrateFromSchema(schema), null, '\t');
|
||||
drafts[activeId] = editor.value;
|
||||
dirtyDrafts.add(activeId);
|
||||
persistDraft(activeId, editor.value);
|
||||
parseEditor();
|
||||
renderFileDeploy();
|
||||
void save();
|
||||
@@ -1197,8 +1534,9 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[];
|
||||
});
|
||||
|
||||
try {
|
||||
const state = await api<ServerState>('/api/state');
|
||||
selectSetupMethod(state.wired ? 'overrides' : 'proxy');
|
||||
let state = await api<ServerState>('/api/state');
|
||||
state = await restoreBrowserDrafts(state, true);
|
||||
selectSetupMethod('proxy');
|
||||
applyState(state);
|
||||
if (endpoints.length) {
|
||||
selectEndpoint(endpoints[0].id);
|
||||
|
||||
@@ -31,22 +31,6 @@
|
||||
</div>
|
||||
<a id="policies-nav" href="#policies" aria-current="page">Policies</a>
|
||||
</nav>
|
||||
<details class="cache-command-picker">
|
||||
<summary class="btn-header">Clear SDK Policy Cache</summary>
|
||||
<div class="cache-command-popover">
|
||||
<p class="helper-text">Copy the command for the client platform, then run it in a terminal.</p>
|
||||
<details class="cache-platform">
|
||||
<summary>macOS</summary>
|
||||
<code id="macos-cache-command" class="block cache-command"></code>
|
||||
<button id="copy-macos-cache-command" type="button" class="btn-secondary">Copy macOS Command</button>
|
||||
</details>
|
||||
<details class="cache-platform">
|
||||
<summary>Windows (PowerShell)</summary>
|
||||
<code id="windows-cache-command" class="block cache-command"></code>
|
||||
<button id="copy-windows-cache-command" type="button" class="btn-secondary">Copy PowerShell Command</button>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -61,7 +45,10 @@
|
||||
<div>
|
||||
<p class="eyebrow">Connect a client</p>
|
||||
<h2 id="setup-title">Route policy requests to this server</h2>
|
||||
<p class="setup-lede">Select a connection method below to enable its steps. You can switch methods at any time.</p>
|
||||
<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>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,12 +59,16 @@
|
||||
<div class="method-heading">
|
||||
<div class="method-choice">
|
||||
<input id="setup-method-proxy" type="radio" name="setup-method" value="proxy" checked>
|
||||
<label class="method-label" for="setup-method-proxy">
|
||||
<span class="method-badge">Recommended</span>
|
||||
<h3>System Proxy</h3>
|
||||
<span class="method-recommendation">Recommended on macOS and Windows: Proxyman</span>
|
||||
<p>Routes policy requests from Code OSS, Stable, Insiders, Copilot CLI, and SDK/runtime clients. VS Code clients also require the proxy setting shown below.</p>
|
||||
</label>
|
||||
<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>
|
||||
<div class="method-status">
|
||||
<div id="proxy-status" class="status-pill" data-state="checking" role="status" aria-live="polite">Checking…</div>
|
||||
@@ -90,7 +81,7 @@
|
||||
<div class="step-number" aria-hidden="true">1</div>
|
||||
<div class="step-content">
|
||||
<h4>Configure the system proxy</h4>
|
||||
<p>Redirect the managed settings URL to this local server. In Proxyman, create a Map Remote rule using these values.</p>
|
||||
<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>
|
||||
@@ -103,7 +94,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<button id="copy-map" type="button" class="btn-secondary">Copy Mapping</button>
|
||||
<details>
|
||||
<details data-persist-expanded="proxy-tips">
|
||||
<summary>Proxyman tips</summary>
|
||||
<ul>
|
||||
<li>Keep the source and destination paths identical.</li>
|
||||
@@ -141,44 +132,17 @@
|
||||
<h4>Request fresh policy in VS Code</h4>
|
||||
<p>Open the Command Palette and run <code>> Developer: Sync Account Policy</code>.</p>
|
||||
<p>To refresh the policy used by Local Agent Host, also run <code>> Developer: Restart Local Agent Host</code>.</p>
|
||||
<p class="helper-text">If no request appears under Live Requests on the Policies page, open Clear SDK Policy Cache in the header, copy the command for your platform, run it in a terminal, and then run the commands again.</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</article>
|
||||
|
||||
<div class="setup-alternatives">
|
||||
<article id="overrides-method" class="setup-method alternative" data-selected="false">
|
||||
<div class="method-heading">
|
||||
<div class="method-choice">
|
||||
<input id="setup-method-overrides" type="radio" name="setup-method" value="overrides">
|
||||
<label class="method-label" for="setup-method-overrides">
|
||||
<span class="method-badge secondary">Code OSS only</span>
|
||||
<h3>Product overrides</h3>
|
||||
<p>The fastest option for Code OSS built from this checkout. It does not route SDK/runtime requests.</p>
|
||||
</label>
|
||||
</div>
|
||||
<div id="override-status" class="status-pill" data-state="pending" role="status" aria-live="polite">Not applied</div>
|
||||
</div>
|
||||
|
||||
<ol id="overrides-method-steps" class="setup-steps compact" inert>
|
||||
<li>
|
||||
<div class="step-number" aria-hidden="true">1</div>
|
||||
<div class="step-content">
|
||||
<h4>Apply the local endpoint URLs</h4>
|
||||
<p>The server updates <code>product.overrides.json</code> and preserves any other top-level overrides.</p>
|
||||
<button id="overrides-action" type="button" class="btn-primary">Apply Overrides</button>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="step-number" aria-hidden="true">2</div>
|
||||
<div class="step-content">
|
||||
<h4>Reload and request policy</h4>
|
||||
<p>Reload Code OSS, sign in, open the Command Palette, and run <code>> Developer: Sync Account Policy</code>.</p>
|
||||
<p class="helper-text">If no request appears under Live Requests on the Policies page, open Clear SDK Policy Cache in the header, copy the command for your platform, run it in a terminal, and then run the command again.</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">⚠</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—do not exclude probes from the Map Remote rule.</p>
|
||||
</aside>
|
||||
</article>
|
||||
|
||||
<article id="file-method" class="setup-method alternative" data-selected="false">
|
||||
@@ -201,9 +165,9 @@
|
||||
<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 expand <strong>Deploy as a file</strong> to copy the command for the client platform and run it in a terminal.</p>
|
||||
<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>
|
||||
<details data-persist-expanded="file-locations">
|
||||
<summary>File locations</summary>
|
||||
<ul>
|
||||
<li>macOS: <code>/Library/Application Support/GitHubCopilot/managed-settings.json</code></li>
|
||||
@@ -225,7 +189,6 @@
|
||||
</ol>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
</section>
|
||||
@@ -284,46 +247,18 @@
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="editor">Response body (JSON)</label>
|
||||
<span id="save-state" class="save-state" aria-live="polite"></span>
|
||||
<div class="editor-toolbar">
|
||||
<button id="format-editor" type="button" class="btn-link" aria-keyshortcuts="Alt+Shift+F"
|
||||
title="Format JSON (Alt+Shift+F)">Format JSON</button>
|
||||
<span id="save-state" class="save-state" aria-live="polite"></span>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="editor" spellcheck="false" autocomplete="off"
|
||||
aria-describedby="editor-status"></textarea>
|
||||
aria-describedby="editor-status"
|
||||
aria-keyshortcuts="Tab Shift+Tab Alt+Shift+F Escape"></textarea>
|
||||
<p id="editor-status" class="status" aria-live="polite"></p>
|
||||
<div id="validation-results" hidden></div>
|
||||
</div>
|
||||
|
||||
<div id="file-deploy-section" class="section-block file-deploy-section" hidden>
|
||||
<h2 class="file-deploy-heading">
|
||||
<button type="button" class="section-header section-header-toggle" id="file-deploy-toggle"
|
||||
aria-expanded="false" aria-controls="file-deploy-details">
|
||||
<span class="chevron" id="file-deploy-chevron" aria-hidden="true">▶</span>
|
||||
<span>Deploy as a file</span>
|
||||
<span class="file-deploy-badge">managed-settings.json</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="file-deploy-details" class="file-deploy-details" hidden>
|
||||
<p class="helper-text">Write the current response body to <code>managed-settings.json</code> on the client device to enforce it as file-based settings — no proxy required. The client reads the file at startup, so these requests never reach this server and won't appear in Live Requests. 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>
|
||||
<p class="helper-text">Run the command for the client platform in a terminal, then restart the client. On macOS and Linux <code>sudo</code> writes the root-owned, non-writable file that Copilot CLI requires; run the PowerShell command in an elevated terminal.</p>
|
||||
<div id="file-deploy-commands" class="file-deploy-commands">
|
||||
<details class="file-deploy-platform">
|
||||
<summary>macOS</summary>
|
||||
<code id="macos-file-command" class="block file-deploy-command"></code>
|
||||
<button id="copy-macos-file-command" type="button" class="btn-secondary">Copy macOS Command</button>
|
||||
</details>
|
||||
<details class="file-deploy-platform">
|
||||
<summary>Windows (PowerShell)</summary>
|
||||
<code id="windows-file-command" class="block file-deploy-command"></code>
|
||||
<button id="copy-windows-file-command" type="button" class="btn-secondary">Copy PowerShell Command</button>
|
||||
</details>
|
||||
<details class="file-deploy-platform">
|
||||
<summary>Linux</summary>
|
||||
<code id="linux-file-command" class="block file-deploy-command"></code>
|
||||
<button id="copy-linux-file-command" type="button" class="btn-secondary">Copy Linux Command</button>
|
||||
</details>
|
||||
</div>
|
||||
<p id="file-deploy-hint" class="helper-text" hidden>Enter a valid JSON body above to generate the deploy commands.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -337,6 +272,78 @@
|
||||
<p id="log-empty" class="helper-text">No requests yet.</p>
|
||||
<ul id="log" class="log" aria-labelledby="live-requests-title"></ul>
|
||||
</div>
|
||||
<details id="troubleshooting-section" class="section-block troubleshooting-section" data-persist-expanded="troubleshooting" hidden>
|
||||
<summary class="section-header">
|
||||
<span class="section-title">Troubleshooting</span>
|
||||
<span class="troubleshooting-chevron" aria-hidden="true">▶</span>
|
||||
</summary>
|
||||
<div class="troubleshooting-content">
|
||||
<div class="section-header troubleshooting-content-header">
|
||||
<h3>Clear SDK policy cache</h3>
|
||||
<div class="info-tooltip-container">
|
||||
<button type="button" class="info-button" aria-label="About the SDK policy cache"
|
||||
aria-describedby="policy-cache-tooltip">i</button>
|
||||
<div id="policy-cache-tooltip" class="info-tooltip" role="tooltip">
|
||||
The Copilot SDK maintains its own managed-settings cache outside of VS Code. If a client keeps using stale policy or no policy request appears in Live Requests, run the command for its platform to delete that SDK cache file, then request policy again. These commands do not delete VS Code settings or the deployed <code>managed-settings.json</code> file.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<details class="cache-platform" data-persist-expanded="cache-macos">
|
||||
<summary>macOS</summary>
|
||||
<code id="macos-cache-command" class="block cache-command"></code>
|
||||
<button id="copy-macos-cache-command" type="button" class="btn-secondary">Copy macOS Command</button>
|
||||
</details>
|
||||
<details class="cache-platform" data-persist-expanded="cache-windows">
|
||||
<summary>Windows (PowerShell)</summary>
|
||||
<code id="windows-cache-command" class="block cache-command"></code>
|
||||
<button id="copy-windows-cache-command" type="button" class="btn-secondary">Copy PowerShell Command</button>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
<div id="file-deploy-section" class="section-block file-deploy-section" hidden>
|
||||
<div class="section-header file-deploy-header">
|
||||
<h2>File Deployment</h2>
|
||||
<div class="info-tooltip-container">
|
||||
<button type="button" class="info-button" aria-label="About File Deployment"
|
||||
aria-describedby="file-deploy-tooltip">i</button>
|
||||
<div id="file-deploy-tooltip" class="info-tooltip" role="tooltip">
|
||||
Generate a ready-to-run command that deploys the current JSON as file-based Managed Settings in the correct location for your operating system. Commands update automatically as you edit the response. Run <strong>Install or update</strong> in an elevated terminal, then restart the client. Run <strong>Remove</strong> to unset the file-based policy. See <strong>Setup</strong> to learn when to use this delivery method.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="file-deploy-details" class="file-deploy-details">
|
||||
<div id="file-deploy-commands" class="file-deploy-commands">
|
||||
<details class="file-deploy-platform" data-persist-expanded="deploy-macos">
|
||||
<summary>macOS</summary>
|
||||
<span class="label-small file-deploy-command-label">Install or update</span>
|
||||
<code id="macos-file-command" class="block file-deploy-command"></code>
|
||||
<button id="copy-macos-file-command" type="button" class="btn-secondary">Copy macOS Command</button>
|
||||
<span class="label-small file-deploy-command-label">Remove</span>
|
||||
<code id="macos-file-remove-command" class="block file-deploy-command"></code>
|
||||
<button id="copy-macos-file-remove-command" type="button" class="btn-secondary">Copy Remove Command</button>
|
||||
</details>
|
||||
<details class="file-deploy-platform" data-persist-expanded="deploy-windows">
|
||||
<summary>Windows (PowerShell)</summary>
|
||||
<span class="label-small file-deploy-command-label">Install or update</span>
|
||||
<code id="windows-file-command" class="block file-deploy-command"></code>
|
||||
<button id="copy-windows-file-command" type="button" class="btn-secondary">Copy PowerShell Command</button>
|
||||
<span class="label-small file-deploy-command-label">Remove</span>
|
||||
<code id="windows-file-remove-command" class="block file-deploy-command"></code>
|
||||
<button id="copy-windows-file-remove-command" type="button" class="btn-secondary">Copy Remove Command</button>
|
||||
</details>
|
||||
<details class="file-deploy-platform" data-persist-expanded="deploy-linux">
|
||||
<summary>Linux</summary>
|
||||
<span class="label-small file-deploy-command-label">Install or update</span>
|
||||
<code id="linux-file-command" class="block file-deploy-command"></code>
|
||||
<button id="copy-linux-file-command" type="button" class="btn-secondary">Copy Linux Command</button>
|
||||
<span class="label-small file-deploy-command-label">Remove</span>
|
||||
<code id="linux-file-remove-command" class="block file-deploy-command"></code>
|
||||
<button id="copy-linux-file-remove-command" type="button" class="btn-secondary">Copy Remove Command</button>
|
||||
</details>
|
||||
</div>
|
||||
<p id="file-deploy-hint" class="helper-text" hidden>Enter a valid JSON body to generate the deploy commands.</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -127,6 +127,23 @@ h2 {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.validation-details > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.validation-details-chevron {
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.validation-details[open] .validation-details-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.global-status[data-state='ready'] {
|
||||
@@ -189,62 +206,6 @@ h2 {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-header {
|
||||
padding: 6px 8px;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-header:hover {
|
||||
border-color: var(--border);
|
||||
background: var(--surface-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.cache-command-picker {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cache-command-picker > summary {
|
||||
display: block;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.cache-command-picker > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cache-command-picker[open] > summary {
|
||||
border-color: var(--border);
|
||||
background: var(--surface-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.cache-command-popover {
|
||||
position: absolute;
|
||||
inset-block-start: calc(100% + 8px);
|
||||
inset-inline-end: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
width: min(560px, calc(100vw - 48px));
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.cache-platform {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 8px;
|
||||
@@ -268,23 +229,23 @@ h2 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.file-deploy-details a,
|
||||
.step-content a {
|
||||
color: var(--focus);
|
||||
}
|
||||
|
||||
.file-deploy-badge {
|
||||
display: inline-flex;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: none;
|
||||
.file-deploy-section {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.file-deploy-section[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-deploy-header {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.file-deploy-commands {
|
||||
@@ -316,6 +277,11 @@ h2 {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.file-deploy-command-label {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
@@ -403,8 +369,8 @@ h2 {
|
||||
}
|
||||
|
||||
.section-header h2,
|
||||
.schema-heading,
|
||||
.file-deploy-heading {
|
||||
.section-title,
|
||||
.schema-heading {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
@@ -412,6 +378,44 @@ h2 {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.troubleshooting-section > summary {
|
||||
justify-content: space-between;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.troubleshooting-section > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.troubleshooting-section > summary:hover .section-title {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.troubleshooting-chevron {
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.troubleshooting-section[open] .troubleshooting-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.troubleshooting-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.troubleshooting-content h3 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.troubleshooting-content-header {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* --- Setup ----------------------------------------------------------------- */
|
||||
|
||||
.setup-dialog {
|
||||
@@ -470,6 +474,10 @@ h2 {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.setup-lede a {
|
||||
color: var(--focus);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
@@ -481,18 +489,11 @@ h2 {
|
||||
|
||||
.setup-methods {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(400px, 0.8fr);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.setup-alternatives {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.setup-method-picker {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
@@ -517,7 +518,8 @@ h2 {
|
||||
}
|
||||
|
||||
.setup-method[data-selected='false'] .method-choice,
|
||||
.setup-method[data-selected='false'] .setup-steps {
|
||||
.setup-method[data-selected='false'] .setup-steps,
|
||||
.setup-method[data-selected='false'] .setup-warning {
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
@@ -550,6 +552,16 @@ h2 {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.method-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.method-description {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.method-heading h3 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 18px;
|
||||
@@ -565,6 +577,10 @@ h2 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.method-recommendation a {
|
||||
color: var(--focus);
|
||||
}
|
||||
|
||||
.method-heading p {
|
||||
max-width: 640px;
|
||||
margin: 6px 0 0;
|
||||
@@ -664,6 +680,24 @@ h2 {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.setup-warning {
|
||||
margin: 0 20px 20px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--warn);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--warn) 10%, transparent);
|
||||
}
|
||||
|
||||
.setup-warning h4 {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.setup-warning .block {
|
||||
margin: 8px 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.setup-steps > li {
|
||||
display: grid;
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
@@ -829,8 +863,10 @@ button:disabled {
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.global-status[data-state] .status-indicator,
|
||||
.status-pill[data-state]::before {
|
||||
.status-pill[data-state]::before,
|
||||
.validation-details-chevron {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -887,30 +923,37 @@ button:disabled {
|
||||
}
|
||||
}
|
||||
|
||||
.info-button {
|
||||
.info-tooltip-container {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.info-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: help;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.info-button:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.info-button::after {
|
||||
content: attr(data-tooltip);
|
||||
.info-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 280px;
|
||||
width: min(360px, calc(100vw - 64px));
|
||||
padding: 8px 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
@@ -923,14 +966,17 @@ button:disabled {
|
||||
white-space: normal;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.15s ease, visibility 0s linear 0.15s;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.info-button:hover::after,
|
||||
.info-button:focus-visible::after {
|
||||
.info-tooltip-container:hover .info-tooltip,
|
||||
.info-tooltip-container:focus-within .info-tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
@@ -950,6 +996,19 @@ label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.editor-toolbar .btn-link,
|
||||
.editor-toolbar .save-state {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.label-small {
|
||||
@@ -1118,6 +1177,7 @@ code {
|
||||
}
|
||||
|
||||
.tabs {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-wrap: wrap;
|
||||
@@ -1161,7 +1221,7 @@ code {
|
||||
}
|
||||
|
||||
.tab-toggle {
|
||||
position: relative;
|
||||
position: static;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 8px 8px 4px;
|
||||
@@ -1213,6 +1273,32 @@ code {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.tab-toggle-tooltip {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
width: min(320px, 100%);
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.tab-toggle:hover .tab-toggle-tooltip,
|
||||
.tab-toggle:focus-within .tab-toggle-tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.endpoint-meta {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -1320,6 +1406,36 @@ code {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.validation-details {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--surface) 72%, var(--bg));
|
||||
}
|
||||
|
||||
.validation-details > summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.validation-details > summary:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.validation-details[open] > summary {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.validation-details-summary {
|
||||
margin-left: auto;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.validation-table {
|
||||
width: 100%;
|
||||
min-width: 640px;
|
||||
@@ -1395,11 +1511,6 @@ code {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.validation-summary {
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.section-header-toggle:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@@ -19,9 +19,6 @@ const { stripTypeScriptTypes } = require('node:module') as typeof import('node:m
|
||||
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';
|
||||
@@ -29,14 +26,17 @@ 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 DEFAULT_PORT = 3000;
|
||||
const DEFAULT_STATE_FILE = path.join(os.homedir(), '.mock-policy-server', 'state.json');
|
||||
const SETUP_PROBE_PARAM = 'mockPolicySetupProbe';
|
||||
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;
|
||||
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);
|
||||
|
||||
if (args.help) {
|
||||
printHelp();
|
||||
@@ -62,7 +62,8 @@ interface EndpointState {
|
||||
}
|
||||
|
||||
const state = new Map<string, EndpointState>();
|
||||
resetEndpointState();
|
||||
let hasPersistedState = false;
|
||||
initializeEndpointState();
|
||||
|
||||
interface EndpointUpdate {
|
||||
endpoint: string;
|
||||
@@ -81,6 +82,18 @@ interface LogEntry {
|
||||
status: number;
|
||||
}
|
||||
|
||||
const CONTROL_ROUTES = [
|
||||
{ method: 'GET', path: '/api', purpose: 'Discover request shapes, response contracts, routes, and side effects.', returns: 'This discovery document.', sideEffects: 'none' },
|
||||
{ method: 'GET', path: '/api/state', purpose: 'Read endpoint definitions, presets, and current state.', returns: 'Server state with endpoint definitions and configuration.', sideEffects: 'none' },
|
||||
{ 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: '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' },
|
||||
{ method: 'DELETE', path: '/api/cache', purpose: 'Clear the managed-settings disk cache on the server machine.', returns: 'Cleared directories with file counts and missing directories.', sideEffects: 'filesystem' }
|
||||
] as const;
|
||||
|
||||
/** Rolling log of what this server has served, newest first. Shown in the GUI. */
|
||||
let requestLog: LogEntry[] = [];
|
||||
const REQUEST_LOG_LIMIT = 200;
|
||||
@@ -91,8 +104,7 @@ const server = http.createServer((req, res) => {
|
||||
|
||||
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.
|
||||
// an unrelated website cannot drive filesystem control routes.
|
||||
if (pathname === '/api' || pathname.startsWith('/api/')) {
|
||||
if (!isAllowedControlOrigin(req)) {
|
||||
return sendJson(res, 403, { error: 'Cross-origin control API requests are not allowed.' });
|
||||
@@ -166,21 +178,40 @@ function handleControlApi(req: IncomingMessage, res: ServerResponse, pathname: s
|
||||
if ((pathname === '/api' || pathname === '/api/') && req.method === 'GET') {
|
||||
return sendJson(res, 200, {
|
||||
name: 'Mock Policy Server Control API',
|
||||
version: 1,
|
||||
discovery: '/api',
|
||||
errorResponse: {
|
||||
error: 'Human-readable error message.',
|
||||
discovery: 'Routing and method errors also include discovery: "/api".'
|
||||
},
|
||||
persistence: {
|
||||
stateFile: STATE_FILE,
|
||||
description: 'Valid endpoint bodies and response configuration are written atomically after every update and restored at server startup. The GUI also keeps response-body drafts in browser storage.'
|
||||
},
|
||||
recommendedWorkflow: [
|
||||
'GET /api/state and choose endpoint and preset ids from the response.',
|
||||
'POST /api/state with one update or an endpoints array.',
|
||||
'Trigger the client policy request.',
|
||||
'GET /api/log to confirm how the request was handled.'
|
||||
],
|
||||
stateUpdate: {
|
||||
fields: {
|
||||
endpoint: { type: 'string', required: true, source: 'Use an endpoint id returned by GET /api/state.' },
|
||||
preset: { type: 'string', source: 'Use a preset id for the selected endpoint from GET /api/state.' },
|
||||
status: { type: 'integer', minimum: 200, maximum: 599 },
|
||||
body: { type: 'any JSON value' },
|
||||
mode: { type: 'string', enum: ['json', 'malformed-json', 'disconnect', 'timeout'] },
|
||||
active: { type: 'boolean', description: 'true mocks the endpoint; false proxies it upstream.' }
|
||||
},
|
||||
semantics: [
|
||||
'A preset sets status and body and enables mocking.',
|
||||
'Explicit status, body, mode, and active values override preset values.',
|
||||
'Bulk updates are validated first and applied atomically.'
|
||||
],
|
||||
single: { endpoint: 'managedSettings', preset: 'empty', status: 200, body: {}, mode: 'json', 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.' }
|
||||
]
|
||||
routes: CONTROL_ROUTES
|
||||
});
|
||||
}
|
||||
|
||||
@@ -195,7 +226,12 @@ function handleControlApi(req: IncomingMessage, res: ServerResponse, pathname: s
|
||||
} catch (e) {
|
||||
return sendJson(res, 400, { error: `Invalid JSON: ${errorMessage(e)}` });
|
||||
}
|
||||
const result = applyEndpointUpdates(payload);
|
||||
let result: ReturnType<typeof applyEndpointUpdates>;
|
||||
try {
|
||||
result = applyEndpointUpdates(payload);
|
||||
} catch (e) {
|
||||
return sendJson(res, 500, { error: `Failed to persist server state: ${errorMessage(e)}` });
|
||||
}
|
||||
if (!result.ok) {
|
||||
return sendJson(res, 400, { error: result.error });
|
||||
}
|
||||
@@ -204,7 +240,11 @@ function handleControlApi(req: IncomingMessage, res: ServerResponse, pathname: s
|
||||
}
|
||||
|
||||
if (pathname === '/api/reset' && req.method === 'POST') {
|
||||
resetEndpointState();
|
||||
try {
|
||||
resetEndpointState();
|
||||
} catch (e) {
|
||||
return sendJson(res, 500, { error: `Failed to persist reset state: ${errorMessage(e)}` });
|
||||
}
|
||||
return sendJson(res, 200, getState());
|
||||
}
|
||||
|
||||
@@ -214,6 +254,10 @@ function handleControlApi(req: IncomingMessage, res: ServerResponse, pathname: s
|
||||
.catch(e => sendJson(res, 500, { error: errorMessage(e) }));
|
||||
}
|
||||
|
||||
if (pathname === '/api/file-deployment' && req.method === 'GET') {
|
||||
return sendJson(res, 200, getFileDeployment());
|
||||
}
|
||||
|
||||
if (pathname === '/api/cache' && req.method === 'DELETE') {
|
||||
try {
|
||||
return sendJson(res, 200, clearManagedSettingsCache());
|
||||
@@ -231,28 +275,21 @@ function handleControlApi(req: IncomingMessage, res: ServerResponse, pathname: s
|
||||
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) });
|
||||
}
|
||||
const canonicalPath = pathname === '/api/' ? '/api' : pathname;
|
||||
const allowedMethods = CONTROL_ROUTES
|
||||
.filter(route => route.path === canonicalPath)
|
||||
.map(route => route.method);
|
||||
if (allowedMethods.length > 0) {
|
||||
res.setHeader('Allow', allowedMethods.join(', '));
|
||||
return sendJson(res, 405, {
|
||||
error: `${req.method ?? 'Unknown method'} is not allowed for ${canonicalPath}. Allowed methods: ${allowedMethods.join(', ')}.`,
|
||||
discovery: '/api'
|
||||
});
|
||||
}
|
||||
|
||||
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' });
|
||||
return sendJson(res, 404, { error: `Unknown control API route "${pathname}".`, discovery: '/api' });
|
||||
}
|
||||
|
||||
function applyEndpointUpdates(payload: unknown): { ok: true } | { ok: false; error: string } {
|
||||
function applyEndpointUpdates(payload: unknown, persist = true): { ok: true } | { ok: false; error: string } {
|
||||
if (!isRecord(payload)) {
|
||||
return { ok: false, error: 'Request body must be a JSON object.' };
|
||||
}
|
||||
@@ -365,23 +402,80 @@ function applyEndpointUpdates(payload: unknown): { ok: true } | { ok: false; err
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, entry] of nextState) {
|
||||
state.set(id, entry);
|
||||
if (persist) {
|
||||
persistEndpointState(nextState);
|
||||
}
|
||||
replaceEndpointState(nextState);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function resetEndpointState(): void {
|
||||
state.clear();
|
||||
const nextState = createDefaultEndpointState();
|
||||
persistEndpointState(nextState);
|
||||
replaceEndpointState(nextState);
|
||||
}
|
||||
|
||||
function createDefaultEndpointState(): Map<string, EndpointState> {
|
||||
const result = new Map<string, EndpointState>();
|
||||
for (const endpoint of endpoints) {
|
||||
const preset = endpoint.presets[0];
|
||||
state.set(endpoint.id, {
|
||||
result.set(endpoint.id, {
|
||||
status: preset?.status ?? 200,
|
||||
body: preset ? clone(preset.body) : {},
|
||||
mode: 'json',
|
||||
active: endpoint.mockedByDefault === true
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function replaceEndpointState(nextState: Map<string, EndpointState>): void {
|
||||
state.clear();
|
||||
for (const [id, entry] of nextState) {
|
||||
state.set(id, entry);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeEndpointState(): void {
|
||||
replaceEndpointState(createDefaultEndpointState());
|
||||
if (!fs.existsSync(STATE_FILE)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload: unknown = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
||||
const result = applyEndpointUpdates(payload, false);
|
||||
if (!result.ok) {
|
||||
console.error(` Ignoring invalid persisted state at ${STATE_FILE}: ${result.error}`);
|
||||
} else {
|
||||
hasPersistedState = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(` Ignoring unreadable persisted state at ${STATE_FILE}: ${errorMessage(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function persistEndpointState(endpointState: Map<string, EndpointState>): void {
|
||||
const payload = {
|
||||
endpoints: endpoints.map(endpoint => {
|
||||
const entry = endpointState.get(endpoint.id)!;
|
||||
return {
|
||||
endpoint: endpoint.id,
|
||||
status: entry.status,
|
||||
body: entry.body,
|
||||
mode: entry.mode,
|
||||
active: entry.active
|
||||
};
|
||||
})
|
||||
};
|
||||
const temporaryFile = `${STATE_FILE}.${process.pid}.tmp`;
|
||||
fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
|
||||
try {
|
||||
fs.writeFileSync(temporaryFile, `${JSON.stringify(payload, null, '\t')}\n`, { encoding: 'utf8', mode: 0o600 });
|
||||
fs.renameSync(temporaryFile, STATE_FILE);
|
||||
hasPersistedState = true;
|
||||
} finally {
|
||||
fs.rmSync(temporaryFile, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowedControlOrigin(req: IncomingMessage): boolean {
|
||||
@@ -474,6 +568,7 @@ server.listen(PORT, HOST, () => {
|
||||
console.log('');
|
||||
console.log(` Upstream ${UPSTREAM} (anything not mocked is proxied here)`);
|
||||
console.log(` Schema ${SCHEMA_SOURCE}`);
|
||||
console.log(` State ${STATE_FILE}`);
|
||||
console.log('');
|
||||
});
|
||||
|
||||
@@ -486,10 +581,13 @@ function printHelp(): void {
|
||||
console.log('');
|
||||
console.log(' Options:');
|
||||
console.log(' --host <addr> Address to bind (default 127.0.0.1)');
|
||||
console.log(` --port <number> Port to bind (default ${DEFAULT_PORT})`);
|
||||
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(' --state-file <path> Persisted endpoint state file');
|
||||
console.log(` (default ${DEFAULT_STATE_FILE}, env MOCK_POLICY_STATE_FILE)`);
|
||||
console.log(' --help Show this message');
|
||||
console.log('');
|
||||
}
|
||||
@@ -667,134 +765,48 @@ function getState() {
|
||||
mode: state.get(e.id)!.mode,
|
||||
active: state.get(e.id)!.active
|
||||
})),
|
||||
wired: isWired(),
|
||||
overridesPath: PRODUCT_OVERRIDES_JSON,
|
||||
overridesSnippet: buildOverridesSnippet(),
|
||||
baseUrl: `http://${HOST}:${PORT}`,
|
||||
upstream: UPSTREAM,
|
||||
stateFile: STATE_FILE,
|
||||
hasPersistedState,
|
||||
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];
|
||||
function getFileDeployment() {
|
||||
const body = JSON.stringify(state.get('managedSettings')?.body ?? {}, null, '\t');
|
||||
const macOsPath = '/Library/Application Support/GitHubCopilot/managed-settings.json';
|
||||
const windowsPath = '%ProgramFiles%\\GitHubCopilot\\managed-settings.json';
|
||||
const linuxPath = '/etc/github-copilot/managed-settings.json';
|
||||
return {
|
||||
sourceEndpoint: 'managedSettings',
|
||||
body: state.get('managedSettings')?.body ?? {},
|
||||
note: 'Run one installCommand on the client machine, then restart the client. Delete the file or run removeCommand to unset file-based Managed Settings.',
|
||||
platforms: {
|
||||
macos: {
|
||||
path: macOsPath,
|
||||
installCommand: `sudo mkdir -p "/Library/Application Support/GitHubCopilot" && sudo tee "${macOsPath}" >/dev/null <<'JSON'\n${body}\nJSON`,
|
||||
removeCommand: `sudo rm -f -- "${macOsPath}"`
|
||||
},
|
||||
windows: {
|
||||
path: windowsPath,
|
||||
installCommand: [
|
||||
'$dir = Join-Path $env:ProgramFiles \'GitHubCopilot\'',
|
||||
'New-Item -ItemType Directory -Force -Path $dir | Out-Null',
|
||||
'$json = @\'',
|
||||
body,
|
||||
'\'@',
|
||||
'[System.IO.File]::WriteAllText((Join-Path $dir \'managed-settings.json\'), $json)'
|
||||
].join('\n'),
|
||||
removeCommand: '$path = Join-Path $env:ProgramFiles \'GitHubCopilot\\managed-settings.json\'; Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue'
|
||||
},
|
||||
linux: {
|
||||
path: linuxPath,
|
||||
installCommand: `sudo mkdir -p /etc/github-copilot && sudo tee ${linuxPath} >/dev/null <<'JSON'\n${body}\nJSON`,
|
||||
removeCommand: `sudo rm -f -- ${linuxPath}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -880,15 +892,6 @@ 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));
|
||||
}
|
||||
@@ -903,8 +906,10 @@ function stripTrailingSlash(value: string): string {
|
||||
|
||||
interface ServerArgs {
|
||||
host?: string;
|
||||
port?: number;
|
||||
schema?: string;
|
||||
upstream?: string;
|
||||
stateFile?: string;
|
||||
help: boolean;
|
||||
}
|
||||
|
||||
@@ -921,7 +926,7 @@ function parseArgs(argv: string[]): ServerArgs {
|
||||
}
|
||||
|
||||
const [key, inline] = argument.slice(2).split('=', 2);
|
||||
if (key !== 'host' && key !== 'schema' && key !== 'upstream') {
|
||||
if (key !== 'host' && key !== 'port' && key !== 'schema' && key !== 'upstream' && key !== 'state-file') {
|
||||
failArgument(`Unknown option "--${key}".`);
|
||||
}
|
||||
|
||||
@@ -930,7 +935,17 @@ function parseArgs(argv: string[]): ServerArgs {
|
||||
if (!value) {
|
||||
failArgument(`Option "--${key}" requires a value.`);
|
||||
}
|
||||
out[key] = value;
|
||||
if (key === 'port') {
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
failArgument('--port requires an integer from 1 to 65535.');
|
||||
}
|
||||
out.port = port;
|
||||
} else if (key === 'state-file') {
|
||||
out.stateFile = value;
|
||||
} else {
|
||||
out[key] = value;
|
||||
}
|
||||
if (inline === undefined) {
|
||||
i++;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user