From da9ef3ace7bb3dcdcc2980e7aa3eea46b9eeb323 Mon Sep 17 00:00:00 2001 From: QinRui Date: Fri, 14 Aug 2026 20:28:10 +0800 Subject: [PATCH 1/3] Add portable agent preset packages --- README.md | 5 +- README.zh.md | 5 +- docs/preset-packages.md | 33 ++ ...sh-client-ui-agent-preset+0.1.0-rc.6.patch | 450 ++++++++++++++++++ ...seek-ai+dsh-host-apiproxy+0.1.0-rc.6.patch | 300 ++++++++++++ test/preset-transfer-patch.test.ts | 104 ++++ 6 files changed, 893 insertions(+), 4 deletions(-) create mode 100644 docs/preset-packages.md create mode 100644 patches/@deepseek-ai+dsh-client-ui-agent-preset+0.1.0-rc.6.patch create mode 100644 patches/@deepseek-ai+dsh-host-apiproxy+0.1.0-rc.6.patch create mode 100644 test/preset-transfer-patch.test.ts diff --git a/README.md b/README.md index f01a19b..2fa786c 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ DeepSeek Harness already provides a complete agent runtime and Web UI. DSH Deskt - Listens only on a random `127.0.0.1` port for each launch - Removes Node.js privileges from the renderer and enables `contextIsolation`, sandboxing, and navigation restrictions - Uses the DSH brand logo consistently in the desktop window and Harness sidebar +- Imports and exports complete custom Agent presets as portable [`.dshpreset` packages](docs/preset-packages.md), with conflict checks and a trust warning before installation - Includes a production DSH app icon in macOS ICNS and Windows ICO formats ## Model providers @@ -95,7 +96,7 @@ npm install npm run dev ``` -`npm install` runs `patch-package` to reapply DSH Desktop's model-provider onboarding and sidebar branding, installs the brand asset, and then installs the Electron runtime. +`npm install` runs `patch-package` to reapply DSH Desktop's model-provider onboarding, preset package transfer, and sidebar branding, installs the brand asset, and then installs the Electron runtime. ### Quality checks @@ -164,7 +165,7 @@ build/ Application icon assets ## Upstream version and patches -The project currently pins `@deepseek-ai/dsh@0.1.0-rc.6`. The initial provider list is captured with [`patch-package`](https://github.com/ds300/patch-package) under [`patches/`](patches/) rather than relying on untracked changes in `node_modules`. +The project currently pins `@deepseek-ai/dsh@0.1.0-rc.6`. The initial provider list and desktop preset-transfer surface are captured with [`patch-package`](https://github.com/ds300/patch-package) under [`patches/`](patches/) rather than relying on untracked changes in `node_modules`. When upgrading DSH: diff --git a/README.zh.md b/README.zh.md index a174776..a074153 100644 --- a/README.zh.md +++ b/README.zh.md @@ -62,6 +62,7 @@ DeepSeek Harness 本身提供完整的 Agent Runtime 与 Web UI。DSH Desktop - 每次启动仅监听随机的 `127.0.0.1` 端口 - Renderer 关闭 Node.js 权限,启用 `contextIsolation`、sandbox 与导航限制 - 在桌面窗口与 Harness 侧栏统一使用 DSH 品牌 Logo +- 可把完整的自定义 Agent 预设导入/导出为便携的 [`.dshpreset` 压缩包](docs/preset-packages.md),安装前会检查命名冲突并提示信任风险 - 正式 DSH 应用图标,支持 macOS ICNS 与 Windows ICO ## 模型提供方 @@ -95,7 +96,7 @@ npm install npm run dev ``` -`npm install` 会运行 `patch-package`,重放 DSH Desktop 对 Harness 首次模型配置和侧栏品牌的定制,安装品牌静态资源,然后安装 Electron Runtime。 +`npm install` 会运行 `patch-package`,重放 DSH Desktop 对 Harness 首次模型配置、Preset 压缩包导入导出和侧栏品牌的定制,安装品牌静态资源,然后安装 Electron Runtime。 ### 质量检查 @@ -164,7 +165,7 @@ build/ 应用图标资源 ## 上游版本与补丁 -项目当前固定依赖 `@deepseek-ai/dsh@0.1.0-rc.6`。首启 Provider 列表由 [`patch-package`](https://github.com/ds300/patch-package) 固化在 [`patches/`](patches/) 中,而不是依赖未跟踪的 `node_modules` 修改。 +项目当前固定依赖 `@deepseek-ai/dsh@0.1.0-rc.6`。首启 Provider 列表与桌面端 Preset 导入导出界面由 [`patch-package`](https://github.com/ds300/patch-package) 固化在 [`patches/`](patches/) 中,而不是依赖未跟踪的 `node_modules` 修改。 升级 DSH 时必须: diff --git a/docs/preset-packages.md b/docs/preset-packages.md new file mode 100644 index 0000000..4f54e13 --- /dev/null +++ b/docs/preset-packages.md @@ -0,0 +1,33 @@ +# DSH preset packages + +DSH Desktop exchanges custom Agent presets as `.dshpreset` files. A package is a ZIP archive with this layout: + +```text +manifest.json +preset/ +├── agent.cordis.yml +├── preset.yml # optional +└── skills, plugins, and other preset-owned assets +``` + +`manifest.json` currently uses format version 1: + +```json +{ + "format": "dsh-preset", + "version": 1, + "id": "my-agent", + "name": "My agent", + "description": "Optional display copy", + "sourceDshVersion": "0.1.0-rc.6", + "exportedAt": "2026-08-14T12:00:00.000Z" +} +``` + +Only custom presets can be exported. Duplicate a built-in preset first if it should be shared. Model-provider settings, API keys, credentials, sessions, and workspace files are not added by DSH Desktop; only files inside the preset directory are packaged. + +Import is a two-step operation. DSH Desktop first validates and previews the archive, then writes it only after confirmation. Existing preset identifiers are never overwritten: the user must choose a new identifier. Installation writes to a temporary directory, validates the resulting preset through the Harness preset scanner, and atomically moves it into the user preset root. + +The importer rejects absolute archive paths, parent traversal, backslash-based paths, missing compositions, unsupported manifests, oversized packages, and invalid preset compositions. Export rejects symbolic links and unsupported filesystem entries. Common OS metadata such as `.DS_Store`, `Thumbs.db`, and `desktop.ini` is omitted. + +Custom presets are executable configuration. Their compositions may load plugins and expose tools that run commands or access files with the Agent's permissions. Import packages only from trusted sources and review warnings about possible credentials, absolute paths, and DSH version differences. diff --git a/patches/@deepseek-ai+dsh-client-ui-agent-preset+0.1.0-rc.6.patch b/patches/@deepseek-ai+dsh-client-ui-agent-preset+0.1.0-rc.6.patch new file mode 100644 index 0000000..74c9ed1 --- /dev/null +++ b/patches/@deepseek-ai+dsh-client-ui-agent-preset+0.1.0-rc.6.patch @@ -0,0 +1,450 @@ +diff --git a/node_modules/@deepseek-ai/dsh-client-ui-agent-preset/lib/client.js b/node_modules/@deepseek-ai/dsh-client-ui-agent-preset/lib/client.js +index 9f92ac8..0c78cb2 100644 +--- a/node_modules/@deepseek-ai/dsh-client-ui-agent-preset/lib/client.js ++++ b/node_modules/@deepseek-ai/dsh-client-ui-agent-preset/lib/client.js +@@ -64,7 +64,25 @@ window.__ModuleLoader__.load({ + deleteTitle: "Delete this preset?", + deleteDescription: "The preset directory is deleted. Sessions already running on it keep working; new sessions cannot select it.", + deleteConfirm: "Delete", +- deleting: "Deleting…" ++ deleting: "Deleting…", ++ importPreset: "Import preset", ++ exportPreset: "Export preset", ++ importTitle: "Review preset package", ++ importIntro: "Check the package details and choose the local identifier before importing it.", ++ importSecurity: "Custom presets can run tools and commands with the same access as the agent. Import packages only from people you trust.", ++ packageFile: "Package", ++ packageContents: "Contents", ++ packageContentsValue: "{count} files", ++ packageVersion: "Created with DSH {version}", ++ importWarningAbsolutePaths: "Some files contain absolute paths and may need editing on this computer.", ++ importWarningPossibleSecrets: "Some files may contain API keys, tokens, or other secrets. Review them after importing.", ++ importWarningVersionMismatch: "This package was created with another DSH version and may need changes.", ++ importConfirm: "Import", ++ importing: "Importing…", ++ readingPackage: "Reading package…", ++ exporting: "Exporting…", ++ importFailed: "Could not import the preset package.", ++ exportFailed: "Could not export the preset." + }; + /** Simplified Chinese copy. */ + const zh = { +@@ -120,7 +138,25 @@ window.__ModuleLoader__.load({ + deleteTitle: "删除该预设?", + deleteDescription: "预设目录将被删除。已在其上运行的会话不受影响;新会话将无法再选择它。", + deleteConfirm: "删除", +- deleting: "正在删除…" ++ deleting: "正在删除…", ++ importPreset: "导入预设", ++ exportPreset: "导出预设", ++ importTitle: "确认预设包", ++ importIntro: "导入前请确认压缩包信息,并选择它在本机使用的标识符。", ++ importSecurity: "自定义预设可以使用与 Agent 相同权限的工具和命令。请只导入来自可信来源的预设包。", ++ packageFile: "压缩包", ++ packageContents: "内容", ++ packageContentsValue: "{count} 个文件", ++ packageVersion: "由 DSH {version} 创建", ++ importWarningAbsolutePaths: "部分文件包含绝对路径,换到这台电脑后可能需要调整。", ++ importWarningPossibleSecrets: "部分文件可能包含 API Key、Token 或其他密钥,请在导入后检查。", ++ importWarningVersionMismatch: "该压缩包由另一个 DSH 版本创建,可能需要调整。", ++ importConfirm: "导入", ++ importing: "正在导入…", ++ readingPackage: "正在读取压缩包…", ++ exporting: "正在导出…", ++ importFailed: "无法导入该预设压缩包。", ++ exportFailed: "无法导出该预设。" + }; + const BUILT_IN_PRESET_KEYS = { + standard: { +@@ -707,6 +743,8 @@ window.__ModuleLoader__.load({ + rows: [], + copy: null, + view: null, ++ import: null, ++ exporting: null, + pendingDelete: null, + deleting: false, + revealedPaths: {} +@@ -724,6 +762,13 @@ window.__ModuleLoader__.load({ + if (!PRESET_ID.test(draft.id)) return "idInvalid"; + if (rows.some((row) => row.id === draft.id)) return "idTaken"; + } ++ async function presetTransferError(response, fallback) { ++ try { ++ const body = await response.json(); ++ if (typeof body?.error === "string" && body.error !== "") return body.error; ++ } catch {} ++ return fallback; ++ } + /** Reads the roster and drives the copy dialog, viewer, and location reveals. */ + var AgentPresetSectionController = class { + api; +@@ -911,6 +956,143 @@ window.__ModuleLoader__.load({ + this.set({ error: messageOf(error) }); + } + } ++ async previewImport(file) { ++ this.set({ ++ error: null, ++ import: { ++ fileName: file.name, ++ data: null, ++ id: "", ++ name: void 0, ++ description: void 0, ++ fileCount: 0, ++ warnings: [], ++ conflict: false, ++ reading: true, ++ installing: false, ++ error: null ++ } ++ }); ++ try { ++ const data = await file.arrayBuffer(); ++ const response = await fetch("/api/agent-preset.import", { ++ method: "POST", ++ headers: { "content-type": "application/vnd.dsh.preset+zip" }, ++ body: data ++ }); ++ if (!response.ok) { ++ const draft = this.store.getSnapshot().import; ++ if (draft !== null) this.set({ import: { ++ ...draft, ++ reading: false, ++ error: await presetTransferError(response, "Could not import the preset package.") ++ } }); ++ return; ++ } ++ const preview = await response.json(); ++ this.set({ import: { ++ fileName: file.name, ++ data, ++ id: preview.agentPreset, ++ name: preview.name, ++ description: preview.description, ++ sourceDshVersion: preview.sourceDshVersion, ++ fileCount: preview.fileCount, ++ warnings: preview.warnings, ++ conflict: preview.conflict, ++ reading: false, ++ installing: false, ++ error: null ++ } }); ++ } catch (error) { ++ const draft = this.store.getSnapshot().import; ++ if (draft !== null) this.set({ import: { ++ ...draft, ++ reading: false, ++ error: messageOf(error) ++ } }); ++ } ++ } ++ setImportId(id) { ++ const draft = this.store.getSnapshot().import; ++ if (draft === null) return; ++ this.set({ import: { ++ ...draft, ++ id, ++ conflict: this.store.getSnapshot().rows.some((row) => row.id === id), ++ error: null ++ } }); ++ } ++ cancelImport() { ++ if (this.store.getSnapshot().import?.installing === true) return; ++ this.set({ import: null }); ++ } ++ async confirmImport() { ++ const draft = this.store.getSnapshot().import; ++ if (draft === null || draft.reading || draft.installing || draft.data === null || !PRESET_ID.test(draft.id) || draft.conflict) return; ++ this.set({ import: { ++ ...draft, ++ installing: true, ++ error: null ++ } }); ++ try { ++ const query = new URLSearchParams({ ++ agentPreset: draft.id, ++ install: "1" ++ }); ++ const response = await fetch(`/api/agent-preset.import?${query}`, { ++ method: "POST", ++ headers: { "content-type": "application/vnd.dsh.preset+zip" }, ++ body: draft.data ++ }); ++ if (!response.ok) { ++ this.set({ import: { ++ ...draft, ++ installing: false, ++ error: await presetTransferError(response, "Could not import the preset package.") ++ } }); ++ return; ++ } ++ this.set({ import: null }); ++ await this.load(); ++ this.rosterChanged(); ++ } catch (error) { ++ this.set({ import: { ++ ...draft, ++ installing: false, ++ error: messageOf(error) ++ } }); ++ } ++ } ++ async exportPreset(id) { ++ if (this.store.getSnapshot().exporting !== null) return; ++ this.set({ ++ exporting: id, ++ error: null ++ }); ++ try { ++ const query = new URLSearchParams({ agentPreset: id }); ++ const response = await fetch(`/api/agent-preset.export?${query}`); ++ if (!response.ok) { ++ this.set({ error: await presetTransferError(response, "Could not export the preset.") }); ++ return; ++ } ++ const blob = await response.blob(); ++ const href = URL.createObjectURL(blob); ++ const anchor = document.createElement("a"); ++ anchor.href = href; ++ anchor.download = `${id}.dshpreset`; ++ anchor.style.display = "none"; ++ document.body.appendChild(anchor); ++ anchor.click(); ++ anchor.remove(); ++ URL.revokeObjectURL(href); ++ } catch (error) { ++ this.set({ error: messageOf(error) }); ++ } finally { ++ this.set({ exporting: null }); ++ } ++ } + /** + * Ask for confirmation before deleting one preset. + * @param id - the preset to delete, or null to dismiss the confirmation. +@@ -974,7 +1156,7 @@ window.__ModuleLoader__.load({ + }; + //#endregion + //#region \0dsh-css:/home/runner/work/deepseek-harness/deepseek-harness/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css.mjs +- const css = ".rtSEdW_section{max-width:720px;color:var(--dsw-alias-label-primary);flex-direction:column;gap:12px;display:flex}.rtSEdW_title{margin:0;font-size:18px;font-weight:600}.rtSEdW_intro{color:var(--dsw-alias-label-tertiary);margin:0;font-size:13px}.rtSEdW_group{flex-direction:column;gap:10px;display:flex}.rtSEdW_group+.rtSEdW_group{margin-top:20px}.rtSEdW_groupHead{letter-spacing:.06em;text-transform:uppercase;color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;font-weight:600}.rtSEdW_cards{grid-template-columns:repeat(auto-fill,minmax(268px,1fr));grid-auto-rows:1fr;gap:12px;margin:0;padding:0;list-style:none;display:grid}.rtSEdW_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;flex-direction:column;transition:border-color .16s,background .16s;display:flex}.rtSEdW_card:hover:not(.rtSEdW_cardActive){border-color:var(--dsw-alias-label-dimmed)}.rtSEdW_cardActive{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-primary)}.rtSEdW_cardBroken,.rtSEdW_cardBroken:hover{border-color:var(--dsw-alias-state-error-primary)}.rtSEdW_brokenBadge{white-space:nowrap;background:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-bg-layer-3);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.rtSEdW_cardBrokenReason{color:var(--dsw-alias-state-error-primary);overflow-wrap:anywhere;font-size:12px;line-height:1.5}.rtSEdW_cardMain{appearance:none;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px 12px 0 0;flex-direction:column;flex:1;gap:8px;padding:14px 16px 12px;display:flex}.rtSEdW_cardMain:disabled{cursor:default}.rtSEdW_cardMain:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.rtSEdW_cardHead{align-items:center;gap:8px;display:flex}.rtSEdW_cardName{font-size:15px;font-weight:600;line-height:1.4}.rtSEdW_badge,.rtSEdW_inUse{white-space:nowrap;border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.rtSEdW_badge{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary)}.rtSEdW_inUse{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);margin-left:auto}.rtSEdW_cardDesc{color:var(--dsw-alias-label-secondary);-webkit-line-clamp:4;overflow-wrap:anywhere;-webkit-box-orient:vertical;min-height:42px;font-size:13px;line-height:1.55;display:-webkit-box;overflow:hidden}.rtSEdW_cardId{font-family:var(--dsw-font-mono,ui-monospace, SFMono-Regular, Menlo, monospace);color:var(--dsw-alias-label-dimmed);margin-top:auto;font-size:11px}.rtSEdW_cardFoot{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;gap:2px;padding:6px 10px;display:flex}.rtSEdW_iconButton{appearance:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:0;border-radius:7px;align-items:center;padding:6px;display:inline-flex;position:relative}.rtSEdW_iconButton:disabled{opacity:.4;cursor:default}.rtSEdW_iconButton:hover:not(:disabled){background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}.rtSEdW_iconButton:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-1px}.rtSEdW_iconButton:after{content:attr(data-tip);background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);white-space:nowrap;opacity:0;pointer-events:none;border-radius:6px;padding:3px 8px;font-size:11px;line-height:17px;transition:opacity .12s;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translate(-50%)}.rtSEdW_iconButton:hover:after,.rtSEdW_iconButton:focus-visible:after{opacity:1}.rtSEdW_iconDanger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary)}.rtSEdW_revealedPath{color:var(--dsw-alias-label-tertiary);align-items:baseline;gap:6px;margin:0;padding:6px 16px 10px;font-size:11px;display:flex}.rtSEdW_revealedPath code{font-family:var(--dsw-font-mono,ui-monospace, SFMono-Regular, Menlo, monospace);color:var(--dsw-alias-label-secondary);user-select:all;overflow-wrap:anywhere}.rtSEdW_revealedPathLabel{white-space:nowrap}.rtSEdW_secondaryButton{color:var(--dsw-alias-label-secondary);font:inherit;cursor:pointer;background:0 0;border:none;border-radius:7px;padding:5px 8px;font-size:12.5px}.rtSEdW_secondaryButton:hover:not(:disabled){background:var(--dsw-alias-bg-layer-1)}.rtSEdW_secondaryButton:disabled{opacity:.5;cursor:default}.rtSEdW_field{flex-direction:column;gap:6px;display:flex}.rtSEdW_fieldLabel{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:500}.rtSEdW_input{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);font:inherit;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:10px;padding:9px 12px;font-size:13px}.rtSEdW_input:focus{border-color:var(--dsw-alias-brand-primary);outline:none}.rtSEdW_input::placeholder{color:var(--dsw-alias-label-dimmed)}.rtSEdW_dialog{width:min(560px,100%)}.rtSEdW_dialogFields{flex-direction:column;gap:12px;display:flex}.rtSEdW_viewerCode{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);max-height:min(52vh,480px);color:var(--dsw-alias-label-secondary);font-family:var(--dsw-font-mono,ui-monospace, SFMono-Regular, Menlo, monospace);white-space:pre;tab-size:2;--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);border-radius:10px;margin:0;padding:12px;font-size:12.5px;line-height:1.5;overflow:auto}.rtSEdW_error{color:var(--dsw-alias-state-error-primary);margin:0;font-size:12px}.rtSEdW_deleteDialog{width:min(480px,100%)}.rtSEdW_deleteConfirm:not(:disabled){border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}.rtSEdW_deleteConfirm:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}.rtSEdW_creatorButton{box-sizing:border-box;border:1px dashed var(--dsw-alias-border-l3);height:44px;font:inherit;color:var(--dsw-alias-label-primary);cursor:pointer;background:0 0;border-radius:12px;justify-content:center;align-self:stretch;align-items:center;gap:6px;font-size:14px;line-height:22px;display:flex}.rtSEdW_creatorButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.rtSEdW_creatorButton:disabled{opacity:.4;cursor:default}"; ++ const css = ".rtSEdW_section{max-width:720px;color:var(--dsw-alias-label-primary);flex-direction:column;gap:12px;display:flex}.rtSEdW_title{margin:0;font-size:18px;font-weight:600}.rtSEdW_intro{color:var(--dsw-alias-label-tertiary);margin:0;font-size:13px}.rtSEdW_group{flex-direction:column;gap:10px;display:flex}.rtSEdW_group+.rtSEdW_group{margin-top:20px}.rtSEdW_groupHead{letter-spacing:.06em;text-transform:uppercase;color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;font-weight:600}.rtSEdW_cards{grid-template-columns:repeat(auto-fill,minmax(268px,1fr));grid-auto-rows:1fr;gap:12px;margin:0;padding:0;list-style:none;display:grid}.rtSEdW_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;flex-direction:column;transition:border-color .16s,background .16s;display:flex}.rtSEdW_card:hover:not(.rtSEdW_cardActive){border-color:var(--dsw-alias-label-dimmed)}.rtSEdW_cardActive{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-primary)}.rtSEdW_cardBroken,.rtSEdW_cardBroken:hover{border-color:var(--dsw-alias-state-error-primary)}.rtSEdW_brokenBadge{white-space:nowrap;background:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-bg-layer-3);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.rtSEdW_cardBrokenReason{color:var(--dsw-alias-state-error-primary);overflow-wrap:anywhere;font-size:12px;line-height:1.5}.rtSEdW_cardMain{appearance:none;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px 12px 0 0;flex-direction:column;flex:1;gap:8px;padding:14px 16px 12px;display:flex}.rtSEdW_cardMain:disabled{cursor:default}.rtSEdW_cardMain:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.rtSEdW_cardHead{align-items:center;gap:8px;display:flex}.rtSEdW_cardName{font-size:15px;font-weight:600;line-height:1.4}.rtSEdW_badge,.rtSEdW_inUse{white-space:nowrap;border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.rtSEdW_badge{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary)}.rtSEdW_inUse{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);margin-left:auto}.rtSEdW_cardDesc{color:var(--dsw-alias-label-secondary);-webkit-line-clamp:4;overflow-wrap:anywhere;-webkit-box-orient:vertical;min-height:42px;font-size:13px;line-height:1.55;display:-webkit-box;overflow:hidden}.rtSEdW_cardId{font-family:var(--dsw-font-mono,ui-monospace, SFMono-Regular, Menlo, monospace);color:var(--dsw-alias-label-dimmed);margin-top:auto;font-size:11px}.rtSEdW_cardFoot{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;gap:2px;padding:6px 10px;display:flex}.rtSEdW_iconButton{appearance:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:0;border-radius:7px;align-items:center;padding:6px;display:inline-flex;position:relative}.rtSEdW_iconButton:disabled{opacity:.4;cursor:default}.rtSEdW_iconButton:hover:not(:disabled){background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}.rtSEdW_iconButton:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-1px}.rtSEdW_iconButton:after{content:attr(data-tip);background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);white-space:nowrap;opacity:0;pointer-events:none;border-radius:6px;padding:3px 8px;font-size:11px;line-height:17px;transition:opacity .12s;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translate(-50%)}.rtSEdW_iconButton:hover:after,.rtSEdW_iconButton:focus-visible:after{opacity:1}.rtSEdW_iconDanger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary)}.rtSEdW_revealedPath{color:var(--dsw-alias-label-tertiary);align-items:baseline;gap:6px;margin:0;padding:6px 16px 10px;font-size:11px;display:flex}.rtSEdW_revealedPath code{font-family:var(--dsw-font-mono,ui-monospace, SFMono-Regular, Menlo, monospace);color:var(--dsw-alias-label-secondary);user-select:all;overflow-wrap:anywhere}.rtSEdW_revealedPathLabel{white-space:nowrap}.rtSEdW_secondaryButton{color:var(--dsw-alias-label-secondary);font:inherit;cursor:pointer;background:0 0;border:none;border-radius:7px;padding:5px 8px;font-size:12.5px}.rtSEdW_secondaryButton:hover:not(:disabled){background:var(--dsw-alias-bg-layer-1)}.rtSEdW_secondaryButton:disabled{opacity:.5;cursor:default}.rtSEdW_field{flex-direction:column;gap:6px;display:flex}.rtSEdW_fieldLabel{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:500}.rtSEdW_input{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);font:inherit;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:10px;padding:9px 12px;font-size:13px}.rtSEdW_input:focus{border-color:var(--dsw-alias-brand-primary);outline:none}.rtSEdW_input::placeholder{color:var(--dsw-alias-label-dimmed)}.rtSEdW_dialog{width:min(560px,100%)}.rtSEdW_dialogFields{flex-direction:column;gap:12px;display:flex}.rtSEdW_viewerCode{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);max-height:min(52vh,480px);color:var(--dsw-alias-label-secondary);font-family:var(--dsw-font-mono,ui-monospace, SFMono-Regular, Menlo, monospace);white-space:pre;tab-size:2;--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);border-radius:10px;margin:0;padding:12px;font-size:12.5px;line-height:1.5;overflow:auto}.rtSEdW_error{color:var(--dsw-alias-state-error-primary);margin:0;font-size:12px}.rtSEdW_deleteDialog{width:min(480px,100%)}.rtSEdW_deleteConfirm:not(:disabled){border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}.rtSEdW_deleteConfirm:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}.rtSEdW_creatorButton{box-sizing:border-box;border:1px dashed var(--dsw-alias-border-l3);height:44px;font:inherit;color:var(--dsw-alias-label-primary);cursor:pointer;background:0 0;border-radius:12px;justify-content:center;align-self:stretch;align-items:center;gap:6px;font-size:14px;line-height:22px;display:flex}.rtSEdW_creatorButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.rtSEdW_creatorButton:disabled{opacity:.4;cursor:default}.rtSEdW_sectionHead{justify-content:space-between;align-items:center;gap:12px;display:flex}.rtSEdW_importButton{flex:none}.rtSEdW_hiddenInput{display:none}.rtSEdW_importSecurity{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);border-radius:10px;margin:0;padding:10px 12px;font-size:12px;line-height:1.5}.rtSEdW_importSummary{grid-template-columns:max-content 1fr;gap:6px 14px;margin:0;font-size:12px}.rtSEdW_importSummary dt{color:var(--dsw-alias-label-tertiary)}.rtSEdW_importSummary dd{overflow-wrap:anywhere;color:var(--dsw-alias-label-secondary);margin:0}.rtSEdW_importWarnings{color:var(--dsw-alias-state-warning-primary,var(--dsw-alias-label-secondary));margin:0;padding-left:20px;font-size:12px;line-height:1.5}"; + const tagId = "@deepseek-ai/dsh-client-ui-agent-preset/AgentPresetSection.module.css"; + if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) { + const tag = document.createElement("style"); +@@ -985,6 +1167,12 @@ window.__ModuleLoader__.load({ + } + var AgentPresetSection_module_css_default = { + "cardMain": "rtSEdW_cardMain", ++ "sectionHead": "rtSEdW_sectionHead", ++ "importButton": "rtSEdW_importButton", ++ "hiddenInput": "rtSEdW_hiddenInput", ++ "importSecurity": "rtSEdW_importSecurity", ++ "importSummary": "rtSEdW_importSummary", ++ "importWarnings": "rtSEdW_importWarnings", + "field": "rtSEdW_field", + "cardDesc": "rtSEdW_cardDesc", + "dialog": "rtSEdW_dialog", +@@ -1104,6 +1292,84 @@ window.__ModuleLoader__.load({ + }) + }); + } ++ function ImportDialog({ state, t, actions }) { ++ const draft = state.import; ++ const blocker = draft === null || draft.reading ? void 0 : draft.id === "" ? "idRequired" : !PRESET_ID.test(draft.id) ? "idInvalid" : draft.conflict ? "idTaken" : void 0; ++ const warningText = { ++ "absolute-paths": "importWarningAbsolutePaths", ++ "possible-secrets": "importWarningPossibleSecrets", ++ "version-mismatch": "importWarningVersionMismatch" ++ }; ++ return (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Modal, { ++ open: draft !== null, ++ onClose: () => { ++ actions.cancelImport(); ++ }, ++ title: t("importTitle"), ++ closeLabel: t("close"), ++ description: t("importIntro"), ++ className: AgentPresetSection_module_css_default.dialog, ++ footer: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, { ++ variant: "outline", ++ disabled: draft?.installing === true, ++ onClick: () => { ++ actions.cancelImport(); ++ }, ++ children: t("cancel") ++ }), (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, { ++ disabled: draft === null || draft.reading || draft.installing || draft.data === null || blocker !== void 0, ++ onClick: () => { ++ actions.confirmImport(); ++ }, ++ children: draft?.installing === true ? t("importing") : t("importConfirm") ++ })] }), ++ children: draft === null ? null : draft.reading ? (0, react_jsx_runtime.jsx)("p", { children: t("readingPackage") }) : (0, react_jsx_runtime.jsxs)("div", { ++ className: AgentPresetSection_module_css_default.dialogFields, ++ children: [ ++ (0, react_jsx_runtime.jsx)("p", { ++ className: AgentPresetSection_module_css_default.importSecurity, ++ children: t("importSecurity") ++ }), ++ (0, react_jsx_runtime.jsxs)("dl", { ++ className: AgentPresetSection_module_css_default.importSummary, ++ children: [ ++ (0, react_jsx_runtime.jsx)("dt", { children: t("packageFile") }), ++ (0, react_jsx_runtime.jsx)("dd", { children: draft.fileName }), ++ (0, react_jsx_runtime.jsx)("dt", { children: t("packageContents") }), ++ (0, react_jsx_runtime.jsx)("dd", { children: t("packageContentsValue", { count: draft.fileCount }) }), ++ draft.sourceDshVersion === void 0 ? null : (0, react_jsx_runtime.jsx)("dt", { children: "DSH" }), ++ draft.sourceDshVersion === void 0 ? null : (0, react_jsx_runtime.jsx)("dd", { children: t("packageVersion", { version: draft.sourceDshVersion }) }) ++ ] ++ }), ++ (0, react_jsx_runtime.jsxs)("label", { ++ className: AgentPresetSection_module_css_default.field, ++ children: [(0, react_jsx_runtime.jsx)("span", { ++ className: AgentPresetSection_module_css_default.fieldLabel, ++ children: t("presetId") ++ }), (0, react_jsx_runtime.jsx)("input", { ++ className: AgentPresetSection_module_css_default.input, ++ value: draft.id, ++ autoFocus: true, ++ spellCheck: false, ++ placeholder: t("presetIdPlaceholder"), ++ onChange: (event) => { ++ actions.setImportId(event.target.value); ++ } ++ })] ++ }), ++ draft.warnings.length === 0 ? null : (0, react_jsx_runtime.jsx)("ul", { ++ className: AgentPresetSection_module_css_default.importWarnings, ++ children: draft.warnings.map((warning) => (0, react_jsx_runtime.jsx)("li", { children: t(warningText[warning] ?? warning) }, warning)) ++ }), ++ draft.error === null && blocker === void 0 ? null : (0, react_jsx_runtime.jsx)("p", { ++ className: AgentPresetSection_module_css_default.error, ++ role: "alert", ++ children: draft.error ?? t(blocker) ++ }) ++ ] ++ }) ++ }); ++ } + /** + * Render one card's description, clamped by CSS and offered in full on hover. + * The tooltip is attached only while the text is actually cut off, so a short +@@ -1151,6 +1417,7 @@ window.__ModuleLoader__.load({ + function AgentPresetSection(props) { + const { useAgentPresetSection, t, load } = props; + const state = useAgentPresetSection((snapshot) => snapshot); ++ const importInput = (0, react.useRef)(null); + const viewedId = state.view?.id; + const viewedRow = viewedId === void 0 ? void 0 : state.rows.find((row) => row.id === viewedId); + const viewedTitle = state.view === null ? "" : viewedRow === void 0 ? state.view.title : presetDisplayText(viewedRow, t).name; +@@ -1191,9 +1458,31 @@ window.__ModuleLoader__.load({ + return (0, react_jsx_runtime.jsxs)("div", { + className: AgentPresetSection_module_css_default.section, + children: [ +- (0, react_jsx_runtime.jsx)("h2", { +- className: AgentPresetSection_module_css_default.title, +- children: t("nav") ++ (0, react_jsx_runtime.jsxs)("div", { ++ className: AgentPresetSection_module_css_default.sectionHead, ++ children: [(0, react_jsx_runtime.jsx)("h2", { ++ className: AgentPresetSection_module_css_default.title, ++ children: t("nav") ++ }), (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [(0, react_jsx_runtime.jsx)("input", { ++ ref: importInput, ++ type: "file", ++ accept: ".dshpreset,application/vnd.dsh.preset+zip,application/zip", ++ className: AgentPresetSection_module_css_default.hiddenInput, ++ onChange: (event) => { ++ const file = event.target.files?.[0]; ++ event.target.value = ""; ++ if (file !== void 0) props.previewImport(file); ++ } ++ }), (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Button, { ++ variant: "outline", ++ className: AgentPresetSection_module_css_default.importButton, ++ disabled: !state.authorable, ++ title: state.authorable ? void 0 : t("duplicateUnavailable"), ++ onClick: () => { ++ importInput.current?.click(); ++ }, ++ children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconArchiveOutline20, { size: 16 }), t("importPreset")] ++ })] })] + }), + (0, react_jsx_runtime.jsx)("p", { + className: AgentPresetSection_module_css_default.intro, +@@ -1289,7 +1578,7 @@ window.__ModuleLoader__.load({ + }, + children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconFolderOpenOutline16, {}) + }), +- (0, react_jsx_runtime.jsx)("button", { ++ (0, react_jsx_runtime.jsx)("button", { + type: "button", + className: AgentPresetSection_module_css_default.iconButton, + disabled: !state.authorable || row.broken !== void 0, +@@ -1298,9 +1587,20 @@ window.__ModuleLoader__.load({ + onClick: () => { + props.beginCopy(row.id); + }, +- children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCopyOutline16, {}) +- }), +- row.trust === "user" ? (0, react_jsx_runtime.jsx)("button", { ++ children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCopyOutline16, {}) ++ }), ++ row.trust === "user" ? (0, react_jsx_runtime.jsx)("button", { ++ type: "button", ++ className: AgentPresetSection_module_css_default.iconButton, ++ disabled: state.exporting !== null || row.broken !== void 0, ++ "data-tip": state.exporting === row.id ? t("exporting") : t("exportPreset"), ++ "aria-label": `${t("exportPreset")}: ${text.name}`, ++ onClick: () => { ++ props.exportPreset(row.id); ++ }, ++ children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconDownloadOutline16, {}) ++ }) : null, ++ row.trust === "user" ? (0, react_jsx_runtime.jsx)("button", { + type: "button", + className: `${AgentPresetSection_module_css_default.iconButton} ${AgentPresetSection_module_css_default.iconDanger}`, + "data-tip": t("delete"), +@@ -1329,13 +1629,22 @@ window.__ModuleLoader__.load({ + (0, react_jsx_runtime.jsx)(CopyDialog, { + state, + t, +- actions: { ++ actions: { + cancelCopy: props.cancelCopy, + confirmCopy: props.confirmCopy, + setCopyId: props.setCopyId, + setCopyName: props.setCopyName +- } +- }), ++ } ++ }), ++ (0, react_jsx_runtime.jsx)(ImportDialog, { ++ state, ++ t, ++ actions: { ++ cancelImport: props.cancelImport, ++ confirmImport: props.confirmImport, ++ setImportId: props.setImportId ++ } ++ }), + (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Modal, { + open: state.view !== null, + onClose: () => { +@@ -1689,6 +1998,15 @@ window.__ModuleLoader__.load({ + }, + confirmCopy: () => section.confirmCopy(), + openLocation: (id) => section.openLocation(id), ++ previewImport: (file) => section.previewImport(file), ++ setImportId: (id) => { ++ section.setImportId(id); ++ }, ++ cancelImport: () => { ++ section.cancelImport(); ++ }, ++ confirmImport: () => section.confirmImport(), ++ exportPreset: (id) => section.exportPreset(id), + ...creatorDraft === void 0 ? {} : { startCreatorDraft: creatorDraft }, + confirmDelete: (id) => { + section.confirmDelete(id); diff --git a/patches/@deepseek-ai+dsh-host-apiproxy+0.1.0-rc.6.patch b/patches/@deepseek-ai+dsh-host-apiproxy+0.1.0-rc.6.patch new file mode 100644 index 0000000..0e6c034 --- /dev/null +++ b/patches/@deepseek-ai+dsh-host-apiproxy+0.1.0-rc.6.patch @@ -0,0 +1,300 @@ +diff --git a/node_modules/@deepseek-ai/dsh-host-apiproxy/lib/index.js b/node_modules/@deepseek-ai/dsh-host-apiproxy/lib/index.js +index 9e3afa7..1c97a67 100644 +--- a/node_modules/@deepseek-ai/dsh-host-apiproxy/lib/index.js ++++ b/node_modules/@deepseek-ai/dsh-host-apiproxy/lib/index.js +@@ -1,8 +1,8 @@ + import { Service } from "@deepseek-ai/cordis"; + import z from "@deepseek-ai/schemastery"; + import { randomUUID } from "node:crypto"; +-import { mkdir, stat } from "node:fs/promises"; +-import { dirname, extname } from "node:path"; ++import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; ++import { dirname, extname, join, relative, resolve, sep } from "node:path"; + import { installModelSelection } from "@deepseek-ai/dsh-agent"; + import { AttachmentError } from "@deepseek-ai/dsh-attachment"; + import { ReasoningEffortId, contentHasImage, createUserMessage, errorChain, freezeMessage } from "@deepseek-ai/dsh-llm"; +@@ -11,8 +11,8 @@ import { SessionQueryError } from "@deepseek-ai/dsh-session-query"; + import { SubagentError } from "@deepseek-ai/dsh-subagent"; + import { isUserInvocable } from "@deepseek-ai/dsh-skill"; + import { WorkspaceId, WorkspaceMoveInvalidError, WorkspaceOrderInvalidError, WorkspaceUnknownSessionError, workspaceDomainState, workspaceRecord } from "@deepseek-ai/dsh-workspace"; +-import { InvalidPresetIdError, PresetExistsError, PresetMountError, PresetNotWritableError, SETTINGS_NAMESPACE, UnknownPresetError, resolveSessionPreset } from "@deepseek-ai/dsh-agent-presets"; +-import { Zip, ZipDeflate } from "fflate"; ++import { COMPOSITION_FILE, InvalidPresetIdError, PresetExistsError, PresetMountError, PresetNotWritableError, SETTINGS_NAMESPACE, UnknownPresetError, resolveSessionPreset, scanRoot, writableRoot } from "@deepseek-ai/dsh-agent-presets"; ++import { Zip, ZipDeflate, strFromU8, strToU8, unzipSync, zipSync } from "fflate"; + import { GoalError } from "@deepseek-ai/dsh-goal"; + import { SettingsConflictError, settingsNamespace } from "@deepseek-ai/dsh-settings"; + import { credentialRef } from "@deepseek-ai/dsh-credentials"; +@@ -23,6 +23,110 @@ import { DirectoryPickerError } from "@deepseek-ai/dsh-host-directory-picker"; + import { API_REMOTE_FORWARDED_EVENTS, ApiRemoteSessionNotFound, ApiRemoteSubagentSessionOwnership, apiRemoteSubagentOwnershipError, createApiRemoteAgentResolver, hasApiRemoteSubagentOwner, inspectApiRemoteSession } from "@deepseek-ai/dsh-api-remotes"; + import { release } from "node:os"; + import { runNativeCommand } from "@deepseek-ai/dsh-native-command"; ++//#region lib/types/preset-archive.js ++const PRESET_ARCHIVE_FORMAT = "dsh-preset"; ++const PRESET_ARCHIVE_VERSION = 1; ++const PRESET_ARCHIVE_MIME = "application/vnd.dsh.preset+zip"; ++const PRESET_ARCHIVE_MAX_COMPRESSED = 16 * 1024 * 1024; ++const PRESET_ARCHIVE_MAX_UNCOMPRESSED = 32 * 1024 * 1024; ++const PRESET_ARCHIVE_MAX_FILE = 12 * 1024 * 1024; ++const PRESET_ARCHIVE_MAX_FILES = 512; ++const PRESET_ARCHIVE_ID = /^[a-z0-9][a-z0-9-]*$/; ++const PRESET_ARCHIVE_IGNORED_FILES = new Set([".DS_Store", "Thumbs.db", "desktop.ini"]); ++const PRESET_TEXT_EXTENSIONS = new Set([".json", ".jsonc", ".md", ".txt", ".yaml", ".yml", ".toml", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".py", ".sh", ".ps1", ".html", ".css"]); ++function presetArchiveFailure(message, status = 400) { ++ return Response.json({ ++ ok: false, ++ error: message ++ }, { status }); ++} ++function safePresetArchivePath(name) { ++ if (name === "" || name.includes("\0") || name.includes("\\") || name.startsWith("/") || /^[a-zA-Z]:/.test(name)) return false; ++ const segments = name.split("/"); ++ return segments.every((segment) => segment !== "" && segment !== "." && segment !== ".."); ++} ++function presetArchiveWarnings(files) { ++ let hasAbsolutePath = false; ++ let hasPossibleSecret = false; ++ for (const [name, data] of Object.entries(files)) { ++ if (!PRESET_TEXT_EXTENSIONS.has(extname(name).toLowerCase()) || data.length > 1024 * 1024 || data.includes(0)) continue; ++ const text = strFromU8(data); ++ if (/(?:^|[\s"'(:=])(?:\/Users\/|\/home\/)/m.test(text) || /[A-Za-z]:[\\/]/.test(text) || /\\\\[^\\\s]+[\\/]/.test(text)) hasAbsolutePath = true; ++ if (/(?:api[_-]?key|secret|token)\s*[:=]\s*["']?[^\s"']{12,}|\bsk-[A-Za-z0-9_-]{16,}/i.test(text)) hasPossibleSecret = true; ++ } ++ return [ ++ ...hasAbsolutePath ? ["absolute-paths"] : [], ++ ...hasPossibleSecret ? ["possible-secrets"] : [] ++ ]; ++} ++async function collectPresetArchiveFiles(root) { ++ const files = /* @__PURE__ */ Object.create(null); ++ let count = 0; ++ let total = 0; ++ async function visit(directory) { ++ for (const entry of await readdir(directory, { withFileTypes: true })) { ++ if (PRESET_ARCHIVE_IGNORED_FILES.has(entry.name)) continue; ++ const full = join(directory, entry.name); ++ const rel = relative(root, full).split("\\").join("/"); ++ if (!safePresetArchivePath(rel)) throw new Error(`Preset contains an unsafe path: ${rel}`); ++ const info = await lstat(full); ++ if (info.isSymbolicLink()) throw new Error(`Preset contains a symbolic link, which cannot be exported safely: ${rel}`); ++ if (info.isDirectory()) { ++ await visit(full); ++ continue; ++ } ++ if (!info.isFile()) throw new Error(`Preset contains an unsupported filesystem entry: ${rel}`); ++ if (++count > PRESET_ARCHIVE_MAX_FILES) throw new Error(`Preset contains more than ${PRESET_ARCHIVE_MAX_FILES} files`); ++ if (info.size > PRESET_ARCHIVE_MAX_FILE) throw new Error(`Preset file is too large to export: ${rel}`); ++ total += info.size; ++ if (total > PRESET_ARCHIVE_MAX_UNCOMPRESSED) throw new Error("Preset is too large to export"); ++ files[`preset/${rel}`] = new Uint8Array(await readFile(full)); ++ } ++ } ++ await visit(root); ++ return files; ++} ++function parsePresetArchive(data) { ++ if (data.length > PRESET_ARCHIVE_MAX_COMPRESSED) throw new Error("Preset package is larger than 16 MB"); ++ let count = 0; ++ let total = 0; ++ const archive = unzipSync(data, { filter(file) { ++ if (!safePresetArchivePath(file.name)) throw new Error(`Preset package contains an unsafe path: ${file.name}`); ++ if (++count > PRESET_ARCHIVE_MAX_FILES + 1) throw new Error(`Preset package contains more than ${PRESET_ARCHIVE_MAX_FILES} files`); ++ if (file.originalSize > PRESET_ARCHIVE_MAX_FILE) throw new Error(`Preset package contains an oversized file: ${file.name}`); ++ total += file.originalSize; ++ if (total > PRESET_ARCHIVE_MAX_UNCOMPRESSED) throw new Error("Expanded preset package is larger than 32 MB"); ++ return true; ++ } }); ++ const manifestBytes = archive["manifest.json"]; ++ if (manifestBytes === void 0) throw new Error("Preset package has no manifest.json"); ++ let manifest; ++ try { ++ manifest = JSON.parse(strFromU8(manifestBytes)); ++ } catch { ++ throw new Error("Preset package manifest is not valid JSON"); ++ } ++ if (typeof manifest !== "object" || manifest === null || manifest.format !== PRESET_ARCHIVE_FORMAT || manifest.version !== PRESET_ARCHIVE_VERSION || typeof manifest.id !== "string" || !PRESET_ARCHIVE_ID.test(manifest.id)) throw new Error("Preset package manifest is unsupported or invalid"); ++ if (manifest.name !== void 0 && (typeof manifest.name !== "string" || manifest.name.length > 160)) throw new Error("Preset package manifest has an invalid name"); ++ if (manifest.description !== void 0 && (typeof manifest.description !== "string" || manifest.description.length > 4e3)) throw new Error("Preset package manifest has an invalid description"); ++ if (manifest.sourceDshVersion !== void 0 && (typeof manifest.sourceDshVersion !== "string" || manifest.sourceDshVersion.length > 64)) throw new Error("Preset package manifest has an invalid DSH version"); ++ const files = /* @__PURE__ */ Object.create(null); ++ for (const [name, bytes] of Object.entries(archive)) { ++ if (name === "manifest.json") continue; ++ if (!name.startsWith("preset/") || name === "preset/") throw new Error(`Unexpected file outside the preset directory: ${name}`); ++ const rel = name.slice("preset/".length); ++ if (!safePresetArchivePath(rel)) throw new Error(`Preset package contains an unsafe path: ${rel}`); ++ if (PRESET_ARCHIVE_IGNORED_FILES.has(rel.split("/").at(-1))) continue; ++ files[rel] = bytes; ++ } ++ if (files[COMPOSITION_FILE] === void 0) throw new Error(`Preset package is missing ${COMPOSITION_FILE}`); ++ return { ++ manifest, ++ files, ++ warnings: presetArchiveWarnings(files) ++ }; ++} ++//#endregion + //#region lib/types/session-export.js + /** + * Host-side session-log download: streams one ZIP archive whose files are the +@@ -3299,6 +3403,127 @@ function createApiProxy(ctx, defaults) { + } + }, + agentPresets: { ++ async exportArchive(agentPreset, signal) { ++ const presets = ctx.get("agentPresets"); ++ if (presets === void 0) return presetArchiveFailure("This deployment has no agent presets.", 503); ++ try { ++ signal?.throwIfAborted(); ++ const preset = await presets.resolve(agentPreset); ++ if (preset.trust !== "user") return presetArchiveFailure("Built-in presets cannot be exported. Duplicate this preset first, then export the custom copy.", 403); ++ if (preset.broken !== void 0) return presetArchiveFailure(`This preset cannot be exported because it failed to load: ${preset.broken}`); ++ const files = await collectPresetArchiveFiles(dirname(preset.path)); ++ const manifest = { ++ format: PRESET_ARCHIVE_FORMAT, ++ version: PRESET_ARCHIVE_VERSION, ++ id: preset.id, ++ ...preset.name === void 0 ? {} : { name: preset.name }, ++ ...preset.description === void 0 ? {} : { description: preset.description }, ++ sourceDshVersion: "0.1.0-rc.6", ++ exportedAt: (/* @__PURE__ */ new Date()).toISOString() ++ }; ++ const data = zipSync({ ++ "manifest.json": strToU8(JSON.stringify(manifest, null, 2)), ++ ...files ++ }, { level: 6 }); ++ if (data.length > PRESET_ARCHIVE_MAX_COMPRESSED) return presetArchiveFailure("The compressed preset package is larger than 16 MB.", 413); ++ signal?.throwIfAborted(); ++ return new Response(data, { headers: { ++ "content-type": PRESET_ARCHIVE_MIME, ++ "content-disposition": `attachment; filename="${preset.id}.dshpreset"`, ++ "cache-control": "no-store" ++ } }); ++ } catch (error) { ++ if (signal?.aborted) return presetArchiveFailure("Preset export was cancelled.", 499); ++ return presetArchiveFailure(error instanceof Error ? error.message : String(error)); ++ } ++ }, ++ async importArchive(data, options, signal) { ++ const presets = ctx.get("agentPresets"); ++ if (presets === void 0) return presetArchiveFailure("This deployment has no agent presets.", 503); ++ let parsed; ++ try { ++ parsed = parsePresetArchive(data); ++ } catch (error) { ++ return presetArchiveFailure(error instanceof Error ? error.message : String(error)); ++ } ++ const agentPreset = options.agentPreset ?? parsed.manifest.id; ++ if (!PRESET_ARCHIVE_ID.test(agentPreset)) return presetArchiveFailure("Use lowercase letters, digits, and hyphens, starting with a letter or digit."); ++ let rows; ++ try { ++ rows = await presets.list(); ++ } catch (error) { ++ return presetArchiveFailure(`Could not read the existing preset list: ${error instanceof Error ? error.message : String(error)}`, 500); ++ } ++ const conflict = rows.some((row) => row.id === agentPreset); ++ const warnings = [ ++ ...parsed.warnings, ++ ...typeof parsed.manifest.sourceDshVersion === "string" && parsed.manifest.sourceDshVersion !== "0.1.0-rc.6" ? ["version-mismatch"] : [] ++ ]; ++ const preview = { ++ ok: true, ++ agentPreset, ++ sourceAgentPreset: parsed.manifest.id, ++ ...typeof parsed.manifest.name === "string" ? { name: parsed.manifest.name } : {}, ++ ...typeof parsed.manifest.description === "string" ? { description: parsed.manifest.description } : {}, ++ ...typeof parsed.manifest.sourceDshVersion === "string" ? { sourceDshVersion: parsed.manifest.sourceDshVersion } : {}, ++ fileCount: Object.keys(parsed.files).length, ++ warnings, ++ conflict, ++ installed: false ++ }; ++ if (!options.install) return Response.json(preview, { headers: { "cache-control": "no-store" } }); ++ if (conflict) return presetArchiveFailure(`A preset named "${agentPreset}" already exists. Choose another identifier.`, 409); ++ let root; ++ try { ++ root = writableRoot(presets.roots); ++ } catch (error) { ++ return presetArchiveFailure(error instanceof Error ? error.message : String(error), 403); ++ } ++ const target = join(root, agentPreset); ++ let container; ++ try { ++ signal?.throwIfAborted(); ++ await mkdir(root, { recursive: true }); ++ try { ++ await stat(target); ++ return presetArchiveFailure(`A preset named "${agentPreset}" already exists. Choose another identifier.`, 409); ++ } catch (error) { ++ if (error?.code !== "ENOENT") throw error; ++ } ++ container = await mkdtemp(join(root, ".dshpreset-import-")); ++ const imported = join(container, agentPreset); ++ await mkdir(imported, { recursive: true }); ++ for (const [name, bytes] of Object.entries(parsed.files)) { ++ signal?.throwIfAborted(); ++ const destination = resolve(imported, name); ++ if (!destination.startsWith(`${resolve(imported)}${sep}`)) throw new Error(`Preset package contains an unsafe path: ${name}`); ++ await mkdir(dirname(destination), { recursive: true }); ++ await writeFile(destination, bytes); ++ } ++ const scanned = await scanRoot({ ++ path: container, ++ trust: "user" ++ }); ++ const candidate = scanned.find((row) => row.id === agentPreset); ++ if (candidate === void 0) throw new Error("The imported package did not produce a preset."); ++ if (candidate.broken !== void 0) throw new Error(`The imported preset failed validation: ${candidate.broken}`); ++ await rename(imported, target); ++ return Response.json({ ++ ...preview, ++ conflict: false, ++ installed: true ++ }, { headers: { "cache-control": "no-store" } }); ++ } catch (error) { ++ if (signal?.aborted) return presetArchiveFailure("Preset import was cancelled.", 499); ++ if (error?.code === "EEXIST" || error?.code === "ENOTEMPTY") return presetArchiveFailure(`A preset named "${agentPreset}" already exists. Choose another identifier.`, 409); ++ return presetArchiveFailure(error instanceof Error ? error.message : String(error)); ++ } finally { ++ if (container !== void 0) await rm(container, { ++ recursive: true, ++ force: true ++ }).catch(() => {}); ++ } ++ }, + async list(request) { + const presets = ctx.get("agentPresets"); + if (presets === void 0) return ok(request, { +@@ -4993,6 +5218,34 @@ function toFetchHandler(api) { + rpcId: RpcId(randomUUID()), + payload: {} + }, req.signal)); ++ if (path === "/api/agent-preset.export" && (req.method === "GET" || req.method === "HEAD")) { ++ const agentPreset = url.searchParams.get("agentPreset"); ++ if (agentPreset === null || !PRESET_ARCHIVE_ID.test(agentPreset)) return presetArchiveFailure("Missing or invalid agentPreset query parameter."); ++ const response = await api.agentPresets.exportArchive(agentPreset, req.signal); ++ if (req.method === "GET") return response; ++ await response.body?.cancel(); ++ return new Response(null, { ++ status: response.status, ++ headers: response.headers ++ }); ++ } ++ if (path === "/api/agent-preset.import" && req.method === "POST") { ++ const contentType = req.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase(); ++ if (contentType !== PRESET_ARCHIVE_MIME && contentType !== "application/zip" && contentType !== "application/octet-stream") return presetArchiveFailure("Content type must be a DSH preset package.", 415); ++ const contentLength = Number(req.headers.get("content-length")); ++ if (Number.isFinite(contentLength) && contentLength > PRESET_ARCHIVE_MAX_COMPRESSED) return presetArchiveFailure("Preset package is larger than 16 MB.", 413); ++ let data; ++ try { ++ data = new Uint8Array(await req.arrayBuffer()); ++ } catch { ++ return presetArchiveFailure("Could not read the preset package."); ++ } ++ if (data.length > PRESET_ARCHIVE_MAX_COMPRESSED) return presetArchiveFailure("Preset package is larger than 16 MB.", 413); ++ return api.agentPresets.importArchive(data, { ++ agentPreset: url.searchParams.get("agentPreset") ?? void 0, ++ install: url.searchParams.get("install") === "1" ++ }, req.signal); ++ } + if (path === "/api/session.export" && (req.method === "GET" || req.method === "HEAD")) { + const parsed = sessionLogQuerySchema.safeParse(Object.fromEntries(url.searchParams)); + if (!parsed.success) return new Response("missing or invalid sessionId query parameter", { status: 400 }); diff --git a/test/preset-transfer-patch.test.ts b/test/preset-transfer-patch.test.ts new file mode 100644 index 0000000..02bcb51 --- /dev/null +++ b/test/preset-transfer-patch.test.ts @@ -0,0 +1,104 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' + +const projectRoot = path.resolve(import.meta.dirname, '..') + +describe('agent preset package transfer', () => { + it('routes binary export and two-phase import requests outside the JSON RPC carrier', async () => { + const exportArchive = vi.fn(async () => + new Response(new Uint8Array([80, 75, 3, 4]), { + headers: { 'content-type': 'application/vnd.dsh.preset+zip' } + }) + ) + const importArchive = vi.fn(async () => Response.json({ ok: true })) + const handler = toFetchHandler({ + agentPresets: { exportArchive, importArchive } + } as never) + + const exported = await handler.fetch( + new Request('http://127.0.0.1/api/agent-preset.export?agentPreset=my-agent') + ) + expect(exported.status).toBe(200) + expect(exported.headers.get('content-type')).toBe('application/vnd.dsh.preset+zip') + expect(exportArchive).toHaveBeenCalledWith('my-agent', expect.any(AbortSignal)) + + const payload = new Uint8Array([80, 75, 3, 4]) + const previewed = await handler.fetch( + new Request('http://127.0.0.1/api/agent-preset.import', { + method: 'POST', + headers: { 'content-type': 'application/vnd.dsh.preset+zip' }, + body: payload + }) + ) + expect(previewed.status).toBe(200) + expect(importArchive).toHaveBeenLastCalledWith( + expect.any(Uint8Array), + { agentPreset: undefined, install: false }, + expect.any(AbortSignal) + ) + + await handler.fetch( + new Request( + 'http://127.0.0.1/api/agent-preset.import?agentPreset=renamed-agent&install=1', + { + method: 'POST', + headers: { 'content-type': 'application/zip' }, + body: payload + } + ) + ) + expect(importArchive).toHaveBeenLastCalledWith( + expect.any(Uint8Array), + { agentPreset: 'renamed-agent', install: true }, + expect.any(AbortSignal) + ) + }) + + it('keeps the archive boundary strict and installs through an atomic validated directory move', async () => { + const patch = await readFile( + path.join( + projectRoot, + 'patches', + '@deepseek-ai+dsh-host-apiproxy+0.1.0-rc.6.patch' + ), + 'utf8' + ) + + expect(patch).toContain('const PRESET_ARCHIVE_FORMAT = "dsh-preset"') + expect(patch).toContain('const PRESET_ARCHIVE_MAX_COMPRESSED = 16 * 1024 * 1024') + expect(patch).toContain('const PRESET_ARCHIVE_MAX_UNCOMPRESSED = 32 * 1024 * 1024') + expect(patch).toContain('safePresetArchivePath') + expect(patch).toContain('PRESET_ARCHIVE_IGNORED_FILES') + expect(patch).toContain('.DS_Store') + expect(patch).toContain('info.isSymbolicLink()') + expect(patch).toContain('scanRoot({') + expect(patch).toContain('await rename(imported, target)') + expect(patch).toContain('A preset named') + expect(patch).toContain('possible-secrets') + expect(patch).toContain('absolute-paths') + }) + + it('adds import preview, conflict rename, trust warning, and custom-card export controls', async () => { + const patch = await readFile( + path.join( + projectRoot, + 'patches', + '@deepseek-ai+dsh-client-ui-agent-preset+0.1.0-rc.6.patch' + ), + 'utf8' + ) + + expect(patch).toContain('ImportDialog') + expect(patch).toContain('previewImport(file)') + expect(patch).toContain('confirmImport()') + expect(patch).toContain('exportPreset(id)') + expect(patch).toContain('IconArchiveOutline20') + expect(patch).toContain('IconDownloadOutline16') + expect(patch).toContain('Custom presets can run tools and commands') + expect(patch).toContain('自定义预设可以使用与 Agent 相同权限的工具和命令') + expect(patch).toContain('draft.conflict ? "idTaken"') + expect(patch).toContain('.dshpreset') + }) +}) From 3c4d40ec34fa1f0d4bd01a700d6a04743fec2ca9 Mon Sep 17 00:00:00 2001 From: QinRui Date: Fri, 14 Aug 2026 20:45:12 +0800 Subject: [PATCH 2/3] Adjust preset import button alignment --- ...seek-ai+dsh-client-ui-agent-preset+0.1.0-rc.6.patch | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/patches/@deepseek-ai+dsh-client-ui-agent-preset+0.1.0-rc.6.patch b/patches/@deepseek-ai+dsh-client-ui-agent-preset+0.1.0-rc.6.patch index 74c9ed1..8f59cb3 100644 --- a/patches/@deepseek-ai+dsh-client-ui-agent-preset+0.1.0-rc.6.patch +++ b/patches/@deepseek-ai+dsh-client-ui-agent-preset+0.1.0-rc.6.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@deepseek-ai/dsh-client-ui-agent-preset/lib/client.js b/node_modules/@deepseek-ai/dsh-client-ui-agent-preset/lib/client.js -index 9f92ac8..0c78cb2 100644 +index 9f92ac8..321ea28 100644 --- a/node_modules/@deepseek-ai/dsh-client-ui-agent-preset/lib/client.js +++ b/node_modules/@deepseek-ai/dsh-client-ui-agent-preset/lib/client.js @@ -64,7 +64,25 @@ window.__ModuleLoader__.load({ @@ -223,7 +223,7 @@ index 9f92ac8..0c78cb2 100644 /** * Ask for confirmation before deleting one preset. * @param id - the preset to delete, or null to dismiss the confirmation. -@@ -974,7 +1156,7 @@ window.__ModuleLoader__.load({ +@@ -974,17 +1156,23 @@ window.__ModuleLoader__.load({ }; //#endregion //#region \0dsh-css:/home/runner/work/deepseek-harness/deepseek-harness/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css.mjs @@ -232,7 +232,11 @@ index 9f92ac8..0c78cb2 100644 const tagId = "@deepseek-ai/dsh-client-ui-agent-preset/AgentPresetSection.module.css"; if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) { const tag = document.createElement("style"); -@@ -985,6 +1167,12 @@ window.__ModuleLoader__.load({ + tag.dataset.plugin = "@deepseek-ai/dsh-client-ui-agent-preset"; + tag.dataset.pluginCss = tagId; +- tag.textContent = css; ++ tag.textContent = `${css}.rtSEdW_importButton{margin-right:12px}`; + document.head.appendChild(tag); } var AgentPresetSection_module_css_default = { "cardMain": "rtSEdW_cardMain", From a9902845719b87713eb23ee84df4c2f81074d9c2 Mon Sep 17 00:00:00 2001 From: QinRui Date: Fri, 14 Aug 2026 23:05:18 +0800 Subject: [PATCH 3/3] Document online Preset Square skill contract --- docs/preset-packages.md | 10 + docs/preset-square-mvp.md | 415 +++++++++++++++++++++++++++++ test/preset-transfer-patch.test.ts | 21 ++ 3 files changed, 446 insertions(+) create mode 100644 docs/preset-square-mvp.md diff --git a/docs/preset-packages.md b/docs/preset-packages.md index 4f54e13..cb721b5 100644 --- a/docs/preset-packages.md +++ b/docs/preset-packages.md @@ -31,3 +31,13 @@ Import is a two-step operation. DSH Desktop first validates and previews the arc The importer rejects absolute archive paths, parent traversal, backslash-based paths, missing compositions, unsupported manifests, oversized packages, and invalid preset compositions. Export rejects symbolic links and unsupported filesystem entries. Common OS metadata such as `.DS_Store`, `Thumbs.db`, and `desktop.ini` is omitted. Custom presets are executable configuration. Their compositions may load plugins and expose tools that run commands or access files with the Agent's permissions. Import packages only from trusted sources and review warnings about possible credentials, absolute paths, and DSH version differences. + +## Agent and online Skill contract + +DSH Desktop exposes package transfer through the same loopback Harness server used by the UI. Harness contributes its canonical loopback origin to shell tools as `DSH_WEB_URL`, so an explicitly requested online Skill can call the local transfer API without knowing the random port and without requiring the `dsh` CLI. + +- `GET $DSH_WEB_URL/api/agent-preset.export?agentPreset=` exports one custom Preset. +- `POST $DSH_WEB_URL/api/agent-preset.import` previews a binary package without writing it. +- `POST $DSH_WEB_URL/api/agent-preset.import?agentPreset=&install=1` performs the validated atomic install. + +The request body for import is the unchanged binary `.dshpreset` file with `Content-Type: application/vnd.dsh.preset+zip`. Online instructions must never paste or decode the archive into model context, directly unpack it into a Preset root, or silently overwrite an existing identifier. diff --git a/docs/preset-square-mvp.md b/docs/preset-square-mvp.md new file mode 100644 index 0000000..4ff6904 --- /dev/null +++ b/docs/preset-square-mvp.md @@ -0,0 +1,415 @@ +# DSH Preset 广场 MVP 技术方案 + +## 1. 目标与范围 + +MVP 只解决两个自然语言动作: + +1. 用户对 DSH 说“把 `tender-extract` 发布到广场,邮箱是 `name@example.com`”,DSH 将该 Preset 打包、上传,并返回公开详情页链接。 +2. 用户把广场详情页链接交给 DSH 并说“安装这个 Preset”,DSH 读取元数据、下载包、校验并导入本机。 + +广场网站只需要: + +- 首页默认按下载量倒序展示全部 Preset,并可切换为按上传时间倒序; +- Preset 详情页; +- 标题、描述、发布时间、发布者用户名和下载入口; +- 接收发布、返回元数据和下载文件的 API。 + +MVP 不做搜索、分页、账号系统、邮箱验证、评论、评分、更新、版本管理和在线编辑。由于没有身份验证,每次非重复发布都创建一个不可变的新条目,不允许发布者覆盖或删除旧条目;管理员保留隐藏恶意内容的能力。 + +## 2. 关键设计决定 + +### 2.1 复用现有 `.dshpreset` 格式 + +广场不定义第二种包格式。继续使用 DSH Desktop 已实现的 `.dshpreset` ZIP: + +```text +manifest.json +preset/ +├── agent.cordis.yml +└── ... +``` + +标题默认取 `manifest.name`,没有时取 `manifest.id`;描述默认取 `manifest.description`。用户在发布指令中给出的标题和描述可以覆盖广场展示文案,但不修改包内容。 + +发布者邮箱只存于广场数据库,不能写入 `.dshpreset`,避免下载者获得发布者的完整邮箱。公开用户名取邮箱 `@` 前面的部分,例如 `ray@example.com` 显示为 `ray`;不公开邮箱域名和验证状态。 + +### 2.2 在线 Skill,不做自然语言意图解析器 + +广场在官网以独立 Markdown 文件托管完整的 `preset-square` Skill,不把它随 DSH Desktop 安装,也不新增 Cordis Plugin 或模型 Tool。用户第一次接入时,将一段很短的引导语发送给 DSH,明确要求读取该 `SKILL.md`、暂不执行操作并等待下一条指令。DSH 通过已有的 HTTP 与 shell 能力读取正文;后续的查找、安装和发布都在同一个会话中遵循这份正文。 + +生产 Skill 地址为: + +```text +https://www.dshdesktop.com/preset/skills/preset-square/SKILL.md +``` + +本地开发可以使用显式的 loopback 地址,例如 `http://127.0.0.1:4310/skills/preset-square/SKILL.md`。在线 Skill 只在当前会话上下文中生效;新会话需要重新读取。这样官网可以独立修订广场 API 工作流,客户端不需要因为 Skill 文案变化而发版。 + +DSH Web 已向 shell 暴露 `$DSH_WEB_URL`,Skill 可以稳定访问当前随机端口上的本地服务。现有本地 API 继续负责 ZIP 打包、预览、路径校验和原子安装;Skill 不自行解压 `.dshpreset`,也不复制安全逻辑。 + +模型只负责遵循用户明确指定的在线 Skill、补齐缺失信息并向用户解释结果。网站首页提供“连接 DSH”的可复制引导语;读取完成后,用户再发送查找、安装或发布指令。网站不把整份 Skill 正文复制进剪贴板,也不假设客户端已经注册了名为 `preset-square` 的本地 Skill。 + +### 2.3 只支持广场自己的详情页链接 + +MVP 的 Skill 只接受配置中的广场域名及固定路径,例如: + +```text +https://square.dsh.example/p/tender-extract-a1b2c3 +``` + +Skill 从路径提取 slug,再请求广场 JSON API。暂不接受任意 `.zip` URL,避免 SSRF、内网探测、无限重定向和不可信下载源问题。 + +### 2.4 重名与重复内容规则 + +广场中正在展示的 Preset 标题必须全局唯一,避免后来上传的内容使用相同名字冒充原 Preset。`manifest.id` 仍不做全局唯一校验,因为它是安装到本机时使用的技术 ID,不适合作为公开品牌身份。 + +服务端为标题生成 `title_key`,并以它做唯一校验: + +1. Unicode NFC 规范化; +2. 去掉首尾空白; +3. 拒绝控制字符和用于隐藏文本的不可见格式字符; +4. 保留大小写、内部空格和标点符号的差异。 + +因此 `Research Agent`、`research-agent` 和 `RESEARCH_AGENT` 可以作为三个不同名称存在;只有规范化后完全相同的展示标题才视为重名。重名发布返回 `409 PRESET_NAME_TAKEN` 和已有详情页。数据库索引必须使用区分大小写的比较规则。 + +服务端还应维护可配置的保留名称表,包括官方品牌词、内置 Preset 名称,以及 `Official`、`官方`、`认证` 等容易造成官方身份误解的词。普通发布接口不能使用保留名称;官方内容由管理员通道发布。 + +这只能防止完全重名,不能阻止相似名称仿冒或“先抢注”。邮箱尚未验证期间,名称归属是临时的:页面只显示由邮箱前缀派生的用户名,不展示“邮箱未验证”标记;管理员可以根据申诉隐藏冒名条目并释放名称。未来启用邮箱验证后,再将 `title_key` 与已验证发布者 ownership 绑定。 + +真正需要阻止的是完全相同的 Preset 内容被反复发布: + +- `slug` 始终由服务端使用 `slugify(title) + short UUID` 生成并保持唯一; +- 服务端计算下载文件的 `artifact_sha256`,用于下载完整性校验; +- 服务端另行计算 `preset_content_sha256`:按路径排序后,对 `preset/**` 的路径、长度和文件内容做确定性摘要; +- `preset_content_sha256` 建唯一索引。这样即使 `manifest.exportedAt` 不同,同一份 Preset 内容仍会被识别为重复; +- 重复发布返回 `409 DUPLICATE_PRESET_CONTENT`,同时返回已有条目的 `detailUrl`,DSH 直接告诉用户“该内容已经在广场”,而不是再创建一张重复卡片。 + +本地安装仍按 Preset ID 做冲突校验:同名时要求选择新 ID,绝不覆盖已有目录。广场标题唯一性与本地安装 ID 冲突是两条独立规则。 + +## 3. 总体架构 + +```mermaid +flowchart LR + U["用户自然语言"] --> A["DSH Agent"] + A --> S["官网在线 preset-square SKILL.md"] + S --> SH["现有 bash / pwsh"] + SH --> LOCAL["$DSH_WEB_URL 本地 Preset API"] + SH --> API["广场 REST API"] + LOCAL --> L["本机 Preset 目录"] + API --> DB["元数据数据库"] + API --> OBJ["对象存储"] + API --> WEB["广场首页与详情页"] +``` + +本地设置 UI 和 Skill 都复用已经存在的 `/api/agent-preset.export`、`/api/agent-preset.import`。Skill 只做 HTTP 编排,不直接读写 Preset 目录。 + +## 4. DSH Desktop 侧 + +### 4.1 Skill 交付形态 + +`preset-square` 由广场网站以一个可直接读取的 UTF-8 Markdown 文件交付。DSH Desktop 不复制或缓存这份文件到 `$DSH_HOME/skills`,也不设置 `DSH_BUNDLED_SKILL_DIR`。用户明确提供 Skill URL 后,Agent 使用当前模式已有的 HTTP 或 shell 能力读取正文;只要求“读取”时必须回复已准备好并停止,不能提前浏览、下载、导入、导出或发布。 + +Skill 使用已有 `bash`(macOS)或 `pwsh`(Windows),不注册新的模型 Tool,也不要求 `dsh` 命令可执行。命令只负责参数转义、临时文件清理、HTTP 状态检查和 SHA-256 对比;不能自行解压或写入 Preset 目录。 + +当前官方 Standard、Code 和 Cordis/Creator Preset 已包含 Skill 能力。Minimal Preset 不加载 Skill 系统,因此 MVP 明确只支持具备 Skill 与 shell 能力的 Preset;不要为了广场改变 Minimal 的产品边界。 + +### 4.2 发布流程 + +1. 用户明确要求发布某个自定义 Preset;缺少邮箱时只追问邮箱。 +2. Skill 通过以下本地接口导出到系统临时目录: + + ```text + GET $DSH_WEB_URL/api/agent-preset.export?agentPreset= + ``` + +3. Skill 将刚导出的包提交给本地 import preview 接口,仅用于读取 `possible-secrets`、`absolute-paths` 和版本提示,不执行安装: + + ```text + POST $DSH_WEB_URL/api/agent-preset.import + Content-Type: application/vnd.dsh.preset+zip + ``` + +4. 若存在危险警告,停止并征求用户确认;没有警告且用户已经明确要求发布时,不重复确认。 +5. 使用 multipart/form-data 调用广场 `POST /api/v1/presets`,上传包、标题、描述和邮箱。 +6. 返回公开详情页链接并删除临时文件。重名或重复内容时返回广场已有详情页。 + +### 4.3 安装流程 + +1. Skill 校验详情页 URL 必须属于配置的广场域名和 `/p/:slug` 路径。 +2. 请求详情 API,获得同源下载地址、文件大小和 SHA-256。 +3. 下载到系统临时目录,限制响应大小并核对 SHA-256。 +4. 调用本地 preview 接口展示名称、文件数、版本差异、危险警告和 ID 冲突。 +5. 用户已经明确说“安装”时可以继续;只有危险警告、版本不兼容或 ID 冲突才中断询问。 +6. 调用以下接口完成原子安装,绝不直接解压: + + ```text + POST $DSH_WEB_URL/api/agent-preset.import?agentPreset=&install=1 + Content-Type: application/vnd.dsh.preset+zip + ``` + +7. 返回安装结果并清理临时文件。新 Preset 无需重启,对新会话立即可见。 + +只发链接或说“看看”时,Skill 只读取详情 API,不下载和安装。 + +### 4.4 网站复制文案 + +首页提供“连接 DSH”按钮。中文引导语为: + +```text +请读取并遵循这个 Preset Square Skill,暂时不要执行任何操作。读完后告诉我你已准备好,并等待我的下一条指令:https://www.dshdesktop.com/preset/skills/preset-square/SKILL.md +``` + +本地开发环境可把 URL 替换为: + +```text +http://127.0.0.1:4310/skills/preset-square/SKILL.md +``` + +DSH 回复已准备好后,用户再发送操作指令,例如: + +```text +安装这个 Preset: +https://www.dshdesktop.com/preset/p/tender-extract-a1b2c3 +``` + +页面只显示当前语言对应的一条引导语,不同时展示中英文。Skill 正文集中配置 `baseUrl`、`apiBaseUrl` 和允许域名;生产环境只允许官方 HTTPS 域名,loopback 仅用于用户明确给出的本地开发地址。 + +## 5. 广场服务侧 + +### 5.1 推荐最小技术形态 + +- 一个 Web 应用同时提供页面与 REST API; +- PostgreSQL 保存元数据; +- S3/R2 等对象存储保存 `.dshpreset`; +- 可用 Next.js/Node.js 实现,但 API 合同不依赖具体框架。 + +不建议把包放在 serverless 实例本地磁盘;实例更新或扩容后文件可能丢失。若只做单机内部原型,可暂用 SQLite + 本地磁盘,但 API 和数据模型保持一致。 + +### 5.2 数据表 + +```sql +presets ( + id uuid primary key, + slug varchar unique not null, + preset_id varchar not null, + title varchar(160) not null, + title_key varchar(200) not null, + description text not null default '', + publisher_email varchar(254) not null, + publisher_username varchar(128) not null, + publisher_email_verified boolean not null default false, + artifact_key varchar not null, + artifact_sha256 char(64) not null, + preset_content_sha256 char(64) unique not null, + artifact_size_bytes integer not null, + download_count bigint not null default 0, + manifest_json jsonb not null, + status varchar not null default 'published', + created_at timestamptz not null default now() +) +``` + +`status` 首版只需 `published | hidden`。`publisher_username` 在发布时从邮箱 `@` 前面的部分生成并保存,例如 `name@example.com` 生成 `name`。完整邮箱和 `publisher_email_verified` 仅供未来验证及管理员使用;公开 API 只返回用户名,不返回邮箱、邮箱域名或验证状态。用户名不是唯一身份,不需要建立唯一索引。 + +至少建立以下索引: + +```sql +unique (slug) +unique (preset_content_sha256) +unique (title_key) where status = 'published' +index (status, download_count desc, created_at desc) +index (status, created_at desc) +``` + +`title_key` 使用只约束 `published` 条目的部分唯一索引。管理员将冒名条目标记为 `hidden` 后,该名称即可重新发布,同时保留原记录供审计。 + +### 5.3 REST API + +#### 发布 + +```http +POST /api/v1/presets +Content-Type: multipart/form-data + +artifact= +title=Tender Extract +description=Extracts tender parameters and produces deliverables. +publisherEmail=name@example.com +``` + +成功返回 `201`: + +```json +{ + "id": "5e79...", + "slug": "tender-extract-a1b2c3", + "presetId": "tender-extract", + "title": "Tender Extract", + "description": "Extracts tender parameters and produces deliverables.", + "publisher": { + "username": "name" + }, + "artifact": { + "downloadUrl": "https://square.dsh.example/api/v1/presets/tender-extract-a1b2c3/download", + "sha256": "...", + "sizeBytes": 12345, + "formatVersion": 1, + "sourceDshVersion": "0.1.0-rc.6" + }, + "detailUrl": "https://square.dsh.example/p/tender-extract-a1b2c3", + "downloadCount": 0, + "createdAt": "2026-08-14T12:00:00.000Z" +} +``` + +服务端必须在写对象存储前完成:邮箱格式、标题/描述长度、标题规范化与保留名检查、`title_key` 唯一检查、压缩包大小、ZIP 路径、文件数量、解压大小、`manifest.json` 格式、`preset/agent.cordis.yml` 存在性和 `preset_content_sha256` 重复检查。服务端不能加载或执行 Preset。 + +重名返回 `409`: + +```json +{ + "error": { + "code": "PRESET_NAME_TAKEN", + "message": "A published preset already uses this name." + }, + "existing": { + "slug": "research-agent-a1b2c3", + "detailUrl": "https://square.dsh.example/p/research-agent-a1b2c3" + } +} +``` + +重复内容返回 `409`,响应中带已有条目,便于 DSH 直接复用链接: + +```json +{ + "error": { + "code": "DUPLICATE_PRESET_CONTENT", + "message": "The same preset content has already been published." + }, + "existing": { + "slug": "tender-extract-a1b2c3", + "detailUrl": "https://square.dsh.example/p/tender-extract-a1b2c3" + } +} +``` + +#### 列表 + +```http +GET /api/v1/presets?sort=downloads +GET /api/v1/presets?sort=newest +``` + +`sort` 只接受两个值: + +- `downloads`:默认值,按 `download_count desc, created_at desc` 排序; +- `newest`:按 `created_at desc` 排序。 + +直接返回全部 `status=published` 条目。MVP 仍不接收分页与搜索参数。若数量开始影响响应,再新增分页,不提前设计前端假分页。未知 `sort` 值返回 `400 INVALID_SORT`,避免客户端和服务端悄悄采用不同排序。 + +#### 详情 + +```http +GET /api/v1/presets/:slug +``` + +返回与发布响应相同的公开元数据,不包含完整邮箱和内部 `artifact_key`。 + +#### 下载 + +```http +GET /api/v1/presets/:slug/download +``` + +返回 `application/vnd.dsh.preset+zip`,并设置文件名、`Content-Length`、`ETag` 或 SHA-256 响应头。下载 URL 必须与详情页同源或属于固定对象存储域名白名单。 + +下载计数规则: + +- 只统计 `GET`,不统计 `HEAD` 和元数据查询; +- 服务端确认条目可下载、准备返回文件或重定向到对象存储时,以数据库原子更新执行 `download_count = download_count + 1`; +- 下载接口继续做 IP 级限流,降低脚本刷榜风险; +- 首版的下载量是近似热度,不宣称为独立用户数。需要更强防刷时,再增加匿名访客日去重事件表或边缘 KV,不阻塞 MVP。 + +统一错误格式: + +```json +{ + "error": { + "code": "INVALID_PACKAGE", + "message": "Preset package is missing preset/agent.cordis.yml" + } +} +``` + +至少支持:`INVALID_EMAIL`、`INVALID_PACKAGE`、`PACKAGE_TOO_LARGE`、`UNSUPPORTED_FORMAT`、`PRESET_NAME_TAKEN`、`RESERVED_PRESET_NAME`、`DUPLICATE_PRESET_CONTENT`、`INVALID_SORT`、`RATE_LIMITED`、`NOT_FOUND`。 + +### 5.4 页面 + +首页顶部提供两个安静的排序切换项:“最多下载”和“最新上传”,默认选中“最多下载”。每张卡片只显示:标题、两三行描述、Preset ID、发布者用户名、下载量、发布时间和“查看”按钮。页面不显示“邮箱未验证”标记。 + +详情页显示完整描述、包大小、源 DSH 版本、SHA-256、发布时间、安全提示、下载按钮和一段可复制文案: + +```text +把这个链接发给 DSH,并说“安装这个 Preset”。 +``` + +页面必须明确提示:Preset 是可执行配置,可能加载工具、运行命令和访问文件,只安装可信来源内容。 + +## 6. 最低安全与运营边界 + +即使不做账号和邮箱验证,也不能省略以下能力: + +- 发布接口按 IP 做粗粒度限流,例如每小时 10 次; +- 标题、描述和发布者用户名按纯文本输出,防止 XSS; +- 原始邮箱永不出现在公开 HTML、公开 API、日志和下载包中; +- 对象下载不可产生任意文件读取; +- 广场服务只检查包,不执行 Preset; +- DSH 安装端验证域名、大小、重定向、SHA-256 和 ZIP 路径; +- 提供管理员 Token 保护的隐藏接口或数据库操作手册,以便下架恶意内容; +- 日志记录发布 ID、摘要、状态和错误,不记录完整包内容或完整邮箱。 + +后续启用邮箱验证时,直接在现有记录上增加验证 token、过期时间和 ownership,不改变公开安装链接。 + +## 7. 开发顺序 + +### 阶段 A:先冻结合同 + +1. 确认广场域名和 API Base URL。 +2. 固化上面的请求/响应 JSON Schema 或 OpenAPI。 +3. 准备一个合法包、一个路径穿越包、一个超限包和一个含敏感信息警告的测试包。 + +### 阶段 B:网站服务 + +1. 数据表和对象存储。 +2. 发布、列表、详情、下载 API。 +3. 首页和详情页。 +4. 限流、用户名派生、邮箱隐私和管理员隐藏能力。 + +网站做到这一阶段后,可以先用 `curl` 和固定测试包验收,不依赖 DSH Desktop 完成。 + +### 阶段 C:DSH Desktop + +1. 保持现有 Preset 导出、预览和安装 API 的回归测试通过。 +2. 编写 `preset-square/SKILL.md` 与 macOS/Windows 辅助脚本。 +3. 将 Skill 随 DSH Desktop 安装到全局 Skill 目录。 +4. 验证 Standard/Code/Creator、macOS arm64/x64 和 Windows x64;Minimal 明确显示不支持该流程。 + +## 8. MVP 验收标准 + +- 用户一句明确的发布指令且包含邮箱时,DSH 可直接返回公开详情页链接。 +- 缺少邮箱时,DSH 只追问邮箱;标题和描述可从 manifest 自动补默认值。 +- 新条目立即出现在首页,以邮箱前缀显示发布者用户名;公开页面不泄露完整邮箱、邮箱域名和验证状态。 +- NFC 规范化并去除首尾空白后的标题全局唯一;大小写、内部空格和常见分隔符不同的名称允许共存。 +- 官方品牌词、内置 Preset 名称和容易造成认证误解的名称不能通过普通发布接口使用。 +- 完全相同的 `preset/**` 内容不能重复创建条目,并返回已有详情页链接。 +- 首页默认按下载量排序,可切换为按上传时间排序;下载量相同时较新的条目在前。 +- 把详情页链接交给 DSH 并明确说安装后,Preset 能落入本机自定义 Preset 列表。 +- 下载内容与 API 中 SHA-256 一致;不一致时拒绝安装。 +- 本地安装的 Preset ID 冲突不覆盖;危险警告出现时不自动继续。 +- 非广场域名、畸形 ZIP、路径穿越、超限包和缺少 `agent.cordis.yml` 的包均被拒绝。 +- macOS arm64、macOS x64 和 Windows x64 的发布与安装路径一致。 + +## 9. 明确延期 + +搜索、分页、账号、邮箱验证、发布者主页、编辑/删除、版本更新、自动升级、评分、评论、收藏、举报 UI、签名证书和个性化推荐排序都不进入首版。首版仅提供下载量与上传时间两种确定性排序,并先验证一件事:用户能否通过一句话把自己的 Preset 分享出去,并让另一个人通过一句话安装成功。 diff --git a/test/preset-transfer-patch.test.ts b/test/preset-transfer-patch.test.ts index 02bcb51..073e847 100644 --- a/test/preset-transfer-patch.test.ts +++ b/test/preset-transfer-patch.test.ts @@ -101,4 +101,25 @@ describe('agent preset package transfer', () => { expect(patch).toContain('draft.conflict ? "idTaken"') expect(patch).toContain('.dshpreset') }) + + it('keeps the loopback API discoverable by an explicitly requested online Skill', async () => { + const webApp = await readFile( + path.join(projectRoot, 'node_modules', '@deepseek-ai', 'dsh-web-app', 'lib', 'index.js'), + 'utf8' + ) + const hostPatch = await readFile( + path.join( + projectRoot, + 'patches', + '@deepseek-ai+dsh-host-apiproxy+0.1.0-rc.6.patch' + ), + 'utf8' + ) + + expect(webApp).toContain('const DSH_WEB_URL = "DSH_WEB_URL"') + expect(webApp).toContain('variables: { [DSH_WEB_URL]') + expect(hostPatch).toContain('path === "/api/agent-preset.export"') + expect(hostPatch).toContain('path === "/api/agent-preset.import"') + expect(hostPatch).toContain('url.searchParams.get("install") === "1"') + }) })