Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
107cc35
Keep docs versions current and default to TypeScript AppHosts (#1600)
IEvangelist Sep 2, 2026
e29d505
chore: Update integration data and GitHub stats (9/2/26) (#1612)
aspire-repo-bot[bot] Sep 3, 2026
663b252
chore: Update integration data and GitHub stats (9/3/26) (#1615)
aspire-repo-bot[bot] Sep 4, 2026
eeb6f42
chore: Update integration data and GitHub stats (9/4/26) (#1622)
aspire-repo-bot[bot] Sep 7, 2026
2cace1c
chore: Update integration data and GitHub stats (9/7/26) (#1625)
aspire-repo-bot[bot] Sep 8, 2026
1c9224f
chore: Update integration data and GitHub stats (9/8/26) (#1629)
aspire-repo-bot[bot] Sep 9, 2026
65ace7e
Merge Duplicate C#/.NET Sidebar Sections (#1632)
Webmekanic Sep 9, 2026
0ddc16d
Claude's accessibility fixes. (#1595)
alistairmatthews Sep 10, 2026
61fe0a9
chore: Update integration data and GitHub stats (9/9/26) (#1640)
aspire-repo-bot[bot] Sep 10, 2026
cd1e48d
docs: restore Floci integration documentation (#1590)
IEvangelist Sep 11, 2026
d06e72f
chore: Update integration data and GitHub stats (9/10/26) (#1647)
aspire-repo-bot[bot] Sep 11, 2026
507be4b
Improve search discoverability and agent-readable content (#1633)
IEvangelist Sep 11, 2026
8d3bedb
Bump OpenTelemetry.Exporter.OpenTelemetryProtocol and 4 others (#1602)
dependabot[bot] Sep 11, 2026
b603774
build(deps): bump the github-actions group across 1 directory with 2 …
dependabot[bot] Sep 11, 2026
858b965
Hide the right TOC when the mobile TOC is visible (#1655)
IEvangelist Sep 14, 2026
a30b4da
Docs: capture 13.2 Foundry rename breaking changes and remove legacy …
Copilot Sep 15, 2026
173e01f
Update deployment docs to pipeline-step APIs and remove legacy callba…
Copilot Sep 15, 2026
6dbf31b
Live status header icon + redesigned videos page (#758)
IEvangelist Sep 15, 2026
cd7487a
Make Dev Container guides language-neutral (#1667)
IEvangelist Sep 15, 2026
b4ddb48
[auto-sec] aspire.dev: remediate 12 frontend npm security alerts (#1645)
IEvangelist Sep 15, 2026
dbea669
fix: restore site search after client-side navigation (#1675)
IEvangelist Sep 16, 2026
2ed75d8
Fix YouTube WebSub topic and back off subscription failures (#1674)
IEvangelist Sep 16, 2026
67cf307
Update support page for Aspire 13.5.4 release (#1671)
aspire-repo-bot[bot] Sep 16, 2026
d09fc7c
Add scheduled CodeQL scanning (#1683)
IEvangelist Sep 17, 2026
77f26f4
fix: preserve client navigation lifecycles across pages and deploymen…
IEvangelist Sep 17, 2026
26d3191
Improve YouTube WebSub handling and live-status diagnostics (#1685)
IEvangelist Sep 17, 2026
de85064
Merge main into release/13.6 preserving ancestry and release intent
IEvangelist Sep 17, 2026
0a46b09
fix: address open code scanning alerts (#1688)
IEvangelist Sep 17, 2026
eb4e26d
Fix typo in azure-appconfiguration integration name (#1693)
fadamsen Sep 17, 2026
1885cc4
Harden release sync ancestry checks and conflict recovery (#1687)
IEvangelist Sep 18, 2026
5cf4aef
Merge latest release/13.6 credits into ancestry repair
IEvangelist Sep 18, 2026
3117d42
Merge corrected canonical main into release ancestry repair
IEvangelist Sep 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/extensions/og-preview/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,36 @@ port) that serves the static UI from `ui/` and a JSON API:
The target page is fetched and parsed server-side (no external dependencies),
which sidesteps browser CORS and lets it reach `localhost`.

## Network access and isolation

Select a localhost URL in the address form or through a canvas action to authorize
that exact origin (scheme, hostname, and port). Links and resources discovered in
the page cannot authorize another local origin. Public-to-local redirects are
blocked, even if the local origin was previously selected.

Private-network destinations beyond loopback require `OG_ALLOW_PRIVATE_NETWORK=1`
in addition to explicit selection. This setting does not grant arbitrary private
access to fetched pages. Selecting a different origin invalidates the previous
browse capability; resources on additional local ports must be previewed separately.

Every connection validates all DNS answers and pins the validated addresses to
the request, including redirect hops. IPv4-mapped IPv6 addresses are checked
against the same policy as IPv4. The original hostname remains in use for HTTP
Host and TLS certificate verification.

The host-provided canvas URL contains a private UI capability in its fragment.
The renderer uses it for API calls and events; do not share that URL. Sandboxed
pages receive a separate, revocable resource-only capability and cannot select
origins or invoke session/issue actions. Public errors omit exception details.
The browse frame continues to run scripts without `allow-same-origin`; module
import rewriting is a preview transform, not an HTML sanitizer.

Run the dependency-free regression suite with Node.js 24:

```powershell
node --test .github\extensions\og-preview\tests\*.test.mjs
```

## Agent actions & tools

- **`open_og_preview`** `{ url?, instanceId? }` *(tool)* — open or focus the
Expand Down
108 changes: 75 additions & 33 deletions .github/extensions/og-preview/extension.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import { extname } from "node:path";

import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension";

import { fetchUrl, normalizeUrl } from "./lib/http-fetch.mjs";
import { fetchUrl, normalizeUrl, authorizePreview } from "./lib/http-fetch.mjs";
import { createAccess, requestAccess } from "./lib/request-access.mjs";
import { parseMetadata } from "./lib/parse-og.mjs";
import { checkAgentReadiness } from "./lib/agent-readiness.mjs";

Expand Down Expand Up @@ -74,9 +75,9 @@ async function serveAsset(res, name) {
}

/** Fetch a target URL and parse its OpenGraph metadata. */
async function loadMetadata(rawUrl) {
async function loadMetadata(rawUrl, policy) {
const target = normalizeUrl(rawUrl);
const result = await fetchUrl(target);
const result = await fetchUrl(target, policy);
if (result.status >= 400) {
throw new Error(`Target responded with HTTP ${result.status}.`);
}
Expand All @@ -90,6 +91,14 @@ async function loadMetadata(rawUrl) {
return data;
}

async function selectPreview(entry, url) {
const policy = await authorizePreview(url);
entry.policy = policy;
// Previously rendered content must not inherit a later local selection.
entry.browseToken = createAccess().browseToken;
return policy;
}

function sendJson(res, status, obj) {
res.statusCode = status;
res.setHeader("Content-Type", "application/json; charset=utf-8");
Expand Down Expand Up @@ -353,7 +362,7 @@ function rewriteBrowseDoc(html, finalUrl, appOrigin) {
);

// Inline <script type="module">…</script> (no src) → rewrite import specifiers
out = out.replace(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi, (m, attrs, body) => {
out = out.replace(/<script(?=[\t\n\f\r />])((?:[^"'<>]|"[^"]*"|'[^']*')*)>([\s\S]*?)<\/script(?=[\t\n\f\r />])(?:[^"'<>]|"[^"]*"|'[^']*')*>/gi, (m, attrs, body) => {
if (/\ssrc\s*=/i.test(attrs)) return m; // external handled above
if (!/type\s*=\s*["']?module/i.test(attrs)) return m; // classic scripts unaffected
return `<script${attrs}>${rewriteJs(body, finalUrl, appOrigin)}</script>`;
Expand Down Expand Up @@ -439,7 +448,8 @@ function broadcast(entry, payload) {

async function handleRequest(entry, req, res) {
const reqUrl = new URL(req.url, "http://127.0.0.1");
const path = reqUrl.pathname;
let path = reqUrl.pathname;
res.setHeader("Referrer-Policy", "no-referrer");

if (path === "/" || path === "/index.html") {
return serveAsset(res, "index.html");
Expand All @@ -448,6 +458,17 @@ async function handleRequest(entry, req, res) {
return serveAsset(res, path.slice(1));
}

const access = requestAccess(entry, req, reqUrl);
if (!access) return sendJson(res, 403, { error: "Preview access denied." });
path = access.path;
if (path !== "/api/open-session" && path !== "/api/create-issue" && req.method !== "GET") {
return sendJson(res, 405, { error: "Use GET." });
}
// A request keeps its authorization snapshot even when another selection
// replaces the active origin while a fetch/redirect is in flight.
let policy = entry.policy;
const proxyOrigin = new URL(entry.url).origin + `/browse/${entry.browseToken}`;

if (path === "/events") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
Expand All @@ -469,15 +490,19 @@ async function handleRequest(entry, req, res) {
// user out of the Browse tab on every in-page navigation.
const silent = reqUrl.searchParams.get("silent") === "1";
try {
const data = await loadMetadata(u);
if (reqUrl.searchParams.get("select") === "1") {
policy = await selectPreview(entry, u);
}
const data = await loadMetadata(u, policy);
entry.currentUrl = data.requestedUrl;
sendJson(res, 200, data);
// Refresh the host panel title to the resolved URL (fire-and-forget;
// guarded against loops by syncTitle).
if (!silent) syncTitle(entry, data.requestedUrl).catch(() => {});
return;
} catch (err) {
return sendJson(res, 200, { error: err.message });
} catch {
log("OG Viewer: metadata request failed.", "warning");
return sendJson(res, 200, { error: "Couldn't load metadata. Check the URL and selected-origin access." });
}
}

Expand All @@ -486,10 +511,11 @@ async function handleRequest(entry, req, res) {
if (!u) return sendJson(res, 400, { error: "Missing 'u' query parameter." });
try {
const target = normalizeUrl(u);
const report = await checkAgentReadiness(target);
const report = await checkAgentReadiness(target, policy);
return sendJson(res, 200, report);
} catch (err) {
return sendJson(res, 200, { error: err.message });
} catch {
log("OG Viewer: agent-readiness request failed.", "warning");
return sendJson(res, 200, { error: "Couldn't check agent readiness." });
}
}

Expand All @@ -500,7 +526,7 @@ async function handleRequest(entry, req, res) {
return res.end("Missing 'u'");
}
try {
const img = await fetchUrl(u, { accept: "image/*,*/*;q=0.8", timeoutMs: 12000 });
const img = await fetchUrl(u, { ...policy, accept: "image/*,*/*;q=0.8", timeoutMs: 12000 });
res.statusCode = img.status >= 400 ? img.status : 200;
res.setHeader("Content-Type", img.contentType || "application/octet-stream");
res.setHeader("Cache-Control", "public, max-age=300");
Expand All @@ -517,6 +543,7 @@ async function handleRequest(entry, req, res) {
try {
const target = githubBlobToRaw(u);
const r = await fetchUrl(target, {
...policy,
accept: "text/plain,text/markdown,application/json,text/*;q=0.9,*/*;q=0.5",
timeoutMs: 12000,
maxBytes: 1024 * 1024,
Expand Down Expand Up @@ -564,8 +591,9 @@ async function handleRequest(entry, req, res) {
truncated,
text,
});
} catch (err) {
return sendJson(res, 200, { error: err.message || "Couldn't load file preview." });
} catch {
log("OG Viewer: file preview failed.", "warning");
return sendJson(res, 200, { error: "Couldn't load file preview." });
}
}

Expand All @@ -575,17 +603,19 @@ async function handleRequest(entry, req, res) {
// them, and rewrites JS imports / CSS urls so the dependency graph stays inside
// the proxy. The path layout makes relative module imports resolve correctly.
if (path.startsWith("/api/proxy/")) {
const target = proxyDecodePath(path, reqUrl.search);
const search = new URLSearchParams(reqUrl.search);
if (access.privileged) search.delete("key");
const target = proxyDecodePath(path, search.size ? `?${search}` : "");
if (!target) {
res.statusCode = 400;
return res.end("Bad proxy path");
}
const appOrigin = "http://" + (req.headers.host || "127.0.0.1");
const appOrigin = proxyOrigin;
try {
const r = await fetchUrl(target, { accept: "*/*", timeoutMs: 15000 });
const r = await fetchUrl(target, { ...policy, accept: "*/*", timeoutMs: 15000 });
const ct = r.contentType || "";
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Cache-Control", "public, max-age=300");
res.setHeader("Cache-Control", "no-store");
const realPath = (() => {
try {
return new URL(r.url).pathname;
Expand Down Expand Up @@ -617,10 +647,11 @@ async function handleRequest(entry, req, res) {
res.statusCode = r.status >= 400 ? r.status : 200;
res.setHeader("Content-Type", ct || "application/octet-stream");
return res.end(r.body);
} catch (err) {
} catch {
log("OG Viewer: proxy resource request failed.", "warning");
res.statusCode = 502;
res.setHeader("Access-Control-Allow-Origin", "*");
return res.end(String(err && err.message ? err.message : err));
return res.end("Couldn't load preview resource.");
}
}

Expand All @@ -632,14 +663,15 @@ async function handleRequest(entry, req, res) {
}
try {
const r = await fetchUrl(normalizeUrl(u), {
...policy,
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
timeoutMs: 15000,
});
const ct = r.contentType || "";
const isHtml = !ct || /text\/html|application\/xhtml\+xml|\/xml|text\/plain/i.test(ct);
res.setHeader("Cache-Control", "no-store");
res.setHeader("Access-Control-Allow-Origin", "*");
const appOrigin = "http://" + (req.headers.host || "127.0.0.1");
const appOrigin = proxyOrigin;
if (!isHtml) {
// Serve non-HTML targets (images, PDFs, …) verbatim so links to
// them still render inside the browse frame.
Expand All @@ -652,11 +684,12 @@ async function handleRequest(entry, req, res) {
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
return res.end(rewriteBrowseDoc(r.body.toString("utf8"), r.url, appOrigin));
} catch (err) {
} catch {
log("OG Viewer: browse request failed.", "warning");
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "no-store");
return res.end(browseErrorPage(u, err && err.message ? err.message : String(err)));
return res.end(browseErrorPage(u, "Check the URL and selected-origin access."));
}
}

Expand All @@ -667,8 +700,8 @@ async function handleRequest(entry, req, res) {
let body;
try {
body = await readJsonBody(req);
} catch (err) {
return sendJson(res, 400, { error: err.message });
} catch {
return sendJson(res, 400, { error: "Invalid JSON request body." });
}
const repo = typeof body.repo === "string" ? body.repo.trim() : "";
const pageUrl = typeof body.url === "string" ? body.url.trim() : "";
Expand All @@ -690,13 +723,14 @@ async function handleRequest(entry, req, res) {
return sendJson(res, 200, { ok: false, error: "Session bridge unavailable." });
}
// Fire the request into the host chat session; the agent acts on it.
sessionRef.send(message).catch(() => {});
await sessionRef.send(message);
log(`OG Viewer: requested ${kind} for ${repo}.`);
return sendJson(res, 200, { ok: true });
} catch (err) {
} catch {
log("OG Viewer: session action failed.", "warning");
return sendJson(res, 200, {
ok: false,
error: err && err.message ? err.message : String(err),
error: "Couldn't send the session action.",
});
}
}
Expand All @@ -707,17 +741,20 @@ async function handleRequest(entry, req, res) {

async function startServer(instanceId, currentUrl) {
const entry = {
...createAccess(),
instanceId,
server: null,
url: "",
currentUrl: currentUrl || "",
titleKey: currentUrl ? titleKey(currentUrl) : "",
clients: new Set(),
};
if (currentUrl) entry.policy = await authorizePreview(currentUrl);
const server = createServer((req, res) => {
Promise.resolve(handleRequest(entry, req, res)).catch((err) => {
Promise.resolve(handleRequest(entry, req, res)).catch(() => {
log("OG Viewer: request failed.", "warning");
if (!res.headersSent) res.statusCode = 500;
res.end(String(err && err.message ? err.message : err));
res.end("Preview request failed.");
});
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
Expand All @@ -731,7 +768,8 @@ async function startServer(instanceId, currentUrl) {

function instanceUrl(entry) {
const base = entry.url;
return entry.currentUrl ? `${base}?u=${encodeURIComponent(entry.currentUrl)}` : base;
const url = entry.currentUrl ? `${base}?u=${encodeURIComponent(entry.currentUrl)}` : base;
return `${url}#key=${entry.uiToken}`;
}

const ogCanvas = createCanvas({
Expand Down Expand Up @@ -767,7 +805,8 @@ const ogCanvas = createCanvas({
}
const url = ctx.input?.url;
if (!url) throw new CanvasError("invalid_input", "An 'url' value is required.");
const data = await loadMetadata(url);
const policy = await selectPreview(entry, url);
const data = await loadMetadata(url, policy);
entry.currentUrl = data.requestedUrl;
broadcast(entry, { type: "load", url: data.requestedUrl });
syncTitle(entry, data.requestedUrl).catch(() => {});
Expand All @@ -787,7 +826,7 @@ const ogCanvas = createCanvas({
handler: async (ctx) => {
const url = ctx.input?.url;
if (!url) throw new CanvasError("invalid_input", "An 'url' value is required.");
const data = await loadMetadata(url);
const data = await loadMetadata(url, await authorizePreview(url));
return {
requestedUrl: data.requestedUrl,
resolved: data.resolved,
Expand All @@ -803,6 +842,9 @@ const ogCanvas = createCanvas({
if (!entry) {
entry = await startServer(ctx.instanceId, inputUrl);
} else if (inputUrl) {
// Title refreshes use currentUrl and must not grant permissions to
// a redirect or a route reported by the sandboxed page.
if (inputUrl !== entry.currentUrl) await selectPreview(entry, inputUrl);
entry.currentUrl = inputUrl;
broadcast(entry, { type: "load", url: inputUrl });
}
Expand Down
Loading
Loading