From 63405be8aee5d1e4ea2ce13d58d121d919bb7503 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Sat, 15 Aug 2026 01:52:25 +0800 Subject: [PATCH] fix(dsh): package the bridge profile on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.2.18 Desktop Package run failed in `Package (windows-x64)` with `spawnSync npm ENOENT` out of `build-profile.mjs`, taking the whole `frontend:build-all` down with it. Three separate Windows assumptions: - `npm` is a `.cmd` shim there, and Node has refused to spawn one without a shell since CVE-2024-27980. A shell then re-splits every argument, so passing an absolute `--pack-destination` would break on its first space. Both `npm pack` and `tar` now run *in* the staging directory, which leaves their arguments as bare package names and one filename — no quoting to get wrong, and no drive letter reaching `tar -f`, which GNU tar would read as a remote host. - `copyTree`'s filter derived a basename by slicing on '/', which on a '\'-separated path yields the whole path and therefore matched nothing. A vendored tree would have dragged `node_modules` along. - `hashTree` recorded native separators, so the same sources produced a different content stamp per build host. Digests are unchanged on Unix (verified byte-for-byte against the previous script). `prepare:dsh-profile` runs only inside `frontend:build-all`, which no CI job invokes, so its first Windows execution ever was a release build. Add a small `windows-latest` job that runs the packaging and asserts the profile is complete, stamped, and carries nothing it must not ship. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 54 ++++++++++++++++++++++ packages/dsh-acp/scripts/build-profile.mjs | 40 +++++++++++----- 2 files changed, 83 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71d70cdec9..c5afe720c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -278,6 +278,60 @@ jobs: - name: Run search tool tests run: "cargo test --locked -p tool-runtime --lib search::" + # ── DeepSeek Harness bridge: profile packaging on Windows ────────── + # `prepare:dsh-profile` runs only inside `frontend:build-all`, which no CI job + # invokes — so until now its first Windows execution ever was a release build, + # and it failed there (`spawnSync npm ENOENT`: npm is a `.cmd` shim Node will + # not spawn without a shell). This job is the cheapest thing that exercises + # the whole packaging path — npm install, tsc, `npm pack`, tar, tree copy — on + # the platform whose path and process rules differ. + dsh-profile-windows: + name: DSH Profile Packaging (windows-latest) + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version-file: package.json + package-manager-cache: false + + - name: Build the bridge profile + run: node scripts/prepare-dsh-profile.mjs + + - name: Verify the profile is complete and stamped + shell: bash + run: | + set -euo pipefail + root=packages/dsh-acp/dist-profile + for path in \ + "$root/.bitfun-bridge.json" \ + "$root/cordis.patch.yml" \ + "$root/package.json" \ + "$root/lib/app.js" \ + "$root/node_modules/@agentclientprotocol/sdk/package.json" \ + "$root/node_modules/@deepseek-ai/dsh-agent-spine-demo/package.json" + do + [ -f "$path" ] || { echo "::error::missing $path"; exit 1; } + done + # A vendored tree copied with a broken separator filter drags + # node_modules along; the profile resolves those from the user's own + # dsh installation and must ship none of its own beyond the two above. + nested=$(find "$root/lib" "$root/presets" -name node_modules -o -name '*.map' || true) + if [ -n "$nested" ]; then + echo "::error::profile carries files it must not ship:" + echo "$nested" + exit 1 + fi + node -e ' + const stamp = require("./packages/dsh-acp/dist-profile/.bitfun-bridge.json"); + if (stamp.profile !== "bitfun-acp") throw new Error("wrong profile name: " + stamp.profile); + if (!/^[0-9a-f]{64}$/.test(stamp.content)) throw new Error("no content digest"); + process.stdout.write(`profile ${stamp.profile} @ ${stamp.bridge}, min dsh ${stamp.minDshVersion}\n`); + ' + # ── Frontend: build ──────────────────────────────────────────────── frontend-build: name: Frontend Build diff --git a/packages/dsh-acp/scripts/build-profile.mjs b/packages/dsh-acp/scripts/build-profile.mjs index 161b5137bc..b770c08cbc 100644 --- a/packages/dsh-acp/scripts/build-profile.mjs +++ b/packages/dsh-acp/scripts/build-profile.mjs @@ -28,7 +28,7 @@ import { createHash } from 'node:crypto' import { execFileSync } from 'node:child_process' import { cpSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join, relative, resolve } from 'node:path' +import { basename, dirname, join, relative, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' import { parseArgs } from 'node:util' @@ -103,7 +103,9 @@ function copyTree(from, to) { recursive: true, dereference: true, filter: source => { - const base = source.slice(source.lastIndexOf('/') + 1) + // `basename`, not a hand-rolled slice on '/': a Windows path separates on + // '\', where slicing on '/' yields the whole path and matches nothing. + const base = basename(source) return base !== 'node_modules' && base !== '.git' && !base.endsWith('.map') }, }) @@ -127,12 +129,26 @@ function vendor(name, version) { } const staging = mkdtempSync(join(tmpdir(), 'dsh-acp-vendor-')) try { + // Both commands run IN the staging directory so no argument ever carries an + // absolute path. That is not tidiness: `npm` on Windows is a `.cmd` shim, + // which Node refuses to spawn without a shell since CVE-2024-27980, and a + // shell then re-splits every argument — an unquoted `C:\Users\...` path + // would break on its first space. Keeping the arguments to bare package + // names and one filename sidesteps quoting altogether, and also keeps a + // drive letter away from `tar -f`, which GNU tar reads as a remote host. const packed = execFileSync( 'npm', - ['pack', `${name}@${version}`, '--pack-destination', staging, '--silent'], - { encoding: 'utf8' }, - ).trim().split('\n').at(-1) - execFileSync('tar', ['-xzf', join(staging, packed), '-C', staging]) + ['pack', `${name}@${version}`, '--silent'], + { cwd: staging, encoding: 'utf8', shell: process.platform === 'win32' }, + ) + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line !== '') + .at(-1) + if (packed === undefined) { + throw new Error(`npm pack ${name}@${version} named no tarball on stdout`) + } + execFileSync('tar', ['-xzf', packed], { cwd: staging }) copyTree(join(staging, 'package'), destination) } finally { rmSync(staging, { recursive: true, force: true }) @@ -144,23 +160,25 @@ function vendor(name, version) { * * A version string is not enough: during development the bridge's version * stands still while its code changes, and a stale profile on disk would look - * current. Paths are sorted so the digest does not depend on directory order. + * current. Paths are sorted so the digest does not depend on directory order, + * and recorded with '/' so the same sources hash the same on every build host + * rather than once per path separator. * @param root - the output directory, already fully written except the stamp. * @returns a hex digest over every path and its bytes. */ function hashTree(root) { const files = [] const walk = (dir) => { - for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1)) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { const path = join(dir, entry.name) if (entry.isDirectory()) walk(path) - else files.push(path) + else files.push([relative(root, path).split(sep).join('/'), path]) } } walk(root) const digest = createHash('sha256') - for (const path of files.sort()) { - digest.update(relative(root, path)) + for (const [key, path] of files.sort((a, b) => a[0] < b[0] ? -1 : 1)) { + digest.update(key) digest.update('\0') digest.update(readFileSync(path)) }