From 555b31234b45c6012600459f7605d23b8992902e Mon Sep 17 00:00:00 2001 From: Dandre Yang Date: Tue, 4 Aug 2026 08:09:15 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(=E6=8E=A7=E5=88=B6=E5=8F=B0):=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=8F=AA=E8=AF=BB=E6=9C=AC=E5=9C=B0=E6=80=BB?= =?UTF-8?q?=E8=A7=88=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 7 +- .github/workflows/pypi-publish.yml | 2 + MANIFEST.in | 1 + README.md | 12 ++ README.zh-CN.md | 12 ++ docs/designs/local-web-console.md | 17 ++ pyproject.toml | 4 + src/dyro/console/assets.py | 70 +++++++ src/dyro/console/assets/app.js | 294 +++++++++++++++++++++++++++++ src/dyro/console/assets/index.html | 46 +++++ src/dyro/console/assets/styles.css | 126 +++++++++++++ src/dyro/console/server.py | 27 ++- tests/test_console_assets.py | 65 +++++++ 13 files changed, 678 insertions(+), 5 deletions(-) create mode 100644 src/dyro/console/assets.py create mode 100644 src/dyro/console/assets/app.js create mode 100644 src/dyro/console/assets/index.html create mode 100644 src/dyro/console/assets/styles.css create mode 100644 tests/test_console_assets.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4c6fac..b9dd477 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: run: uv run python -m compileall -q src tests experiments wheel-smoke: - name: Wheel/sdist install smoke (dispatch) + name: Wheel/sdist install smoke (dispatch + console) runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -82,7 +82,12 @@ jobs: "$smoke_root/wheel-venv/bin/pip" install /tmp/dyro-dist/dyro-*.whl "$smoke_root/wheel-venv/bin/python" -c "import experiments.local_agent_dispatch" "$smoke_root/wheel-venv/bin/python" -I -c "import dyro.continuation" + "$smoke_root/wheel-venv/bin/python" -I -c "from dyro.console.assets import validate_assets; validate_assets()" DYRO_LOCAL_AGENT_DISPATCH_HOME="$smoke_root/wheel-dispatch-home" "$smoke_root/wheel-venv/bin/dyro" dispatch doctor >"$smoke_root/dispatch-doctor.json" + uv run python -m venv "$smoke_root/sdist-venv" + "$smoke_root/sdist-venv/bin/pip" install /tmp/dyro-dist/dyro-*.tar.gz + "$smoke_root/sdist-venv/bin/python" -I -c "import dyro.continuation" + "$smoke_root/sdist-venv/bin/python" -I -c "from dyro.console.assets import validate_assets; validate_assets()" windows-dispatch-import: name: Windows dispatch import / fail-closed smoke diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index eb08775..7e15a0b 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -81,11 +81,13 @@ jobs: uv run python -m venv "$smoke_root/wheel-venv" "$smoke_root/wheel-venv/bin/pip" install "$GITHUB_WORKSPACE"/dist/dyro-*.whl "$smoke_root/wheel-venv/bin/python" -c "import experiments.local_agent_dispatch" + "$smoke_root/wheel-venv/bin/python" -I -c "from dyro.console.assets import validate_assets; validate_assets()" DYRO_LOCAL_AGENT_DISPATCH_HOME="$smoke_root/wheel-dispatch-home" "$smoke_root/wheel-venv/bin/dyro" dispatch doctor >"$smoke_root/wheel-doctor.json" uv run python -c "import json; json.load(open('$smoke_root/wheel-doctor.json'))" uv run python -m venv "$smoke_root/sdist-venv" "$smoke_root/sdist-venv/bin/pip" install "$GITHUB_WORKSPACE"/dist/dyro-*.tar.gz "$smoke_root/sdist-venv/bin/python" -c "import experiments.local_agent_dispatch" + "$smoke_root/sdist-venv/bin/python" -I -c "from dyro.console.assets import validate_assets; validate_assets()" DYRO_LOCAL_AGENT_DISPATCH_HOME="$smoke_root/sdist-dispatch-home" "$smoke_root/sdist-venv/bin/dyro" dispatch doctor >"$smoke_root/sdist-doctor.json" uv run python -c "import json; json.load(open('$smoke_root/sdist-doctor.json'))" diff --git a/MANIFEST.in b/MANIFEST.in index db8f72f..410163f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -5,3 +5,4 @@ include docs/tool-catalog.md include docs/updates.md include docs/workspace-blueprints.md include examples/blueprints/acme-platform.toml +recursive-include src/dyro/console/assets * diff --git a/README.md b/README.md index 15b43ef..a4d54a4 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,18 @@ DyroEngineeringFlow is not coupled to Codex, Claude, or any business domain. Each team supplies a `dyro.toml` Profile for repositories, layouts, agent adapters, and delivery policy; business rules, model cost, and release practices stay in that Profile. +## Local web Console + +Run `dyro console` from any directory to open a read-only local dashboard for registered workspaces. It binds only to `127.0.0.1` on a random port, hands the browser a one-time fragment secret, and never exposes a network listener or browser write actions. + +```bash +dyro console +dyro --workspace platform console --no-open +dyro --root /path/to/profile console +``` + +The dashboard surfaces workspace health, attention, task execution counts, active objectives, and a safe next CLI command. It never runs agents, changes task state, marks a workspace as recently used, merges, pushes, or contacts external services. See the [Console design](docs/designs/local-web-console.md) for the read-only boundary and recovery behavior. + ## What it enforces - A task belongs to exactly one development line—never a mixed feature or hotfix workspace. diff --git a/README.zh-CN.md b/README.zh-CN.md index 9449d24..b735e72 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,6 +8,18 @@ DyroEngineeringFlow 不绑定 Codex、Claude 或任何业务领域。每个团队通过 `dyro.toml` Profile 定义仓库、目录布局、Agent adapter 与交付策略;业务规则、模型成本和发布实践始终留在各自的 Profile 中。 +## 本地 Web 控制台 + +可在任意目录运行 `dyro console`,打开已登记工作区的只读本地总览。它只绑定随机端口的 `127.0.0.1`,通过一次性 fragment secret 与浏览器建立会话,不暴露网络监听,也不提供浏览器写操作。 + +```bash +dyro console +dyro --workspace platform console --no-open +dyro --root /path/to/profile console +``` + +控制台展示工作区健康、需要关注的事项、Task 执行计数、活跃 Objective 和一条安全的下一步 CLI 命令。它不会启动 Agent、改变任务状态、标记最近使用、合并、push 或访问外部服务。只读边界与恢复方式见 [Console 设计](docs/designs/local-web-console.md)。 + ## 核心约束 - 一个任务只能属于一条开发线,不能混用功能版本与 Hotfix 工作区。 diff --git a/docs/designs/local-web-console.md b/docs/designs/local-web-console.md index b8e998a..9f721be 100644 --- a/docs/designs/local-web-console.md +++ b/docs/designs/local-web-console.md @@ -254,6 +254,23 @@ C05 提供前台入口 `dyro console [--no-open] [--port PORT]`,但不增加 - bare `dyro` Home 新增“查看全部项目控制台”。它复用当前已登记工作区作为初始焦点,且 Home 的 dry-run 会保留零副作用;需要不登记某个 Profile 时,应显式使用 `dyro --root PATH console`。 +### 4.6 C06 已落地的离线总览界面 + +C06 将已认证的总览接口接入无框架浏览器界面,优先让使用者在一页内识别需要处理的工程: + +- `index.html`、`app.js` 和 `styles.css` 是 wheel 内的固定资源;每个启动都通过 SHA-256 与大小 + manifest 验证,缺失、漂移或未知 asset 使 listener fail closed,绝不从 cwd 或 source tree 回退; +- 页面先将 fragment 中的一次性 bootstrap secret 读入局部变量并立即清除 URL,再交换为只存在 + 当前 tab `sessionStorage` 的 bearer。它不用 cookie、`localStorage`、外部资源、inline script、 + `innerHTML` 或服务端 mutation; +- 全局页显示 attention、Task 状态分布、active Objective、健康与 freshness、推荐命令。所有 + workspace 文本通过 DOM `textContent` 写入;复制只在浏览器内进行; +- 可点击卡片查看当前 C04 summary,支持条件 ETag 刷新。页面在后台时暂停轮询,恢复可见时只恢复 + 单一轮询定时器;会话过期或本地读取失败显示一条恢复说明,不猜测数据或修改项目。 + +C06 仅消费已发布的 overview/workspace 只读 DTO;Objective、Task 证据链、图和活动的深度 API +仍须在对应 Core 投影准备完成后通过单独的只读扩展交付,不能由浏览器自行推导。 + ## 5. 模块设计 建议模块边界: diff --git a/pyproject.toml b/pyproject.toml index 0c88ad5..beb2358 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,12 +48,16 @@ dev = ["build==1.3.0", "ruff==0.15.2", "twine==6.2.0"] packages = [ "dyro", "dyro.console", + "dyro.console.assets", "dyro.continuation", "experiments", "experiments.local_agent_dispatch", "experiments.local_agent_dispatch.adapters", ] +[tool.setuptools.package-data] +"dyro.console" = ["assets/*"] + [tool.unittest] start-directory = "tests" diff --git a/src/dyro/console/assets.py b/src/dyro/console/assets.py new file mode 100644 index 0000000..7909f83 --- /dev/null +++ b/src/dyro/console/assets.py @@ -0,0 +1,70 @@ +"""Verified package resources for the offline local Console shell.""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +from importlib.resources import files + + +@dataclass(frozen=True) +class ConsoleAsset: + name: str + content_type: str + sha256: str + size: int + body: bytes + + +# The values are generated from the checked-in resources. The server validates +# every byte before listening so it never silently falls back to cwd or source +# tree files when a packaged asset is missing or has drifted. +ASSET_MANIFEST = { + "index.html": ( + "text/html; charset=utf-8", + "8980f489da7e7fbd6c6023fc3338c7ff768e14dfade2c13d6b53b69f6304821a", + 1949, + ), + "app.js": ( + "text/javascript; charset=utf-8", + "034405c85e20d7fbcde3d07b0bf0a29abf2c20c9ce5be16b579b48561d69965f", + 10484, + ), + "styles.css": ( + "text/css; charset=utf-8", + "4759e04327b0a29d0bcc039d09d64f633670014ecf7fe111244ec78f60d091af", + 4842, + ), +} + + +class ConsoleAssetError(RuntimeError): + """A packaged static asset is missing, malformed, or has unexpected bytes.""" + + +def load_asset(name: str) -> ConsoleAsset: + if name not in ASSET_MANIFEST: + raise ConsoleAssetError("unknown Console asset") + content_type, expected_sha256, expected_size = ASSET_MANIFEST[name] + resource = files("dyro.console").joinpath("assets", name) + if not resource.is_file(): + raise ConsoleAssetError("missing Console asset") + try: + body = resource.read_bytes() + except OSError as exc: + raise ConsoleAssetError("unreadable Console asset") from exc + digest = hashlib.sha256(body).hexdigest() + if ( + not expected_sha256 + or expected_size <= 0 + or len(body) != expected_size + or digest != expected_sha256 + ): + raise ConsoleAssetError("invalid Console asset manifest") + return ConsoleAsset(name, content_type, digest, len(body), body) + + +def validate_assets() -> None: + """Fail closed unless every public resource matches the fixed manifest.""" + for name in ASSET_MANIFEST: + load_asset(name) diff --git a/src/dyro/console/assets/app.js b/src/dyro/console/assets/app.js new file mode 100644 index 0000000..24fcdcc --- /dev/null +++ b/src/dyro/console/assets/app.js @@ -0,0 +1,294 @@ +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/; +const TOKEN_KEY = "dyro.console.bearer"; +const state = { bearer: "", etags: new Map(), timer: null, focus: "" }; + +const $ = (id) => document.getElementById(id); + +function setStatus(message, error = false) { + const node = $("session-status"); + node.textContent = message; + node.classList.toggle("error", error); +} + +function text(value) { + return typeof value === "string" ? value : ""; +} + +function count(value) { + return Number.isInteger(value) && value >= 0 ? value : 0; +} + +function element(name, value = "") { + const node = document.createElement(name); + if (value) node.textContent = value; + return node; +} + +function consumeFragment() { + const raw = window.location.hash.startsWith("#") ? window.location.hash.slice(1) : ""; + const values = new URLSearchParams(raw); + const bootstrap = values.get("bootstrap"); + const workspace = values.get("workspace"); + state.focus = workspace && SAFE_ID.test(workspace) ? workspace : ""; + const safeRoute = state.focus ? `#workspace=${state.focus}` : ""; + window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}${safeRoute}`); + return bootstrap && bootstrap.length <= 256 ? bootstrap : ""; +} + +async function exchange(bootstrap) { + const response = await fetch("/api/v1/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ bootstrap }), + cache: "no-store", + credentials: "omit", + }); + const body = await response.json().catch(() => null); + if (!response.ok || !body || typeof body.bearer !== "string") { + throw new Error("SESSION_REJECTED"); + } + state.bearer = body.bearer; + sessionStorage.setItem(TOKEN_KEY, state.bearer); +} + +async function request(path, key) { + const headers = { Authorization: `Bearer ${state.bearer}` }; + const etag = state.etags.get(key); + if (etag) headers["If-None-Match"] = etag; + const response = await fetch(path, { headers, cache: "no-store", credentials: "omit" }); + if (response.status === 304) return null; + const body = await response.json().catch(() => null); + if (response.status === 401) throw new Error("SESSION_EXPIRED"); + if (!response.ok || !body) { + const code = text(body && body.error && body.error.code) || "LOCAL_READ_UNAVAILABLE"; + throw new Error(code); + } + const received = response.headers.get("ETag"); + if (received) state.etags.set(key, received); + return body; +} + +function expireSession() { + state.bearer = ""; + state.etags.clear(); + window.clearTimeout(state.timer); + state.timer = null; + sessionStorage.removeItem(TOKEN_KEY); + setStatus("本地会话已过期;请重新运行 dyro console。", true); +} + +function addBadge(parent, label, level = "") { + const badge = element("span", label); + badge.className = "badge"; + if (level) badge.dataset.level = level; + parent.append(badge); +} + +async function copyCommand(command, button) { + try { + await navigator.clipboard.writeText(command); + button.textContent = "已复制"; + } catch (_) { + button.textContent = "请手动复制"; + } +} + +function commandRow(command) { + const row = element("div"); + row.className = "command"; + const code = element("code", command); + const button = element("button", "复制命令"); + button.type = "button"; + button.addEventListener("click", () => copyCommand(command, button)); + row.append(code, button); + return row; +} + +function attentionLevel(summary) { + const attention = summary.attention_counts || {}; + if (count(attention.repair_required)) return "danger"; + if (count(attention.needs_user)) return "warning"; + if (summary.health === "healthy") return "success"; + return ""; +} + +function renderCounts(attention) { + const root = $("attention-counts"); + root.replaceChildren(); + for (const [key, label] of [ + ["repair_required", "需要修复"], + ["needs_user", "需要你处理"], + ["ready", "可推进"], + ["paused", "已暂停"], + ["waiting", "等待中"], + ]) { + const card = element("div"); + card.className = "count"; + card.append(element("strong", String(count(attention && attention[key]))), element("span", label)); + root.append(card); + } +} + +function renderWorkspaceCard(summary) { + const card = element("article"); + card.className = "workspace-card"; + const header = element("header"); + const title = element("h3", text(summary.display_name) || text(summary.alias) || "未命名工作区"); + const badges = element("div"); + badges.className = "badges"; + addBadge(badges, text(summary.health) || "unavailable", attentionLevel(summary)); + addBadge(badges, text(summary.freshness) || "partial"); + header.append(title, badges); + card.append(header); + + const meta = element("p", `别名:${text(summary.alias)} · Task:${count(summary.task_count)} · Active Objective:${count(summary.active_objective_count)}`); + meta.className = "workspace-meta"; + card.append(meta); + const statuses = summary.task_status_counts && typeof summary.task_status_counts === "object" + ? Object.entries(summary.task_status_counts).filter(([, value]) => Number.isInteger(value) && value > 0) + : []; + if (statuses.length) { + const execution = element("p", `任务状态:${statuses.map(([key, value]) => `${key} ${value}`).join(" · ")}`); + execution.className = "workspace-meta"; + card.append(execution); + } + + const recommendation = summary.recommendation || {}; + const reason = text(recommendation.reason) || "LOCAL_READ_UNAVAILABLE"; + const priority = element("p", `当前关注:${reason}`); + priority.className = "priority"; + card.append(priority); + const command = text(recommendation.command); + if (command) card.append(commandRow(command)); + + const footer = element("footer"); + const view = element("button", "查看摘要"); + view.type = "button"; + view.addEventListener("click", () => loadWorkspace(text(summary.alias))); + footer.append(view); + card.append(footer); + return card; +} + +function renderOverview(payload) { + const data = payload && payload.data; + if (!data || !Array.isArray(data.workspaces)) throw new Error("OVERVIEW_UNAVAILABLE"); + const total = count(data.total_workspaces); + $("overview-summary").textContent = total ? `已加载 ${total} 个本地工作区;先处理有明确恢复路径的事项。` : "尚未登记工作区。可运行 dyro setup、dyro join 或 dyro workspace add。"; + $("captured-at").textContent = text(payload.captured_at) ? `采样时间:${text(payload.captured_at)}` : ""; + renderCounts(data.attention_counts || {}); + const list = $("workspace-list"); + list.replaceChildren(); + if (!data.workspaces.length) { + const empty = element("p", "没有可展示的工作区。页面不会自动创建或登记项目。"); + empty.className = "empty"; + list.append(empty); + return; + } + for (const summary of data.workspaces) list.append(renderWorkspaceCard(summary)); + if (state.focus) loadWorkspace(state.focus, true); +} + +function definition(label, value) { + const wrapper = element("div"); + wrapper.append(element("dt", label), element("dd", value)); + return wrapper; +} + +async function loadWorkspace(alias, silent = false) { + if (!SAFE_ID.test(alias)) return; + try { + const payload = await request(`/api/v1/workspaces/${encodeURIComponent(alias)}`, `workspace:${alias}`); + if (!payload) return; + const summary = payload.data && payload.data.workspace; + if (!summary) throw new Error("WORKSPACE_UNAVAILABLE"); + const detail = $("workspace-detail"); + const content = $("detail-content"); + const grid = element("dl"); + grid.className = "detail-grid"; + grid.append( + definition("别名", text(summary.alias)), + definition("健康", text(summary.health)), + definition("可用性", text(summary.availability)), + definition("Task 总数", String(count(summary.task_count))), + definition("开发线", String(count(summary.line_count))), + definition("Objective", String(count(summary.objective_count))), + ); + content.replaceChildren(grid); + const command = text(summary.recommendation && summary.recommendation.command); + if (command) content.append(commandRow(command)); + detail.hidden = false; + $("detail-heading").focus(); + } catch (error) { + if (error && error.message === "SESSION_EXPIRED") { + expireSession(); + return; + } + if (!silent) showError(error); + } +} + +function showError(error) { + const code = text(error && error.message) || "LOCAL_READ_UNAVAILABLE"; + setStatus(`无法读取本地状态:${code}。可重新运行 dyro console。`, true); + const list = $("workspace-list"); + list.replaceChildren(); + const notice = element("p", `读取失败:${code}。Console 未修改任何项目文件。`); + notice.className = "error"; + list.append(notice); +} + +async function refresh() { + try { + const payload = await request("/api/v1/overview?limit=100", "overview"); + if (payload) renderOverview(payload); + setStatus("本地会话已就绪;页面只读。", false); + } catch (error) { + if (error && error.message === "SESSION_EXPIRED") { + expireSession(); + return; + } + showError(error); + } +} + +function scheduleRefresh() { + window.clearTimeout(state.timer); + if (state.bearer && !document.hidden) state.timer = window.setTimeout(async () => { + await refresh(); + scheduleRefresh(); + }, 5000); +} + +async function start() { + $("refresh").addEventListener("click", async () => { await refresh(); scheduleRefresh(); }); + $("detail-close").addEventListener("click", () => { $("workspace-detail").hidden = true; }); + document.addEventListener("visibilitychange", () => { + if (document.hidden) { + scheduleRefresh(); + return; + } + refresh().finally(scheduleRefresh); + }); + const bootstrap = consumeFragment(); + try { + if (bootstrap) await exchange(bootstrap); + else state.bearer = sessionStorage.getItem(TOKEN_KEY) || ""; + if (!state.bearer) throw new Error("SESSION_EXPIRED"); + const meta = await request("/api/v1/meta", "meta"); + if (meta && !state.focus) state.focus = text(meta.data && meta.data.initial_workspace); + await refresh(); + scheduleRefresh(); + } catch (error) { + if (error && error.message === "SESSION_EXPIRED") { + expireSession(); + return; + } else { + sessionStorage.removeItem(TOKEN_KEY); + setStatus("无法建立本地会话;请重新运行 dyro console。", true); + } + showError(error); + } +} + +start(); diff --git a/src/dyro/console/assets/index.html b/src/dyro/console/assets/index.html new file mode 100644 index 0000000..552a968 --- /dev/null +++ b/src/dyro/console/assets/index.html @@ -0,0 +1,46 @@ + + + + + + + Dyro Console + + + + + +
+
+
+

