chore: publish from main

This commit is contained in:
github-actions[bot]
2026-07-23 17:56:34 +00:00
parent 67b97ccdb7
commit 38eb0a34b0
76 changed files with 10104 additions and 732 deletions
+19
View File
@@ -0,0 +1,19 @@
{
"name": "backrooms-canvas",
"description": "Wander an endless first-person backrooms in a Copilot canvas while agents work; their status ghost-writes on the walls.",
"version": "1.0.0",
"author": {
"name": "John Haugabook",
"url": "https://github.com/jhauga"
},
"keywords": [
"backrooms",
"copilot-canvas",
"interactive-canvas",
"first-person",
"procedural-generation",
"session-breaks"
],
"logo": "assets/preview.png",
"extensions": "extensions"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,90 @@
# BackRooms Canvas
A GitHub Copilot canvas that opens an endless first-person backrooms in the side panel. Yellow-wallpapered halls under humming fluorescent panels, drop-tile ceilings, and worn office carpet, filmed through the shake and grain of a 1990-era handheld camcorder. Somewhere past the fog, something else walks the same halls.
It is a canvas port of the [BackViews VSCode extension](https://github.com/isocialPractice/vscode-backviews). The world is powered by [cmd-backedges](https://github.com/isocialPractice/cmd-backedges), a procedural infinite maze engine, so every wall, room, and prop is a pure function of the seed: the same seed always rebuilds the same rooms, forever, in every direction.
While an agent works in the session, the halls start writing back. Its status and streaming responses are scrawled onto the walls in a messy ink script, and a camcorder-style token counter runs under the HUD battery.
## Files
- `extension.mjs` — canvas declaration, loopback game server, static asset handling, and agent actions.
- `game/` — the prebuilt game bundle (`webview.js`) and the `index.html` host shim served inside the canvas.
- `materials/` — photo textures (`wallpaper.jpg`, `ceiling.jpg`, `carpet.jpg`) tiled over the procedural atlas.
- `assets/` — app icon and `preview.png` for the extensions gallery.
- `package.json` — declares the Copilot SDK dependency and ESM entry point.
- `copilot-extension.json` — Copilot extension name/version metadata.
## Prerequisites
- **Node.js 20.19 or newer**, because the Copilot SDK requires `node ^20.19.0 || >=22.12.0`.
- A WebGL-capable canvas surface (the renderer is raw WebGL).
- The GitHub Copilot app canvas / UI-extensions experiment enabled.
## Install
Drop this folder at `~/.copilot/extensions/backrooms-canvas/` for user scope, or in a repository at `.github/extensions/backrooms-canvas/` for project scope. Then install dependencies from inside the copied folder:
```sh
# User scope
cd ~/.copilot/extensions/backrooms-canvas
# Or project scope, from the repository root
cd .github/extensions/backrooms-canvas
npm install
```
Reload extensions in the GitHub Copilot app, then open the `backrooms-canvas` canvas. Click the view to capture the mouse and start walking.
The canvas accepts optional open inputs:
| Input | Type | Description |
| --- | --- | --- |
| `seed` | number | Maze seed. The same seed always rebuilds the same halls. `0` rolls a random seed. |
| `materialPreset` | string | Wall material set: `classic`, `office`, `pool`, `concrete`, or `panel`. |
| `monsterEnabled` | boolean | Whether something else walks the halls. |
## Controls
| Input | Action |
| --- | --- |
| `W` / `S` or `Up` / `Down` | Walk forward / back |
| `A` / `D` | Strafe left / right |
| `Left` / `Right` or `Q` / `E` | Turn |
| `Shift` | Hurry |
| Mouse (after clicking the view) | Look around |
| `M` or `Esc` | Open the in-game menu |
The in-game menu has Resume, Relocate, Settings, and Help, plus live stats. Settings changed there persist in the canvas via `localStorage`, so your choices survive a reload.
## Agent actions
The agent drives the game and feeds the ghost-writer through three actions. They are the canvas equivalent of the original extension's `backviews_reportJob` tool and chat-session mirror.
- `report_job { status, tokens?, done? }` — report the current job step. The status text is scrawled on the walls and the token count drives the HUD counter. Call it when work starts, again on each new step, and once more with `done: true` when finished.
- `ghost_write { text, tokens?, done? }` — ghost-write a block of text onto the wall ahead, character by character as it grows, like a streaming response. Send the growing text on each call, then once with `done: true` to settle it in place.
- `relocate` — drop the wanderer into a fresh random seed, wiping any writing already on the walls.
To have the agent narrate itself automatically, reference these actions from a `.github/copilot-instructions.md` so it calls `report_job` on each step of a chat request.
## How the port works
The game itself is unchanged: `game/webview.js` is the same self-contained bundle the VSCode extension ships (WebGL renderer, maze engine, camcorder overlay, and wall-writing subsystem in one esbuild IIFE). The bundle was written to talk to a VSCode webview host through `acquireVsCodeApi()` and `window.postMessage`.
`game/index.html` shims that host:
- `acquireVsCodeApi()` is backed by `localStorage` for state (seed and player position) and settings persistence.
- The canvas server (`extension.mjs`) runs a loopback HTTP server that serves the bundle and streams agent activity over Server-Sent Events at `/events`. The shim translates those events into the exact `jobStatus`, `chatSession`, and `relocate` messages the bundle already listens for.
- Agent actions broadcast onto that SSE stream, so `report_job` writes on the walls and `ghost_write` streams text just as the VSCode chat mirror did.
This mirrors the [`arcade-canvas`](../arcade-canvas) architecture: a static frontend served from a loopback server, with the agent driving it through canvas actions.
## Credits
- Maze generation: [cmd-backedges](https://github.com/isocialPractice/cmd-backedges) by John Haugabook.
- Original extension: [vscode-backviews](https://github.com/isocialPractice/vscode-backviews).
## License
MIT
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,4 @@
{
"name": "backrooms-canvas",
"version": 1
}
@@ -0,0 +1,472 @@
/**
* Copilot canvas entry point for BackRooms.
*
* The game itself is the prebuilt browser bundle at game/webview.js (an
* esbuild IIFE that already carries the WebGL renderer, the procedural maze
* engine, and the whole first-person world). This file is only the host: a
* tiny local HTTP server that serves that bundle plus its photo materials,
* and a canvas registration that lets an agent drive the game through actions.
*
* The bundle was written for a VSCode webview and talks to its host through
* `acquireVsCodeApi()` + `window.postMessage`. game/index.html shims that API
* against this server's Server-Sent Events stream, so the same bundle runs
* unchanged inside the canvas. The message shapes below match the bundle's
* expectations exactly (see the original src/shared/settings.ts):
*
* host -> game : { type: 'config', settings }
* { type: 'jobStatus', job } // CopilotJob
* { type: 'chatSession', session } // ChatSessionSnapshot
* { type: 'relocate' }
* game -> host : { type: 'ready' } // handled in the shim
* { type: 'updateSetting', key, value } // handled in the shim
*/
import { createReadStream } from "node:fs";
import { readFile, stat } from "node:fs/promises";
import { createServer } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const gameRoot = path.join(__dirname, "game");
const materialsRoot = path.join(__dirname, "materials");
const assetsRoot = path.join(__dirname, "assets");
const indexPath = path.join(gameRoot, "index.html");
/**
* Default settings, mirrored from the bundle's own DEFAULT_SETTINGS. The shim
* merges any per-open overrides and the player's saved menu choices on top of
* these before handing the game its first `config` message.
*/
const DEFAULT_SETTINGS = {
seed: 0,
moveSpeed: 2.2,
renderDistance: 14,
cameraShake: true,
filmGrain: true,
vhsHud: true,
furniture: true,
wallpaperShifts: false,
mouseLook: true,
invertTurn: false,
invertStrafe: false,
invertForward: false,
materialPreset: "classic",
materialHueShift: 0,
materialBrightness: 1,
monsterEnabled: true,
monsterSpeed: 2.6,
monsterSpawnMin: 1,
monsterSpawnMax: 5,
monsterForm: "random",
copilotGhostWriter: true,
};
const MATERIAL_PRESETS = ["classic", "office", "pool", "concrete", "panel"];
const MONSTER_FORMS = ["spider", "humanoid", "cloud", "random"];
/** Idle Copilot job; the walls stay quiet and the HUD counter fades out. */
const IDLE_JOB = { working: false, status: "", tokens: 0 };
const servers = new Map();
function contentType(filePath) {
switch (path.extname(filePath).toLowerCase()) {
case ".html":
return "text/html; charset=utf-8";
case ".js":
return "text/javascript; charset=utf-8";
case ".css":
return "text/css; charset=utf-8";
case ".json":
return "application/json; charset=utf-8";
case ".map":
return "application/json; charset=utf-8";
case ".png":
return "image/png";
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".svg":
return "image/svg+xml";
case ".webp":
return "image/webp";
default:
return "application/octet-stream";
}
}
/** Resolves a request path under a root, refusing anything that escapes it. */
function resolveUnder(root, requestPath) {
const resolved = path.resolve(root, `.${requestPath}`);
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
throw new CanvasError("invalid_path", "Requested path is outside the backrooms assets.");
}
return resolved;
}
function sendJson(res, value) {
res.writeHead(200, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
});
res.end(JSON.stringify(value));
}
function sendNotFound(res) {
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
res.end("Not found");
}
function sendSse(res, event, data) {
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
function broadcast(entry, event, data) {
for (const client of entry.clients) {
sendSse(client, event, data);
}
}
function randomSeed() {
return Math.floor(Math.random() * 999_999) + 1;
}
/** Coerces one enum-ish value, falling back to the default when unknown. */
function pickEnum(value, allowed, fallback) {
return typeof value === "string" && allowed.includes(value) ? value : fallback;
}
/** Coerces a finite number into [min, max], or returns the fallback. */
function clampNumber(value, min, max, fallback) {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
return Math.min(max, Math.max(min, value));
}
/**
* Turns arbitrary open-input into a partial settings override the shim can
* merge over the defaults. Only the settings that make sense to preset from an
* agent are honored; everything else stays on its default or saved value.
*/
function normalizeOverrides(input) {
const overrides = {};
if (!input || typeof input !== "object") {
return overrides;
}
if (input.seed !== undefined) {
const seed = Math.trunc(clampNumber(input.seed, 0, Number.MAX_SAFE_INTEGER, 0));
overrides.seed = seed === 0 ? randomSeed() : seed;
}
if (input.materialPreset !== undefined) {
overrides.materialPreset = pickEnum(input.materialPreset, MATERIAL_PRESETS, "classic");
}
if (input.monsterEnabled !== undefined) {
overrides.monsterEnabled = input.monsterEnabled === true;
}
if (input.monsterForm !== undefined) {
overrides.monsterForm = pickEnum(input.monsterForm, MONSTER_FORMS, "random");
}
if (input.copilotGhostWriter !== undefined) {
overrides.copilotGhostWriter = input.copilotGhostWriter !== false;
}
return overrides;
}
/** Coerces report_job input into a CopilotJob the game understands. */
function normalizeJob(input) {
if (!input || typeof input !== "object") {
return { ...IDLE_JOB };
}
const done = input.done === true;
return {
working: !done,
status: typeof input.status === "string" ? input.status.slice(0, 120) : "",
tokens:
typeof input.tokens === "number" && Number.isFinite(input.tokens)
? Math.max(0, Math.floor(input.tokens))
: 0,
};
}
/**
* Coerces ghost_write input into a ChatSessionSnapshot. The game ghost-writes
* `current` onto the wall ahead as it grows, and the token count drives the
* HUD odometer. `done` settles the writing in place and stops the counter.
*/
function normalizeSession(input) {
const text = typeof input?.text === "string" ? input.text : "";
const done = input?.done === true;
const tokens =
typeof input?.tokens === "number" && Number.isFinite(input.tokens)
? Math.max(0, Math.floor(input.tokens))
: Math.ceil(text.length / 4);
return { working: !done && text.length > 0, history: [], current: text, tokens };
}
async function renderIndex(entry) {
const html = await readFile(indexPath, "utf8");
// Handed to the shim so it can build the game's first `config` message and
// replay any job that is already running when the panel opens.
const init = {
defaults: DEFAULT_SETTINGS,
overrides: entry.overrides,
materials: {
wallpaper: "/materials/wallpaper.jpg",
ceiling: "/materials/ceiling.jpg",
carpet: "/materials/carpet.jpg",
},
job: entry.job,
session: entry.session,
};
return html.replace("__BACKROOMS_INIT__", JSON.stringify(init).replace(/</g, "\\u003c"));
}
async function streamFile(res, filePath) {
const fileStat = await stat(filePath).catch(() => undefined);
if (!fileStat?.isFile()) {
sendNotFound(res);
return;
}
res.writeHead(200, {
"content-type": contentType(filePath),
"cache-control": "no-cache",
});
const stream = createReadStream(filePath);
stream.on("error", () => {
if (!res.headersSent) {
sendNotFound(res);
} else {
res.destroy();
}
});
stream.pipe(res);
}
async function handleRequest(entry, req, res) {
const url = new URL(req.url ?? "/", entry.url);
if (url.pathname === "/events") {
res.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
connection: "keep-alive",
});
entry.clients.add(res);
// Catch a fresh (or reconnecting) client up to the live state.
sendSse(res, "jobStatus", { job: entry.job });
if (entry.session) {
sendSse(res, "chatSession", { session: entry.session });
}
req.on("close", () => entry.clients.delete(res));
return;
}
// The shim reports explicit setting changes (menu edits, relocate): the
// per-open override for that key stops applying, so a reload keeps the
// player's choice instead of replaying the stale override.
if (req.method === "DELETE" && url.pathname.startsWith("/override/")) {
delete entry.overrides[decodeURIComponent(url.pathname.slice("/override/".length))];
res.writeHead(204);
res.end();
return;
}
if (url.pathname === "/favicon.ico") {
await streamFile(res, path.join(assetsRoot, "icon.png"));
return;
}
try {
if (url.pathname === "/" || url.pathname === "/index.html") {
res.writeHead(200, {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-cache",
});
res.end(await renderIndex(entry));
return;
}
const staticPath = url.pathname.startsWith("/materials/")
? resolveUnder(materialsRoot, url.pathname.slice("/materials".length))
: url.pathname.startsWith("/assets/")
? resolveUnder(assetsRoot, url.pathname.slice("/assets".length))
: resolveUnder(gameRoot, url.pathname);
await streamFile(res, staticPath);
} catch (error) {
if (error instanceof CanvasError) {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
res.end(error.message);
return;
}
throw error;
}
}
async function startServer(instanceId, overrides) {
const entry = {
clients: new Set(),
overrides,
job: { ...IDLE_JOB },
session: null,
server: undefined,
url: undefined,
};
const server = createServer((req, res) => {
handleRequest(entry, req, res).catch((error) => {
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
res.end(error instanceof Error ? error.message : "Backrooms canvas server error");
});
});
entry.server = server;
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
entry.url = `http://127.0.0.1:${port}/`;
servers.set(instanceId, entry);
return entry;
}
function getOpenEntry(instanceId) {
const entry = servers.get(instanceId);
if (!entry) {
throw new CanvasError("backrooms_not_open", "Open the Backrooms canvas before invoking this action.");
}
return entry;
}
await joinSession({
canvases: [
createCanvas({
id: "backrooms-canvas",
displayName: "BackRooms",
description:
"An endless first-person backrooms to wander while agents work. The agent's status ghost-writes on the walls.",
inputSchema: {
type: "object",
properties: {
seed: {
type: "number",
description: "Maze seed. The same seed always rebuilds the same halls. 0 rolls a random seed.",
},
materialPreset: {
type: "string",
enum: MATERIAL_PRESETS,
description: "Wall material set to start with.",
},
monsterEnabled: {
type: "boolean",
description: "Whether something else walks the halls.",
},
},
additionalProperties: false,
},
actions: [
{
name: "report_job",
description:
"Report your current job status to the backrooms. Call it when you start a chat request, again on each new step, and once more with done=true when finished. The status text is scrawled on the walls and the token count drives a camcorder-style HUD counter.",
inputSchema: {
type: "object",
properties: {
status: {
type: "string",
description:
"Short status text to scrawl on the walls (e.g. 'reading the codebase', 'rewriting the parser').",
},
tokens: {
type: "number",
description: "Approximate tokens consumed by the current job so far.",
},
done: {
type: "boolean",
description: "Set true when the job is finished; the walls stop updating and the counter fades out.",
},
},
required: ["status"],
additionalProperties: false,
},
handler: (ctx) => {
const entry = getOpenEntry(ctx.instanceId);
entry.job = normalizeJob(ctx.input);
broadcast(entry, "jobStatus", { job: entry.job });
return { job: entry.job };
},
},
{
name: "ghost_write",
description:
"Ghost-write a block of text onto the walls ahead, character by character as it grows, like a streaming chat response. Call repeatedly with the full text so far, then once with done=true to settle it in place.",
inputSchema: {
type: "object",
properties: {
text: {
type: "string",
description: "The full response text so far. Send the growing text on each call; the walls reveal the new characters.",
},
tokens: {
type: "number",
description: "Approximate tokens of the response so far. Defaults to text length / 4.",
},
done: {
type: "boolean",
description: "Set true when the response is complete; the writing settles onto the wall and the counter stops.",
},
},
required: ["text"],
additionalProperties: false,
},
handler: (ctx) => {
const entry = getOpenEntry(ctx.instanceId);
entry.session = normalizeSession(ctx.input);
broadcast(entry, "chatSession", { session: entry.session });
return { working: entry.session.working, tokens: entry.session.tokens };
},
},
{
name: "relocate",
description: "Drop the wanderer into a fresh random seed, wiping any writing already on the walls.",
handler: (ctx) => {
const entry = getOpenEntry(ctx.instanceId);
const seed = randomSeed();
// The walls are wiped on relocate, so drop the job and
// session snapshots too or a reload would replay them
// onto the fresh maze.
entry.job = { ...IDLE_JOB };
entry.session = null;
broadcast(entry, "jobStatus", { job: entry.job });
broadcast(entry, "relocate", { seed });
return { seed };
},
},
],
open: async (ctx) => {
const overrides = normalizeOverrides(ctx.input);
let entry = servers.get(ctx.instanceId);
if (!entry) {
entry = await startServer(ctx.instanceId, overrides);
} else {
entry.overrides = { ...entry.overrides, ...overrides };
}
return {
title: "BackRooms",
status: entry.job.working && entry.job.status ? entry.job.status : "Wandering",
url: entry.url,
};
},
onClose: async (ctx) => {
const entry = servers.get(ctx.instanceId);
if (!entry) return;
servers.delete(ctx.instanceId);
for (const client of entry.clients) {
client.end();
}
await new Promise((resolve) => entry.server.close(() => resolve()));
},
}),
],
});
@@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self';">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BackRooms</title>
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #101010; }
#app { position: relative; width: 100%; height: 100%; }
</style>
</head>
<body>
<div id="app"></div>
<script>
/*
* Host shim. The game bundle (webview.js) was written for a VSCode webview:
* it calls acquireVsCodeApi() for state + host messaging and reads its
* material URIs off window.__BACKROOMS_MATERIALS__. This block stands in for
* that host, backing state with localStorage and translating the canvas
* server's Server-Sent Events into the window `message` events the bundle
* already listens for. It MUST run before webview.js loads.
*/
(() => {
const init = __BACKROOMS_INIT__;
const SETTINGS_KEY = "backrooms.settings";
const STATE_KEY = "backrooms.state";
// Material photo URIs the bundle patches over its procedural atlas.
window.__BACKROOMS_MATERIALS__ = init.materials || {};
function readJson(key) {
try {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
function writeJson(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// Storage disabled or full: settings/position just do not persist.
}
}
// Effective settings: bundle defaults, then the player's saved menu
// choices, then any per-open overrides the agent requested win on top.
function currentSettings() {
const saved = readJson(SETTINGS_KEY) || {};
return { ...init.defaults, ...saved, ...(init.overrides || {}) };
}
function dispatch(message) {
window.postMessage(message, "*");
}
window.acquireVsCodeApi = () => ({
postMessage(message) {
if (!message || typeof message !== "object") {
return;
}
if (message.type === "ready") {
// Reply after the bundle has wired its own message listener.
setTimeout(() => {
dispatch({ type: "config", settings: currentSettings() });
if (init.job) {
dispatch({ type: "jobStatus", job: init.job });
}
if (init.session) {
dispatch({ type: "chatSession", session: init.session });
}
}, 0);
} else if (message.type === "updateSetting") {
const saved = readJson(SETTINGS_KEY) || {};
saved[message.key] = message.value;
writeJson(SETTINGS_KEY, saved);
// An explicit change beats a per-open override, now and after a
// reload (the server replays its overrides into every reload).
if (init.overrides && message.key in init.overrides) {
delete init.overrides[message.key];
fetch("/override/" + encodeURIComponent(message.key), { method: "DELETE" }).catch(() => {});
}
}
},
getState() {
return readJson(STATE_KEY) || undefined;
},
setState(state) {
writeJson(STATE_KEY, state);
},
});
// Live host -> game channel: the server pushes agent activity over SSE and
// we forward it as the exact messages the bundle expects.
try {
const events = new EventSource("/events");
events.addEventListener("jobStatus", (event) => {
try {
dispatch({ type: "jobStatus", job: JSON.parse(event.data).job });
} catch {}
});
events.addEventListener("chatSession", (event) => {
try {
dispatch({ type: "chatSession", session: JSON.parse(event.data).session });
} catch {}
});
events.addEventListener("relocate", (event) => {
let seed;
try {
seed = JSON.parse(event.data).seed;
} catch {}
dispatch({ type: "relocate", seed });
});
events.addEventListener("reload", () => window.location.reload());
} catch {
// No EventSource: the game still runs, just without live agent activity.
}
})();
</script>
<script src="./webview.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

@@ -0,0 +1,446 @@
{
"name": "backrooms-canvas",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "backrooms-canvas",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@github/copilot-sdk": "latest"
}
},
"node_modules/@github/copilot": {
"version": "1.0.71",
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.71.tgz",
"integrity": "sha512-F3axBi+sXSLYDJbxCBW36bM6MYKNC2rlyAf3Ivo/MjiHKKJW7j5AmaR1IRYS9Gt8r9mxOwWFM1cJFA+CuLaR8g==",
"dependencies": {
"detect-libc": "^2.1.2"
},
"bin": {
"copilot": "npm-loader.js"
},
"optionalDependencies": {
"@github/copilot-darwin-arm64": "1.0.71",
"@github/copilot-darwin-x64": "1.0.71",
"@github/copilot-linux-arm64": "1.0.71",
"@github/copilot-linux-x64": "1.0.71",
"@github/copilot-linuxmusl-arm64": "1.0.71",
"@github/copilot-linuxmusl-x64": "1.0.71",
"@github/copilot-win32-arm64": "1.0.71",
"@github/copilot-win32-x64": "1.0.71"
}
},
"node_modules/@github/copilot-darwin-arm64": {
"version": "1.0.71",
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.71.tgz",
"integrity": "sha512-mEWzyqbqRAWgyU7i2uuSRoVPx/TwaFQX0nZmw0bc30aJ0BnO7cy2kYQyCHw8ykmf/tfxT0xauZ6k0BOFmWizzQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"bin": {
"copilot-darwin-arm64": "copilot"
}
},
"node_modules/@github/copilot-darwin-x64": {
"version": "1.0.71",
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.71.tgz",
"integrity": "sha512-Md9yEg406OBVBx3w4PeEj62TubulVLBcHleqmCoOoUmPgUxPZotUbrqz3rtbzADbXfrrD7JWvVsbd2UiNL194w==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"bin": {
"copilot-darwin-x64": "copilot"
}
},
"node_modules/@github/copilot-linux-arm64": {
"version": "1.0.71",
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.71.tgz",
"integrity": "sha512-ykLJYOqBj3jRB5IJCDugLClAqbr7DmtTbUjlNY7+Jdq/n6i+d7xUQGclf1IWL5gnxbGQVAf+zkToD+sRM389Kg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"bin": {
"copilot-linux-arm64": "copilot"
}
},
"node_modules/@github/copilot-linux-x64": {
"version": "1.0.71",
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.71.tgz",
"integrity": "sha512-pC0FNHG+BBwZd6yZlM85kkAGN+uJhM6o+THi76N2GnnSxmw7+remb1mvYxdgRVbdCm+LBUIbCKRWJLuMwrfb6A==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"bin": {
"copilot-linux-x64": "copilot"
}
},
"node_modules/@github/copilot-linuxmusl-arm64": {
"version": "1.0.71",
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.71.tgz",
"integrity": "sha512-hBmDljFTjacxqZTasCEy43H8EIzuXB/hHEBBCMFjhB9J00nIxsO6Dh0woTifKpx7knTYZdpTjjca3D0pAoZlUA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"bin": {
"copilot-linuxmusl-arm64": "copilot"
}
},
"node_modules/@github/copilot-linuxmusl-x64": {
"version": "1.0.71",
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.71.tgz",
"integrity": "sha512-CfTXU8pa5dxRz22xQzoi3TiG1PJo9+WR8PRDiPSdkIBSyPJ1NvX87DJmfXjTgeAfR+wkjt/p0keDCaBBVhNmUA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"bin": {
"copilot-linuxmusl-x64": "copilot"
}
},
"node_modules/@github/copilot-sdk": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.7.tgz",
"integrity": "sha512-dgCFCPfxWUkrgclQbrm7WCFzTf5RnJHsK1Lqsc3KjPBbDLPutJT0qIGg3xJ0ZELLyX0icg3TOmVczhR4HdwHxw==",
"dependencies": {
"@github/copilot": "^1.0.71",
"koffi": "^3.1.0",
"vscode-jsonrpc": "^8.2.1",
"zod": "^4.3.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@github/copilot-win32-arm64": {
"version": "1.0.71",
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.71.tgz",
"integrity": "sha512-+HI1DokixXhHUahj06Fw67ZAigBuXKC58BFma4UJOGrQsDgwOSbqeTQHCw6vuymzjKlg3sactfsCUTaefkjscQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"bin": {
"copilot-win32-arm64": "copilot.exe"
}
},
"node_modules/@github/copilot-win32-x64": {
"version": "1.0.71",
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.71.tgz",
"integrity": "sha512-02kXOBd9CwBbCaztuf71WYWn+uGapCuiaasomN4tcMH3HBVZ4gi3J0ZUoRcgcS80xh81uQyeBHbnUKzb/RE/9A==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"bin": {
"copilot-win32-x64": "copilot.exe"
}
},
"node_modules/@koromix/koffi-darwin-arm64": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.1.1.tgz",
"integrity": "sha512-+Dl0zQDh1Wb55AWOn9hp7K30qgkODvrvN+ZNkFOh81Q0oFX/rpJQtocgjAuYk2zFAcajSeVDumkcHMPwnKSXzA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-darwin-x64": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.1.1.tgz",
"integrity": "sha512-cDFAKn1qdZBFLrp7dAc9QUDw3l4xAhTJbOdPWWb0LxssVicUdHcRCLZGrDsmPW2tpH6LGNNeLgqRpAoD2Mo8iA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-freebsd-arm64": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.1.1.tgz",
"integrity": "sha512-zaP7FJISI/scQW9Wa5QicY3a09WmtKBWSbmC+5nfCqPzwWe7Hx2so74Er7mPsDfCiMMR0Ya+evKbJQDkfyXicg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"freebsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-freebsd-ia32": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.1.1.tgz",
"integrity": "sha512-7GejVb688TLM8rbjfc0oezJrATxZc0dn801xWEDJekN2DgmRXu7HquGqWQ6z3NeSq7ZxEggz4T3xtlbCysQapA==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"freebsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-freebsd-x64": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.1.1.tgz",
"integrity": "sha512-XLiCFP9OFCyOoGTjAimtDKLhzhfo34WcP1ShVWxRzNCWDGjfz8BYjwd69cp/cDSUXZbxamqs4+/6vmkePq9wxA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"freebsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-arm64": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.1.1.tgz",
"integrity": "sha512-HA9xINK7G4dRAkpfnBWD9VfuyIBgW1SuK+KPHjksUwRMOnhgqP8J/JqgrAzdzcDiefGBkqEacIP776OUwz7knQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-ia32": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.1.1.tgz",
"integrity": "sha512-jG7IFytmP8K5Qtbx0ro0ZeuX3JjSsLxmYhq+nmXDdrtOAlxIsWGynuiDLS6Jk3vOchVii2m6Y2f/L3GLG2fG5A==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-loong64": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.1.1.tgz",
"integrity": "sha512-CIsT1cNnih8FuU52Me/IVlJBpH28SQfoDeYPctJswgJzaARktusF7m4MUbtR1PBDjuquCVM4/vFyNdOzfPonvA==",
"cpu": [
"loong64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-riscv64": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.1.1.tgz",
"integrity": "sha512-9D6RmqeKsSvs3U6jILJU9PcAjMwKKyn7yLxNBb5k6z9PCoUoGJ3/BrhXAX0qjrLLwEiIpP/hS/40RuXvH8Lc3Q==",
"cpu": [
"riscv64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-x64": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.1.1.tgz",
"integrity": "sha512-pyTcX5fePeYbt7TZAwRby69wdlRx3PT+g15ra5IYdat/Pgh3qAKEYeZ+uu7WpPGOy43p/oSRqqZoa2kORzozlA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-openbsd-ia32": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.1.1.tgz",
"integrity": "sha512-iPnPzvG2HOfdzaiG1drdkt86sAqmTPDv9mAf+5gL7mRzkeeQC88EVGboRy7eXwdXn7R+v0ntA3iQxdHrBn6yXw==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"openbsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-openbsd-x64": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.1.1.tgz",
"integrity": "sha512-/Xqc3R0SVoMCYjMPZnJ9bULtRo364+dKmnQhfDrI83tSpxUHRw7HRNf12vBeL+hPgKxSBjtMpWfQ/ZIyVyLFag==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"openbsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-win32-arm64": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.1.1.tgz",
"integrity": "sha512-JhqHauEwQvdcWUERxrV5HH/DT9W7hY1A1eU6/o8tB+yck+D3kt5elpRDBt9KjpW6h+vHPy3V0sjDvO0CXyabTA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-win32-ia32": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.1.1.tgz",
"integrity": "sha512-ZRuyYmlGS/rCc966qqs0qREXDW4FRdul7rDF1VgSWHbVmdc196PUgUT+blq/GjZgTwqzeEXtMRgM+cU8krHjvA==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"win32"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-win32-x64": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.1.1.tgz",
"integrity": "sha512-KqHPmvj6QILhNyI/To8QSihHsijeVGIYYPBOUnXEpcnH2LuLbargY4Hd6dDeTN3Z90uUUxN+1FWz1UnhVzFOiA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/koffi": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/koffi/-/koffi-3.1.1.tgz",
"integrity": "sha512-mRX6AMeeKCxSOeOopqAcLAl5jcNvge7NAG8l7rF/8gGJATI0tdHFYjteIdE0mGOtWdsrJOij+PjnP8Q9c1gwgA==",
"hasInstallScript": true,
"funding": {
"url": "https://liberapay.com/Koromix"
},
"optionalDependencies": {
"@koromix/koffi-darwin-arm64": "3.1.1",
"@koromix/koffi-darwin-x64": "3.1.1",
"@koromix/koffi-freebsd-arm64": "3.1.1",
"@koromix/koffi-freebsd-ia32": "3.1.1",
"@koromix/koffi-freebsd-x64": "3.1.1",
"@koromix/koffi-linux-arm64": "3.1.1",
"@koromix/koffi-linux-ia32": "3.1.1",
"@koromix/koffi-linux-loong64": "3.1.1",
"@koromix/koffi-linux-riscv64": "3.1.1",
"@koromix/koffi-linux-x64": "3.1.1",
"@koromix/koffi-openbsd-ia32": "3.1.1",
"@koromix/koffi-openbsd-x64": "3.1.1",
"@koromix/koffi-win32-arm64": "3.1.1",
"@koromix/koffi-win32-ia32": "3.1.1",
"@koromix/koffi-win32-x64": "3.1.1"
}
},
"node_modules/vscode-jsonrpc": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz",
"integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
@@ -0,0 +1,20 @@
{
"name": "backrooms-canvas",
"version": "1.0.0",
"main": "extension.mjs",
"author": "John Haugabook",
"license": "MIT",
"type": "module",
"dependencies": {
"@github/copilot-sdk": "latest"
},
"description": "Wander an endless first-person backrooms in a Copilot canvas while agents work; their status ghost-writes on the walls.",
"keywords": [
"backrooms",
"copilot-canvas",
"interactive-canvas",
"first-person",
"procedural-generation",
"session-breaks"
]
}
@@ -16,8 +16,8 @@ and connected-server management into the Copilot side panel.
- **My MCPs** - see which servers are connected and ready to add to Copilot.
- **Namespace playground** - open any connected server in the Connector
Namespace playground with **Sandbox**.
- **Persistent setup** - retain the selected namespace and restore Azure sign-in
securely across app restarts.
- **Persistent namespace selection** - retain the selected namespace while Azure
tokens remain in memory and sign-in is requested again after a restart.
## Install
@@ -36,10 +36,6 @@ and select **Install in GitHub Copilot app**.
- Permission to view the namespace and create its connections and hosted MCP
server configurations.
- A browser for Microsoft Entra sign-in and connector consent.
- An operating-system secure credential store. Windows and macOS provide one by
default. Linux and WSL require a Secret Service-compatible keyring, such as
GNOME Keyring, with `libsecret` available. Unencrypted token storage is
intentionally disabled.
Connector Namespace is currently an Azure preview service and availability can
vary by region.
@@ -64,13 +60,13 @@ playground. Use **Change namespace** to switch subscriptions or namespaces.
Azure sign-in and connector sign-in are separate:
- **Azure sign-in** lets the canvas discover and manage Connector Namespace
resources. Access and refresh tokens are stored in the operating system's
encrypted credential store. To select that encrypted cache entry after an app
restart, the extension separately saves a non-secret authentication record
containing the authority, client ID, account ID, tenant ID, and username under
`~/.copilot/extensions/connector-namespaces/artifacts/azure-auth-record.json`,
with user-only permissions where supported. Raw tokens are never written to
extension files.
resources. Access and refresh tokens remain in the extension process and are
never written to extension files. Reloading the extension or restarting the
app requires Azure sign-in again. The selected namespace coordinates are
retained in
`~/.copilot/extensions/connector-namespaces/artifacts/gateway-config.json` so
the canvas can explain that the namespace is still linked and return directly
to its connectors after sign-in.
- **Connector sign-in** grants an individual MCP server access to its backing
service. The resulting connection is managed by Connector Namespace.
@@ -2,16 +2,9 @@ import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import {
deserializeAuthenticationRecord,
InteractiveBrowserCredential,
serializeAuthenticationRecord,
useIdentityPlugin,
} from "@azure/identity";
import { cachePersistencePlugin } from "@azure/identity-cache-persistence";
import { InteractiveBrowserCredential } from "@azure/identity";
export const ARM_SCOPE = "https://management.azure.com/.default";
export const TOKEN_CACHE_NAME = "github-copilot-connector-namespaces";
const TOKEN_EXPIRY_SKEW_MS = 5 * 60 * 1000;
const SIGN_IN_SESSION_TTL_MS = 10 * 60 * 1000;
@@ -24,50 +17,20 @@ const AUTH_STORAGE_DIR = join(
const AUTH_RECORD_FILE = join(AUTH_STORAGE_DIR, "azure-auth-record.json");
const LEGACY_AUTH_CACHE = join(AUTH_STORAGE_DIR, "auth-cache.json");
let legacyAuthCacheRemoved = false;
let legacyAuthArtifactsRemoved = false;
useIdentityPlugin(cachePersistencePlugin);
export async function loadAuthenticationRecord({
readFile = fs.readFile,
deserialize = deserializeAuthenticationRecord,
authRecordFile = AUTH_RECORD_FILE,
} = {}) {
let serialized;
try {
serialized = await readFile(authRecordFile, "utf-8");
} catch (error) {
if (error?.code === "ENOENT") return undefined;
throw error;
}
try {
return deserialize(serialized);
} catch {
return undefined;
}
}
async function saveAuthenticationRecord(record) {
await fs.mkdir(AUTH_STORAGE_DIR, { recursive: true, mode: 0o700 });
await fs.chmod(AUTH_STORAGE_DIR, 0o700);
await fs.writeFile(
AUTH_RECORD_FILE,
serializeAuthenticationRecord(record),
{ encoding: "utf-8", mode: 0o600 },
);
await fs.chmod(AUTH_RECORD_FILE, 0o600);
}
async function removeLegacyAuthCache() {
if (legacyAuthCacheRemoved) return;
try {
await fs.unlink(LEGACY_AUTH_CACHE);
} catch (error) {
if (error?.code !== "ENOENT") {
throw new Error(`Could not remove the legacy connector credential cache at ${LEGACY_AUTH_CACHE}: ${error.message}`);
async function removeLegacyAuthArtifacts() {
if (legacyAuthArtifactsRemoved) return;
for (const path of [AUTH_RECORD_FILE, LEGACY_AUTH_CACHE]) {
try {
await fs.unlink(path);
} catch (error) {
if (error?.code !== "ENOENT") {
throw new Error(`Could not remove the legacy connector authentication file at ${path}: ${error.message}`);
}
}
}
legacyAuthCacheRemoved = true;
legacyAuthArtifactsRemoved = true;
}
export class ConnectorAuthenticationRequiredError extends Error {
@@ -103,20 +66,14 @@ export class InteractiveAuthBroker {
createCredential = credentialFactory,
createSessionId = randomUUID,
cleanupLegacyCredentials = async () => {},
loadAuthRecord = async () => undefined,
saveAuthRecord = async () => {},
now = Date.now,
scope = ARM_SCOPE,
tokenCacheName = TOKEN_CACHE_NAME,
} = {}) {
this.createCredential = createCredential;
this.createSessionId = createSessionId;
this.cleanupLegacyCredentials = cleanupLegacyCredentials;
this.loadAuthRecord = loadAuthRecord;
this.saveAuthRecord = saveAuthRecord;
this.now = now;
this.scope = scope;
this.tokenCacheName = tokenCacheName;
this.credential = null;
this.accessToken = null;
this.cleanupInFlight = null;
@@ -124,15 +81,10 @@ export class InteractiveAuthBroker {
this.sessions = new Map();
}
createInteractiveCredential(authenticationRecord) {
createInteractiveCredential() {
return this.createCredential({
redirectUri: "http://localhost",
disableAutomaticAuthentication: true,
tokenCachePersistenceOptions: {
enabled: true,
name: this.tokenCacheName,
},
...(authenticationRecord ? { authenticationRecord } : {}),
});
}
@@ -188,7 +140,6 @@ export class InteractiveAuthBroker {
if (!authenticationRecord) {
throw new Error("Azure identity did not return an authentication record.");
}
await this.saveAuthRecord(authenticationRecord);
const accessToken = await credential.getToken(this.scope, { abortSignal: abortController.signal });
if (!accessToken?.token || !Number.isFinite(accessToken.expiresOnTimestamp)) {
throw new Error("Azure identity returned an incomplete ARM access token.");
@@ -235,13 +186,8 @@ export class InteractiveAuthBroker {
let credential = this.credential;
if (!credential) {
try {
const authenticationRecord = await this.loadAuthRecord();
if (hasUsableToken(this.accessToken, this.now())) return this.accessToken.token;
credential = this.credential;
if (!credential) {
credential = this.createInteractiveCredential(authenticationRecord);
this.credential = credential;
}
credential = this.createInteractiveCredential();
this.credential = credential;
} catch (error) {
if (isAuthenticationRequiredError(error)) {
throw new ConnectorAuthenticationRequiredError(
@@ -281,9 +227,7 @@ export class InteractiveAuthBroker {
}
export const interactiveAuth = new InteractiveAuthBroker({
cleanupLegacyCredentials: removeLegacyAuthCache,
loadAuthRecord: loadAuthenticationRecord,
saveAuthRecord: saveAuthenticationRecord,
cleanupLegacyCredentials: removeLegacyAuthArtifacts,
});
export const startSignIn = () => interactiveAuth.startSignIn();
@@ -4,8 +4,6 @@ import assert from "node:assert/strict";
import {
ConnectorAuthenticationRequiredError,
InteractiveAuthBroker,
loadAuthenticationRecord,
TOKEN_CACHE_NAME,
} from "./auth.mjs";
function accessToken(token = "token", expiresOnTimestamp = 2_000_000_000_000) {
@@ -35,7 +33,6 @@ test("ARM token requests require an explicit browser sign-in", async () => {
},
};
},
loadAuthRecord: async () => undefined,
});
await assert.rejects(
@@ -43,32 +40,13 @@ test("ARM token requests require an explicit browser sign-in", async () => {
(error) => error instanceof ConnectorAuthenticationRequiredError
&& error.code === "authentication_required",
);
assert.deepEqual(credentialOptions.tokenCachePersistenceOptions, {
enabled: true,
name: TOKEN_CACHE_NAME,
assert.deepEqual(credentialOptions, {
redirectUri: "http://localhost",
disableAutomaticAuthentication: true,
});
});
test("a malformed authentication record falls back to browser sign-in", async () => {
assert.equal(
await loadAuthenticationRecord({
readFile: async () => "{malformed-json",
}),
undefined,
);
});
test("authentication record read failures remain operational errors", async () => {
const readError = Object.assign(new Error("authentication record is unreadable"), { code: "EACCES" });
await assert.rejects(
loadAuthenticationRecord({
readFile: async () => { throw readError; },
}),
(error) => error === readError,
);
});
test("interactive sign-in reports pending then done and caches the ARM token", async () => {
test("interactive sign-in reports pending then done and keeps the ARM token in memory", async () => {
let credentialOptions;
let authenticateOptions;
const credential = {
@@ -89,7 +67,6 @@ test("interactive sign-in reports pending then done and caches the ARM token", a
return credential;
},
createSessionId: () => "signin-session",
saveAuthRecord: async (record) => assert.deepEqual(record, authenticationRecord()),
now: () => 1_000,
});
@@ -110,10 +87,6 @@ test("interactive sign-in reports pending then done and caches the ARM token", a
assert.deepEqual(credentialOptions, {
redirectUri: "http://localhost",
disableAutomaticAuthentication: true,
tokenCachePersistenceOptions: {
enabled: true,
name: TOKEN_CACHE_NAME,
},
});
assert.equal(authenticateOptions.abortSignal.aborted, false);
assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "done" });
@@ -124,22 +97,24 @@ test("interactive sign-in reports pending then done and caches the ARM token", a
assert.equal(await broker.getToken(), "token");
});
test("a new broker restores the persisted credential without reopening the browser", async () => {
const cache = { record: null, token: null };
test("a new broker requires browser sign-in after the extension reloads", async () => {
let authenticateCalls = 0;
let createCredentialCalls = 0;
const createCredential = (options) => {
assert.deepEqual(options.tokenCachePersistenceOptions, {
enabled: true,
name: TOKEN_CACHE_NAME,
createCredentialCalls++;
assert.deepEqual(options, {
redirectUri: "http://localhost",
disableAutomaticAuthentication: true,
});
let signedIn = false;
return {
async authenticate() {
authenticateCalls++;
cache.token = accessToken("persisted-token");
signedIn = true;
return authenticationRecord();
},
async getToken() {
if (cache.token && options.authenticationRecord) return cache.token;
if (signedIn) return accessToken("memory-token");
const error = new Error("No cached account found.");
error.name = "AuthenticationRequiredError";
throw error;
@@ -149,41 +124,36 @@ test("a new broker restores the persisted credential without reopening the brows
const signedInBroker = new InteractiveAuthBroker({
createCredential,
createSessionId: () => "persist-session",
saveAuthRecord: async (record) => { cache.record = record; },
now: () => 1_000,
});
const started = signedInBroker.startSignIn();
await signedInBroker.sessions.get(started.sessionId).promise;
assert.equal(authenticateCalls, 1);
assert.equal(await signedInBroker.getToken(), "memory-token");
const restartedBroker = new InteractiveAuthBroker({
createCredential,
loadAuthRecord: async () => cache.record,
now: () => 1_000,
});
assert.equal(await restartedBroker.getToken(), "persisted-token");
await assert.rejects(restartedBroker.getToken(), ConnectorAuthenticationRequiredError);
assert.equal(authenticateCalls, 1);
assert.equal(createCredentialCalls, 2);
});
test("concurrent first-time token requests share credential initialization and acquisition", async () => {
let releaseAuthenticationRecord;
const authenticationRecordReady = new Promise((resolve) => {
releaseAuthenticationRecord = resolve;
test("concurrent first-time token requests share credential acquisition", async () => {
let releaseToken;
const tokenReady = new Promise((resolve) => {
releaseToken = resolve;
});
let loadAuthRecordCalls = 0;
let createCredentialCalls = 0;
let tokenCalls = 0;
const broker = new InteractiveAuthBroker({
async loadAuthRecord() {
loadAuthRecordCalls++;
await authenticationRecordReady;
return authenticationRecord();
},
createCredential: () => {
createCredentialCalls++;
return {
async getToken() {
tokenCalls++;
await tokenReady;
return accessToken("shared-token");
},
};
@@ -193,15 +163,12 @@ test("concurrent first-time token requests share credential initialization and a
const firstRequest = broker.getToken();
const secondRequest = broker.getToken();
await new Promise((resolve) => setImmediate(resolve));
const loadsBeforeRelease = loadAuthRecordCalls;
releaseAuthenticationRecord();
releaseToken();
assert.deepEqual(
await Promise.all([firstRequest, secondRequest]),
["shared-token", "shared-token"],
);
assert.equal(loadsBeforeRelease, 1);
assert.equal(loadAuthRecordCalls, 1);
assert.equal(createCredentialCalls, 1);
assert.equal(tokenCalls, 1);
});
@@ -224,7 +191,6 @@ test("cancelling sign-in aborts the credential request", async () => {
const broker = new InteractiveAuthBroker({
createCredential: () => credential,
createSessionId: () => "cancel-session",
loadAuthRecord: async () => undefined,
now: () => 1_000,
});
@@ -289,7 +255,6 @@ test("legacy credential cleanup retries after a transient failure", async () =>
return accessToken();
},
}),
loadAuthRecord: async () => authenticationRecord(),
});
await assert.rejects(broker.getToken(), /legacy cache is locked/);
@@ -312,7 +277,6 @@ test("token acquisition preserves operational errors and retries the credential"
},
};
},
loadAuthRecord: async () => authenticationRecord(),
});
await assert.rejects(broker.getToken(), (error) => error === outage);
@@ -328,7 +292,6 @@ test("incomplete tokens remain operational errors instead of prompting sign-in",
return { token: "incomplete" };
},
}),
loadAuthRecord: async () => authenticationRecord(),
});
await assert.rejects(
@@ -15,7 +15,6 @@
"license": "MIT",
"dependencies": {
"@azure/identity": "4.13.1",
"@azure/identity-cache-persistence": "1.3.0",
"@github/copilot-sdk": "1.0.6"
}
}
@@ -304,14 +304,23 @@ button:focus-visible, a:focus-visible, [tabindex]:focus-visible { outline: 2px s
// Setup / Namespace Picker
// ---------------------------------------------------------------------------
export function renderSetupHtml(subscriptions = [], notice = "", capabilityToken = "") {
export function renderSetupHtml(subscriptions = [], notice = "", capabilityToken = "", { linkedNamespace = "" } = {}) {
const hasLinkedNamespace = typeof linkedNamespace === "string" && linkedNamespace.length > 0;
const pageTitle = hasLinkedNamespace ? "Sign in to MCP Connectors" : "Select Connector Namespace";
const heading = hasLinkedNamespace ? "Sign in to see your connectors" : "Select a Connector Namespace";
const subheading = hasLinkedNamespace
? `Connector namespace <code>${esc(linkedNamespace)}</code> is already linked.`
: "Choose which connector namespace to browse. This choice is saved for future sessions.";
const defaultSigninMessage = hasLinkedNamespace
? "Sign in to Azure to view and manage its connectors."
: "Sign in to Azure to load your subscriptions and connector namespaces.";
const subOptions = subscriptions.map((s) =>
`<option value="${s.id}">${esc(s.name)} (${s.id.slice(0, 8)}\u2026)</option>`
).join("");
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Select Connector Namespace</title>${baseStyles()}
<title>${pageTitle}</title>${baseStyles()}
<style>
.skeleton { animation: pulse 1.2s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity: .4; } 50% { opacity: .8; } }
@@ -357,18 +366,18 @@ export function renderSetupHtml(subscriptions = [], notice = "", capabilityToken
}
</style></head><body>
<div class="header brand-head">
<h1>${brandMark(30, "setup")}<span>Select a Connector Namespace</span></h1>
<div class="sub">Choose which connector namespace to browse. This choice is saved for future sessions.</div>
<h1>${brandMark(30, "setup")}<span>${heading}</span></h1>
<div class="sub">${subheading}</div>
</div>
${notice ? `<div class="setup-notice">${esc(notice)}</div>` : ""}
<div id="signin-panel" class="signin-panel" data-state="idle" aria-busy="false" hidden>
<p id="signin-message" class="signin-message" aria-live="polite">Sign in to Azure to load your subscriptions and connector namespaces.</p>
<p id="signin-message" class="signin-message" aria-live="polite">${defaultSigninMessage}</p>
<div class="signin-actions">
<button id="signin-btn" class="item-add primary signin-primary" type="button">Sign in to Azure</button>
<button id="cancel-signin-btn" class="signin-cancel" type="button" hidden>Cancel</button>
</div>
</div>
<div id="setup-content">
<div id="setup-content"${hasLinkedNamespace ? " hidden" : ""}>
<div class="create-row">
<button id="create-ns-btn" class="create-link" type="button"${subscriptions.length ? "" : " disabled"}><span class="plus">+</span><span>New connector namespace</span></button>
</div>
@@ -386,6 +395,8 @@ ${notice ? `<div class="setup-notice">${esc(notice)}</div>` : ""}
</div>
<script>
const connectorNamespaceToken = ${JSON.stringify(capabilityToken)};
const hasLinkedNamespace = ${hasLinkedNamespace};
const defaultSigninMessage = ${JSON.stringify(defaultSigninMessage)};
const rawFetch = window.fetch.bind(window);
window.fetch = (input, init = {}) => {
const url = typeof input === "string" ? input : input && input.url;
@@ -423,7 +434,7 @@ function setSigninRequired(message, state = "idle") {
signinPanel.dataset.state = state;
signinPanel.setAttribute("aria-busy", "false");
setupContent.hidden = true;
signinMessage.textContent = message || "Sign in to Azure to load your subscriptions and connector namespaces.";
signinMessage.textContent = message || defaultSigninMessage;
signinButton.disabled = false;
signinButton.hidden = false;
cancelSigninButton.hidden = true;
@@ -530,8 +541,14 @@ async function pollSignin() {
stopSigninPolling();
signinPanel.dataset.state = "pending";
signinPanel.setAttribute("aria-busy", "true");
signinMessage.textContent = "Signed in. Loading subscriptions\u2026";
signinMessage.textContent = hasLinkedNamespace
? "Signed in. Loading your connectors\u2026"
: "Signed in. Loading subscriptions\u2026";
signinPanel.hidden = false;
if (hasLinkedNamespace) {
window.location.replace("/");
return;
}
await loadSubscriptions(true);
return;
}
@@ -691,7 +708,8 @@ async function selectGateway(subscriptionId, resourceGroup, gatewayName) {
else { gatewayList.innerHTML = '<div class="empty" style="color:var(--danger);">Failed to save.</div>'; }
}
if (${subscriptions.length > 0}) setSetupReady();
if (hasLinkedNamespace) setSigninRequired();
else if (${subscriptions.length > 0}) setSetupReady();
else loadSubscriptions(false);
</script></body></html>`;
}
@@ -67,6 +67,27 @@ test("setup prompts, polls, cancels, and reloads subscriptions after browser sig
assert.match(html, /\/api\/subscriptions/);
});
test("a linked namespace gets a focused sign-in state and returns to its catalog", () => {
const html = renderSetupHtml([], "", "token", { linkedNamespace: "saved-gateway" });
assert.match(html, /Sign in to see your connectors/);
assert.match(html, /Connector namespace <code>saved-gateway<\/code> is already linked\./);
assert.match(html, /Sign in to Azure to view and manage its connectors\./);
assert.match(html, /const hasLinkedNamespace = true/);
assert.match(html, /window\.location\.replace\("\/"\)/);
assert.match(html, /<div id="setup-content" hidden>/);
});
test("linked namespace sign-in escapes saved names and produces valid client JavaScript", () => {
const html = renderSetupHtml([], "", "token", {
linkedNamespace: `gateway</code><script>alert("xss")</script>`,
});
assert.doesNotMatch(html, /<script>alert\("xss"\)<\/script>/);
assert.match(html, /gateway&lt;\/code&gt;&lt;script&gt;alert\(&quot;xss&quot;\)&lt;\/script&gt;/);
const script = html.match(/<script>([\s\S]*)<\/script>/)?.[1];
assert.ok(script, "linked setup page must include its client script");
assert.doesNotThrow(() => new Function(script));
});
test("setup sign-in uses a compact borderless blank state", () => {
const html = renderSetupHtml([], "", "token");
assert.match(html, /id="signin-btn" class="item-add primary signin-primary"/);
@@ -49,17 +49,16 @@ test("namespace creation polls an empty 202 result until explicit success", asyn
);
});
test("Azure authentication uses an interactive browser and persistent encrypted cache", async () => {
test("Azure authentication uses an interactive browser with process-memory tokens", async () => {
const [authSource, armSource] = await Promise.all([
readFile(new URL("auth.mjs", here), "utf8"),
readFile(new URL("armClient.mjs", here), "utf8"),
]);
assert.match(authSource, /new InteractiveBrowserCredential\(options\)/);
assert.match(authSource, /useIdentityPlugin\(cachePersistencePlugin\)/);
assert.match(authSource, /disableAutomaticAuthentication: true/);
assert.match(authSource, /tokenCachePersistenceOptions/);
assert.match(authSource, /credential\.authenticate\(\s*this\.scope,\s*\{ abortSignal/);
assert.match(authSource, /serializeAuthenticationRecord/);
assert.doesNotMatch(authSource, /identity-cache-persistence|cachePersistencePlugin|tokenCachePersistenceOptions/);
assert.doesNotMatch(authSource, /serializeAuthenticationRecord|saveAuthenticationRecord/);
assert.doesNotMatch(armSource, /get-access-token|az login|resolvePosixAzureCli/);
});
@@ -561,7 +561,9 @@ async function handleRequest(req, res, instanceId, serverEntry) {
} catch (err) {
res.setHeader("Content-Type", "text/html; charset=utf-8");
if (isAuthenticationRequiredError(err)) {
res.end(renderSetupHtml([], "", serverEntry.token));
res.end(renderSetupHtml([], "", serverEntry.token, {
linkedNamespace: config.gatewayName,
}));
} else {
res.end(renderSetupHtml(
[],
@@ -164,7 +164,9 @@ test("saved namespace prompts for sign-in after the extension credential is rese
const response = await fetch(entry.url);
const html = await response.text();
assert.match(html, /id="signin-btn"/);
assert.match(html, /Sign in to Azure/);
assert.match(html, /Sign in to see your connectors/);
assert.match(html, /Connector namespace <code>yeah-github-cli<\/code> is already linked\./);
assert.match(html, /Sign in to Azure to view and manage its connectors\./);
assert.doesNotMatch(html, /Couldn't load the saved namespace|couldn't open namespace/i);
});
@@ -20,18 +20,13 @@ MCP server issue locally.
## Prerequisites
1. **A browser for Microsoft Entra sign-in.** The harness opens the same
interactive Azure sign-in as the extension when its encrypted Azure Identity
session is not already available.
interactive Azure sign-in as the extension at the start of each process.
2. **A gateway already picked once.** The harness reads gateway coordinates from
`~/.copilot/extensions/connector-namespaces/artifacts/gateway-config.json`
(`{ subscriptionId, resourceGroup, gatewayName }`). Pick a gateway once in
the connector-namespaces canvas, or write that file by hand.
3. **An operating-system secure credential store.** Windows and macOS provide one
by default. Linux and WSL require a Secret Service-compatible keyring, such as
GNOME Keyring, with `libsecret` available. Unencrypted token storage is
intentionally disabled.
4. **Node 20+** (developed on Node 24).
5. **Extension dependencies installed.** From the repository root, run:
3. **Node 20+** (developed on Node 24).
4. **Extension dependencies installed.** From the repository root, run:
```bash
npm install --prefix extensions/connector-namespaces
@@ -65,9 +60,9 @@ node extensions/connector-namespaces/test/smoke.mjs --limit=5 --open-consent
## One-time connector consent
OAuth-backed servers (most of them) need a human to consent **once** in a
browser. Azure ARM sign-in is restored from the operating system's encrypted
credential store described in the prerequisites; the one-time behavior below
applies to the connector's own consent. The model:
browser. Azure ARM sign-in is process-memory only and is repeated for each
harness process; the one-time behavior below applies to the connector's own
consent. The model:
1. **First run** hits a server that needs consent → the harness prints a consent
URL and marks it `NEEDS_CONSENT`. It saves a pending record to