feat(v0.5.0): secure project previews and fix host UI compatibility

This commit is contained in:
Aisland
2026-10-27 23:09:34 +08:00
parent 6cb86b6c73
commit 56ffe0cd1c
30 changed files with 3202 additions and 1447 deletions
+1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+262 -25
View File
@@ -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);
+4 -3
View File
@@ -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",
+8 -6
View File
@@ -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": {
+23 -2
View File
@@ -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;官方路径 / 包不可解析兜底 / ~ 与相对路径展开
+57
View File
@@ -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" />
}
+25 -3
View File
@@ -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 桌面端适配,修复项目内容串挂
+18
View File
@@ -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
View File
@@ -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
+4 -4
View File
@@ -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',
+10 -4
View File
@@ -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
View File
@@ -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) }) }
},
})
+93
View File
@@ -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()
}
},
}
}
+23
View File
@@ -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
View File
@@ -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})
+36
View File
@@ -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)
+11 -1
View File
@@ -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')
+107
View File
@@ -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)
})
+132
View File
@@ -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()}
})
+226
View File
@@ -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()
})
+2 -2
View File
@@ -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 = ''",
+244
View File
@@ -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()
+197
View File
@@ -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 })
}
+23 -32
View File
@@ -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 路径问题的存档,不代表当前宿主仍有此缺陷。先核对目标版本源码与复现,不自动套用或清理旧补丁。
## 安装 / 重启
+12 -8
View File
@@ -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
View File
@@ -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` 不再导入;旧对话可从目标窗口「自定义 → 发送到会话」发一次任务建立新关联,随后在同一对话直接继续修改。手动关闭/替换内容会撤销关联,此前误挂的标签需关闭一次。同目录项目若主动写同一个实际文件,文件内容仍可能互相影响。
+80
View File
@@ -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 资产不覆盖。