feat(v0.5.0): secure project previews and fix host UI compatibility
This commit is contained in:
+26
-3
@@ -17,7 +17,30 @@
|
||||
- 状态监控为宿主会话运行时快照的事件订阅镜像;视图/项目/绑定状态存 localStorage。
|
||||
- 客户端:TypeScript + React(host externals)+ 原生 CSS;服务端:Node。
|
||||
|
||||
## v0.4.0:兼容与验证范围
|
||||
## 0.5.0:兼容与安全说明
|
||||
|
||||
公开发布状态以 GitHub Release 为准。
|
||||
|
||||
- HTTP 文件/目录/写入/Git/工作区与终端握手先通过宿主身份验证,仅放行 owner(0.2.0 使用单 operator 身份);缺少可确认身份的服务时返回 503,不降级开放。health 仍可公开读取状态。以前直接用无登录 curl 调用这些接口的方式将被拒绝;不提供跳过鉴权开关。
|
||||
- 候选 dev.1 已在 Windows 隔离数据目录验证 **DSH 0.2.0-rc.2 / 0.2.1-rc.3 网页端**的冷启动、加载、分栏、文件与网页资源;0.2.1 另测创建/写入、控制室绑定和终端,未发送模型请求。用户已在官方 Windows Desktop 0.2.1-rc.3 验证 dev.2 的项目打开、分栏缩放与侧栏设置弹窗正常;前轮新项目也已点测。正式包另做安装与回归检查,这些证据不等于双端全功能验收。
|
||||
- 本版的 `engines.dsh` 与兼容矩阵只列上述两个 0.2 版本。旧 0.1.x 不在本版支持范围;下方 v0.4.0 的记录是历史范围,不代表 0.5.0 继续支持旧宿主。
|
||||
- 折叠侧栏与展开侧栏共用一份弹窗层,使控制室新建项目、绑定、图标与删除弹窗不因侧栏收起而消失,避免重复渲染。
|
||||
- 修复分栏 margin 的小数值被浏览器规范化后误判为外部改写,以及会话根更换后观察器仍盯着旧元素的问题。
|
||||
|
||||
### 网页隔离的影响与边界
|
||||
|
||||
- 浏览器/动画/自定义 HTML 窗格使用无 `allow-same-origin` 的 sandbox;网页不能直接读取 DSH 主页面及其同源存储;私有文件操作和终端接口仍需宿主身份校验。工作台自己的项目、布局和媒体存储不迁移、不删除。
|
||||
- **可能受影响**:嵌入页面的 localStorage、IndexedDB、Cookie 登录、依赖同源的请求、弹出新窗口/顶层跳转,以及标注时自动读取 iframe 内文字。项目页内嵌 iframe/object 被限制;依赖这些能力的页面可能需要单独适配。网页窗口级标注仍可使用,但不能承诺保留逐元素文字提取。
|
||||
- **已观察到的兼容限制**:Cesium 等库的 WebGL 纹理可能需要显式跨源加载。StarMap 的月球纹理已在独立项目中修复并验证,改动不包含在工作台安装包里;其他旧网页仍需各自检查。不会为恢复旧网页取消安全隔离。
|
||||
- 本地 HTML 通过登录后创建的随机只读地址加载,允许读取该次选择目录内的资源(包括子目录),支持相对 CSS、JS 模块与 fetch;它不是“只能读当前 HTML 文件”。不要给不可信页面挂载含敏感文件的目录;页面仍可主动联网,隔离不等于阻止数据外发。
|
||||
- 临时地址不写入项目存档,单资源最多 40 MiB,最多 128 个挂载,连续 30 分钟无读取后失效;失效后点击窗格刷新重新创建。关闭网页标签、刷新替换、组件卸载或插件卸载会撤销;仅关闭/切换项目仍沿用最多 6 个项目的保活池,不立即撤销仍挂载的页面。
|
||||
- 预览使用同一文件句柄限量读取,读取前后复核真实路径、文件身份与状态;检测到替换、改写或撤销时不返回内容。此措施不是针对恶意本地写入者精确竞态的操作系统沙箱;40 MiB 是单资源限制,不是所有并发请求的总内存额度。
|
||||
- 终端插件卸载时关闭本插件连接及 Shell。鉴权检查发生在每次 HTTP 请求/WS 建连;本轮没有实现宿主身份撤销后即时断开已有 WS 的机制。
|
||||
- `npm run test:security` 覆盖鉴权、预览目录越界/生命周期、终端卸载;`npm run pack` 同时执行真实 Chromium 小数 margin 回归。需要本机安装 Edge/Chrome,或用 `DSH_TEST_CHROMIUM` 指定可执行文件。
|
||||
|
||||
完整路由、错误码、预览生命周期与验收边界见源码仓库的 `docs/compatibility-and-security.md`;该文档随源码交付,不额外放入七文件安装包。
|
||||
|
||||
## v0.4.0:兼容与验证范围(历史发布记录)
|
||||
|
||||
- DSH 网页端兼容声明仅列 **0.1.1-rc.2 / 0.1.2-rc.1 / 0.2.0-rc.2**,不是对任意新旧版本的保证。
|
||||
- Windows + 0.2.0-rc.2 网页端已有冷启动、工作台/控制室、新建绑定与短文本回复、关闭回切等验收基础;桌面主要页面与交互已点测,共目录串挂修复获用户确认。最终包经过 98 项源码回归、独立安装与客户端加载门禁;这些检查不等于完整桌面端业务验收。旧版沿用此前页面验收并补定向代码回归,未重跑整套旧版 GUI。
|
||||
@@ -37,7 +60,7 @@
|
||||
## 权限与外部服务(如实披露)
|
||||
|
||||
- **文件访问**:服务端读写工作区与用户指定的目录——文件窗目录列表、MD 编辑保存、项目文件夹 mkdir、站点静态托管、widget 产物读取。除用户当次操作外,插件在加载时、项目状态变化时、已登记窗口任务完成时会自动读取当前关联的 `.dsh-worktable/` 结果文件及其指定产物,并读取宿主提供的工作区数据(workspaces 路由)。不扫描根目录旧 `widget-result.json`;产物路径可来自任务结果,这不是文件系统沙箱。失败返回错误或降级。
|
||||
- **网络**:① 更新检查向 api.github.com 发只读 GET(无凭据,请求仅含公开仓库名,不携带会话内容);② 插件与宿主的通信走宿主自身的 API / WebSocket 通道(由宿主管理);③ 「浏览器」「动画」等网页窗加载用户指定的页面,页面自身可能发起任意网络请求——等同于用户在自己的浏览器中打开这些页面。
|
||||
- **网络**:① 更新检查向 api.github.com 发只读 GET(无凭据,请求仅含公开仓库名,不携带会话内容);② 插件与宿主的通信走宿主自身的 API / WebSocket 通道;③ 网页窗加载用户指定的页面,页面自身可能发起任意网络请求;本版的隔离与存储限制见上节,不等同于完整浏览器页面的功能。
|
||||
- **命令执行**:① 「源代码管理」窗运行 git 只读状态命令;② 「终端」窗经 node-pty 启动交互式 Shell(Windows 下为 PowerShell)——这是用户主动使用的完整终端:终端内执行的任何命令都可能读写文件、联网,行为等同用户本人操作。
|
||||
- **凭据与环境**:插件代码不专门提取、存储或上传任何 API Key / Token / Cookie;但终端窗把宿主进程的完整环境变量传给子 Shell(其中可能包含敏感信息),终端内命令的行为不受插件约束。插件对 process.env 的读取用于运行参数(数据目录 DSH_HOME、终端默认 Shell SHELL 等),不将其外发。以上声明均有对应源码可查;本说明不是安全保证,高权限能力如实列出,使用风险由用户在自身环境评估。
|
||||
- **外部依赖与边界**:无生产依赖;终端窗依赖宿主的 ws / node-pty(缺失时该窗降级提示,不影响其余功能)。不替换、不禁用任何官方插件。本插件不声称解决宿主自身的全部启动问题。
|
||||
@@ -61,7 +84,7 @@
|
||||
|
||||
先保存任务,从应用菜单/托盘完整退出(只关窗口不够);将占位路径替换为实际桌面端安装目录,在 PowerShell 执行,再手动重新打开应用:
|
||||
|
||||
& "<桌面端安装目录>\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add "https://github.com/Aisland-SJL/dsh-worktable/releases/download/v0.4.0/dsh-worktable-0.4.0.tgz"
|
||||
& "<桌面端安装目录>\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add "https://github.com/Aisland-SJL/dsh-worktable/releases/download/v0.5.0/dsh-worktable-0.5.0.tgz"
|
||||
|
||||
不要以不固定版本的 npx 或网页端 CLI 代替桌面端自带 CLI;保留实际 DSH_HOME,勿把测试数据目录覆盖正式数据。新建控制室管理对话时,DSH 0.2 用户请选择分组,或直接绑定可用的现有对话。
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dsh-worktable",
|
||||
"description": "工作台:DSH 侧边栏的 agent 级项目容器,收纳如 TravelAtlas 等自装项目",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"entry": {
|
||||
"name": "dsh-worktable",
|
||||
"inject": []
|
||||
|
||||
+1322
-1194
File diff suppressed because one or more lines are too long
+262
-25
@@ -2,11 +2,121 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { readdirSync, realpathSync } from "node:fs";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { dirname, resolve as pathResolve, sep } from "node:path";
|
||||
import { dirname, resolve as pathResolve, sep as sep2 } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { homedir } from "node:os";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
// src/requestAuth.ts
|
||||
async function requestRejection(ctx, req) {
|
||||
try {
|
||||
const connection = typeof ctx.get === "function" ? ctx.get("connection") : void 0;
|
||||
if (!connection) return 503;
|
||||
if (typeof connection.admit === "function") {
|
||||
const admission = await connection.admit(req);
|
||||
if (!admission || typeof admission !== "object") return 503;
|
||||
if ("rejection" in admission) return admission.rejection === 401 ? 401 : 403;
|
||||
const peer = admission.peer;
|
||||
if (peer?.principal?.kind === "owner") return void 0;
|
||||
if (peer && peer === connection.operator && peer.principal === void 0) return void 0;
|
||||
return 403;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return 503;
|
||||
}
|
||||
function rejectionText(status) {
|
||||
return status === 401 ? "unauthorized" : status === 403 ? "forbidden" : "Host authentication unavailable";
|
||||
}
|
||||
|
||||
// src/previewFiles.ts
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { open, realpath, stat } from "node:fs/promises";
|
||||
import { isAbsolute, relative, resolve, sep } from "node:path";
|
||||
var PREVIEW_PREFIX = "/api/worktable/preview-static";
|
||||
var PREVIEW_SANDBOX = "allow-scripts allow-forms allow-downloads";
|
||||
var PREVIEW_CSP = `sandbox ${PREVIEW_SANDBOX}; base-uri 'none'; object-src 'none'; frame-src 'none'`;
|
||||
var PREVIEW_MAX_BYTES = 40 * 1024 * 1024;
|
||||
var IDLE_MS = 30 * 60 * 1e3;
|
||||
var MAX_MOUNTS = 128;
|
||||
function inside(root, file) {
|
||||
const rel = relative(root, file);
|
||||
return rel === "" || !isAbsolute(rel) && rel !== ".." && !rel.startsWith(".." + sep);
|
||||
}
|
||||
function createPreviewFiles(now = () => Date.now()) {
|
||||
const mounts = /* @__PURE__ */ new Map();
|
||||
const prune = () => {
|
||||
for (const [key, item] of mounts) if (now() - item.touched >= IDLE_MS) mounts.delete(key);
|
||||
};
|
||||
return {
|
||||
async mount(root) {
|
||||
if (!isAbsolute(root)) throw new Error("Preview root must be absolute");
|
||||
const canonical = await realpath(root);
|
||||
if (!(await stat(canonical)).isDirectory()) throw new Error("Preview root is not a directory");
|
||||
prune();
|
||||
if (mounts.size >= MAX_MOUNTS) throw new Error("Too many open previews; close an unused preview first");
|
||||
const token = randomBytes(24).toString("base64url");
|
||||
mounts.set(token, { root: canonical, touched: now() });
|
||||
return token;
|
||||
},
|
||||
revoke(token) {
|
||||
mounts.delete(token);
|
||||
},
|
||||
clear() {
|
||||
mounts.clear();
|
||||
},
|
||||
async file(token, segments, headOnly = false) {
|
||||
prune();
|
||||
const mount = mounts.get(token);
|
||||
if (!mount) return null;
|
||||
if (segments.some((s) => s === ".." || s.includes("/") || s.includes("\\") || s.includes("\0"))) return null;
|
||||
let target = resolve(mount.root, ...segments);
|
||||
if (!inside(mount.root, target)) return null;
|
||||
let info = await stat(target).catch(() => null);
|
||||
if (info?.isDirectory()) target = resolve(target, "index.html");
|
||||
const canonical = await realpath(target).catch(() => null);
|
||||
if (!canonical || !inside(mount.root, canonical)) return null;
|
||||
const handle = await open(canonical, "r").catch(() => null);
|
||||
if (!handle) return null;
|
||||
try {
|
||||
const before = await handle.stat({ bigint: true });
|
||||
if (!before.isFile()) return null;
|
||||
const sameFile = (other) => other.isFile() && other.dev === before.dev && other.ino === before.ino && other.size === before.size && other.mtimeNs === before.mtimeNs && other.ctimeNs === before.ctimeNs;
|
||||
const stillValid = async () => {
|
||||
const current = await realpath(canonical);
|
||||
if (current !== canonical || !inside(mount.root, current)) return false;
|
||||
const named = await stat(current, { bigint: true });
|
||||
const opened = await handle.stat({ bigint: true });
|
||||
return sameFile(named) && sameFile(opened) && mounts.get(token) === mount && now() - mount.touched < IDLE_MS;
|
||||
};
|
||||
const tooLarge = () => Object.assign(new Error("file too large"), { status: 413 });
|
||||
if (before.size > BigInt(PREVIEW_MAX_BYTES)) throw tooLarge();
|
||||
if (!await stillValid()) return null;
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
if (!headOnly) {
|
||||
while (total <= PREVIEW_MAX_BYTES) {
|
||||
const chunk = Buffer.alloc(Math.min(64 * 1024, PREVIEW_MAX_BYTES + 1 - total));
|
||||
const { bytesRead } = await handle.read(chunk, 0, chunk.length, total);
|
||||
if (!bytesRead) break;
|
||||
total += bytesRead;
|
||||
if (total > PREVIEW_MAX_BYTES) throw tooLarge();
|
||||
chunks.push(chunk.subarray(0, bytesRead));
|
||||
}
|
||||
}
|
||||
if (!await stillValid() || !headOnly && BigInt(total) !== before.size) return null;
|
||||
mount.touched = now();
|
||||
return { path: canonical, size: Number(before.size), data: headOnly ? void 0 : Buffer.concat(chunks, total) };
|
||||
} catch (error) {
|
||||
if (error?.status === 413) throw error;
|
||||
return null;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// template/dshell.css
|
||||
var dshell_default = `/* dsh-worktable \u539F\u751F\u76AE\u80A4 \xB7 DSH \u8BBE\u8BA1\u7CFB\u7EDF\u7EC4\u4EF6\u5E93
|
||||
\u7528\u6CD5\uFF1A<link rel="stylesheet" href="/api/worktable/template/dshell.css">
|
||||
@@ -115,7 +225,7 @@ function resolveDshHomeSafe() {
|
||||
cachedDshHome = baseDshHome();
|
||||
return cachedDshHome;
|
||||
}
|
||||
var PLUGIN_VERSION = false ? "dev" : "0.4.0";
|
||||
var PLUGIN_VERSION = false ? "dev" : "0.5.0";
|
||||
var name = "dsh-worktable";
|
||||
var inject = ["webServer", "sessions"];
|
||||
var HEALTH_PATH = "/api/worktable/health";
|
||||
@@ -212,7 +322,13 @@ function json(res, status, body) {
|
||||
}
|
||||
async function readJsonBody(req) {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||
let bytes = 0;
|
||||
for await (const chunk of req) {
|
||||
const data = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
||||
bytes += data.length;
|
||||
if (bytes > 22 * 1024 * 1024) throw Object.assign(new Error("request too large"), { status: 413 });
|
||||
chunks.push(data);
|
||||
}
|
||||
const text = Buffer.concat(chunks).toString("utf8");
|
||||
if (!text) return {};
|
||||
try {
|
||||
@@ -224,7 +340,7 @@ async function readJsonBody(req) {
|
||||
async function listDirectory(path) {
|
||||
const abs = pathResolve(path);
|
||||
const dirents = await readdir(abs, { withFileTypes: true });
|
||||
const entries = dirents.map((d) => ({ name: d.name, path: abs + sep + d.name, isDir: d.isDirectory(), hidden: d.name.startsWith(".") })).sort((a, b) => {
|
||||
const entries = dirents.map((d) => ({ name: d.name, path: abs + sep2 + d.name, isDir: d.isDirectory(), hidden: d.name.startsWith(".") })).sort((a, b) => {
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
||||
return a.name.localeCompare(b.name, void 0, { sensitivity: "base" });
|
||||
});
|
||||
@@ -262,11 +378,43 @@ function setupTerminal(webServer, ctx) {
|
||||
if (!WebSocketServer) return;
|
||||
const pty = ptyMod.default ?? ptyMod;
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
let disposed = false;
|
||||
const terminals = /* @__PURE__ */ new Set();
|
||||
ctx.effect(() => () => {
|
||||
disposed = true;
|
||||
for (const ws of wss.clients ?? []) {
|
||||
try {
|
||||
ws.terminate();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
for (const term of terminals) {
|
||||
try {
|
||||
term.kill();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
terminals.clear();
|
||||
try {
|
||||
wss.close();
|
||||
} catch {
|
||||
}
|
||||
}, "dsh-worktable: terminal sessions");
|
||||
const spawnShell = () => process.platform === "win32" ? { cmd: "powershell.exe", args: ["-NoLogo", "-NoProfile"] } : { cmd: process.env.SHELL || "/bin/bash", args: [] };
|
||||
const clampDim = (v, fallback) => Math.min(1024, Math.max(2, Number.isFinite(v) ? v : fallback));
|
||||
ctx.effect(() => webServer.registerUpgrade({
|
||||
path: "/api/worktable/term",
|
||||
handler: (req, socket, head) => {
|
||||
handler: async (req, socket, head) => {
|
||||
let rejected = await requestRejection(ctx, req);
|
||||
if (disposed) rejected = 503;
|
||||
if (rejected !== void 0) {
|
||||
socket.end(`HTTP/1.1 ${rejected} ${rejectionText(rejected)}\r
|
||||
Connection: close\r
|
||||
Content-Length: 0\r
|
||||
\r
|
||||
`);
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
const u = new URL(req.url ?? "/", "http://dsh.internal");
|
||||
const cwd = serverCwd(ctx, u.searchParams.get("sessionId") || void 0, u.searchParams.get("cwd") || void 0);
|
||||
@@ -276,6 +424,7 @@ function setupTerminal(webServer, ctx) {
|
||||
try {
|
||||
const shell = spawnShell();
|
||||
term = pty.spawn(shell.cmd, shell.args, { name: "xterm-256color", cols, rows, cwd, env: process.env });
|
||||
terminals.add(term);
|
||||
} catch (err) {
|
||||
try {
|
||||
ws.send("\r\n[worktable] \u7EC8\u7AEF\u542F\u52A8\u5931\u8D25\uFF1A" + String(err));
|
||||
@@ -294,6 +443,7 @@ function setupTerminal(webServer, ctx) {
|
||||
}
|
||||
});
|
||||
term.onExit(() => {
|
||||
terminals.delete(term);
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
@@ -315,6 +465,7 @@ function setupTerminal(webServer, ctx) {
|
||||
}
|
||||
});
|
||||
ws.on("close", () => {
|
||||
terminals.delete(term);
|
||||
try {
|
||||
term.kill();
|
||||
} catch {
|
||||
@@ -330,14 +481,85 @@ function apply(ctx) {
|
||||
ctx.logger?.warn("[dsh-worktable] ctx.webServer \u4E0D\u53EF\u7528\uFF08headless profile\uFF1F\uFF09\uFF0C\u8DF3\u8FC7\u670D\u52A1\u7AEF\u8DEF\u7531");
|
||||
return;
|
||||
}
|
||||
webServer.register({
|
||||
const previews = createPreviewFiles();
|
||||
ctx.effect(() => () => previews.clear(), "dsh-worktable: preview capabilities");
|
||||
const register = (route, publicAsset = false) => ctx.effect(() => webServer.register({
|
||||
...route,
|
||||
handler: async (req, res) => {
|
||||
if (!publicAsset && route.path !== HEALTH_PATH) {
|
||||
const rejected = await requestRejection(ctx, req);
|
||||
if (rejected !== void 0) {
|
||||
json(res, rejected, { error: rejectionText(rejected) });
|
||||
return;
|
||||
}
|
||||
}
|
||||
return route.handler(req, res);
|
||||
}
|
||||
}), "dsh-worktable: " + route.path);
|
||||
register({
|
||||
kind: "exact",
|
||||
path: "/api/worktable/preview",
|
||||
handler: async (req, res) => {
|
||||
if (req.method !== "POST") {
|
||||
json(res, 405, { error: "method not allowed" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await readJsonBody(req);
|
||||
if (typeof body.revoke === "string") {
|
||||
previews.revoke(body.revoke);
|
||||
json(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
if (typeof body.root !== "string") {
|
||||
json(res, 400, { error: "missing root" });
|
||||
return;
|
||||
}
|
||||
const token = await previews.mount(body.root);
|
||||
json(res, 200, { token, base: PREVIEW_PREFIX + "/" + token + "/" });
|
||||
} catch (err) {
|
||||
json(res, err.status ?? 400, { error: String(err) });
|
||||
}
|
||||
}
|
||||
});
|
||||
register({
|
||||
kind: "prefix",
|
||||
path: PREVIEW_PREFIX,
|
||||
handler: async (req, res) => {
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
json(res, 405, { error: "method not allowed" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parts = new URL(req.url ?? "/", "http://dsh.internal").pathname.slice(PREVIEW_PREFIX.length + 1).split("/").map(decodeURIComponent);
|
||||
const file = await previews.file(parts.shift() ?? "", parts.filter(Boolean), req.method === "HEAD");
|
||||
if (!file) {
|
||||
json(res, 404, { error: "preview unavailable; refresh to reopen" });
|
||||
return;
|
||||
}
|
||||
const ext = (file.path.split(".").pop() || "").toLowerCase();
|
||||
res.writeHead(200, {
|
||||
"content-type": FILE_TYPES[ext] ?? "application/octet-stream",
|
||||
"cache-control": "no-store",
|
||||
"referrer-policy": "no-referrer",
|
||||
"content-security-policy": PREVIEW_CSP,
|
||||
"x-content-type-options": "nosniff",
|
||||
"access-control-allow-origin": "*"
|
||||
});
|
||||
res.end(file.data);
|
||||
} catch (err) {
|
||||
json(res, err?.status === 413 ? 413 : 404, { error: err?.status === 413 ? "file too large" : "preview unavailable" });
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
register({
|
||||
kind: "exact",
|
||||
path: HEALTH_PATH,
|
||||
handler: (_req, res) => {
|
||||
json(res, 200, { plugin: "dsh-worktable", version: PLUGIN_VERSION, ok: true });
|
||||
}
|
||||
});
|
||||
webServer.register({
|
||||
register({
|
||||
kind: "exact",
|
||||
path: "/api/worktable/file",
|
||||
handler: async (req, res) => {
|
||||
@@ -349,8 +571,8 @@ function apply(ctx) {
|
||||
return;
|
||||
}
|
||||
const abs = pathResolve(p);
|
||||
const stat = await import("node:fs/promises").then((m) => m.stat(abs));
|
||||
if (stat.size > 20 * 1024 * 1024) {
|
||||
const stat2 = await import("node:fs/promises").then((m) => m.stat(abs));
|
||||
if (stat2.size > 20 * 1024 * 1024) {
|
||||
json(res, 413, { error: "file too large" });
|
||||
return;
|
||||
}
|
||||
@@ -377,14 +599,19 @@ function apply(ctx) {
|
||||
bmp: "image/bmp",
|
||||
ico: "image/x-icon"
|
||||
};
|
||||
res.writeHead(200, { "content-type": FILE_TYPES[ext] ?? "application/octet-stream", "cache-control": "no-store" });
|
||||
res.writeHead(200, {
|
||||
"content-type": FILE_TYPES[ext] ?? "application/octet-stream",
|
||||
"cache-control": "no-store",
|
||||
"x-content-type-options": "nosniff",
|
||||
.../^(html?|svg)$/.test(ext) ? { "content-security-policy": PREVIEW_CSP, "referrer-policy": "no-referrer" } : {}
|
||||
});
|
||||
res.end(data);
|
||||
} catch (err) {
|
||||
json(res, 404, { error: String(err) });
|
||||
}
|
||||
}
|
||||
});
|
||||
webServer.register({
|
||||
register({
|
||||
kind: "prefix",
|
||||
path: TEMPLATE_PREFIX,
|
||||
handler: (req, res) => {
|
||||
@@ -409,7 +636,7 @@ function apply(ctx) {
|
||||
}
|
||||
}
|
||||
});
|
||||
webServer.register({
|
||||
register({
|
||||
kind: "prefix",
|
||||
path: SITE_PREFIX,
|
||||
handler: async (req, res) => {
|
||||
@@ -435,7 +662,7 @@ function apply(ctx) {
|
||||
}
|
||||
const root = pathResolve(rootToken);
|
||||
let abs = pathResolve(root, rel);
|
||||
if (abs !== root && !abs.startsWith(root + sep)) {
|
||||
if (abs !== root && !abs.startsWith(root + sep2)) {
|
||||
json(res, 403, { error: "outside root" });
|
||||
return;
|
||||
}
|
||||
@@ -455,14 +682,20 @@ function apply(ctx) {
|
||||
}
|
||||
const data = await readFile(abs);
|
||||
const ext = (abs.split(".").pop() || "").toLowerCase();
|
||||
res.writeHead(200, { "content-type": FILE_TYPES[ext] ?? "application/octet-stream", "cache-control": "no-store" });
|
||||
res.writeHead(200, {
|
||||
"content-type": FILE_TYPES[ext] ?? "application/octet-stream",
|
||||
"cache-control": "no-store",
|
||||
"content-security-policy": PREVIEW_CSP,
|
||||
"referrer-policy": "no-referrer",
|
||||
"x-content-type-options": "nosniff"
|
||||
});
|
||||
res.end(data);
|
||||
} catch (err) {
|
||||
json(res, 404, { error: String(err) });
|
||||
}
|
||||
}
|
||||
});
|
||||
webServer.register({
|
||||
register({
|
||||
kind: "exact",
|
||||
path: "/api/worktable/fs",
|
||||
handler: async (req, res) => {
|
||||
@@ -471,11 +704,11 @@ function apply(ctx) {
|
||||
const path = typeof body.path === "string" && body.path ? body.path : serverCwd(ctx, body.sessionId, body.cwd);
|
||||
json(res, 200, await listDirectory(path));
|
||||
} catch (err) {
|
||||
json(res, 500, { path: "", entries: [], truncated: false, error: String(err) });
|
||||
json(res, err.status ?? 500, { path: "", entries: [], truncated: false, error: String(err) });
|
||||
}
|
||||
}
|
||||
});
|
||||
webServer.register({
|
||||
register({
|
||||
kind: "exact",
|
||||
path: "/api/worktable/workspaces",
|
||||
handler: async (_req, res) => {
|
||||
@@ -524,7 +757,7 @@ function apply(ctx) {
|
||||
}
|
||||
}
|
||||
});
|
||||
webServer.register({
|
||||
register({
|
||||
kind: "exact",
|
||||
path: "/api/worktable/write",
|
||||
handler: async (req, res) => {
|
||||
@@ -549,11 +782,11 @@ function apply(ctx) {
|
||||
await import("node:fs/promises").then((m) => m.writeFile(abs, content, "utf8"));
|
||||
json(res, 200, { ok: true });
|
||||
} catch (err) {
|
||||
json(res, 500, { error: String(err) });
|
||||
json(res, err.status ?? 500, { error: String(err) });
|
||||
}
|
||||
}
|
||||
});
|
||||
webServer.register({
|
||||
register({
|
||||
kind: "exact",
|
||||
path: "/api/worktable/mkdir",
|
||||
handler: async (req, res) => {
|
||||
@@ -581,17 +814,21 @@ function apply(ctx) {
|
||||
await fsx.mkdir(abs);
|
||||
json(res, 200, { ok: true, path: abs });
|
||||
} catch (err) {
|
||||
json(res, err?.code === "EEXIST" ? 200 : 500, err?.code === "EEXIST" ? { ok: true, exists: true } : { error: String(err) });
|
||||
json(res, err?.code === "EEXIST" ? 200 : err.status ?? 500, err?.code === "EEXIST" ? { ok: true, exists: true } : { error: String(err) });
|
||||
}
|
||||
}
|
||||
});
|
||||
webServer.register({
|
||||
register({
|
||||
kind: "exact",
|
||||
path: "/api/worktable/git",
|
||||
handler: async (req, res) => {
|
||||
const body = await readJsonBody(req);
|
||||
const cwd = serverCwd(ctx, body.sessionId, body.cwd);
|
||||
json(res, 200, await gitStatus(cwd));
|
||||
try {
|
||||
const body = await readJsonBody(req);
|
||||
const cwd = serverCwd(ctx, body.sessionId, body.cwd);
|
||||
json(res, 200, await gitStatus(cwd));
|
||||
} catch (err) {
|
||||
json(res, err.status ?? 500, { error: String(err) });
|
||||
}
|
||||
}
|
||||
});
|
||||
setupTerminal(webServer, ctx);
|
||||
|
||||
Generated
+4
-3
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "dsh-worktable",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dsh-worktable",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=22",
|
||||
"dsh": "0.2.0-rc.2 || 0.2.1-rc.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "dsh-worktable",
|
||||
"description": "工作台 Worktable:DeepSeek Harness 侧边栏的 agent 级项目容器(应用抽屉)",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Aisland-SJL/dsh-worktable.git",
|
||||
"directory": "01_content"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=22",
|
||||
"dsh": "0.2.0-rc.2 || 0.2.1-rc.3"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -39,11 +40,10 @@
|
||||
]
|
||||
},
|
||||
"compatibility": {
|
||||
"dsh": "0.1.1-rc.2 || 0.1.2-rc.1 || 0.2.0-rc.2",
|
||||
"dsh": "0.2.0-rc.2 || 0.2.1-rc.3",
|
||||
"dshReleases": {
|
||||
"0.1.1-rc.2": "compatible",
|
||||
"0.1.2-rc.1": "compatible",
|
||||
"0.2.0-rc.2": "compatible"
|
||||
"0.2.0-rc.2": "compatible",
|
||||
"0.2.1-rc.3": "compatible"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -52,11 +52,13 @@
|
||||
"check": "node --check lib/index.js && node --check lib/client.js",
|
||||
"pack": "node release-prep.mjs",
|
||||
"test:gate": "node client-factory-gate.test.mjs",
|
||||
"test:sidebar": "node ../04_test/sidebar-popup.test.mjs",
|
||||
"test:input": "node ../04_test/host-input.test.mjs",
|
||||
"test:session": "node ../04_test/session-compat.test.mjs",
|
||||
"test:details": "node ../04_test/session-details.test.mjs",
|
||||
"test:transport": "node ../04_test/host-transport.test.mjs",
|
||||
"test:widget": "node ../04_test/widget-mount.test.mjs",
|
||||
"test:security": "node --test ../04_test/request-auth.test.mjs ../04_test/preview-files.test.mjs ../04_test/sandboxed-frame.test.mjs",
|
||||
"verify:remote": "node verify-remote.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -83,7 +83,7 @@ if (!patchOk) process.exit(1)
|
||||
console.log('[release-prep] identity check passed (' + IDENTITY + ', cordis.patch.yml strict)')
|
||||
|
||||
// ---------- 2. 会话/输入兼容回归 + 构建 + 语法检查 ----------
|
||||
const compatTests = ['host-input.test.mjs', 'session-compat.test.mjs', 'session-details.test.mjs', 'host-transport.test.mjs', 'widget-mount.test.mjs']
|
||||
const compatTests = ['host-input.test.mjs', 'session-compat.test.mjs', 'session-details.test.mjs', 'host-transport.test.mjs', 'widget-mount.test.mjs', 'request-auth.test.mjs', 'preview-files.test.mjs', 'sandboxed-frame.test.mjs']
|
||||
.map((file) => join(HERE, '..', '04_test', file))
|
||||
const compatRun = spawnSync(process.execPath, ['--test', ...compatTests], { cwd: HERE, stdio: 'inherit' })
|
||||
if (compatRun.status !== 0) { console.error('[release-prep] FAIL: session/input compatibility tests'); process.exit(1) }
|
||||
@@ -191,7 +191,28 @@ if (anchorRun.status !== 0) {
|
||||
console.error(anchorRun.stderr || anchorRun.stdout)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('[release-prep] split anchor dom tests passed (8 scenarios, on installed artifact)')
|
||||
console.log('[release-prep] split anchor dom tests passed (14 scenarios, on installed artifact)')
|
||||
|
||||
// Sidebar popup regression evaluates the same installed client, including rail/wide branches.
|
||||
const sidebarTest = join(HERE, '..', '04_test', 'sidebar-popup.test.mjs')
|
||||
if (!existsSync(sidebarTest)) { console.error('[release-prep] FAIL: sidebar-popup.test.mjs missing'); process.exit(1) }
|
||||
const sidebarRun = spawnSync(process.execPath, [sidebarTest, join(inst, 'node_modules', 'dsh-worktable', 'lib', 'client.js')], { cwd: HERE, stdio: 'pipe', encoding: 'utf8' })
|
||||
if (sidebarRun.status !== 0) {
|
||||
console.error('[release-prep] FAIL: sidebar-popup.test.mjs')
|
||||
console.error(sidebarRun.stderr || sidebarRun.stdout)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(sidebarRun.stdout.trim())
|
||||
console.log('[release-prep] sidebar popup tests passed (installed artifact)')
|
||||
|
||||
// CSSOM serializes fractional margins; a string-only fake DOM cannot cover this.
|
||||
const marginRun = spawnSync(process.execPath, [join(HERE, '..', '04_test', 'split-margin.test.mjs'), join(inst, 'node_modules', 'dsh-worktable', 'lib', 'client.js')], { cwd: HERE, stdio: 'pipe', encoding: 'utf8' })
|
||||
if (marginRun.status !== 0) {
|
||||
console.error('[release-prep] FAIL: real Chromium split margin regression')
|
||||
console.error(marginRun.stderr || marginRun.stdout)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('[release-prep] real Chromium split margin tests passed (5 scenarios, on installed artifact)')
|
||||
|
||||
// ---------- 5d. 服务端数据目录解析回归(loadPkg ↔ resolveDshHomeSafe 循环调用修复) ----------
|
||||
// 评估「安装目录中的最终产物」lib/index.js;官方路径 / 包不可解析兜底 / ~ 与相对路径展开
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { hostPreviewUrl } from './hostTransport'
|
||||
|
||||
const SANDBOX = 'allow-scripts allow-forms allow-downloads'
|
||||
const SITE = '/api/worktable/site/'
|
||||
|
||||
/** Accept navigations, not executable URL schemes. Keep saved URLs free of capabilities. */
|
||||
export function previewTarget(input: string, base: string) {
|
||||
const url = new URL(input, base)
|
||||
const own = new URL(base)
|
||||
const sameHost = url.protocol === own.protocol && url.host === own.host
|
||||
if (!['http:', 'https:'].includes(url.protocol) && !(sameHost && url.protocol === 'dsh-app:')) throw new Error('Unsupported preview URL')
|
||||
if (!sameHost || !url.pathname.startsWith(SITE)) return { url: url.href }
|
||||
const segments = url.pathname.slice(SITE.length).split('/')
|
||||
const root = decodeURIComponent(segments.shift() ?? '')
|
||||
if (!root) throw new Error('Missing preview directory')
|
||||
const relative = segments.map(decodeURIComponent)
|
||||
if (relative.some(s => s === '..' || /[\\/\0]/.test(s))) throw new Error('Invalid preview path')
|
||||
return { root, relative: relative.map(encodeURIComponent).join('/'), suffix: url.search + url.hash }
|
||||
}
|
||||
|
||||
/** Untrusted project pages never share the host's document origin or storage. */
|
||||
export function SandboxedFrame(props: { url: string; title?: string; reloadKey: number }) {
|
||||
const [src, setSrc] = useState('about:blank')
|
||||
const [error, setError] = useState('')
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
let token: string | undefined
|
||||
const revoke = (value: string) => { void fetch('/api/worktable/preview', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ revoke: value }), keepalive: true,
|
||||
}).catch(() => {}) }
|
||||
setSrc('about:blank')
|
||||
setError('')
|
||||
void (async () => {
|
||||
if (!props.url || props.url === 'about:blank') return
|
||||
const target = previewTarget(props.url, location.href)
|
||||
if ('url' in target) { if (alive) setSrc(target.url!); return }
|
||||
const response = await fetch('/api/worktable/preview', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ root: target.root }),
|
||||
})
|
||||
if (!response.ok) throw new Error('Preview HTTP ' + response.status)
|
||||
const grant = await response.json()
|
||||
if (typeof grant.token !== 'string' || !/^[\w-]{32}$/.test(grant.token) || grant.base !== '/api/worktable/preview-static/' + grant.token + '/') throw new Error('Invalid preview grant')
|
||||
token = grant.token
|
||||
if (!alive) { revoke(token!); token = undefined; return }
|
||||
setSrc(hostPreviewUrl(grant.base + target.relative + target.suffix))
|
||||
})().catch(err => {
|
||||
if (token) { revoke(token); token = undefined }
|
||||
if (alive) setError(String(err))
|
||||
})
|
||||
return () => { alive = false; if (token) revoke(token) }
|
||||
}, [props.url, props.reloadKey])
|
||||
if (error) return <div className="dsh-wt_paneWip"><span className="dsh-wt_paneWipText">{error}</span></div>
|
||||
return <iframe key={props.reloadKey} className="dsh-wt_paneFrame" src={src}
|
||||
data-source-url={props.url} title={props.title ?? ''} sandbox={SANDBOX} referrerPolicy="no-referrer" />
|
||||
}
|
||||
@@ -1,6 +1,28 @@
|
||||
/** 更新公告正文(v0.4.0:双端适配、分组与项目内容隔离;纯文本呈现,发布状态以 GitHub 为准) */
|
||||
export const CHANGELOG_VERSION = 'v0.4.0'
|
||||
export const CHANGELOG_V030 = `更新公告 · v0.4.0
|
||||
/** 更新公告正文;公开发布状态以 GitHub Release 为准。 */
|
||||
export const CHANGELOG_VERSION = 'v0.5.0'
|
||||
export const CHANGELOG_V030 = `更新公告 · v0.5.0
|
||||
|
||||
🛡️ 新版宿主兼容、安全隔离与侧栏修复
|
||||
|
||||
【接口身份校验】文件、目录、写入、Git、工作区接口和终端握手复用宿主身份验证。新版只允许 owner,0.2.0 使用单 operator 身份;不能确认身份时拒绝访问,不静默开放。健康接口仍可读状态,不代表全部功能可用。
|
||||
|
||||
【项目网页隔离】浏览器、动画及自定义 HTML 窗格不再拥有 DSH 主界面的同源权限。本地 HTML 通过临时只读地址加载该目录内的资源;地址不写入项目存档,关闭网页标签、刷新替换或卸载会撤销。切换项目仍保留原有最多 6 个项目保活,不等于立即撤销。网页仍能联网,不应给不可信页面挂载含敏感文件的目录。
|
||||
|
||||
【隔离带来的影响】部分网页的浏览器存储、Cookie 登录、嵌套页面、WebGL 贴图及标注自动取字可能受限,旧网页可能需要项目侧适配。工作台不自动改写项目网页,也不通过取消隔离恢复这些能力。窗口级标注仍可用,必要时补截图。工作台自己的项目、布局与媒体不清空、不迁移。
|
||||
|
||||
【分栏与侧栏】修复小数尺寸经浏览器规范化后误判、反复重排,以及会话根更换后仍观察旧元素的问题。展开和折叠侧栏共用一份弹窗层,控制室的新建入口及绑定、图标、删除弹窗不再因侧栏收起而缺失,避免重复渲染。
|
||||
|
||||
【预览读取】单资源上限 40 MiB,读取期间也执行限制;使用同一文件句柄并复核路径与文件身份,遇到文件替换、变化或挂载撤销则拒绝返回内容。它不是针对恶意本地进程的操作系统级文件沙箱。
|
||||
|
||||
【兼容与验收边界】本版只列 DSH 0.2.0-rc.2 / 0.2.1-rc.3。候选已在 Windows 隔离网页端验证主要页面、文件、网页资源与连接;用户在官方 Windows Desktop 0.2.1-rc.3 验证候选的新项目、项目打开、分栏缩放及侧栏设置弹窗正常。正式包另做安装与回归检查,不将候选页面结果扩大为正式包全功能验收。未验证模型生成全链路、真实审批/子代理及 macOS,不保证未测版本兼容;旧 0.1.x 不在本版支持范围。
|
||||
|
||||
【发布检查】流水线包含源码回归、独立安装、工厂门禁、14 项分栏锚点、侧栏实际组件回归、5 项 Chromium 几何回归与数据目录检查。社区 PR 的修复思路按当前源码吸收,贡献范围与来源见发布说明;采用修法不等于原 PR 已合并。
|
||||
|
||||
【升级提醒】公开发布以 GitHub Release 为准。安装前备份重要数据、核对其他插件兼容性;网页端重启后刷新,桌面端完整退出后通过自带 CLI 安装、再手动打开。若仍加载失败,保留第一条完整错误。这个包不包含宿主模型 HTTP 400 修复。
|
||||
|
||||
───────
|
||||
|
||||
此前版本说明(v0.4.0)
|
||||
|
||||
🖥️ 网页端与 Windows 桌面端适配,修复项目内容串挂
|
||||
|
||||
|
||||
@@ -17,6 +17,24 @@ export function hostWebSocketUrl(
|
||||
return url.href
|
||||
}
|
||||
|
||||
/** Desktop preview assets use the Host HTTP origin with a read-only capability. */
|
||||
export function hostPreviewUrl(
|
||||
path: string,
|
||||
surface: { protocol: string; streamBaseUrl?: string } = {
|
||||
protocol: location.protocol,
|
||||
streamBaseUrl: (globalThis as any).__DSH_TRANSPORT__?.streamBaseUrl,
|
||||
},
|
||||
): string {
|
||||
if (!path.startsWith('/api/worktable/preview-static/')) throw new Error('Invalid preview path')
|
||||
if (surface.protocol !== 'dsh-app:') return path
|
||||
if (!surface.streamBaseUrl) throw new Error('Desktop Host preview URL unavailable')
|
||||
const url = new URL(path, surface.streamBaseUrl)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('Unsupported Host preview protocol')
|
||||
// Opaque sandbox requests cannot pass Desktop's dsh-app Origin fence. This
|
||||
// read-only capability needs no Host cookie, so its assets use the Host origin.
|
||||
return url.href
|
||||
}
|
||||
|
||||
/** Keep update instructions on the active profile; Desktop must use its bundled CLI. */
|
||||
export function worktableUpgrade(protocol = typeof location === 'undefined' ? 'http:' : location.protocol) {
|
||||
const desktop = protocol === 'dsh-app:'
|
||||
|
||||
+131
-122
@@ -2516,128 +2516,8 @@ function buildCustomLayoutPrompt(req: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
if (!wide) {
|
||||
// 收起态 = 等行高的正方形圆角按钮,中间只留 emoji;点击 = 进入对应项目
|
||||
const railItems: { icon: string; name: string; onClick: (e: any) => void }[] = [
|
||||
{ icon: CONSOLE_ICON, name: t('console.name'), onClick: (e) => clickConsoleCard(e.currentTarget as HTMLElement) },
|
||||
...aliveRegisteredIds.map((id) => ({
|
||||
icon: projects.iconOverrides[id] ?? metas[id]?.icon ?? '📦',
|
||||
name: projects.nameOverrides[id] ?? metas[id]?.name ?? id,
|
||||
onClick: () => openRailProject(id),
|
||||
})),
|
||||
...projects.shortcuts.map((s) => ({
|
||||
icon: s.icon,
|
||||
name: s.name,
|
||||
onClick: () => { try { window.open(s.href, '_blank', 'noopener') } catch {} },
|
||||
})),
|
||||
...projects.layouts.map((l) => ({
|
||||
icon: l.icon ?? '🧱',
|
||||
name: projects.nameOverrides[l.id] ?? l.title,
|
||||
onClick: () => openSplit(l),
|
||||
})),
|
||||
]
|
||||
// 浮动态:fixed 定位到拖前高度(左/宽取折叠列实测几何);停靠态:文档流原位
|
||||
const railStyle = isFloat && railRect
|
||||
? { position: 'fixed' as const, top: float.top, left: railRect.left, width: railRect.width, zIndex: 70 }
|
||||
: bottomInset > 0 ? { marginBottom: bottomInset } : undefined
|
||||
return (
|
||||
<div ref={rootRef} className="dsh-wt_section dsh-wt_rail" style={railStyle}>
|
||||
<div className="dsh-wt_divider" />
|
||||
<div className="dsh-wt_railBox">
|
||||
{railItems.length > 0
|
||||
? railItems.map((it, i) => (
|
||||
<button key={i} type="button" className="dsh-wt_railBtn" title={it.name} aria-label={it.name} onClick={it.onClick}>
|
||||
<span aria-hidden>{it.icon}</span>
|
||||
</button>
|
||||
))
|
||||
: <span className="dsh-wt_railIcon">≡</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={'dsh-wt_section' + (isFloat ? ' dsh-wt_float' : '')} style={isFloat ? floatStyle : dockedStyle}>
|
||||
<div className="dsh-wt_divider" />
|
||||
<div className="dsh-wt_header">
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_handle"
|
||||
data-float={isFloat ? 'true' : 'false'}
|
||||
title={t('handle.title')}
|
||||
aria-label={t('handle.aria')}
|
||||
onPointerDown={onHandlePointerDown}
|
||||
onPointerMove={onHandlePointerMove}
|
||||
onPointerUp={onHandlePointerUp}
|
||||
onPointerCancel={onHandlePointerUp}
|
||||
onDoubleClick={resetDock}
|
||||
>≡</button>
|
||||
<span
|
||||
className="dsh-wt_title"
|
||||
title={t('handle.title')}
|
||||
onPointerDown={onHandlePointerDown}
|
||||
onPointerMove={onHandlePointerMove}
|
||||
onPointerUp={onHandlePointerUp}
|
||||
onPointerCancel={onHandlePointerUp}
|
||||
onDoubleClick={resetDock}
|
||||
>{t('title')}</span>
|
||||
{updateInfo && (
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_updateBadge"
|
||||
title={t('update.badgeTitle') + ' · v' + updateInfo.latest}
|
||||
aria-label={t('update.badgeTitle')}
|
||||
onClick={() => setViewOptionsOpen(true)}
|
||||
>{ICON_SYNC}</button>
|
||||
)}
|
||||
<div className="dsh-wt_actions">
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_iconBtn"
|
||||
aria-label={t('menu.search')}
|
||||
title={t('menu.search')}
|
||||
onClick={() => persistView({ searchOpen: !view.searchOpen, query: view.searchOpen ? '' : view.query })}
|
||||
>{ICON_SEARCH}</button>
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_iconBtn"
|
||||
aria-label={t('menu.viewOptions')}
|
||||
title={t('menu.viewOptions')}
|
||||
onClick={() => { invalidatePickState(); setViewOptionsOpen((v) => !v); setAddOpen(false) }}
|
||||
>{ICON_VIEW_OPTIONS}</button>
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_iconBtn"
|
||||
aria-label={t('menu.add')}
|
||||
title={t('menu.add')}
|
||||
onClick={() => {
|
||||
invalidatePickState()
|
||||
setAddOpen((v) => !v); setViewOptionsOpen(false)
|
||||
// 父目录默认 = 当前会话工作目录(不落 C 盘默认位置)
|
||||
if (!wsFolderParent) {
|
||||
const cwd = sessionScopeStore.snapshot?.cwd ?? ''
|
||||
if (cwd) setWsFolderParent(cwd)
|
||||
}
|
||||
}}
|
||||
>{ICON_ADD}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view.searchOpen && (
|
||||
<div className="dsh-wt_search">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder={t('search.placeholder')}
|
||||
value={view.query}
|
||||
onChange={(e) => persistView({ query: e.target.value })}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
/>
|
||||
<button type="button" className="dsh-wt_searchClear" aria-label={t('search.close')}
|
||||
onClick={() => persistView({ searchOpen: false, query: '' })}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
const popupLayer = (
|
||||
<>
|
||||
{addOpen && <div className="dsh-wt_popBackdrop" onClick={() => { invalidatePickState(); setAddOpen(false) }} />}
|
||||
{addOpen && (
|
||||
<div className="dsh-wt_menu dsh-wt_add dsh-wt_pop" style={{ position: 'fixed', left: popLeft, top: popTop, width: 360, zIndex: 80 }}>
|
||||
@@ -3064,6 +2944,135 @@ function buildCustomLayoutPrompt(req: string): string {
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
)
|
||||
|
||||
if (!wide) {
|
||||
// 收起态 = 等行高的正方形圆角按钮,中间只留 emoji;点击 = 进入对应项目
|
||||
const railItems: { icon: string; name: string; onClick: (e: any) => void }[] = [
|
||||
{ icon: CONSOLE_ICON, name: t('console.name'), onClick: (e) => clickConsoleCard(e.currentTarget as HTMLElement) },
|
||||
...aliveRegisteredIds.map((id) => ({
|
||||
icon: projects.iconOverrides[id] ?? metas[id]?.icon ?? '📦',
|
||||
name: projects.nameOverrides[id] ?? metas[id]?.name ?? id,
|
||||
onClick: () => openRailProject(id),
|
||||
})),
|
||||
...projects.shortcuts.map((s) => ({
|
||||
icon: s.icon,
|
||||
name: s.name,
|
||||
onClick: () => { try { window.open(s.href, '_blank', 'noopener') } catch {} },
|
||||
})),
|
||||
...projects.layouts.map((l) => ({
|
||||
icon: l.icon ?? '🧱',
|
||||
name: projects.nameOverrides[l.id] ?? l.title,
|
||||
onClick: () => openSplit(l),
|
||||
})),
|
||||
]
|
||||
// 浮动态:fixed 定位到拖前高度(左/宽取折叠列实测几何);停靠态:文档流原位
|
||||
const railStyle = isFloat && railRect
|
||||
? { position: 'fixed' as const, top: float.top, left: railRect.left, width: railRect.width, zIndex: 70 }
|
||||
: bottomInset > 0 ? { marginBottom: bottomInset } : undefined
|
||||
return (
|
||||
<>
|
||||
<div ref={rootRef} className="dsh-wt_section dsh-wt_rail" style={railStyle}>
|
||||
<div className="dsh-wt_divider" />
|
||||
<div className="dsh-wt_railBox">
|
||||
{railItems.length > 0
|
||||
? railItems.map((it, i) => (
|
||||
<button key={i} type="button" className="dsh-wt_railBtn" title={it.name} aria-label={it.name} onClick={it.onClick}>
|
||||
<span aria-hidden>{it.icon}</span>
|
||||
</button>
|
||||
))
|
||||
: <span className="dsh-wt_railIcon">≡</span>}
|
||||
</div>
|
||||
</div>
|
||||
{popupLayer}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={'dsh-wt_section' + (isFloat ? ' dsh-wt_float' : '')} style={isFloat ? floatStyle : dockedStyle}>
|
||||
<div className="dsh-wt_divider" />
|
||||
<div className="dsh-wt_header">
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_handle"
|
||||
data-float={isFloat ? 'true' : 'false'}
|
||||
title={t('handle.title')}
|
||||
aria-label={t('handle.aria')}
|
||||
onPointerDown={onHandlePointerDown}
|
||||
onPointerMove={onHandlePointerMove}
|
||||
onPointerUp={onHandlePointerUp}
|
||||
onPointerCancel={onHandlePointerUp}
|
||||
onDoubleClick={resetDock}
|
||||
>≡</button>
|
||||
<span
|
||||
className="dsh-wt_title"
|
||||
title={t('handle.title')}
|
||||
onPointerDown={onHandlePointerDown}
|
||||
onPointerMove={onHandlePointerMove}
|
||||
onPointerUp={onHandlePointerUp}
|
||||
onPointerCancel={onHandlePointerUp}
|
||||
onDoubleClick={resetDock}
|
||||
>{t('title')}</span>
|
||||
{updateInfo && (
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_updateBadge"
|
||||
title={t('update.badgeTitle') + ' · v' + updateInfo.latest}
|
||||
aria-label={t('update.badgeTitle')}
|
||||
onClick={() => setViewOptionsOpen(true)}
|
||||
>{ICON_SYNC}</button>
|
||||
)}
|
||||
<div className="dsh-wt_actions">
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_iconBtn"
|
||||
aria-label={t('menu.search')}
|
||||
title={t('menu.search')}
|
||||
onClick={() => persistView({ searchOpen: !view.searchOpen, query: view.searchOpen ? '' : view.query })}
|
||||
>{ICON_SEARCH}</button>
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_iconBtn"
|
||||
aria-label={t('menu.viewOptions')}
|
||||
title={t('menu.viewOptions')}
|
||||
onClick={() => { invalidatePickState(); setViewOptionsOpen((v) => !v); setAddOpen(false) }}
|
||||
>{ICON_VIEW_OPTIONS}</button>
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_iconBtn"
|
||||
aria-label={t('menu.add')}
|
||||
title={t('menu.add')}
|
||||
onClick={() => {
|
||||
invalidatePickState()
|
||||
setAddOpen((v) => !v); setViewOptionsOpen(false)
|
||||
// 父目录默认 = 当前会话工作目录(不落 C 盘默认位置)
|
||||
if (!wsFolderParent) {
|
||||
const cwd = sessionScopeStore.snapshot?.cwd ?? ''
|
||||
if (cwd) setWsFolderParent(cwd)
|
||||
}
|
||||
}}
|
||||
>{ICON_ADD}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view.searchOpen && (
|
||||
<div className="dsh-wt_search">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder={t('search.placeholder')}
|
||||
value={view.query}
|
||||
onChange={(e) => persistView({ query: e.target.value })}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
/>
|
||||
<button type="button" className="dsh-wt_searchClear" aria-label={t('search.close')}
|
||||
onClick={() => persistView({ searchOpen: false, query: '' })}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{popupLayer}
|
||||
<div className="dsh-wt_projects" data-managing={viewOptionsOpen ? 'true' : undefined}>
|
||||
{/* 「工作台」控制室项目:固定首位、不可删除;未绑定点开 = 强制绑定弹窗 */}
|
||||
<button
|
||||
|
||||
@@ -96,7 +96,7 @@ export const zh = {
|
||||
'custom.groupNew': '+ 新建一个分组…',
|
||||
'custom.groupNewParent': '分组父目录',
|
||||
'custom.groupNewName': '新分组名',
|
||||
'custom.groupNewParentPh': '例如 E:\\AI_Workspace\\DeepseekHarness',
|
||||
'custom.groupNewParentPh': '例如 C:\\Projects',
|
||||
'custom.groupNewNamePh': '例如 MyProject(将新建此文件夹并注册为分组)',
|
||||
'custom.groupNeedPath': '请填写分组父目录和新分组名',
|
||||
'bind.title': '绑定对话',
|
||||
@@ -174,7 +174,7 @@ export const zh = {
|
||||
'console.groupNew': '新建分组',
|
||||
'console.chooseGroup': '选择分组…',
|
||||
'console.blankNeedsGroup': '当前 DSH 的未分组空对话无法输入,请选择分组或加入现有对话。',
|
||||
'console.newParentPh': '分组父目录,例如 E:\\AI_Workspace',
|
||||
'console.newParentPh': '分组父目录,例如 C:\\Projects',
|
||||
'console.newNamePh': '分组文件夹名(将创建并注册)',
|
||||
'console.createBind': '新建并绑定',
|
||||
'console.bindFail': '创建失败,请重试',
|
||||
@@ -334,7 +334,7 @@ export const en = {
|
||||
'custom.groupNew': '+ Create a new group…',
|
||||
'custom.groupNewParent': 'Group parent folder',
|
||||
'custom.groupNewName': 'New group name',
|
||||
'custom.groupNewParentPh': 'e.g. E:\\AI_Workspace\\DeepseekHarness',
|
||||
'custom.groupNewParentPh': 'e.g. C:\\Projects',
|
||||
'custom.groupNewNamePh': 'e.g. MyProject (folder will be created and registered)',
|
||||
'custom.groupNeedPath': 'Fill in the parent folder and new group name',
|
||||
'bind.title': 'Bind conversation',
|
||||
@@ -412,7 +412,7 @@ export const en = {
|
||||
'console.groupNew': 'New group',
|
||||
'console.chooseGroup': 'Choose a group…',
|
||||
'console.blankNeedsGroup': 'Blank ungrouped chats cannot accept input in this DSH. Choose a group or join an existing chat.',
|
||||
'console.newParentPh': 'Group parent folder, e.g. E:\\AI_Workspace',
|
||||
'console.newParentPh': 'Group parent folder, e.g. C:\\Projects',
|
||||
'console.newNamePh': 'Group folder name (created & registered)',
|
||||
'console.createBind': 'Create & bind',
|
||||
'console.bindFail': 'Creation failed, try again',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
|
||||
import { parentPathOf } from './pathutil'
|
||||
import { hostWebSocketUrl, worktableUpgrade } from './hostTransport'
|
||||
import { SandboxedFrame } from './SandboxedFrame'
|
||||
import { CHANGELOG_V030 } from './changelog'
|
||||
import { LOCAL_VERSION, checkUpdate, getAutoCheck, readCache, setAutoCheck as storeAutoCheck, setSkipVersion, type UpdateInfo, type UpdateStatus } from './updateCheck'
|
||||
import { Terminal } from 'xterm'
|
||||
@@ -476,7 +477,7 @@ function boxPayload(x0: number, y0: number, x1: number, y1: number): { primary:
|
||||
const r = iframe.getBoundingClientRect()
|
||||
if (r.width <= 0 || r.height <= 0) continue
|
||||
if (cx < r.left || cx > r.right || cy < r.top || cy > r.bottom) continue
|
||||
src = iframe.getAttribute('src') ?? ''
|
||||
src = iframe.getAttribute('data-source-url') ?? iframe.getAttribute('src') ?? ''
|
||||
let inner: Document | null = null
|
||||
try { inner = iframe.contentDocument } catch { inner = null }
|
||||
if (inner) readDoc(inner, r.left, r.top)
|
||||
@@ -939,6 +940,8 @@ export const splitStore: SplitState = {
|
||||
this.savedMarginLeft = viewArea.style.marginLeft
|
||||
this.savedMarginRight = viewArea.style.marginRight
|
||||
this.savedMarginTop = viewArea.style.marginTop
|
||||
this.yieldObserver?.disconnect()
|
||||
this.yieldObserver?.observe(viewArea, { attributes: true, attributeFilter: ['style'] })
|
||||
}
|
||||
this.observer?.disconnect()
|
||||
this.observer.observe(next)
|
||||
@@ -988,6 +991,9 @@ export const splitStore: SplitState = {
|
||||
viewArea.style.marginLeft = this.lastMarginLeft
|
||||
viewArea.style.marginRight = this.lastMarginRight
|
||||
viewArea.style.marginTop = mt
|
||||
this.lastMarginLeft = viewArea.style.marginLeft
|
||||
this.lastMarginRight = viewArea.style.marginRight
|
||||
this.lastMarginTop = viewArea.style.marginTop
|
||||
},
|
||||
|
||||
setChatW(w) {
|
||||
@@ -1429,14 +1435,14 @@ function BrowserPane(props: { row: PaneRow; index: number; tabId: string; conten
|
||||
/>
|
||||
<button type="button" className="dsh-wt_browserGo" onClick={go}>↗</button>
|
||||
</div>
|
||||
<iframe key={props.reloadKey} className="dsh-wt_paneFrame" src={src} title="browser" />
|
||||
<SandboxedFrame url={src} title="browser" reloadKey={props.reloadKey} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** iframe 内容标签(网页/站点产物):刷新统一在标签栏最左(重挂载整页刷新,跨域可靠) */
|
||||
function IframePane(props: { url: string; title?: string; reloadKey: number }) {
|
||||
return <iframe key={props.reloadKey} className="dsh-wt_paneFrame" src={props.url} title={props.title ?? ''} />
|
||||
return <SandboxedFrame {...props} />
|
||||
}
|
||||
|
||||
/** 动画播放窗:iframe 壳 + 地址栏(站内自带项目/场景列表、播放、画幅切换、导出等全部控件) */
|
||||
@@ -1464,7 +1470,7 @@ function AnimPane(props: { row: PaneRow; index: number; tabId: string; content:
|
||||
/>
|
||||
<button type="button" className="dsh-wt_browserGo" onClick={go}>↗</button>
|
||||
</div>
|
||||
<iframe key={props.reloadKey} className="dsh-wt_paneFrame" src={src} title="anim" />
|
||||
<SandboxedFrame url={src} title="anim" reloadKey={props.reloadKey} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+101
-21
@@ -6,6 +6,8 @@ import { dirname, resolve as pathResolve, sep } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { homedir } from 'node:os'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { requestRejection, rejectionText } from './requestAuth'
|
||||
import { createPreviewFiles, PREVIEW_PREFIX, PREVIEW_CSP } from './previewFiles'
|
||||
|
||||
/**
|
||||
* 基础数据目录解析:不加载任何官方包(loadPkg 的兜底只能用它,禁止反向调用包加载函数——否则成环)。
|
||||
@@ -149,7 +151,13 @@ function json(res: any, status: number, body: unknown) {
|
||||
|
||||
async function readJsonBody(req: any): Promise<any> {
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of req) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
|
||||
let bytes = 0
|
||||
for await (const chunk of req) {
|
||||
const data = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
|
||||
bytes += data.length
|
||||
if (bytes > 22 * 1024 * 1024) throw Object.assign(new Error('request too large'), { status: 413 })
|
||||
chunks.push(data)
|
||||
}
|
||||
const text = Buffer.concat(chunks).toString('utf8')
|
||||
if (!text) return {}
|
||||
try { return JSON.parse(text) } catch { return {} }
|
||||
@@ -207,6 +215,15 @@ function setupTerminal(webServer: any, ctx: any) {
|
||||
if (!WebSocketServer) return
|
||||
const pty = ptyMod.default ?? ptyMod
|
||||
const wss = new WebSocketServer({ noServer: true })
|
||||
let disposed = false
|
||||
const terminals = new Set<any>()
|
||||
ctx.effect(() => () => {
|
||||
disposed = true
|
||||
for (const ws of wss.clients ?? []) { try { ws.terminate() } catch {} }
|
||||
for (const term of terminals) { try { term.kill() } catch {} }
|
||||
terminals.clear()
|
||||
try { wss.close() } catch {}
|
||||
}, 'dsh-worktable: terminal sessions')
|
||||
const spawnShell = (): { cmd: string; args: string[] } =>
|
||||
process.platform === 'win32'
|
||||
? { cmd: 'powershell.exe', args: ['-NoLogo', '-NoProfile'] } // -NoProfile:跳过用户配置(oh-my-posh 花哨提示符在 xterm 里是乱码,PSReadLine 长输入行不换行被截断)
|
||||
@@ -215,7 +232,13 @@ function setupTerminal(webServer: any, ctx: any) {
|
||||
|
||||
ctx.effect(() => webServer.registerUpgrade({
|
||||
path: '/api/worktable/term',
|
||||
handler: (req: any, socket: any, head: any) => {
|
||||
handler: async (req: any, socket: any, head: any) => {
|
||||
let rejected = await requestRejection(ctx, req)
|
||||
if (disposed) rejected = 503
|
||||
if (rejected !== undefined) {
|
||||
socket.end(`HTTP/1.1 ${rejected} ${rejectionText(rejected)}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
|
||||
return
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws: any) => {
|
||||
const u = new URL(req.url ?? '/', 'http://dsh.internal')
|
||||
const cwd = serverCwd(ctx, u.searchParams.get('sessionId') || undefined, u.searchParams.get('cwd') || undefined)
|
||||
@@ -225,13 +248,14 @@ function setupTerminal(webServer: any, ctx: any) {
|
||||
try {
|
||||
const shell = spawnShell()
|
||||
term = pty.spawn(shell.cmd, shell.args, { name: 'xterm-256color', cols, rows, cwd, env: process.env })
|
||||
terminals.add(term)
|
||||
} catch (err) {
|
||||
try { ws.send('\r\n[worktable] 终端启动失败:' + String(err)) } catch {}
|
||||
try { ws.close() } catch {}
|
||||
return
|
||||
}
|
||||
term.onData((d: string) => { try { ws.send(d) } catch {} })
|
||||
term.onExit(() => { try { ws.close() } catch {} })
|
||||
term.onExit(() => { terminals.delete(term); try { ws.close() } catch {} })
|
||||
ws.on('message', (raw: any) => {
|
||||
const text = String(raw)
|
||||
try {
|
||||
@@ -243,7 +267,7 @@ function setupTerminal(webServer: any, ctx: any) {
|
||||
} catch {}
|
||||
try { term.write(text) } catch {}
|
||||
})
|
||||
ws.on('close', () => { try { term.kill() } catch {} })
|
||||
ws.on('close', () => { terminals.delete(term); try { term.kill() } catch {} })
|
||||
})
|
||||
},
|
||||
}), 'dsh-worktable: terminal upgrade')
|
||||
@@ -256,7 +280,57 @@ export function apply(ctx: Context) {
|
||||
return
|
||||
}
|
||||
|
||||
webServer.register({
|
||||
const previews = createPreviewFiles()
|
||||
ctx.effect(() => () => previews.clear(), 'dsh-worktable: preview capabilities')
|
||||
// Direct WebServer routes do NOT inherit the host's /api authentication route.
|
||||
const register = (route: any, publicAsset = false) => ctx.effect(() => webServer.register({
|
||||
...route,
|
||||
handler: async (req: any, res: any) => {
|
||||
if (!publicAsset && route.path !== HEALTH_PATH) {
|
||||
const rejected = await requestRejection(ctx, req)
|
||||
if (rejected !== undefined) { json(res, rejected, { error: rejectionText(rejected) }); return }
|
||||
}
|
||||
return route.handler(req, res)
|
||||
},
|
||||
}), 'dsh-worktable: ' + route.path)
|
||||
|
||||
register({
|
||||
kind: 'exact', path: '/api/worktable/preview',
|
||||
handler: async (req: any, res: any) => {
|
||||
if (req.method !== 'POST') { json(res, 405, { error: 'method not allowed' }); return }
|
||||
try {
|
||||
const body = await readJsonBody(req)
|
||||
if (typeof body.revoke === 'string') { previews.revoke(body.revoke); json(res, 200, { ok: true }); return }
|
||||
if (typeof body.root !== 'string') { json(res, 400, { error: 'missing root' }); return }
|
||||
const token = await previews.mount(body.root)
|
||||
json(res, 200, { token, base: PREVIEW_PREFIX + '/' + token + '/' })
|
||||
} catch (err: any) { json(res, err.status ?? 400, { error: String(err) }) }
|
||||
},
|
||||
})
|
||||
|
||||
register({
|
||||
kind: 'prefix', path: PREVIEW_PREFIX,
|
||||
handler: async (req: any, res: any) => {
|
||||
// A sandboxed document has an opaque origin. It can read only this capability's
|
||||
// static directory, without credentials; it cannot pass the host API admission.
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') { json(res, 405, { error: 'method not allowed' }); return }
|
||||
try {
|
||||
const parts = new URL(req.url ?? '/', 'http://dsh.internal').pathname.slice(PREVIEW_PREFIX.length + 1).split('/').map(decodeURIComponent)
|
||||
const file = await previews.file(parts.shift() ?? '', parts.filter(Boolean), req.method === 'HEAD')
|
||||
if (!file) { json(res, 404, { error: 'preview unavailable; refresh to reopen' }); return }
|
||||
const ext = (file.path.split('.').pop() || '').toLowerCase()
|
||||
res.writeHead(200, {
|
||||
'content-type': FILE_TYPES[ext] ?? 'application/octet-stream',
|
||||
'cache-control': 'no-store', 'referrer-policy': 'no-referrer',
|
||||
'content-security-policy': PREVIEW_CSP, 'x-content-type-options': 'nosniff',
|
||||
'access-control-allow-origin': '*',
|
||||
})
|
||||
res.end(file.data)
|
||||
} catch (err: any) { json(res, err?.status === 413 ? 413 : 404, { error: err?.status === 413 ? 'file too large' : 'preview unavailable' }) }
|
||||
},
|
||||
}, true)
|
||||
|
||||
register({
|
||||
kind: 'exact',
|
||||
path: HEALTH_PATH,
|
||||
handler: (_req: any, res: any) => {
|
||||
@@ -265,7 +339,7 @@ export function apply(ctx: Context) {
|
||||
})
|
||||
|
||||
// 本地文件读取(资源管理器点击 .html 后浏览器标签内打开)
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/file',
|
||||
handler: async (req: any, res: any) => {
|
||||
@@ -284,7 +358,10 @@ export function apply(ctx: Context) {
|
||||
json: 'application/json; charset=utf-8', md: 'text/markdown; charset=utf-8', markdown: 'text/markdown; charset=utf-8', txt: 'text/plain; charset=utf-8', log: 'text/plain; charset=utf-8',
|
||||
pdf: 'application/pdf', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', ico: 'image/x-icon',
|
||||
}
|
||||
res.writeHead(200, { 'content-type': FILE_TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'no-store' })
|
||||
res.writeHead(200, { 'content-type': FILE_TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'no-store',
|
||||
'x-content-type-options': 'nosniff',
|
||||
...(/^(html?|svg)$/.test(ext) ? { 'content-security-policy': PREVIEW_CSP, 'referrer-policy': 'no-referrer' } : {}),
|
||||
})
|
||||
res.end(data)
|
||||
} catch (err) {
|
||||
json(res, 404, { error: String(err) })
|
||||
@@ -295,7 +372,7 @@ export function apply(ctx: Context) {
|
||||
// 本地站点(目录级静态托管):点开 index.html 时挂载整个所在目录,
|
||||
// 让 ./assets/... 等相对引用正常解析(前缀路由,余下路径 = <rootToken>/<相对路径>)。
|
||||
// 原生皮肤模板:HTML 骨架 + 设计系统样式表(随插件分发,主题自动适配)
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'prefix',
|
||||
path: TEMPLATE_PREFIX,
|
||||
handler: (req: any, res: any) => {
|
||||
@@ -316,7 +393,7 @@ export function apply(ctx: Context) {
|
||||
},
|
||||
})
|
||||
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'prefix',
|
||||
path: SITE_PREFIX,
|
||||
handler: async (req: any, res: any) => {
|
||||
@@ -340,7 +417,8 @@ export function apply(ctx: Context) {
|
||||
if (info.size > 40 * 1024 * 1024) { json(res, 413, { error: 'file too large' }); return }
|
||||
const data = await readFile(abs)
|
||||
const ext = (abs.split('.').pop() || '').toLowerCase()
|
||||
res.writeHead(200, { 'content-type': FILE_TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'no-store' })
|
||||
res.writeHead(200, { 'content-type': FILE_TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'no-store',
|
||||
'content-security-policy': PREVIEW_CSP, 'referrer-policy': 'no-referrer', 'x-content-type-options': 'nosniff' })
|
||||
res.end(data)
|
||||
} catch (err) {
|
||||
json(res, 404, { error: String(err) })
|
||||
@@ -348,7 +426,7 @@ export function apply(ctx: Context) {
|
||||
},
|
||||
})
|
||||
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/fs',
|
||||
handler: async (req: any, res: any) => {
|
||||
@@ -359,7 +437,7 @@ export function apply(ctx: Context) {
|
||||
: serverCwd(ctx, body.sessionId, body.cwd)
|
||||
json(res, 200, await listDirectory(path))
|
||||
} catch (err) {
|
||||
json(res, 500, { path: '', entries: [], truncated: false, error: String(err) })
|
||||
json(res, (err as any).status ?? 500, { path: '', entries: [], truncated: false, error: String(err) })
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -368,7 +446,7 @@ export function apply(ctx: Context) {
|
||||
// 优先走宿主正式服务 ctx.workspaceRegistry(0.1.1/0.1.2 均有,正确感知 DSH_HOME 与存储后端);
|
||||
// 不可用时回退按 resolveDshHomeSafe() 读 storages/workspace.json(只读)。
|
||||
// 返回结构是客户端契约,两种来源都映射成同一 shape。
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/workspaces',
|
||||
handler: async (_req: any, res: any) => {
|
||||
@@ -412,7 +490,7 @@ export function apply(ctx: Context) {
|
||||
})
|
||||
|
||||
// 本地文件写入(MD 编辑模式保存回磁盘)
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/write',
|
||||
handler: async (req: any, res: any) => {
|
||||
@@ -427,13 +505,13 @@ export function apply(ctx: Context) {
|
||||
await import('node:fs/promises').then((m) => m.writeFile(abs, content, 'utf8'))
|
||||
json(res, 200, { ok: true })
|
||||
} catch (err) {
|
||||
json(res, 500, { error: String(err) })
|
||||
json(res, (err as any).status ?? 500, { error: String(err) })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// 新建分组:创建目录(仅当父目录已存在,避免递归误建深层垃圾目录)
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/mkdir',
|
||||
handler: async (req: any, res: any) => {
|
||||
@@ -449,18 +527,20 @@ export function apply(ctx: Context) {
|
||||
await fsx.mkdir(abs)
|
||||
json(res, 200, { ok: true, path: abs })
|
||||
} catch (err: any) {
|
||||
json(res, err?.code === 'EEXIST' ? 200 : 500, err?.code === 'EEXIST' ? { ok: true, exists: true } : { error: String(err) })
|
||||
json(res, err?.code === 'EEXIST' ? 200 : err.status ?? 500, err?.code === 'EEXIST' ? { ok: true, exists: true } : { error: String(err) })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/git',
|
||||
handler: async (req: any, res: any) => {
|
||||
const body = await readJsonBody(req)
|
||||
const cwd = serverCwd(ctx, body.sessionId, body.cwd)
|
||||
json(res, 200, await gitStatus(cwd))
|
||||
try {
|
||||
const body = await readJsonBody(req)
|
||||
const cwd = serverCwd(ctx, body.sessionId, body.cwd)
|
||||
json(res, 200, await gitStatus(cwd))
|
||||
} catch (err: any) { json(res, err.status ?? 500, { error: String(err) }) }
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { open, realpath, stat } from 'node:fs/promises'
|
||||
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
|
||||
export const PREVIEW_PREFIX = '/api/worktable/preview-static'
|
||||
export const PREVIEW_SANDBOX = 'allow-scripts allow-forms allow-downloads'
|
||||
export const PREVIEW_CSP = `sandbox ${PREVIEW_SANDBOX}; base-uri 'none'; object-src 'none'; frame-src 'none'`
|
||||
export const PREVIEW_MAX_BYTES = 40 * 1024 * 1024
|
||||
const IDLE_MS = 30 * 60 * 1000
|
||||
const MAX_MOUNTS = 128
|
||||
|
||||
function inside(root: string, file: string): boolean {
|
||||
const rel = relative(root, file)
|
||||
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('..' + sep))
|
||||
}
|
||||
|
||||
/** Short-lived read-only capabilities. They grant one directory, never host APIs. */
|
||||
export function createPreviewFiles(now = () => Date.now()) {
|
||||
const mounts = new Map<string, { root: string; touched: number }>()
|
||||
const prune = () => { for (const [key, item] of mounts) if (now() - item.touched >= IDLE_MS) mounts.delete(key) }
|
||||
return {
|
||||
async mount(root: string) {
|
||||
if (!isAbsolute(root)) throw new Error('Preview root must be absolute')
|
||||
const canonical = await realpath(root)
|
||||
if (!(await stat(canonical)).isDirectory()) throw new Error('Preview root is not a directory')
|
||||
prune()
|
||||
if (mounts.size >= MAX_MOUNTS) throw new Error('Too many open previews; close an unused preview first')
|
||||
const token = randomBytes(24).toString('base64url')
|
||||
mounts.set(token, { root: canonical, touched: now() })
|
||||
return token
|
||||
},
|
||||
revoke(token: string) { mounts.delete(token) },
|
||||
clear() { mounts.clear() },
|
||||
async file(token: string, segments: string[], headOnly = false) {
|
||||
prune()
|
||||
const mount = mounts.get(token)
|
||||
if (!mount) return null
|
||||
// Decode exactly once before this call; forbid separators embedded in a segment.
|
||||
if (segments.some(s => s === '..' || s.includes('/') || s.includes('\\') || s.includes('\0'))) return null
|
||||
let target = resolve(mount.root, ...segments)
|
||||
if (!inside(mount.root, target)) return null
|
||||
let info = await stat(target).catch(() => null)
|
||||
if (info?.isDirectory()) target = resolve(target, 'index.html')
|
||||
const canonical = await realpath(target).catch(() => null)
|
||||
if (!canonical || !inside(mount.root, canonical)) return null
|
||||
// Keep the read bound to one handle. Recheck path/identity around the read
|
||||
// to reject ordinary replacement races; this is not an OS filesystem jail
|
||||
// against an adversarial local process coordinating ABA path swaps.
|
||||
const handle = await open(canonical, 'r').catch(() => null)
|
||||
if (!handle) return null
|
||||
try {
|
||||
const before = await handle.stat({ bigint: true })
|
||||
if (!before.isFile()) return null
|
||||
const sameFile = (other: typeof before) => other.isFile()
|
||||
&& other.dev === before.dev && other.ino === before.ino
|
||||
&& other.size === before.size && other.mtimeNs === before.mtimeNs
|
||||
&& other.ctimeNs === before.ctimeNs
|
||||
const stillValid = async () => {
|
||||
const current = await realpath(canonical)
|
||||
if (current !== canonical || !inside(mount.root, current)) return false
|
||||
const named = await stat(current, { bigint: true })
|
||||
const opened = await handle.stat({ bigint: true })
|
||||
return sameFile(named) && sameFile(opened)
|
||||
&& mounts.get(token) === mount && now() - mount.touched < IDLE_MS
|
||||
}
|
||||
const tooLarge = () => Object.assign(new Error('file too large'), { status: 413 })
|
||||
if (before.size > BigInt(PREVIEW_MAX_BYTES)) throw tooLarge()
|
||||
if (!(await stillValid())) return null
|
||||
const chunks: Buffer[] = []
|
||||
let total = 0
|
||||
if (!headOnly) {
|
||||
// A file may grow after stat. Never allocate/read an unbounded body.
|
||||
while (total <= PREVIEW_MAX_BYTES) {
|
||||
const chunk = Buffer.alloc(Math.min(64 * 1024, PREVIEW_MAX_BYTES + 1 - total))
|
||||
const { bytesRead } = await handle.read(chunk, 0, chunk.length, total)
|
||||
if (!bytesRead) break
|
||||
total += bytesRead
|
||||
if (total > PREVIEW_MAX_BYTES) throw tooLarge()
|
||||
chunks.push(chunk.subarray(0, bytesRead))
|
||||
}
|
||||
}
|
||||
if (!(await stillValid()) || (!headOnly && BigInt(total) !== before.size)) return null
|
||||
mount.touched = now()
|
||||
return { path: canonical, size: Number(before.size), data: headOnly ? undefined : Buffer.concat(chunks, total) }
|
||||
} catch (error: any) {
|
||||
if (error?.status === 413) throw error
|
||||
return null
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Use the host's current authentication service for every request, never a cached grant. */
|
||||
export async function requestRejection(ctx: any, req: any): Promise<number | undefined> {
|
||||
try {
|
||||
const connection = typeof ctx.get === 'function' ? ctx.get('connection') : undefined
|
||||
if (!connection) return 503
|
||||
if (typeof connection.admit === 'function') {
|
||||
const admission = await connection.admit(req)
|
||||
if (!admission || typeof admission !== 'object') return 503
|
||||
if ('rejection' in admission) return admission.rejection === 401 ? 401 : 403
|
||||
const peer = admission.peer
|
||||
// 0.2.1 has owner/non-owner principals. 0.2.0 has a single operator Peer.
|
||||
if (peer?.principal?.kind === 'owner') return undefined
|
||||
if (peer && peer === connection.operator && peer.principal === undefined) return undefined
|
||||
return 403
|
||||
}
|
||||
} catch {}
|
||||
// An unavailable or failed security service must not turn into public filesystem access.
|
||||
return 503
|
||||
}
|
||||
|
||||
export function rejectionText(status: number): string {
|
||||
return status === 401 ? 'unauthorized' : status === 403 ? 'forbidden' : 'Host authentication unavailable'
|
||||
}
|
||||
Reference in New Issue
Block a user