From 5c5238e1b9d472e0163e55fd1550430272318db2 Mon Sep 17 00:00:00 2001 From: Ame <123734885+luokerenx4@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:37:43 +0800 Subject: [PATCH 1/2] build(desktop): dedupe Windows Git runtime --- .github/workflows/desktop-package-smoke.yml | 6 ++-- .github/workflows/release.yml | 6 ++-- docs/managed-workspace-runtime.md | 27 ++++++++------ package.json | 5 ++- scripts/assert-desktop-package.mjs | 13 ++++++- scripts/assert-desktop-package.spec.ts | 34 ++++++++++++++++++ scripts/smoke-packaged-toolchain.mjs | 39 +++++++++++++++++++++ scripts/smoke-packaged-toolchain.spec.ts | 13 ++++--- 8 files changed, 121 insertions(+), 22 deletions(-) diff --git a/.github/workflows/desktop-package-smoke.yml b/.github/workflows/desktop-package-smoke.yml index b9438f5ef..b33492f70 100644 --- a/.github/workflows/desktop-package-smoke.yml +++ b/.github/workflows/desktop-package-smoke.yml @@ -113,10 +113,10 @@ jobs: run: pnpm broker-packs:build timeout-minutes: 10 - # Keep this until the Windows managed Git runtime fully replaces dugite's - # embedded payload. It catches a skipped dugite postinstall before a - # package artifact reaches a tester. + # macOS still uses dugite's embedded Git. Windows keeps only dugite's JS + # wrapper and routes it to managed PortableGit in the packaged smoke. - name: Verify bundled git (dugite) was fetched + if: runner.os == 'macOS' shell: bash run: | node -e "const{existsSync}=require('fs');const{dirname,join}=require('path');const g=join(dirname(require.resolve('dugite/package.json')),'git');if(!existsSync(g)){console.error('dugite embedded git MISSING at '+g);process.exit(1)}console.log('dugite embedded git OK '+g)" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb3383734..cc19d5524 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -121,10 +121,10 @@ jobs: - name: Build optional Broker Packs run: pnpm broker-packs:build - # Fail loud if dugite's postinstall was skipped (e.g. dropped from - # pnpm.onlyBuiltDependencies) — otherwise node_modules/dugite/git/ is - # silently empty and workspace creation breaks at runtime, not build. + # macOS still uses dugite's embedded Git. Windows keeps only dugite's JS + # wrapper and routes it to managed PortableGit in the packaged smoke. - name: Verify bundled git (dugite) was fetched + if: runner.os == 'macOS' run: | node -e "const{existsSync}=require('fs');const{dirname,join}=require('path');const g=join(dirname(require.resolve('dugite/package.json')),'git');if(!existsSync(g)){console.error('dugite embedded git MISSING at '+g);process.exit(1)}console.log('dugite embedded git OK '+g)" diff --git a/docs/managed-workspace-runtime.md b/docs/managed-workspace-runtime.md index a8a87e0e6..fedc6d17e 100644 --- a/docs/managed-workspace-runtime.md +++ b/docs/managed-workspace-runtime.md @@ -169,9 +169,11 @@ Source/dev falls back to `node` from the contributor environment. Keep the publi JavaScript directly makes behavior depend on the host Node version and the nearest `package.json` module type. -The Windows package currently retains dugite's embedded Git payload as well as -PortableGit. This duplication is intentional until all Workspace Git call -sites have moved behind an OpenAlice-owned wrapper. +The Windows package retains dugite's JavaScript execution wrapper but excludes +its embedded Git payload. `LOCAL_GIT_DIRECTORY` routes every dugite call to the +same pinned PortableGit tree that supplies Workspace Bash. macOS continues to +ship dugite's embedded Git because its packaged path does not need a separate +managed Unix shell payload. ### Windows workspace shell preference @@ -344,9 +346,9 @@ Keep these true together: - `vendor/**` remains in the Electron builder file list. - `asar` remains disabled while packaged scripts and binaries are executed from the resource tree. -- `dugite` remains in `pnpm.onlyBuiltDependencies` until its embedded payload - is deliberately removed; skipping its postinstall silently produces an - incomplete package. +- `dugite` remains in `pnpm.onlyBuiltDependencies` because macOS packages use + its embedded Git. The Windows builder excludes `node_modules/dugite/git/**`, + keeps the JS wrapper, and must route it through managed PortableGit. - Pi and PortableGit versions, download URLs, and checksums remain pinned in `scripts/vendor-managed-runtime.mjs`. - Managed `fd` and `ripgrep` versions, release URLs, checksums, binaries, and @@ -384,6 +386,10 @@ contract: when structured assistant output is decoded and the created issue is visible from the external `/api/issues` surface. +The focused Windows toolchain smoke additionally loads the packaged dugite JS +wrapper with no embedded dugite Git present, then performs a real +`init`/`add`/`commit`/`status` cycle through managed PortableGit. + The second assertion deliberately uses an observable Workspace side effect, not a model claiming that a command succeeded. The run emits a versioned JSON receipt whose individual checks make PATH, injection, CLI transport, runtime @@ -475,10 +481,11 @@ A release-facing change should also verify a clean-machine flow: ## Known Follow-up -PortableGit and dugite's embedded Git are still duplicated on Windows. The -next cleanup is to introduce an OpenAlice-owned Git execution wrapper, migrate -Workspace/template call sites to it, and remove dugite only after macOS and -Windows packaged smokes remain green. +OpenAlice still imports dugite directly at several Workspace and template call +sites. A future OpenAlice-owned Git execution wrapper can centralize timeouts, +errors, and environment policy and eventually replace the dugite dependency. +That refactor is no longer required to keep duplicate Git binaries out of the +Windows package. That cleanup must not weaken the first-run contract: install OpenAlice, configure a credential, open a Workspace, and let Alice work. diff --git a/package.json b/package.json index 36ec4544a..d9f4d1ed9 100644 --- a/package.json +++ b/package.json @@ -132,7 +132,10 @@ "target": [ "nsis" ], - "icon": "apps/desktop/build/icon.ico" + "icon": "apps/desktop/build/icon.ico", + "files": [ + "!node_modules/dugite/git/**" + ] }, "nsis": { "oneClick": false, diff --git a/scripts/assert-desktop-package.mjs b/scripts/assert-desktop-package.mjs index 414bf2c19..2448942f4 100644 --- a/scripts/assert-desktop-package.mjs +++ b/scripts/assert-desktop-package.mjs @@ -34,6 +34,8 @@ export const BASE_REQUIRED_FILES = [ 'src/workspaces/cli/bin/alice-uta.cmd', 'src/workspaces/templates/_common.mjs', 'src/workspaces/templates/chat/bootstrap.mjs', + 'node_modules/dugite/package.json', + 'node_modules/dugite/build/lib/index.js', 'vendor/manifest.json', 'vendor/pi/package.json', 'vendor/pi/node_modules/@earendil-works/pi-coding-agent/dist/cli.js', @@ -124,6 +126,13 @@ export function assertDesktopPackage(options = {}) { } if (platform === 'win32') { + const embeddedDugiteGit = join(appRoot, 'node_modules', 'dugite', 'git') + if (existsSync(embeddedDugiteGit)) { + errors.push( + '[desktop-package] Windows must use managed PortableGit; dugite\'s embedded Git payload is forbidden', + ) + } + const git = manifest?.git?.[platformArch] if (!git) { errors.push(`[desktop-package] expected manifest.git.${platformArch} for Windows managed Git Bash`) @@ -171,7 +180,9 @@ function platformRequiredFiles(platform, platformArch) { `vendor/git/${platformArch}/bin/bash.exe`, `vendor/git/${platformArch}/bin/sh.exe`, ] - : [] + : platform === 'darwin' + ? ['node_modules/dugite/git/bin/git'] + : [] return [...searchTools, ...git] } diff --git a/scripts/assert-desktop-package.spec.ts b/scripts/assert-desktop-package.spec.ts index 27dcd7107..c8186945b 100644 --- a/scripts/assert-desktop-package.spec.ts +++ b/scripts/assert-desktop-package.spec.ts @@ -64,6 +64,7 @@ describe('assertDesktopPackage', () => { const appRoot = join(root, 'mac-arm64/OpenAlice.app/Contents/Resources/app') writeBasePackage(appRoot, { ...piManifest(), ...searchToolsManifest('darwin-arm64') }) writeSearchToolFiles(appRoot, 'darwin-arm64') + writePackageFile(appRoot, 'node_modules/dugite/git/bin/git') const result = assertDesktopPackage({ packageRoot: root, repoRoot: root, arch: 'arm64' }) @@ -125,12 +126,45 @@ describe('assertDesktopPackage', () => { } }) + it('rejects dugite embedded Git in Windows packages', () => { + const root = mkdtempSync(join(tmpdir(), 'openalice-package-win-dugite-git-')) + try { + const appRoot = join(root, 'win-unpacked/resources/app') + writeBasePackage(appRoot, { + ...piManifest(), + ...searchToolsManifest('win32-x64', true), + git: { + 'win32-x64': { + version: '2.55.0.2', + path: 'vendor/git/win32-x64', + gitBin: 'cmd/git.exe', + shellPath: 'bin/bash.exe', + shPath: 'bin/sh.exe', + }, + }, + }) + writePackageFile(appRoot, 'vendor/git/win32-x64/cmd/git.exe') + writePackageFile(appRoot, 'vendor/git/win32-x64/bin/bash.exe') + writePackageFile(appRoot, 'vendor/git/win32-x64/bin/sh.exe') + writeSearchToolFiles(appRoot, 'win32-x64', true) + writePackageFile(appRoot, 'node_modules/dugite/git/cmd/git.exe') + + const result = assertDesktopPackage({ packageRoot: root, repoRoot: root, arch: 'x64' }) + + expect(result.ok).toBe(false) + expect(result.errors.join('\n')).toContain('dugite\'s embedded Git payload is forbidden') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + it('rejects optional broker SDKs bundled into the desktop app', () => { const root = mkdtempSync(join(tmpdir(), 'openalice-package-broker-sdk-')) try { const appRoot = join(root, 'mac-arm64/OpenAlice.app/Contents/Resources/app') writeBasePackage(appRoot, { ...piManifest(), ...searchToolsManifest('darwin-arm64') }) writeSearchToolFiles(appRoot, 'darwin-arm64') + writePackageFile(appRoot, 'node_modules/dugite/git/bin/git') writePackageFile(appRoot, 'node_modules/.pnpm/ccxt@4.5.38/package.json') writePackageFile(appRoot, 'node_modules/longbridge/package.json') diff --git a/scripts/smoke-packaged-toolchain.mjs b/scripts/smoke-packaged-toolchain.mjs index a40ecd193..57fcb67a8 100644 --- a/scripts/smoke-packaged-toolchain.mjs +++ b/scripts/smoke-packaged-toolchain.mjs @@ -117,6 +117,7 @@ export function buildPackagedToolchainSmokePlan(packageResult) { const gitExe = join(gitRoot, git.gitBin) const bashExe = join(gitRoot, git.shellPath) const shExe = join(gitRoot, git.shPath) + const dugiteEntry = join(packageResult.appRoot, 'node_modules', 'dugite', 'build', 'lib', 'index.js') const workspaceCliDir = join(packageResult.appRoot, 'src', 'workspaces', 'cli', 'bin') const toolchainPath = (Array.isArray(git.toolchainPaths) ? git.toolchainPaths : ['cmd', 'bin', 'usr/bin']) .map((entry) => join(gitRoot, entry)) @@ -136,6 +137,44 @@ export function buildPackagedToolchainSmokePlan(packageResult) { args: ['--version'], expectStdout: /^git version /m, }) + const dugiteProbe = ` +const { mkdtempSync, rmSync, writeFileSync } = require('node:fs') +const { tmpdir } = require('node:os') +const { join } = require('node:path') +const { exec } = require(${JSON.stringify(dugiteEntry)}) +const root = mkdtempSync(join(tmpdir(), 'openalice-dugite-portable-git-')) +const run = async (args) => { + const result = await exec(args, root) + if (result.exitCode !== 0) { + throw new Error('git ' + args.join(' ') + ' failed: ' + String(result.stderr).slice(0, 500)) + } + return result +} +;(async () => { + try { + await run(['init', '-q']) + writeFileSync(join(root, 'smoke.txt'), 'portable git through dugite\\n') + await run(['add', 'smoke.txt']) + await run(['-c', 'user.email=smoke@openalice.local', '-c', 'user.name=OpenAlice Smoke', 'commit', '-q', '-m', 'smoke']) + const status = await run(['status', '--porcelain']) + if (status.stdout.trim()) throw new Error('git status was not clean: ' + status.stdout) + const version = await run(['--version']) + console.log('OPENALICE_DUGITE_PORTABLE_GIT_OK ' + version.stdout.trim()) + } finally { + rmSync(root, { recursive: true, force: true }) + } +})().catch((error) => { console.error(error); process.exit(1) }) +`.trim() + commands.push({ + label: 'dugite JS wrapper routes Git operations through managed PortableGit', + command: electron, + args: ['-e', dugiteProbe], + env: { + ELECTRON_RUN_AS_NODE: '1', + LOCAL_GIT_DIRECTORY: gitRoot, + }, + expectStdout: /OPENALICE_DUGITE_PORTABLE_GIT_OK git version /, + }) commands.push({ label: 'managed bash.exe', command: bashExe, diff --git a/scripts/smoke-packaged-toolchain.spec.ts b/scripts/smoke-packaged-toolchain.spec.ts index c148eba66..74dccd3c6 100644 --- a/scripts/smoke-packaged-toolchain.spec.ts +++ b/scripts/smoke-packaged-toolchain.spec.ts @@ -101,6 +101,7 @@ describe('buildPackagedToolchainSmokePlan', () => { 'managed Pi resolves packaged fd/rg without download', 'workspace CLI payload through packaged Electron Node', 'managed git.exe', + 'dugite JS wrapper routes Git operations through managed PortableGit', 'managed bash.exe', 'managed sh.exe can resolve git and bash on PATH', 'Workspace CLI launcher through managed Git Bash', @@ -108,11 +109,15 @@ describe('buildPackagedToolchainSmokePlan', () => { ]) expect(plan.commands[2].command.replaceAll('\\', '/')).toContain('vendor/tools/win32-x64/bin/fd.exe') expect(plan.commands[6].command.replaceAll('\\', '/')).toContain('vendor/git/win32-x64/cmd/git.exe') - expect(plan.commands[8].env?.PATH.replaceAll('\\', '/')).toContain('vendor/git/win32-x64/mingw64/bin') - expect(plan.commands[8].env?.PATH.replaceAll('\\', '/')).toContain('vendor/tools/win32-x64/bin') - expect(plan.commands[9].env?.OPENALICE_MANAGED_PI_NODE_PATH.replaceAll('\\', '/')) + expect(plan.commands[7].env?.LOCAL_GIT_DIRECTORY.replaceAll('\\', '/')) + .toContain('vendor/git/win32-x64') + expect(plan.commands[7].args.join('\n').replaceAll('\\', '/')) + .toContain('node_modules/dugite/build/lib/index.js') + expect(plan.commands[9].env?.PATH.replaceAll('\\', '/')).toContain('vendor/git/win32-x64/mingw64/bin') + expect(plan.commands[9].env?.PATH.replaceAll('\\', '/')).toContain('vendor/tools/win32-x64/bin') + expect(plan.commands[10].env?.OPENALICE_MANAGED_PI_NODE_PATH.replaceAll('\\', '/')) .toContain('win-unpacked/OpenAlice.exe') - expect(plan.commands[10].env?.OPENALICE_TOOL_URL).toBe('/cli') + expect(plan.commands[11].env?.OPENALICE_TOOL_URL).toBe('/cli') } finally { rmSync(root, { recursive: true, force: true }) } From 9d068ff36d7db02075ca478728cb400eb79e1917 Mon Sep 17 00:00:00 2001 From: Ame <123734885+luokerenx4@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:49:21 +0800 Subject: [PATCH 2/2] test(desktop): normalize Windows probe paths --- scripts/smoke-packaged-toolchain.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/smoke-packaged-toolchain.spec.ts b/scripts/smoke-packaged-toolchain.spec.ts index 74dccd3c6..95677607d 100644 --- a/scripts/smoke-packaged-toolchain.spec.ts +++ b/scripts/smoke-packaged-toolchain.spec.ts @@ -111,7 +111,7 @@ describe('buildPackagedToolchainSmokePlan', () => { expect(plan.commands[6].command.replaceAll('\\', '/')).toContain('vendor/git/win32-x64/cmd/git.exe') expect(plan.commands[7].env?.LOCAL_GIT_DIRECTORY.replaceAll('\\', '/')) .toContain('vendor/git/win32-x64') - expect(plan.commands[7].args.join('\n').replaceAll('\\', '/')) + expect(plan.commands[7].args.join('\n').replaceAll('\\', '/').replaceAll('//', '/')) .toContain('node_modules/dugite/build/lib/index.js') expect(plan.commands[9].env?.PATH.replaceAll('\\', '/')).toContain('vendor/git/win32-x64/mingw64/bin') expect(plan.commands[9].env?.PATH.replaceAll('\\', '/')).toContain('vendor/tools/win32-x64/bin')