feat(v0.5.0): secure project previews and fix host UI compatibility
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
- [README](../README.md) — 项目概述与目录结构
|
||||
- [PRD](../02_process/PRD.md) — 需求与设计(含接手分界线 §11)
|
||||
- [兼容与安全](../docs/compatibility-and-security.md) — 当前 0.5.0 范围、接口与隔离限制
|
||||
- [AGENTS](../AGENTS.md) — 项目规则
|
||||
- [01_content](../01_content) — 插件包
|
||||
- [02_process](../02_process) — 过程文档
|
||||
|
||||
+26
-3
@@ -17,7 +17,30 @@
|
||||
- 状态监控为宿主会话运行时快照的事件订阅镜像;视图/项目/绑定状态存 localStorage。
|
||||
- 客户端:TypeScript + React(host externals)+ 原生 CSS;服务端:Node。
|
||||
|
||||
## v0.4.0:兼容与验证范围
|
||||
## 0.5.0:兼容与安全说明
|
||||
|
||||
公开发布状态以 GitHub Release 为准。
|
||||
|
||||
- HTTP 文件/目录/写入/Git/工作区与终端握手先通过宿主身份验证,仅放行 owner(0.2.0 使用单 operator 身份);缺少可确认身份的服务时返回 503,不降级开放。health 仍可公开读取状态。以前直接用无登录 curl 调用这些接口的方式将被拒绝;不提供跳过鉴权开关。
|
||||
- 候选 dev.1 已在 Windows 隔离数据目录验证 **DSH 0.2.0-rc.2 / 0.2.1-rc.3 网页端**的冷启动、加载、分栏、文件与网页资源;0.2.1 另测创建/写入、控制室绑定和终端,未发送模型请求。用户已在官方 Windows Desktop 0.2.1-rc.3 验证 dev.2 的项目打开、分栏缩放与侧栏设置弹窗正常;前轮新项目也已点测。正式包另做安装与回归检查,这些证据不等于双端全功能验收。
|
||||
- 本版的 `engines.dsh` 与兼容矩阵只列上述两个 0.2 版本。旧 0.1.x 不在本版支持范围;下方 v0.4.0 的记录是历史范围,不代表 0.5.0 继续支持旧宿主。
|
||||
- 折叠侧栏与展开侧栏共用一份弹窗层,使控制室新建项目、绑定、图标与删除弹窗不因侧栏收起而消失,避免重复渲染。
|
||||
- 修复分栏 margin 的小数值被浏览器规范化后误判为外部改写,以及会话根更换后观察器仍盯着旧元素的问题。
|
||||
|
||||
### 网页隔离的影响与边界
|
||||
|
||||
- 浏览器/动画/自定义 HTML 窗格使用无 `allow-same-origin` 的 sandbox;网页不能直接读取 DSH 主页面及其同源存储;私有文件操作和终端接口仍需宿主身份校验。工作台自己的项目、布局和媒体存储不迁移、不删除。
|
||||
- **可能受影响**:嵌入页面的 localStorage、IndexedDB、Cookie 登录、依赖同源的请求、弹出新窗口/顶层跳转,以及标注时自动读取 iframe 内文字。项目页内嵌 iframe/object 被限制;依赖这些能力的页面可能需要单独适配。网页窗口级标注仍可使用,但不能承诺保留逐元素文字提取。
|
||||
- **已观察到的兼容限制**:Cesium 等库的 WebGL 纹理可能需要显式跨源加载。StarMap 的月球纹理已在独立项目中修复并验证,改动不包含在工作台安装包里;其他旧网页仍需各自检查。不会为恢复旧网页取消安全隔离。
|
||||
- 本地 HTML 通过登录后创建的随机只读地址加载,允许读取该次选择目录内的资源(包括子目录),支持相对 CSS、JS 模块与 fetch;它不是“只能读当前 HTML 文件”。不要给不可信页面挂载含敏感文件的目录;页面仍可主动联网,隔离不等于阻止数据外发。
|
||||
- 临时地址不写入项目存档,单资源最多 40 MiB,最多 128 个挂载,连续 30 分钟无读取后失效;失效后点击窗格刷新重新创建。关闭网页标签、刷新替换、组件卸载或插件卸载会撤销;仅关闭/切换项目仍沿用最多 6 个项目的保活池,不立即撤销仍挂载的页面。
|
||||
- 预览使用同一文件句柄限量读取,读取前后复核真实路径、文件身份与状态;检测到替换、改写或撤销时不返回内容。此措施不是针对恶意本地写入者精确竞态的操作系统沙箱;40 MiB 是单资源限制,不是所有并发请求的总内存额度。
|
||||
- 终端插件卸载时关闭本插件连接及 Shell。鉴权检查发生在每次 HTTP 请求/WS 建连;本轮没有实现宿主身份撤销后即时断开已有 WS 的机制。
|
||||
- `npm run test:security` 覆盖鉴权、预览目录越界/生命周期、终端卸载;`npm run pack` 同时执行真实 Chromium 小数 margin 回归。需要本机安装 Edge/Chrome,或用 `DSH_TEST_CHROMIUM` 指定可执行文件。
|
||||
|
||||
完整路由、错误码、预览生命周期与验收边界见源码仓库的 `docs/compatibility-and-security.md`;该文档随源码交付,不额外放入七文件安装包。
|
||||
|
||||
## v0.4.0:兼容与验证范围(历史发布记录)
|
||||
|
||||
- DSH 网页端兼容声明仅列 **0.1.1-rc.2 / 0.1.2-rc.1 / 0.2.0-rc.2**,不是对任意新旧版本的保证。
|
||||
- Windows + 0.2.0-rc.2 网页端已有冷启动、工作台/控制室、新建绑定与短文本回复、关闭回切等验收基础;桌面主要页面与交互已点测,共目录串挂修复获用户确认。最终包经过 98 项源码回归、独立安装与客户端加载门禁;这些检查不等于完整桌面端业务验收。旧版沿用此前页面验收并补定向代码回归,未重跑整套旧版 GUI。
|
||||
@@ -37,7 +60,7 @@
|
||||
## 权限与外部服务(如实披露)
|
||||
|
||||
- **文件访问**:服务端读写工作区与用户指定的目录——文件窗目录列表、MD 编辑保存、项目文件夹 mkdir、站点静态托管、widget 产物读取。除用户当次操作外,插件在加载时、项目状态变化时、已登记窗口任务完成时会自动读取当前关联的 `.dsh-worktable/` 结果文件及其指定产物,并读取宿主提供的工作区数据(workspaces 路由)。不扫描根目录旧 `widget-result.json`;产物路径可来自任务结果,这不是文件系统沙箱。失败返回错误或降级。
|
||||
- **网络**:① 更新检查向 api.github.com 发只读 GET(无凭据,请求仅含公开仓库名,不携带会话内容);② 插件与宿主的通信走宿主自身的 API / WebSocket 通道(由宿主管理);③ 「浏览器」「动画」等网页窗加载用户指定的页面,页面自身可能发起任意网络请求——等同于用户在自己的浏览器中打开这些页面。
|
||||
- **网络**:① 更新检查向 api.github.com 发只读 GET(无凭据,请求仅含公开仓库名,不携带会话内容);② 插件与宿主的通信走宿主自身的 API / WebSocket 通道;③ 网页窗加载用户指定的页面,页面自身可能发起任意网络请求;本版的隔离与存储限制见上节,不等同于完整浏览器页面的功能。
|
||||
- **命令执行**:① 「源代码管理」窗运行 git 只读状态命令;② 「终端」窗经 node-pty 启动交互式 Shell(Windows 下为 PowerShell)——这是用户主动使用的完整终端:终端内执行的任何命令都可能读写文件、联网,行为等同用户本人操作。
|
||||
- **凭据与环境**:插件代码不专门提取、存储或上传任何 API Key / Token / Cookie;但终端窗把宿主进程的完整环境变量传给子 Shell(其中可能包含敏感信息),终端内命令的行为不受插件约束。插件对 process.env 的读取用于运行参数(数据目录 DSH_HOME、终端默认 Shell SHELL 等),不将其外发。以上声明均有对应源码可查;本说明不是安全保证,高权限能力如实列出,使用风险由用户在自身环境评估。
|
||||
- **外部依赖与边界**:无生产依赖;终端窗依赖宿主的 ws / node-pty(缺失时该窗降级提示,不影响其余功能)。不替换、不禁用任何官方插件。本插件不声称解决宿主自身的全部启动问题。
|
||||
@@ -61,7 +84,7 @@
|
||||
|
||||
先保存任务,从应用菜单/托盘完整退出(只关窗口不够);将占位路径替换为实际桌面端安装目录,在 PowerShell 执行,再手动重新打开应用:
|
||||
|
||||
& "<桌面端安装目录>\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add "https://github.com/Aisland-SJL/dsh-worktable/releases/download/v0.4.0/dsh-worktable-0.4.0.tgz"
|
||||
& "<桌面端安装目录>\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add "https://github.com/Aisland-SJL/dsh-worktable/releases/download/v0.5.0/dsh-worktable-0.5.0.tgz"
|
||||
|
||||
不要以不固定版本的 npx 或网页端 CLI 代替桌面端自带 CLI;保留实际 DSH_HOME,勿把测试数据目录覆盖正式数据。新建控制室管理对话时,DSH 0.2 用户请选择分组,或直接绑定可用的现有对话。
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dsh-worktable",
|
||||
"description": "工作台:DSH 侧边栏的 agent 级项目容器,收纳如 TravelAtlas 等自装项目",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"entry": {
|
||||
"name": "dsh-worktable",
|
||||
"inject": []
|
||||
|
||||
+1322
-1194
File diff suppressed because one or more lines are too long
+262
-25
@@ -2,11 +2,121 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { readdirSync, realpathSync } from "node:fs";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { dirname, resolve as pathResolve, sep } from "node:path";
|
||||
import { dirname, resolve as pathResolve, sep as sep2 } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { homedir } from "node:os";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
// src/requestAuth.ts
|
||||
async function requestRejection(ctx, req) {
|
||||
try {
|
||||
const connection = typeof ctx.get === "function" ? ctx.get("connection") : void 0;
|
||||
if (!connection) return 503;
|
||||
if (typeof connection.admit === "function") {
|
||||
const admission = await connection.admit(req);
|
||||
if (!admission || typeof admission !== "object") return 503;
|
||||
if ("rejection" in admission) return admission.rejection === 401 ? 401 : 403;
|
||||
const peer = admission.peer;
|
||||
if (peer?.principal?.kind === "owner") return void 0;
|
||||
if (peer && peer === connection.operator && peer.principal === void 0) return void 0;
|
||||
return 403;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return 503;
|
||||
}
|
||||
function rejectionText(status) {
|
||||
return status === 401 ? "unauthorized" : status === 403 ? "forbidden" : "Host authentication unavailable";
|
||||
}
|
||||
|
||||
// src/previewFiles.ts
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { open, realpath, stat } from "node:fs/promises";
|
||||
import { isAbsolute, relative, resolve, sep } from "node:path";
|
||||
var PREVIEW_PREFIX = "/api/worktable/preview-static";
|
||||
var PREVIEW_SANDBOX = "allow-scripts allow-forms allow-downloads";
|
||||
var PREVIEW_CSP = `sandbox ${PREVIEW_SANDBOX}; base-uri 'none'; object-src 'none'; frame-src 'none'`;
|
||||
var PREVIEW_MAX_BYTES = 40 * 1024 * 1024;
|
||||
var IDLE_MS = 30 * 60 * 1e3;
|
||||
var MAX_MOUNTS = 128;
|
||||
function inside(root, file) {
|
||||
const rel = relative(root, file);
|
||||
return rel === "" || !isAbsolute(rel) && rel !== ".." && !rel.startsWith(".." + sep);
|
||||
}
|
||||
function createPreviewFiles(now = () => Date.now()) {
|
||||
const mounts = /* @__PURE__ */ new Map();
|
||||
const prune = () => {
|
||||
for (const [key, item] of mounts) if (now() - item.touched >= IDLE_MS) mounts.delete(key);
|
||||
};
|
||||
return {
|
||||
async mount(root) {
|
||||
if (!isAbsolute(root)) throw new Error("Preview root must be absolute");
|
||||
const canonical = await realpath(root);
|
||||
if (!(await stat(canonical)).isDirectory()) throw new Error("Preview root is not a directory");
|
||||
prune();
|
||||
if (mounts.size >= MAX_MOUNTS) throw new Error("Too many open previews; close an unused preview first");
|
||||
const token = randomBytes(24).toString("base64url");
|
||||
mounts.set(token, { root: canonical, touched: now() });
|
||||
return token;
|
||||
},
|
||||
revoke(token) {
|
||||
mounts.delete(token);
|
||||
},
|
||||
clear() {
|
||||
mounts.clear();
|
||||
},
|
||||
async file(token, segments, headOnly = false) {
|
||||
prune();
|
||||
const mount = mounts.get(token);
|
||||
if (!mount) return null;
|
||||
if (segments.some((s) => s === ".." || s.includes("/") || s.includes("\\") || s.includes("\0"))) return null;
|
||||
let target = resolve(mount.root, ...segments);
|
||||
if (!inside(mount.root, target)) return null;
|
||||
let info = await stat(target).catch(() => null);
|
||||
if (info?.isDirectory()) target = resolve(target, "index.html");
|
||||
const canonical = await realpath(target).catch(() => null);
|
||||
if (!canonical || !inside(mount.root, canonical)) return null;
|
||||
const handle = await open(canonical, "r").catch(() => null);
|
||||
if (!handle) return null;
|
||||
try {
|
||||
const before = await handle.stat({ bigint: true });
|
||||
if (!before.isFile()) return null;
|
||||
const sameFile = (other) => other.isFile() && other.dev === before.dev && other.ino === before.ino && other.size === before.size && other.mtimeNs === before.mtimeNs && other.ctimeNs === before.ctimeNs;
|
||||
const stillValid = async () => {
|
||||
const current = await realpath(canonical);
|
||||
if (current !== canonical || !inside(mount.root, current)) return false;
|
||||
const named = await stat(current, { bigint: true });
|
||||
const opened = await handle.stat({ bigint: true });
|
||||
return sameFile(named) && sameFile(opened) && mounts.get(token) === mount && now() - mount.touched < IDLE_MS;
|
||||
};
|
||||
const tooLarge = () => Object.assign(new Error("file too large"), { status: 413 });
|
||||
if (before.size > BigInt(PREVIEW_MAX_BYTES)) throw tooLarge();
|
||||
if (!await stillValid()) return null;
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
if (!headOnly) {
|
||||
while (total <= PREVIEW_MAX_BYTES) {
|
||||
const chunk = Buffer.alloc(Math.min(64 * 1024, PREVIEW_MAX_BYTES + 1 - total));
|
||||
const { bytesRead } = await handle.read(chunk, 0, chunk.length, total);
|
||||
if (!bytesRead) break;
|
||||
total += bytesRead;
|
||||
if (total > PREVIEW_MAX_BYTES) throw tooLarge();
|
||||
chunks.push(chunk.subarray(0, bytesRead));
|
||||
}
|
||||
}
|
||||
if (!await stillValid() || !headOnly && BigInt(total) !== before.size) return null;
|
||||
mount.touched = now();
|
||||
return { path: canonical, size: Number(before.size), data: headOnly ? void 0 : Buffer.concat(chunks, total) };
|
||||
} catch (error) {
|
||||
if (error?.status === 413) throw error;
|
||||
return null;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// template/dshell.css
|
||||
var dshell_default = `/* dsh-worktable \u539F\u751F\u76AE\u80A4 \xB7 DSH \u8BBE\u8BA1\u7CFB\u7EDF\u7EC4\u4EF6\u5E93
|
||||
\u7528\u6CD5\uFF1A<link rel="stylesheet" href="/api/worktable/template/dshell.css">
|
||||
@@ -115,7 +225,7 @@ function resolveDshHomeSafe() {
|
||||
cachedDshHome = baseDshHome();
|
||||
return cachedDshHome;
|
||||
}
|
||||
var PLUGIN_VERSION = false ? "dev" : "0.4.0";
|
||||
var PLUGIN_VERSION = false ? "dev" : "0.5.0";
|
||||
var name = "dsh-worktable";
|
||||
var inject = ["webServer", "sessions"];
|
||||
var HEALTH_PATH = "/api/worktable/health";
|
||||
@@ -212,7 +322,13 @@ function json(res, status, body) {
|
||||
}
|
||||
async function readJsonBody(req) {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||
let bytes = 0;
|
||||
for await (const chunk of req) {
|
||||
const data = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
||||
bytes += data.length;
|
||||
if (bytes > 22 * 1024 * 1024) throw Object.assign(new Error("request too large"), { status: 413 });
|
||||
chunks.push(data);
|
||||
}
|
||||
const text = Buffer.concat(chunks).toString("utf8");
|
||||
if (!text) return {};
|
||||
try {
|
||||
@@ -224,7 +340,7 @@ async function readJsonBody(req) {
|
||||
async function listDirectory(path) {
|
||||
const abs = pathResolve(path);
|
||||
const dirents = await readdir(abs, { withFileTypes: true });
|
||||
const entries = dirents.map((d) => ({ name: d.name, path: abs + sep + d.name, isDir: d.isDirectory(), hidden: d.name.startsWith(".") })).sort((a, b) => {
|
||||
const entries = dirents.map((d) => ({ name: d.name, path: abs + sep2 + d.name, isDir: d.isDirectory(), hidden: d.name.startsWith(".") })).sort((a, b) => {
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
||||
return a.name.localeCompare(b.name, void 0, { sensitivity: "base" });
|
||||
});
|
||||
@@ -262,11 +378,43 @@ function setupTerminal(webServer, ctx) {
|
||||
if (!WebSocketServer) return;
|
||||
const pty = ptyMod.default ?? ptyMod;
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
let disposed = false;
|
||||
const terminals = /* @__PURE__ */ new Set();
|
||||
ctx.effect(() => () => {
|
||||
disposed = true;
|
||||
for (const ws of wss.clients ?? []) {
|
||||
try {
|
||||
ws.terminate();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
for (const term of terminals) {
|
||||
try {
|
||||
term.kill();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
terminals.clear();
|
||||
try {
|
||||
wss.close();
|
||||
} catch {
|
||||
}
|
||||
}, "dsh-worktable: terminal sessions");
|
||||
const spawnShell = () => process.platform === "win32" ? { cmd: "powershell.exe", args: ["-NoLogo", "-NoProfile"] } : { cmd: process.env.SHELL || "/bin/bash", args: [] };
|
||||
const clampDim = (v, fallback) => Math.min(1024, Math.max(2, Number.isFinite(v) ? v : fallback));
|
||||
ctx.effect(() => webServer.registerUpgrade({
|
||||
path: "/api/worktable/term",
|
||||
handler: (req, socket, head) => {
|
||||
handler: async (req, socket, head) => {
|
||||
let rejected = await requestRejection(ctx, req);
|
||||
if (disposed) rejected = 503;
|
||||
if (rejected !== void 0) {
|
||||
socket.end(`HTTP/1.1 ${rejected} ${rejectionText(rejected)}\r
|
||||
Connection: close\r
|
||||
Content-Length: 0\r
|
||||
\r
|
||||
`);
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
const u = new URL(req.url ?? "/", "http://dsh.internal");
|
||||
const cwd = serverCwd(ctx, u.searchParams.get("sessionId") || void 0, u.searchParams.get("cwd") || void 0);
|
||||
@@ -276,6 +424,7 @@ function setupTerminal(webServer, ctx) {
|
||||
try {
|
||||
const shell = spawnShell();
|
||||
term = pty.spawn(shell.cmd, shell.args, { name: "xterm-256color", cols, rows, cwd, env: process.env });
|
||||
terminals.add(term);
|
||||
} catch (err) {
|
||||
try {
|
||||
ws.send("\r\n[worktable] \u7EC8\u7AEF\u542F\u52A8\u5931\u8D25\uFF1A" + String(err));
|
||||
@@ -294,6 +443,7 @@ function setupTerminal(webServer, ctx) {
|
||||
}
|
||||
});
|
||||
term.onExit(() => {
|
||||
terminals.delete(term);
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
@@ -315,6 +465,7 @@ function setupTerminal(webServer, ctx) {
|
||||
}
|
||||
});
|
||||
ws.on("close", () => {
|
||||
terminals.delete(term);
|
||||
try {
|
||||
term.kill();
|
||||
} catch {
|
||||
@@ -330,14 +481,85 @@ function apply(ctx) {
|
||||
ctx.logger?.warn("[dsh-worktable] ctx.webServer \u4E0D\u53EF\u7528\uFF08headless profile\uFF1F\uFF09\uFF0C\u8DF3\u8FC7\u670D\u52A1\u7AEF\u8DEF\u7531");
|
||||
return;
|
||||
}
|
||||
webServer.register({
|
||||
const previews = createPreviewFiles();
|
||||
ctx.effect(() => () => previews.clear(), "dsh-worktable: preview capabilities");
|
||||
const register = (route, publicAsset = false) => ctx.effect(() => webServer.register({
|
||||
...route,
|
||||
handler: async (req, res) => {
|
||||
if (!publicAsset && route.path !== HEALTH_PATH) {
|
||||
const rejected = await requestRejection(ctx, req);
|
||||
if (rejected !== void 0) {
|
||||
json(res, rejected, { error: rejectionText(rejected) });
|
||||
return;
|
||||
}
|
||||
}
|
||||
return route.handler(req, res);
|
||||
}
|
||||
}), "dsh-worktable: " + route.path);
|
||||
register({
|
||||
kind: "exact",
|
||||
path: "/api/worktable/preview",
|
||||
handler: async (req, res) => {
|
||||
if (req.method !== "POST") {
|
||||
json(res, 405, { error: "method not allowed" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await readJsonBody(req);
|
||||
if (typeof body.revoke === "string") {
|
||||
previews.revoke(body.revoke);
|
||||
json(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
if (typeof body.root !== "string") {
|
||||
json(res, 400, { error: "missing root" });
|
||||
return;
|
||||
}
|
||||
const token = await previews.mount(body.root);
|
||||
json(res, 200, { token, base: PREVIEW_PREFIX + "/" + token + "/" });
|
||||
} catch (err) {
|
||||
json(res, err.status ?? 400, { error: String(err) });
|
||||
}
|
||||
}
|
||||
});
|
||||
register({
|
||||
kind: "prefix",
|
||||
path: PREVIEW_PREFIX,
|
||||
handler: async (req, res) => {
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
json(res, 405, { error: "method not allowed" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parts = new URL(req.url ?? "/", "http://dsh.internal").pathname.slice(PREVIEW_PREFIX.length + 1).split("/").map(decodeURIComponent);
|
||||
const file = await previews.file(parts.shift() ?? "", parts.filter(Boolean), req.method === "HEAD");
|
||||
if (!file) {
|
||||
json(res, 404, { error: "preview unavailable; refresh to reopen" });
|
||||
return;
|
||||
}
|
||||
const ext = (file.path.split(".").pop() || "").toLowerCase();
|
||||
res.writeHead(200, {
|
||||
"content-type": FILE_TYPES[ext] ?? "application/octet-stream",
|
||||
"cache-control": "no-store",
|
||||
"referrer-policy": "no-referrer",
|
||||
"content-security-policy": PREVIEW_CSP,
|
||||
"x-content-type-options": "nosniff",
|
||||
"access-control-allow-origin": "*"
|
||||
});
|
||||
res.end(file.data);
|
||||
} catch (err) {
|
||||
json(res, err?.status === 413 ? 413 : 404, { error: err?.status === 413 ? "file too large" : "preview unavailable" });
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
register({
|
||||
kind: "exact",
|
||||
path: HEALTH_PATH,
|
||||
handler: (_req, res) => {
|
||||
json(res, 200, { plugin: "dsh-worktable", version: PLUGIN_VERSION, ok: true });
|
||||
}
|
||||
});
|
||||
webServer.register({
|
||||
register({
|
||||
kind: "exact",
|
||||
path: "/api/worktable/file",
|
||||
handler: async (req, res) => {
|
||||
@@ -349,8 +571,8 @@ function apply(ctx) {
|
||||
return;
|
||||
}
|
||||
const abs = pathResolve(p);
|
||||
const stat = await import("node:fs/promises").then((m) => m.stat(abs));
|
||||
if (stat.size > 20 * 1024 * 1024) {
|
||||
const stat2 = await import("node:fs/promises").then((m) => m.stat(abs));
|
||||
if (stat2.size > 20 * 1024 * 1024) {
|
||||
json(res, 413, { error: "file too large" });
|
||||
return;
|
||||
}
|
||||
@@ -377,14 +599,19 @@ function apply(ctx) {
|
||||
bmp: "image/bmp",
|
||||
ico: "image/x-icon"
|
||||
};
|
||||
res.writeHead(200, { "content-type": FILE_TYPES[ext] ?? "application/octet-stream", "cache-control": "no-store" });
|
||||
res.writeHead(200, {
|
||||
"content-type": FILE_TYPES[ext] ?? "application/octet-stream",
|
||||
"cache-control": "no-store",
|
||||
"x-content-type-options": "nosniff",
|
||||
.../^(html?|svg)$/.test(ext) ? { "content-security-policy": PREVIEW_CSP, "referrer-policy": "no-referrer" } : {}
|
||||
});
|
||||
res.end(data);
|
||||
} catch (err) {
|
||||
json(res, 404, { error: String(err) });
|
||||
}
|
||||
}
|
||||
});
|
||||
webServer.register({
|
||||
register({
|
||||
kind: "prefix",
|
||||
path: TEMPLATE_PREFIX,
|
||||
handler: (req, res) => {
|
||||
@@ -409,7 +636,7 @@ function apply(ctx) {
|
||||
}
|
||||
}
|
||||
});
|
||||
webServer.register({
|
||||
register({
|
||||
kind: "prefix",
|
||||
path: SITE_PREFIX,
|
||||
handler: async (req, res) => {
|
||||
@@ -435,7 +662,7 @@ function apply(ctx) {
|
||||
}
|
||||
const root = pathResolve(rootToken);
|
||||
let abs = pathResolve(root, rel);
|
||||
if (abs !== root && !abs.startsWith(root + sep)) {
|
||||
if (abs !== root && !abs.startsWith(root + sep2)) {
|
||||
json(res, 403, { error: "outside root" });
|
||||
return;
|
||||
}
|
||||
@@ -455,14 +682,20 @@ function apply(ctx) {
|
||||
}
|
||||
const data = await readFile(abs);
|
||||
const ext = (abs.split(".").pop() || "").toLowerCase();
|
||||
res.writeHead(200, { "content-type": FILE_TYPES[ext] ?? "application/octet-stream", "cache-control": "no-store" });
|
||||
res.writeHead(200, {
|
||||
"content-type": FILE_TYPES[ext] ?? "application/octet-stream",
|
||||
"cache-control": "no-store",
|
||||
"content-security-policy": PREVIEW_CSP,
|
||||
"referrer-policy": "no-referrer",
|
||||
"x-content-type-options": "nosniff"
|
||||
});
|
||||
res.end(data);
|
||||
} catch (err) {
|
||||
json(res, 404, { error: String(err) });
|
||||
}
|
||||
}
|
||||
});
|
||||
webServer.register({
|
||||
register({
|
||||
kind: "exact",
|
||||
path: "/api/worktable/fs",
|
||||
handler: async (req, res) => {
|
||||
@@ -471,11 +704,11 @@ function apply(ctx) {
|
||||
const path = typeof body.path === "string" && body.path ? body.path : serverCwd(ctx, body.sessionId, body.cwd);
|
||||
json(res, 200, await listDirectory(path));
|
||||
} catch (err) {
|
||||
json(res, 500, { path: "", entries: [], truncated: false, error: String(err) });
|
||||
json(res, err.status ?? 500, { path: "", entries: [], truncated: false, error: String(err) });
|
||||
}
|
||||
}
|
||||
});
|
||||
webServer.register({
|
||||
register({
|
||||
kind: "exact",
|
||||
path: "/api/worktable/workspaces",
|
||||
handler: async (_req, res) => {
|
||||
@@ -524,7 +757,7 @@ function apply(ctx) {
|
||||
}
|
||||
}
|
||||
});
|
||||
webServer.register({
|
||||
register({
|
||||
kind: "exact",
|
||||
path: "/api/worktable/write",
|
||||
handler: async (req, res) => {
|
||||
@@ -549,11 +782,11 @@ function apply(ctx) {
|
||||
await import("node:fs/promises").then((m) => m.writeFile(abs, content, "utf8"));
|
||||
json(res, 200, { ok: true });
|
||||
} catch (err) {
|
||||
json(res, 500, { error: String(err) });
|
||||
json(res, err.status ?? 500, { error: String(err) });
|
||||
}
|
||||
}
|
||||
});
|
||||
webServer.register({
|
||||
register({
|
||||
kind: "exact",
|
||||
path: "/api/worktable/mkdir",
|
||||
handler: async (req, res) => {
|
||||
@@ -581,17 +814,21 @@ function apply(ctx) {
|
||||
await fsx.mkdir(abs);
|
||||
json(res, 200, { ok: true, path: abs });
|
||||
} catch (err) {
|
||||
json(res, err?.code === "EEXIST" ? 200 : 500, err?.code === "EEXIST" ? { ok: true, exists: true } : { error: String(err) });
|
||||
json(res, err?.code === "EEXIST" ? 200 : err.status ?? 500, err?.code === "EEXIST" ? { ok: true, exists: true } : { error: String(err) });
|
||||
}
|
||||
}
|
||||
});
|
||||
webServer.register({
|
||||
register({
|
||||
kind: "exact",
|
||||
path: "/api/worktable/git",
|
||||
handler: async (req, res) => {
|
||||
const body = await readJsonBody(req);
|
||||
const cwd = serverCwd(ctx, body.sessionId, body.cwd);
|
||||
json(res, 200, await gitStatus(cwd));
|
||||
try {
|
||||
const body = await readJsonBody(req);
|
||||
const cwd = serverCwd(ctx, body.sessionId, body.cwd);
|
||||
json(res, 200, await gitStatus(cwd));
|
||||
} catch (err) {
|
||||
json(res, err.status ?? 500, { error: String(err) });
|
||||
}
|
||||
}
|
||||
});
|
||||
setupTerminal(webServer, ctx);
|
||||
|
||||
Generated
+4
-3
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "dsh-worktable",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dsh-worktable",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=22",
|
||||
"dsh": "0.2.0-rc.2 || 0.2.1-rc.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "dsh-worktable",
|
||||
"description": "工作台 Worktable:DeepSeek Harness 侧边栏的 agent 级项目容器(应用抽屉)",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Aisland-SJL/dsh-worktable.git",
|
||||
"directory": "01_content"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=22",
|
||||
"dsh": "0.2.0-rc.2 || 0.2.1-rc.3"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -39,11 +40,10 @@
|
||||
]
|
||||
},
|
||||
"compatibility": {
|
||||
"dsh": "0.1.1-rc.2 || 0.1.2-rc.1 || 0.2.0-rc.2",
|
||||
"dsh": "0.2.0-rc.2 || 0.2.1-rc.3",
|
||||
"dshReleases": {
|
||||
"0.1.1-rc.2": "compatible",
|
||||
"0.1.2-rc.1": "compatible",
|
||||
"0.2.0-rc.2": "compatible"
|
||||
"0.2.0-rc.2": "compatible",
|
||||
"0.2.1-rc.3": "compatible"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -52,11 +52,13 @@
|
||||
"check": "node --check lib/index.js && node --check lib/client.js",
|
||||
"pack": "node release-prep.mjs",
|
||||
"test:gate": "node client-factory-gate.test.mjs",
|
||||
"test:sidebar": "node ../04_test/sidebar-popup.test.mjs",
|
||||
"test:input": "node ../04_test/host-input.test.mjs",
|
||||
"test:session": "node ../04_test/session-compat.test.mjs",
|
||||
"test:details": "node ../04_test/session-details.test.mjs",
|
||||
"test:transport": "node ../04_test/host-transport.test.mjs",
|
||||
"test:widget": "node ../04_test/widget-mount.test.mjs",
|
||||
"test:security": "node --test ../04_test/request-auth.test.mjs ../04_test/preview-files.test.mjs ../04_test/sandboxed-frame.test.mjs",
|
||||
"verify:remote": "node verify-remote.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -83,7 +83,7 @@ if (!patchOk) process.exit(1)
|
||||
console.log('[release-prep] identity check passed (' + IDENTITY + ', cordis.patch.yml strict)')
|
||||
|
||||
// ---------- 2. 会话/输入兼容回归 + 构建 + 语法检查 ----------
|
||||
const compatTests = ['host-input.test.mjs', 'session-compat.test.mjs', 'session-details.test.mjs', 'host-transport.test.mjs', 'widget-mount.test.mjs']
|
||||
const compatTests = ['host-input.test.mjs', 'session-compat.test.mjs', 'session-details.test.mjs', 'host-transport.test.mjs', 'widget-mount.test.mjs', 'request-auth.test.mjs', 'preview-files.test.mjs', 'sandboxed-frame.test.mjs']
|
||||
.map((file) => join(HERE, '..', '04_test', file))
|
||||
const compatRun = spawnSync(process.execPath, ['--test', ...compatTests], { cwd: HERE, stdio: 'inherit' })
|
||||
if (compatRun.status !== 0) { console.error('[release-prep] FAIL: session/input compatibility tests'); process.exit(1) }
|
||||
@@ -191,7 +191,28 @@ if (anchorRun.status !== 0) {
|
||||
console.error(anchorRun.stderr || anchorRun.stdout)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('[release-prep] split anchor dom tests passed (8 scenarios, on installed artifact)')
|
||||
console.log('[release-prep] split anchor dom tests passed (14 scenarios, on installed artifact)')
|
||||
|
||||
// Sidebar popup regression evaluates the same installed client, including rail/wide branches.
|
||||
const sidebarTest = join(HERE, '..', '04_test', 'sidebar-popup.test.mjs')
|
||||
if (!existsSync(sidebarTest)) { console.error('[release-prep] FAIL: sidebar-popup.test.mjs missing'); process.exit(1) }
|
||||
const sidebarRun = spawnSync(process.execPath, [sidebarTest, join(inst, 'node_modules', 'dsh-worktable', 'lib', 'client.js')], { cwd: HERE, stdio: 'pipe', encoding: 'utf8' })
|
||||
if (sidebarRun.status !== 0) {
|
||||
console.error('[release-prep] FAIL: sidebar-popup.test.mjs')
|
||||
console.error(sidebarRun.stderr || sidebarRun.stdout)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(sidebarRun.stdout.trim())
|
||||
console.log('[release-prep] sidebar popup tests passed (installed artifact)')
|
||||
|
||||
// CSSOM serializes fractional margins; a string-only fake DOM cannot cover this.
|
||||
const marginRun = spawnSync(process.execPath, [join(HERE, '..', '04_test', 'split-margin.test.mjs'), join(inst, 'node_modules', 'dsh-worktable', 'lib', 'client.js')], { cwd: HERE, stdio: 'pipe', encoding: 'utf8' })
|
||||
if (marginRun.status !== 0) {
|
||||
console.error('[release-prep] FAIL: real Chromium split margin regression')
|
||||
console.error(marginRun.stderr || marginRun.stdout)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('[release-prep] real Chromium split margin tests passed (5 scenarios, on installed artifact)')
|
||||
|
||||
// ---------- 5d. 服务端数据目录解析回归(loadPkg ↔ resolveDshHomeSafe 循环调用修复) ----------
|
||||
// 评估「安装目录中的最终产物」lib/index.js;官方路径 / 包不可解析兜底 / ~ 与相对路径展开
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { hostPreviewUrl } from './hostTransport'
|
||||
|
||||
const SANDBOX = 'allow-scripts allow-forms allow-downloads'
|
||||
const SITE = '/api/worktable/site/'
|
||||
|
||||
/** Accept navigations, not executable URL schemes. Keep saved URLs free of capabilities. */
|
||||
export function previewTarget(input: string, base: string) {
|
||||
const url = new URL(input, base)
|
||||
const own = new URL(base)
|
||||
const sameHost = url.protocol === own.protocol && url.host === own.host
|
||||
if (!['http:', 'https:'].includes(url.protocol) && !(sameHost && url.protocol === 'dsh-app:')) throw new Error('Unsupported preview URL')
|
||||
if (!sameHost || !url.pathname.startsWith(SITE)) return { url: url.href }
|
||||
const segments = url.pathname.slice(SITE.length).split('/')
|
||||
const root = decodeURIComponent(segments.shift() ?? '')
|
||||
if (!root) throw new Error('Missing preview directory')
|
||||
const relative = segments.map(decodeURIComponent)
|
||||
if (relative.some(s => s === '..' || /[\\/\0]/.test(s))) throw new Error('Invalid preview path')
|
||||
return { root, relative: relative.map(encodeURIComponent).join('/'), suffix: url.search + url.hash }
|
||||
}
|
||||
|
||||
/** Untrusted project pages never share the host's document origin or storage. */
|
||||
export function SandboxedFrame(props: { url: string; title?: string; reloadKey: number }) {
|
||||
const [src, setSrc] = useState('about:blank')
|
||||
const [error, setError] = useState('')
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
let token: string | undefined
|
||||
const revoke = (value: string) => { void fetch('/api/worktable/preview', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ revoke: value }), keepalive: true,
|
||||
}).catch(() => {}) }
|
||||
setSrc('about:blank')
|
||||
setError('')
|
||||
void (async () => {
|
||||
if (!props.url || props.url === 'about:blank') return
|
||||
const target = previewTarget(props.url, location.href)
|
||||
if ('url' in target) { if (alive) setSrc(target.url!); return }
|
||||
const response = await fetch('/api/worktable/preview', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ root: target.root }),
|
||||
})
|
||||
if (!response.ok) throw new Error('Preview HTTP ' + response.status)
|
||||
const grant = await response.json()
|
||||
if (typeof grant.token !== 'string' || !/^[\w-]{32}$/.test(grant.token) || grant.base !== '/api/worktable/preview-static/' + grant.token + '/') throw new Error('Invalid preview grant')
|
||||
token = grant.token
|
||||
if (!alive) { revoke(token!); token = undefined; return }
|
||||
setSrc(hostPreviewUrl(grant.base + target.relative + target.suffix))
|
||||
})().catch(err => {
|
||||
if (token) { revoke(token); token = undefined }
|
||||
if (alive) setError(String(err))
|
||||
})
|
||||
return () => { alive = false; if (token) revoke(token) }
|
||||
}, [props.url, props.reloadKey])
|
||||
if (error) return <div className="dsh-wt_paneWip"><span className="dsh-wt_paneWipText">{error}</span></div>
|
||||
return <iframe key={props.reloadKey} className="dsh-wt_paneFrame" src={src}
|
||||
data-source-url={props.url} title={props.title ?? ''} sandbox={SANDBOX} referrerPolicy="no-referrer" />
|
||||
}
|
||||
@@ -1,6 +1,28 @@
|
||||
/** 更新公告正文(v0.4.0:双端适配、分组与项目内容隔离;纯文本呈现,发布状态以 GitHub 为准) */
|
||||
export const CHANGELOG_VERSION = 'v0.4.0'
|
||||
export const CHANGELOG_V030 = `更新公告 · v0.4.0
|
||||
/** 更新公告正文;公开发布状态以 GitHub Release 为准。 */
|
||||
export const CHANGELOG_VERSION = 'v0.5.0'
|
||||
export const CHANGELOG_V030 = `更新公告 · v0.5.0
|
||||
|
||||
🛡️ 新版宿主兼容、安全隔离与侧栏修复
|
||||
|
||||
【接口身份校验】文件、目录、写入、Git、工作区接口和终端握手复用宿主身份验证。新版只允许 owner,0.2.0 使用单 operator 身份;不能确认身份时拒绝访问,不静默开放。健康接口仍可读状态,不代表全部功能可用。
|
||||
|
||||
【项目网页隔离】浏览器、动画及自定义 HTML 窗格不再拥有 DSH 主界面的同源权限。本地 HTML 通过临时只读地址加载该目录内的资源;地址不写入项目存档,关闭网页标签、刷新替换或卸载会撤销。切换项目仍保留原有最多 6 个项目保活,不等于立即撤销。网页仍能联网,不应给不可信页面挂载含敏感文件的目录。
|
||||
|
||||
【隔离带来的影响】部分网页的浏览器存储、Cookie 登录、嵌套页面、WebGL 贴图及标注自动取字可能受限,旧网页可能需要项目侧适配。工作台不自动改写项目网页,也不通过取消隔离恢复这些能力。窗口级标注仍可用,必要时补截图。工作台自己的项目、布局与媒体不清空、不迁移。
|
||||
|
||||
【分栏与侧栏】修复小数尺寸经浏览器规范化后误判、反复重排,以及会话根更换后仍观察旧元素的问题。展开和折叠侧栏共用一份弹窗层,控制室的新建入口及绑定、图标、删除弹窗不再因侧栏收起而缺失,避免重复渲染。
|
||||
|
||||
【预览读取】单资源上限 40 MiB,读取期间也执行限制;使用同一文件句柄并复核路径与文件身份,遇到文件替换、变化或挂载撤销则拒绝返回内容。它不是针对恶意本地进程的操作系统级文件沙箱。
|
||||
|
||||
【兼容与验收边界】本版只列 DSH 0.2.0-rc.2 / 0.2.1-rc.3。候选已在 Windows 隔离网页端验证主要页面、文件、网页资源与连接;用户在官方 Windows Desktop 0.2.1-rc.3 验证候选的新项目、项目打开、分栏缩放及侧栏设置弹窗正常。正式包另做安装与回归检查,不将候选页面结果扩大为正式包全功能验收。未验证模型生成全链路、真实审批/子代理及 macOS,不保证未测版本兼容;旧 0.1.x 不在本版支持范围。
|
||||
|
||||
【发布检查】流水线包含源码回归、独立安装、工厂门禁、14 项分栏锚点、侧栏实际组件回归、5 项 Chromium 几何回归与数据目录检查。社区 PR 的修复思路按当前源码吸收,贡献范围与来源见发布说明;采用修法不等于原 PR 已合并。
|
||||
|
||||
【升级提醒】公开发布以 GitHub Release 为准。安装前备份重要数据、核对其他插件兼容性;网页端重启后刷新,桌面端完整退出后通过自带 CLI 安装、再手动打开。若仍加载失败,保留第一条完整错误。这个包不包含宿主模型 HTTP 400 修复。
|
||||
|
||||
───────
|
||||
|
||||
此前版本说明(v0.4.0)
|
||||
|
||||
🖥️ 网页端与 Windows 桌面端适配,修复项目内容串挂
|
||||
|
||||
|
||||
@@ -17,6 +17,24 @@ export function hostWebSocketUrl(
|
||||
return url.href
|
||||
}
|
||||
|
||||
/** Desktop preview assets use the Host HTTP origin with a read-only capability. */
|
||||
export function hostPreviewUrl(
|
||||
path: string,
|
||||
surface: { protocol: string; streamBaseUrl?: string } = {
|
||||
protocol: location.protocol,
|
||||
streamBaseUrl: (globalThis as any).__DSH_TRANSPORT__?.streamBaseUrl,
|
||||
},
|
||||
): string {
|
||||
if (!path.startsWith('/api/worktable/preview-static/')) throw new Error('Invalid preview path')
|
||||
if (surface.protocol !== 'dsh-app:') return path
|
||||
if (!surface.streamBaseUrl) throw new Error('Desktop Host preview URL unavailable')
|
||||
const url = new URL(path, surface.streamBaseUrl)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('Unsupported Host preview protocol')
|
||||
// Opaque sandbox requests cannot pass Desktop's dsh-app Origin fence. This
|
||||
// read-only capability needs no Host cookie, so its assets use the Host origin.
|
||||
return url.href
|
||||
}
|
||||
|
||||
/** Keep update instructions on the active profile; Desktop must use its bundled CLI. */
|
||||
export function worktableUpgrade(protocol = typeof location === 'undefined' ? 'http:' : location.protocol) {
|
||||
const desktop = protocol === 'dsh-app:'
|
||||
|
||||
+131
-122
@@ -2516,128 +2516,8 @@ function buildCustomLayoutPrompt(req: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
if (!wide) {
|
||||
// 收起态 = 等行高的正方形圆角按钮,中间只留 emoji;点击 = 进入对应项目
|
||||
const railItems: { icon: string; name: string; onClick: (e: any) => void }[] = [
|
||||
{ icon: CONSOLE_ICON, name: t('console.name'), onClick: (e) => clickConsoleCard(e.currentTarget as HTMLElement) },
|
||||
...aliveRegisteredIds.map((id) => ({
|
||||
icon: projects.iconOverrides[id] ?? metas[id]?.icon ?? '📦',
|
||||
name: projects.nameOverrides[id] ?? metas[id]?.name ?? id,
|
||||
onClick: () => openRailProject(id),
|
||||
})),
|
||||
...projects.shortcuts.map((s) => ({
|
||||
icon: s.icon,
|
||||
name: s.name,
|
||||
onClick: () => { try { window.open(s.href, '_blank', 'noopener') } catch {} },
|
||||
})),
|
||||
...projects.layouts.map((l) => ({
|
||||
icon: l.icon ?? '🧱',
|
||||
name: projects.nameOverrides[l.id] ?? l.title,
|
||||
onClick: () => openSplit(l),
|
||||
})),
|
||||
]
|
||||
// 浮动态:fixed 定位到拖前高度(左/宽取折叠列实测几何);停靠态:文档流原位
|
||||
const railStyle = isFloat && railRect
|
||||
? { position: 'fixed' as const, top: float.top, left: railRect.left, width: railRect.width, zIndex: 70 }
|
||||
: bottomInset > 0 ? { marginBottom: bottomInset } : undefined
|
||||
return (
|
||||
<div ref={rootRef} className="dsh-wt_section dsh-wt_rail" style={railStyle}>
|
||||
<div className="dsh-wt_divider" />
|
||||
<div className="dsh-wt_railBox">
|
||||
{railItems.length > 0
|
||||
? railItems.map((it, i) => (
|
||||
<button key={i} type="button" className="dsh-wt_railBtn" title={it.name} aria-label={it.name} onClick={it.onClick}>
|
||||
<span aria-hidden>{it.icon}</span>
|
||||
</button>
|
||||
))
|
||||
: <span className="dsh-wt_railIcon">≡</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={'dsh-wt_section' + (isFloat ? ' dsh-wt_float' : '')} style={isFloat ? floatStyle : dockedStyle}>
|
||||
<div className="dsh-wt_divider" />
|
||||
<div className="dsh-wt_header">
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_handle"
|
||||
data-float={isFloat ? 'true' : 'false'}
|
||||
title={t('handle.title')}
|
||||
aria-label={t('handle.aria')}
|
||||
onPointerDown={onHandlePointerDown}
|
||||
onPointerMove={onHandlePointerMove}
|
||||
onPointerUp={onHandlePointerUp}
|
||||
onPointerCancel={onHandlePointerUp}
|
||||
onDoubleClick={resetDock}
|
||||
>≡</button>
|
||||
<span
|
||||
className="dsh-wt_title"
|
||||
title={t('handle.title')}
|
||||
onPointerDown={onHandlePointerDown}
|
||||
onPointerMove={onHandlePointerMove}
|
||||
onPointerUp={onHandlePointerUp}
|
||||
onPointerCancel={onHandlePointerUp}
|
||||
onDoubleClick={resetDock}
|
||||
>{t('title')}</span>
|
||||
{updateInfo && (
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_updateBadge"
|
||||
title={t('update.badgeTitle') + ' · v' + updateInfo.latest}
|
||||
aria-label={t('update.badgeTitle')}
|
||||
onClick={() => setViewOptionsOpen(true)}
|
||||
>{ICON_SYNC}</button>
|
||||
)}
|
||||
<div className="dsh-wt_actions">
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_iconBtn"
|
||||
aria-label={t('menu.search')}
|
||||
title={t('menu.search')}
|
||||
onClick={() => persistView({ searchOpen: !view.searchOpen, query: view.searchOpen ? '' : view.query })}
|
||||
>{ICON_SEARCH}</button>
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_iconBtn"
|
||||
aria-label={t('menu.viewOptions')}
|
||||
title={t('menu.viewOptions')}
|
||||
onClick={() => { invalidatePickState(); setViewOptionsOpen((v) => !v); setAddOpen(false) }}
|
||||
>{ICON_VIEW_OPTIONS}</button>
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_iconBtn"
|
||||
aria-label={t('menu.add')}
|
||||
title={t('menu.add')}
|
||||
onClick={() => {
|
||||
invalidatePickState()
|
||||
setAddOpen((v) => !v); setViewOptionsOpen(false)
|
||||
// 父目录默认 = 当前会话工作目录(不落 C 盘默认位置)
|
||||
if (!wsFolderParent) {
|
||||
const cwd = sessionScopeStore.snapshot?.cwd ?? ''
|
||||
if (cwd) setWsFolderParent(cwd)
|
||||
}
|
||||
}}
|
||||
>{ICON_ADD}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view.searchOpen && (
|
||||
<div className="dsh-wt_search">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder={t('search.placeholder')}
|
||||
value={view.query}
|
||||
onChange={(e) => persistView({ query: e.target.value })}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
/>
|
||||
<button type="button" className="dsh-wt_searchClear" aria-label={t('search.close')}
|
||||
onClick={() => persistView({ searchOpen: false, query: '' })}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
const popupLayer = (
|
||||
<>
|
||||
{addOpen && <div className="dsh-wt_popBackdrop" onClick={() => { invalidatePickState(); setAddOpen(false) }} />}
|
||||
{addOpen && (
|
||||
<div className="dsh-wt_menu dsh-wt_add dsh-wt_pop" style={{ position: 'fixed', left: popLeft, top: popTop, width: 360, zIndex: 80 }}>
|
||||
@@ -3064,6 +2944,135 @@ function buildCustomLayoutPrompt(req: string): string {
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
)
|
||||
|
||||
if (!wide) {
|
||||
// 收起态 = 等行高的正方形圆角按钮,中间只留 emoji;点击 = 进入对应项目
|
||||
const railItems: { icon: string; name: string; onClick: (e: any) => void }[] = [
|
||||
{ icon: CONSOLE_ICON, name: t('console.name'), onClick: (e) => clickConsoleCard(e.currentTarget as HTMLElement) },
|
||||
...aliveRegisteredIds.map((id) => ({
|
||||
icon: projects.iconOverrides[id] ?? metas[id]?.icon ?? '📦',
|
||||
name: projects.nameOverrides[id] ?? metas[id]?.name ?? id,
|
||||
onClick: () => openRailProject(id),
|
||||
})),
|
||||
...projects.shortcuts.map((s) => ({
|
||||
icon: s.icon,
|
||||
name: s.name,
|
||||
onClick: () => { try { window.open(s.href, '_blank', 'noopener') } catch {} },
|
||||
})),
|
||||
...projects.layouts.map((l) => ({
|
||||
icon: l.icon ?? '🧱',
|
||||
name: projects.nameOverrides[l.id] ?? l.title,
|
||||
onClick: () => openSplit(l),
|
||||
})),
|
||||
]
|
||||
// 浮动态:fixed 定位到拖前高度(左/宽取折叠列实测几何);停靠态:文档流原位
|
||||
const railStyle = isFloat && railRect
|
||||
? { position: 'fixed' as const, top: float.top, left: railRect.left, width: railRect.width, zIndex: 70 }
|
||||
: bottomInset > 0 ? { marginBottom: bottomInset } : undefined
|
||||
return (
|
||||
<>
|
||||
<div ref={rootRef} className="dsh-wt_section dsh-wt_rail" style={railStyle}>
|
||||
<div className="dsh-wt_divider" />
|
||||
<div className="dsh-wt_railBox">
|
||||
{railItems.length > 0
|
||||
? railItems.map((it, i) => (
|
||||
<button key={i} type="button" className="dsh-wt_railBtn" title={it.name} aria-label={it.name} onClick={it.onClick}>
|
||||
<span aria-hidden>{it.icon}</span>
|
||||
</button>
|
||||
))
|
||||
: <span className="dsh-wt_railIcon">≡</span>}
|
||||
</div>
|
||||
</div>
|
||||
{popupLayer}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={'dsh-wt_section' + (isFloat ? ' dsh-wt_float' : '')} style={isFloat ? floatStyle : dockedStyle}>
|
||||
<div className="dsh-wt_divider" />
|
||||
<div className="dsh-wt_header">
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_handle"
|
||||
data-float={isFloat ? 'true' : 'false'}
|
||||
title={t('handle.title')}
|
||||
aria-label={t('handle.aria')}
|
||||
onPointerDown={onHandlePointerDown}
|
||||
onPointerMove={onHandlePointerMove}
|
||||
onPointerUp={onHandlePointerUp}
|
||||
onPointerCancel={onHandlePointerUp}
|
||||
onDoubleClick={resetDock}
|
||||
>≡</button>
|
||||
<span
|
||||
className="dsh-wt_title"
|
||||
title={t('handle.title')}
|
||||
onPointerDown={onHandlePointerDown}
|
||||
onPointerMove={onHandlePointerMove}
|
||||
onPointerUp={onHandlePointerUp}
|
||||
onPointerCancel={onHandlePointerUp}
|
||||
onDoubleClick={resetDock}
|
||||
>{t('title')}</span>
|
||||
{updateInfo && (
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_updateBadge"
|
||||
title={t('update.badgeTitle') + ' · v' + updateInfo.latest}
|
||||
aria-label={t('update.badgeTitle')}
|
||||
onClick={() => setViewOptionsOpen(true)}
|
||||
>{ICON_SYNC}</button>
|
||||
)}
|
||||
<div className="dsh-wt_actions">
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_iconBtn"
|
||||
aria-label={t('menu.search')}
|
||||
title={t('menu.search')}
|
||||
onClick={() => persistView({ searchOpen: !view.searchOpen, query: view.searchOpen ? '' : view.query })}
|
||||
>{ICON_SEARCH}</button>
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_iconBtn"
|
||||
aria-label={t('menu.viewOptions')}
|
||||
title={t('menu.viewOptions')}
|
||||
onClick={() => { invalidatePickState(); setViewOptionsOpen((v) => !v); setAddOpen(false) }}
|
||||
>{ICON_VIEW_OPTIONS}</button>
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_iconBtn"
|
||||
aria-label={t('menu.add')}
|
||||
title={t('menu.add')}
|
||||
onClick={() => {
|
||||
invalidatePickState()
|
||||
setAddOpen((v) => !v); setViewOptionsOpen(false)
|
||||
// 父目录默认 = 当前会话工作目录(不落 C 盘默认位置)
|
||||
if (!wsFolderParent) {
|
||||
const cwd = sessionScopeStore.snapshot?.cwd ?? ''
|
||||
if (cwd) setWsFolderParent(cwd)
|
||||
}
|
||||
}}
|
||||
>{ICON_ADD}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view.searchOpen && (
|
||||
<div className="dsh-wt_search">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder={t('search.placeholder')}
|
||||
value={view.query}
|
||||
onChange={(e) => persistView({ query: e.target.value })}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
/>
|
||||
<button type="button" className="dsh-wt_searchClear" aria-label={t('search.close')}
|
||||
onClick={() => persistView({ searchOpen: false, query: '' })}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{popupLayer}
|
||||
<div className="dsh-wt_projects" data-managing={viewOptionsOpen ? 'true' : undefined}>
|
||||
{/* 「工作台」控制室项目:固定首位、不可删除;未绑定点开 = 强制绑定弹窗 */}
|
||||
<button
|
||||
|
||||
@@ -96,7 +96,7 @@ export const zh = {
|
||||
'custom.groupNew': '+ 新建一个分组…',
|
||||
'custom.groupNewParent': '分组父目录',
|
||||
'custom.groupNewName': '新分组名',
|
||||
'custom.groupNewParentPh': '例如 E:\\AI_Workspace\\DeepseekHarness',
|
||||
'custom.groupNewParentPh': '例如 C:\\Projects',
|
||||
'custom.groupNewNamePh': '例如 MyProject(将新建此文件夹并注册为分组)',
|
||||
'custom.groupNeedPath': '请填写分组父目录和新分组名',
|
||||
'bind.title': '绑定对话',
|
||||
@@ -174,7 +174,7 @@ export const zh = {
|
||||
'console.groupNew': '新建分组',
|
||||
'console.chooseGroup': '选择分组…',
|
||||
'console.blankNeedsGroup': '当前 DSH 的未分组空对话无法输入,请选择分组或加入现有对话。',
|
||||
'console.newParentPh': '分组父目录,例如 E:\\AI_Workspace',
|
||||
'console.newParentPh': '分组父目录,例如 C:\\Projects',
|
||||
'console.newNamePh': '分组文件夹名(将创建并注册)',
|
||||
'console.createBind': '新建并绑定',
|
||||
'console.bindFail': '创建失败,请重试',
|
||||
@@ -334,7 +334,7 @@ export const en = {
|
||||
'custom.groupNew': '+ Create a new group…',
|
||||
'custom.groupNewParent': 'Group parent folder',
|
||||
'custom.groupNewName': 'New group name',
|
||||
'custom.groupNewParentPh': 'e.g. E:\\AI_Workspace\\DeepseekHarness',
|
||||
'custom.groupNewParentPh': 'e.g. C:\\Projects',
|
||||
'custom.groupNewNamePh': 'e.g. MyProject (folder will be created and registered)',
|
||||
'custom.groupNeedPath': 'Fill in the parent folder and new group name',
|
||||
'bind.title': 'Bind conversation',
|
||||
@@ -412,7 +412,7 @@ export const en = {
|
||||
'console.groupNew': 'New group',
|
||||
'console.chooseGroup': 'Choose a group…',
|
||||
'console.blankNeedsGroup': 'Blank ungrouped chats cannot accept input in this DSH. Choose a group or join an existing chat.',
|
||||
'console.newParentPh': 'Group parent folder, e.g. E:\\AI_Workspace',
|
||||
'console.newParentPh': 'Group parent folder, e.g. C:\\Projects',
|
||||
'console.newNamePh': 'Group folder name (created & registered)',
|
||||
'console.createBind': 'Create & bind',
|
||||
'console.bindFail': 'Creation failed, try again',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
|
||||
import { parentPathOf } from './pathutil'
|
||||
import { hostWebSocketUrl, worktableUpgrade } from './hostTransport'
|
||||
import { SandboxedFrame } from './SandboxedFrame'
|
||||
import { CHANGELOG_V030 } from './changelog'
|
||||
import { LOCAL_VERSION, checkUpdate, getAutoCheck, readCache, setAutoCheck as storeAutoCheck, setSkipVersion, type UpdateInfo, type UpdateStatus } from './updateCheck'
|
||||
import { Terminal } from 'xterm'
|
||||
@@ -476,7 +477,7 @@ function boxPayload(x0: number, y0: number, x1: number, y1: number): { primary:
|
||||
const r = iframe.getBoundingClientRect()
|
||||
if (r.width <= 0 || r.height <= 0) continue
|
||||
if (cx < r.left || cx > r.right || cy < r.top || cy > r.bottom) continue
|
||||
src = iframe.getAttribute('src') ?? ''
|
||||
src = iframe.getAttribute('data-source-url') ?? iframe.getAttribute('src') ?? ''
|
||||
let inner: Document | null = null
|
||||
try { inner = iframe.contentDocument } catch { inner = null }
|
||||
if (inner) readDoc(inner, r.left, r.top)
|
||||
@@ -939,6 +940,8 @@ export const splitStore: SplitState = {
|
||||
this.savedMarginLeft = viewArea.style.marginLeft
|
||||
this.savedMarginRight = viewArea.style.marginRight
|
||||
this.savedMarginTop = viewArea.style.marginTop
|
||||
this.yieldObserver?.disconnect()
|
||||
this.yieldObserver?.observe(viewArea, { attributes: true, attributeFilter: ['style'] })
|
||||
}
|
||||
this.observer?.disconnect()
|
||||
this.observer.observe(next)
|
||||
@@ -988,6 +991,9 @@ export const splitStore: SplitState = {
|
||||
viewArea.style.marginLeft = this.lastMarginLeft
|
||||
viewArea.style.marginRight = this.lastMarginRight
|
||||
viewArea.style.marginTop = mt
|
||||
this.lastMarginLeft = viewArea.style.marginLeft
|
||||
this.lastMarginRight = viewArea.style.marginRight
|
||||
this.lastMarginTop = viewArea.style.marginTop
|
||||
},
|
||||
|
||||
setChatW(w) {
|
||||
@@ -1429,14 +1435,14 @@ function BrowserPane(props: { row: PaneRow; index: number; tabId: string; conten
|
||||
/>
|
||||
<button type="button" className="dsh-wt_browserGo" onClick={go}>↗</button>
|
||||
</div>
|
||||
<iframe key={props.reloadKey} className="dsh-wt_paneFrame" src={src} title="browser" />
|
||||
<SandboxedFrame url={src} title="browser" reloadKey={props.reloadKey} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** iframe 内容标签(网页/站点产物):刷新统一在标签栏最左(重挂载整页刷新,跨域可靠) */
|
||||
function IframePane(props: { url: string; title?: string; reloadKey: number }) {
|
||||
return <iframe key={props.reloadKey} className="dsh-wt_paneFrame" src={props.url} title={props.title ?? ''} />
|
||||
return <SandboxedFrame {...props} />
|
||||
}
|
||||
|
||||
/** 动画播放窗:iframe 壳 + 地址栏(站内自带项目/场景列表、播放、画幅切换、导出等全部控件) */
|
||||
@@ -1464,7 +1470,7 @@ function AnimPane(props: { row: PaneRow; index: number; tabId: string; content:
|
||||
/>
|
||||
<button type="button" className="dsh-wt_browserGo" onClick={go}>↗</button>
|
||||
</div>
|
||||
<iframe key={props.reloadKey} className="dsh-wt_paneFrame" src={src} title="anim" />
|
||||
<SandboxedFrame url={src} title="anim" reloadKey={props.reloadKey} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+101
-21
@@ -6,6 +6,8 @@ import { dirname, resolve as pathResolve, sep } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { homedir } from 'node:os'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { requestRejection, rejectionText } from './requestAuth'
|
||||
import { createPreviewFiles, PREVIEW_PREFIX, PREVIEW_CSP } from './previewFiles'
|
||||
|
||||
/**
|
||||
* 基础数据目录解析:不加载任何官方包(loadPkg 的兜底只能用它,禁止反向调用包加载函数——否则成环)。
|
||||
@@ -149,7 +151,13 @@ function json(res: any, status: number, body: unknown) {
|
||||
|
||||
async function readJsonBody(req: any): Promise<any> {
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of req) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
|
||||
let bytes = 0
|
||||
for await (const chunk of req) {
|
||||
const data = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
|
||||
bytes += data.length
|
||||
if (bytes > 22 * 1024 * 1024) throw Object.assign(new Error('request too large'), { status: 413 })
|
||||
chunks.push(data)
|
||||
}
|
||||
const text = Buffer.concat(chunks).toString('utf8')
|
||||
if (!text) return {}
|
||||
try { return JSON.parse(text) } catch { return {} }
|
||||
@@ -207,6 +215,15 @@ function setupTerminal(webServer: any, ctx: any) {
|
||||
if (!WebSocketServer) return
|
||||
const pty = ptyMod.default ?? ptyMod
|
||||
const wss = new WebSocketServer({ noServer: true })
|
||||
let disposed = false
|
||||
const terminals = new Set<any>()
|
||||
ctx.effect(() => () => {
|
||||
disposed = true
|
||||
for (const ws of wss.clients ?? []) { try { ws.terminate() } catch {} }
|
||||
for (const term of terminals) { try { term.kill() } catch {} }
|
||||
terminals.clear()
|
||||
try { wss.close() } catch {}
|
||||
}, 'dsh-worktable: terminal sessions')
|
||||
const spawnShell = (): { cmd: string; args: string[] } =>
|
||||
process.platform === 'win32'
|
||||
? { cmd: 'powershell.exe', args: ['-NoLogo', '-NoProfile'] } // -NoProfile:跳过用户配置(oh-my-posh 花哨提示符在 xterm 里是乱码,PSReadLine 长输入行不换行被截断)
|
||||
@@ -215,7 +232,13 @@ function setupTerminal(webServer: any, ctx: any) {
|
||||
|
||||
ctx.effect(() => webServer.registerUpgrade({
|
||||
path: '/api/worktable/term',
|
||||
handler: (req: any, socket: any, head: any) => {
|
||||
handler: async (req: any, socket: any, head: any) => {
|
||||
let rejected = await requestRejection(ctx, req)
|
||||
if (disposed) rejected = 503
|
||||
if (rejected !== undefined) {
|
||||
socket.end(`HTTP/1.1 ${rejected} ${rejectionText(rejected)}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
|
||||
return
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws: any) => {
|
||||
const u = new URL(req.url ?? '/', 'http://dsh.internal')
|
||||
const cwd = serverCwd(ctx, u.searchParams.get('sessionId') || undefined, u.searchParams.get('cwd') || undefined)
|
||||
@@ -225,13 +248,14 @@ function setupTerminal(webServer: any, ctx: any) {
|
||||
try {
|
||||
const shell = spawnShell()
|
||||
term = pty.spawn(shell.cmd, shell.args, { name: 'xterm-256color', cols, rows, cwd, env: process.env })
|
||||
terminals.add(term)
|
||||
} catch (err) {
|
||||
try { ws.send('\r\n[worktable] 终端启动失败:' + String(err)) } catch {}
|
||||
try { ws.close() } catch {}
|
||||
return
|
||||
}
|
||||
term.onData((d: string) => { try { ws.send(d) } catch {} })
|
||||
term.onExit(() => { try { ws.close() } catch {} })
|
||||
term.onExit(() => { terminals.delete(term); try { ws.close() } catch {} })
|
||||
ws.on('message', (raw: any) => {
|
||||
const text = String(raw)
|
||||
try {
|
||||
@@ -243,7 +267,7 @@ function setupTerminal(webServer: any, ctx: any) {
|
||||
} catch {}
|
||||
try { term.write(text) } catch {}
|
||||
})
|
||||
ws.on('close', () => { try { term.kill() } catch {} })
|
||||
ws.on('close', () => { terminals.delete(term); try { term.kill() } catch {} })
|
||||
})
|
||||
},
|
||||
}), 'dsh-worktable: terminal upgrade')
|
||||
@@ -256,7 +280,57 @@ export function apply(ctx: Context) {
|
||||
return
|
||||
}
|
||||
|
||||
webServer.register({
|
||||
const previews = createPreviewFiles()
|
||||
ctx.effect(() => () => previews.clear(), 'dsh-worktable: preview capabilities')
|
||||
// Direct WebServer routes do NOT inherit the host's /api authentication route.
|
||||
const register = (route: any, publicAsset = false) => ctx.effect(() => webServer.register({
|
||||
...route,
|
||||
handler: async (req: any, res: any) => {
|
||||
if (!publicAsset && route.path !== HEALTH_PATH) {
|
||||
const rejected = await requestRejection(ctx, req)
|
||||
if (rejected !== undefined) { json(res, rejected, { error: rejectionText(rejected) }); return }
|
||||
}
|
||||
return route.handler(req, res)
|
||||
},
|
||||
}), 'dsh-worktable: ' + route.path)
|
||||
|
||||
register({
|
||||
kind: 'exact', path: '/api/worktable/preview',
|
||||
handler: async (req: any, res: any) => {
|
||||
if (req.method !== 'POST') { json(res, 405, { error: 'method not allowed' }); return }
|
||||
try {
|
||||
const body = await readJsonBody(req)
|
||||
if (typeof body.revoke === 'string') { previews.revoke(body.revoke); json(res, 200, { ok: true }); return }
|
||||
if (typeof body.root !== 'string') { json(res, 400, { error: 'missing root' }); return }
|
||||
const token = await previews.mount(body.root)
|
||||
json(res, 200, { token, base: PREVIEW_PREFIX + '/' + token + '/' })
|
||||
} catch (err: any) { json(res, err.status ?? 400, { error: String(err) }) }
|
||||
},
|
||||
})
|
||||
|
||||
register({
|
||||
kind: 'prefix', path: PREVIEW_PREFIX,
|
||||
handler: async (req: any, res: any) => {
|
||||
// A sandboxed document has an opaque origin. It can read only this capability's
|
||||
// static directory, without credentials; it cannot pass the host API admission.
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') { json(res, 405, { error: 'method not allowed' }); return }
|
||||
try {
|
||||
const parts = new URL(req.url ?? '/', 'http://dsh.internal').pathname.slice(PREVIEW_PREFIX.length + 1).split('/').map(decodeURIComponent)
|
||||
const file = await previews.file(parts.shift() ?? '', parts.filter(Boolean), req.method === 'HEAD')
|
||||
if (!file) { json(res, 404, { error: 'preview unavailable; refresh to reopen' }); return }
|
||||
const ext = (file.path.split('.').pop() || '').toLowerCase()
|
||||
res.writeHead(200, {
|
||||
'content-type': FILE_TYPES[ext] ?? 'application/octet-stream',
|
||||
'cache-control': 'no-store', 'referrer-policy': 'no-referrer',
|
||||
'content-security-policy': PREVIEW_CSP, 'x-content-type-options': 'nosniff',
|
||||
'access-control-allow-origin': '*',
|
||||
})
|
||||
res.end(file.data)
|
||||
} catch (err: any) { json(res, err?.status === 413 ? 413 : 404, { error: err?.status === 413 ? 'file too large' : 'preview unavailable' }) }
|
||||
},
|
||||
}, true)
|
||||
|
||||
register({
|
||||
kind: 'exact',
|
||||
path: HEALTH_PATH,
|
||||
handler: (_req: any, res: any) => {
|
||||
@@ -265,7 +339,7 @@ export function apply(ctx: Context) {
|
||||
})
|
||||
|
||||
// 本地文件读取(资源管理器点击 .html 后浏览器标签内打开)
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/file',
|
||||
handler: async (req: any, res: any) => {
|
||||
@@ -284,7 +358,10 @@ export function apply(ctx: Context) {
|
||||
json: 'application/json; charset=utf-8', md: 'text/markdown; charset=utf-8', markdown: 'text/markdown; charset=utf-8', txt: 'text/plain; charset=utf-8', log: 'text/plain; charset=utf-8',
|
||||
pdf: 'application/pdf', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', ico: 'image/x-icon',
|
||||
}
|
||||
res.writeHead(200, { 'content-type': FILE_TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'no-store' })
|
||||
res.writeHead(200, { 'content-type': FILE_TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'no-store',
|
||||
'x-content-type-options': 'nosniff',
|
||||
...(/^(html?|svg)$/.test(ext) ? { 'content-security-policy': PREVIEW_CSP, 'referrer-policy': 'no-referrer' } : {}),
|
||||
})
|
||||
res.end(data)
|
||||
} catch (err) {
|
||||
json(res, 404, { error: String(err) })
|
||||
@@ -295,7 +372,7 @@ export function apply(ctx: Context) {
|
||||
// 本地站点(目录级静态托管):点开 index.html 时挂载整个所在目录,
|
||||
// 让 ./assets/... 等相对引用正常解析(前缀路由,余下路径 = <rootToken>/<相对路径>)。
|
||||
// 原生皮肤模板:HTML 骨架 + 设计系统样式表(随插件分发,主题自动适配)
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'prefix',
|
||||
path: TEMPLATE_PREFIX,
|
||||
handler: (req: any, res: any) => {
|
||||
@@ -316,7 +393,7 @@ export function apply(ctx: Context) {
|
||||
},
|
||||
})
|
||||
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'prefix',
|
||||
path: SITE_PREFIX,
|
||||
handler: async (req: any, res: any) => {
|
||||
@@ -340,7 +417,8 @@ export function apply(ctx: Context) {
|
||||
if (info.size > 40 * 1024 * 1024) { json(res, 413, { error: 'file too large' }); return }
|
||||
const data = await readFile(abs)
|
||||
const ext = (abs.split('.').pop() || '').toLowerCase()
|
||||
res.writeHead(200, { 'content-type': FILE_TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'no-store' })
|
||||
res.writeHead(200, { 'content-type': FILE_TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'no-store',
|
||||
'content-security-policy': PREVIEW_CSP, 'referrer-policy': 'no-referrer', 'x-content-type-options': 'nosniff' })
|
||||
res.end(data)
|
||||
} catch (err) {
|
||||
json(res, 404, { error: String(err) })
|
||||
@@ -348,7 +426,7 @@ export function apply(ctx: Context) {
|
||||
},
|
||||
})
|
||||
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/fs',
|
||||
handler: async (req: any, res: any) => {
|
||||
@@ -359,7 +437,7 @@ export function apply(ctx: Context) {
|
||||
: serverCwd(ctx, body.sessionId, body.cwd)
|
||||
json(res, 200, await listDirectory(path))
|
||||
} catch (err) {
|
||||
json(res, 500, { path: '', entries: [], truncated: false, error: String(err) })
|
||||
json(res, (err as any).status ?? 500, { path: '', entries: [], truncated: false, error: String(err) })
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -368,7 +446,7 @@ export function apply(ctx: Context) {
|
||||
// 优先走宿主正式服务 ctx.workspaceRegistry(0.1.1/0.1.2 均有,正确感知 DSH_HOME 与存储后端);
|
||||
// 不可用时回退按 resolveDshHomeSafe() 读 storages/workspace.json(只读)。
|
||||
// 返回结构是客户端契约,两种来源都映射成同一 shape。
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/workspaces',
|
||||
handler: async (_req: any, res: any) => {
|
||||
@@ -412,7 +490,7 @@ export function apply(ctx: Context) {
|
||||
})
|
||||
|
||||
// 本地文件写入(MD 编辑模式保存回磁盘)
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/write',
|
||||
handler: async (req: any, res: any) => {
|
||||
@@ -427,13 +505,13 @@ export function apply(ctx: Context) {
|
||||
await import('node:fs/promises').then((m) => m.writeFile(abs, content, 'utf8'))
|
||||
json(res, 200, { ok: true })
|
||||
} catch (err) {
|
||||
json(res, 500, { error: String(err) })
|
||||
json(res, (err as any).status ?? 500, { error: String(err) })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// 新建分组:创建目录(仅当父目录已存在,避免递归误建深层垃圾目录)
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/mkdir',
|
||||
handler: async (req: any, res: any) => {
|
||||
@@ -449,18 +527,20 @@ export function apply(ctx: Context) {
|
||||
await fsx.mkdir(abs)
|
||||
json(res, 200, { ok: true, path: abs })
|
||||
} catch (err: any) {
|
||||
json(res, err?.code === 'EEXIST' ? 200 : 500, err?.code === 'EEXIST' ? { ok: true, exists: true } : { error: String(err) })
|
||||
json(res, err?.code === 'EEXIST' ? 200 : err.status ?? 500, err?.code === 'EEXIST' ? { ok: true, exists: true } : { error: String(err) })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/git',
|
||||
handler: async (req: any, res: any) => {
|
||||
const body = await readJsonBody(req)
|
||||
const cwd = serverCwd(ctx, body.sessionId, body.cwd)
|
||||
json(res, 200, await gitStatus(cwd))
|
||||
try {
|
||||
const body = await readJsonBody(req)
|
||||
const cwd = serverCwd(ctx, body.sessionId, body.cwd)
|
||||
json(res, 200, await gitStatus(cwd))
|
||||
} catch (err: any) { json(res, err.status ?? 500, { error: String(err) }) }
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { open, realpath, stat } from 'node:fs/promises'
|
||||
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
|
||||
export const PREVIEW_PREFIX = '/api/worktable/preview-static'
|
||||
export const PREVIEW_SANDBOX = 'allow-scripts allow-forms allow-downloads'
|
||||
export const PREVIEW_CSP = `sandbox ${PREVIEW_SANDBOX}; base-uri 'none'; object-src 'none'; frame-src 'none'`
|
||||
export const PREVIEW_MAX_BYTES = 40 * 1024 * 1024
|
||||
const IDLE_MS = 30 * 60 * 1000
|
||||
const MAX_MOUNTS = 128
|
||||
|
||||
function inside(root: string, file: string): boolean {
|
||||
const rel = relative(root, file)
|
||||
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('..' + sep))
|
||||
}
|
||||
|
||||
/** Short-lived read-only capabilities. They grant one directory, never host APIs. */
|
||||
export function createPreviewFiles(now = () => Date.now()) {
|
||||
const mounts = new Map<string, { root: string; touched: number }>()
|
||||
const prune = () => { for (const [key, item] of mounts) if (now() - item.touched >= IDLE_MS) mounts.delete(key) }
|
||||
return {
|
||||
async mount(root: string) {
|
||||
if (!isAbsolute(root)) throw new Error('Preview root must be absolute')
|
||||
const canonical = await realpath(root)
|
||||
if (!(await stat(canonical)).isDirectory()) throw new Error('Preview root is not a directory')
|
||||
prune()
|
||||
if (mounts.size >= MAX_MOUNTS) throw new Error('Too many open previews; close an unused preview first')
|
||||
const token = randomBytes(24).toString('base64url')
|
||||
mounts.set(token, { root: canonical, touched: now() })
|
||||
return token
|
||||
},
|
||||
revoke(token: string) { mounts.delete(token) },
|
||||
clear() { mounts.clear() },
|
||||
async file(token: string, segments: string[], headOnly = false) {
|
||||
prune()
|
||||
const mount = mounts.get(token)
|
||||
if (!mount) return null
|
||||
// Decode exactly once before this call; forbid separators embedded in a segment.
|
||||
if (segments.some(s => s === '..' || s.includes('/') || s.includes('\\') || s.includes('\0'))) return null
|
||||
let target = resolve(mount.root, ...segments)
|
||||
if (!inside(mount.root, target)) return null
|
||||
let info = await stat(target).catch(() => null)
|
||||
if (info?.isDirectory()) target = resolve(target, 'index.html')
|
||||
const canonical = await realpath(target).catch(() => null)
|
||||
if (!canonical || !inside(mount.root, canonical)) return null
|
||||
// Keep the read bound to one handle. Recheck path/identity around the read
|
||||
// to reject ordinary replacement races; this is not an OS filesystem jail
|
||||
// against an adversarial local process coordinating ABA path swaps.
|
||||
const handle = await open(canonical, 'r').catch(() => null)
|
||||
if (!handle) return null
|
||||
try {
|
||||
const before = await handle.stat({ bigint: true })
|
||||
if (!before.isFile()) return null
|
||||
const sameFile = (other: typeof before) => other.isFile()
|
||||
&& other.dev === before.dev && other.ino === before.ino
|
||||
&& other.size === before.size && other.mtimeNs === before.mtimeNs
|
||||
&& other.ctimeNs === before.ctimeNs
|
||||
const stillValid = async () => {
|
||||
const current = await realpath(canonical)
|
||||
if (current !== canonical || !inside(mount.root, current)) return false
|
||||
const named = await stat(current, { bigint: true })
|
||||
const opened = await handle.stat({ bigint: true })
|
||||
return sameFile(named) && sameFile(opened)
|
||||
&& mounts.get(token) === mount && now() - mount.touched < IDLE_MS
|
||||
}
|
||||
const tooLarge = () => Object.assign(new Error('file too large'), { status: 413 })
|
||||
if (before.size > BigInt(PREVIEW_MAX_BYTES)) throw tooLarge()
|
||||
if (!(await stillValid())) return null
|
||||
const chunks: Buffer[] = []
|
||||
let total = 0
|
||||
if (!headOnly) {
|
||||
// A file may grow after stat. Never allocate/read an unbounded body.
|
||||
while (total <= PREVIEW_MAX_BYTES) {
|
||||
const chunk = Buffer.alloc(Math.min(64 * 1024, PREVIEW_MAX_BYTES + 1 - total))
|
||||
const { bytesRead } = await handle.read(chunk, 0, chunk.length, total)
|
||||
if (!bytesRead) break
|
||||
total += bytesRead
|
||||
if (total > PREVIEW_MAX_BYTES) throw tooLarge()
|
||||
chunks.push(chunk.subarray(0, bytesRead))
|
||||
}
|
||||
}
|
||||
if (!(await stillValid()) || (!headOnly && BigInt(total) !== before.size)) return null
|
||||
mount.touched = now()
|
||||
return { path: canonical, size: Number(before.size), data: headOnly ? undefined : Buffer.concat(chunks, total) }
|
||||
} catch (error: any) {
|
||||
if (error?.status === 413) throw error
|
||||
return null
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Use the host's current authentication service for every request, never a cached grant. */
|
||||
export async function requestRejection(ctx: any, req: any): Promise<number | undefined> {
|
||||
try {
|
||||
const connection = typeof ctx.get === 'function' ? ctx.get('connection') : undefined
|
||||
if (!connection) return 503
|
||||
if (typeof connection.admit === 'function') {
|
||||
const admission = await connection.admit(req)
|
||||
if (!admission || typeof admission !== 'object') return 503
|
||||
if ('rejection' in admission) return admission.rejection === 401 ? 401 : 403
|
||||
const peer = admission.peer
|
||||
// 0.2.1 has owner/non-owner principals. 0.2.0 has a single operator Peer.
|
||||
if (peer?.principal?.kind === 'owner') return undefined
|
||||
if (peer && peer === connection.operator && peer.principal === undefined) return undefined
|
||||
return 403
|
||||
}
|
||||
} catch {}
|
||||
// An unavailable or failed security service must not turn into public filesystem access.
|
||||
return 503
|
||||
}
|
||||
|
||||
export function rejectionText(status: number): string {
|
||||
return status === 401 ? 'unauthorized' : status === 403 ? 'forbidden' : 'Host authentication unavailable'
|
||||
}
|
||||
+12
-9
@@ -3,7 +3,7 @@
|
||||
> 版本:v0.2 草案 · 日期:2026-08-16 · 状态:v2 已实现并验收;§12 多项目分栏框架(v3)与 §13 乐高式工作区框架(v4)设计定案、待实现
|
||||
> 关联项目:dsh-travelatlas(第一个入驻项目)、上游参考 dsh-reminder(文件夹结构)
|
||||
|
||||
> 文档边界:§3/§9–§13 包含早期目标、历史验收表与设计草案,不是当前版本全部实现的规格书。当前 v0.4.0 的安装、兼容与验证状态以 README / 包内 README 为准;§6/§8 为当前架构与权限说明。
|
||||
> 文档边界:§3/§9–§13 包含早期目标、历史验收表与设计草案,不是当前版本全部实现的规格书。当前 0.5.0的安装、兼容与验证状态以 README / 包内 README 为准;§6/§8 为当前架构与权限说明。
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
@@ -155,10 +155,12 @@ ctx.slots.register({
|
||||
package.json `dsh.client.inject` 声明 `@deepseek-ai/dsh-client-locale`(peerDependencies 可选)。
|
||||
- 宿主 locale 服务缺席时 t 回退 zh 词典,保证工作台独立可用。
|
||||
|
||||
当前侧栏的添加、绑定、图标、删除弹窗由同一份 `popupLayer` 渲染,展开/收起分支各引用一次;避免折叠时入口更新状态但没有弹窗,或展开时双重弹窗。选择器失效/卸载守卫保持原有语义。
|
||||
|
||||
## 6. 架构与技术方案
|
||||
|
||||
- 插件包:`01_content/`(dsh.plugin.json + cordis.patch.yml + build.mjs,参照 dsh-travelatlas / dsh-usage);
|
||||
- 服务端:Cordis 插件,`inject: ['webServer','sessions']`;`/api/worktable/*` 包含 health、fs、git、file、write、site、mkdir、workspaces、template 等业务路由,`/api/worktable/term` 为终端 WebSocket。数据目录由官方 home-paths(可解析时)或无循环 baseDshHome 兜底定位,不写死 ~/.dsh。
|
||||
- 服务端:Cordis 插件,`inject: ['webServer','sessions']`;`/api/worktable/*` 包含 health、fs、git、file、write、site、mkdir、workspaces、template、preview、preview-static 等路由,`/api/worktable/term` 为终端 WebSocket。数据目录由官方 home-paths(可解析时)或无循环 baseDshHome 兜底定位,不写死 ~/.dsh。
|
||||
- 客户端:单文件 CJS(`window.__ModuleLoader__.load` 握手),external react / @deepseek-ai/*;
|
||||
- 注入座位:`sidebar.footer.action`(order 20,位于 dsh-usage 之后);
|
||||
- 服务注入:`['slots','locale','sessions','conversation','workspaces']`;新版可选服务经 `ctx.get` 探测,缺席时保留旧版分支;locale 缺席回退 zh。Desktop 的 platform 仍为 web,终端地址经 `hostTransport.ts` 从宿主 transport 取得。
|
||||
@@ -202,6 +204,8 @@ ctx.slots.register({
|
||||
|
||||
## 8. 隐私与安全
|
||||
|
||||
- 当前候选私有 HTTP 与终端握手借宿主 `connection.admit` 验证身份:新版仅 owner,0.2.0 仅同一 operator;不可确认身份时拒绝。health 公开,preview-static 使用 owner 创建的临时只读能力地址。路由、错误码、生命周期及排查统一见 [兼容与安全说明](../docs/compatibility-and-security.md)。
|
||||
- browser/anim/iframe 使用不含 allow-same-origin 的 sandbox。本地网页先创建 preview 挂载,再经 preview-static 读取资源;CORS 不授予私有接口权限,网页仍可外连。存储、登录、WebGL 和自动取字可能受影响;StarMap 月球纹理已在独立项目修复,不包含在工作台包中;不放宽隔离规避旧网页兼容问题。
|
||||
- 不新增分析上报服务;项目/布局/绑定存 localStorage,媒体存 IndexedDB,网页与桌面来源各自独立。搜索在本机过滤项目名。
|
||||
- 文件窗、编辑保存、mkdir、站点与 widget 产物握手会读写配置的项目目录;workspaces 路由读取宿主工作区数据,部分读取由加载/项目变化/会话完成自动触发。
|
||||
- 更新检查为可关闭的 GitHub Releases 只读 GET,不携带会话内容;其余通信包括宿主 API/WebSocket 与网页窗加载内容自身的请求。不能概括为「不联网」。
|
||||
@@ -289,7 +293,7 @@ type SplitPane = {
|
||||
title: string
|
||||
width: { default: number; min: number; max: number }
|
||||
content:
|
||||
| { kind: 'iframe'; url: string } // 主推:同源站点路由(/xxx/site/)
|
||||
| { kind: 'iframe'; url: string } // URL 形式;当前候选仍使用隔离 iframe,不继承宿主同源权限
|
||||
| { kind: 'component'; component: any } // 预留:项目打包的 React 组件(实现时验证跨插件引用可行性)
|
||||
}
|
||||
```
|
||||
@@ -309,8 +313,7 @@ type SplitPane = {
|
||||
- 对话栏:固定最右,可拖宽范围 = 240 起、上限为「列宽 − 左侧内容最小宽」(参考实现取 160px,
|
||||
与 travelatlas 现行语义一致;不再设固定 480 上限);
|
||||
- 持久化:`dsh.worktable.split.v1` = `{ [projectId]: { [paneId]: width, chat: width } }`;
|
||||
- iframe 内容:同源路由约定 `/<project>/site/`(项目服务端自行托管,travelatlas 模式),
|
||||
URL 校验 http/https,新标签打开入口同 travelatlas;
|
||||
- iframe 内容:早期设计使用 `/<project>/site/` 路由;当前候选使用隔离页面,即使 URL 与宿主同站也不授予同源权限。本地 HTML 通过只读预览地址加载,限制见 §8。
|
||||
- 内容形式:`iframe` 为主;`component` 预留位,待深度交互项目出现时验证并实现。
|
||||
|
||||
### 12.5 实施时机
|
||||
@@ -351,7 +354,7 @@ type SplitPane = {
|
||||
|
||||
### 13.4 内容插件协议 PaneProvider
|
||||
|
||||
- 内容三态:`iframe`(同源 URL,主推)/ `component`(插件打包 React 组件)/ `builtin`(工作台内置);
|
||||
- 内容三态:`iframe`(URL 页面,当前加 sandbox 隔离)/ `component`(插件打包 React 组件)/ `builtin`(工作台内置);
|
||||
- builtin 注册表:工作台内置「浏览器」;资源管理器 / 终端 / 任务管理作为后续内容插件逐个接入;
|
||||
- 内容插件 = 独立 DSH 插件包,经专用座位或注册表挂载,不硬编码进工作台本体。
|
||||
|
||||
@@ -418,7 +421,7 @@ type SplitPane = {
|
||||
| 内容窗 | 可行性 | 依据 |
|
||||
| --- | --- | --- |
|
||||
| 浏览器 | ✅ 已实现 | iframe + 地址栏 |
|
||||
| 资源管理器 | ✅ 已实现(第一版) | 服务端 /api/worktable/fs 目录列表(参考 better-sidebar 架构);文件点击开预览标签(2026-08-17):.html → /api/worktable/site 目录级静态托管(相对资源随目录解析,本地网页完整渲染;PDF 已回退原生 iframe 阅读器)、.md markdown-it 渲染、.txt/.log 纯文本、常见图片居中展示 |
|
||||
| 资源管理器 | ✅ 已实现(第一版) | 服务端 /api/worktable/fs 目录列表(参考 better-sidebar 架构);文件点击开预览标签(2026-08-17):.html → 鉴权创建 preview 挂载后经 preview-static 读取相对资源(早期使用 site);隔离兼容限制见 §8,PDF 仍走现有阅读器、.md markdown-it 渲染、.txt/.log 纯文本、常见图片居中展示 |
|
||||
| 源代码管理 | ✅ 已实现(第一版) | 服务端 /api/worktable/git(porcelain v1 -z);diff/暂存/提交待后续 |
|
||||
| 终端 | ✅ 已实现(第一版) | WS /api/worktable/term + node-pty + xterm(宿主缺 node-pty 时降级提示) |
|
||||
| 任务管理 | ✅ 已实现(第一版) | 客户端 sessions 快照 jobsBySession(后台任务列表,2s 刷新) |
|
||||
@@ -451,7 +454,7 @@ type SplitPane = {
|
||||
用户自建布局 = 同一引擎的运行时产物,共用 tiling 引擎与持久化;
|
||||
- 本地快捷方式、接入指引保留于「+」面板第二入口。
|
||||
|
||||
## 14. 当前实现参考(由规则手册迁入,v0.4.0)
|
||||
## 14. 当前实现参考(0.5.0)
|
||||
|
||||
以下为界面、状态与产物协议的实现细节,供改动对应组件时对照源码;约束与验证入口见 AGENTS.md,发布状态见 README。0.2 导航使用 openHostSession,新旧 API 桥接见 sessionCompat.ts/sessionDetails.ts;不要将旧接口示例当作通用公开 API。
|
||||
|
||||
@@ -575,7 +578,7 @@ type SplitPane = {
|
||||
旧卡暂保留未去重);铃铛按钮发现新版本时右上角琥珀呼吸灯(dockBadge)。
|
||||
- 指哪打哪标注:窗格折叠键旁 annotBtn——蓝泡光标→点选/拖框(小拖=点)→输入→✓ 注入宿主 textarea(不发送,失败回退剪贴板)。
|
||||
payload v3.2:窗口身份(编号+窗格标题+内容类型/URL)+ 主目标(caretPositionFromPoint 取字 + computed style 字号)+ 整行 + 候选;
|
||||
同源 iframe 下钻取字(boxPayload),跨域输出「读取受限」+ src;提示词含「禁止编造,缺失时如实说明,建议截图或视觉模型」;
|
||||
隔离 iframe 下钻取字被浏览器拒绝时输出「读取受限」+ src,保留窗口级信息;不能取消 sandbox 恢复取字。提示词含「禁止编造,缺失时如实说明,建议截图或视觉模型」;
|
||||
知识包含标注协议行(窗口编号+处理方式);回退锚点 tag:pre-annotate-v3 / pre-annotate-v3.1 / pre-annotate-v3.2。
|
||||
- 分隔线:DIVIDER=4(分栏可拖分隔条更细)。
|
||||
- 冷会话消息预览:0.2 用 sessions.using 临时持有后读 eventSource 的已加载事件窗口;旧版预热走 face.history({maxMessages:6})
|
||||
|
||||
@@ -326,4 +326,40 @@ function fresh() {
|
||||
ok('G. settling 过渡态保持等待')
|
||||
}
|
||||
|
||||
// 来源:Aisland-SJL/dsh-worktable PR #16(xpeter80),head 0e35aa14636a4433c261bbac4fe818d8ab533ded。
|
||||
// 模拟浏览器缩放下 CSSOM 对 px 的小数序列化,并通过真实 splitStore yield observer 验证边界。
|
||||
for (const chatSide of ['left', 'right']) {
|
||||
for (const externalMargin of ['marginLeft', 'marginRight', 'marginTop']) {
|
||||
const { sandbox, splitStore, allEls, moInstances } = fresh()
|
||||
const root = new FakeEl('DIV', 'wSkVaW_root', { phase: 'hero' }).setRect({ left: 0, top: 0, right: 969.0908203125, bottom: 800 })
|
||||
const body = new FakeEl('DIV', 'wSkVaW_body')
|
||||
body.style = new Proxy(new StyleProxy(), {
|
||||
set(target, key, value) {
|
||||
// Chromium serializes 649.0908203125px as 649.091px.
|
||||
target[key] = typeof value === 'string' && /^-?[\d.]+px$/.test(value)
|
||||
? Number(Number.parseFloat(value).toPrecision(6)) + 'px' : value
|
||||
return true
|
||||
},
|
||||
})
|
||||
root.appendChild(body)
|
||||
allEls.push(root); sandbox.document.body.appendChild(root)
|
||||
if (!splitStore.open({ ...makeSpec('zoom'), chatSide })) fail('zoom.open', 'open failed')
|
||||
splitStore.setChatW(320)
|
||||
const observer = moInstances.find((m) => m._target === body)
|
||||
if (!observer) fail('zoom.observer', 'yield observer not installed')
|
||||
const ownedMargin = chatSide === 'left' ? 'marginRight' : 'marginLeft'
|
||||
if (body.style[ownedMargin] !== '649.091px') fail('zoom.fixture', body.style[ownedMargin])
|
||||
observer.cb()
|
||||
if (!splitStore.active) fail('zoom.self', 'own serialized write closed split')
|
||||
splitStore.applyMargin()
|
||||
observer.cb()
|
||||
if (!splitStore.active) fail('zoom.refresh', 'repeated layout closed split')
|
||||
body.style[externalMargin] = '17px'
|
||||
observer.cb()
|
||||
if (splitStore.active) fail('zoom.takeover', externalMargin + ' did not yield')
|
||||
if (body.style.marginLeft !== '' || body.style.marginRight !== '' || body.style.marginTop !== '') fail('zoom.restore', 'original margins not restored')
|
||||
ok('zoom ' + chatSide + ': self-write retained; external ' + externalMargin + ' yields')
|
||||
}
|
||||
}
|
||||
|
||||
console.log('all anchor-dom tests passed: ' + pass + '/' + pass)
|
||||
|
||||
@@ -9,7 +9,17 @@ const { buildSync } = require('esbuild')
|
||||
const source = buildSync({ entryPoints: [fileURLToPath(new URL('../01_content/src/client/hostTransport.ts', import.meta.url))], bundle: true, write: false, platform: 'node', format: 'cjs' }).outputFiles[0].text
|
||||
const module = { exports: {} }
|
||||
runInNewContext(source, { module, exports: module.exports, URL })
|
||||
const { hostWebSocketUrl, worktableUpgrade } = module.exports
|
||||
const { hostWebSocketUrl, hostPreviewUrl, worktableUpgrade } = module.exports
|
||||
|
||||
test('Desktop sandbox assets use the owned Host URL without a login token', () => {
|
||||
assert.equal(hostPreviewUrl('/api/worktable/preview-static/cap/app.js', {protocol:'dsh-app:',streamBaseUrl:'http://127.0.0.1:19499'}), 'http://127.0.0.1:19499/api/worktable/preview-static/cap/app.js')
|
||||
})
|
||||
test('Web sandbox assets retain a relative URL; missing Desktop carrier fails closed', () => {
|
||||
assert.equal(hostPreviewUrl('/api/worktable/preview-static/cap/index.html', {protocol:'https:'}), '/api/worktable/preview-static/cap/index.html')
|
||||
assert.throws(() => hostPreviewUrl('/api/worktable/preview-static/cap/index.html',{protocol:'dsh-app:'}), /unavailable/)
|
||||
assert.throws(() => hostPreviewUrl('/api/worktable/preview-static/cap/index.html',{protocol:'dsh-app:',streamBaseUrl:'file:///fixture'}), /Unsupported/)
|
||||
assert.throws(() => hostPreviewUrl('//other.example/api',{protocol:'https:'}), /Invalid/)
|
||||
})
|
||||
|
||||
test('Web HTTP uses its current authority and preserves encoded query', () => {
|
||||
assert.equal(hostWebSocketUrl('/api/worktable/term?cwd=C%3A%2Ftest&cols=80', { href: 'http://127.0.0.1:3080/', protocol: 'http:' }), 'ws://127.0.0.1:3080/api/worktable/term?cwd=C%3A%2Ftest&cols=80')
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test, after, afterEach } from 'node:test'
|
||||
import { createRequire } from 'node:module'
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, symlinkSync, renameSync, truncateSync, unlinkSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url'
|
||||
const require=createRequire(new URL('../01_content/package.json',import.meta.url))
|
||||
const {build}=require('esbuild')
|
||||
const temp=mkdtempSync(join(tmpdir(),'wt-preview-test-'))
|
||||
after(()=>rmSync(temp,{recursive:true,force:true}))
|
||||
// Instrument filesystem scheduling only in the test bundle; production has no hooks.
|
||||
afterEach(()=>{ delete globalThis.__wtPreviewHooks })
|
||||
await build({
|
||||
entryPoints:[fileURLToPath(new URL('../01_content/src/previewFiles.ts',import.meta.url))],
|
||||
bundle:true,platform:'node',format:'esm',outfile:join(temp,'preview.mjs'),
|
||||
plugins:[{name:'filesystem-race-fixture',setup(build){
|
||||
build.onResolve({filter:/^node:fs\/promises$/},args=>args.namespace==='race-fixture'?undefined:{path:args.path,namespace:'race-fixture'})
|
||||
build.onLoad({filter:/.*/,namespace:'race-fixture'},()=>({loader:'js',contents:`
|
||||
import * as fs from 'node:fs/promises';
|
||||
export const realpath=fs.realpath, stat=fs.stat;
|
||||
export async function open(...args) {
|
||||
await globalThis.__wtPreviewHooks?.beforeOpen?.();
|
||||
const h=await fs.open(...args);
|
||||
await globalThis.__wtPreviewHooks?.afterOpen?.();
|
||||
const read=h.read.bind(h);
|
||||
h.read=async(...args)=>{await globalThis.__wtPreviewHooks?.beforeRead?.();return read(...args)};
|
||||
return h;
|
||||
}`}))
|
||||
}}],
|
||||
})
|
||||
const {createPreviewFiles,PREVIEW_MAX_BYTES}=await import(pathToFileURL(join(temp,'preview.mjs')).href)
|
||||
const root=join(temp,'root'),outside=join(temp,'outside');mkdirSync(root);mkdirSync(outside)
|
||||
writeFileSync(join(root,'index.html'),'fixture');writeFileSync(join(outside,'outside.txt'),'outside')
|
||||
test('Preview reads only its canonical root, including directory indexes',async()=>{
|
||||
const store=createPreviewFiles(),token=await store.mount(root)
|
||||
assert.equal((await store.file(token,[])).path,join(root,'index.html'))
|
||||
assert.equal(await store.file('invented-token',['index.html']),null)
|
||||
for(const parts of [['..','outside','outside.txt'],['..\\outside\\outside.txt'],['/outside'],['bad\0']]) assert.equal(await store.file(token,parts),null)
|
||||
symlinkSync(outside,join(root,'escape'),process.platform==='win32'?'junction':'dir')
|
||||
assert.equal(await store.file(token,['escape','outside.txt']),null)
|
||||
rmSync(join(root,'escape'))
|
||||
})
|
||||
test('Capabilities expire after idle time; revocation and disposal remove them',async()=>{
|
||||
let now=0;const store=createPreviewFiles(()=>now),a=await store.mount(root)
|
||||
now=29*60*1000;assert.ok(await store.file(a,['index.html']))
|
||||
now+=30*60*1000;assert.equal(await store.file(a,['index.html']),null)
|
||||
const b=await store.mount(root);store.revoke(b);assert.equal(await store.file(b,[]),null)
|
||||
const c=await store.mount(root);store.clear();assert.equal(await store.file(c,[]),null)
|
||||
})
|
||||
test('Reject relative roots and bound the live capability table',async()=>{
|
||||
const store=createPreviewFiles();await assert.rejects(store.mount('.'),/absolute/)
|
||||
for(let i=0;i<128;i++)await store.mount(root)
|
||||
await assert.rejects(store.mount(root),/Too many/)
|
||||
})
|
||||
|
||||
|
||||
test('Read content through the checked handle; HEAD omits the body and enforces the limit',async()=>{
|
||||
const store=createPreviewFiles(),token=await store.mount(root)
|
||||
assert.equal((await store.file(token,['index.html'])).data.toString(),'fixture')
|
||||
const head=await store.file(token,['index.html'],true)
|
||||
assert.equal(head.size,7);assert.equal(head.data,undefined)
|
||||
const large=join(root,'large.bin');writeFileSync(large,'x');truncateSync(large,PREVIEW_MAX_BYTES+1)
|
||||
await assert.rejects(store.file(token,['large.bin'],true),e=>e.status===413)
|
||||
})
|
||||
|
||||
test('A directory replaced by a junction after path checking cannot return outside content',async()=>{
|
||||
const slot=join(root,'slot'),moved=join(root,'slot-original')
|
||||
mkdirSync(slot);writeFileSync(join(slot,'outside.txt'),'inside')
|
||||
const store=createPreviewFiles(),token=await store.mount(root)
|
||||
globalThis.__wtPreviewHooks={beforeOpen(){
|
||||
delete globalThis.__wtPreviewHooks
|
||||
renameSync(slot,moved);symlinkSync(outside,slot,process.platform==='win32'?'junction':'dir')
|
||||
}}
|
||||
try {assert.equal(await store.file(token,['slot','outside.txt']),null)}
|
||||
finally {unlinkSync(slot);renameSync(moved,slot)}
|
||||
})
|
||||
|
||||
test('Replacing the named file after open rejects the response, even with an open old handle',async()=>{
|
||||
const name=join(root,'replace.txt'),old=join(root,'replace-original.txt')
|
||||
writeFileSync(name,'original')
|
||||
const store=createPreviewFiles(),token=await store.mount(root)
|
||||
globalThis.__wtPreviewHooks={afterOpen(){
|
||||
delete globalThis.__wtPreviewHooks
|
||||
renameSync(name,old);writeFileSync(name,'replacement')
|
||||
}}
|
||||
assert.equal(await store.file(token,['replace.txt']),null)
|
||||
})
|
||||
|
||||
test('Growth after stat cannot produce a response over 40 MiB',async()=>{
|
||||
const name=join(root,'growing.bin');writeFileSync(name,'small')
|
||||
const store=createPreviewFiles(),token=await store.mount(root)
|
||||
globalThis.__wtPreviewHooks={beforeRead(){
|
||||
delete globalThis.__wtPreviewHooks
|
||||
truncateSync(name,PREVIEW_MAX_BYTES+1)
|
||||
}}
|
||||
await assert.rejects(store.file(token,['growing.bin']),e=>e.status===413)
|
||||
})
|
||||
|
||||
test('Revocation during a pending read prevents the response',async()=>{
|
||||
const store=createPreviewFiles(),token=await store.mount(root)
|
||||
globalThis.__wtPreviewHooks={beforeRead(){
|
||||
delete globalThis.__wtPreviewHooks
|
||||
store.revoke(token)
|
||||
}}
|
||||
assert.equal(await store.file(token,['index.html']),null)
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test, after } from 'node:test'
|
||||
import { createRequire } from 'node:module'
|
||||
import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, rmSync, existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url'
|
||||
|
||||
const require = createRequire(new URL('../01_content/package.json', import.meta.url))
|
||||
const { buildSync } = require('esbuild')
|
||||
const temp = mkdtempSync(join(tmpdir(), 'wt-auth-test-'))
|
||||
after(() => rmSync(temp, { recursive: true, force: true }))
|
||||
for (const [pkg, source] of Object.entries({
|
||||
ws: `const {EventEmitter}=require('node:events'); exports.WebSocketServer = class {
|
||||
clients=new Set();
|
||||
handleUpgrade(req,socket,head,callback){
|
||||
globalThis.__wtTestUpgrade=true;
|
||||
if(!globalThis.__wtAllowFakeShell) throw Error('TEST: unexpected authorized upgrade');
|
||||
const ws=new EventEmitter(); ws.send=()=>{}; ws.close=()=>{this.clients.delete(ws);ws.emit('close')};
|
||||
ws.terminate=()=>{globalThis.__wtSocketTerminated=true;ws.close()};this.clients.add(ws);callback(ws);
|
||||
}
|
||||
close(){}
|
||||
}`,
|
||||
'node-pty': `exports.spawn = () => {
|
||||
if(!globalThis.__wtAllowFakeShell)throw Error('TEST: unexpected shell');
|
||||
return {onData(){},onExit(){},write(){},resize(){},kill(){globalThis.__wtShellKilled=true}};
|
||||
}`,
|
||||
})) {
|
||||
mkdirSync(join(temp, 'node_modules', pkg), { recursive: true })
|
||||
writeFileSync(join(temp, 'node_modules', pkg, 'index.js'), source)
|
||||
}
|
||||
const entry = fileURLToPath(new URL('../01_content/src/index.ts', import.meta.url))
|
||||
buildSync({ entryPoints: [entry], bundle: true, outfile: join(temp, 'server.mjs'), platform: 'node', format: 'esm', external: ['@deepseek-ai/*', 'ws', 'node-pty'], loader: { '.css': 'text', '.html': 'text' } })
|
||||
const server = await import(pathToFileURL(join(temp, 'server.mjs')).href)
|
||||
const owner = { principal: { kind: 'owner' } }
|
||||
function harness(connection) {
|
||||
const routes = new Map(), upgrades = new Map(), disposers = []
|
||||
const ctx = { get: () => connection.current, sessions: {}, logger: { info(){},warn(){} },
|
||||
webServer: { register(r){ routes.set(r.path,r); return () => routes.delete(r.path) }, registerUpgrade(r){upgrades.set(r.path,r); return () => upgrades.delete(r.path)} },
|
||||
effect(fn) { const d = fn(); if (typeof d === 'function') disposers.push(d) },
|
||||
}
|
||||
server.apply(ctx)
|
||||
return { routes, upgrades, close(){for (const d of disposers.reverse()) d()} }
|
||||
}
|
||||
async function invoke(route, body, method = 'POST', url = route.path) {
|
||||
let consumed = false
|
||||
const req = { method, url, headers: {}, async *[Symbol.asyncIterator](){consumed=true;yield Buffer.from(JSON.stringify(body ?? {}))} }
|
||||
const result = { status: 0, headers: {}, body: '' }
|
||||
await route.handler(req, { writeHead(s,h){result.status=s;result.headers=h??{}},end(b){result.body=b?.toString()??''} })
|
||||
return { ...result, consumed }
|
||||
}
|
||||
|
||||
test('Every private HTTP route rejects unauthenticated requests before reading a body or file', async () => {
|
||||
const h = harness({ current: { admit: () => ({ rejection: 401 }) } })
|
||||
try {
|
||||
for (const [path, route] of h.routes) {
|
||||
if (path === server.HEALTH_PATH || path.endsWith('/preview-static')) continue
|
||||
const r = await invoke(route, {path: join(temp,'not-created'),content:'no'})
|
||||
assert.equal(r.status,401,path);assert.equal(r.consumed,false,path)
|
||||
}
|
||||
assert.equal(existsSync(join(temp,'not-created')),false)
|
||||
} finally { h.close() }
|
||||
})
|
||||
test('Missing auth, exceptions, malformed admissions and non-owner API peers fail closed', async () => {
|
||||
for (const [connection,status] of [[undefined,503],[{requestRejection:()=>undefined},503],[{admit(){throw Error('unavailable')}},503],[{admit:()=>null},503],[{admit:()=>({})},403],[{admit:()=>({peer:{principal:{kind:'agent'}}})},403]]) {
|
||||
const h=harness({current:connection})
|
||||
try { assert.equal((await invoke(h.routes.get('/api/worktable/write'),{})).status,status) } finally { h.close() }
|
||||
}
|
||||
})
|
||||
test('An admitted owner writes normally; revocation is checked on the next request', async () => {
|
||||
const connection={current:{admit:()=>({peer:owner})}}
|
||||
const h=harness(connection), path=join(temp,'authorized.txt')
|
||||
try {
|
||||
assert.equal((await invoke(h.routes.get('/api/worktable/write'),{path,content:'authorized'})).status,200)
|
||||
connection.current={admit:()=>({rejection:403})}
|
||||
assert.equal((await invoke(h.routes.get('/api/worktable/write'),{path,content:'blocked'})).status,403)
|
||||
assert.equal(readFileSync(path,'utf8'),'authorized')
|
||||
} finally {h.close()}
|
||||
})
|
||||
test('0.2.0 single operator identity remains supported', async () => {
|
||||
const operator={id:'isolated-operator'}
|
||||
for(const connection of [{operator,admit:()=>({peer:operator})}]) {
|
||||
const h=harness({current:connection})
|
||||
try { assert.equal((await invoke(h.routes.get('/api/worktable/mkdir'),{})).status,400) } finally {h.close()}
|
||||
}
|
||||
})
|
||||
test('Terminal denies before upgrading or starting a shell; no auth service also denies', async () => {
|
||||
for (const [connection,status] of [[{admit:()=>({rejection:401})},401],[undefined,503]]) {
|
||||
const h=harness({current:connection});let response='';globalThis.__wtTestUpgrade=false
|
||||
try {
|
||||
const route=h.upgrades.get('/api/worktable/term');assert.ok(route)
|
||||
await route.handler({headers:{},url:route.path},{end(s){response=s}},Buffer.alloc(0))
|
||||
assert.match(response,new RegExp('HTTP/1.1 '+status));assert.equal(globalThis.__wtTestUpgrade,false)
|
||||
} finally {h.close()}
|
||||
}
|
||||
})
|
||||
test('Health discloses only status; route effects and preview grants are disposed', async () => {
|
||||
const h=harness({current:undefined})
|
||||
assert.equal((await invoke(h.routes.get(server.HEALTH_PATH),undefined,'GET')).status,200)
|
||||
h.close();assert.equal(h.routes.size,0);assert.equal(h.upgrades.size,0)
|
||||
})
|
||||
test('Plugin disposal terminates its shell/socket and refuses a pending upgrade', async () => {
|
||||
globalThis.__wtAllowFakeShell=true;globalThis.__wtShellKilled=false;globalThis.__wtSocketTerminated=false
|
||||
const h=harness({current:{admit:()=>({peer:owner})}})
|
||||
try {
|
||||
await h.upgrades.get('/api/worktable/term').handler({url:'/api/worktable/term?cwd='+encodeURIComponent(temp)}, {}, Buffer.alloc(0))
|
||||
h.close();assert.equal(globalThis.__wtShellKilled,true);assert.equal(globalThis.__wtSocketTerminated,true)
|
||||
} finally {globalThis.__wtAllowFakeShell=false}
|
||||
let finish, response='';globalThis.__wtTestUpgrade=false
|
||||
const pending=harness({current:{admit:()=>new Promise(resolve=>{finish=resolve})}})
|
||||
const call=pending.upgrades.get('/api/worktable/term').handler({}, {end(s){response=s}}, Buffer.alloc(0))
|
||||
pending.close();finish({peer:owner});await call
|
||||
assert.match(response,/HTTP\/1.1 503/);assert.equal(globalThis.__wtTestUpgrade,false)
|
||||
})
|
||||
test('Only an authenticated owner can mint a preview; a capability is read-only and revocable', async () => {
|
||||
const h=harness({current:{admit:()=>({peer:owner})}})
|
||||
try {
|
||||
writeFileSync(join(temp,'page.html'),'<h1>fixture</h1>')
|
||||
const grant=JSON.parse((await invoke(h.routes.get('/api/worktable/preview'),{root:temp})).body)
|
||||
assert.match(grant.token,/^[\w-]{32}$/)
|
||||
const route=h.routes.get('/api/worktable/preview-static'), url=grant.base+'page.html'
|
||||
const r=await invoke(route,undefined,'GET',url)
|
||||
assert.equal(r.status,200);assert.equal(r.body,'<h1>fixture</h1>');
|
||||
assert.equal((await invoke(route,undefined,'HEAD',url)).status,200);
|
||||
assert.match(r.headers['content-security-policy'],/sandbox allow-scripts/)
|
||||
assert.doesNotMatch(r.headers['content-security-policy'],/allow-same-origin/)
|
||||
assert.equal(r.headers['referrer-policy'],'no-referrer');assert.equal(r.headers['access-control-allow-origin'],'*')
|
||||
assert.equal((await invoke(route,{},'POST',url)).status,405)
|
||||
await invoke(h.routes.get('/api/worktable/preview'),{revoke:grant.token})
|
||||
assert.equal((await invoke(route,undefined,'GET',url)).status,404)
|
||||
} finally {h.close()}
|
||||
})
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* SandboxedFrame lifecycle tests.
|
||||
* Transpiles the real component with esbuild and runs it with a minimal React
|
||||
* hooks fixture. Network and JSX are stubbed; this is not React DOM / browser E2E.
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import { test, after } from 'node:test'
|
||||
import { createRequire } from 'node:module'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const require = createRequire(new URL('../01_content/package.json', import.meta.url))
|
||||
const { buildSync } = require('esbuild')
|
||||
const temp = mkdtempSync(join(tmpdir(), 'wt-sandboxed-frame-test-'))
|
||||
after(() => rmSync(temp, { recursive: true, force: true }))
|
||||
|
||||
const componentPath = fileURLToPath(new URL('../01_content/src/client/SandboxedFrame.tsx', import.meta.url))
|
||||
const bundlePath = join(temp, 'SandboxedFrame.cjs')
|
||||
buildSync({
|
||||
entryPoints: [componentPath], bundle: true, platform: 'node', format: 'cjs',
|
||||
outfile: bundlePath, external: ['react', 'react/jsx-runtime'], jsx: 'automatic',
|
||||
})
|
||||
const componentCode = readFileSync(bundlePath, 'utf8')
|
||||
|
||||
function createHarness({ location, fetch }) {
|
||||
const slots = []
|
||||
let cursor = 0
|
||||
let pendingEffects = []
|
||||
let currentProps
|
||||
let currentOutput
|
||||
|
||||
const jsx = (type, props) => ({ type, props: props ?? {} })
|
||||
const react = {
|
||||
useState(initial) {
|
||||
const index = cursor++
|
||||
if (!slots[index]) slots[index] = { kind: 'state', value: typeof initial === 'function' ? initial() : initial }
|
||||
const slot = slots[index]
|
||||
return [slot.value, value => { slot.value = typeof value === 'function' ? value(slot.value) : value }]
|
||||
},
|
||||
useEffect(create, deps) {
|
||||
const index = cursor++
|
||||
const previous = slots[index]
|
||||
const same = previous?.kind === 'effect' && Array.isArray(deps) && Array.isArray(previous.deps)
|
||||
&& deps.length === previous.deps.length && deps.every((value, i) => Object.is(value, previous.deps[i]))
|
||||
if (!same) pendingEffects.push({ index, create, deps: Array.isArray(deps) ? [...deps] : undefined })
|
||||
},
|
||||
}
|
||||
|
||||
const module = { exports: {} }
|
||||
const sandbox = {
|
||||
module,
|
||||
exports: module.exports,
|
||||
URL,
|
||||
location,
|
||||
fetch,
|
||||
require(id) {
|
||||
if (id === 'react') return react
|
||||
if (id === 'react/jsx-runtime') return { jsx, jsxs: jsx, Fragment: Symbol.for('test.fragment') }
|
||||
throw new Error('unexpected external module: ' + id)
|
||||
},
|
||||
}
|
||||
runInNewContext(componentCode, sandbox, { filename: 'SandboxedFrame.cjs' })
|
||||
const { SandboxedFrame, previewTarget } = module.exports
|
||||
|
||||
function render(props) {
|
||||
currentProps = props ?? currentProps
|
||||
cursor = 0
|
||||
pendingEffects = []
|
||||
currentOutput = SandboxedFrame(currentProps)
|
||||
for (const effect of pendingEffects) {
|
||||
const previous = slots[effect.index]
|
||||
if (previous?.kind === 'effect') previous.cleanup?.()
|
||||
const cleanup = effect.create()
|
||||
slots[effect.index] = {
|
||||
kind: 'effect', deps: effect.deps,
|
||||
cleanup: typeof cleanup === 'function' ? cleanup : undefined,
|
||||
}
|
||||
}
|
||||
return currentOutput
|
||||
}
|
||||
|
||||
function unmount() {
|
||||
for (const slot of slots) if (slot?.kind === 'effect') slot.cleanup?.()
|
||||
slots.length = 0
|
||||
}
|
||||
|
||||
return { render, unmount, previewTarget, get output() { return currentOutput } }
|
||||
}
|
||||
|
||||
const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve() }
|
||||
const siteUrl = (protocol = 'https:', host = 'dsh.example') =>
|
||||
protocol + '//' + host + '/api/worktable/site/C%3A%2FProjects%2Flegacy/index.html'
|
||||
const grant = token => ({ token, base: '/api/worktable/preview-static/' + token + '/' })
|
||||
const response = value => ({ ok: true, status: 200, json: async () => value })
|
||||
function recordingFetch(grants) {
|
||||
const calls = []
|
||||
const fetch = (url, init = {}) => {
|
||||
const body = init.body ? JSON.parse(init.body) : {}
|
||||
calls.push({ url, init, body })
|
||||
if (body.revoke) return Promise.resolve({ ok: true, status: 200 })
|
||||
const value = typeof grants === 'function' ? grants(body, calls.length) : grants.shift()
|
||||
return Promise.resolve(typeof value?.then === 'function' ? value : response(value))
|
||||
}
|
||||
return { fetch, calls, revoked: () => calls.filter(call => call.body.revoke).map(call => call.body.revoke) }
|
||||
}
|
||||
const iframeProps = output => {
|
||||
assert.equal(output?.type, 'iframe', 'expected iframe output; actual=' + JSON.stringify(output))
|
||||
return output.props
|
||||
}
|
||||
const errorText = output => output?.props?.children?.props?.children
|
||||
|
||||
test('dangerous URL schemes are rejected before a preview grant is requested', async () => {
|
||||
const network = recordingFetch([])
|
||||
const location = { href: 'https://dsh.example/worktable', protocol: 'https:' }
|
||||
const harness = createHarness({ location, fetch: network.fetch })
|
||||
for (const url of ['javascript:alert(1)', 'data:text/html,unsafe', 'file:///tmp/page.html', 'blob:https://dsh.example/id', 'ftp://dsh.example/page']) {
|
||||
assert.throws(() => harness.previewTarget(url, location.href), /Unsupported preview URL/)
|
||||
}
|
||||
harness.render({ url: 'javascript:alert(1)', reloadKey: 0 })
|
||||
await flush()
|
||||
const output = harness.render()
|
||||
assert.equal(output.type, 'div')
|
||||
assert.match(errorText(output), /Unsupported preview URL/)
|
||||
assert.equal(network.calls.length, 0)
|
||||
harness.unmount()
|
||||
})
|
||||
|
||||
test('legacy site URLs request a grant for the decoded root and load the relative resource', async () => {
|
||||
const token = 'a'.repeat(32)
|
||||
const network = recordingFetch([grant(token)])
|
||||
const location = { href: 'https://dsh.example/worktable', protocol: 'https:' }
|
||||
const harness = createHarness({ location, fetch: network.fetch })
|
||||
const url = 'https://dsh.example/api/worktable/site/C%3A%2FProjects%2Flegacy%20site/assets/main%20file.css?theme=dark#top'
|
||||
const props = { url, title: 'legacy', reloadKey: 0 }
|
||||
harness.render(props)
|
||||
await flush()
|
||||
const frame = iframeProps(harness.render(props))
|
||||
assert.deepEqual(network.calls[0].body, { root: 'C:/Projects/legacy site' })
|
||||
assert.equal(frame.src, grant(token).base + 'assets/main%20file.css?theme=dark#top')
|
||||
assert.equal(frame['data-source-url'], url)
|
||||
assert.equal(frame.sandbox, 'allow-scripts allow-forms allow-downloads')
|
||||
harness.unmount()
|
||||
assert.deepEqual(network.revoked(), [token], 'revoke calls=' + JSON.stringify(network.calls))
|
||||
})
|
||||
|
||||
test('a grant that returns after unmount is revoked immediately', async () => {
|
||||
const token = 'b'.repeat(32)
|
||||
const pending = {}
|
||||
pending.promise = new Promise(resolve => { pending.resolve = resolve })
|
||||
const calls = []
|
||||
const fetch = (url, init = {}) => {
|
||||
const body = init.body ? JSON.parse(init.body) : {}
|
||||
calls.push({ url, body })
|
||||
return body.revoke ? Promise.resolve({ ok: true }) : pending.promise
|
||||
}
|
||||
const harness = createHarness({
|
||||
location: { href: 'https://dsh.example/worktable', protocol: 'https:' }, fetch,
|
||||
})
|
||||
harness.render({ url: siteUrl(), reloadKey: 0 })
|
||||
harness.unmount()
|
||||
pending.resolve(response(grant(token)))
|
||||
await flush()
|
||||
assert.deepEqual(calls.filter(call => call.body.revoke).map(call => call.body.revoke), [token])
|
||||
})
|
||||
|
||||
test('Desktop URL construction failure revokes the minted token immediately and only once', async () => {
|
||||
const token = 'c'.repeat(32)
|
||||
const network = recordingFetch([grant(token)])
|
||||
const location = { href: 'dsh-app://app/worktable', protocol: 'dsh-app:' }
|
||||
const harness = createHarness({ location, fetch: network.fetch })
|
||||
const props = { url: siteUrl('dsh-app:', 'app'), reloadKey: 0 }
|
||||
harness.render(props)
|
||||
await flush()
|
||||
assert.deepEqual(network.revoked(), [token])
|
||||
const output = harness.render(props)
|
||||
assert.equal(output.type, 'div')
|
||||
assert.match(errorText(output), /Desktop Host preview URL unavailable/)
|
||||
harness.unmount()
|
||||
assert.deepEqual(network.revoked(), [token], 'unmount must not revoke the already-cleared token again')
|
||||
})
|
||||
|
||||
test('reload revokes the previous token and uses only the newly issued token', async () => {
|
||||
const first = 'd'.repeat(32)
|
||||
const second = 'e'.repeat(32)
|
||||
const network = recordingFetch([grant(first), grant(second)])
|
||||
const harness = createHarness({
|
||||
location: { href: 'https://dsh.example/worktable', protocol: 'https:' }, fetch: network.fetch,
|
||||
})
|
||||
const props = { url: siteUrl(), reloadKey: 0 }
|
||||
harness.render(props)
|
||||
await flush()
|
||||
assert.ok(iframeProps(harness.render(props)).src.includes(first))
|
||||
const reloadProps = { ...props, reloadKey: 1 }
|
||||
harness.render(reloadProps)
|
||||
assert.deepEqual(network.revoked(), [first], 'reload cleanup must revoke the old token before the new grant settles')
|
||||
await flush()
|
||||
const nextFrame = iframeProps(harness.render(reloadProps))
|
||||
assert.ok(nextFrame.src.includes(second))
|
||||
assert.equal(nextFrame.src.includes(first), false)
|
||||
harness.unmount()
|
||||
assert.deepEqual(network.revoked(), [first, second])
|
||||
})
|
||||
|
||||
test('an unavailable preview authorization service renders an error without navigating', async () => {
|
||||
const calls = []
|
||||
const fetch = async (url, init = {}) => {
|
||||
calls.push({ url, body: init.body ? JSON.parse(init.body) : {} })
|
||||
return { ok: false, status: 503 }
|
||||
}
|
||||
const harness = createHarness({
|
||||
location: { href: 'https://dsh.example/worktable', protocol: 'https:' }, fetch,
|
||||
})
|
||||
const props = { url: siteUrl(), reloadKey: 0 }
|
||||
harness.render(props)
|
||||
await flush()
|
||||
const output = harness.render(props)
|
||||
assert.equal(output.type, 'div')
|
||||
assert.match(errorText(output), /Preview HTTP 503/)
|
||||
assert.equal(calls.length, 1)
|
||||
assert.deepEqual(calls[0].body, { root: 'C:/Projects/legacy' })
|
||||
assert.equal(calls.some(call => call.body.revoke), false)
|
||||
harness.unmount()
|
||||
})
|
||||
@@ -90,7 +90,7 @@ function runChild(envHome, name, withOfficial = false) {
|
||||
"import { homedir } from 'node:os'",
|
||||
"const mod = await import(pathToFileURL(process.argv[1]).href)",
|
||||
"const routes = []",
|
||||
"const fakeCtx = { webServer: { register: (r) => { routes.push(r) } }, effect: () => {}, logger: { warn() {}, info() {} } }",
|
||||
"const fakeCtx = { webServer: { register: (r) => { routes.push(r) } }, get: () => ({ admit: () => ({ peer: { principal: { kind: 'owner' } } }) }), effect: (fn) => fn(), logger: { warn() {}, info() {} } }",
|
||||
"mod.apply(fakeCtx)",
|
||||
"const route = routes.find((r) => r.path === '/api/worktable/workspaces')",
|
||||
"let status = 0; let body = ''",
|
||||
@@ -162,7 +162,7 @@ function runChild(envHome, name, withOfficial = false) {
|
||||
"import { pathToFileURL } from 'node:url'",
|
||||
"const mod = await import(pathToFileURL(process.argv[1]).href)",
|
||||
"const routes = []",
|
||||
"const fakeCtx = { webServer: { register: (r) => { routes.push(r) } }, effect: () => {}, logger: { warn() {}, info() {} } }",
|
||||
"const fakeCtx = { webServer: { register: (r) => { routes.push(r) } }, get: () => ({ admit: () => ({ peer: { principal: { kind: 'owner' } } }) }), effect: (fn) => fn(), logger: { warn() {}, info() {} } }",
|
||||
"mod.apply(fakeCtx)",
|
||||
"const route = routes.find((r) => r.path === '/api/worktable/workspaces')",
|
||||
"let status = 0; let body = ''",
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* WorktableSection popup regression against the actual client bundle.
|
||||
* Usage: node 04_test/sidebar-popup.test.mjs [lib/client.js]
|
||||
* The small React hook/JSX harness runs the exported component and its event handlers;
|
||||
* it does not claim browser layout or desktop GUI coverage.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createContext, runInNewContext } from 'node:vm'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
|
||||
const BUNDLE = resolve(process.argv[2] ?? join(dirname(fileURLToPath(import.meta.url)), '..', '01_content', 'lib', 'client.js'))
|
||||
const source = readFileSync(BUNDLE, 'utf8')
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
const Fragment = Symbol('test.fragment')
|
||||
const jsx = (type, props, key) => ({ type, props: props ?? {}, key: key ?? props?.key ?? null })
|
||||
|
||||
function makeHooks() {
|
||||
const slots = []
|
||||
const effects = []
|
||||
let cursor = 0
|
||||
let mounted = false
|
||||
|
||||
const useState = (initial) => {
|
||||
const i = cursor++
|
||||
if (!(i in slots)) slots[i] = typeof initial === 'function' ? initial() : initial
|
||||
return [slots[i], (next) => { slots[i] = typeof next === 'function' ? next(slots[i]) : next }]
|
||||
}
|
||||
const useRef = (initial) => {
|
||||
const i = cursor++
|
||||
if (!(i in slots)) slots[i] = { current: initial }
|
||||
return slots[i]
|
||||
}
|
||||
const useMemo = (make) => { cursor++; return make() }
|
||||
const useCallback = (callback) => { cursor++; return callback }
|
||||
const useEffect = (effect) => { cursor++; if (!mounted) effects.push(effect) }
|
||||
|
||||
return {
|
||||
React: { Fragment, useState, useRef, useMemo, useCallback, useEffect, useLayoutEffect: useEffect },
|
||||
render(component, props) {
|
||||
cursor = 0
|
||||
const tree = component(props)
|
||||
if (!mounted) {
|
||||
mounted = true
|
||||
for (const effect of effects.splice(0)) {
|
||||
try { effect() } catch (error) { throw new Error('mount effect failed: ' + error.message) }
|
||||
}
|
||||
}
|
||||
return tree
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function fakeElement(tagName = 'div') {
|
||||
return {
|
||||
tagName: tagName.toUpperCase(),
|
||||
className: '',
|
||||
style: {},
|
||||
children: [],
|
||||
setAttribute(name, value) { this[name] = value },
|
||||
appendChild(child) { child.parentElement = this; this.children.push(child); return child },
|
||||
remove() {},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
getBoundingClientRect() { return { left: 24, top: 100, right: 64, bottom: 140, width: 40, height: 40 } },
|
||||
getContext() {
|
||||
return {
|
||||
globalCompositeOperation: '',
|
||||
createLinearGradient() { return { addColorStop() {} } },
|
||||
fillRect() {},
|
||||
getImageData() { return { data: [0, 0, 0, 255] } },
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function loadComponent() {
|
||||
const values = new Map([
|
||||
['dsh.worktable.updateCheck.v1', '0'],
|
||||
['dsh.worktable.projects.v1', JSON.stringify({
|
||||
order: [], lastUsed: {}, hidden: [], nameOverrides: {}, iconOverrides: {}, removed: [],
|
||||
views: {}, shortcuts: [], bindings: {}, folders: {},
|
||||
layouts: [{
|
||||
id: 'popup-test-layout', title: 'Popup Test', icon: '🧱', top: null,
|
||||
main: [{ id: 'popup-test-pane', title: 'Pane', min: 200, content: null }],
|
||||
chatWidth: { default: 320, min: 240, max: 600 },
|
||||
}],
|
||||
})],
|
||||
])
|
||||
const localStorage = {
|
||||
getItem: (key) => values.has(key) ? values.get(key) : null,
|
||||
setItem: (key, value) => values.set(key, String(value)),
|
||||
removeItem: (key) => values.delete(key),
|
||||
}
|
||||
const body = fakeElement('body')
|
||||
const head = fakeElement('head')
|
||||
const document = {
|
||||
body, head,
|
||||
documentElement: fakeElement('html'),
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
createElement: (tag) => fakeElement(tag),
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}
|
||||
const window = {
|
||||
innerWidth: 1280,
|
||||
innerHeight: 900,
|
||||
localStorage,
|
||||
document,
|
||||
__ModuleLoader__: { load: (definition) => { window.captured = definition } },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
dispatchEvent() {},
|
||||
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
|
||||
setTimeout: () => 1,
|
||||
clearTimeout() {},
|
||||
setInterval: () => 1,
|
||||
clearInterval() {},
|
||||
open() {},
|
||||
}
|
||||
const hooks = makeHooks()
|
||||
const jsxRuntime = { Fragment, jsx, jsxs: jsx }
|
||||
const sandbox = {
|
||||
window, document, localStorage,
|
||||
self: window,
|
||||
location: { href: 'http://localhost/', protocol: 'http:' },
|
||||
navigator: { userAgent: 'node popup regression', platform: 'Win32', language: 'en-US' },
|
||||
console,
|
||||
atob,
|
||||
btoa,
|
||||
fetch: async () => ({ ok: false, status: 404, json: async () => ({}) }),
|
||||
setTimeout: window.setTimeout,
|
||||
clearTimeout: window.clearTimeout,
|
||||
setInterval: window.setInterval,
|
||||
clearInterval: window.clearInterval,
|
||||
requestAnimationFrame: () => 1,
|
||||
cancelAnimationFrame() {},
|
||||
MutationObserver: class { observe() {} disconnect() {} },
|
||||
ResizeObserver: class { observe() {} unobserve() {} disconnect() {} },
|
||||
IntersectionObserver: class { observe() {} unobserve() {} disconnect() {} },
|
||||
CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init?.detail } },
|
||||
URL, Blob, AbortController,
|
||||
indexedDB: undefined,
|
||||
}
|
||||
createContext(sandbox)
|
||||
runInNewContext(source, sandbox, { filename: BUNDLE, timeout: 20000 })
|
||||
assert(typeof window.captured?.factory === 'function', 'client bundle did not register a factory')
|
||||
const requireExternal = (id) => {
|
||||
if (id === 'react') return hooks.React
|
||||
if (id === 'react/jsx-runtime') return jsxRuntime
|
||||
if (id === 'react-dom') return { createPortal: (child) => child }
|
||||
if (id === 'scheduler') return {}
|
||||
throw new Error('unexpected external module: ' + id)
|
||||
}
|
||||
const exports = window.captured.factory(requireExternal)
|
||||
assert(typeof exports.WorktableSection === 'function', 'bundle did not export WorktableSection')
|
||||
return { component: exports.WorktableSection, hooks, window, values }
|
||||
}
|
||||
|
||||
function walk(node, predicate, out = []) {
|
||||
if (Array.isArray(node)) { for (const child of node) walk(child, predicate, out); return out }
|
||||
if (!node || typeof node !== 'object') return out
|
||||
if (typeof node.type !== 'undefined' && predicate(node)) out.push(node)
|
||||
walk(node.props?.children, predicate, out)
|
||||
return out
|
||||
}
|
||||
|
||||
const classHas = (node, name) => String(node.props?.className ?? '').split(/\s+/).includes(name)
|
||||
const byClass = (tree, name) => walk(tree, (node) => classHas(node, name))
|
||||
const click = (node, rect = fakeElement()) => {
|
||||
assert(typeof node?.props?.onClick === 'function', 'target has no onClick handler')
|
||||
node.props.onClick({ currentTarget: rect, target: rect, stopPropagation() {}, preventDefault() {} })
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { component, hooks, window, values } = loadComponent()
|
||||
const render = (wide) => hooks.render(component, { wide, t: (key) => key })
|
||||
let tree = render(false)
|
||||
assert(byClass(tree, 'dsh-wt_rail').length === 1, 'collapsed rail was not rendered')
|
||||
assert(byClass(tree, 'dsh-wt_add').length === 0, 'add popup unexpectedly started open')
|
||||
|
||||
const addFromConsole = window.__dshCustomEnv?.console?.onAdd
|
||||
assert(typeof addFromConsole === 'function', 'control-room onAdd action was not exposed')
|
||||
addFromConsole()
|
||||
tree = render(false)
|
||||
assert(byClass(tree, 'dsh-wt_add').length === 1, 'control-room add action did not render once in collapsed rail')
|
||||
tree = render(true)
|
||||
assert(byClass(tree, 'dsh-wt_add').length === 1, 'switching wide retained duplicate or lost add popup')
|
||||
click(byClass(tree, 'dsh-wt_popBackdrop')[0])
|
||||
tree = render(true)
|
||||
assert(byClass(tree, 'dsh-wt_add').length === 0, 'add popup backdrop did not close')
|
||||
|
||||
const viewOptionsButton = walk(tree, (node) => node.type === 'button' && node.props?.['aria-label'] === 'menu.viewOptions')[0]
|
||||
assert(viewOptionsButton, 'view-options control missing in wide mode')
|
||||
click(viewOptionsButton)
|
||||
tree = render(true)
|
||||
assert(byClass(tree, 'dsh-wt_manage').length === 1, 'manage popup did not open')
|
||||
tree = render(false)
|
||||
assert(byClass(tree, 'dsh-wt_manage').length === 1, 'switching collapsed while popup open lost or duplicated the popup')
|
||||
tree = render(true)
|
||||
|
||||
const layoutIcon = byClass(tree, 'dsh-wt_manageIcon').at(-1)
|
||||
assert(layoutIcon, 'layout icon control missing')
|
||||
click(layoutIcon)
|
||||
tree = render(true)
|
||||
assert(byClass(tree, 'dsh-wt_iconPop').length === 1, 'icon popup did not open once')
|
||||
const iconCell = byClass(tree, 'dsh-wt_iconCell')[1]
|
||||
assert(iconCell, 'icon choices missing')
|
||||
const chosenIcon = iconCell.props.children
|
||||
click(iconCell)
|
||||
tree = render(true)
|
||||
assert(byClass(tree, 'dsh-wt_iconPop').length === 0, 'choosing an icon did not close its popup')
|
||||
const savedProjects = JSON.parse(values.get('dsh.worktable.projects.v1'))
|
||||
assert(savedProjects.layouts[0].icon === chosenIcon, 'chosen layout icon was not persisted')
|
||||
|
||||
const layoutCard = byClass(tree, 'dsh-wt_layout').at(-1)
|
||||
const bindButton = layoutCard && walk(layoutCard, (node) => classHas(node, 'dsh-wt_bindBtn'))[0]
|
||||
assert(bindButton, 'layout binding control missing')
|
||||
click(bindButton)
|
||||
tree = render(true)
|
||||
assert(byClass(tree, 'dsh-wt_bindPop').length === 1, 'binding popup did not open once')
|
||||
click(byClass(tree, 'dsh-wt_popBackdrop').at(-1))
|
||||
tree = render(true)
|
||||
assert(byClass(tree, 'dsh-wt_bindPop').length === 0, 'binding popup backdrop did not close')
|
||||
|
||||
const deleteButton = walk(tree, (node) => node.type === 'button' && node.props?.title === 'manage.deleteLayout')[0]
|
||||
assert(deleteButton, 'layout delete control missing')
|
||||
click(deleteButton)
|
||||
tree = render(true)
|
||||
assert(walk(tree, (node) => node.props?.role === 'alertdialog').length === 1, 'delete confirmation did not open')
|
||||
const cancelButton = walk(tree, (node) => node.type === 'button' && node.props?.children === 'confirm.cancel')[0]
|
||||
assert(cancelButton, 'delete confirmation cancel control missing')
|
||||
click(cancelButton)
|
||||
tree = render(true)
|
||||
assert(walk(tree, (node) => node.props?.role === 'alertdialog').length === 0, 'delete confirmation did not close')
|
||||
|
||||
console.log('all sidebar-popup tests passed: add, icon, binding, delete confirmation, wide/collapsed switching')
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Split margin/CSSOM regression tests.
|
||||
* Usage: node split-margin.test.mjs [path/to/lib/client.js]
|
||||
*
|
||||
* Runs the actual client bundle in isolated headless Chromium with a temporary
|
||||
* profile. The fractional case uses real CSSStyleDeclaration and MutationObserver.
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import { copyFileSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { dirname, join, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = resolve(testDir, '..')
|
||||
const bundlePath = resolve(process.argv[2] ?? join(repoRoot, '01_content', 'lib', 'client.js'))
|
||||
assert.ok(existsSync(bundlePath), 'client bundle not found: ' + bundlePath)
|
||||
|
||||
const browserPath = process.env.DSH_TEST_CHROMIUM || [
|
||||
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||
].find(existsSync)
|
||||
assert.ok(browserPath && existsSync(browserPath),
|
||||
'Chromium not found; set DSH_TEST_CHROMIUM to an installed Chromium executable')
|
||||
|
||||
const browserCode = [
|
||||
"const result = document.getElementById('result');",
|
||||
"const lines = [];",
|
||||
"const record = (name, ok, detail) => lines.push((ok ? 'PASS ' : 'FAIL ') + name + (detail ? ' (' + detail + ')' : ''));",
|
||||
"const sleep = () => new Promise(resolve => setTimeout(resolve, 80));",
|
||||
"const makeStub = () => {",
|
||||
" const stub = function () { return makeStub(); };",
|
||||
" return new Proxy(stub, {",
|
||||
" get: (_target, key) => (key === Symbol.toPrimitive ? () => 'stub' : makeStub()),",
|
||||
" apply: () => makeStub(),",
|
||||
" construct: () => makeStub(),",
|
||||
" });",
|
||||
"};",
|
||||
"const requireStub = id => {",
|
||||
" if (id === 'react' || id === 'react/jsx-runtime') return makeStub();",
|
||||
" throw new Error('unexpected external require: ' + id);",
|
||||
"};",
|
||||
"const makeSpec = (id, extra = {}) => Object.assign({",
|
||||
" id, title: id, top: null,",
|
||||
" main: [{ id: 'p1', title: 'p1', min: 200, content: null }],",
|
||||
" chatWidth: { default: 320, min: 240, max: 600 },",
|
||||
"}, extra);",
|
||||
"function addRoot(phase, width, headerHeight = 48) {",
|
||||
" const root = document.createElement('div');",
|
||||
" root.dataset.phase = phase;",
|
||||
" root.className = 'wSkVaW_root';",
|
||||
" root.style.cssText = 'position:absolute;left:0;top:0;width:' + width + 'px;height:800px;padding:0;border:0;box-sizing:content-box;';",
|
||||
" let header = null;",
|
||||
" let area;",
|
||||
" if (headerHeight === null) {",
|
||||
" area = document.createElement('div');",
|
||||
" root.appendChild(area);",
|
||||
" } else {",
|
||||
" header = document.createElement('div');",
|
||||
" header.className = 'wSkVaW_header';",
|
||||
" header.style.cssText = 'height:' + headerHeight + 'px;width:100%;';",
|
||||
" area = document.createElement('div');",
|
||||
" area.style.cssText = 'height:700px;width:100%;';",
|
||||
" root.appendChild(header);",
|
||||
" root.appendChild(area);",
|
||||
" }",
|
||||
" document.body.appendChild(root);",
|
||||
" return { root, header, area };",
|
||||
"}",
|
||||
"async function runCase(name, fn) {",
|
||||
" try { await fn(); record(name, true); }",
|
||||
" catch (error) { record(name, false, String(error && error.message || error)); }",
|
||||
" try { if (window.__splitStore && window.__splitStore.active) window.__splitStore.close(); } catch {}",
|
||||
" document.querySelectorAll('[data-phase]').forEach(el => el.remove());",
|
||||
" await sleep();",
|
||||
"}",
|
||||
"(async () => {",
|
||||
" try {",
|
||||
" if (!window.__captured || typeof window.__captured.factory !== 'function') throw new Error('client factory not captured');",
|
||||
" window.__captured.factory(requireStub);",
|
||||
" const splitStore = window.__dshWorktable && window.__dshWorktable.splitStore;",
|
||||
" if (!splitStore) throw new Error('splitStore not exported');",
|
||||
" window.__splitStore = splitStore;",
|
||||
" document.body.style.margin = '0';",
|
||||
" await runCase('A fractional CSSOM write stays open', async () => {",
|
||||
" const { root, area } = addRoot('active', 1000.333);",
|
||||
" if (!splitStore.open(makeSpec('split-margin-a'))) throw new Error('open failed');",
|
||||
" splitStore.setChatW(splitStore.chatW + 1);",
|
||||
" const colW = splitStore.geom.right - splitStore.geom.left;",
|
||||
" const rawMargin = Math.max(0, colW - splitStore.chatW) + 'px';",
|
||||
" const probe = document.createElement('div');",
|
||||
" probe.style.marginLeft = rawMargin;",
|
||||
" if (probe.style.marginLeft === rawMargin) throw new Error('fixture did not exercise CSSOM rounding: ' + rawMargin);",
|
||||
" await sleep();",
|
||||
" if (!splitStore.active) throw new Error('self-write closed split; raw=' + rawMargin + ', CSSOM=' + probe.style.marginLeft);",
|
||||
" if (area.style.marginLeft !== probe.style.marginLeft) throw new Error('unexpected applied CSSOM value: ' + area.style.marginLeft);",
|
||||
" root.remove();",
|
||||
" });",
|
||||
" await runCase('B top/left resizing stays open', async () => {",
|
||||
" const { root } = addRoot('active', 1000.333);",
|
||||
" const spec = makeSpec('split-margin-b', {",
|
||||
" top: [{ id: 't1', title: 't1', min: 120, content: null }],",
|
||||
" left: { id: 'l1', title: 'l1', min: 160, content: null },",
|
||||
" topHeight: { default: 200, min: 80 },",
|
||||
" leftWidth: { default: 260, min: 160 },",
|
||||
" });",
|
||||
" if (!splitStore.open(spec)) throw new Error('open failed');",
|
||||
" splitStore.setTopH(201.4);",
|
||||
" splitStore.setLeftW(301.4);",
|
||||
" splitStore.setChatW(400);",
|
||||
" await sleep();",
|
||||
" if (!splitStore.active) throw new Error('resizing closed split');",
|
||||
" root.remove();",
|
||||
" });",
|
||||
" await runCase('C external style rewrite still yields', async () => {",
|
||||
" const { area } = addRoot('active', 1000);",
|
||||
" if (!splitStore.open(makeSpec('split-margin-c'))) throw new Error('open failed');",
|
||||
" await sleep();",
|
||||
" if (!splitStore.active) throw new Error('split closed before external rewrite');",
|
||||
" area.style.marginRight = '19px';",
|
||||
" await sleep();",
|
||||
" if (splitStore.active) throw new Error('external rewrite was not detected');",
|
||||
" });",
|
||||
" await runCase('D yield observer follows a new view area', async () => {",
|
||||
" const old = addRoot('hero', 1000, null);",
|
||||
" if (!splitStore.open(makeSpec('split-margin-d'))) throw new Error('open failed');",
|
||||
" await sleep();",
|
||||
" const next = addRoot('active', 1000);",
|
||||
" splitStore.syncAnchor();",
|
||||
" await sleep();",
|
||||
" if (splitStore.root !== next.root || splitStore.viewArea !== next.area) throw new Error('syncAnchor did not move to new root');",
|
||||
" if (!splitStore.active) throw new Error('split closed during re-anchor');",
|
||||
" next.area.style.marginLeft = '23px';",
|
||||
" await sleep();",
|
||||
" if (splitStore.active) throw new Error('new view area rewrite was not observed');",
|
||||
" old.root.remove();",
|
||||
" });",
|
||||
" await runCase('E integer geometry stays open', async () => {",
|
||||
" const { root } = addRoot('active', 1000);",
|
||||
" if (!splitStore.open(makeSpec('split-margin-e'))) throw new Error('open failed');",
|
||||
" await sleep();",
|
||||
" if (!splitStore.active) throw new Error('integer geometry closed split');",
|
||||
" root.remove();",
|
||||
" });",
|
||||
" } catch (error) {",
|
||||
" lines.push('FAIL setup (' + String(error && error.message || error) + ')');",
|
||||
" }",
|
||||
" result.textContent = (lines.some(line => line.startsWith('FAIL ')) ? 'FAIL|' : 'PASS|') + lines.join('|');",
|
||||
"})();",
|
||||
].join('\n')
|
||||
|
||||
const html = [
|
||||
'<!doctype html><html><head><meta charset="utf-8"></head><body>',
|
||||
'<pre id="result">RUNNING</pre>',
|
||||
'<script>window.__ModuleLoader__ = { load: reg => { window.__captured = reg; } };</script>',
|
||||
'<script src="./client.js"></script>',
|
||||
'<script>',
|
||||
browserCode,
|
||||
'</script>',
|
||||
'</body></html>',
|
||||
].join('\n')
|
||||
|
||||
const systemTemp = realpathSync(tmpdir())
|
||||
const tempDir = mkdtempSync(join(systemTemp, 'dsh-worktable-split-margin-'))
|
||||
try {
|
||||
const tempReal = realpathSync(tempDir)
|
||||
assert.ok(tempReal.toLowerCase().startsWith(systemTemp.toLowerCase() + sep), 'temporary directory escaped system temp')
|
||||
copyFileSync(bundlePath, join(tempDir, 'client.js'))
|
||||
writeFileSync(join(tempDir, 'index.html'), html, 'utf8')
|
||||
const browser = spawnSync(browserPath, [
|
||||
'--headless=new',
|
||||
'--disable-gpu',
|
||||
'--disable-extensions',
|
||||
'--disable-background-networking',
|
||||
'--allow-file-access-from-files',
|
||||
'--force-device-scale-factor=1.5',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--dump-dom',
|
||||
'--virtual-time-budget=5000',
|
||||
'--user-data-dir=' + join(tempDir, 'profile'),
|
||||
pathToFileURL(join(tempDir, 'index.html')).href,
|
||||
], { encoding: 'utf8', timeout: 30000, maxBuffer: 5 * 1024 * 1024 })
|
||||
if (browser.error) throw browser.error
|
||||
const resultMatch = /<pre id="result">([\s\S]*?)<\/pre>/.exec(browser.stdout || '')
|
||||
assert.ok(resultMatch, 'Chromium did not return the test result. stderr: ' + String(browser.stderr || '').slice(-1200))
|
||||
const result = resultMatch[1]
|
||||
for (const line of result.split('|').slice(1)) console.log(line)
|
||||
assert.ok(result.startsWith('PASS|'), 'split margin regression failed: ' + result)
|
||||
assert.equal(browser.status, 0, 'Chromium exited with status ' + browser.status)
|
||||
console.log('all split margin tests passed')
|
||||
} finally {
|
||||
const tempReal = realpathSync(tempDir)
|
||||
assert.ok(tempReal.toLowerCase().startsWith(systemTemp.toLowerCase() + sep), 'refusing to remove path outside system temp')
|
||||
rmSync(tempReal, { recursive: true, force: true })
|
||||
}
|
||||
@@ -4,8 +4,6 @@
|
||||
|
||||
## 协作方式(用户定案,最高优先级)
|
||||
|
||||
> 与全局 `~/.dsh/AGENTS.md` 同步;外部 Agent(如 Codex)不加载全局文件,故此处保留全文。
|
||||
|
||||
- **设计先给最小版**:任何 UI/文案/方案先交「最少元素」版本给用户拍板,确认后再增量;
|
||||
默认克制——没有存在理由的元素不放;不一次搭「完整版」再返工。
|
||||
- **长任务拆 checkpoint**:每个可验收阶段完成后停下汇报,等用户确认再进下一段;
|
||||
@@ -13,15 +11,14 @@
|
||||
- **对外动作永远先审**:发布、评论、给观众的内容、任何公开操作,一律先给用户过目,
|
||||
用户不点头不执行。
|
||||
- **改完必读回**:每次编辑后读回改动处的完整行/段落,确认无残留、无断尾
|
||||
(教训:改一半的 URL 留下旧尾巴,被复审抓为发布阻断)。
|
||||
(包含命令与 URL)。
|
||||
- **验收用最终产物,不用中间信号**:任何交付物(tgz、命令、文档、UI)的验收动作必须是
|
||||
「解包 / 复制 / 实跑用户路径」;「构建成功」「bundle 里有字符串」「退出码 0」不算验收。
|
||||
- **发布前自跑最终产物清单**:干净目录安装、最终包逐文件核对、双资产哈希、关键行逐字
|
||||
grep —— 先自查再报告,不等外部复审来抓。
|
||||
- **发布包验收=结构检查 + 真实安装**:标准 package/ 前缀、白名单精确 7 文件(无 src/、.map),
|
||||
再独立目录 npm install + import() 断言;仅核对文件存在不能证明包可安装。
|
||||
- **声明可证伪,不许写满**:验收结论只写已验证范围。「通知全覆盖」「等价端到端」这类表述会被
|
||||
反向检查打脸——只写「版本比较逻辑上重新激活」「验证了安装与服务端模块导入」。
|
||||
- **声明可证伪**:只写已验证范围;安装/导入不是端到端验收,版本比较正常不等于通知覆盖所有用户。
|
||||
- **同 tag 资产不可覆盖(用户已拍板)**:坏包处理 = 发新版本号 + 旧 Release 正文标注问题,
|
||||
默认不替换旧资产;仅经用户明确批准的紧急补救例外,且须:不改名(保持原 URL 名)、
|
||||
用已验证包、不删不盖现有资产、上传后从完整 URL 下载核对、旧 Release 注明补救经过。
|
||||
@@ -32,9 +29,17 @@
|
||||
- **不替换、不禁用任何官方插件**(ui-sidebar / ui-workspace / ui-layout)。
|
||||
- 项目/布局/绑定存 localStorage,媒体存 IndexedDB;文件窗、mkdir 与产物握手会读写用户配置的项目目录,权限边界见 `01_content/README.md`。
|
||||
- dsh-travelatlas 是入驻项目而非本仓库的一部分;协议见 `02_process/PRD.md` §5.3/5.4。
|
||||
- **平台边界**:Windows 网页端已测范围见 `01_content/README.md`,不宣称全功能验收;桌面仅列已测 Windows 0.2.0-rc.2 范围;macOS 为实验性支持(核心文件路径代码已做跨平台适配,
|
||||
- **平台边界**:Windows 网页端已测范围见 `01_content/README.md`,不宣称全功能验收;桌面已测版本与候选边界同包内 README;macOS 为实验性支持(核心文件路径代码已做跨平台适配,
|
||||
尚未真机端到端验证)。路径拼接必须走 `pathutil.ts` helper 或 Node `path` API,不手写分隔符。
|
||||
|
||||
## 当前 0.5.0 安全与兼容范围
|
||||
|
||||
- 私有 HTTP/终端握手统一调用 `requestAuth.ts`:新宿主要求 owner,0.2.0 仅放行同一 operator 对象;未知/不可用服务关闭访问,不用 requestRejection-only 回退。health 与随机只读预览地址是明确例外。
|
||||
- `SandboxedFrame` 禁止 `allow-same-origin`;本地目录先鉴权创建临时只读挂载,Desktop 资源走 Host HTTP 地址。不得为恢复页面存储/自动取字而取消隔离。影响、保活与过期语义必须见包内 README。
|
||||
- 预览读取必须绑定同一文件句柄、限量并复核文件身份/路径及撤销状态;保留目录替换/检查后增长等回归。该机制不等于恶意本地进程的 OS 文件沙箱,文档不得作绝对防护承诺。
|
||||
- 当前版本只声明 DSH 0.2.0-rc.2 / 0.2.1-rc.3;网页隔离实测与 Desktop 传输单测不能冒充真实 Desktop 全功能验收。下文旧版本兼容条目是历史依据。
|
||||
- `npm run test:security` = 22 项源码/夹具回归;流水线源码回归合计 122 项。安装产物另跑 `node 04_test/split-margin.test.mjs [client.js 路径]` 的 5 项真实 Chromium 回归(Edge/Chrome 或 `DSH_TEST_CHROMIUM`),改 margin/换根观察器必须保留旧产物变红的回归证据。
|
||||
|
||||
## 构建与验证
|
||||
|
||||
```powershell
|
||||
@@ -49,16 +54,17 @@ node --check lib/index.js
|
||||
- **构建必须 `cd 01_content` 后执行**:误在仓库根跑会把 lib 写到仓库根 `lib/`,宿主仍加载
|
||||
`01_content/lib` 旧 bundle,出现「改完不生效」假象(已有教训,见工作日志)。
|
||||
- **发布打包唯一入口 = `npm run pack`(01_content/release-prep.mjs)**:身份断言(package/manifest/cordis.patch.yml 严格结构)→
|
||||
版本一致性 → **会话/输入/传输/挂载回归**(test:input / test:session / test:details / test:transport / test:widget 共 98 项,评估源码契约)→ 构建(cwd 固定 01_content)→ node --check → npm pack → 结构清单断言(package/ 前缀 + 精确 7 文件,
|
||||
版本一致性 → **会话/输入/传输/挂载/安全回归**(当前 122 项,评估源码契约及夹具)→ 构建(cwd 固定 01_content)→ node --check → npm pack → 结构清单断言(package/ 前缀 + 精确 7 文件,
|
||||
无 src/、无 .map)→ 独立临时目录 npm install + import() 断言(apply 函数/inject 含 webServer+sessions/name/
|
||||
HEALTH_PATH/包内双 bundle 版本)→ **客户端工厂求值门禁**(ModuleLoader 恰好注册一次 + ID 校验 + 精确外部依赖
|
||||
白名单 react/react/jsx-runtime + apply/inject 断言)→ **分栏锚点 DOM 回归**(8 场景,评估安装产物 lib/client.js;
|
||||
测试接受包路径参数)→ **服务端数据目录回归**(3 组场景,评估安装产物 lib/index.js)→ dist/v版本号/ 双资产(终态恰好 2 文件 + 双 SHA 同源)。
|
||||
白名单 react/react/jsx-runtime + apply/inject 断言)→ **分栏锚点 DOM 回归**(14 场景,评估安装产物 lib/client.js;
|
||||
测试接受包路径参数)→ **侧栏弹窗回归**(安装产物,展开/收起共用层)→ **真实 Chromium 分栏 margin 回归**(5 场景,评估安装产物)→ **服务端数据目录回归**(3 组场景,评估安装产物 lib/index.js)→ dist/v版本号/ 双资产(终态恰好 2 文件 + 双 SHA 同源)。
|
||||
脚本零 git/gh 动作,发布上传由 gh 手动完成。
|
||||
**发布禁止裸 npm pack 或手工 tar 生成发布包**;脚本从仓库任意目录调用均安全(以自身位置解析)。
|
||||
- 弹窗必须由展开/收起两种侧栏共用一份 JSX 层;保留路径选择失效/卸载守卫,禁止再复制弹窗。`npm run test:sidebar` 评估实际构建组件,旧缺失/重复渲染应变红。
|
||||
- **配套检查入口**:`npm run test:gate` = 工厂门禁 10 个失败/正向用例;
|
||||
`npm run test:widget` = 项目/会话归属、共目录、延迟结果与补挂重试 26 项回归(执行实际函数,不等于模型生成端到端验收);
|
||||
`node 04_test/anchor-dom.test.mjs [lib/client.js 路径]` = 分栏锚点 8 场景(缺省用工作目录构建产物);
|
||||
`node 04_test/anchor-dom.test.mjs [lib/client.js 路径]` = 分栏锚点 14 场景(缺省用工作目录构建产物);
|
||||
`node 04_test/server-home.test.mjs [lib/index.js 路径]` = 数据目录解析 3 组场景(子进程隔离夹具;
|
||||
**突变体验证法**:把 loadPkg 兜底的 baseDshHome() 故意改回 resolveDshHomeSafe() 恢复循环,测试必须红)。
|
||||
`npm run verify:remote -- --expect-sha <release-prep 输出的 SHA> [tag]` = 发布后只读核对
|
||||
@@ -70,6 +76,7 @@ node --check lib/index.js
|
||||
|
||||
| 实现参考 | 对应材料 |
|
||||
| --- | --- |
|
||||
| 接口鉴权、预览能力、隔离限制与验收边界 | `docs/compatibility-and-security.md` |
|
||||
| 控制室 UI、预设/模型、提醒、产物握手、更新状态 | `02_process/PRD.md` §14(修改对应组件时对照源码,不以历史 API 示例替代新旧桥接) |
|
||||
|
||||
- **Desktop 兼容边界**:0.4.0 保留客户端 platform=web(Desktop 0.2.0-rc.2 也按此契约加载)。终端 WebSocket 经 `hostTransport.ts` 使用宿主 `__DSH_TRANSPORT__.streamBaseUrl`,不能由 dsh-app://app 拼 ws://app;更新提示须指向 desktop profile 与桌面端自带 CLI,不能修改 web profile。页面点测与源码回归不能替代完整桌面端业务验收,不宣称桌面全功能兼容。
|
||||
@@ -83,20 +90,8 @@ node --check lib/index.js
|
||||
- **预设追加规则**:新布局预设只允许追加到 `PRESET_DEFS` 末尾(选择器里的「+自定义」磁贴
|
||||
永远是最后一个);字段 leftCount/topCount/contentCount/chatFull/topHeightDefault/topHeightRatio,
|
||||
聊天窗恒在右侧;缩略图在 presetThumb() 加分支。
|
||||
- **分栏引擎双版本锚点(v0.3.3 兼容契约,0.1.1-rc.2 与 0.1.2-rc.1)**:会话根三种结构——
|
||||
0.1.1 active = [头部, 滚动区];空会话 hero = [隐藏头部(headerHidden), 内容区];0.1.2 无会话
|
||||
hero = [内容区](单子元素)。resolveAnchor:仅「存在第二个子元素」且第一个可见(自身有高度,
|
||||
或零高槽位包装内第一个可见后代——visibleHeaderIn)才作头部;否则顶部取根顶部。phase 排序
|
||||
active > hero > settling,settling 等待不关闭。**同根重锚(hero↔active)只在锚点元素真正
|
||||
更换时才更新 saved 原值**,否则关闭时泄漏已应用的 margin/宽度变量。改锚点必须跑
|
||||
anchor-dom 8 场景(含 H:0.1.2 零高包装+内部头部)。0.1.2 的 children[0] 是 display:contents
|
||||
式零高包装、真实标题栏在内部——不要凭 0.1.1 的结构想当然。
|
||||
- **数据目录解析无循环(v0.3.3 红线)**:resolveDshHomeSafe = 官方 @deepseek-ai/dsh-home-paths
|
||||
(可用时)→ baseDshHome 兜底;**loadPkg 的 profiles 兜底只能用 baseDshHome,禁止回调
|
||||
resolveDshHomeSafe(会成环)**。baseDshHome 规则对齐官方:DSH_HOME 优先、空/纯空白视为未设、
|
||||
~ 与 ~/ 与 ~\ 展开、相对路径按 cwd、默认 ~/.dsh;trim 只用于空白判断、路径保留原字符串。
|
||||
**不把 dsh-home-paths 声明为生产依赖**(其 peer cordis ^4.0.2 不满足 0.1.1-rc.2 的 4.0.1,
|
||||
已试过并撤回)。改这块必须跑 server-home 3 组场景 + 突变体验证(恢复循环测试必须红)。
|
||||
- **分栏锚点与还原**:历史宿主三种根结构、零高包装取可见头部的规则见 PRD §12.4;同根重锚仅在锚点更换时保存原值,避免关闭时泄漏 margin/宽度。写入 margin 后须从 CSSOM 读回基线;换 viewArea 同步重绑让位观察器。改动必须跑锚点与真实 Chromium 回归,保留旧产物变红证据。
|
||||
- **数据目录解析无循环**:resolveDshHomeSafe 优先官方 home-paths,再 baseDshHome;loadPkg 兜底只能调 baseDshHome,不得反调 resolveDshHomeSafe。DSH_HOME 空白视未设;实际路径保留两端空格,支持 ~、~/、~\\ 与相对 cwd,默认 ~/.dsh。不得新增 dsh-home-paths 生产依赖(旧宿主 peer cordis 不兼容)。修改须跑 server-home 三组与循环突变体检查。
|
||||
- **新会话策略**:创建后应用默认预设与有效模型;桥接保持原选择策略,持有结束即释放。失败不重复发送;插件导航须 markPluginSessionOpen,未绑定项目成功任务会 autoBind。
|
||||
- **项目文件夹**:保存要求绝对路径,mkdir 非 2xx 必须中止;自定义任务带项目 cwd,产出落该目录。
|
||||
- **窗口任务**:buildWindowTaskText 统一携带窗口身份/项目目录/通用知识;不重复侦察插件源码。
|
||||
@@ -111,17 +106,13 @@ node --check lib/index.js
|
||||
- **控制室红线**:wt-console 固定首位、不可删除、唯一 console 标签不可关;隐藏项目不入网格。状态/计时/预览只镜像宿主,不为计时激活冷会话,不把完成标记当成功证明;业务临时持有结束释放。
|
||||
- **更新检查/发布**:设置与控制室检查暂未去重,共用 updateCheck/lastUpdateCheck/skipVersion.v1,updateCache.v1 仅控制室。单次 8s、最多 3 次尝试;失败不可报最新。版本从 package.json 构建注入;新版本需 tag+Release,双资产固定名 dsh-worktable.tgz + 版本化名同 SHA。插件仅提示不自升级,安装/重启留给用户。
|
||||
|
||||
## 旧版 link 加载兼容边界
|
||||
## link 加载兼容边界
|
||||
|
||||
- 特定旧版 0.1.1-rc.2 的裸包 import / link peer 解析曾使用上级 node_modules junction 兜底,不是通用修法。
|
||||
本机 0.2.0-rc.2 经无旧链接冷启动与真实工作台/控制室检查后已移除该链接,原目标目录保留。
|
||||
重新遇到加载失败时先查实际宿主、profile、插件及第一条完整错误;不得直接创建更高层级链接或删除它指向的依赖目录。
|
||||
- 不把旧宿主的祖先 node_modules junction 当通用修法。加载失败先核对宿主/profile/插件版本与第一条完整错误;不得直接创建更高层级链接、删除其目标目录或把社区桌面 resources/app 路径视为官方桌面的固定契约。
|
||||
|
||||
## 宿主 bug 跟踪(UTF-16 路径截断)
|
||||
## 宿主历史补丁
|
||||
|
||||
- 官方 native picker 的 readUtf16 只看 UTF-16LE 低字节,含 U+XX00 字符(开/一/言/Ā/🀀 等)的路径被截断。非我们插件问题。
|
||||
修复与回归测试存档:`02_process/upstream/utf16-picker-fix.patch`;官方 Discussions #580 已接单。
|
||||
待办:官方修 master 后核验对应 npm 版本是否含修复,随后可清理 patch 存档。
|
||||
- `02_process/upstream/utf16-picker-fix.patch` 是旧 picker 路径问题的存档,不代表当前宿主仍有此缺陷。先核对目标版本源码与复现,不自动套用或清理旧补丁。
|
||||
|
||||
## 安装 / 重启
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
**An agent-project workbench for DeepSeek Harness** — a sidebar app drawer that turns every project into dockable windows, plus a built-in control room that watches them all in real time.
|
||||
|
||||
> Current source is **0.5.0**; publication is determined by GitHub Release. It adds host identity checks, sandboxed project pages, preview read limits, fractional-margin split fixes and collapsed-sidebar popups. Windows Web checks cover DSH 0.2.0-rc.2 / 0.2.1-rc.3; the user tested project opening, split resizing and sidebar settings in dev.2 on official Windows Desktop 0.2.1-rc.3. Formal-package installation/regressions are separate from candidate GUI evidence. DSH 0.1.x is outside this release’s support range. Embedded storage, login, WebGL textures and automatic annotation text extraction can be affected. See [compatibility and security](docs/compatibility-and-security.md); v0.4.0 sections below are historical release records.
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
| | |
|
||||
@@ -51,7 +53,7 @@
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
## Quick start (published v0.4.0)
|
||||
|
||||
**v0.4.0** adds Windows official Desktop support within the tested scope below. Web compatibility lists only DSH **0.1.1-rc.2 / 0.1.2-rc.1 / 0.2.0-rc.2**; Desktop checks cover **Windows + 0.2.0-rc.2**. Desktop page checks and targeted regressions do not establish full final-package Desktop end-to-end acceptance. Older versions rely on prior page checks plus code regressions, not a newly repeated full GUI suite. Back up important data and check other plugins before upgrading; GitHub Release is the publication authority.
|
||||
|
||||
@@ -81,7 +83,7 @@
|
||||
Save your tasks, fully exit the app from its menu/tray (closing the window is not enough), replace the installation-directory placeholder, run in PowerShell, then reopen the app manually:
|
||||
|
||||
```powershell
|
||||
& "<Desktop installation directory>\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add "https://github.com/Aisland-SJL/dsh-worktable/releases/download/v0.4.0/dsh-worktable-0.4.0.tgz"
|
||||
& "<Desktop installation directory>\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add "https://github.com/Aisland-SJL/dsh-worktable/releases/download/v0.5.0/dsh-worktable-0.5.0.tgz"
|
||||
```
|
||||
|
||||
Use the Desktop-bundled CLI and `desktop` profile, not an unpinned npx or Web CLI. Preserve the actual DSH_HOME. Browser-local projects/layouts/media do not automatically sync between Web and Desktop origins, even when sessions share one data home.
|
||||
@@ -94,7 +96,7 @@ Explicit **Ungrouped** in a custom-window task no longer silently joins a worksp
|
||||
|
||||
One package ships the **host Cordis plugin** and the **web client**:
|
||||
|
||||
- **host**: `/api/worktable/*` routes — health, file system, git, file read/write, site serving, mkdir, workspaces, native skin template; WebSocket `/api/worktable/term` for the terminal pane (PowerShell on Windows)
|
||||
- **host**: owner-checked file/Git/write/workspace routes and terminal WebSocket; public health and random read-only preview capabilities are explicit exceptions. See the [route and lifecycle reference](docs/compatibility-and-security.md).
|
||||
- **client**: injected into the sidebar and the shell overlay via the slot protocol; the split engine, tab model, drag/drop and persistence are self-built
|
||||
- **control room**: reads the host session list snapshot (running / pending / completed, jobs, subagent catalogs) — an event-driven mirror, no model involvement
|
||||
- **window tasks**: custom tasks register project/pane/binding ownership and write a dedicated result under `.dsh-worktable/project-<project ID>/`; sharing a folder does not make a new project inherit another project's windows
|
||||
@@ -112,9 +114,9 @@ node --check lib/index.js
|
||||
|
||||
- **Build must run inside `01_content`** — building from the repo root writes `lib/` to the wrong place while the host keeps loading the old bundle
|
||||
- The client bundle keeps the `window.__ModuleLoader__.load` handshake; `react` and `@deepseek-ai/*` stay external
|
||||
- Regression: `04_test/functional-diag.cjs` (20 steps, strict gate) plus targeted probes (control room, bind panel, collapsed rail, model inheritance), the path matrix (`04_test/pathutil-matrix.cjs`) and update-check scenarios (`04_test/probe-update-scenarios.cjs`)
|
||||
- In the release pipeline: split-anchor DOM regression `04_test/anchor-dom.test.mjs` (8 scenarios, both host conversation-root shapes) and data-home resolution regression `04_test/server-home.test.mjs` (3 groups: no-cycle fallback, path expansion, official-branch fixture)
|
||||
- Release packaging uses `npm run pack` only. It also runs 98 input/session/details/transport/widget regressions, installation and client-factory gates. `npm run test:widget` covers 26 ownership, session-isolation, shared-folder, late-result and retry cases; after uploading, run `npm run verify:remote -- --expect-sha <final SHA> v0.4.0` and check `latest` separately.
|
||||
- Historical diagnostic scripts such as `04_test/functional-diag.cjs` and browser probes contain local assumptions; inspect their paths/ports before running and use an isolated profile.
|
||||
- Installed-artifact checks: `04_test/anchor-dom.test.mjs` (14 DOM scenarios, including fractional geometry), `04_test/sidebar-popup.test.mjs` (expanded/collapsed sidebar popups), `04_test/split-margin.test.mjs` (5 real Chromium cases), and `04_test/server-home.test.mjs` (3 groups). Use Edge/Chrome or `DSH_TEST_CHROMIUM` for the browser check.
|
||||
- Release packaging uses `npm run pack` only. It also runs 122 source regressions, independent installation and client-factory checks. `npm run test:sidebar` runs the popup checks alone; `npm run test:widget` covers 26 ownership/session/late-result cases. After an approved release, run `npm run verify:remote -- --expect-sha <final SHA> <release tag>` and check `latest` separately.
|
||||
|
||||
---
|
||||
|
||||
@@ -146,12 +148,14 @@ Every window title bar has a small **annotate button** (chat-bubble with a plus)
|
||||
|
||||
- Point and speak — no verbal location description needed.
|
||||
- The payload is self-explaining: any agent, in any conversation, can pick it up.
|
||||
- Same behavior for every user — no local setup, no special tools.
|
||||
- Window-level annotation remains available; sandboxed pages cannot expose their internal text/DOM to the host. Describe the target or provide a screenshot when extraction is unavailable.
|
||||
- Same family of polish: the console gains a 5th dock button **Changelog** (version notes / check for updates / copy-upgrade command) and ships **two default background presets** (aurora gradient + coastal view) in the custom background library.
|
||||
|
||||
## Known limits
|
||||
|
||||
- **Platform**: Windows Web and the official Windows Desktop have the version/flow limits above. Full final-package Desktop end-to-end acceptance is not complete; macOS is experimental and has not been tested end to end on real hardware.
|
||||
- Sandboxing can break embedded storage, login and WebGL/Cesium textures. StarMap’s moon-texture issue was fixed separately in that project; the worktable package does not contain this project-specific fix. A working new project does not establish compatibility for every existing page.
|
||||
|
||||
- **Platform**: see the release scope above and [compatibility guide](docs/compatibility-and-security.md). Dev.2 passed the specified user Desktop checks; this does not establish complete formal-package Desktop acceptance; macOS has not been tested on real hardware.
|
||||
- State lives in the browser (localStorage and IndexedDB) — projects, bindings, views and media do not sync across machines
|
||||
- The terminal pane is a plain PowerShell host on Windows (no PTY feature parity with the native terminal app)
|
||||
- Auto-mount requires a registered custom task and its matching v2 result file. Old saved windows remain; unowned root-level `widget-result.json` files are not imported. To re-enable an old conversation, send one task via the target window's Custom → Send to conversation action. Later edits in that conversation may reuse the binding. Manually closing/replacing content revokes it; existing mistakenly mounted tabs must be closed once. Shared-folder projects can still overwrite the same artifact if explicitly told to write the same physical file.
|
||||
|
||||
+11
-7
@@ -4,6 +4,8 @@
|
||||
|
||||
**DeepSeek Harness 的 agent 项目工作台**——侧边栏「应用抽屉」把每个项目变成可停靠的窗口,再加一个实时监控所有项目的内置「控制室」。
|
||||
|
||||
> 当前源码为 **0.5.0**,公开状态以 GitHub Release 为准:接口身份校验、项目网页隔离、预览读取限制、小数分栏修复及折叠侧栏弹窗补全。Windows 隔离网页端已测 DSH 0.2.0-rc.2 / 0.2.1-rc.3;用户已在官方 Windows Desktop 0.2.1-rc.3 验证 dev.2 的项目打开、分栏缩放和侧栏设置弹窗。正式包安装/回归与候选 GUI 证据分开记录,不宣称全功能验收。0.1.x 不在本版支持范围。浏览器存储、登录、WebGL 贴图及标注自动取字可能受隔离影响,详见 [兼容与安全说明](docs/compatibility-and-security.md)。下方 v0.4.0 内容为历史发布记录。
|
||||
|
||||
## 📸 截图
|
||||
|
||||
| | |
|
||||
@@ -51,7 +53,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
## 快速开始(已发布 v0.4.0)
|
||||
|
||||
**v0.4.0** 在下述已测范围内增加 Windows 官方桌面端支持。网页端兼容声明仅列 **DSH 0.1.1-rc.2 / 0.1.2-rc.1 / 0.2.0-rc.2**;桌面已测范围为 **Windows 官方桌面端 + 0.2.0-rc.2**。桌面页面点测与定向回归不等于最终包完整桌面端业务验收;旧版沿用此前页面验收并补代码回归,未重跑整套旧版 GUI。升级前备份重要数据并核对其他插件,公开发布状态以 GitHub Release 为准。
|
||||
|
||||
@@ -81,7 +83,7 @@
|
||||
先保存任务,从应用菜单/托盘完整退出(只关窗口不够);将安装目录占位符替换为实际位置,在 PowerShell 执行,再手动重新打开:
|
||||
|
||||
```powershell
|
||||
& "<桌面端安装目录>\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add "https://github.com/Aisland-SJL/dsh-worktable/releases/download/v0.4.0/dsh-worktable-0.4.0.tgz"
|
||||
& "<桌面端安装目录>\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add "https://github.com/Aisland-SJL/dsh-worktable/releases/download/v0.5.0/dsh-worktable-0.5.0.tgz"
|
||||
```
|
||||
|
||||
必须使用桌面端自带 CLI 与 `desktop` profile,不用不固定版本的 npx 或网页 CLI 代替。保留实际 DSH_HOME;即便共用会话数据目录,网页与桌面来源的项目/布局/媒体也不会自动同步。
|
||||
@@ -112,10 +114,10 @@ node --check lib/index.js
|
||||
|
||||
- **构建必须在 01_content 内执行**——在仓库根构建会把 lib 写到错误位置,宿主仍加载旧 bundle
|
||||
- 客户端 bundle 保持 `window.__ModuleLoader__.load` 握手;react 与 @deepseek-ai/* 全部 external
|
||||
- 回归:`04_test/functional-diag.cjs`(20 步)+ 专项探测(控制室、绑定弹窗、收起态贴片、模型继承);
|
||||
发布流水线内嵌:分栏锚点 DOM 回归 `04_test/anchor-dom.test.mjs`(8 场景,双宿主会话结构)与
|
||||
- 历史诊断 `04_test/functional-diag.cjs` 与专项探针有本机路径/端口假设,运行前先核对,限定隔离 profile;
|
||||
发布流水线内嵌:分栏锚点 DOM 回归 `04_test/anchor-dom.test.mjs`(14 场景,含小数几何)与
|
||||
数据目录解析回归 `04_test/server-home.test.mjs`(3 组场景,无循环/路径展开/官方分支夹具)
|
||||
- 发布打包唯一入口为 `npm run pack`,另含 98 项输入/会话/详情/传输/挂载回归、独立安装与客户端工厂门禁;`npm run test:widget` 单独运行 26 项归属、会话隔离、共目录、延迟结果与重试回归。上传后用 `npm run verify:remote -- --expect-sha <最终 SHA> v0.4.0` 核对,并另查 latest。
|
||||
- 发布打包唯一入口为 `npm run pack`,含 122 项源码回归、独立安装与客户端工厂门禁;安装产物另跑侧栏弹窗、14 项锚点、5 项真实 Chromium 与数据目录回归。`npm run test:sidebar` 可单跑弹窗;`npm run test:widget` 为 26 项归属/会话隔离/补挂回归。批准发布后,用 `npm run verify:remote -- --expect-sha <最终 SHA> <发布 tag>` 核对,并另查 latest。
|
||||
|
||||
---
|
||||
|
||||
@@ -147,12 +149,14 @@ node --check lib/index.js
|
||||
|
||||
- 指一下 + 说一句话,不用口头描述位置。
|
||||
- 载荷自带说明:任何会话、任何 AI 收到都能按协议配合。
|
||||
- 对所有用户行为一致——无需本地配置、无需额外工具。
|
||||
- 窗口级标注可用;隔离网页内部文字/DOM 读取受限,缺失时应描述目标或提供截图。
|
||||
- 同批打磨:控制台新增第 5 键「更新公告」(版本说明/检查更新/复制升级命令);自定义背景媒体库开箱自带两张默认预设图(极光渐变 + 海岸风景)。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- **平台**:Windows 网页端与官方桌面端的已测版本/流程见上方说明,最终包完整桌面端业务验收仍未完成;macOS 为实验性支持且尚未真机端到端验证。
|
||||
- 网页隔离可能影响存储、登录及 WebGL/Cesium 贴图。StarMap 月球纹理已在独立项目中修复,不包含在工作台包中;新项目成功不能证明所有旧网页都兼容。
|
||||
|
||||
- **平台**:本版范围见页首与[兼容说明](docs/compatibility-and-security.md)。dev.2 已通过用户指定的桌面交互检查,不能外推正式包全部业务;macOS 尚未真机验证。
|
||||
- 状态在浏览器本地(localStorage 与 IndexedDB)——项目、绑定、视图与媒体不跨设备同步
|
||||
- 终端窗格在 Windows 上是朴素的 PowerShell 宿主(与原生终端应用无 PTY 对等)
|
||||
- 自动挂载要求已登记「自定义」任务且写出身份匹配的 v2 结果文件。旧已保存窗口保留,根目录旧 `widget-result.json` 不再导入;旧对话可从目标窗口「自定义 → 发送到会话」发一次任务建立新关联,随后在同一对话直接继续修改。手动关闭/替换内容会撤销关联,此前误挂的标签需关闭一次。同目录项目若主动写同一个实际文件,文件内容仍可能互相影响。
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# 工作台 0.5.0:兼容、隔离与故障处理
|
||||
|
||||
适用源码:0.5.0。公开版本以 GitHub Release 为准。本文解释当前代码契约;验证范围与安装命令见 [包内说明](../01_content/README.md)。
|
||||
|
||||
## 兼容范围
|
||||
|
||||
| 环境 | 已有证据 | 不能外推 |
|
||||
| --- | --- | --- |
|
||||
| Windows Web / DSH 0.2.0-rc.2、0.2.1-rc.3 | 前轮 dev.1 冷启动、页面、分栏、资源和鉴权隔离检查 | 全部模型任务、审批/子代理、所有自定义站点 |
|
||||
| 官方 Windows Desktop / 0.2.1-rc.3 | 已装 dev.2,用户确认项目打开、分栏缩放和侧栏设置弹窗正常;新项目前轮已测 | 正式 0.5.0 已装入用户 Desktop、完整桌面端验收 |
|
||||
| 旧 DSH 0.1.x、macOS、社区桌面壳 | 不在当前版本已测支持范围 | 不能因旧兼容分支仍在就声明支持 |
|
||||
|
||||
正式包的弹层与分栏回归在最终安装产物上运行;这不替代宿主真实页面验收,用户 Desktop 仍保留已验收的 dev.2。关闭并重新打开正式 DSH 由用户操作,不自动迁移会话或浏览器数据。
|
||||
|
||||
## 接口与访问身份
|
||||
|
||||
宿主直接注册的 WebServer 路由不自动继承宿主 API 登录保护。工作台用 `requestAuth.ts` 在每次私有 HTTP 请求及终端升级前调用当前宿主的 `connection.admit(req)`:
|
||||
|
||||
- 有 principal 的新宿主仅放行 owner;0.2.0 分支仅放行同一 operator 对象且无 principal。
|
||||
- 未登录返回 401,非允许身份返回 403;安全服务缺失、抛错或响应不可确认返回 503。
|
||||
- 不缓存一次登录为永久授权,不另建插件密码体系;无跳过校验开关。
|
||||
- WS 建连后不逐字符重新鉴权;本轮未实现宿主撤权后立即断开已有连接。插件卸载会关闭自己的连接及 Shell。
|
||||
|
||||
| 路由 | 能力与身份 |
|
||||
| --- | --- |
|
||||
| GET /api/worktable/health | 公开,仅插件/版本/ok,不证明界面或模型业务通过 |
|
||||
| /api/worktable/fs、git、file、write、mkdir、workspaces、template,以及旧 site 路由 | 宿主身份校验;原请求字段保持 |
|
||||
| WS /api/worktable/term | 创建 PTY 前校验身份;缺少宿主 ws/node-pty 则降级 |
|
||||
| POST /api/worktable/preview | 身份校验后创建/撤销目录预览 |
|
||||
| GET/HEAD /api/worktable/preview-static/<临时令牌>/<相对资源> | 令牌允许的只读目录资源,不需宿主登录;令牌是临时访问能力,不能分享或持久保存 |
|
||||
|
||||
登录的工作台主页面可调用 `POST /api/worktable/preview`,JSON body 为 `{"root":"<绝对目录>"}`。返回 `{token,base}`;用 base 拼接经编码的相对资源路径。撤销使用同一路由及 `{"revoke":"<该次token>"}`。示例只表示字段契约,不提供真实 token。
|
||||
|
||||
不应从隔离页面访问这个创建接口,也不要为方便 curl 或模型 Shell 直调而关闭鉴权。普通未登录脚本得到 401 是预期变化。
|
||||
|
||||
## 网页隔离与资源流向
|
||||
|
||||
1. 客户端保存的是原始目标 URL,不保存临时访问能力。
|
||||
2. 本地旧 site URL 被识别后,由主页面先申请只读目录预览;Desktop 通过宿主 Host HTTP 地址取资源,不能把 dsh-app://app 当 HTTP 主机。
|
||||
3. browser/anim/iframe 窗格使用 `sandbox="allow-scripts allow-forms allow-downloads"`,不提供 allow-same-origin。
|
||||
4. 静态响应附 CSP sandbox、no-store、no-referrer、nosniff 与允许无凭据资源读取的 CORS 头;HTML 不获得访问 DSH 主页面的同源身份。
|
||||
|
||||
目录先解析真实路径,拒绝已识别的越界路径与目录联接;访问范围包括子目录,并非只允许当前 HTML。页面仍可联网,**不是防外发保证**,不应挂载含敏感文件的目录。
|
||||
|
||||
读取绑定同一文件句柄,前后核对路径、文件身份(dev/ino)与大小/时间;最多读取 40 MiB + 1 字节用于判超,超限返回 413,替换/改写或读取中撤销则不返回内容。测试覆盖单次目录联接替换、文件替换和检查后增长。这是隔离网页的只读访问边界加固,不承诺抵御另一个恶意本地进程精确配合的 ABA 路径交换;不应将本插件作为限制本地 Agent/进程文件权限的操作系统沙箱。多个并发请求仍可能累积内存,40 MiB 不是总内存额度。
|
||||
|
||||
限制:最多 128 个挂载、每个资源最多 40 MiB、30 分钟闲置过期。关闭具体标签、刷新替换或卸载会撤销;项目切换/关闭沿用最多 6 个项目的保活池,不立即销毁其中仍挂载的页面。过期后刷新窗格重新申请。
|
||||
|
||||
## 对页面开发者的影响
|
||||
|
||||
- localStorage、IndexedDB、Cookie 登录、同源 fetch、页面内 iframe/object、弹窗/顶层跳转与标注自动取字可能受限。
|
||||
- 相对 CSS、JS module 和 JSON fetch 已在夹具页面验证;不能据此保证所有前端库。
|
||||
- **WebGL/Canvas 图片纹理**还要求图片请求与响应满足跨源条件。仅服务器返回 CORS 头未必足够;加载图片的代码/库也需正确请求。StarMap 的月球纹理路径已在其独立项目中通过 Blob 图片加载与保留隔离修复、验证;这不是工作台包内修复,也不证明所有旧网页纹理都已兼容。
|
||||
- 自动标注可退为窗口级坐标/用户描述,不承诺从隔离 DOM 获取文字。
|
||||
- React 组件插件与宿主权限是另一条边界;iframe 隔离不等同把所有已安装插件变成不可信代码沙箱。
|
||||
|
||||
## 常见故障
|
||||
|
||||
| 表现 | 检查与处置 |
|
||||
| --- | --- |
|
||||
| 401 / 403 | 在宿主主界面确认当前身份;不要复制浏览器凭据给脚本 |
|
||||
| 503 Host authentication unavailable | 核对 DSH/插件版本与 connection 服务;不开放路由兜底 |
|
||||
| 预览 400 | 查看目录是否绝对、存在且为目录,或是否超过挂载数量;创建仅接受 POST |
|
||||
| 预览 404 | 可能已撤销、过期、文件不存在或越界;刷新后重试合法资源 |
|
||||
| 413 | 预览资源超过 40 MiB;普通 file 路由上限为 20 MiB |
|
||||
| WebGL texImage2D SecurityError | 记录具体纹理 URL、网络请求及完整错误,检查图片跨源加载,不去掉 sandbox |
|
||||
| 收起侧栏后弹窗不见/出现双份 | 弹层必须共用一份,宽/窄各引用一次;运行 sidebar-popup 回归 |
|
||||
| 拖动/缩放后分栏关闭 | 检查 CSSOM 读回基线与换根观察器;运行 anchor-dom 与 split-margin |
|
||||
| link 安装终端不可用 | 核对实际宿主打包结构与 ABI;不能直接套社区桌面 resources/app 路径 |
|
||||
|
||||
## 回归与发布
|
||||
|
||||
从 `01_content` 执行 `npm run pack`;它评估最终临时安装包中的客户端工厂、锚点、弹层与真实 Chromium margin,并核对服务端数据目录和双资产 SHA。单独运行:
|
||||
|
||||
- `npm run test:sidebar`:当前构建产物的弹层回归;脚本可直接接受任意 client.js 路径。
|
||||
- `node ../04_test/anchor-dom.test.mjs [client.js]`:14 场景,含 PR #16 的 6 个小数几何补充。
|
||||
- `node ../04_test/split-margin.test.mjs [client.js]`:5 个真实 Chromium 场景。
|
||||
- `npm run test:security`:鉴权、只读预览及生命周期;不等于完整渗透测试。
|
||||
|
||||
正式版本/公告需同步;源码、包内 README 或版本变化后必须重新打包。新最终包完成验收后再申请发布,上传后按最终 SHA 跑 verify:remote 并核对指定 tag 与 latest;同 tag 资产不覆盖。
|
||||
Reference in New Issue
Block a user