项目总览

+

优先处理需要关注的工程

+

正在读取本地工作区状态。

+
+ +
+
+
+
+

工作区

+

+
+
+
+ +
+ + + diff --git a/src/dyro/console/assets/styles.css b/src/dyro/console/assets/styles.css new file mode 100644 index 0000000..939322a --- /dev/null +++ b/src/dyro/console/assets/styles.css @@ -0,0 +1,126 @@ +:root { + color-scheme: light dark; + --surface: #ffffff; + --surface-muted: #f4f7fb; + --text: #14213d; + --muted: #58677f; + --border: #d5deeb; + --accent: #1455d9; + --accent-contrast: #ffffff; + --danger: #b42318; + --warning: #9a6700; + --success: #16794a; + --shadow: 0 12px 35px rgb(20 33 61 / 8%); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --surface: #111827; + --surface-muted: #192338; + --text: #f5f8ff; + --muted: #b5c0d4; + --border: #3b4961; + --accent: #8cb4ff; + --accent-contrast: #0b1730; + --danger: #ffb4ab; + --warning: #ffd98a; + --success: #7de6aa; + --shadow: none; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--surface-muted); + color: var(--text); + line-height: 1.5; +} + +button, code { font: inherit; } + +button { + cursor: pointer; + border: 1px solid var(--accent); + border-radius: .5rem; + background: var(--accent); + color: var(--accent-contrast); + font-weight: 650; + padding: .55rem .85rem; +} + +button:focus-visible { outline: 3px solid var(--warning); outline-offset: 3px; } + +button.secondary { background: var(--surface); color: var(--accent); } + +.site-header, main, footer { + width: min(1120px, calc(100% - 2rem)); + margin: 0 auto; +} + +.site-header { + align-items: center; + display: flex; + gap: 1rem; + justify-content: space-between; + min-height: 8.25rem; +} + +h1, h2, h3, p { margin-top: 0; } +h1 { margin-bottom: .2rem; } +h2 { font-size: clamp(1.25rem, 2vw, 1.65rem); margin-bottom: .35rem; } + +.eyebrow { color: var(--accent); font-size: .78rem; font-weight: 800; letter-spacing: .09em; margin-bottom: .35rem; } +.session-status, .section-heading p, #overview-summary, .workspace-meta, footer { color: var(--muted); } +.session-status { max-width: 28rem; text-align: right; } + +main { padding-bottom: 2rem; } +.hero, .workspace-card, .workspace-detail, .count { + background: var(--surface); + border: 1px solid var(--border); + border-radius: .85rem; + box-shadow: var(--shadow); +} + +.hero { align-items: center; display: flex; justify-content: space-between; gap: 1rem; padding: 1.4rem; } +.counts { display: grid; gap: .8rem; grid-template-columns: repeat(5, minmax(0, 1fr)); margin: 1rem 0 2rem; } +.count { min-height: 6.2rem; padding: .85rem; } +.count strong { display: block; font-size: 1.75rem; } +.count span { color: var(--muted); font-size: .85rem; } + +.section-heading { align-items: baseline; display: flex; gap: 1rem; justify-content: space-between; } +.workspace-list { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(290px, 1fr)); } +.workspace-card { display: flex; flex-direction: column; gap: .9rem; padding: 1.1rem; } +.workspace-card h3 { margin: 0; } +.workspace-card header, .workspace-card footer { align-items: center; display: flex; gap: .6rem; justify-content: space-between; width: auto; margin: 0; } +.badges { display: flex; flex-wrap: wrap; gap: .4rem; } +.badge { border: 1px solid var(--border); border-radius: 999px; font-size: .78rem; font-weight: 700; padding: .15rem .5rem; } +.badge[data-level="danger"] { border-color: var(--danger); color: var(--danger); } +.badge[data-level="warning"] { border-color: var(--warning); color: var(--warning); } +.badge[data-level="success"] { border-color: var(--success); color: var(--success); } + +.priority { border-left: 3px solid var(--accent); margin: 0; padding-left: .7rem; } +.command { align-items: center; display: flex; gap: .5rem; } +code { background: var(--surface-muted); border-radius: .35rem; color: var(--text); overflow-wrap: anywhere; padding: .35rem .45rem; } +.command code { flex: 1; } +.command button { flex: 0 0 auto; padding: .35rem .55rem; } +.workspace-detail { margin-top: 1.5rem; padding: 1.1rem; } +.detail-grid { display: grid; gap: .75rem; grid-template-columns: repeat(2, minmax(0, 1fr)); } +.detail-grid div { border-top: 1px solid var(--border); padding-top: .55rem; } +.detail-grid dt { color: var(--muted); font-size: .85rem; } +.detail-grid dd { font-weight: 700; margin: .2rem 0 0; } +.empty, .error { border: 1px dashed var(--border); border-radius: .7rem; color: var(--muted); padding: 1.2rem; } +.error { border-color: var(--danger); color: var(--danger); } +footer { border-top: 1px solid var(--border); font-size: .85rem; padding: 1.4rem 0 2.5rem; } + +@media (max-width: 720px) { + .site-header, .hero, .section-heading { align-items: flex-start; flex-direction: column; } + .site-header { justify-content: center; } + .session-status { text-align: left; } + .counts { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .detail-grid { grid-template-columns: 1fr; } +} + +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; } } diff --git a/src/dyro/console/server.py b/src/dyro/console/server.py index 5bf0240..bb1985b 100644 --- a/src/dyro/console/server.py +++ b/src/dyro/console/server.py @@ -12,6 +12,7 @@ from urllib.parse import urlsplit from .. import __version__ +from .assets import ConsoleAssetError, load_asset, validate_assets from .inspection import IsolatedOverviewService from .overview import ConsoleOverviewError from .session import ConsoleSessionStore, SessionRejected @@ -30,9 +31,6 @@ "connect-src 'self'; worker-src 'none'; object-src 'none'; base-uri 'none'; " "form-action 'none'; frame-ancestors 'none'" ) -_STATIC_SHELL = b""" -Dyro Console -

Dyro Console

Secure local session is starting.

""" _TOKEN = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") @@ -59,6 +57,10 @@ def __init__( initial_workspace: str | None, max_concurrent_requests: int, ) -> None: + try: + validate_assets() + except ConsoleAssetError: + raise ValueError("Console static assets unavailable") from None self._request_slots = threading.BoundedSemaphore(max_concurrent_requests) self.sessions = session_store self.overview_service = overview_service or IsolatedOverviewService() @@ -272,7 +274,13 @@ def _dispatch(self) -> None: if self._has_body(): self._error(400, "BAD_REQUEST") return - self._send(200, _STATIC_SHELL, "text/html; charset=utf-8") + self._asset("index.html") + return + if self.command == "GET" and parsed.path.startswith("/assets/"): + if self._has_body(): + self._error(400, "BAD_REQUEST") + return + self._asset(parsed.path.removeprefix("/assets/")) return if parsed.path == "/api/v1/session": if self.command != "POST": @@ -330,6 +338,17 @@ def _dispatch(self) -> None: return self._error(404, "NOT_FOUND") + def _asset(self, name: str) -> None: + if not name or "/" in name or "\\" in name: + self._error(404, "NOT_FOUND") + return + try: + asset = load_asset(name) + except ConsoleAssetError: + self._error(404, "NOT_FOUND") + return + self._send(200, asset.body, asset.content_type) + def _overview(self, query: str) -> None: service = self.console.overview_service if service is None: diff --git a/tests/test_console_assets.py b/tests/test_console_assets.py new file mode 100644 index 0000000..3ceda93 --- /dev/null +++ b/tests/test_console_assets.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from http.client import HTTPConnection +from threading import Thread +import unittest + +from dyro.console.assets import load_asset, validate_assets +from dyro.console.server import create_console_http_server + + +class ConsoleAssetTests(unittest.TestCase): + def setUp(self) -> None: + self.server = create_console_http_server(port=0, bootstrap_secret="a" * 43) + self.thread = Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.port = self.server.server_port + + def tearDown(self) -> None: + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + + def _request(self, path: str) -> tuple[int, dict[str, str], bytes]: + connection = HTTPConnection("127.0.0.1", self.port, timeout=5) + connection.request("GET", path) + response = connection.getresponse() + result = response.status, dict(response.getheaders()), response.read() + connection.close() + return result + + def test_manifest_assets_are_packaged_and_safe_for_static_serving(self) -> None: + validate_assets() + shell = load_asset("index.html") + script = load_asset("app.js") + + self.assertEqual(shell.content_type, "text/html; charset=utf-8") + self.assertEqual(script.content_type, "text/javascript; charset=utf-8") + self.assertIn(b'type="module"', shell.body) + self.assertNotIn(b"innerHTML", script.body) + self.assertNotIn(b"http://", script.body) + self.assertNotIn(b"https://", script.body) + + def test_public_assets_are_fixed_manifest_entries_and_reject_paths(self) -> None: + status, headers, body = self._request("/assets/app.js") + self.assertEqual(status, 200) + self.assertEqual(headers["Content-Type"], "text/javascript; charset=utf-8") + self.assertIn(b"sessionStorage", body) + self.assertEqual(headers["Cache-Control"], "no-store") + + for path in ("/assets/unknown.js", "/assets/../app.js", "/assets/%2e%2e/app.js"): + with self.subTest(path=path): + rejected, _, _ = self._request(path) + self.assertEqual(rejected, 404) + + def test_static_shell_has_no_bootstrap_secret_or_workspace_data(self) -> None: + status, _, body = self._request("/") + + self.assertEqual(status, 200) + self.assertNotIn(b"a" * 43, body) + self.assertNotIn(b"dyro.toml", body) + self.assertNotIn(b"bootstrap=", body) + + +if __name__ == "__main__": + unittest.main() From 4d9f7d60cd762ae4d90b8ed9872b5372cdfff6dc Mon Sep 17 00:00:00 2001 From: Dandre Yang Date: Tue, 4 Aug 2026 08:12:05 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(ci):=20=E4=BF=9D=E6=8C=81=E5=8F=97?= =?UTF-8?q?=E4=BF=9D=E6=8A=A4=E6=A3=80=E6=9F=A5=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9dd477..f1d885b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: run: uv run python -m compileall -q src tests experiments wheel-smoke: - name: Wheel/sdist install smoke (dispatch + console) + name: Wheel/sdist install smoke (dispatch) runs-on: ubuntu-latest timeout-minutes: 10 steps: