From d4e6313b63b92a379d5a903beb091d12dd4a3f5d Mon Sep 17 00:00:00 2001
From: benjsmith
Date: Fri, 18 Sep 2026 20:30:31 +0000
Subject: [PATCH 01/25] feat: Phase 4a same-origin embed reverse-proxy (no
iframes)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Daemon proxies /embed/ce/* → 127.0.0.1:8766 and /embed/okstratr/* →
:8767 with loopback-only upstream guard and X-CE-Host /
X-Okstratr-Host: switchbay. Feature-flag Graph/Agents to proxied
same-document panels (default off). ADR-004 + allowlist tests.
---
docs/ADR-004-same-origin-embed-proxy.md | 59 ++++
frontend/src/center/builtinTabs.tsx | 16 +-
frontend/src/index.css | 73 +++++
frontend/src/layout/SettingsModal.tsx | 66 ++++
.../src/widgets/embed/ProxiedSkillPanel.tsx | 183 +++++++++++
.../widgets/embed/useProxiedSkillEmbeds.ts | 38 +++
frontend/vite.config.ts | 2 +
src/switchbay/app_settings.py | 22 ++
src/switchbay/daemon.py | 14 +-
src/switchbay/embed_proxy.py | 307 ++++++++++++++++++
tests/unit/test_embed_proxy.py | 145 +++++++++
11 files changed, 919 insertions(+), 6 deletions(-)
create mode 100644 docs/ADR-004-same-origin-embed-proxy.md
create mode 100644 frontend/src/widgets/embed/ProxiedSkillPanel.tsx
create mode 100644 frontend/src/widgets/embed/useProxiedSkillEmbeds.ts
create mode 100644 src/switchbay/embed_proxy.py
create mode 100644 tests/unit/test_embed_proxy.py
diff --git a/docs/ADR-004-same-origin-embed-proxy.md b/docs/ADR-004-same-origin-embed-proxy.md
new file mode 100644
index 0000000..26a52f5
--- /dev/null
+++ b/docs/ADR-004-same-origin-embed-proxy.md
@@ -0,0 +1,59 @@
+# ADR-004: Same-origin embed reverse-proxy (no iframes)
+
+- **Status:** Accepted (Phase 4a)
+- **Date:** 2026-09-18
+- **Deciders:** Ben / skill-shell rationalization charter
+
+## Context
+
+Switchbay hosts Graph (curiosity-engine atlas/wiki) and Agents (okstratr
+observer/desk) surfaces. Cross-origin iframes pointed at
+`127.0.0.1:8766` / `:8767` break hosted-mode control (okstratr HTML
+settings must hide when `host=switchbay`) and create an opaque nested
+browsing context.
+
+Charter locked decision #1: shells **same-origin reverse-proxy** skill
+daemons; in-app panels load first-party proxied routes — **not nested
+frames**.
+
+## Decision
+
+1. **Daemon reverse-proxy** (always on; independent of the UI flag):
+ - `/embed/ce/*` → `http://127.0.0.1:8766/*` (override:
+ `SWITCHBAY_CE_UPSTREAM`, must remain loopback)
+ - `/embed/okstratr/*` → `http://127.0.0.1:8767/*` (override:
+ `SWITCHBAY_OKSTRATR_UPSTREAM`, must remain loopback)
+ - Upstream allowlist is **loopback-only** (`127.0.0.1` / `::1` /
+ `localhost`). Non-loopback upstreams are rejected (502).
+ - Inject hosted-shell headers on every proxied request:
+ - `X-CE-Host: switchbay`
+ - `X-Okstratr-Host: switchbay`
+
+2. **No iframes** for Graph/Agents skill surfaces. Feature flag
+ `proxied_skill_embeds` (default **false**) switches Graph → CE panel
+ and Agents → okstratr panel that navigate `/embed/*` via same-origin
+ `fetch` + same-document rendering (script-stripped HTML / JSON).
+ Built-in GraphTab / AgentDashboardTab / filebrowser remain the
+ default and are **not deleted**.
+
+3. **Settings → okstratr registry (TODO).** Switchbay settings will
+ become a client that writes okstratr's harness/model registry
+ (`harnesses.toml` via API). Out of scope for 4a; tracked here so the
+ shell does not grow a second allowlist.
+
+## Consequences
+
+- Dev Vite must proxy `/embed` to the daemon (`vite.config.ts`).
+- CE and okstratr should honor public-base + `X-*-Host` (okstratr
+ Phase 1a `public_base.py`; CE equivalent TBD).
+- Full atlas/observer chrome parity is gated by the parity checklist
+ before any Switchbay duplicate deletion (Phase 4b+).
+- WebSocket upgrade through the embed proxy is deferred; HTTP(S) first.
+
+## Alternatives considered
+
+| Option | Why not |
+|--------|---------|
+| Cross-origin iframe to :8766/:8767 | Opaque origin; hosted settings leak; charter forbid |
+| Same-origin iframe to `/embed/*` | Still a nested frame; charter: no iframes |
+| Delete built-in Graph/Agents now | Violates parity checklist / dual-stack gate |
diff --git a/frontend/src/center/builtinTabs.tsx b/frontend/src/center/builtinTabs.tsx
index 5ceb3b2..7452403 100644
--- a/frontend/src/center/builtinTabs.tsx
+++ b/frontend/src/center/builtinTabs.tsx
@@ -1,6 +1,8 @@
import { lazy } from "react";
import SketchErrorBoundary from "../widgets/sketch/ErrorBoundary";
import { registerTabKind, type TabComponent } from "./tabRegistry";
+import ProxiedSkillPanel from "../widgets/embed/ProxiedSkillPanel";
+import { useProxiedSkillEmbeds } from "../widgets/embed/useProxiedSkillEmbeds";
/**
* Wire each built-in tab kind into the registry. Called once from
@@ -40,9 +42,11 @@ const ReportDocTab = lazy(() => import("../widgets/library/ReportDocTab"));
const ThrustersTab = lazy(() => import("../widgets/thrusters/ThrustersTab"));
const OwidTab = lazy(() => import("../widgets/owid/OwidTab"));
-const GraphAdapter: TabComponent = ({ graphData, graphError }) => (
-
-);
+const GraphAdapter: TabComponent = ({ graphData, graphError }) => {
+ const proxied = useProxiedSkillEmbeds();
+ if (proxied) return ;
+ return ;
+};
const EditorAdapter: TabComponent = ({ tab }) => ;
const DuckDBAdapter: TabComponent = () => ;
const SheetAdapter: TabComponent = () => ;
@@ -50,7 +54,11 @@ const VegaAdapter: TabComponent = () => ;
const SketchAdapter: TabComponent = () => (
);
-const AgentsAdapter: TabComponent = () => ;
+const AgentsAdapter: TabComponent = () => {
+ const proxied = useProxiedSkillEmbeds();
+ if (proxied) return ;
+ return ;
+};
const ProjectsAdapter: TabComponent = () => ;
const PackFileListAdapter: TabComponent = ({ tab }) => ;
diff --git a/frontend/src/index.css b/frontend/src/index.css
index f212a1b..da36056 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -8986,3 +8986,76 @@ a.sy-source-cite { color: var(--accent); }
color: var(--danger, #c44);
font-size: 10px;
}
+
+
+/* ── Phase 4a: same-origin proxied skill panel (no iframe) ───────── */
+.sy-proxied-skill {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ background: var(--sy-bg, #0f1115);
+ color: var(--sy-fg, #e8eaed);
+}
+.sy-proxied-skill-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px 12px;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--sy-border, #2a2f3a);
+ flex-shrink: 0;
+}
+.sy-proxied-skill-hint {
+ opacity: 0.65;
+ font-size: 0.85em;
+}
+.sy-proxied-skill-nav {
+ display: flex;
+ gap: 6px;
+ flex: 1;
+ min-width: 200px;
+}
+.sy-proxied-skill-nav input {
+ flex: 1;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.85em;
+ padding: 4px 8px;
+ border-radius: 4px;
+ border: 1px solid var(--sy-border, #2a2f3a);
+ background: var(--sy-input-bg, #1a1f2a);
+ color: inherit;
+}
+.sy-proxied-skill-nav button {
+ padding: 4px 10px;
+ border-radius: 4px;
+ border: 1px solid var(--sy-border, #2a2f3a);
+ background: var(--sy-btn-bg, #222833);
+ color: inherit;
+ cursor: pointer;
+}
+.sy-proxied-skill-body {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ padding: 12px;
+}
+.sy-proxied-skill-muted { opacity: 0.7; }
+.sy-proxied-skill-error {
+ border: 1px solid #a44;
+ border-radius: 6px;
+ padding: 12px;
+ background: rgba(160, 40, 40, 0.12);
+}
+.sy-proxied-skill-error pre,
+.sy-proxied-skill-json,
+.sy-proxied-skill-text {
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.85em;
+}
+.sy-proxied-skill-html {
+ /* Hosted skill HTML body, scripts stripped — same-document panel. */
+ max-width: 100%;
+}
diff --git a/frontend/src/layout/SettingsModal.tsx b/frontend/src/layout/SettingsModal.tsx
index fca160e..63c42e8 100644
--- a/frontend/src/layout/SettingsModal.tsx
+++ b/frontend/src/layout/SettingsModal.tsx
@@ -1732,6 +1732,7 @@ type SettingsBody = {
admin_ceiling?: number | null;
chief_counted?: boolean;
note?: string;
+ proxied_skill_embeds?: boolean;
media?: {
modalities?: Record;
note?: string;
@@ -3646,6 +3647,42 @@ function StoragePanel({ open }: { open: boolean }) {
}
};
+ const toggleProxiedEmbeds = async () => {
+ if (!settings || busy) return;
+ const next = !settings.proxied_skill_embeds;
+ setBusy(true);
+ setStatus(null);
+ try {
+ const r = await fetch("/api/settings", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ proxied_skill_embeds: next }),
+ });
+ if (!r.ok) {
+ const body = await r.json().catch(() => ({} as Record));
+ setStatus({ ok: false, msg: body.error || `HTTP ${r.status}` });
+ return;
+ }
+ const body = (await r.json()) as SettingsBody;
+ setSettings(body);
+ window.dispatchEvent(
+ new CustomEvent("sy:proxied-skill-embeds", {
+ detail: { enabled: !!body.proxied_skill_embeds },
+ }),
+ );
+ setStatus({
+ ok: true,
+ msg: next
+ ? "Graph/Agents now use same-origin /embed/* panels (no iframe)."
+ : "Graph/Agents restored to built-in tabs.",
+ });
+ } catch (e) {
+ setStatus({ ok: false, msg: (e as Error).message });
+ } finally {
+ setBusy(false);
+ }
+ };
+
const setEmbeddingBackend = async (backend: string) => {
if (!settings || busy) return;
setBusy(true);
@@ -3728,6 +3765,35 @@ function StoragePanel({ open }: { open: boolean }) {
Machine-local avoids that at the cost of per-machine history.
+
+ Proxied skill embeds (Phase 4a)
+
+
+ When on, Graph and Agents load CE / okstratr through the daemon
+ same-origin reverse proxy (/embed/ce/,{" "}
+ /embed/okstratr/) — in-app panels, no iframes.
+ Default off keeps the built-in tabs. Settings will later write the
+ okstratr harness registry (TODO).
+