Skip to content

Instantly share code, notes, and snippets.

@AndreaGriffiths11
Last active June 16, 2026 12:38
Show Gist options
  • Select an option

  • Save AndreaGriffiths11/b9ebe41deabdcdd0930954d5542cd918 to your computer and use it in GitHub Desktop.

Select an option

Save AndreaGriffiths11/b9ebe41deabdcdd0930954d5542cd918 to your computer and use it in GitHub Desktop.
chromium canvas extension for GitHub Copilot — headful Chromium via Playwright (source only)

Chromium Control Canvas

A GitHub Copilot canvas that drives a real headful Chromium window via Playwright. The host app's built-in browser canvas is WebKit (WKWebView); this gives you actual Chromium, controllable both from the panel UI and by the agent.

The canvas panel is a control strip (URL bar, back/forward/reload, screenshot). A separate Chromium window does the real rendering, because you can't embed Chromium inside a WebKit iframe.

Files

  • extension.mjs — the extension: canvas declaration, Playwright launch, a loopback HTTP server for the panel, and the agent actions.
  • index.html — the control strip UI the panel renders.
  • package.json — declares the playwright dependency and "type": "module".
  • copilot-extension.json — name/version metadata.

Prerequisites

  • Node.js 20.19 or newer (the Copilot SDK requires node ^20.19.0 || >=22.12.0). The extension runs as a Node child process.
  • The app's canvas / UI-extensions experiment enabled. Without it, the extension loads but the canvas never appears in the panel. Enable it in the app's Settings → Experiments. (This may not be available to all accounts.)

Install

Drop this folder at ~/.copilot/extensions/chromium-control-canvas/ (user scope) or in a repo's .github/extensions/chromium-control-canvas/ (project scope), then install dependencies and the Chromium binary from inside the folder you copied:

# User scope
cd ~/.copilot/extensions/chromium-control-canvas

# Or project scope, from the repository root
cd .github/extensions/chromium-control-canvas

npm install                     # playwright is declared in package.json
npx playwright install chromium # downloads the browser, a few hundred MB

Reload extensions in the app, then open the chromium-control-canvas canvas.

Note: copying the extension files only places the source. It does not run the commands above or enable the experiment, so those steps are still required on first setup.

Attach to your own Chrome

By default the canvas launches the bundled Chromium with a persistent profile. To drive a Chrome you already have running instead, start it with a debug port and pass cdpUrl when opening the canvas:

google-chrome --remote-debugging-port=9222   # then open the canvas with cdpUrl: http://localhost:9222

In this mode the extension connects over CDP and never launches or kills your browser; closing the canvas just disconnects.

Agent actions

  • navigate { url } — go to a URL or search query (blocklist-guarded).
  • back / forward / reload — history navigation.
  • current_url — current URL and page title.
  • snapshot — structured list of visible interactive elements, each with a stable ref.
  • click { ref | selector } — click an element by snapshot ref or CSS selector.
  • type { ref | selector, text, submit? } — fill an input; optionally press Enter.
  • screenshot { fullPage? } — save a PNG to artifacts/ and return its path and size.

