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
+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'
}