diff --git a/packages/web-readonly-status/index.html b/packages/web-readonly-status/index.html new file mode 100644 index 0000000..ac1dddf --- /dev/null +++ b/packages/web-readonly-status/index.html @@ -0,0 +1,19 @@ + + + + + + + OpenCode Read-Only Status + + + +
+ + diff --git a/packages/web-readonly-status/package.json b/packages/web-readonly-status/package.json new file mode 100644 index 0000000..c1aad81 --- /dev/null +++ b/packages/web-readonly-status/package.json @@ -0,0 +1,23 @@ +{ + "name": "@openchamber/web-readonly-status", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "type-check": "tsc --noEmit", + "lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js" + }, + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.9.3", + "vite": "^7.1.2" + } +} diff --git a/packages/web-readonly-status/src/App.tsx b/packages/web-readonly-status/src/App.tsx new file mode 100644 index 0000000..cc4adb4 --- /dev/null +++ b/packages/web-readonly-status/src/App.tsx @@ -0,0 +1,170 @@ +import { useEffect, useState } from "react"; +import { + REAL_ADAPTER_SUMMARY_URL, + assertRealAdapterSummaryUrl, +} from "./adapter/realAdapter"; +import { + type RealAdapterSummary, + fetchRealAdapterSummary, +} from "./adapter/realAdapterClient"; + +type LoadState = "idle" | "loading" | "ready" | "error"; + +function formatBoolean(value: boolean | null): string { + if (value === true) { + return "true"; + } + if (value === false) { + return "false"; + } + return "unknown"; +} + +function formatStatusCode(value: number | null): string { + return value === null ? "unknown" : String(value); +} + +function formatTagCount(value: number | null): string { + return value === null ? "unknown" : String(value); +} + +function formatTimestamp(value: string | null): string { + if (!value) { + return "Not refreshed yet"; + } + const date = new Date(value); + return Number.isNaN(date.valueOf()) ? value : date.toLocaleString(); +} + +function App() { + const [loadState, setLoadState] = useState("idle"); + const [summary, setSummary] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const [lastRefreshedAt, setLastRefreshedAt] = useState(null); + + async function refreshSummary(): Promise { + try { + setLoadState("loading"); + setErrorMessage(null); + assertRealAdapterSummaryUrl(); + const nextSummary = await fetchRealAdapterSummary(); + setSummary(nextSummary); + setLastRefreshedAt(new Date().toISOString()); + setLoadState("ready"); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : "Unknown error"); + setLastRefreshedAt(new Date().toISOString()); + setLoadState("error"); + } + } + + useEffect(() => { + void refreshSummary(); + }, []); + + return ( +
+
+

Read-only local adapter view

+

OpenCode Read-Only Status

+

+ This page is limited to one approved browser request and displays + status only. +

+
+ Allowed endpoint + {REAL_ADAPTER_SUMMARY_URL} +
+
+ +

+ Last refreshed: {formatTimestamp(lastRefreshedAt)} +

+
+
+ +
+
+
+

Adapter

+ {loadState} +
+
+
+
Status
+
{summary?.adapterStatus ?? "unknown"}
+
+
+
OpenCode reachable
+
{summary?.opencodeReachable ? "true" : "false"}
+
+
+
OpenCode HTTP status
+
{formatStatusCode(summary?.opencodeStatus ?? null)}
+
+
+
+ +
+
+

Repository

+ Display only +
+
+
+
Branch
+
{summary?.repoBranch ?? "unknown"}
+
+
+
HEAD
+
{summary?.repoHead ?? "unknown"}
+
+
+
Clean
+
{formatBoolean(summary?.repoClean ?? null)}
+
+
+
Stage 3 tag count
+
{formatTagCount(summary?.repoStage3TagCount ?? null)}
+
+
+
+ +
+
+

Safety boundary

+ Hard-coded +
+
    +
  • No direct 4096 browser egress
  • +
  • No chat, agent, terminal, git, or file actions
  • +
  • No provider setup, auth, cookies, or embedded credentials
  • +
  • No POST, PUT, PATCH, or DELETE routes
  • +
+
+
+ + {errorMessage ? ( +
+

Adapter request failed

+

{errorMessage}

+

+ This page should fail closed and must not fall back to any other + endpoint. +

+
+ ) : null} +
+ ); +} + +export default App; diff --git a/packages/web-readonly-status/src/adapter/realAdapter.ts b/packages/web-readonly-status/src/adapter/realAdapter.ts new file mode 100644 index 0000000..967e1b0 --- /dev/null +++ b/packages/web-readonly-status/src/adapter/realAdapter.ts @@ -0,0 +1,31 @@ +/** + * Web Spike 3P: read-only browser client with one fixed summary endpoint. + * + * This package must never expand into chat, terminal, git mutation, provider, + * filesystem, auth, or direct 4096 access without a separate approved review. + */ +export const REAL_ADAPTER_READONLY_MODE = true; + +export const REAL_ADAPTER_SUMMARY_URL = + "http://192.168.50.202:8790/api/local/v0/summary"; + +export function assertRealAdapterSummaryUrl( + summaryUrl: string = REAL_ADAPTER_SUMMARY_URL, +): void { + const url = new URL(summaryUrl); + if (url.protocol !== "http:") { + throw new Error(`real adapter refuses non-http protocol: ${url.protocol}`); + } + if (url.username || url.password) { + throw new Error("real adapter refuses embedded credentials"); + } + if (url.hostname !== "192.168.50.202") { + throw new Error(`real adapter refuses unexpected host: ${url.hostname}`); + } + if (url.port !== "8790") { + throw new Error(`real adapter refuses unexpected port: ${url.port}`); + } + if (url.pathname !== "/api/local/v0/summary") { + throw new Error(`real adapter refuses unexpected path: ${url.pathname}`); + } +} diff --git a/packages/web-readonly-status/src/adapter/realAdapterClient.ts b/packages/web-readonly-status/src/adapter/realAdapterClient.ts new file mode 100644 index 0000000..63df428 --- /dev/null +++ b/packages/web-readonly-status/src/adapter/realAdapterClient.ts @@ -0,0 +1,70 @@ +import { + REAL_ADAPTER_READONLY_MODE, + REAL_ADAPTER_SUMMARY_URL, + assertRealAdapterSummaryUrl, +} from "./realAdapter"; + +interface AdapterSummaryResponse { + adapter?: { + status?: unknown; + }; + opencode?: { + reachable?: unknown; + status?: unknown; + }; + repo?: { + branch?: unknown; + head?: unknown; + clean?: unknown; + stage3TagCount?: unknown; + }; +} + +export interface RealAdapterSummary { + adapterStatus: string; + opencodeReachable: boolean; + opencodeStatus: number | null; + repoBranch: string; + repoHead: string; + repoClean: boolean | null; + repoStage3TagCount: number | null; +} + +function guard(): void { + if (!REAL_ADAPTER_READONLY_MODE) { + throw new Error("real adapter read-only web app is disabled"); + } + assertRealAdapterSummaryUrl(REAL_ADAPTER_SUMMARY_URL); +} + +function toNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +export async function fetchRealAdapterSummary(): Promise { + guard(); + const response = await fetch(REAL_ADAPTER_SUMMARY_URL, { + method: "GET", + headers: { + Accept: "application/json", + }, + credentials: "omit", + }); + + if (!response.ok) { + throw new Error(`real adapter GET /summary -> ${response.status}`); + } + + const data = (await response.json()) as AdapterSummaryResponse; + return { + adapterStatus: + typeof data.adapter?.status === "string" ? data.adapter.status : "unknown", + opencodeReachable: data.opencode?.reachable === true, + opencodeStatus: toNumber(data.opencode?.status), + repoBranch: + typeof data.repo?.branch === "string" ? data.repo.branch : "unknown", + repoHead: typeof data.repo?.head === "string" ? data.repo.head : "unknown", + repoClean: typeof data.repo?.clean === "boolean" ? data.repo.clean : null, + repoStage3TagCount: toNumber(data.repo?.stage3TagCount), + }; +} diff --git a/packages/web-readonly-status/src/main.tsx b/packages/web-readonly-status/src/main.tsx new file mode 100644 index 0000000..3589701 --- /dev/null +++ b/packages/web-readonly-status/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/packages/web-readonly-status/src/styles.css b/packages/web-readonly-status/src/styles.css new file mode 100644 index 0000000..3bd031e --- /dev/null +++ b/packages/web-readonly-status/src/styles.css @@ -0,0 +1,248 @@ +:root { + color-scheme: light; + font-family: + "IBM Plex Sans", + "Segoe UI", + sans-serif; + line-height: 1.5; + font-weight: 400; + background: + radial-gradient(circle at top, rgba(214, 232, 255, 0.9), transparent 42%), + linear-gradient(180deg, #f3efe2 0%, #f7f8fc 48%, #e4edf9 100%); + color: #123048; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; +} + +button, +code, +.mono { + font-family: + "IBM Plex Mono", + "Cascadia Code", + monospace; +} + +button { + border: 0; + cursor: pointer; +} + +#root { + min-height: 100vh; +} + +.shell { + width: min(1080px, calc(100% - 2rem)); + margin: 0 auto; + padding: 2rem 0 3rem; +} + +.hero { + padding: 1.5rem; + border: 1px solid rgba(18, 48, 72, 0.12); + border-radius: 28px; + background: rgba(255, 252, 245, 0.86); + box-shadow: 0 18px 48px rgba(33, 74, 109, 0.12); + backdrop-filter: blur(10px); +} + +.eyebrow { + margin: 0 0 0.5rem; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: #8e4c2e; +} + +.hero h1 { + margin: 0; + font-size: clamp(2rem, 7vw, 4.4rem); + line-height: 0.96; + letter-spacing: -0.04em; +} + +.lede { + max-width: 44rem; + margin: 1rem 0 0; + font-size: 1.05rem; + color: #33536d; +} + +.boundary { + display: grid; + gap: 0.5rem; + margin-top: 1.25rem; + padding: 1rem; + border-radius: 18px; + background: rgba(215, 229, 244, 0.8); +} + +.boundary-label, +.subtle, +.timestamp, +dt { + font-size: 0.85rem; + color: #5a7388; +} + +.boundary code { + display: inline-block; + overflow-wrap: anywhere; + font-size: 0.92rem; + color: #113250; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.85rem 1rem; + align-items: center; + margin-top: 1.25rem; +} + +.refreshButton { + padding: 0.9rem 1.2rem; + border-radius: 999px; + background: linear-gradient(135deg, #0a5d94 0%, #123048 100%); + color: #f8fbff; + font-size: 0.95rem; + font-weight: 700; + box-shadow: 0 10px 24px rgba(10, 93, 148, 0.24); +} + +.refreshButton:disabled { + cursor: wait; + opacity: 0.72; +} + +.grid { + display: grid; + gap: 1rem; + margin-top: 1rem; +} + +.card { + padding: 1.25rem; + border: 1px solid rgba(18, 48, 72, 0.12); + border-radius: 24px; + background: rgba(255, 255, 255, 0.84); + box-shadow: 0 14px 32px rgba(18, 48, 72, 0.1); +} + +.card header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: center; + margin-bottom: 1rem; +} + +.card h2, +.errorPanel h2 { + margin: 0; + font-size: 1.2rem; +} + +.accent { + background: + linear-gradient(180deg, rgba(213, 236, 255, 0.92), rgba(255, 255, 255, 0.92)); +} + +.warning { + background: + linear-gradient(180deg, rgba(255, 241, 220, 0.92), rgba(255, 255, 255, 0.92)); +} + +.pill { + padding: 0.3rem 0.7rem; + border-radius: 999px; + font-size: 0.76rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.pill-idle, +.pill-loading { + background: #fff0c4; + color: #8a5b00; +} + +.pill-ready { + background: #d8f1db; + color: #1e6d34; +} + +.pill-error { + background: #ffd8d5; + color: #9b1f19; +} + +dl { + display: grid; + gap: 0.9rem; + margin: 0; +} + +dt { + margin-bottom: 0.2rem; +} + +dd { + margin: 0; + font-size: 1rem; + font-weight: 600; + overflow-wrap: anywhere; +} + +.guardList { + margin: 0; + padding-left: 1.2rem; + color: #25445b; +} + +.guardList li + li { + margin-top: 0.6rem; +} + +.errorPanel { + margin-top: 1rem; + padding: 1.25rem; + border: 1px solid rgba(155, 31, 25, 0.18); + border-radius: 24px; + background: rgba(255, 241, 241, 0.9); + color: #7f241c; +} + +.errorPanel p { + margin-bottom: 0; +} + +@media (min-width: 760px) { + .shell { + width: min(1180px, calc(100% - 3rem)); + padding-top: 3rem; + } + + .hero { + padding: 2rem; + } + + .grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: start; + } +} diff --git a/packages/web-readonly-status/tsconfig.json b/packages/web-readonly-status/tsconfig.json new file mode 100644 index 0000000..1f66ada --- /dev/null +++ b/packages/web-readonly-status/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/packages/web-readonly-status/vite.config.ts b/packages/web-readonly-status/vite.config.ts new file mode 100644 index 0000000..ffa1700 --- /dev/null +++ b/packages/web-readonly-status/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + css: { + postcss: { + plugins: [], + }, + }, +});