Notes

  • A persistent profile is stored under $COPILOT_HOME/extensions/chromium-control-canvas/profile (default ~/.copilot/extensions/chromium-control-canvas/profile) so logins survive restarts. Do not commit or share this folder — it contains real session cookies.
  • Raw evaluate (arbitrary in-page JS) is intentionally omitted.
  • navigate is checked against a blocklist, and a request interceptor also blocks navigations to blocked hosts that happen via in-page redirects. The shipped BLOCKLIST entries are illustrative examples, not real coverage — edit the list in extension.mjs to fit your environment.
  • The loopback control server requires a per-launch token (templated into the panel), so other pages in your browser can't drive it.
  • Typed text (e.g. passwords) is redacted in audit.log, and password field values are excluded from snapshots.
  • Generated at runtime and not part of the source: node_modules/ in the copied extension folder, plus profile/, artifacts/, and audit.log under $COPILOT_HOME/extensions/chromium-control-canvas/.
{
"name": "chromium-control-canvas",
"version": 1
}
// Extension: chromium-control-canvas
// Launches a real headful Chromium window via Playwright and uses the canvas
// panel as a control strip (URL bar, back, forward, reload, screenshot,
// snapshot/click/type).
//
// Why this shape: the host app renders canvases in a WebKit (WKWebView) webview,
// not Chromium. To get a true Chromium engine we run the browser as a separate
// headful window owned by this extension process and drive it with Playwright.
// The canvas iframe is only the control surface; it POSTs commands to a
// per-instance loopback HTTP server, which calls Playwright on the page. The
// same handlers are exposed as agent-callable canvas actions.
//
// Patterns borrowed from AndreaGriffiths11/claw-relay (grep "[claw-relay]" below
// for the exact spots):
// - persistent profile so logins survive restarts
// - optional connect-to-existing-Chrome over CDP instead of relaunching
// - a real action set (snapshot/click/type/screenshot), not just navigation
// - ref-based element resolution from a snapshot, or raw CSS selector
// - a site blocklist guard and a JSONL audit log
// Intentionally omitted: raw `evaluate` (arbitrary JS execution).
import { randomUUID } from "node:crypto";
import { appendFile, mkdir, readFile, readlink, rm, stat, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
CanvasError,
createCanvas,
joinSession,
} from "@github/copilot-sdk/extension";
import { chromium } from "playwright";
const __dirname = dirname(fileURLToPath(import.meta.url));
const COPILOT_HOME = process.env.COPILOT_HOME || join(homedir(), ".copilot");
const EXT_HOME = join(COPILOT_HOME, "extensions", "chromium-control-canvas");
// [claw-relay] Persistent profile: cookies/logins survive canvas closes,
// reloads, and sessions, so a hand-login in the window sticks.
const PROFILE_DIR = join(EXT_HOME, "profile");
const ARTIFACTS_DIR = join(EXT_HOME, "artifacts");
const AUDIT_LOG = join(EXT_HOME, "audit.log");
// [claw-relay] Site blocklist guard.
// Sites the agent may never drive. Edit this list to taste. Patterns match the
// hostname; a leading "*." matches any subdomain. Navigation to a blocked host
// is refused before Chromium is told to go there.
const BLOCKLIST = [
"*.bank.com",
"*.chase.com",
"*.paypal.com",
"accounts.google.com",
];
// One Chromium context + control-strip server per open canvas instance.
const instances = new Map(); // instanceId -> { context, browser, page, server, url, mode }
// Per-launch secret, templated into index.html and required on every state route
// so cross-origin pages in the user's normal browser can't POST to this server.
const TOKEN = randomUUID();
let log = (..._args) => {};
function hostMatches(host, pattern) {
if (pattern.startsWith("*.")) {
const base = pattern.slice(2);
return host === base || host.endsWith(`.${base}`);
}
return host === pattern;
}
function blockedReason(targetUrl) {
let host;
try {
host = new URL(targetUrl).hostname;
} catch (_) {
return null;
}
const hit = BLOCKLIST.find((p) => hostMatches(host, p));
return hit ? `${host} is blocked by the Chromium Control Canvas blocklist (${hit})` : null;
}
function normalizeUrl(input) {
const raw = String(input ?? "").trim();
if (!raw) return "about:blank";
if (raw === "about:blank") return raw;
const scheme = raw.match(/^([a-z][a-z0-9+.-]*):/i)?.[1]?.toLowerCase();
if (scheme) {
if (scheme === "http" || scheme === "https") return raw;
throw new CanvasError("unsupported_scheme", "Only http and https URLs are supported.");
}
// Local dev servers have no dot; send them to http, not search.
if (/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?([/?#]|$)/i.test(raw)) return `http://${raw}`;
if (!/\s/.test(raw) && /\.[a-z]{2,}/i.test(raw)) return `https://${raw}`;
return `https://www.google.com/search?q=${encodeURIComponent(raw)}`;
}
// [claw-relay] JSONL audit log: one line per action (panel or agent) for a
// reviewable trail of what drove the browser.
async function audit(entry) {
const line = JSON.stringify({ at: new Date().toISOString(), ...entry });
await mkdir(EXT_HOME, { recursive: true }).catch(() => {});
await appendFile(AUDIT_LOG, `${line}\n`).catch(() => {});
}
// Keep secrets out of the audit log: redact free-text fields (e.g. a typed
// password) while preserving the rest of the entry for the trail.
function redactInput(input) {
if (input && typeof input === "object" && "text" in input) {
return { ...input, text: "[redacted]" };
}
return input;
}
async function pageState(page) {
let url = "about:blank";
let title = "";
try {
url = page.url();
} catch (_) {}
try {
title = await page.title();
} catch (_) {}
return { url, title };
}
function getInstance(instanceId) {
const entry = instances.get(instanceId);
if (!entry) {
throw new CanvasError(
"no_instance",
"No open Chromium canvas for this instance. Open the canvas first.",
);
}
return entry;
}
// Return a usable page for the instance, recovering if the user closed the tab
// or window. Reuses an open tab, opens a new one in the live context, or (for
// persistent mode) relaunches the browser in place.
async function livePage(entry) {
if (entry.page && !entry.page.isClosed()) return entry.page;
try {
const open = entry.context?.pages().find((p) => !p.isClosed());
entry.page = open || (await entry.context.newPage());
return entry.page;
} catch (_) {
// Context/browser is gone below.
}
if (entry.mode === "persistent") {
// Memoize the relaunch so two concurrent panel requests after a crash
// don't race launchPersistentContext into the lock error we handle below.
if (!entry.relaunching) {
entry.relaunching = (async () => {
await mkdir(PROFILE_DIR, { recursive: true });
await clearStaleLockIfDead();
const context = await chromium.launchPersistentContext(PROFILE_DIR, PERSISTENT_OPTS);
await installGuards(context);
entry.context = context;
entry.page = context.pages()[0] || (await context.newPage());
return entry.page;
})();
entry.relaunching.then(
() => {
entry.relaunching = null;
},
() => {
entry.relaunching = null;
},
);
}
return entry.relaunching;
}
throw new CanvasError(
"disconnected",
"The Chrome connection was lost. Close this panel and reopen the canvas.",
);
}
async function navigate(page, rawUrl) {
const url = normalizeUrl(rawUrl);
const reason = blockedReason(url);
if (reason) throw new CanvasError("site_blocked", reason);
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
return pageState(page);
}
// [claw-relay] ref-based element resolution. Resolve an element target to a
// Playwright selector. `selector` wins over `ref` when both are given. `ref`
// values come from a prior snapshot and are stamped onto the DOM as
// data-cc-ref attributes.
function resolveTarget(input) {
if (input?.selector) return input.selector;
if (input?.ref) return `[data-cc-ref="${String(input.ref).replace(/"/g, "")}"]`;
throw new CanvasError("bad_target", "Provide a `ref` (from snapshot) or a `selector`.");
}
// [claw-relay] Accessibility-style page snapshot: enumerate visible interactive
// elements and stamp each with a stable ref (e1, e2, ...) the agent can target.
const SNAPSHOT_FN = () => {
const sel = [
"a[href]",
"button",
"input",
"textarea",
"select",
"[role=button]",
"[role=link]",
"[role=textbox]",
"[role=checkbox]",
"[onclick]",
'[contenteditable=""]',
'[contenteditable="true"]',
].join(",");
const out = [];
let i = 0;
// Clear refs from a prior snapshot so a renumbered ref can't match a stale
// element that scrolled out of view.
for (const stamped of document.querySelectorAll("[data-cc-ref]")) {
stamped.removeAttribute("data-cc-ref");
}
for (const el of document.querySelectorAll(sel)) {
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
const visible =
rect.width > 0 &&
rect.height > 0 &&
style.visibility !== "hidden" &&
style.display !== "none";
if (!visible) continue;
i += 1;
if (i > 200) break;
const ref = `e${i}`;
el.setAttribute("data-cc-ref", ref);
const name = (
el.getAttribute("aria-label") ||
el.getAttribute("placeholder") ||
(el.type === "password" ? "" : el.value) ||
el.innerText ||
el.getAttribute("title") ||
""
)
.trim()
.replace(/\s+/g, " ")
.slice(0, 80);
out.push({
ref,
tag: el.tagName.toLowerCase(),
type: el.getAttribute("type") || el.getAttribute("role") || "",
name,
});
}
return out;
};
async function snapshot(page) {
const elements = await page.evaluate(SNAPSHOT_FN);
return { ...(await pageState(page)), elements };
}
async function clickTarget(page, input) {
const selector = resolveTarget(input);
await page.click(selector, { timeout: 15000 });
return pageState(page);
}
async function typeTarget(page, input) {
const text = String(input?.text ?? "");
const selector = resolveTarget(input);
const locator = page.locator(selector).first();
await locator.fill(text, { timeout: 15000 });
if (input?.submit) await locator.press("Enter").catch(() => {});
return pageState(page);
}
async function screenshot(page, opts = {}) {
await mkdir(ARTIFACTS_DIR, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const name = `shot-${stamp}.png`;
const buffer = await page.screenshot({ fullPage: !!opts.fullPage });
await writeFile(join(ARTIFACTS_DIR, name), buffer);
return { name, path: join(ARTIFACTS_DIR, name), size: buffer.length };
}
function sendJson(res, status, body) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(body));
}
function publicErrorMessage(err, fallback) {
return err instanceof CanvasError ? err.message : fallback;
}
async function readBody(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
return Buffer.concat(chunks).toString("utf-8");
}
async function readJson(req) {
try {
const body = await readBody(req);
return body ? JSON.parse(body) : {};
} catch (_) {
throw new CanvasError("bad_json", "Invalid JSON request body.");
}
}
function makeHandler(entry) {
return async function handleRequest(req, res) {
const reqUrl = new URL(req.url, "http://127.0.0.1");
const { pathname } = reqUrl;
if (req.method === "GET" && (pathname === "/" || pathname === "/index.html")) {
if (reqUrl.searchParams.get("token") !== TOKEN) {
sendJson(res, 403, { error: "forbidden" });
return;
}
const html = (await readFile(join(__dirname, "index.html"), "utf-8")).replaceAll("__TOKEN__", TOKEN);
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(html);
return;
}
if (req.method === "GET" && pathname.startsWith("/shot/")) {
if (req.headers["x-canvas-token"] !== TOKEN && reqUrl.searchParams.get("token") !== TOKEN) {
sendJson(res, 403, { error: "forbidden" });
return;
}
const name = decodeURIComponent(pathname.slice("/shot/".length));
if (!/^shot-[\w-]+\.png$/.test(name)) {
sendJson(res, 400, { error: "invalid name" });
return;
}
const shotPath = join(ARTIFACTS_DIR, name);
const exists = await stat(shotPath).then(() => true, () => false);
if (!exists) {
sendJson(res, 404, { error: "not found" });
return;
}
const bytes = await readFile(shotPath);
res.writeHead(200, { "Content-Type": "image/png", "Cache-Control": "no-store" });
res.end(bytes);
return;
}
// Every state route below requires the per-launch token templated into
// index.html. A cross-origin page can't read it, and the custom header
// also forces a CORS preflight this server never answers with allow.
if (req.headers["x-canvas-token"] !== TOKEN) {
sendJson(res, 403, { error: "forbidden" });
return;
}
const page = await livePage(entry);
if (req.method === "GET" && pathname === "/state") {
sendJson(res, 200, { ...(await pageState(page)), mode: entry.mode });
return;
}
if (req.method === "POST" && pathname === "/navigate") {
try {
const body = await readJson(req);
const state = await navigate(page, body?.url);
await audit({ source: "panel", instanceId: entry.instanceId, action: "navigate", input: body?.url, url: state.url, ok: true });
sendJson(res, 200, state);
} catch (err) {
await audit({ source: "panel", instanceId: entry.instanceId, action: "navigate", ok: false, error: publicErrorMessage(err, "Navigation failed.") });
sendJson(res, 200, { ...(await pageState(page)), error: publicErrorMessage(err, "Navigation failed.") });
}
return;
}
const simple = { "/back": "goBack", "/forward": "goForward", "/reload": "reload" };
if (req.method === "POST" && simple[pathname]) {
const actionName = pathname.slice(1);
try {
await page[simple[pathname]]({ waitUntil: "domcontentloaded" });
const state = await pageState(page);
await audit({ source: "panel", instanceId: entry.instanceId, action: actionName, url: state.url, ok: true });
sendJson(res, 200, state);
} catch (err) {
await audit({ source: "panel", instanceId: entry.instanceId, action: actionName, ok: false, error: publicErrorMessage(err, `${actionName} failed.`) });
sendJson(res, 200, { ...(await pageState(page)), error: publicErrorMessage(err, `${actionName} failed.`) });
}
return;
}
if (req.method === "POST" && pathname === "/screenshot") {
try {
const shot = await screenshot(page);
await audit({ source: "panel", instanceId: entry.instanceId, action: "screenshot", url: page.url(), ok: true });
sendJson(res, 200, shot);
} catch (err) {
await audit({ source: "panel", instanceId: entry.instanceId, action: "screenshot", ok: false, error: publicErrorMessage(err, "Screenshot failed.") });
sendJson(res, 200, { error: publicErrorMessage(err, "Screenshot failed.") });
}
return;
}
sendJson(res, 404, { error: "not found" });
};
}
async function startServer(entry) {
const handler = makeHandler(entry);
const server = createServer((req, res) => {
handler(req, res).catch((err) => {
sendJson(res, 500, { error: publicErrorMessage(err, "Request failed.") });
});
});
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;
return { server, url: `http://127.0.0.1:${port}/?token=${encodeURIComponent(TOKEN)}` };
}
const PERSISTENT_OPTS = { headless: false, viewport: null };
// [claw-relay] Enforce the blocklist on real navigations, not just explicit
// navigate() calls, so in-page redirects are caught too. Every request
// round-trips through Node, which is fine at agent/human browsing pace.
async function installGuards(context) {
await context.route("**/*", (route) => {
const req = route.request();
if (req.isNavigationRequest() && blockedReason(req.url())) {
return route.abort("blockedbyclient");
}
return route.continue();
});
}
// Chromium writes a SingletonLock symlink (target: "<host>-<pid>") into the
// profile while a window owns it. A reload or killed process can leave that
// lock behind even though no Chromium is running. If the referenced PID is
// dead, the lock is stale and safe to clear; if it's alive, a real window
// owns the profile and we must not touch it.
async function clearStaleLockIfDead() {
const lockPath = join(PROFILE_DIR, "SingletonLock");
let target;
try {
target = await readlink(lockPath);
} catch (_) {
return false; // no lock present
}
const pid = Number(target.split("-").pop());
if (Number.isFinite(pid) && pid > 0) {
try {
process.kill(pid, 0); // throws if the process is gone
return false; // alive -> a real window owns the profile
} catch (err) {
if (err?.code === "EPERM") return false; // exists, not ours -> leave it
}
}
for (const f of ["SingletonLock", "SingletonSocket", "SingletonCookie"]) {
await rm(join(PROFILE_DIR, f), { force: true }).catch(() => {});
}
return true;
}
async function openInstance(instanceId, input) {
const cdpUrl = input?.cdpUrl;
let context;
let browser = null;
let mode;
if (cdpUrl) {
// [claw-relay] Connect to an already-running Chrome over CDP (started with
// --remote-debugging-port) instead of launching our own window.
browser = await chromium.connectOverCDP(cdpUrl);
context = browser.contexts()[0] || (await browser.newContext());
mode = "cdp";
} else {
// One shared persistent profile can only back one live Chromium window.
// Refuse a second persistent instance with a readable message instead of
// a cryptic Playwright lock dump.
for (const e of instances.values()) {
if (e.mode === "persistent") {
throw new CanvasError(
"profile_busy",
"A Chromium canvas is already open. Close it before opening another — they share one logged-in profile.",
);
}
}
// Persistent profile: same Chromium user-data-dir every time, so logins
// you complete by hand in the window survive across sessions.
await mkdir(PROFILE_DIR, { recursive: true });
try {
context = await chromium.launchPersistentContext(PROFILE_DIR, PERSISTENT_OPTS);
} catch (err) {
if (!/existing browser session/i.test(String(err?.message))) throw err;
const cleared = await clearStaleLockIfDead();
if (!cleared) {
throw new CanvasError(
"profile_busy",
"The Chromium profile is in use by another live window. Close that Chromium window first.",
);
}
context = await chromium.launchPersistentContext(PROFILE_DIR, PERSISTENT_OPTS);
}
mode = "persistent";
}
await installGuards(context);
const page = context.pages()[0] || (await context.newPage());
const entry = { instanceId, context, browser, page, mode };
if (input?.url) {
await navigate(page, input.url);
}
const { server, url } = await startServer(entry);
entry.server = server;
entry.url = url;
instances.set(instanceId, entry);
await audit({ instanceId, action: "open", mode, url: input?.url || "about:blank" });
return entry;
}
async function closeInstance(instanceId) {
const entry = instances.get(instanceId);
if (!entry) return;
instances.delete(instanceId);
await new Promise((resolve) => {
entry.server.closeAllConnections?.();
entry.server.close(() => resolve());
}).catch(() => {});
if (entry.mode === "cdp") {
// Don't kill the user's own Chrome; just disconnect.
await entry.browser?.close().catch(() => {});
} else {
await entry.context.close().catch(() => {});
}
await audit({ instanceId, action: "close", mode: entry.mode });
}
// Wrap a canvas action handler so every agent-driven call is audited.
function action(name, description, run, inputSchema) {
return {
name,
description,
...(inputSchema ? { inputSchema } : {}),
handler: async (ctx) => {
const entry = getInstance(ctx.instanceId);
try {
const page = await livePage(entry);
const result = await run(page, ctx.input, entry);
const state = result?.url ? result : await pageState(page);
await audit({ source: "agent", instanceId: ctx.instanceId, action: name, input: redactInput(ctx.input), url: state.url, ok: true });
return result;
} catch (err) {
await audit({ source: "agent", instanceId: ctx.instanceId, action: name, input: redactInput(ctx.input), ok: false, error: publicErrorMessage(err, "Action failed.") });
throw err;
}
},
};
}
const session = await joinSession({
canvases: [
createCanvas({
id: "chromium-control-canvas",
displayName: "Chromium Control Canvas",
description:
"Control canvas for a real headful Chromium window driven by Playwright.",
inputSchema: {
type: "object",
properties: {
url: { type: "string", description: "Optional URL to open on launch." },
cdpUrl: {
type: "string",
description:
"Optional CDP endpoint (e.g. http://localhost:9222) to attach to an existing Chrome instead of launching one.",
},
},
},
actions: [
action(
"navigate",
"Navigate to a URL or search query (blocklist enforced).",
(page, input) => navigate(page, input?.url),
{
type: "object",
properties: { url: { type: "string", description: "URL (https assumed) or search query." } },
required: ["url"],
},
),
action("back", "Go back in history.", async (page) => {
await page.goBack({ waitUntil: "domcontentloaded" }).catch(() => {});
return pageState(page);
}),
action("forward", "Go forward in history.", async (page) => {
await page.goForward({ waitUntil: "domcontentloaded" }).catch(() => {});
return pageState(page);
}),
action("reload", "Reload the current page.", async (page) => {
await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {});
return pageState(page);
}),
action("current_url", "Get the current URL and page title.", (page) => pageState(page)),
action(
"snapshot",
"List visible interactive elements with stable refs (e1, e2, ...) for click/type.",
(page) => snapshot(page),
),
action(
"click",
"Click an element by `ref` (from snapshot) or CSS `selector`.",
(page, input) => clickTarget(page, input),
{
type: "object",
properties: {
ref: { type: "string", description: "Element ref from a snapshot, e.g. e3." },
selector: { type: "string", description: "CSS selector (takes priority over ref)." },
},
},
),
action(
"type",
"Fill text into an input by `ref` or `selector`; set submit to press Enter.",
(page, input) => typeTarget(page, input),
{
type: "object",
properties: {
text: { type: "string", description: "Text to enter." },
ref: { type: "string", description: "Element ref from a snapshot." },
selector: { type: "string", description: "CSS selector (takes priority over ref)." },
submit: { type: "boolean", description: "Press Enter after filling." },
},
required: ["text"],
},
),
action(
"screenshot",
"Capture a PNG of the page, saved under the extension artifacts dir.",
(page, input) => screenshot(page, { fullPage: input?.fullPage }),
{
type: "object",
properties: { fullPage: { type: "boolean", description: "Capture the full scrollable page." } },
},
),
],
open: async (ctx) => {
let entry = instances.get(ctx.instanceId);
if (!entry) {
entry = await openInstance(ctx.instanceId, ctx.input || {});
log(`Launched Chromium (${entry.mode}) for instance ${ctx.instanceId}`, {
level: "info",
ephemeral: true,
});
}
return { title: "Chromium Control Canvas", url: entry.url };
},
onClose: async (ctx) => {
await closeInstance(ctx.instanceId);
},
}),
],
});
log = (message, opts) => session.log(message, opts);
// Close Chromium contexts on shutdown so reloading the extension doesn't orphan
// the window and leave a stale profile lock behind. The runtime sends SIGTERM
// (then SIGKILL after ~5s), so keep teardown fast.
let shuttingDown = false;
async function shutdown() {
if (shuttingDown) return;
shuttingDown = true;
await Promise.allSettled(
[...instances.keys()].map((id) => closeInstance(id)),
);
process.exit(0);
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Chromium</title>
<style>
:root {
color-scheme: dark light;
--bg: #0b0f14;
--panel: #11161d;
--panel-2: #171d25;
--panel-3: #1d242e;
--border: #2a313c;
--border-strong: #3a4350;
--text: #e6edf3;
--muted: #8b949e;
--faint: #6e7681;
--accent: #2f81f7;
--green: #238636;
--green-hi: #2ea043;
--danger: #f85149;
--radius: 12px;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
font-size: 13px;
background: var(--bg);
color: var(--text);
display: flex;
flex-direction: column;
}
/* Indeterminate progress bar, shown while a request is in flight. */
.progress {
height: 2px;
background: transparent;
overflow: hidden;
flex: 0 0 auto;
}
body.busy .progress::after {
content: "";
display: block;
height: 100%;
width: 40%;
background: var(--accent);
animation: slide 1s ease-in-out infinite;
}
@keyframes slide {
0% { margin-left: -40%; }
100% { margin-left: 100%; }
}
.toolbar {
padding: 8px 8px 6px;
border-bottom: 1px solid rgba(48, 54, 61, .65);
background:
radial-gradient(circle at top left, rgba(47, 129, 247, .12), transparent 38%),
var(--bg);
}
.control-card {
display: flex;
flex-direction: column;
gap: 5px;
padding: 6px;
border: 1px solid var(--border);
border-radius: 13px;
background: rgba(17, 22, 29, .92);
box-shadow: 0 8px 22px rgba(0, 0, 0, .18);
}
.chrome-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.nav {
display: inline-flex;
gap: 2px;
padding: 2px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--panel-2);
}
.icon-btn {
width: 26px; height: 26px;
display: inline-flex; align-items: center; justify-content: center;
font: inherit; font-size: 14px; line-height: 1;
cursor: pointer; border-radius: 8px;
border: 1px solid transparent; background: transparent; color: var(--muted);
transition: background .12s ease, border-color .12s ease;
}
.icon-btn:hover { background: var(--panel-3); border-color: var(--border); }
.icon-btn:active { background: var(--border); }
.address {
flex: 1; min-width: 0;
display: flex; align-items: center; gap: 6px;
padding: 0 10px;
height: 30px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg);
transition: border-color .12s ease, box-shadow .12s ease;
}
.address:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(47, 129, 247, .25);
}
.scheme { font-size: 12px; flex: 0 0 auto; color: var(--muted); }
.scheme.secure { color: var(--green-hi); }
input#url {
flex: 1; min-width: 0;
font: inherit; border: 0; outline: none;
background: transparent; color: var(--text);
}
input#url::placeholder { color: var(--faint); }
.btn {
font: inherit; cursor: pointer; height: 30px; padding: 0 12px;
border-radius: 10px;
border: 1px solid var(--border); background: var(--panel-2); color: var(--text);
transition: background .12s ease, transform .06s ease, border-color .12s ease;
}
.btn:hover { background: var(--border); }
.btn:active { transform: translateY(1px); }
.btn.primary { background: var(--green); border-color: var(--green-hi); font-weight: 600; }
.btn.primary:hover { background: var(--green-hi); }
.btn.subtle {
color: var(--muted);
background: var(--panel-2);
border-color: var(--border);
}
.btn.subtle:hover {
color: var(--text);
background: var(--panel-2);
border-color: var(--border);
}
main {
flex: 1; min-height: 0;
display: flex; flex-direction: column;
gap: 10px; padding: 10px;
overflow-y: auto;
}
.meta {
display: flex; align-items: center; gap: 8px;
min-width: 0;
padding: 0 4px 1px;
}
.badge {
flex: 0 0 auto;
font-size: 11px; font-weight: 600; letter-spacing: .02em;
text-transform: uppercase;
padding: 2px 7px; border-radius: 999px;
border: 1px solid var(--border-strong); color: var(--muted);
background: var(--panel);
}
.badge.persistent { color: var(--muted); border-color: var(--border-strong); }
.badge.cdp { color: var(--accent); border-color: rgba(47,129,247,.4); }
.page-title {
flex: 1; min-width: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
color: var(--muted);
font-size: 11.5px;
}
.status {
font-size: 12px; color: var(--muted); min-height: 16px;
display: flex; align-items: center; gap: 6px;
}
.status.error { color: var(--danger); }
.card {
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--panel); overflow: hidden;
display: flex; flex-direction: column;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .03);
}
.card-head {
display: flex; align-items: center; justify-content: space-between;
gap: 8px; padding: 8px 10px;
border-bottom: 1px solid var(--border);
font-size: 12px; color: var(--muted);
}
.card-head a { color: var(--accent); text-decoration: none; }
.card-head a:hover { text-decoration: underline; }
.card-body img { width: 100%; display: block; }
.empty {
font-size: 12px; color: var(--faint);
padding: 22px 16px; text-align: center;
}
.hint {
margin-top: auto;
font-size: 12px; color: var(--muted); line-height: 1.55;
border-top: 1px solid var(--border); padding-top: 10px;
}
code {
background: var(--panel-2); padding: 1px 5px; border-radius: 5px;
font-size: 11.5px; color: var(--text);
}
.spinner {
width: 12px; height: 12px; flex: 0 0 auto;
border: 2px solid var(--border-strong); border-top-color: var(--accent);
border-radius: 50%; animation: spin .7s linear infinite;
display: none;
}
body.busy .spinner { display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="progress"></div>
<div class="toolbar">
<div class="control-card">
<div class="chrome-row">
<div class="nav" aria-label="Browser controls">
<button class="icon-btn" id="back" aria-label="Back" title="Back">←</button>
<button class="icon-btn" id="forward" aria-label="Forward" title="Forward">→</button>
<button class="icon-btn" id="reload" aria-label="Reload" title="Reload">⟳</button>
</div>
<div class="address">
<span class="scheme" id="scheme" title="Connection">🌐</span>
<input id="url" type="text" placeholder="Search or enter address" autocomplete="off" spellcheck="false" />
</div>
<button class="btn primary" id="go">Go</button>
<button class="btn subtle" id="shot">Capture</button>
</div>
<div class="meta">
<span class="badge" id="badge" title="Connection mode">…</span>
<span class="page-title" id="title">about:blank</span>
</div>
</div>
</div>
<main>
<div class="status" id="status"><span class="spinner"></span><span id="status-text"></span></div>
<div class="card">
<div class="card-head">
<span id="shot-name">Screenshot</span>
<a id="shot-open" href="#" target="_blank" rel="noopener" style="display:none">Open ↗</a>
</div>
<div class="card-body" id="preview">
<div class="empty">
This panel drives a separate <strong>Chromium window</strong>. Navigate above and
a snapshot of the page shows up here. Log in by hand in that window; the profile
persists. The agent can also <code>snapshot</code>, <code>click</code>, and
<code>type</code>.
</div>
</div>
</div>
<div class="hint">
The preview updates after you navigate from this panel. Agent <code>click</code> and
<code>type</code> actions go straight to the page and bypass this panel, so the
preview can lag during agent work — focus the panel to resync.
</div>
</main>
<script>
const urlEl = document.getElementById("url");
const titleEl = document.getElementById("title");
const statusEl = document.getElementById("status");
const statusTextEl = document.getElementById("status-text");
const schemeEl = document.getElementById("scheme");
const badgeEl = document.getElementById("badge");
const previewEl = document.getElementById("preview");
const shotNameEl = document.getElementById("shot-name");
const shotOpenEl = document.getElementById("shot-open");
const TOKEN = "__TOKEN__";
const AUTH = { "x-canvas-token": TOKEN };
let inFlight = 0;
let currentUrl = "about:blank";
function busy(on) {
inFlight = Math.max(0, inFlight + (on ? 1 : -1));
document.body.classList.toggle("busy", inFlight > 0);
}
function setStatus(msg, isError) {
statusTextEl.textContent = msg || "";
statusEl.classList.toggle("error", !!isError);
}
function setScheme(url) {
const secure = /^https:\/\//i.test(url || "");
schemeEl.textContent = secure ? "🔒" : "🌐";
schemeEl.classList.toggle("secure", secure);
}
function setBadge(mode) {
if (!mode) return;
badgeEl.textContent = mode === "cdp" ? "attached" : "persistent";
badgeEl.classList.remove("persistent", "cdp");
badgeEl.classList.add(mode === "cdp" ? "cdp" : "persistent");
badgeEl.title = mode === "cdp"
? "Attached to an existing Chrome over CDP; closing the panel only disconnects."
: "Persistent profile — logins you complete by hand in the window survive restarts.";
}
function shotUrl(name) {
const params = new URLSearchParams({ token: TOKEN, t: String(Date.now()) });
return "/shot/" + encodeURIComponent(name) + "?" + params.toString();
}
function render(state) {
if (!state) return;
if (state.mode) setBadge(state.mode);
if (state.url && document.activeElement !== urlEl) {
urlEl.value = state.url === "about:blank" ? "" : state.url;
}
setScheme(state.url);
if (state.url) currentUrl = state.url;
titleEl.textContent = state.title || state.url || "about:blank";
titleEl.title = state.url || "";
setStatus(state.error ? "Error: " + state.error : "", !!state.error);
}
async function capturePreview(announce) {
try {
const res = await fetch("/screenshot", { method: "POST", headers: { ...AUTH } });
const data = await res.json();
if (data.error) { if (announce) setStatus("Error: " + data.error, true); return; }
const src = shotUrl(data.name);
previewEl.innerHTML = "";
const img = document.createElement("img");
img.src = src;
img.alt = data.name;
previewEl.appendChild(img);
shotNameEl.textContent = data.name;
shotOpenEl.href = src;
shotOpenEl.style.display = "";
if (announce) {
const kb = data.size ? " (" + Math.round(data.size / 1024) + " KB)" : "";
setStatus("Captured " + data.name + kb);
}
} catch (err) {
if (announce) setStatus(String(err && err.message ? err.message : err), true);
}
}
async function call(path, body) {
const opts = body
? { method: "POST", headers: { "Content-Type": "application/json", ...AUTH }, body: JSON.stringify(body) }
: { method: "POST", headers: { ...AUTH } };
busy(true);
setStatus("Loading…");
try {
const res = await fetch(path, opts);
const state = await res.json();
render(state);
// Couple capture to navigation so the preview reflects the current page
// without a manual screenshot click.
if (!state || !state.error) await capturePreview(false);
} catch (err) {
setStatus(String(err && err.message ? err.message : err), true);
} finally {
busy(false);
}
}
document.getElementById("go").addEventListener("click", () => call("/navigate", { url: urlEl.value }));
urlEl.addEventListener("keydown", (e) => { if (e.key === "Enter") call("/navigate", { url: urlEl.value }); });
document.getElementById("back").addEventListener("click", () => call("/back"));
document.getElementById("forward").addEventListener("click", () => call("/forward"));
document.getElementById("reload").addEventListener("click", () => call("/reload"));
document.getElementById("shot").addEventListener("click", async () => {
busy(true);
setStatus("Capturing screenshot…");
try {
await capturePreview(true);
} finally {
busy(false);
}
});
async function refreshState() {
try { render(await (await fetch("/state", { headers: { ...AUTH } })).json()); } catch (_) {}
}
// The panel can't see the Chromium window, so it goes stale if you browse by
// hand there or the agent acts on the page. Resync state and preview whenever
// the panel regains focus (skip the blank-page case).
document.addEventListener("visibilitychange", () => {
if (document.hidden) return;
refreshState();
if (currentUrl && currentUrl !== "about:blank") capturePreview(false);
});
refreshState();
</script>
</body>
</html>
{
"name": "chromium-control-canvas",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "chromium-control-canvas",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@github/copilot-sdk": "latest",
"playwright": "^1.60.0"
}
},
"node_modules/@github/copilot": {
"version": "1.0.61",
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.61.tgz",
"integrity": "sha512-E4f7YXTL2uUZY/ypnfsUruAeSgrHx3AGYEbm5N0DrpzPqoNAZqV6kHEWM4vu+W/nGvydIfPxmOTqaMEhM8r0Uw==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"detect-libc": "^2.1.2"
},
"bin": {
"copilot": "npm-loader.js"
},
"optionalDependencies": {
"@github/copilot-darwin-arm64": "1.0.61",
"@github/copilot-darwin-x64": "1.0.61",
"@github/copilot-linux-arm64": "1.0.61",
"@github/copilot-linux-x64": "1.0.61",
"@github/copilot-linuxmusl-arm64": "1.0.61",
"@github/copilot-linuxmusl-x64": "1.0.61",
"@github/copilot-win32-arm64": "1.0.61",
"@github/copilot-win32-x64": "1.0.61"
}
},
"node_modules/@github/copilot-darwin-arm64": {
"version": "1.0.61",
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.61.tgz",
"integrity": "sha512-10prvjHRXB0SD28NsIpzdNDgLquQYUwaH5Ev9KVdIWdBPAvlQsHmQ4JSCyD/UILc/nrrr02CKUgum+mZRKUKIg==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
],
"bin": {
"copilot-darwin-arm64": "copilot"
}
},
"node_modules/@github/copilot-darwin-x64": {
"version": "1.0.61",
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.61.tgz",
"integrity": "sha512-NXUjageJ3mxDfHtXGYu//XhJ+dhJFYObT4R3jeWgIHhd+4lX7FlC754nwlBP/ZuVhJ3ND22JK9sua9d2F3Cbwg==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
],
"bin": {
"copilot-darwin-x64": "copilot"
}
},
"node_modules/@github/copilot-linux-arm64": {
"version": "1.0.61",
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.61.tgz",
"integrity": "sha512-dwB2+QSMr622JkePeK56M7YWXsTT/DQzKfpDq8Lk2kmGU052RZAarRmt8gcNm4anofN7pMSrqc3YHj1TM84MFw==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
],
"bin": {
"copilot-linux-arm64": "copilot"
}
},
"node_modules/@github/copilot-linux-x64": {
"version": "1.0.61",
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.61.tgz",
"integrity": "sha512-q6n8R8oybvuCmmkP+43w809Wpud/wwRi/fFSZEYJagiNGmYJ00SDkrfJxHbZsAFMpaJC+oTswqzJHjRoZbO74w==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
],
"bin": {
"copilot-linux-x64": "copilot"
}
},
"node_modules/@github/copilot-linuxmusl-arm64": {
"version": "1.0.61",
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.61.tgz",
"integrity": "sha512-yWo7JXnZS11eJpm68E1RWKMR47EwzPKj3V7GX0EMTd8Fw0T2Aurk9wt9p3c9w0v02nTO1DqJhi68KVWJPdVqvA==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
],
"bin": {
"copilot-linuxmusl-arm64": "copilot"
}
},
"node_modules/@github/copilot-linuxmusl-x64": {
"version": "1.0.61",
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.61.tgz",
"integrity": "sha512-nHzx27Ac4B0fpD9CcmvyrGOBEMJ01CPRgVRP0yAl4wpU4cM2I6+9TPyfYThlWDqZqiUKGXC1ZRQ+B8cJREVGmA==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
],
"bin": {
"copilot-linuxmusl-x64": "copilot"
}
},
"node_modules/@github/copilot-sdk": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.1.tgz",
"integrity": "sha512-w6AaS0WqqTE/3iyUrZznvgCLQhsUF7ZmEVCneacuHCfOzlH0r6ww9WUmyA0zgqmXO75V0IYrkIcnFke/qJkkDg==",
"license": "MIT",
"dependencies": {
"@github/copilot": "^1.0.61",
"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.61",
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.61.tgz",
"integrity": "sha512-k6knzI+K5HlZeJDS/yeJAfoYD4xcURWfuqunpTCyk1pDbIFxmrLSqR/TDi7KNlpsf883n5WqpnB06K5kysdHHQ==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
],
"bin": {
"copilot-win32-arm64": "copilot.exe"
}
},
"node_modules/@github/copilot-win32-x64": {
"version": "1.0.61",
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.61.tgz",
"integrity": "sha512-L6NZ6o73VZFHd7OoRaztV3Prh1PbW9HXqYsAx+XywNALQvE1u489WBUC1ggfYBW5MTBCf8mxSkYQdb3Am2omsw==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
],
"bin": {
"copilot-win32-x64": "copilot.exe"
}
},
"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==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"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==",
"license": "MIT",
"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==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
{
"name": "chromium-control-canvas",
"version": "1.0.0",
"description": "GitHub Copilot canvas that drives a real headful Chromium window via Playwright.",
"main": "extension.mjs",
"keywords": [],
"author": "Andrea Griffiths",
"license": "MIT",
"type": "module",
"dependencies": {
"@github/copilot-sdk": "latest",
"playwright": "^1.60.0"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment