dsh-crate-web 0.1.2-preview.3 → 0.1.2-preview.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -3
- package/core/dsh_pack/_version.py +1 -1
- package/core/dsh_pack/cli.py +2 -2
- package/core/dsh_pack/desktop_verify.py +6 -0
- package/core/dsh_pack/errors.py +14 -0
- package/core/dsh_pack/export.py +58 -15
- package/lib/index.js +6 -29
- package/package.json +7 -2
- package/packaging/windows-terminal/README.md +43 -0
- package/packaging/windows-terminal/build.py +258 -0
- package/packaging/windows-terminal/dsh__node_modules__@deepseek-ai__dsh-sandbox-local__lib__index.js.patch +21 -0
- package/packaging/windows-terminal/dsh__node_modules__@deepseek-ai__dsh-subprocess-local__lib__index.js.patch +51 -0
- package/packaging/windows-terminal/dsh__node_modules__@deepseek-ai__dsh-terminal-bash__lib__index.js.patch +38 -0
- package/packaging/windows-terminal/dsh__node_modules__node-pty__lib__windowsPtyAgent.js.patch +31 -0
- package/packaging/windows-terminal/dsh__node_modules__node-pty__lib__windowsTerminal.js.patch +107 -0
- package/packaging/windows-terminal/manifest.json +577 -0
- package/runtime-inventory.mjs +31 -0
- package/skills/dsh-crate-troubleshooting/compatibility.json +1 -1
- package/skills/dsh-crate-troubleshooting/reference/diagnostics.generated.md +1 -1
- package/verify-desktop.mjs +6 -2
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# DSH Crate — Preview
|
|
2
2
|
|
|
3
|
-
DSH Crate 为 DeepSeek Harness 的设置页提供环境包导出、检查、恢复、验证和历史记录。正式 npm 包名是 `dsh-crate-web`;本候选为 `0.1.2-preview.
|
|
3
|
+
DSH Crate 为 DeepSeek Harness 的设置页提供环境包导出、检查、恢复、验证和历史记录。正式 npm 包名是 `dsh-crate-web`;本候选为 `0.1.2-preview.6`,使用 `preview` 标签,保留既有 `latest`。
|
|
4
4
|
|
|
5
5
|
可通过官方插件入口安装明确版本:
|
|
6
6
|
|
|
7
7
|
```powershell
|
|
8
|
-
dsh plugin --profile web add dsh-crate-web@0.1.2-preview.
|
|
8
|
+
dsh plugin --profile web add dsh-crate-web@0.1.2-preview.6
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
重启后打开 Settings → DSH Crate。离线环境可从同版本 tgz 安装。Windows x64 随包保留 Python 3.14.6,无需另外安装系统 Python。其他系统未完成本轮桌面验收。
|
|
@@ -14,7 +14,16 @@ Import 默认恢复到新 Profile,先执行 Preflight 和计划检查。覆盖
|
|
|
14
14
|
|
|
15
15
|
默认 Verify 使用独立验证桌面并隔离 Home、工作区与缓存,不调用模型。模型、原生工具和各插件的真实业务验收必须有单独的执行证据;报告中的 UNTESTED 不能视为 PASS。
|
|
16
16
|
|
|
17
|
-
此 npm
|
|
17
|
+
此 npm 包包含 Crate 插件、Core、必要运行文件,以及供用户显式调用的 Windows 终端独立副本构建入口,不包含完整 DSH 桌面。安装插件不会向官方安装目录写入 Windows 终端或 NARB 补丁。历史业务证据来自配套的 Windows x64 / DSH 0.2.1-rc.5 独立修补桌面;尚未完成当前完整组合的模型及插件业务复测,不能宣称任意官方桌面均已兼容。
|
|
18
|
+
|
|
19
|
+
`0.1.2-preview.6` 新增随包的 [Windows 终端宿主构建入口](./packaging/windows-terminal/README.md)。它针对 manifest 锁定的官方输入,使用随包控制台 Node 修复 runner 空 stdin/退出 127,并补齐退出码和终端生命周期;匹配的独立副本已通过恢复后、重启后、移除 Crate 后的真实终端及正常清理验证。这些宿主证据来自升版前同字节补丁,不等于新 npm 版本完成了模型业务验收。必须由用户显式运行脚本,目标为不存在的新目录;原安装保持不变:
|
|
20
|
+
|
|
21
|
+
```powershell
|
|
22
|
+
# <包目录> 是已安装 dsh-crate-web 的目录;Windows x64 可使用包内 Python。
|
|
23
|
+
& "<包目录>\runtime\python\python.exe" "<包目录>\packaging\windows-terminal\build.py" --source "<匹配的官方桌面目录>" --destination "<不存在的新副本目录>"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
输入 EXE、ASAR 和控制台 Node 哈希不匹配时会停止;构建不关闭完整性检查,但新副本 EXE 的原代码签名不再有效。该入口不包含 Inspector/NARB 补丁,也不会自动启动新副本或调用模型。
|
|
18
27
|
|
|
19
28
|
已知限制:Windows 上 Import 偶发在临时 Profile 改为正式目录名时返回 WinError 5。界面会提示文件可能仍被占用或权限不足,并保留完整原始错误与回滚结果。请关闭正在使用目标目录的文件预览、编辑器或终端,核实权限与残留状态后手动重新导入;程序不会自动重试。历史故障的实际占用者尚未定位,当前提示不代表根因已修复。
|
|
20
29
|
|
|
@@ -23,3 +32,5 @@ Import 默认恢复到新 Profile,先执行 Preflight 和计划检查。覆盖
|
|
|
23
32
|
工作流先扫描提交与实际 tgz,再让发布任务复用同一 tgz 并核对 SHA-256。新代码必须升 prerelease 版本;同版本不同内容会停止。现有 npm Trusted Publisher 对应本仓库和 `publish-dsh-crate-web.yml`,无需把长期 npm Token 写入仓库。
|
|
24
33
|
|
|
25
34
|
宿主依赖:DSH 模块与 React 由目标 DSH 宿主提供,npm 将这些 peer 标记为 optional,避免在空 Profile 中自动安装另一套旧 DSH。这不表示插件可以脱离 DSH 运行,也不跳过实际宿主服务检查;客户端 UI 组件仍须由 DSH 模块加载器提供。发布流程对最终 tgz 执行普通干净安装,不使用 legacy-peer-deps 或 force。官方 CLI 与真实桌面的覆盖以对应版本的验证报告为准。
|
|
35
|
+
|
|
36
|
+
本候选修复了两个实际入口问题:无系统 npm 时,Export 可以复用桌面提供的 pnpm,禁用打包脚本与钩子并保留源清单;默认 Verify 根据完整的运行插件清单判断 Inspector 端点是否适用,清单不完整或已启用的 Inspector 缺监听时仍失败。Windows x64 / 原装 DSH 0.2.1-rc.5 下,前一候选的页面导出、下载、检查和新 Profile 恢复真实通过;本候选使用同一配置、仅更新 Crate 制品的诊断包,真实通过恢复、默认 Verify 的组合、启动、页面就绪、空白会话动作和清理。会话动作遵循官方行为,复用了已有空白会话,没有制造新的会话文件。默认 Verify 总结果仍为 UNTESTED:零模型,没有覆盖模型、原生工具、计划重启和其后的插件测试;本轮也没有覆盖启用 Inspector 的真实运行或历史 Worker 取消故障。诊断包不是本候选重新完成的页面 Export,不能将两轮证据合称为完整候选验收。
|
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.1.2-preview.
|
|
1
|
+
__version__ = "0.1.2-preview.6"
|
package/core/dsh_pack/cli.py
CHANGED
|
@@ -8,7 +8,7 @@ import os
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import Sequence
|
|
10
10
|
|
|
11
|
-
from .errors import DshPackError, PackImportError
|
|
11
|
+
from .errors import DshPackError, ExportError, PackImportError
|
|
12
12
|
from .export import EMBEDDED, REFERENCE_ONLY, ExportOptions, PluginExportOptions, export_profile
|
|
13
13
|
from .importer import ImportOptions, create_import_plan, delete_profile, import_pack
|
|
14
14
|
from .preflight import PreflightContext, inspect_pack, render_text
|
|
@@ -250,7 +250,7 @@ def _run_export(args: argparse.Namespace) -> int:
|
|
|
250
250
|
return 0
|
|
251
251
|
result = export_profile(args.profile, args.output, options=options)
|
|
252
252
|
except (DshPackError, OSError, ValueError, json.JSONDecodeError) as error:
|
|
253
|
-
payload = {"status": "failed", "error": {"message": str(error)}}
|
|
253
|
+
payload = {"status": "failed", "error": error.as_dict() if isinstance(error, ExportError) else {"message": str(error)}}
|
|
254
254
|
if args.json:
|
|
255
255
|
_emit(payload, args.command)
|
|
256
256
|
else:
|
|
@@ -154,6 +154,9 @@ class DesktopVerifyAdapter:
|
|
|
154
154
|
|
|
155
155
|
def _surface(self):
|
|
156
156
|
value = self._rpc('surface')
|
|
157
|
+
inspector = value.get('inspector')
|
|
158
|
+
if not isinstance(inspector, dict) or inspector.get('inventoryComplete') is not True or type(inspector.get('applicable')) is not bool:
|
|
159
|
+
raise RuntimeError('Inspector applicability not confirmed: runtime inventory missing or incomplete')
|
|
157
160
|
self.endpoints[f'web-{self.generation}'] = value['webPort']
|
|
158
161
|
table = tcp_rows()
|
|
159
162
|
with self.lock:
|
|
@@ -164,6 +167,9 @@ class DesktopVerifyAdapter:
|
|
|
164
167
|
for name, port in self.endpoints.items():
|
|
165
168
|
if not name.endswith('-' + str(self.generation)):
|
|
166
169
|
continue
|
|
170
|
+
if name.startswith('inspector-') and not inspector['applicable']:
|
|
171
|
+
evidence.append({'name': name, 'port': port, 'status': 'NOT_APPLICABLE', 'applicability': inspector})
|
|
172
|
+
continue
|
|
167
173
|
listeners = [row for row in table if row['localPort'] == port and row['state'] == 'LISTEN']
|
|
168
174
|
if not listeners or not all(row['pid'] in pids for row in listeners):
|
|
169
175
|
raise RuntimeError('Owned endpoint listener not confirmed: ' + name)
|
package/core/dsh_pack/errors.py
CHANGED
|
@@ -28,6 +28,20 @@ class IntegrityError(DshPackError):
|
|
|
28
28
|
class ExportError(DshPackError):
|
|
29
29
|
"""A Profile cannot be exported into a valid DSH Pack."""
|
|
30
30
|
|
|
31
|
+
def __init__(self, message: str, *, stage: str | None = None, code: str | None = None, details: dict | None = None) -> None:
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.stage, self.code, self.details = stage, code, details
|
|
34
|
+
|
|
35
|
+
def as_dict(self) -> dict:
|
|
36
|
+
result = {"message": str(self)}
|
|
37
|
+
if self.stage is not None:
|
|
38
|
+
result["stage"] = self.stage
|
|
39
|
+
if self.code is not None:
|
|
40
|
+
result["code"] = self.code
|
|
41
|
+
if self.details is not None:
|
|
42
|
+
result["details"] = self.details
|
|
43
|
+
return result
|
|
44
|
+
|
|
31
45
|
|
|
32
46
|
class PackImportError(DshPackError):
|
|
33
47
|
"""A Pack cannot be prepared as a new Profile."""
|
package/core/dsh_pack/export.py
CHANGED
|
@@ -492,12 +492,22 @@ def _with_bundle_composition(profile_files: dict[str, bytes], bundles: Sequence[
|
|
|
492
492
|
profile_files["package.json"] = (json.dumps(manifest, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
|
493
493
|
|
|
494
494
|
|
|
495
|
-
def _npm_command(configured: str | None) -> str:
|
|
495
|
+
def _npm_command(configured: str | None) -> str | tuple[str, ...]:
|
|
496
496
|
if configured:
|
|
497
497
|
return configured
|
|
498
|
-
if os.name == "nt"
|
|
499
|
-
|
|
500
|
-
|
|
498
|
+
available = (shutil.which("npm.cmd") if os.name == "nt" else None) or shutil.which("npm")
|
|
499
|
+
if available:
|
|
500
|
+
return available
|
|
501
|
+
value = os.environ.get("DSH_PACK_PNPM_COMMAND")
|
|
502
|
+
if value:
|
|
503
|
+
try:
|
|
504
|
+
argv = json.loads(value)
|
|
505
|
+
except ValueError:
|
|
506
|
+
argv = None
|
|
507
|
+
if not isinstance(argv, list) or not argv or not all(isinstance(item, str) and item and "\x00" not in item for item in argv):
|
|
508
|
+
raise ExportError("DSH_PACK_PNPM_COMMAND must be a nonempty JSON argv array", stage="package-artifact", code="EXPORT_PACK_COMMAND_INVALID")
|
|
509
|
+
return tuple(argv)
|
|
510
|
+
return "npm.cmd" if os.name == "nt" else "npm"
|
|
501
511
|
|
|
502
512
|
|
|
503
513
|
def _bundle_patch_path(package_dir: Path, package_name: str, patch: str) -> str:
|
|
@@ -613,13 +623,14 @@ def _validate_tarball(content: bytes, package_name: str, required_file: str | Se
|
|
|
613
623
|
def _npm_pack(
|
|
614
624
|
package_dir: Path,
|
|
615
625
|
package_name: str,
|
|
616
|
-
npm_command: str,
|
|
626
|
+
npm_command: str | tuple[str, ...],
|
|
617
627
|
*,
|
|
618
628
|
required_file: str | Sequence[str] | None = None,
|
|
619
629
|
) -> bytes:
|
|
620
630
|
with tempfile.TemporaryDirectory(prefix="dsh-pack-npm-") as directory:
|
|
621
631
|
cache_directory = Path(directory) / "npm-cache"
|
|
622
632
|
cache_directory.mkdir()
|
|
633
|
+
is_pnpm = isinstance(npm_command, tuple)
|
|
623
634
|
command = [
|
|
624
635
|
npm_command,
|
|
625
636
|
"pack",
|
|
@@ -630,24 +641,56 @@ def _npm_pack(
|
|
|
630
641
|
"--cache",
|
|
631
642
|
str(cache_directory),
|
|
632
643
|
]
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
644
|
+
environment = None
|
|
645
|
+
if is_pnpm:
|
|
646
|
+
command = [*npm_command, "pack", "--pack-destination", directory,
|
|
647
|
+
"--skip-manifest-obfuscation", "--config.ignore-scripts=true",
|
|
648
|
+
"--config.ignore-pnpmfile=true", "--config.offline=true",
|
|
649
|
+
"--config.pm-on-fail=ignore", "--config.runtime-on-fail=ignore"]
|
|
650
|
+
environment = dict(os.environ, ELECTRON_RUN_AS_NODE="1")
|
|
651
|
+
try:
|
|
652
|
+
completed = subprocess.run(
|
|
653
|
+
command, cwd=package_dir, capture_output=True, text=True,
|
|
654
|
+
check=False, timeout=120, **({"env": environment} if environment is not None else {}),
|
|
655
|
+
)
|
|
656
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
657
|
+
raise ExportError(
|
|
658
|
+
f"Cannot pack {package_name}: {error}. Install npm or use the desktop-provided pnpm entry.",
|
|
659
|
+
stage="package-artifact", code="EXPORT_PACK_START_FAILED",
|
|
660
|
+
details={"package": package_name, "command": command, "cwd": str(package_dir), "originalError": str(error)},
|
|
661
|
+
) from error
|
|
641
662
|
if completed.returncode != 0:
|
|
642
663
|
raise ExportError(
|
|
643
664
|
f"npm pack failed for {package_name} with exit code {completed.returncode}: "
|
|
644
|
-
f"{completed.stderr.strip() or completed.stdout.strip()}"
|
|
665
|
+
f"{completed.stderr.strip() or completed.stdout.strip()}",
|
|
666
|
+
stage="package-artifact", code="EXPORT_PACK_FAILED",
|
|
667
|
+
details={"package": package_name, "command": command, "cwd": str(package_dir),
|
|
668
|
+
"exitCode": completed.returncode, "stdout": completed.stdout, "stderr": completed.stderr},
|
|
645
669
|
)
|
|
646
670
|
tarballs = sorted(Path(directory).glob("*.tgz"))
|
|
647
671
|
if len(tarballs) != 1:
|
|
648
672
|
raise ExportError(f"npm pack produced {len(tarballs)} tarballs for {package_name}")
|
|
649
673
|
content = tarballs[0].read_bytes()
|
|
650
674
|
_validate_tarball(content, package_name, required_file)
|
|
675
|
+
if is_pnpm:
|
|
676
|
+
# pnpm strips fields even with --skip-manifest-obfuscation. Preserve the
|
|
677
|
+
# installed manifest exactly; the artifact is a snapshot, not a publish.
|
|
678
|
+
import io
|
|
679
|
+
manifest = (package_dir / "package.json").read_bytes()
|
|
680
|
+
output = io.BytesIO()
|
|
681
|
+
with tarfile.open(fileobj=io.BytesIO(content), mode="r:gz") as source:
|
|
682
|
+
manifests = [member for member in source.getmembers() if member.name == "package/package.json"]
|
|
683
|
+
if len(manifests) != 1 or not manifests[0].isfile():
|
|
684
|
+
raise ExportError(f"pnpm pack artifact has no unique regular package manifest: {package_name}")
|
|
685
|
+
with tarfile.open(fileobj=output, mode="w:gz") as target:
|
|
686
|
+
for member in source.getmembers():
|
|
687
|
+
if member.name == "package/package.json":
|
|
688
|
+
member.size = len(manifest)
|
|
689
|
+
target.addfile(member, io.BytesIO(manifest))
|
|
690
|
+
else:
|
|
691
|
+
target.addfile(member, source.extractfile(member) if member.isfile() else None)
|
|
692
|
+
content = output.getvalue()
|
|
693
|
+
_validate_tarball(content, package_name, required_file)
|
|
651
694
|
return content
|
|
652
695
|
|
|
653
696
|
|
|
@@ -663,7 +706,7 @@ def _plugin_lock_entry(
|
|
|
663
706
|
specifier: str,
|
|
664
707
|
bundle_order: int | None,
|
|
665
708
|
options: PluginExportOptions,
|
|
666
|
-
npm_command: str,
|
|
709
|
+
npm_command: str | tuple[str, ...],
|
|
667
710
|
) -> tuple[dict[str, Any], dict[str, bytes]]:
|
|
668
711
|
package_dir, package_manifest = _installed_package(profile_dir, package_name, bundle=bundle_order is not None)
|
|
669
712
|
installed_name = _string(package_manifest.get("name", package_name), f"{package_name}.package.json.name")
|
package/lib/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readRuntimeInventory } from "../runtime-inventory.mjs";
|
|
1
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
3
|
import { access, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
4
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
@@ -951,33 +952,7 @@ const SAFE_PLUGIN_NAME = /^@?[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)?$/;
|
|
|
951
952
|
const SAFE_OPERATION_ID = /^[0-9a-f-]{36}$/i;
|
|
952
953
|
const CRATE_PACKAGE_NAME = "dsh-crate-web";
|
|
953
954
|
/** Cordis FiberState enum values; mirrors dsh-host-plugin-inventory phases. */
|
|
954
|
-
|
|
955
|
-
0: "pending",
|
|
956
|
-
1: "loading",
|
|
957
|
-
2: "active",
|
|
958
|
-
3: "failed",
|
|
959
|
-
4: null,
|
|
960
|
-
5: "unloading"
|
|
961
|
-
};
|
|
962
|
-
/** Read-only projection of the currently running Loader's non-group entries. */
|
|
963
|
-
function listRuntimePlugins(ctx) {
|
|
964
|
-
const loader = ctx.loader;
|
|
965
|
-
if (!loader || typeof loader.entries !== "function") return [];
|
|
966
|
-
const rows = [];
|
|
967
|
-
try {
|
|
968
|
-
for (const entry of loader.entries()) {
|
|
969
|
-
if (entry.options.group) continue;
|
|
970
|
-
if (entry.options.name.startsWith("cordis:")) continue;
|
|
971
|
-
rows.push({
|
|
972
|
-
id: entry.id,
|
|
973
|
-
name: entry.options.name,
|
|
974
|
-
enabled: entry.disabled !== true,
|
|
975
|
-
phase: entry.fiber ? RUNTIME_FIBER_PHASE[entry.fiber.state] ?? null : null
|
|
976
|
-
});
|
|
977
|
-
}
|
|
978
|
-
} catch {}
|
|
979
|
-
return rows;
|
|
980
|
-
}
|
|
955
|
+
|
|
981
956
|
function json(res, status, body) {
|
|
982
957
|
const payload = JSON.stringify(body);
|
|
983
958
|
res.writeHead(status, {
|
|
@@ -1594,7 +1569,7 @@ async function createProfile(home, body, workspace) {
|
|
|
1594
1569
|
history: item
|
|
1595
1570
|
};
|
|
1596
1571
|
}
|
|
1597
|
-
async function handleApi(home, body, runtimePlugins = []) {
|
|
1572
|
+
async function handleApi(home, body, runtimePlugins = [], runtimeInventoryComplete = false) {
|
|
1598
1573
|
const action = body.action;
|
|
1599
1574
|
const workspace = await ensureWorkspace(home);
|
|
1600
1575
|
if (action === "profiles") {
|
|
@@ -1608,6 +1583,7 @@ async function handleApi(home, body, runtimePlugins = []) {
|
|
|
1608
1583
|
status: "ok",
|
|
1609
1584
|
profiles: await listProfiles(home),
|
|
1610
1585
|
runtimePlugins: classifiedPlugins,
|
|
1586
|
+
runtimeInventoryComplete,
|
|
1611
1587
|
runtime
|
|
1612
1588
|
};
|
|
1613
1589
|
}
|
|
@@ -2137,7 +2113,8 @@ function apply(ctx, config = {}) {
|
|
|
2137
2113
|
return;
|
|
2138
2114
|
}
|
|
2139
2115
|
try {
|
|
2140
|
-
|
|
2116
|
+
const inventory = readRuntimeInventory(ctx);
|
|
2117
|
+
json(res, 200, await handleApi(home, await readRequest(req), inventory.rows, inventory.complete));
|
|
2141
2118
|
} catch (error) {
|
|
2142
2119
|
json(res, 400, {
|
|
2143
2120
|
status: "failed",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-crate-web",
|
|
3
3
|
"description": "DSH Web Settings surface for DSH Crate export, import, inspect, and history",
|
|
4
|
-
"version": "0.1.2-preview.
|
|
4
|
+
"version": "0.1.2-preview.6",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
|
@@ -29,7 +29,12 @@
|
|
|
29
29
|
"skills/dsh-crate-troubleshooting/SKILL.md",
|
|
30
30
|
"skills/dsh-crate-troubleshooting/compatibility.json",
|
|
31
31
|
"skills/dsh-crate-troubleshooting/reference/*.md",
|
|
32
|
-
"THIRD-PARTY-LICENSE.txt"
|
|
32
|
+
"THIRD-PARTY-LICENSE.txt",
|
|
33
|
+
"runtime-inventory.mjs",
|
|
34
|
+
"packaging/windows-terminal/build.py",
|
|
35
|
+
"packaging/windows-terminal/manifest.json",
|
|
36
|
+
"packaging/windows-terminal/README.md",
|
|
37
|
+
"packaging/windows-terminal/*.patch"
|
|
33
38
|
],
|
|
34
39
|
"publishConfig": {
|
|
35
40
|
"access": "public",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Windows 终端宿主修复
|
|
2
|
+
|
|
3
|
+
该目录保存 DSH 0.2.1-rc.5 / Windows x64 的精确输入补丁和独立副本构建入口。它属于桌面宿主修复,**不随 npm 插件静默应用**,不修改用户原安装、Home、PowerShell Profile 或注册表。
|
|
4
|
+
|
|
5
|
+
已确认的原始故障是:打包 GUI EXE 作为 ConPTY 的 ACL runner 宿主时,runner 内 `GetStdHandle(-10)` 返回 NULL,受限 PowerShell 创建前即退出 127。管道关闭通知先于原生退出回调,旧 node-pty 又把退出码传播成 undefined,掩盖了原始错误。
|
|
6
|
+
|
|
7
|
+
补丁让独立桌面使用其随附的控制台 Node 运行原字节 ACL runner,保留原 sandbox 策略及显式 runner 覆盖优先级;同时修复退出事件、有效 PID 就绪、连接取消及 Windows PTY 收尾,并保留已采集的启动错误详情。不删除 required 插件,不放宽 ACL。
|
|
8
|
+
|
|
9
|
+
## 构建新副本
|
|
10
|
+
|
|
11
|
+
需要 Python 3.12 或更新版本,仅使用标准库。提供合法取得、与 manifest 中 EXE / ASAR / 控制台 Node 哈希完全一致的桌面目录;版本名相同不足以匹配。此仓库不分发官方桌面二进制或完整官方模块。
|
|
12
|
+
|
|
13
|
+
```powershell
|
|
14
|
+
python packaging/windows-terminal/build.py --source "<官方桌面目录>" --destination "<不存在的新目录>"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
输入和输出不能嵌套,拒绝已有目标及重解析点。所有补丁与 ACL 依赖先核对哈希,再复制;不使用硬链接。EXE 仅更新与 ASAR 对应的完整性资源,不关闭完整性检查、不改 fuse;代码签名不会因此保持有效。失败时保留新副本现场,不自动删除或重试。
|
|
18
|
+
|
|
19
|
+
输出中的 `crate-terminal-fix-receipt.json` 记录输入 manifest 与输出身份。`BUILT_NOT_TESTED` 只表示构建完成,必须在独立 DSH_HOME、workspace、Electron userData、TEMP 和缓存中另做业务验收。
|
|
20
|
+
|
|
21
|
+
## 验证
|
|
22
|
+
|
|
23
|
+
```powershell
|
|
24
|
+
python -m unittest discover -s tests -p test_windows_terminal_build.py
|
|
25
|
+
|
|
26
|
+
# 可选:读取本地官方输入,应用实际补丁并执行真实模块的受控事件回归。
|
|
27
|
+
$env:DSH_CRATE_TEST_DESKTOP = "<官方桌面目录>"
|
|
28
|
+
python -m unittest discover -s tests -p test_windows_terminal_build.py
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
受控回归不启动桌面、Worker 或模型;没有指定本地桌面时,实际模块用例标为 skipped,不能据此声称真实终端通过。真实业务验收还需覆盖:打开终端、命令输出及文件标记、关闭、自然退出码、计划重启和移除 Crate 后继续可用,以及进程/Job/端点释放。
|
|
32
|
+
|
|
33
|
+
### 隔离目录的权限前提
|
|
34
|
+
|
|
35
|
+
验收夹具 `tests/windows_terminal_directory.py` 只创建明确 scope 内的新目录,并给原所有者显式保留 Modify + WRITE_OWNER,其他主体、owner、继承和官方沙箱策略不变。必须先准备整棵隔离运行目录的全新根目录,再在其中创建 Home、workspace、TEMP 和缓存,使子目录继承明确的所有者权限。只处理 workspace / TEMP 叶目录,仍可能因祖先目录不可访问而得到错误的工作目录。不要把它用于已有用户目录。仅继承 `OWNER RIGHTS` 的私有临时目录,可能在官方受限令牌下无法读取或写入;只补 WRITE_OWNER 不足以证明可用。
|
|
36
|
+
|
|
37
|
+
已观察到的失败链路是:沙箱子进程不能写入指定 TEMP → PowerShell 进入受限语言模式 → 编码/提示符初始化无法工作 → 最后报告就绪超时。应检查实际 PowerShell 语言模式、工作目录、指定 TEMP 写入及目录外写入仍被拒绝,不能通过关闭语言限制、增加 Everyone 权限或提高超时来制造通过。
|
|
38
|
+
|
|
39
|
+
在 manifest 锁定的 DSH 0.2.1-rc.5 / Windows x64 / 随包 Node 24.21.0 独立副本中,已实际验证恢复后、重启后和移除 Crate 后的 `terminal_open → terminal_send → terminal_close`:命令输出和文件标记正确、工作目录正确、目录外写入被拒绝,桌面正常退出,受控 Job 活动进程归零,相关 TCP 连接释放。自然退出 0 / 127 的事件传播另有实际 ConPTY 回归。调用通过显式隔离测试插件执行真实注册工具,不经过外部模型;逐名称管道释放没有独立探测。
|
|
40
|
+
|
|
41
|
+
默认零模型 Verify 的 composition、boot、surface_ready、new_session 和 cleanup 已实际通过;其模型、核心工具及插件业务步骤仍保留 `UNTESTED`,不能据此宣称完整业务验收通过。移除后的 API 检查应核对官方回退层语义:该版本静态回退层对未匹配的 POST 返回 405,不能把“必须是 404”用作卸载成功条件。
|
|
42
|
+
|
|
43
|
+
此补丁不包含 Inspector / NARB 的历史候选修复,不代表完整桌面产品交付,也不宣称适用所有 rc.5 制品或后续版本。node-pty 的变更基于其 MIT 许可模块;构建保留原安装内许可证,其他官方模块使用者仍须遵守其适用许可。
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""对精确匹配的 Windows 桌面创建独立修复副本;只用 Python 标准库。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import copy
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path, PurePosixPath
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import struct
|
|
12
|
+
|
|
13
|
+
ROOT = Path(__file__).resolve().parent
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def digest(data):
|
|
17
|
+
return hashlib.sha256(data).hexdigest()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def file_hash(path):
|
|
21
|
+
with Path(path).open('rb') as stream:
|
|
22
|
+
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def safe_name(name):
|
|
26
|
+
if not isinstance(name, str) or not name or '\\' in name:
|
|
27
|
+
raise ValueError('非法资源相对路径')
|
|
28
|
+
path = PurePosixPath(name)
|
|
29
|
+
if path.is_absolute() or ':' in name or any(p in ('', '.', '..') for p in name.split('/')):
|
|
30
|
+
raise ValueError('非法资源相对路径')
|
|
31
|
+
return name
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def leaves(header, prefix=''):
|
|
35
|
+
for key, node in header.get('files', {}).items():
|
|
36
|
+
name = prefix + key
|
|
37
|
+
safe_name(name)
|
|
38
|
+
if 'files' in node:
|
|
39
|
+
yield from leaves(node, name + '/')
|
|
40
|
+
else:
|
|
41
|
+
yield name, node
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Asar:
|
|
45
|
+
def __init__(self, path):
|
|
46
|
+
self.path = Path(path)
|
|
47
|
+
self.file = self.path.open('rb')
|
|
48
|
+
try:
|
|
49
|
+
a, n, c, size = struct.unpack('<4I', self.file.read(16))
|
|
50
|
+
if a != 4 or size > 64 * 1024 * 1024 or n < 8 + size or c < 4 + size:
|
|
51
|
+
raise ValueError('不支持的 ASAR 头')
|
|
52
|
+
raw = self.file.read(size)
|
|
53
|
+
if len(raw) != size:
|
|
54
|
+
raise ValueError('ASAR 头不完整')
|
|
55
|
+
self.header_hash = digest(raw)
|
|
56
|
+
self.header = json.loads(raw)
|
|
57
|
+
self.base = 8 + n
|
|
58
|
+
except BaseException:
|
|
59
|
+
self.close()
|
|
60
|
+
raise
|
|
61
|
+
|
|
62
|
+
def entry(self, name):
|
|
63
|
+
node = self.header
|
|
64
|
+
for part in safe_name(name).split('/'):
|
|
65
|
+
node = node['files'][part]
|
|
66
|
+
return node
|
|
67
|
+
|
|
68
|
+
def read(self, name, seen=()):
|
|
69
|
+
if name in seen or len(seen) > 64:
|
|
70
|
+
raise ValueError('ASAR 链接循环')
|
|
71
|
+
entry = self.entry(name)
|
|
72
|
+
if 'link' in entry:
|
|
73
|
+
return self.read(entry['link'], (*seen, name))
|
|
74
|
+
if entry.get('unpacked'):
|
|
75
|
+
return Path(str(self.path) + '.unpacked').joinpath(*name.split('/')).read_bytes()
|
|
76
|
+
self.file.seek(self.base + int(entry['offset']))
|
|
77
|
+
data = self.file.read(entry['size'])
|
|
78
|
+
if len(data) != entry['size']:
|
|
79
|
+
raise ValueError('ASAR 内容不完整')
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
def close(self):
|
|
83
|
+
self.file.close()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def apply_patch(source, patch):
|
|
87
|
+
"""精确应用统一 diff;行号、旧内容和 hunk 数量均核对,不模糊匹配。"""
|
|
88
|
+
original = source.splitlines(keepends=True)
|
|
89
|
+
result, position, index, hunks = [], 0, 0, 0
|
|
90
|
+
lines = patch.splitlines(keepends=True)
|
|
91
|
+
while index < len(lines):
|
|
92
|
+
line = lines[index]
|
|
93
|
+
if not line.startswith('@@ '):
|
|
94
|
+
index += 1
|
|
95
|
+
continue
|
|
96
|
+
match = re.fullmatch(r'@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[^\r\n]*\n?', line)
|
|
97
|
+
if not match:
|
|
98
|
+
raise ValueError('非法补丁 hunk')
|
|
99
|
+
start = int(match[1]) - 1
|
|
100
|
+
if start < position or start > len(original):
|
|
101
|
+
raise ValueError('补丁 hunk 越界或重叠')
|
|
102
|
+
result.extend(original[position:start])
|
|
103
|
+
position, index, old_count, new_count = start, index + 1, 0, 0
|
|
104
|
+
hunks += 1
|
|
105
|
+
while index < len(lines) and not lines[index].startswith('@@ '):
|
|
106
|
+
line = lines[index]
|
|
107
|
+
index += 1
|
|
108
|
+
if not line or line[0] not in ' +-':
|
|
109
|
+
raise ValueError('非法补丁内容')
|
|
110
|
+
kind, text = line[0], line[1:]
|
|
111
|
+
if kind in ' -':
|
|
112
|
+
if position >= len(original) or original[position] != text:
|
|
113
|
+
raise ValueError('补丁上下文不匹配')
|
|
114
|
+
position += 1
|
|
115
|
+
old_count += 1
|
|
116
|
+
if kind in ' +':
|
|
117
|
+
result.append(text)
|
|
118
|
+
new_count += 1
|
|
119
|
+
if old_count != int(match[2] or 1) or new_count != int(match[4] or 1):
|
|
120
|
+
raise ValueError('补丁行数不匹配')
|
|
121
|
+
if not hunks:
|
|
122
|
+
raise ValueError('空补丁')
|
|
123
|
+
result.extend(original[position:])
|
|
124
|
+
return ''.join(result)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def patched_modules(reader, manifest, asset_root=ROOT):
|
|
128
|
+
changes = {}
|
|
129
|
+
for row in manifest['patches']:
|
|
130
|
+
name = safe_name(row['path'])
|
|
131
|
+
if name in changes or reader.entry(name).get('unpacked') or 'link' in reader.entry(name):
|
|
132
|
+
raise ValueError('补丁目标重复或不是 packed 普通文件')
|
|
133
|
+
original = reader.read(name)
|
|
134
|
+
patch = (asset_root / safe_name(row['patch'])).read_bytes()
|
|
135
|
+
if digest(original) != row['beforeSha256'] or digest(patch) != row['patchSha256']:
|
|
136
|
+
raise ValueError('模块或补丁身份不匹配: ' + name)
|
|
137
|
+
normalized = original.decode('utf8').replace('\r\n', '\n')
|
|
138
|
+
text = apply_patch(normalized if normalized.endswith('\n') else normalized + '\n', patch.decode('utf8'))
|
|
139
|
+
if not row['outputFinalNewline']:
|
|
140
|
+
text = text.removesuffix('\n')
|
|
141
|
+
if row['outputNewline'] == 'CRLF':
|
|
142
|
+
text = text.replace('\n', '\r\n')
|
|
143
|
+
elif row['outputNewline'] != 'LF':
|
|
144
|
+
raise ValueError('未知输出换行格式')
|
|
145
|
+
raw = text.encode('utf8')
|
|
146
|
+
if digest(raw) != row['afterSha256']:
|
|
147
|
+
raise ValueError('补丁输出身份不匹配: ' + name)
|
|
148
|
+
changes[name] = raw
|
|
149
|
+
return changes
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def rewrite_asar(reader, destination, changes):
|
|
153
|
+
header = copy.deepcopy(reader.header)
|
|
154
|
+
packed = []
|
|
155
|
+
for name, node in leaves(header):
|
|
156
|
+
if name in changes:
|
|
157
|
+
data = changes[name]
|
|
158
|
+
node['size'] = len(data)
|
|
159
|
+
if 'integrity' in node:
|
|
160
|
+
integrity = node['integrity']
|
|
161
|
+
block = integrity['blockSize']
|
|
162
|
+
if block <= 0:
|
|
163
|
+
raise ValueError('非法 ASAR blockSize')
|
|
164
|
+
integrity['hash'] = digest(data)
|
|
165
|
+
integrity['blocks'] = [digest(data[i:i + block]) for i in range(0, len(data), block)]
|
|
166
|
+
if 'offset' in node:
|
|
167
|
+
packed.append((int(reader.entry(name)['offset']), name, node))
|
|
168
|
+
packed.sort()
|
|
169
|
+
offset = 0
|
|
170
|
+
for _, name, node in packed:
|
|
171
|
+
node['offset'] = str(offset)
|
|
172
|
+
offset += node['size']
|
|
173
|
+
raw = json.dumps(header, ensure_ascii=False, separators=(',', ':')).encode()
|
|
174
|
+
padded = (len(raw) + 3) & ~3
|
|
175
|
+
with Path(destination).open('wb') as output:
|
|
176
|
+
output.write(struct.pack('<4I', 4, 8 + padded, 4 + padded, len(raw)) + raw + b'\0' * (padded - len(raw)))
|
|
177
|
+
for _, name, node in packed:
|
|
178
|
+
output.write(changes[name] if name in changes else reader.read(name))
|
|
179
|
+
return digest(raw)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def patch_integrity(executable, spec, new_hash):
|
|
183
|
+
"""精确输入已锁定,唯一更新已核对的 ELECTRONASAR 完整性资源。"""
|
|
184
|
+
raw = executable.read_bytes()
|
|
185
|
+
offset, size = spec['resourceOffset'], spec['resourceBytes']
|
|
186
|
+
old = spec['oldHeaderSha256'].encode()
|
|
187
|
+
section = raw[offset:offset + size]
|
|
188
|
+
if len(section) != size or section.count(old) != 1 or len(new_hash) != len(old):
|
|
189
|
+
raise ValueError('EXE/ASAR 完整性资源不匹配')
|
|
190
|
+
executable.write_bytes(raw[:offset] + section.replace(old, new_hash.encode()) + raw[offset + size:])
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def plain_tree(path):
|
|
194
|
+
for item in [path, *path.rglob('*')]:
|
|
195
|
+
if item.is_symlink() or item.is_junction():
|
|
196
|
+
raise ValueError('目录含重解析点')
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def materialize(source, destination):
|
|
200
|
+
source_arg, destination_arg = Path(source).absolute(), Path(destination).absolute()
|
|
201
|
+
for path in (source_arg, destination_arg):
|
|
202
|
+
for ancestor in (path, *path.parents):
|
|
203
|
+
if ancestor.is_symlink() or ancestor.is_junction():
|
|
204
|
+
raise ValueError('输入或输出路径含重解析点')
|
|
205
|
+
source, destination = source_arg.resolve(), destination_arg.resolve()
|
|
206
|
+
if destination.exists() or destination.is_relative_to(source) or source.is_relative_to(destination):
|
|
207
|
+
raise ValueError('目标已存在或与输入嵌套,拒绝覆盖')
|
|
208
|
+
manifest = json.loads((ROOT / 'manifest.json').read_text('utf8'))
|
|
209
|
+
if manifest['schemaVersion'] != 1:
|
|
210
|
+
raise ValueError('不支持的 manifest 版本')
|
|
211
|
+
if file_hash(source / 'DeepSeek Harness.exe') != manifest['inputExeSha256'] or file_hash(source / 'resources/app.asar') != manifest['inputAsarSha256']:
|
|
212
|
+
raise ValueError('官方输入身份不匹配;同一版本名不能代替文件身份')
|
|
213
|
+
if file_hash(source / safe_name(manifest['consoleNode']['path'])) != manifest['consoleNode']['sha256']:
|
|
214
|
+
raise ValueError('随包控制台 Node 身份不匹配')
|
|
215
|
+
plain_tree(source)
|
|
216
|
+
reader = Asar(source / 'resources/app.asar')
|
|
217
|
+
try:
|
|
218
|
+
if reader.header_hash != manifest['exeIntegrity']['oldHeaderSha256']:
|
|
219
|
+
raise ValueError('原 ASAR header 身份不匹配')
|
|
220
|
+
changes = patched_modules(reader, manifest)
|
|
221
|
+
resources = {}
|
|
222
|
+
for name, expected in manifest['aclFiles'].items():
|
|
223
|
+
safe_name(name)
|
|
224
|
+
raw = reader.read('dsh/' + name)
|
|
225
|
+
if digest(raw) != expected:
|
|
226
|
+
raise ValueError('官方 ACL 依赖身份不匹配')
|
|
227
|
+
resources[name] = raw
|
|
228
|
+
# 所有内容先核对;新副本不使用硬链接,不修改原安装或用户 Home。
|
|
229
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
shutil.copytree(source, destination, symlinks=False)
|
|
231
|
+
header_hash = rewrite_asar(reader, destination / 'resources/app.asar', changes)
|
|
232
|
+
patch_integrity(destination / 'DeepSeek Harness.exe', manifest['exeIntegrity'], header_hash)
|
|
233
|
+
resource_root = destination / 'resources/runtime' / safe_name(manifest['aclResourceDirectory'])
|
|
234
|
+
resource_root.mkdir(exist_ok=False)
|
|
235
|
+
for name, raw in resources.items():
|
|
236
|
+
target = resource_root / name
|
|
237
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
238
|
+
with target.open('xb') as stream:
|
|
239
|
+
stream.write(raw)
|
|
240
|
+
receipt = {'status': 'BUILT_NOT_TESTED', 'manifestSha256': file_hash(ROOT / 'manifest.json'),
|
|
241
|
+
'changedModules': list(changes), 'testHooksIncluded': False,
|
|
242
|
+
'outputExeSha256': file_hash(destination / 'DeepSeek Harness.exe'),
|
|
243
|
+
'outputAsarSha256': file_hash(destination / 'resources/app.asar'),
|
|
244
|
+
'integrityDisabled': False, 'originalInstallationChanged': False,
|
|
245
|
+
'modelsRequested': 0, 'desktopStarted': False}
|
|
246
|
+
with (destination / 'crate-terminal-fix-receipt.json').open('x', encoding='utf8') as stream:
|
|
247
|
+
json.dump(receipt, stream, ensure_ascii=False, indent=2)
|
|
248
|
+
return receipt
|
|
249
|
+
finally:
|
|
250
|
+
reader.close()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
if __name__ == '__main__':
|
|
254
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
255
|
+
parser.add_argument('--source', required=True, type=Path)
|
|
256
|
+
parser.add_argument('--destination', required=True, type=Path)
|
|
257
|
+
args = parser.parse_args()
|
|
258
|
+
print(json.dumps(materialize(args.source, args.destination), ensure_ascii=False))
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
--- a/dsh/node_modules/@deepseek-ai/dsh-sandbox-local/lib/index.js
|
|
2
|
+
+++ b/dsh/node_modules/@deepseek-ai/dsh-sandbox-local/lib/index.js
|
|
3
|
+
@@ -540,7 +540,17 @@
|
|
4
|
+
const override = this.internals.windowsAclRunnerArgs;
|
|
5
|
+
if (override !== void 0) return override;
|
|
6
|
+
const builtEntry = this.internals.windowsAclRunnerEntry ?? fileURLToPath(import.meta.resolve("@deepseek-ai/dsh-sandbox-windows-acl/runner"));
|
|
7
|
+
- if (existsSync(builtEntry)) return [process.execPath, builtEntry];
|
|
8
|
+
+ if (existsSync(builtEntry)) {
|
|
9
|
+
+ // 打包 Windows 桌面的 GUI EXE 不作为继承控制台句柄的 runner 宿主。
|
|
10
|
+
+ // 使用随附控制台 Node 和原字节官方 ACL runner,保持原沙箱协议。
|
|
11
|
+
+ if (process.platform === "win32" && process.versions.electron && import.meta.url.includes("/app.asar/")) {
|
|
12
|
+
+ const node = fileURLToPath(new URL("../../../../../../runtime/primary-runtime/dependencies/node/bin/node.exe", import.meta.url));
|
|
13
|
+
+ const runner = fileURLToPath(new URL("../../../../../../runtime/windows-acl-runner-v127/node_modules/@deepseek-ai/dsh-sandbox-windows-acl/lib/runner.js", import.meta.url));
|
|
14
|
+
+ if (!existsSync(node) || !existsSync(runner)) throw new Error("sandbox-local: Windows ACL console host resources are missing");
|
|
15
|
+
+ return [node, runner];
|
|
16
|
+
+ }
|
|
17
|
+
+ return [process.execPath, builtEntry];
|
|
18
|
+
+ }
|
|
19
|
+
const sourceEntry = fileURLToPath(import.meta.resolve("@deepseek-ai/dsh-sandbox-windows-acl/src/runner.ts"));
|
|
20
|
+
const sourceConfig = fileURLToPath(new URL("../../../../tsconfig.base.json", import.meta.url));
|
|
21
|
+
const registration = `import { register } from ${JSON.stringify(import.meta.resolve("tsx/esm/api"))}; register({ tsconfig: ${JSON.stringify(sourceConfig)} });`;
|