From f6258c4f0c0cb5f8bc0f28676e65fcc080f4b3d2 Mon Sep 17 00:00:00 2001 From: joshspicer <23246594+joshspicer@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:26:25 -0700 Subject: [PATCH] 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> --- .../local-testing.md | 29 +- scripts/mock-policy-server/README.md | 104 +++- scripts/mock-policy-server/endpoints.ts | 7 +- scripts/mock-policy-server/public/app.ts | 472 +++++++++++++++--- scripts/mock-policy-server/public/index.html | 203 ++++---- scripts/mock-policy-server/public/style.css | 299 +++++++---- scripts/mock-policy-server/server.ts | 365 +++++++------- 7 files changed, 1003 insertions(+), 476 deletions(-) diff --git a/.github/skills/policy-and-managed-settings/local-testing.md b/.github/skills/policy-and-managed-settings/local-testing.md index 8733fd9f9e7b..d8acbe9dbe03 100644 --- a/.github/skills/policy-and-managed-settings/local-testing.md +++ b/.github/skills/policy-and-managed-settings/local-testing.md @@ -15,24 +15,29 @@ Only managed settings is mocked by default. Use the switch beside each endpoint tab to choose mock or passthrough. Presets apply immediately; response edits auto-save. +Valid endpoint state persists atomically in +`~/.mock-policy-server/state.json` and response-body drafts are also retained in +browser storage. On the first restart after upgrading from an in-memory-only +server, the GUI restores valid browser drafts into the server-side state file. +Use `--state-file` or `MOCK_POLICY_STATE_FILE` for isolated test instances. + Agents should use the JSON control API: start with `GET /api` for discovery and `GET /api/state` for endpoint IDs and presets, then use `POST /api/state` for a single update or an atomic endpoint array. Prefer known preset IDs over copying -preset bodies. +preset bodies. Use `GET /api/file-deployment` to generate platform-specific +install and removal commands from the current Managed Settings response. Choose the client setup in the GUI: -- **Code OSS from sources:** apply `product.overrides.json`, reload, sign in, and - run **Developer: Sync Account Policy**. -- **Stable, Insiders, CLI, or other clients:** configure the displayed system - proxy mapping and enable Proxyman's platform proxy toggle (**Tools > macOS - Proxy** or **Tools > Override Windows Proxy**). VS Code clients must also add - the displayed `http.proxy` property to `settings.json`. -- **File-based settings (no proxy):** expand **Deploy as a file** under the - Managed Settings response body and run the copied per-platform command to write - the current body to `managed-settings.json` on the device. Restart the client to - load it. Use it to skip proxying or to test precedence against a server-managed - response. See [Deploying file-based settings](https://docs.github.com/en/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/configure-enterprise-managed-settings#deploying-file-based-settings). +- **Code OSS, Stable, Insiders, CLI, or other clients:** configure the displayed + system proxy mapping and enable Proxyman's platform proxy toggle (**Tools > + macOS Proxy** or **Tools > Override Windows Proxy**). VS Code clients must + also add the displayed `http.proxy` property to `settings.json`. +- **File-based settings (no proxy):** use **File Deployment** in the right + sidebar and run the copied per-platform command to write the current body to + `managed-settings.json` on the device. Restart the client to load it. Use it + to skip proxying or to test precedence against a server-managed response. See + [Deploying file-based settings](https://docs.github.com/en/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/configure-enterprise-managed-settings#deploying-file-based-settings). Use **Clear SDK Policy Cache**, expand the macOS or Windows section, and run the copied command when the runtime's fresh managed-settings cache prevents a network diff --git a/scripts/mock-policy-server/README.md b/scripts/mock-policy-server/README.md index afc7f9bfb783..dd7c5a326b3d 100644 --- a/scripts/mock-policy-server/README.md +++ b/scripts/mock-policy-server/README.md @@ -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 /copilot_internal/managed_settings?mockPolicySetupProbe=` +(`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` | diff --git a/scripts/mock-policy-server/endpoints.ts b/scripts/mock-policy-server/endpoints.ts index fcd229607451..03c4bfbf94c1 100644 --- a/scripts/mock-policy-server/endpoints.ts +++ b/scripts/mock-policy-server/endpoints.ts @@ -12,10 +12,9 @@ * environments without a build step. * * For the default (github.com) provider these URLs are read verbatim from - * `product.json` -> `defaultChatAgent.`, 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.`. 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. diff --git a/scripts/mock-policy-server/public/app.ts b/scripts/mock-policy-server/public/app.ts index b4e64a292bd3..534cdb8c479f 100644 --- a/scripts/mock-policy-server/public/app.ts +++ b/scripts/mock-policy-server/public/app.ts @@ -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 = {}; + const dirtyDrafts = new Set(); + 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 = Promise.resolve(); @@ -118,11 +138,106 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; let lastServerSignature = ''; let stateWritesInFlight = 0; const pendingSaves = new Map>(); + 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('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=` + : ''; } 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 { + if (state.hasPersistedState !== false) { + return state; + } + const updates: Array<{ endpoint: string; body: unknown }> = []; + const synchronizedUpdates = new Set(); + 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 { - 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 { - try { - const state = await api(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 { const badgeEl = $('schema-badge'); @@ -890,6 +1214,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; function renderValidationResults(parsed: Record): 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('/api/state'); - selectSetupMethod(state.wired ? 'overrides' : 'proxy'); + let state = await api('/api/state'); + state = await restoreBrowserDrafts(state, true); + selectSetupMethod('proxy'); applyState(state); if (endpoints.length) { selectEndpoint(endpoints[0].id); diff --git a/scripts/mock-policy-server/public/index.html b/scripts/mock-policy-server/public/index.html index 773acca3892f..3006c8548d63 100644 --- a/scripts/mock-policy-server/public/index.html +++ b/scripts/mock-policy-server/public/index.html @@ -31,22 +31,6 @@ Policies -
- Clear SDK Policy Cache -
-

Copy the command for the client platform, then run it in a terminal.

-
- macOS - - -
-
- Windows (PowerShell) - - -
-
-
@@ -61,7 +45,10 @@

Connect a client

Route policy requests to this server

-

Select a connection method below to enable its steps. You can switch methods at any time.

+

+ Learn about configuring GitHub Copilot managed settings + and review the managed settings schema and supported agent policy keys. +

@@ -72,12 +59,16 @@
- +
+ + Any HTTP debugging proxy that can rewrite HTTPS requests works. I suggest Proxyman. + +
Checking…
@@ -90,7 +81,7 @@

Configure the system proxy

-

Redirect the managed settings URL to this local server. In Proxyman, create a Map Remote rule using these values.

+

Configure an HTTPS rewrite from the managed settings URL to this local server. In Proxyman, create a Map Remote rule using these values.

Map from @@ -103,7 +94,7 @@
-
+
Proxyman tips
  • Keep the source and destination paths identical.
  • @@ -141,44 +132,17 @@

    Request fresh policy in VS Code

    Open the Command Palette and run > Developer: Sync Account Policy.

    To refresh the policy used by Local Agent Host, also run > Developer: Restart Local Agent Host.

    -

    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.

    -
- - - - -
-
-
-
- - -
-
Not applied
-
- -
    -
  1. - -
    -

    Apply the local endpoint URLs

    -

    The server updates product.overrides.json and preserves any other top-level overrides.

    - -
    -
  2. -
  3. - -
    -

    Reload and request policy

    -

    Reload Code OSS, sign in, open the Command Palette, and run > Developer: Sync Account Policy.

    -

    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.

    +

    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.

+
@@ -201,9 +165,9 @@

Write the policy file

-

On the Policies page, edit the Managed Settings response body, then expand Deploy as a file to copy the command for the client platform and run it in a terminal.

+

On the Policies page, edit the Managed Settings response body, then copy the command for the client platform from File Deployment and run it in a terminal.

-
+
File locations
  • macOS: /Library/Application Support/GitHubCopilot/managed-settings.json
  • @@ -225,7 +189,6 @@
-
@@ -284,46 +247,18 @@
- +
+ + +
+ aria-describedby="editor-status" + aria-keyshortcuts="Tab Shift+Tab Alt+Shift+F Escape">

- -
@@ -337,6 +272,78 @@

No requests yet.

    + + diff --git a/scripts/mock-policy-server/public/style.css b/scripts/mock-policy-server/public/style.css index ce71259ea601..cbfb00d0c222 100644 --- a/scripts/mock-policy-server/public/style.css +++ b/scripts/mock-policy-server/public/style.css @@ -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); } diff --git a/scripts/mock-policy-server/server.ts b/scripts/mock-policy-server/server.ts index bb8690131b23..146b5bdf7706 100644 --- a/scripts/mock-policy-server/server.ts +++ b/scripts/mock-policy-server/server.ts @@ -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(); -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; + 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 { + const result = new Map(); 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): 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): 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 Address to bind (default 127.0.0.1)'); + console.log(` --port Port to bind (default ${DEFAULT_PORT})`); console.log(' --upstream Real API that un-mocked requests are proxied to'); console.log(` (default ${DEFAULT_UPSTREAM}, env MOCK_POLICY_UPSTREAM)`); console.log(' --schema Managed-settings schema path, file: URI, or URL'); console.log(` (default ${DEFAULT_SCHEMA_SOURCE}, env MANAGED_SETTINGS_SCHEMA)`); + console.log(' --state-file 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 { - const urls: Record = {}; - 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 { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function shallowEqual(a: Record, b: Record): 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++; }