diff --git a/.github/scripts/feishu_release_notes.py b/.github/scripts/feishu_release_notes.py index a3d1f159..0ecf9be9 100644 --- a/.github/scripts/feishu_release_notes.py +++ b/.github/scripts/feishu_release_notes.py @@ -14,6 +14,17 @@ from dataclasses import dataclass from pathlib import Path +if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass +if sys.stderr.encoding and sys.stderr.encoding.lower() != "utf-8": + try: + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + STABLE_TAG_PATTERN = re.compile(r"^v\d+\.\d+\.\d+$") TOPIC_PATTERN = re.compile(r"^\*\*.+? (\d+)\. .+\*\*$", re.MULTILINE) LINK_PATTERN = re.compile(r"https?://|\[[^\]]+\]\([^)]+\)") @@ -125,20 +136,19 @@ class ReleaseEvidence: code_diff: str -def git_output(*args: str) -> str: - return subprocess.check_output( - ["git", *args], - text=True, - stderr=subprocess.DEVNULL, - ).strip() +def git_output(*args: str, default: str = "") -> str: + try: + return subprocess.check_output( + ["git", *args], + text=True, + stderr=subprocess.DEVNULL, + ).strip() + except Exception: + return default def read_annotated_tag_note(release_tag: str) -> str: - try: - object_type = git_output("cat-file", "-t", f"refs/tags/{release_tag}") - except subprocess.CalledProcessError: - return f"Release {release_tag}" - + object_type = git_output("cat-file", "-t", f"refs/tags/{release_tag}") if object_type != "tag": return f"Release {release_tag}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b098058..de544dc9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,8 @@ jobs: runs-on: macos-15 steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: 22 @@ -78,12 +80,13 @@ jobs: env: CSC_KEYCHAIN: ${{ steps.signing_keychain.outputs.keychain }} run: npm run package:mac:arm64 - - name: Build unsigned Apple Silicon verification package + - name: Build isolated Apple Silicon development package if: ${{ !startsWith(github.ref, 'refs/tags/v') }} env: CSC_IDENTITY_AUTO_DISCOVERY: 'false' - run: npm run package:mac:arm64 + run: npm run package:dev:mac:arm64 - name: Preserve Apple Silicon update metadata + if: startsWith(github.ref, 'refs/tags/v') run: mv dist/latest-mac.yml dist/latest-mac-arm64.yml - name: Sign, notarize, and staple Apple Silicon DMG if: startsWith(github.ref, 'refs/tags/v') @@ -130,6 +133,7 @@ jobs: spctl --assess --type open --context context:primary-signature --verbose=4 dist/dsh-desktop-mac-arm64.dmg xcrun stapler validate dist/dsh-desktop-mac-arm64.dmg - uses: actions/upload-artifact@v4 + if: startsWith(github.ref, 'refs/tags/v') with: name: macos-apple-silicon path: | @@ -138,6 +142,15 @@ jobs: dist/dsh-desktop-mac-arm64.zip.blockmap dist/latest-mac-arm64.yml if-no-files-found: error + - uses: actions/upload-artifact@v4 + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + with: + name: macos-apple-silicon-dev + path: | + dist-dev/dsh-desktop-dev-mac-arm64.dmg + dist-dev/dsh-desktop-dev-mac-arm64.zip + dist-dev/dsh-desktop-dev-mac-arm64.zip.blockmap + if-no-files-found: error macos-intel: name: macOS Intel @@ -145,6 +158,8 @@ jobs: runs-on: macos-15-intel steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: 22 @@ -196,12 +211,13 @@ jobs: env: CSC_KEYCHAIN: ${{ steps.signing_keychain.outputs.keychain }} run: npm run package:mac:x64 - - name: Build unsigned Intel verification package + - name: Build isolated Intel development package if: ${{ !startsWith(github.ref, 'refs/tags/v') }} env: CSC_IDENTITY_AUTO_DISCOVERY: 'false' - run: npm run package:mac:x64 + run: npm run package:dev:mac:x64 - name: Preserve Intel update metadata + if: startsWith(github.ref, 'refs/tags/v') run: mv dist/latest-mac.yml dist/latest-mac-x64.yml - name: Sign, notarize, and staple Intel DMG if: startsWith(github.ref, 'refs/tags/v') @@ -248,6 +264,7 @@ jobs: spctl --assess --type open --context context:primary-signature --verbose=4 dist/dsh-desktop-mac-x64.dmg xcrun stapler validate dist/dsh-desktop-mac-x64.dmg - uses: actions/upload-artifact@v4 + if: startsWith(github.ref, 'refs/tags/v') with: name: macos-intel path: | @@ -256,6 +273,15 @@ jobs: dist/dsh-desktop-mac-x64.zip.blockmap dist/latest-mac-x64.yml if-no-files-found: error + - uses: actions/upload-artifact@v4 + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + with: + name: macos-intel-dev + path: | + dist-dev/dsh-desktop-dev-mac-x64.dmg + dist-dev/dsh-desktop-dev-mac-x64.zip + dist-dev/dsh-desktop-dev-mac-x64.zip.blockmap + if-no-files-found: error windows-x64: name: Windows x64 @@ -263,6 +289,8 @@ jobs: runs-on: windows-2022 steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: 22 @@ -410,6 +438,8 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: 22 diff --git a/electron-builder.dev.cjs b/electron-builder.dev.cjs index 4ec5151f..8c8e09ca 100644 --- a/electron-builder.dev.cjs +++ b/electron-builder.dev.cjs @@ -13,6 +13,7 @@ module.exports = { productName: 'DSH Desktop Dev', dshDesktopChannel: 'development' }, + artifactName: 'dsh-desktop-dev-${os}-${arch}.${ext}', nsis: { ...packageJson.build.nsis, artifactName: 'dsh-desktop-dev-windows-${arch}-setup.${ext}' diff --git a/package.json b/package.json index 7aa33592..93079de3 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "test:watch": "vitest", "package:dir": "npm run build && electron-builder --dir", "package:dev:dir": "npm run build && electron-builder --dir --config electron-builder.dev.cjs", + "package:dev:mac:arm64": "node scripts/verify-target.mjs darwin arm64 && npm run build && electron-builder --mac --arm64 --publish never --config electron-builder.dev.cjs", + "package:dev:mac:x64": "node scripts/verify-target.mjs darwin x64 && npm run build && electron-builder --mac --x64 --publish never --config electron-builder.dev.cjs", "package:dev:win": "node scripts/verify-target.mjs win32 x64 && npm run build && electron-builder --win --x64 --publish never --config electron-builder.dev.cjs", "package:mac": "npm run build && electron-builder --mac --publish never", "package:mac:arm64": "node scripts/verify-target.mjs darwin arm64 && npm run build && electron-builder --mac --arm64 --publish never", diff --git a/patches/@deepseek-ai+cordis-plugin-loader+1.0.2.patch b/patches/@deepseek-ai+cordis-plugin-loader+1.0.2.patch new file mode 100644 index 00000000..995f5d15 --- /dev/null +++ b/patches/@deepseek-ai+cordis-plugin-loader+1.0.2.patch @@ -0,0 +1,34 @@ +diff --git a/node_modules/@deepseek-ai/cordis-plugin-loader/lib/index.js b/node_modules/@deepseek-ai/cordis-plugin-loader/lib/index.js +index 6f4c713..dc48f9d 100644 +--- a/node_modules/@deepseek-ai/cordis-plugin-loader/lib/index.js ++++ b/node_modules/@deepseek-ai/cordis-plugin-loader/lib/index.js +@@ -266,10 +266,25 @@ var EntryTree = class EntryTree { + /* @vite-ignore */ + new URL(name, this.ctx.baseUrl).href + )); +- else return await import(__rewriteRelativeImportExtension( +- /* @vite-ignore */ +- name +- )); ++ else { ++ try { ++ return await import(__rewriteRelativeImportExtension( ++ /* @vite-ignore */ ++ name ++ )); ++ } catch (error) { ++ if (this.ctx.baseUrl) { ++ try { ++ const { createRequire } = await import("node:module"); ++ const { pathToFileURL } = await import("node:url"); ++ const req = createRequire(new URL("package.json", this.ctx.baseUrl).href); ++ const resolved = req.resolve(name); ++ return await import(pathToFileURL(resolved).href); ++ } catch {} ++ } ++ throw error; ++ } ++ } + }, getOuterStack); + } + }; diff --git a/src/main/index.ts b/src/main/index.ts index f400870c..ac510712 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -10,10 +10,12 @@ import { Menu, nativeTheme, shell, + utilityProcess, type IpcMainInvokeEvent, type MessageBoxOptions } from 'electron' import { extractFailureCause, HarnessRuntime } from './runtime/harness-runtime' +import { launchDisclaimedUtilityProcess } from './runtime/disclaimed-utility-process' import { removeProfilePluginWithDsh } from './runtime/profile-plugin-command' import { LanMobileBridge } from './mobile/lan-mobile-bridge' import { @@ -940,7 +942,10 @@ async function bootstrap(): Promise { dshPatchPath: desktopResourcePath('dsh-desktop.patch.yml'), dshHome: join(app.getPath('userData'), 'harness'), logPath: join(app.getPath('logs'), 'harness.log'), - launchProcess: (executablePath, args, options) => spawn(executablePath, args, options), + launchProcess: (executablePath, args, options) => + process.platform === 'darwin' + ? launchDisclaimedUtilityProcess(utilityProcess, args, options) + : spawn(executablePath, args, options), onChanged: (snapshot) => { if (snapshot.phase === 'ready' && snapshot.url) { void openHarness(snapshot.url).catch(showUnexpectedError) diff --git a/src/main/runtime/disclaimed-utility-process.ts b/src/main/runtime/disclaimed-utility-process.ts new file mode 100644 index 00000000..c72d0856 --- /dev/null +++ b/src/main/runtime/disclaimed-utility-process.ts @@ -0,0 +1,102 @@ +import { EventEmitter } from 'node:events' +import type { SpawnOptionsWithoutStdio } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import type { UtilityProcess } from 'electron' +import type { HarnessChildProcess } from './harness-runtime' + +interface UtilityProcessLauncher { + fork(modulePath: string, args?: string[], options?: Electron.ForkOptions): UtilityProcess +} + +export interface DisclaimedUtilityProcessSpec { + modulePath: string + args: string[] + options: Electron.ForkOptions +} + +export function buildDisclaimedUtilityProcessSpec( + nodeArguments: readonly string[], + spawnOptions: SpawnOptionsWithoutStdio +): DisclaimedUtilityProcessSpec { + const [internalLoaderFlag, modulePath, ...args] = nodeArguments + if (internalLoaderFlag !== '--expose-internals' || !modulePath) { + throw new Error('Unexpected Harness Node arguments for the macOS utility process.') + } + + return { + modulePath, + args, + options: { + cwd: + typeof spawnOptions.cwd === 'string' + ? spawnOptions.cwd + : spawnOptions.cwd + ? fileURLToPath(spawnOptions.cwd) + : undefined, + env: definedEnvironment(spawnOptions.env), + execArgv: [internalLoaderFlag], + stdio: 'pipe', + serviceName: 'DSH Harness', + // Harness loads user-installed plugins and can launch third-party tools. + // Keep their TCC requests out of DSH Desktop's responsibility chain. + disclaim: true + } + } +} + +export function launchDisclaimedUtilityProcess( + launcher: UtilityProcessLauncher, + nodeArguments: readonly string[], + spawnOptions: SpawnOptionsWithoutStdio +): HarnessChildProcess { + const spec = buildDisclaimedUtilityProcessSpec(nodeArguments, spawnOptions) + return new UtilityProcessAdapter( + launcher.fork(spec.modulePath, spec.args, spec.options) + ) +} + +function definedEnvironment(environment: NodeJS.ProcessEnv | undefined): Record { + return Object.fromEntries( + Object.entries(environment ?? {}).filter( + (entry): entry is [string, string] => entry[1] !== undefined + ) + ) +} + +class UtilityProcessAdapter extends EventEmitter implements HarnessChildProcess { + readonly stdout: NodeJS.ReadableStream + readonly stderr: NodeJS.ReadableStream + exitCode: number | null = null + + constructor(private readonly child: UtilityProcess) { + super() + if (!child.stdout || !child.stderr) { + child.kill() + throw new Error('The DSH Harness utility process did not expose piped output.') + } + this.stdout = child.stdout + this.stderr = child.stderr + + child.once('spawn', () => this.emit('spawn')) + child.once('error', (type, location, report) => { + const detail = [type, location, report].filter(Boolean).join(': ') + this.emit('error', new Error(`Harness utility process failed: ${detail}`)) + }) + child.once('exit', (code) => { + this.exitCode = code + this.emit('exit', code, null) + }) + } + + kill(signal?: NodeJS.Signals): boolean { + if (signal === 'SIGKILL' && this.child.pid !== undefined) { + try { + process.kill(this.child.pid, signal) + return true + } catch { + return false + } + } + return this.child.kill() + } +} diff --git a/src/main/runtime/harness-runtime.ts b/src/main/runtime/harness-runtime.ts index 9e4b7375..ab97ea41 100644 --- a/src/main/runtime/harness-runtime.ts +++ b/src/main/runtime/harness-runtime.ts @@ -1,4 +1,5 @@ -import type { ChildProcessWithoutNullStreams, SpawnOptionsWithoutStdio } from 'node:child_process' +import type { SpawnOptionsWithoutStdio } from 'node:child_process' +import type { EventEmitter } from 'node:events' import { createWriteStream, existsSync, type WriteStream } from 'node:fs' import { mkdir } from 'node:fs/promises' import { createServer } from 'node:net' @@ -16,11 +17,18 @@ export interface HarnessRuntimeOptions { executablePath: string, args: string[], options: SpawnOptionsWithoutStdio - ): ChildProcessWithoutNullStreams + ): HarnessChildProcess startupTimeoutMs?: number onChanged(snapshot: RuntimeSnapshot): void } +export interface HarnessChildProcess extends EventEmitter { + readonly stdout: NodeJS.ReadableStream + readonly stderr: NodeJS.ReadableStream + readonly exitCode: number | null + kill(signal?: NodeJS.Signals): boolean +} + export function buildHarnessArguments(port: number, patchPath?: string): string[] { return [ 'web', @@ -86,7 +94,7 @@ export function updateReadyStability( } export class HarnessRuntime { - private child?: ChildProcessWithoutNullStreams + private child?: HarnessChildProcess private logStream?: WriteStream private phase: RuntimePhase = 'idle' private message = 'Harness is not running.' @@ -148,7 +156,7 @@ export class HarnessRuntime { this.writeLog(`[desktop] endpoint ${url}`) this.setState('starting', 'Starting DeepSeek Harness…') - let child: ChildProcessWithoutNullStreams + let child: HarnessChildProcess try { child = this.options.launchProcess( this.options.nodeExecutablePath, @@ -228,7 +236,7 @@ ${cause}` this.setState('idle', 'Harness is not running.') } - private async stopChild(child: ChildProcessWithoutNullStreams): Promise { + private async stopChild(child: HarnessChildProcess): Promise { if (child.exitCode !== null) return const exitPromise = new Promise((resolve) => child.once('exit', () => resolve(true)) diff --git a/test/cordis-plugin-loader-patch.test.ts b/test/cordis-plugin-loader-patch.test.ts new file mode 100644 index 00000000..43cd9830 --- /dev/null +++ b/test/cordis-plugin-loader-patch.test.ts @@ -0,0 +1,22 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +const projectRoot = path.resolve(import.meta.dirname, '..') + +describe('cordis-plugin-loader resolution patch', () => { + it('falls back to resolving bare plugins relative to ctx.baseUrl', async () => { + const patch = await readFile( + path.join( + projectRoot, + 'patches', + '@deepseek-ai+cordis-plugin-loader+1.0.2.patch' + ), + 'utf8' + ) + + expect(patch).toContain('const req = createRequire(new URL("package.json", this.ctx.baseUrl).href)') + expect(patch).toContain('const resolved = req.resolve(name)') + expect(patch).toContain('return await import(pathToFileURL(resolved).href)') + }) +}) diff --git a/test/feishu-release-notes.test.ts b/test/feishu-release-notes.test.ts index 4aac6cf8..0d47c11a 100644 --- a/test/feishu-release-notes.test.ts +++ b/test/feishu-release-notes.test.ts @@ -7,9 +7,12 @@ describe('Feishu release notes pipeline', () => { const scriptPath = join(process.cwd(), '.github', 'scripts', 'feishu_release_notes.py') const workflowPath = join(process.cwd(), '.github', 'workflows', 'release.yml') + const pythonEnv = { ...process.env, PYTHONIOENCODING: 'utf-8' } + it('builds a prompt with valid metadata and evidence blocks', () => { const output = execFileSync('python3', [scriptPath, 'build-prompt', '--tag', 'v0.4.0'], { - encoding: 'utf8' + encoding: 'utf8', + env: pythonEnv }) expect(output).toContain("You are DSH Desktop's Release Bot.") @@ -26,7 +29,8 @@ describe('Feishu release notes pipeline', () => { const tempFile = join(process.cwd(), '.temp-feishu-test-notes.md') try { execFileSync('python3', [scriptPath, 'generate-fallback', '--tag', 'v0.4.0', '--output', tempFile], { - encoding: 'utf8' + encoding: 'utf8', + env: pythonEnv }) const content = readFileSync(tempFile, 'utf8') @@ -37,7 +41,8 @@ describe('Feishu release notes pipeline', () => { // Validate passes without error const validateOutput = execFileSync('python3', [scriptPath, 'validate', '--tag', 'v0.4.0', '--input', tempFile], { - encoding: 'utf8' + encoding: 'utf8', + env: pythonEnv }) expect(validateOutput).toContain('validated successfully') } finally { @@ -74,7 +79,8 @@ Description here. expect(() => { execFileSync('python3', [scriptPath, 'validate', '--tag', 'v0.4.0', '--input', tempFile], { encoding: 'utf8', - stdio: 'pipe' + stdio: 'pipe', + env: pythonEnv }) }).toThrow() } finally { diff --git a/test/release.test.ts b/test/release.test.ts index feb37b80..cd19c949 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -183,7 +183,10 @@ describe('GitHub release contract', () => { 'package:mac', 'package:mac:arm64', 'package:mac:x64', - 'package:win' + 'package:win', + 'package:dev:mac:arm64', + 'package:dev:mac:x64', + 'package:dev:win' ]) { expect(packageJson.scripts[script]).toContain('--publish never') } @@ -201,6 +204,10 @@ describe('GitHub release contract', () => { expect(packageJson.scripts['package:dev:dir']).toContain('npm run build') expect(packageJson.scripts['package:dev:dir']).toContain('electron-builder.dev.cjs') + expect(packageJson.scripts['package:dev:mac:arm64']).toContain('verify-target.mjs darwin arm64') + expect(packageJson.scripts['package:dev:mac:arm64']).toContain('electron-builder.dev.cjs') + expect(packageJson.scripts['package:dev:mac:x64']).toContain('verify-target.mjs darwin x64') + expect(packageJson.scripts['package:dev:mac:x64']).toContain('electron-builder.dev.cjs') expect(packageJson.scripts['package:dev:win']).toContain('verify-target.mjs win32 x64') expect(packageJson.scripts['package:dev:win']).toContain('electron-builder.dev.cjs') expect(packageJson.scripts['package:dev:win']).toContain('--publish never') @@ -208,6 +215,9 @@ describe('GitHub release contract', () => { expect(developmentConfig).toContain("productName: 'DSH Desktop Dev'") expect(developmentConfig).toContain("output: 'dist-dev'") expect(developmentConfig).toContain("dshDesktopChannel: 'development'") + expect(developmentConfig).toContain( + "artifactName: 'dsh-desktop-dev-${os}-${arch}.${ext}'" + ) expect(developmentConfig).toContain( "artifactName: 'dsh-desktop-dev-windows-${arch}-setup.${ext}'" ) diff --git a/test/runtime.test.ts b/test/runtime.test.ts index 15606c13..20effe78 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -13,6 +13,7 @@ import { updateReadyStability } from '../src/main/runtime/harness-runtime' import { canGrantWindowPermission, isTrustedAppUrl } from '../src/main/security-policy' +import { buildDisclaimedUtilityProcessSpec } from '../src/main/runtime/disclaimed-utility-process' import { desktopHarnessUrl, isAbortedNavigationError, @@ -108,6 +109,58 @@ describe('Harness launch contract', () => { ]) }) + it('disclaims macOS TCC responsibility when Harness runs as a utility process', () => { + const spawnOptions = buildHarnessSpawnOptions( + '/Users/tester/Library/Application Support/dsh-desktop/launch-root', + '/Users/tester/Library/Application Support/dsh-desktop/harness', + 'darwin', + { PATH: '/usr/bin', ELECTRON_RUN_AS_NODE: '1' } + ) + const nodeArguments = buildNodeArguments( + '/Applications/DSH Desktop.app/Contents/Resources/harness-node-entry.mjs', + '/Applications/DSH Desktop.app/Contents/Resources/app/node_modules/@deepseek-ai/dsh/lib/bin.js', + 43127, + '/Applications/DSH Desktop.app/Contents/Resources/dsh-desktop.patch.yml' + ) + + expect(buildDisclaimedUtilityProcessSpec(nodeArguments, spawnOptions)).toEqual({ + modulePath: '/Applications/DSH Desktop.app/Contents/Resources/harness-node-entry.mjs', + args: [ + '/Applications/DSH Desktop.app/Contents/Resources/app/node_modules/@deepseek-ai/dsh/lib/bin.js', + 'web', + '--patch', + '/Applications/DSH Desktop.app/Contents/Resources/dsh-desktop.patch.yml', + '--no-open', + '--host', + '127.0.0.1', + '--port', + '43127' + ], + options: { + cwd: '/Users/tester/Library/Application Support/dsh-desktop/launch-root', + env: { + PATH: '/usr/bin', + DSH_HOME: '/Users/tester/Library/Application Support/dsh-desktop/harness', + NO_COLOR: '1' + }, + execArgv: ['--expose-internals'], + stdio: 'pipe', + serviceName: 'DSH Harness', + disclaim: true + } + }) + }) + + it('rejects an unexpected macOS Harness argument layout', () => { + expect(() => + buildDisclaimedUtilityProcessSpec(['entry.mjs'], { + cwd: '/tmp/dsh', + env: {}, + stdio: ['pipe', 'pipe', 'pipe'] + }) + ).toThrow('Unexpected Harness Node arguments') + }) + it('makes native Windows termination codes diagnosable', () => { expect(formatExitCode(4294930435)).toContain( '0xFFFF7003, Crashpad handler unavailable'