diff --git a/.github/workflows/daily-dev-prerelease.yml b/.github/workflows/daily-dev-prerelease.yml index 73db6376c..0aa87ae6b 100644 --- a/.github/workflows/daily-dev-prerelease.yml +++ b/.github/workflows/daily-dev-prerelease.yml @@ -149,7 +149,7 @@ jobs: dist/latest.yml build-linux: - name: Build Linux + name: Build Linux (x64) runs-on: ubuntu-latest timeout-minutes: 90 needs: @@ -199,6 +199,64 @@ jobs: dist/Kun-*-linux-amd64.deb dist/latest-linux.yml + build-linux-arm64: + name: Build Linux (ARM64) + runs-on: ubuntu-24.04-arm + timeout-minutes: 90 + needs: + - prepare + env: + KUN_APP_VERSION: ${{ needs.prepare.outputs.app_version }} + KUN_ARTIFACT_VERSION: ${{ needs.prepare.outputs.dev_version }} + KUN_UPDATE_CHANNEL: frontier + DEEPSEEK_GUI_APP_VERSION: ${{ needs.prepare.outputs.app_version }} + DEEPSEEK_GUI_ARTIFACT_VERSION: ${{ needs.prepare.outputs.dev_version }} + DEEPSEEK_GUI_UPDATE_CHANNEL: frontier + RELEASE_CHANNEL: frontier + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + steps: + - name: Check out develop commit + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ needs.prepare.outputs.head_sha }} + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install Linux packaging dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends libarchive-tools rpm fakeroot dpkg build-essential python3 cmake file + + - name: Install dependencies + run: npm ci + + - name: Build Linux ARM64 AppImage and deb + run: npm run dist:linux:arm64 + + - name: Verify Linux ARM64 package architecture + run: >- + node ./scripts/verify-linux-package-architecture.mjs + --version "${{ needs.prepare.outputs.dev_version }}" + --arch arm64 + --dist dist + + - name: Upload Linux ARM64 artifacts + uses: actions/upload-artifact@v4 + with: + name: daily-dev-linux-arm64 + if-no-files-found: error + retention-days: 7 + path: | + dist/Kun-*-linux-arm64.AppImage + dist/Kun-*-linux-arm64.AppImage.blockmap + dist/Kun-*-linux-arm64.deb + dist/latest-linux-arm64.yml + build-tui: name: Build standalone TUI prerelease (${{ matrix.target }}) runs-on: ${{ matrix.runner }} @@ -217,6 +275,8 @@ jobs: target: win32-x64 - runner: ubuntu-22.04 target: linux-x64 + - runner: ubuntu-24.04-arm + target: linux-arm64 env: KUN_APP_VERSION: ${{ needs.prepare.outputs.app_version }} KUN_ARTIFACT_VERSION: ${{ needs.prepare.outputs.dev_version }} @@ -280,6 +340,7 @@ jobs: - build-macos - build-windows - build-linux + - build-linux-arm64 - build-tui env: GH_TOKEN: ${{ github.token }} @@ -337,13 +398,17 @@ jobs: "Kun-${DEV_VERSION}-win-x64.exe" "Kun-${DEV_VERSION}-linux-x86_64.AppImage" "Kun-${DEV_VERSION}-linux-amd64.deb" + "Kun-${DEV_VERSION}-linux-arm64.AppImage" + "Kun-${DEV_VERSION}-linux-arm64.deb" "latest-mac.yml" "latest.yml" "latest-linux.yml" + "latest-linux-arm64.yml" "Kun-TUI-${DEV_VERSION}-mac-arm64.tar.gz" "Kun-TUI-${DEV_VERSION}-mac-x64.tar.gz" "Kun-TUI-${DEV_VERSION}-win-x64.zip" "Kun-TUI-${DEV_VERSION}-linux-x64.tar.gz" + "Kun-TUI-${DEV_VERSION}-linux-arm64.tar.gz" ) for file in "${required[@]}"; do @@ -382,8 +447,8 @@ jobs: - App version: \`0.0.0-dev-${DEV_VERSION//./-}\` - Branch: \`develop\` - Commit: \`${short_sha}\` - - Platforms: macOS arm64/x64, Windows x64, Linux x64 AppImage/deb - - Standalone TUI: macOS arm64/x64, Windows x64, Linux x64 + - Platforms: macOS arm64/x64, Windows x64, Linux arm64/x64 AppImage/deb + - Standalone TUI: macOS arm64/x64, Windows x64, Linux arm64/x64 EOF - name: Ensure prerelease tag diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 51ca49a66..352923688 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -20,7 +20,7 @@ env: jobs: package: - name: Build Linux package + name: Build Linux package (x64) runs-on: ubuntu-latest timeout-minutes: 60 steps: @@ -58,6 +58,73 @@ jobs: dist/Kun-*-linux-x86_64.AppImage.blockmap dist/Kun-*-linux-amd64.deb + package-linux-arm64: + name: Build Linux package and TUI (ARM64) + runs-on: ubuntu-24.04-arm + timeout-minutes: 90 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.23.1' + cache: npm + cache-dependency-path: | + package-lock.json + kun/package-lock.json + + - name: Install Linux packaging dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends libarchive-tools rpm fakeroot dpkg build-essential python3 cmake file + + - name: Install root dependencies + run: npm ci + + - name: Build standalone TUI (host-native ARM64) + run: | + version="$(node -p "require('./kun/package.json').version")" + export KUN_APP_VERSION="${version}" + export KUN_ARTIFACT_VERSION="${version}" + export KUN_UPDATE_CHANNEL=stable + export RELEASE_CHANNEL=stable + npm run build:kun + npm run package:tui -- \ + --version "${version}" \ + --artifact-version "${version}" \ + --tag "v${version}" \ + --channel stable \ + --commit "${GITHUB_SHA}" \ + --target linux-arm64 \ + --output dist/tui-pr + + - name: Build Linux ARM64 AppImage and deb + run: npm run dist:linux:arm64 + + - name: Verify Linux ARM64 package architecture + run: >- + node ./scripts/verify-linux-package-architecture.mjs + --version "$(node -p "require('./package.json').version")" + --arch arm64 + --dist dist + + - name: Upload Linux ARM64 PR package + uses: actions/upload-artifact@v4 + with: + name: pr-linux-arm64-package + if-no-files-found: error + retention-days: 3 + path: | + dist/Kun-*-linux-arm64.AppImage + dist/Kun-*-linux-arm64.AppImage.blockmap + dist/Kun-*-linux-arm64.deb + dist/latest-linux-arm64.yml + dist/tui-pr/Kun-TUI-*-linux-arm64.tar.gz + dist/tui-pr/Kun-TUI-*-linux-arm64.tar.gz.sha256 + dist/tui-pr/Kun-TUI-*-linux-arm64.tar.gz.json + package-macos: name: Build ad-hoc macOS packages (PR) runs-on: macos-latest @@ -155,6 +222,7 @@ jobs: runs-on: ubuntu-latest needs: - package + - package-linux-arm64 - package-macos - package-windows if: >- @@ -164,6 +232,7 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository && ( needs.package.result == 'failure' || + needs['package-linux-arm64'].result == 'failure' || needs['package-macos'].result == 'failure' || needs['package-windows'].result == 'failure' ) @@ -178,6 +247,7 @@ jobs: script: | const failedJobs = [ ['Build Linux package', '${{ needs.package.result }}'], + ['Build Linux package and TUI (ARM64)', '${{ needs['package-linux-arm64'].result }}'], ['Build ad-hoc macOS packages (PR)', '${{ needs['package-macos'].result }}'], ['Build Windows NSIS installer (PR)', '${{ needs['package-windows'].result }}'] ] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3ae55d477..6b9efa61d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -184,7 +184,7 @@ jobs: dist/latest.yml build-linux: - name: Build Linux + name: Build Linux (x64) runs-on: ubuntu-latest timeout-minutes: 90 needs: @@ -234,6 +234,62 @@ jobs: dist/Kun-*-linux-amd64.deb dist/latest-linux.yml + build-linux-arm64: + name: Build Linux (ARM64) + runs-on: ubuntu-24.04-arm + timeout-minutes: 90 + needs: + - prepare + env: + KUN_APP_VERSION: ${{ needs.prepare.outputs.version }} + KUN_UPDATE_CHANNEL: stable + DEEPSEEK_GUI_APP_VERSION: ${{ needs.prepare.outputs.version }} + DEEPSEEK_GUI_UPDATE_CHANNEL: stable + RELEASE_CHANNEL: stable + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + steps: + - name: Check out merge commit + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.merge_commit_sha }} + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install Linux packaging dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends libarchive-tools rpm fakeroot dpkg build-essential python3 cmake file + + - name: Install dependencies + run: npm ci + + - name: Build Linux ARM64 AppImage and deb + run: npm run dist:linux:arm64 + + - name: Verify Linux ARM64 package architecture + run: >- + node ./scripts/verify-linux-package-architecture.mjs + --version "${{ needs.prepare.outputs.version }}" + --arch arm64 + --dist dist + + - name: Upload Linux ARM64 artifacts + uses: actions/upload-artifact@v4 + with: + name: release-linux-arm64 + if-no-files-found: error + retention-days: 7 + path: | + dist/Kun-*-linux-arm64.AppImage + dist/Kun-*-linux-arm64.AppImage.blockmap + dist/Kun-*-linux-arm64.deb + dist/latest-linux-arm64.yml + build-tui: name: Build standalone TUI (${{ matrix.target }}) runs-on: ${{ matrix.runner }} @@ -252,6 +308,8 @@ jobs: target: win32-x64 - runner: ubuntu-22.04 target: linux-x64 + - runner: ubuntu-24.04-arm + target: linux-arm64 env: KUN_APP_VERSION: ${{ needs.prepare.outputs.version }} KUN_ARTIFACT_VERSION: ${{ needs.prepare.outputs.version }} @@ -315,6 +373,7 @@ jobs: - build-macos - build-windows - build-linux + - build-linux-arm64 - build-tui env: GH_TOKEN: ${{ github.token }} @@ -373,13 +432,17 @@ jobs: "Kun-*-win-x64.exe" "Kun-*-linux-x86_64.AppImage" "Kun-*-linux-amd64.deb" + "Kun-*-linux-arm64.AppImage" + "Kun-*-linux-arm64.deb" "latest-mac.yml" "latest.yml" "latest-linux.yml" + "latest-linux-arm64.yml" "Kun-TUI-${RELEASE_VERSION}-mac-arm64.tar.gz" "Kun-TUI-${RELEASE_VERSION}-mac-x64.tar.gz" "Kun-TUI-${RELEASE_VERSION}-win-x64.zip" "Kun-TUI-${RELEASE_VERSION}-linux-x64.tar.gz" + "Kun-TUI-${RELEASE_VERSION}-linux-arm64.tar.gz" ) for pattern in "${required[@]}"; do @@ -408,7 +471,10 @@ jobs: - name: Generate release notes run: | set -euo pipefail - if [[ -n "${PREVIOUS_TAG}" ]]; then + curated_notes="release/release-v${RELEASE_VERSION}.md" + if [[ -f "${curated_notes}" ]]; then + cp "${curated_notes}" release-notes.md + elif [[ -n "${PREVIOUS_TAG}" ]]; then node ./scripts/generate-release-notes.cjs "${PREVIOUS_TAG}" > release-notes.md else node ./scripts/generate-release-notes.cjs > release-notes.md @@ -425,8 +491,8 @@ jobs: echo "- Branch: \`master\`" echo "- Commit: \`${GITHUB_SHA::7}\`" echo "- macOS: Developer ID signed and notarized" - echo "- Platforms: macOS arm64/x64, Windows x64, Linux x64 AppImage/deb" - echo "- Standalone TUI: macOS arm64/x64, Windows x64, Linux x64" + echo "- Platforms: macOS arm64/x64, Windows x64, Linux arm64/x64 AppImage/deb" + echo "- Standalone TUI: macOS arm64/x64, Windows x64, Linux arm64/x64" } >> release-notes.md - name: Ensure release tag diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs index b3705d916..f8661912e 100644 --- a/electron-builder.config.cjs +++ b/electron-builder.config.cjs @@ -85,6 +85,7 @@ const appId = developmentFlavor ? 'com.xingyuzhong.deepseekgui.dv' : 'com.xingyuzhong.deepseekgui' const productName = developmentFlavor ? 'kun-dv' : 'Kun' +const linuxBuildArch = normalizeOptionalLinuxBuildArch(process.env.KUN_LINUX_BUILD_ARCH) function normalizeUpdateChannel(raw) { const value = String(raw || '').trim() @@ -98,6 +99,13 @@ function normalizeAppFlavor(raw) { throw new Error(`KUN_APP_FLAVOR must be "production" or "development", got: ${raw}`) } +function normalizeOptionalLinuxBuildArch(raw) { + const value = String(raw || '').trim() + if (!value) return undefined + if (value === 'x64' || value === 'arm64') return value + throw new Error(`KUN_LINUX_BUILD_ARCH must be "x64" or "arm64", got: ${raw}`) +} + if (releaseAppVersion && !semverVersionPattern.test(releaseAppVersion)) { throw new Error( `KUN_APP_VERSION (or legacy DEEPSEEK_GUI_APP_VERSION) must be a valid semver for electron-updater, got: ${releaseAppVersion}` @@ -185,7 +193,14 @@ module.exports = { '!**/tsconfig*.json', '!**/README*', '!**/CHANGELOG*', - 'packages/create-kun-extension/templates/**/*' + 'packages/create-kun-extension/templates/**/*', + // @computer-use/libnut-linux currently publishes an x86-64 libnut.node + // even though its npm metadata also declares arm64. Keep that incompatible + // binary out of ARM64 packages; HostController already degrades the optional + // Computer Use backend when its runtime-only import is unavailable. + ...(linuxBuildArch === 'arm64' + ? ['!**/node_modules/@computer-use/libnut-linux/**/*'] + : []) // node_modules/openclaw (the vendor/openclaw-shim file: dep) must ship: // the WeChat bridge imports @tencent-weixin/openclaw-weixin/dist at // runtime to send media, and that chain resolves openclaw/plugin-sdk/*. @@ -305,8 +320,8 @@ module.exports = { // AppImage covers generic Linux; deb covers Debian-family installers such as // openKylin / Ubuntu that expect apt/software-store packages. target: [ - { target: 'AppImage', arch: ['x64'] }, - { target: 'deb', arch: ['x64'] } + { target: 'AppImage', arch: ['arm64', 'x64'] }, + { target: 'deb', arch: ['arm64', 'x64'] } ] }, // Override electron-builder's sandbox-disabling default desktop argument. diff --git a/kun/src/adapters/tool/delegation-tool-provider.test.ts b/kun/src/adapters/tool/delegation-tool-provider.test.ts index f801f920c..f2d71d761 100644 --- a/kun/src/adapters/tool/delegation-tool-provider.test.ts +++ b/kun/src/adapters/tool/delegation-tool-provider.test.ts @@ -23,6 +23,9 @@ describe('delegate_task observability output', () => { expect(properties).toHaveProperty('profile') expect(properties).toHaveProperty('resumeChildId') expect(properties).toHaveProperty('expectedResumeCount') + expect(properties?.resumeChildId?.description).toContain('Omit this field entirely for a new child') + expect(properties?.expectedResumeCount?.description).toContain('omit it entirely for a new child') + expect(delegateTool?.description).toContain('omit resumeChildId and expectedResumeCount entirely') expect(properties).not.toHaveProperty('custom_agent') expect(delegateTool?.inputSchema.required).toEqual(['prompt']) @@ -371,9 +374,88 @@ describe('delegate_task observability output', () => { resumeChildId: 'child_other', expectedResumeCount: 1 }, resumeContext)).resolves.toMatchObject({ isError: true }) + + await expect(tool.execute({ + prompt: 'invalid override', + resumeChildId: 'child_resume', + expectedResumeCount: 1, + returnFormat: 'evidence' + }, resumeContext)).resolves.toMatchObject({ + isError: true, + output: { error: expect.stringContaining('omit resumeChildId and expectedResumeCount') } + }) expect(resumeChild).toHaveBeenCalledTimes(1) }) + it('creates a new child when a provider materializes neutral resume placeholders', async () => { + const resumeChild = vi.fn() + const runChild = vi.fn(async (input: Parameters[0]) => ({ + id: 'child_review', + parentThreadId: input.parentThreadId, + parentTurnId: input.parentTurnId, + launcher: 'delegate_task' as const, + label: input.label, + prompt: input.prompt, + profile: input.inlineProfile?.id, + profileSnapshot: input.inlineProfile?.profile, + security: { sandboxRoot: '/workspace', memoryEnabled: false }, + approvalReviewer: 'user' as const, + status: 'completed' as const, + resumable: false, + summary: 'No findings.', + evidence: ['Reviewed the targeted provider change.'], + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + returnFormat: 'evidence' as const, + createdAt: '2026-08-15T00:00:00.000Z', + updatedAt: '2026-08-15T00:00:01.000Z' + })) + const runtime = { + enabled: () => true, + useExistingAgents: true, + defaultToolPolicy: 'inherit', + resolveProfileSnapshot: vi.fn(async () => ({ + id: 'code-reviewer', + source: 'builtin' as const, + profile: { name: 'Code Reviewer', toolPolicy: 'readOnly' as const } + })), + runChild, + resumeChild + } as unknown as DelegationRuntime + const tool = buildDelegationToolProviders(runtime)[0]!.tools[0]! + + const result = await tool.execute({ + detach: false, + expectedResumeCount: 0, + label: 'Provider fix review', + profile: 'code-reviewer', + prompt: 'Review the targeted provider fix without modifying files.', + resumeChildId: '', + returnFormat: 'evidence' + }, context()) + + expect(result).toMatchObject({ + isError: false, + output: { childId: 'child_review', status: 'completed', returnFormat: 'evidence' } + }) + expect(resumeChild).not.toHaveBeenCalled() + expect(runChild).toHaveBeenCalledTimes(1) + expect(runChild).toHaveBeenCalledWith(expect.objectContaining({ + label: 'Provider fix review', + returnFormat: 'evidence', + inlineProfile: expect.objectContaining({ id: 'code-reviewer' }) + })) + + await expect(tool.execute({ + prompt: 'Invalid orphaned count', + profile: 'code-reviewer', + expectedResumeCount: 1 + }, context())).resolves.toMatchObject({ + isError: true, + output: { error: expect.stringContaining('omit both fields') } + }) + expect(runChild).toHaveBeenCalledTimes(1) + }) + it('rejects custom arguments in existing-profile mode and stale arguments that cross custom-only mode', async () => { const runChild = vi.fn() const existingRuntime = { diff --git a/kun/src/adapters/tool/delegation-tool-provider.ts b/kun/src/adapters/tool/delegation-tool-provider.ts index d7c595e6f..fb31c54df 100644 --- a/kun/src/adapters/tool/delegation-tool-provider.ts +++ b/kun/src/adapters/tool/delegation-tool-provider.ts @@ -62,8 +62,15 @@ export function buildDelegationToolProviders( properties: { label: { type: 'string', description: 'A distinct 2-4 word UI title for this child.' }, prompt: { type: 'string', description: 'The task for the child agent.' }, - resumeChildId: { type: 'string', description: 'Existing interrupted child id to continue instead of creating a new child.' }, - expectedResumeCount: { type: 'integer', minimum: 0, description: 'Last observed resumeCount for stale/double-submit protection.' }, + resumeChildId: { + type: 'string', + description: 'Exact existing interrupted child id to continue. Omit this field entirely for a new child; never send an empty string or a sentinel such as "new".' + }, + expectedResumeCount: { + type: 'integer', + minimum: 0, + description: 'Last observed resumeCount for stale/double-submit protection. Set only with resumeChildId; omit it entirely for a new child.' + }, ...modeProperties, detach: { type: 'boolean', description: 'Run in the background and return after the child is queued.' }, returnFormat: { type: 'string', enum: ['summary', 'evidence'] } @@ -389,15 +396,21 @@ function parseResumeArgs( args: Record, context: ToolHostContext ): ResumeArgs | undefined | Error { - const childId = stringValue(args.resumeChildId) + const rawChildId = args.resumeChildId + const childId = stringValue(rawChildId) const rawCount = args.expectedResumeCount const expectedResumeCount = typeof rawCount === 'number' && Number.isInteger(rawCount) && rawCount >= 0 ? rawCount : undefined if (!childId) { - if (args.resumeChildId !== undefined) return new Error('resumeChildId must be a non-empty string') - if (rawCount !== undefined) return new Error('expectedResumeCount requires resumeChildId') if (context.subagentResume) return new Error('this turn must resume the requested child instead of creating a new one') + const neutralEmptyChildId = typeof rawChildId === 'string' && rawChildId.trim().length === 0 + if (rawChildId !== undefined && !neutralEmptyChildId) { + return new Error('resumeChildId must be a non-empty string; omit it to create a new child') + } + if (rawCount !== undefined && rawCount !== 0) { + return new Error('expectedResumeCount requires resumeChildId; omit both fields to create a new child') + } return undefined } if (rawCount !== undefined && expectedResumeCount === undefined) { @@ -405,7 +418,12 @@ function parseResumeArgs( } const creationOnly = ['label', 'profile', 'custom_agent', 'detach', 'returnFormat'] .find((key) => args[key] !== undefined) - if (creationOnly) return new Error(`${creationOnly} is unavailable when resumeChildId is set`) + if (creationOnly) { + return new Error( + `${creationOnly} is unavailable when resumeChildId is set; ` + + 'omit resumeChildId and expectedResumeCount to create a new child' + ) + } if (context.subagentResume) { if (childId !== context.subagentResume.childId) { return new Error(`this turn may only resume child ${context.subagentResume.childId}`) @@ -626,6 +644,7 @@ function buildDelegateTaskDescription(runtime: DelegationRuntime): string { return [ 'Run a standalone child agent and return its result.', modeDescription, + 'For a new child, omit resumeChildId and expectedResumeCount entirely; never use empty or "new" sentinel values.', 'Child model, provider, and reasoning strength remain host-controlled and are not tool-call arguments.', 'Issue multiple calls in one message for independent parallel work.', `Children default to the "${runtime.defaultToolPolicy}" tool policy and can never recursively delegate.` diff --git a/kun/src/cli/self-update.test.ts b/kun/src/cli/self-update.test.ts index c9b3cd45a..ee4ae1337 100644 --- a/kun/src/cli/self-update.test.ts +++ b/kun/src/cli/self-update.test.ts @@ -37,15 +37,16 @@ describe('standalone TUI self-update', () => { expect(standaloneTuiTarget('darwin', 'arm64')).toBe('darwin-arm64') expect(standaloneTuiTarget('darwin', 'x64')).toBe('darwin-x64') expect(standaloneTuiTarget('linux', 'x64')).toBe('linux-x64') + expect(standaloneTuiTarget('linux', 'arm64')).toBe('linux-arm64') expect(standaloneTuiTarget('win32', 'x64')).toBe('win32-x64') - expect(standaloneTuiTarget('linux', 'arm64')).toBeUndefined() + expect(standaloneTuiTarget('linux', 'arm')).toBeUndefined() }) it('accepts a stable manifest only when it matches the shared release contract', () => { const current = release() const manifest = parseTuiUpdateManifest(latest(), current) expect(manifest.version).toBe('1.2.4') - expect(manifest.artifacts).toHaveLength(4) + expect(manifest.artifacts).toHaveLength(5) expect(() => parseTuiUpdateManifest( { ...latest(), channel: 'frontier' }, current @@ -228,6 +229,7 @@ function latest() { artifacts: [ artifact('darwin-arm64', 'mac', 'arm64', 'tar.gz'), artifact('darwin-x64', 'mac', 'x64', 'tar.gz'), + artifact('linux-arm64', 'linux', 'arm64', 'tar.gz'), artifact('linux-x64', 'linux', 'x64', 'tar.gz'), artifact('win32-x64', 'win', 'x64', 'zip') ] @@ -292,6 +294,7 @@ async function updateArchive(parent: string, target: string): Promise { function targetName(target: string): string { if (target === 'darwin-arm64') return 'mac-arm64.tar.gz' if (target === 'darwin-x64') return 'mac-x64.tar.gz' + if (target === 'linux-arm64') return 'linux-arm64.tar.gz' if (target === 'linux-x64') return 'linux-x64.tar.gz' throw new Error(`Unsupported Unix test target: ${target}`) } diff --git a/kun/src/cli/self-update.ts b/kun/src/cli/self-update.ts index f10a184fa..6bfbfec93 100644 --- a/kun/src/cli/self-update.ts +++ b/kun/src/cli/self-update.ts @@ -25,6 +25,7 @@ const DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1_000 const STANDALONE_TUI_TARGETS = new Set([ 'darwin-arm64', 'darwin-x64', + 'linux-arm64', 'linux-x64', 'win32-x64' ]) @@ -112,10 +113,11 @@ export function parseTuiUpdateManifest( } const artifacts = value.artifacts.map((artifact) => parseArtifact(artifact)) const targets = new Set(artifacts.map((artifact) => artifact.target)) - const expectedTargets = ['darwin-arm64', 'darwin-x64', 'linux-x64', 'win32-x64'] + const expectedTargets = ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64', 'win32-x64'] const expectedFiles = new Map([ ['darwin-arm64', `Kun-TUI-${value.version}-mac-arm64.tar.gz`], ['darwin-x64', `Kun-TUI-${value.version}-mac-x64.tar.gz`], + ['linux-arm64', `Kun-TUI-${value.version}-linux-arm64.tar.gz`], ['linux-x64', `Kun-TUI-${value.version}-linux-x64.tar.gz`], ['win32-x64', `Kun-TUI-${value.version}-win-x64.zip`] ]) diff --git a/kun/src/manager/manager-client.ts b/kun/src/manager/manager-client.ts index e1d5217f3..09a4981f6 100644 --- a/kun/src/manager/manager-client.ts +++ b/kun/src/manager/manager-client.ts @@ -35,22 +35,17 @@ import { type ManagerDiscoveryRecord } from './manager-discovery.js' import { sameCanonicalPath } from './canonical-path.js' -import { KUN_MANAGER_CAPABILITIES } from './service-manager.js' import { withRuntimeDataDirAncillaryWriter } from '../server/runtime-data-dir-lease.js' +import { + resolveServiceManager +} from './manager-resolution.js' +export { + resolveServiceManager, + resolveServiceManagerForMigration +} from './manager-resolution.js' const START_TIMEOUT_MS = 30_000 const POLL_MS = 100 const LEGACY_HANDOVER_TIMEOUT_MS = 5 * 60_000 -const ManagerHealthSchema = z.object({ - status: z.literal('ok'), - service: z.literal('kun-service-manager'), - protocolVersion: z.literal(KUN_MANAGER_PROTOCOL_VERSION), - instanceId: z.string(), - pid: z.number().int().positive(), - startedAt: z.string().datetime(), - serviceVersion: z.string(), - buildId: z.string().regex(/^[a-f0-9]{64}$/).optional(), - capabilities: z.array(z.string()) -}) export type ServiceManagerConnection = { discovery: ManagerDiscoveryRecord } @@ -180,32 +175,6 @@ export class ManagerResourceLeaseClient { } } -export async function resolveServiceManager( - controlDir = defaultKunControlDir(), - fetchImpl: typeof fetch = fetch -): Promise { - const discovery = await readManagerDiscovery(controlDir).catch(() => null) - if (!discovery || !safeManagerUrl(discovery) || !processIsAlive(discovery.pid)) return null - try { - const response = await fetchImpl(`${discovery.baseUrl}/health`, { - signal: AbortSignal.timeout(2_000) - }) - if (!response.ok) return null - const health = ManagerHealthSchema.parse(await response.json()) - if ( - health.instanceId !== discovery.instanceId || - health.pid !== discovery.pid || - health.startedAt !== discovery.startedAt || - health.serviceVersion !== discovery.serviceVersion || - health.buildId !== discovery.buildId || - !KUN_MANAGER_CAPABILITIES.every((capability) => health.capabilities.includes(capability)) - ) return null - return { discovery } - } catch { - return null - } -} - export async function ensureServiceManager(input: { flavor: RuntimeFlavor controlDir?: string @@ -675,8 +644,7 @@ import { delay, processIsAlive, requestManagerResponse, - requireManagerJson, - safeManagerUrl + requireManagerJson } from './manager-client-support.js' export { defaultManagerControlDirForTests, diff --git a/kun/src/manager/manager-resolution.test.ts b/kun/src/manager/manager-resolution.test.ts new file mode 100644 index 000000000..9234a7c4f --- /dev/null +++ b/kun/src/manager/manager-resolution.test.ts @@ -0,0 +1,122 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + publishManagerDiscovery, + type ManagerDiscoveryRecord +} from './manager-discovery.js' +import { + resolveServiceManager, + resolveServiceManagerForHandoff, + resolveServiceManagerForMigration +} from './manager-resolution.js' +import { KUN_MANAGER_CAPABILITIES } from './service-manager.js' + +describe('Service Manager resolution', () => { + const roots: string[] = [] + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) + }) + + it('keeps normal resolution strict about the current capability set', async () => { + const fixture = await managerFixture([...KUN_MANAGER_CAPABILITIES]) + const fetchImpl = managerFetch(fixture) + + await expect(resolveServiceManager(fixture.controlDir, fetchImpl)).resolves.toEqual({ + discovery: fixture.discovery + }) + expect(fetchImpl).toHaveBeenCalledTimes(1) + }) + + it('authenticates an older same-protocol manager only for migration handoff', async () => { + const capabilities = KUN_MANAGER_CAPABILITIES.filter((value) => value !== 'item-page-v1') + const fixture = await managerFixture(capabilities) + const fetchImpl = managerFetch(fixture) + + await expect(resolveServiceManager(fixture.controlDir, fetchImpl)).resolves.toBeNull() + await expect(resolveServiceManagerForMigration(fixture.controlDir, fetchImpl)).resolves.toEqual({ + discovery: fixture.discovery + }) + + const statusCall = vi.mocked(fetchImpl).mock.calls.find(([input]) => + String(input).endsWith('/v1/manager/status') + ) + expect(new Headers(statusCall?.[1]?.headers).get('authorization')).toBe('Bearer manager-token') + }) + + it('rejects a capability-incompatible manager when status authentication fails', async () => { + const capabilities = KUN_MANAGER_CAPABILITIES.filter((value) => value !== 'item-page-v1') + const fixture = await managerFixture(capabilities) + const fetchImpl = managerFetch(fixture, { statusCode: 401 }) + + await expect(resolveServiceManagerForHandoff(fixture.controlDir, fetchImpl)).resolves.toBeNull() + }) + + it('rejects a handoff when authenticated status identifies a different manager', async () => { + const capabilities = KUN_MANAGER_CAPABILITIES.filter((value) => value !== 'item-page-v1') + const fixture = await managerFixture(capabilities) + const fetchImpl = managerFetch(fixture, { statusInstanceId: 'replacement-manager' }) + + await expect(resolveServiceManagerForHandoff(fixture.controlDir, fetchImpl)).resolves.toBeNull() + }) + + async function managerFixture(capabilities: string[]): Promise<{ + controlDir: string + discovery: ManagerDiscoveryRecord + capabilities: string[] + }> { + const root = await mkdtemp(join(tmpdir(), 'kun-manager-resolution-')) + roots.push(root) + const controlDir = join(root, 'control') + const discovery = await publishManagerDiscovery(controlDir, { + instanceId: 'manager-instance', + pid: process.pid, + startedAt: '2026-08-15T00:00:00.000Z', + host: '127.0.0.1', + port: 18973, + baseUrl: 'http://127.0.0.1:18973', + managerToken: 'manager-token', + serviceVersion: '0.2.37', + dataDir: join(root, 'data'), + settingsPath: join(root, 'kun-settings.json') + }) + return { controlDir, discovery, capabilities } + } +}) + +function managerFetch( + fixture: { discovery: ManagerDiscoveryRecord; capabilities: string[] }, + options: { statusCode?: number; statusInstanceId?: string } = {} +): typeof fetch { + return vi.fn(async (input: string | URL | Request) => { + const url = String(input) + const identity = { + protocolVersion: fixture.discovery.protocolVersion, + instanceId: fixture.discovery.instanceId, + pid: fixture.discovery.pid, + startedAt: fixture.discovery.startedAt, + serviceVersion: fixture.discovery.serviceVersion, + capabilities: fixture.capabilities + } + if (url.endsWith('/health')) { + return Response.json({ + status: 'ok', + service: 'kun-service-manager', + ...identity + }) + } + if (url.endsWith('/v1/manager/status')) { + if (options.statusCode && options.statusCode !== 200) { + return new Response('', { status: options.statusCode }) + } + return Response.json({ + ...identity, + instanceId: options.statusInstanceId ?? identity.instanceId, + slots: [] + }) + } + return new Response('', { status: 404 }) + }) as unknown as typeof fetch +} diff --git a/kun/src/manager/manager-resolution.ts b/kun/src/manager/manager-resolution.ts new file mode 100644 index 000000000..2353da06f --- /dev/null +++ b/kun/src/manager/manager-resolution.ts @@ -0,0 +1,132 @@ +import { z } from 'zod' +import { + KUN_MANAGER_PROTOCOL_VERSION, + defaultKunControlDir, + readManagerDiscovery, + type ManagerDiscoveryRecord +} from './manager-discovery.js' +import { KUN_MANAGER_CAPABILITIES } from './service-manager.js' +import { processIsAlive, safeManagerUrl } from './manager-client-support.js' + +const ManagerHealthSchema = z.object({ + status: z.literal('ok'), + service: z.literal('kun-service-manager'), + protocolVersion: z.literal(KUN_MANAGER_PROTOCOL_VERSION), + instanceId: z.string(), + pid: z.number().int().positive(), + startedAt: z.string().datetime(), + serviceVersion: z.string(), + buildId: z.string().regex(/^[a-f0-9]{64}$/).optional(), + capabilities: z.array(z.string()) +}) + +const ManagerStatusSchema = ManagerHealthSchema.omit({ + status: true, + service: true +}).extend({ + slots: z.array(z.unknown()) +}) + +type ManagerIdentity = z.infer + +export async function resolveServiceManager( + controlDir = defaultKunControlDir(), + fetchImpl: typeof fetch = fetch +): Promise<{ discovery: ManagerDiscoveryRecord } | null> { + const candidate = await probeManagerHealth(controlDir, fetchImpl) + if (!candidate) return null + if (!KUN_MANAGER_CAPABILITIES.every((capability) => + candidate.health.capabilities.includes(capability) + )) return null + return { discovery: candidate.discovery } +} + +/** + * Resolves an older same-protocol Manager only for migration handoff. Normal + * callers must use resolveServiceManager so current data operations never run + * against an incomplete capability set. + */ +export async function resolveServiceManagerForHandoff( + controlDir = defaultKunControlDir(), + fetchImpl: typeof fetch = fetch +): Promise<{ discovery: ManagerDiscoveryRecord } | null> { + const candidate = await probeManagerHealth(controlDir, fetchImpl) + if (!candidate || !candidate.health.capabilities.includes('runtime-slots-v1')) return null + try { + const response = await fetchImpl(`${candidate.discovery.baseUrl}/v1/manager/status`, { + headers: { + authorization: `Bearer ${candidate.discovery.managerToken}` + }, + signal: AbortSignal.timeout(2_000) + }) + if (!response.ok) return null + const status = ManagerStatusSchema.parse(await response.json()) + if ( + !managerIdentityMatchesDiscovery(status, candidate.discovery) || + !sameManagerIdentity(status, candidate.health) || + !sameStringSet(status.capabilities, candidate.health.capabilities) || + !status.capabilities.includes('runtime-slots-v1') + ) return null + return { discovery: candidate.discovery } + } catch { + return null + } +} + +export async function resolveServiceManagerForMigration( + controlDir = defaultKunControlDir(), + fetchImpl: typeof fetch = fetch +): Promise<{ discovery: ManagerDiscoveryRecord } | null> { + return await resolveServiceManager(controlDir, fetchImpl) ?? + await resolveServiceManagerForHandoff(controlDir, fetchImpl) +} + +async function probeManagerHealth( + controlDir: string, + fetchImpl: typeof fetch +): Promise<{ discovery: ManagerDiscoveryRecord; health: ManagerIdentity } | null> { + const discovery = await readManagerDiscovery(controlDir).catch(() => null) + if (!discovery || !safeManagerUrl(discovery) || !processIsAlive(discovery.pid)) return null + try { + const response = await fetchImpl(`${discovery.baseUrl}/health`, { + signal: AbortSignal.timeout(2_000) + }) + if (!response.ok) return null + const health = ManagerHealthSchema.parse(await response.json()) + return managerIdentityMatchesDiscovery(health, discovery) + ? { discovery, health } + : null + } catch { + return null + } +} + +function managerIdentityMatchesDiscovery( + identity: Omit, + discovery: ManagerDiscoveryRecord +): boolean { + return identity.protocolVersion === discovery.protocolVersion && + identity.instanceId === discovery.instanceId && + identity.pid === discovery.pid && + identity.startedAt === discovery.startedAt && + identity.serviceVersion === discovery.serviceVersion && + identity.buildId === discovery.buildId +} + +function sameManagerIdentity( + status: z.infer, + health: ManagerIdentity +): boolean { + return status.protocolVersion === health.protocolVersion && + status.instanceId === health.instanceId && + status.pid === health.pid && + status.startedAt === health.startedAt && + status.serviceVersion === health.serviceVersion && + status.buildId === health.buildId +} + +function sameStringSet(left: string[], right: string[]): boolean { + if (left.length !== right.length) return false + const values = new Set(left) + return values.size === left.length && right.every((value) => values.has(value)) +} diff --git a/package.json b/package.json index 1f6b7230d..903afb58e 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,9 @@ "dist:mac:arm64:zip": "node ./scripts/zip-mac-app.cjs arm64", "dist:mac:x64:zip": "node ./scripts/zip-mac-app.cjs x64", "dist:win": "npm run dist -- --win nsis --x64 && node ./scripts/check-package-size.cjs --platform win32 --arch x64", - "dist:linux": "npm run dist -- --linux AppImage deb --x64 && node ./scripts/check-package-size.cjs --platform linux --arch x64", + "dist:linux": "npm run dist:linux:x64", + "dist:linux:x64": "KUN_LINUX_BUILD_ARCH=x64 npm run dist -- --linux AppImage deb --x64 && node ./scripts/check-package-size.cjs --platform linux --arch x64", + "dist:linux:arm64": "KUN_LINUX_BUILD_ARCH=arm64 npm run dist -- --linux AppImage deb --arm64 && node ./scripts/check-package-size.cjs --platform linux --arch arm64", "release:all": "bash ./scripts/release-mac.sh", "release:mac": "bash ./scripts/release-mac.sh", "release:win": "powershell -NoProfile -ExecutionPolicy Bypass -File ./scripts/release-win.ps1", diff --git a/release/release-v0.3.2.md b/release/release-v0.3.2.md new file mode 100644 index 000000000..9098e7edf --- /dev/null +++ b/release/release-v0.3.2.md @@ -0,0 +1,46 @@ +# Kun v0.3.2 + +v0.3.2 是一个稳定性热修复版本,修复子代理新建请求被误判为恢复请求、macOS 更新后的旧版 Service Manager 残留,以及 Windows 首次设置无法进入凭据恢复流程的问题;本版本同时开始提供官方 Linux ARM64 安装包。 + +### 子代理委托修复 + +- 普通新建子代理时,模型或兼容供应商自动补出的空 `resumeChildId` 与默认 `expectedResumeCount: 0` 现在按未提供处理,不再误入恢复分支。 +- `delegate_task` 的模型可见说明明确区分“新建”和“恢复”:新建时必须省略恢复字段,不能使用空字符串或 `"new"` 等占位值。 +- 真实恢复请求仍保持严格校验:必须指向确切的中断 child,并继续复用已持久化的 profile、label、return format 与安全边界;恢复时不能重新覆盖创建参数。 +- 增加真实失败载荷回归测试,覆盖完整的新建参数、空恢复占位、孤立的非零恢复计数和结构化恢复路径。 + +### macOS 更新启动恢复(#1169) + +- 修复从旧版更新或重启后,ShipIt 临时应用目录中的旧 Service Manager 仍存活时,Kun 反复进入 `active_writer` 启动恢复页面的问题。 +- 将“当前能力兼容检查”和“迁移交接认证”分离:普通运行仍要求完整的当前能力集;迁移交接允许识别缺少 `item-page-v1` 的同协议旧 manager。 +- 交接前仍会严格核对 loopback discovery、PID、instance ID、启动时间、版本/build 身份,并用 discovery token 验证 `/v1/manager/status`;认证失败或身份不一致时不会关闭进程。 +- 认证成功后复用现有的活动任务检查、双 runtime 停止、实例绑定 shutdown 和 PID 退出等待流程,使 `Retry Kun` 能安全替换残留 manager 并继续启动。 +- 增加正常兼容、旧版已认证、认证失败和实例身份不一致的回归测试。 + +### Windows 首次设置凭据恢复 + +- 修复 Windows DPAPI 保护密钥已不可读时,“保存并继续”只显示 `Shared model connection request failed (HTTP 0)`、无法判断真实原因的问题。 +- 修复“暂时跳过”显示原始 `settings:set` IPC 错误,却不提供恢复入口、导致首次设置页面无法关闭的问题。 +- 保存和跳过现在统一识别 `credential_key_unreadable`,并进入现有的本地化恢复界面。 +- 恢复仍必须由用户显式选择并通过系统确认;重置前会再次验证密钥状态,原密钥和加密凭据会先备份,失败时回滚,不会自动覆盖用户数据。 + +### Linux ARM64 官方发行(#1168) + +- GitHub Release 现提供原生 Linux ARM64 AppImage 与 deb,并保留现有 Linux x64 产物。 +- 新增 Linux ARM64 standalone TUI,与 GUI 及其他 TUI 平台共享相同版本、commit 和 runtime build identity。 +- ARM64 包使用 GitHub 托管的原生 `ubuntu-24.04-arm` runner 构建,并在上传前校验 AppImage、deb、Electron、OfficeCLI、Whisper 和 Node 原生模块的实际架构。 +- 增加独立的 `latest-linux-arm64.yml` 自动更新元数据;R2 归档与 latest promotion 同时要求 x64/ARM64 两套元数据和安装包,避免 ARM 客户端下载 x64 更新。 +- 补入上游 OfficeCLI v1.0.141 官方 Linux ARM64 资源,并继续执行固定大小与 SHA-256 校验。 +- `@computer-use/libnut-linux` 上游包目前仍只发布 x86-64 原生绑定;ARM64 安装包会明确排除该不兼容二进制,因此该平台暂不提供 Computer Use 桌面控制,其余功能和安装包均保持原生 ARM64。 + +### 影响与升级 + +- 子代理问题只影响新 child 的派发:红色错误卡片对应的子代理此前没有真正启动,已经完成的子代理和主任务数据不会受损。 +- Service Manager 修复不删除 discovery 或强杀未认证进程,也不会改写会话和工作区数据。 +- Windows 凭据恢复只让已有的安全恢复操作变得可达,不会因检测到错误就自动删除或替换凭据。 +- Linux ARM64 用户可直接下载 `linux-arm64.AppImage` 或 `linux-arm64.deb`;x64 用户的文件名和更新路径保持不变。 +- 从 v0.3.0 或 v0.3.1 升级无需迁移会话、子代理记录、工作区或 Provider 配置;受 #1169 影响的安装在升级后重新启动或点击 `Retry Kun` 即可恢复。 + +### 完整变更 + +https://github.com/KunAgent/Kun/compare/v0.3.1...v0.3.2 diff --git a/resources/officecli/manifest.json b/resources/officecli/manifest.json index 38552f000..aba630ab4 100644 --- a/resources/officecli/manifest.json +++ b/resources/officecli/manifest.json @@ -23,6 +23,12 @@ "sha256": "4a542155ce3e1b0c211ba117d5d3bc6c25357d74fd5ba55786f1c29c12ac866e", "url": "https://github.com/iOfficeAI/OfficeCLI/releases/download/v1.0.141/officecli-linux-x64" }, + "linux-arm64": { + "name": "officecli-linux-arm64", + "size": 34655751, + "sha256": "0aa4f01ec47de12f2bead5b98835d329179ba0cf7de9a9dd27bdd59aadfde9bf", + "url": "https://github.com/iOfficeAI/OfficeCLI/releases/download/v1.0.141/officecli-linux-arm64" + }, "win32-x64": { "name": "officecli-win-x64.exe", "size": 33300392, diff --git a/scripts/assemble-tui-release.mjs b/scripts/assemble-tui-release.mjs index b5e20ddb8..a54a641f2 100644 --- a/scripts/assemble-tui-release.mjs +++ b/scripts/assemble-tui-release.mjs @@ -11,6 +11,7 @@ import yauzl from 'yauzl' const TARGETS = new Map([ ['darwin-arm64', { os: 'mac', arch: 'arm64', format: 'tar.gz' }], ['darwin-x64', { os: 'mac', arch: 'x64', format: 'tar.gz' }], + ['linux-arm64', { os: 'linux', arch: 'arm64', format: 'tar.gz' }], ['linux-x64', { os: 'linux', arch: 'x64', format: 'tar.gz' }], ['win32-x64', { os: 'win', arch: 'x64', format: 'zip' }] ]) diff --git a/scripts/assemble-tui-release.test.mjs b/scripts/assemble-tui-release.test.mjs index 63c8bb476..3211c2580 100644 --- a/scripts/assemble-tui-release.test.mjs +++ b/scripts/assemble-tui-release.test.mjs @@ -14,6 +14,7 @@ const COMMIT = 'b'.repeat(40) const DEFINITIONS = [ ['darwin-arm64', 'darwin', 'mac', 'arm64', 'tar.gz'], ['darwin-x64', 'darwin', 'mac', 'x64', 'tar.gz'], + ['linux-arm64', 'linux', 'linux', 'arm64', 'tar.gz'], ['linux-x64', 'linux', 'linux', 'x64', 'tar.gz'], ['win32-x64', 'win32', 'win', 'x64', 'zip'] ] diff --git a/scripts/check-extension-release-gate-packaging.mjs b/scripts/check-extension-release-gate-packaging.mjs index be69fa4b4..271c09e88 100644 --- a/scripts/check-extension-release-gate-packaging.mjs +++ b/scripts/check-extension-release-gate-packaging.mjs @@ -367,7 +367,8 @@ check( 'Linux packaging and native smokes must retain user namespace and seccomp sandboxing' ) check( - electronBuilderConfig.includes("{ target: 'deb', arch: ['x64'] }") && - String(rootPackage.scripts?.['dist:linux'] || '').includes('deb'), + electronBuilderConfig.includes("{ target: 'deb', arch: ['arm64', 'x64'] }") && + String(rootPackage.scripts?.['dist:linux:x64'] || '').includes('deb') && + String(rootPackage.scripts?.['dist:linux:arm64'] || '').includes('deb'), 'Linux packaging must ship both AppImage and deb for Debian-family installers' ) diff --git a/scripts/check-extension-release-gate-workflows.mjs b/scripts/check-extension-release-gate-workflows.mjs index 42679b889..3b9175c10 100644 --- a/scripts/check-extension-release-gate-workflows.mjs +++ b/scripts/check-extension-release-gate-workflows.mjs @@ -634,8 +634,8 @@ if (buildOnlyCi) { const release = parseYaml(releaseWorkflow) const daily = parseYaml(dailyWorkflow) for (const [label, workflow, buildJobs] of [ - ['Stable release', release, ['build-macos', 'build-windows', 'build-linux', 'build-tui']], - ['Daily prerelease', daily, ['build-macos', 'build-windows', 'build-linux', 'build-tui']] + ['Stable release', release, ['build-macos', 'build-windows', 'build-linux', 'build-linux-arm64', 'build-tui']], + ['Daily prerelease', daily, ['build-macos', 'build-windows', 'build-linux', 'build-linux-arm64', 'build-tui']] ]) { check(!workflow.jobs.validate, `${label} must not define a validation job`) check(!workflow.jobs['verify-macos-x64'], `${label} must not define a macOS artifact verification job`) @@ -657,6 +657,7 @@ if (buildOnlyCi) { } for (const [jobId, buildCommand] of [ ['package', 'npm run dist:linux'], + ['package-linux-arm64', 'npm run dist:linux:arm64'], ['package-macos', 'npm run dist:mac'], ['package-windows', 'npm run dist:win'] ]) { @@ -672,8 +673,9 @@ if (buildOnlyCi) { ? prWorkflowDocument.jobs['request-changes-on-failure'].needs : [] check( - prFailureNeeds.length === 3 && - ['package', 'package-macos', 'package-windows'].every((jobId) => prFailureNeeds.includes(jobId)), + prFailureNeeds.length === 4 && + ['package', 'package-linux-arm64', 'package-macos', 'package-windows'] + .every((jobId) => prFailureNeeds.includes(jobId)), 'PR failure feedback must depend only on platform builds' ) } diff --git a/scripts/check-package-size.cjs b/scripts/check-package-size.cjs index 347278811..9eafc815c 100644 --- a/scripts/check-package-size.cjs +++ b/scripts/check-package-size.cjs @@ -47,7 +47,7 @@ function packagedAppPath(distDir, platform, arch) { return join(distDir, arch === 'arm64' ? 'mac-arm64' : 'mac', 'Kun.app') } if (platform === 'win32') return join(distDir, 'win-unpacked') - return join(distDir, 'linux-unpacked') + return join(distDir, arch === 'arm64' ? 'linux-arm64-unpacked' : 'linux-unpacked') } function resourcesPath(appPath, platform) { diff --git a/scripts/check-package-size.test.cjs b/scripts/check-package-size.test.cjs index 96492ee05..bfadf9f0d 100644 --- a/scripts/check-package-size.test.cjs +++ b/scripts/check-package-size.test.cjs @@ -20,6 +20,7 @@ test('resolves platform-specific unpacked application paths', () => { assert.match(packagedAppPath('/dist', 'darwin', 'x64'), /mac[\\/]Kun\.app$/u) assert.match(packagedAppPath('/dist', 'win32', 'x64'), /win-unpacked$/u) assert.match(packagedAppPath('/dist', 'linux', 'x64'), /linux-unpacked$/u) + assert.match(packagedAppPath('/dist', 'linux', 'arm64'), /linux-arm64-unpacked$/u) }) test('parses explicit report and enforcement arguments', () => { diff --git a/scripts/package-tui.mjs b/scripts/package-tui.mjs index edb8d86ac..06ed80075 100644 --- a/scripts/package-tui.mjs +++ b/scripts/package-tui.mjs @@ -31,6 +31,7 @@ const TAG = /^(?:v\d+\.\d+\.\d+|dev-\d{8}\.\d{4})$/ const TARGETS = { 'darwin-arm64': { os: 'mac', arch: 'arm64', format: 'tar.gz' }, 'darwin-x64': { os: 'mac', arch: 'x64', format: 'tar.gz' }, + 'linux-arm64': { os: 'linux', arch: 'arm64', format: 'tar.gz' }, 'linux-x64': { os: 'linux', arch: 'x64', format: 'tar.gz' }, 'win32-x64': { os: 'win', arch: 'x64', format: 'zip' } } diff --git a/scripts/package-tui.test.mjs b/scripts/package-tui.test.mjs index 8a35c53bd..f0b0c70fb 100644 --- a/scripts/package-tui.test.mjs +++ b/scripts/package-tui.test.mjs @@ -15,6 +15,7 @@ test('maps the supported standalone TUI targets to canonical release names', () const targets = [ [resolveTuiTarget('darwin', 'arm64'), 'Kun-TUI-1.2.3-mac-arm64.tar.gz'], [resolveTuiTarget('darwin', 'x64'), 'Kun-TUI-1.2.3-mac-x64.tar.gz'], + [resolveTuiTarget('linux', 'arm64'), 'Kun-TUI-1.2.3-linux-arm64.tar.gz'], [resolveTuiTarget('linux', 'x64'), 'Kun-TUI-1.2.3-linux-x64.tar.gz'], [resolveTuiTarget('win32', 'x64'), 'Kun-TUI-1.2.3-win-x64.zip'] ] @@ -24,7 +25,6 @@ test('maps the supported standalone TUI targets to canonical release names', () }) test('rejects unsupported target architectures', () => { - assert.throws(() => resolveTuiTarget('linux', 'arm64'), /Unsupported standalone TUI target/) assert.throws(() => resolveTuiTarget('win32', 'arm64'), /Unsupported standalone TUI target/) }) diff --git a/scripts/publish-r2-support.mjs b/scripts/publish-r2-support.mjs index c25a7a4a7..3cb738bce 100644 --- a/scripts/publish-r2-support.mjs +++ b/scripts/publish-r2-support.mjs @@ -23,8 +23,9 @@ export const PLATFORM_SPECS = { }, linux: { updateFile: 'latest-linux.yml', + updateFiles: ['latest-linux.yml', 'latest-linux-arm64.yml'], // Auto-update stays on AppImage; deb is a Debian-family installer sidecar. - assetPattern: /^Kun-.+-linux-(?:x86_64\.AppImage(\.blockmap)?|amd64\.deb)$/ + assetPattern: /^Kun-.+-linux-(?:(?:x86_64|arm64)\.AppImage(\.blockmap)?|(?:amd64|arm64)\.deb)$/ } } @@ -356,19 +357,24 @@ export function classifyDownload(fileName, platform) { return { platform, arch: 'x64', format: extension, label: 'Windows x64 installer' } } if (extension === 'deb') { - return { platform, arch: 'x64', format: extension, label: 'Linux x64 deb' } + const arch = fileName.includes('-arm64.') ? 'arm64' : 'x64' + return { platform, arch, format: extension, label: `Linux ${arch} deb` } } - return { platform, arch: 'x64', format: extension, label: 'Linux x64 AppImage' } + const arch = fileName.includes('-arm64.') ? 'arm64' : 'x64' + return { platform, arch, format: extension, label: `Linux ${arch} AppImage` } } export function collectRequiredSidecarAssets({ entries, platform, tagVersion }) { if (platform !== 'linux') return [] - const expected = `Kun-${tagVersion}-linux-amd64.deb` - const candidates = entries.filter((name) => /^Kun-.+-linux-amd64\.deb$/.test(name)).sort() - if (candidates.length !== 1 || candidates[0] !== expected) { + const expected = [ + `Kun-${tagVersion}-linux-amd64.deb`, + `Kun-${tagVersion}-linux-arm64.deb` + ] + const candidates = entries.filter((name) => /^Kun-.+-linux-(?:amd64|arm64)\.deb$/.test(name)).sort() + if (candidates.length !== expected.length || candidates.some((name, index) => name !== expected[index])) { throw new Error( - `Expected exactly one Linux deb sidecar named ${expected}, ` + + `Expected Linux deb sidecars ${expected.join(', ')}, ` + `found ${candidates.length}: ${candidates.join(', ') || '(none)'}` ) } diff --git a/scripts/publish-r2.mjs b/scripts/publish-r2.mjs index 528f2cd76..e046a8840 100644 --- a/scripts/publish-r2.mjs +++ b/scripts/publish-r2.mjs @@ -41,22 +41,28 @@ export { releaseVersionForTag } from './publish-r2-support.mjs' -async function collectPlatformRelease({ distDir, platform, tag, channel, config }) { +export async function collectPlatformRelease({ distDir, platform, tag, channel, config }) { const spec = PLATFORM_SPECS[platform] if (!spec) throw new Error(`Unsupported platform: ${platform}`) const entries = await readdir(distDir) - const updatePath = join(distDir, spec.updateFile) - const updateText = await readFile(updatePath, 'utf8') - const updateMetadata = parseUpdateYml(updateText) + const updateFiles = spec.updateFiles ?? [spec.updateFile] + const updateDocuments = await Promise.all(updateFiles.map(async (fileName) => ({ + fileName, + metadata: parseUpdateYml(await readFile(join(distDir, fileName), 'utf8')) + }))) const releaseVersion = releaseVersionForTag(tag) - if (updateMetadata.version !== releaseVersion) { - throw new Error( - `${spec.updateFile} version ${updateMetadata.version} does not match ${tag}. Rebuild with KUN_APP_VERSION=${releaseVersion} (legacy DEEPSEEK_GUI_APP_VERSION is also accepted).` - ) + for (const document of updateDocuments) { + if (document.metadata.version !== releaseVersion) { + throw new Error( + `${document.fileName} version ${document.metadata.version} does not match ${tag}. Rebuild with KUN_APP_VERSION=${releaseVersion} (legacy DEEPSEEK_GUI_APP_VERSION is also accepted).` + ) + } } - const referenced = new Set(updateMetadata.files.map((file) => basename(file.url))) + const referenced = new Set(updateDocuments.flatMap(({ metadata }) => + metadata.files.map((file) => basename(file.url)) + )) const sidecarAssets = collectRequiredSidecarAssets({ entries, platform, @@ -69,9 +75,11 @@ async function collectPlatformRelease({ distDir, platform, tag, channel, config } } - const fileNames = Array.from(new Set([spec.updateFile, ...assets, ...referenced])).sort() + const fileNames = Array.from(new Set([...updateFiles, ...assets, ...referenced])).sort() const files = [] - const downloadByName = new Map(updateMetadata.files.map((file) => [basename(file.url), file])) + const downloadByName = new Map(updateDocuments.flatMap(({ metadata }) => + metadata.files.map((file) => [basename(file.url), file]) + )) for (const fileName of fileNames) { const path = join(distDir, fileName) @@ -89,14 +97,14 @@ async function collectPlatformRelease({ distDir, platform, tag, channel, config sha256, sha512, contentType: contentType(fileName), - updateMetadata: fileName === spec.updateFile, + updateMetadata: updateFiles.includes(fileName), // deb is outside electron-updater metadata but is still a public installer. downloadable: downloadByName.has(fileName) || fileName.endsWith('.deb') }) } const filesByName = new Map(files.map((file) => [file.fileName, file])) - const updateDownloads = updateMetadata.files.map((file) => { + const updateDownloads = [...downloadByName.values()].map((file) => { const fileName = basename(file.url) const local = filesByName.get(fileName) if (!local) throw new Error(`Missing collected file: ${fileName}`) @@ -135,13 +143,18 @@ async function collectPlatformRelease({ distDir, platform, tag, channel, config tag, channel, platform, - version: updateMetadata.version, - releaseDate: updateMetadata.releaseDate, + version: releaseVersion, + releaseDate: updateDocuments.map(({ metadata }) => metadata.releaseDate).filter(Boolean).sort().at(-1), generatedAt: new Date().toISOString(), updateMetadata: { fileName: spec.updateFile, archiveUrl: joinUrl(config.publicBaseUrl, config.prefix, 'channels', channel, 'releases', tag, spec.updateFile), - latestUrl: joinUrl(config.publicBaseUrl, config.prefix, 'channels', channel, 'latest', spec.updateFile) + latestUrl: joinUrl(config.publicBaseUrl, config.prefix, 'channels', channel, 'latest', spec.updateFile), + alternates: updateFiles.slice(1).map((fileName) => ({ + fileName, + archiveUrl: joinUrl(config.publicBaseUrl, config.prefix, 'channels', channel, 'releases', tag, fileName), + latestUrl: joinUrl(config.publicBaseUrl, config.prefix, 'channels', channel, 'latest', fileName) + })) }, files, downloads @@ -164,14 +177,15 @@ export async function collectTuiRelease({ distDir, tag, channel, config }) { typeof manifest?.commit !== 'string' || !/^[a-f0-9]{40}$/.test(manifest.commit) || !Array.isArray(manifest?.artifacts) || - manifest.artifacts.length !== 4 + manifest.artifacts.length !== 5 ) { throw new Error('release-tui.json does not match the requested release') } - const expectedTargets = new Set(['darwin-arm64', 'darwin-x64', 'linux-x64', 'win32-x64']) + const expectedTargets = new Set(['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64', 'win32-x64']) const expectedNames = new Map([ ['darwin-arm64', `Kun-TUI-${artifactVersionForTag(tag)}-mac-arm64.tar.gz`], ['darwin-x64', `Kun-TUI-${artifactVersionForTag(tag)}-mac-x64.tar.gz`], + ['linux-arm64', `Kun-TUI-${artifactVersionForTag(tag)}-linux-arm64.tar.gz`], ['linux-x64', `Kun-TUI-${artifactVersionForTag(tag)}-linux-x64.tar.gz`], ['win32-x64', `Kun-TUI-${artifactVersionForTag(tag)}-win-x64.zip`] ]) @@ -399,6 +413,26 @@ export function validatePromotionContract({ ) { throw new Error('GUI platform manifest is incompatible with the requested release') } + if (manifest.platform === 'linux') { + const requiredLinuxFiles = new Set([ + `Kun-${artifactVersionForTag(tag)}-linux-x86_64.AppImage`, + `Kun-${artifactVersionForTag(tag)}-linux-amd64.deb`, + `Kun-${artifactVersionForTag(tag)}-linux-arm64.AppImage`, + `Kun-${artifactVersionForTag(tag)}-linux-arm64.deb`, + 'latest-linux.yml', + 'latest-linux-arm64.yml' + ]) + for (const file of manifest.files) requiredLinuxFiles.delete(file?.fileName) + const alternateMetadata = manifest.updateMetadata?.alternates ?? [] + const hasArmMetadata = alternateMetadata.some((entry) => ( + entry?.fileName === 'latest-linux-arm64.yml' + )) + if (requiredLinuxFiles.size !== 0 || !hasArmMetadata) { + throw new Error( + `Linux GUI manifest is missing required x64/ARM64 release files: ${[...requiredLinuxFiles].join(', ') || 'ARM64 update metadata'}` + ) + } + } manifestPlatforms.add(manifest.platform) } if (manifestPlatforms.size !== platformSet.size || @@ -409,6 +443,7 @@ export function validatePromotionContract({ const expectedTuiTargets = new Set([ 'darwin-arm64', 'darwin-x64', + 'linux-arm64', 'linux-x64', 'win32-x64' ]) @@ -539,7 +574,15 @@ async function promoteRelease({ flags, dryRun }) { manifest.platform, { fileName: manifest.updateMetadata.fileName, - url: joinUrl(config.publicBaseUrl, target.basePath, 'latest', manifest.updateMetadata.fileName) + url: joinUrl(config.publicBaseUrl, target.basePath, 'latest', manifest.updateMetadata.fileName), + ...(Array.isArray(manifest.updateMetadata.alternates) && manifest.updateMetadata.alternates.length > 0 + ? { + alternates: manifest.updateMetadata.alternates.map((entry) => ({ + fileName: entry.fileName, + url: joinUrl(config.publicBaseUrl, target.basePath, 'latest', entry.fileName) + })) + } + : {}) } ]) ), diff --git a/scripts/publish-r2.test.mjs b/scripts/publish-r2.test.mjs index df6e85451..71430914a 100644 --- a/scripts/publish-r2.test.mjs +++ b/scripts/publish-r2.test.mjs @@ -6,6 +6,7 @@ import { join } from 'node:path' import test from 'node:test' import { artifactVersionForTag, + collectPlatformRelease, collectRequiredSidecarAssets, collectTuiRelease, releaseVersionForTag, @@ -18,22 +19,25 @@ test('requires exactly one Linux deb sidecar matching the release tag', () => { entries: [ 'Kun-1.2.3-linux-x86_64.AppImage', 'Kun-1.2.3-linux-amd64.deb', + 'Kun-1.2.3-linux-arm64.AppImage', + 'Kun-1.2.3-linux-arm64.deb', 'latest-linux.yml' ], platform: 'linux', tagVersion: '1.2.3' }), - ['Kun-1.2.3-linux-amd64.deb'] + ['Kun-1.2.3-linux-amd64.deb', 'Kun-1.2.3-linux-arm64.deb'] ) for (const entries of [ [], ['Kun-1.2.2-linux-amd64.deb'], - ['Kun-1.2.2-linux-amd64.deb', 'Kun-1.2.3-linux-amd64.deb'] + ['Kun-1.2.3-linux-amd64.deb'], + ['Kun-1.2.3-linux-amd64.deb', 'Kun-1.2.2-linux-arm64.deb'] ]) { assert.throws( () => collectRequiredSidecarAssets({ entries, platform: 'linux', tagVersion: '1.2.3' }), - /Expected exactly one Linux deb sidecar named Kun-1\.2\.3-linux-amd64\.deb/ + /Expected Linux deb sidecars Kun-1\.2\.3-linux-amd64\.deb, Kun-1\.2\.3-linux-arm64\.deb/ ) } }) @@ -45,6 +49,61 @@ test('does not require Linux sidecars for other platforms', () => { ) }) +test('collects separate x64 and ARM64 Linux update metadata', async () => { + const directory = await mkdtemp(join(tmpdir(), 'publish-r2-linux-')) + try { + const assets = [ + 'Kun-1.2.3-linux-x86_64.AppImage', + 'Kun-1.2.3-linux-x86_64.AppImage.blockmap', + 'Kun-1.2.3-linux-amd64.deb', + 'Kun-1.2.3-linux-arm64.AppImage', + 'Kun-1.2.3-linux-arm64.AppImage.blockmap', + 'Kun-1.2.3-linux-arm64.deb' + ] + for (const name of assets) await writeFile(join(directory, name), `bytes:${name}`) + const metadata = (appImage) => [ + 'version: 1.2.3', + 'files:', + ` - url: ${appImage}`, + ' sha512: Zml4dHVyZQ==', + ' size: 7', + 'releaseDate: 2026-08-15T00:00:00.000Z', + '' + ].join('\n') + await writeFile( + join(directory, 'latest-linux.yml'), + metadata('Kun-1.2.3-linux-x86_64.AppImage') + ) + await writeFile( + join(directory, 'latest-linux-arm64.yml'), + metadata('Kun-1.2.3-linux-arm64.AppImage') + ) + + const release = await collectPlatformRelease({ + distDir: directory, + platform: 'linux', + tag: 'v1.2.3', + channel: 'stable', + config: { + prefix: 'deepseek-gui', + publicBaseUrl: 'https://downloads.example.test' + } + }) + + assert.deepEqual( + release.downloads.map(({ arch, format }) => `${arch}:${format}`).sort(), + ['arm64:AppImage', 'arm64:deb', 'x64:AppImage', 'x64:deb'] + ) + assert.deepEqual( + release.files.filter((file) => file.updateMetadata).map((file) => file.fileName).sort(), + ['latest-linux-arm64.yml', 'latest-linux.yml'] + ) + assert.equal(release.updateMetadata.alternates[0].fileName, 'latest-linux-arm64.yml') + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + test('derives one GUI/TUI version pair from stable and Daily tags', () => { assert.equal(releaseVersionForTag('v1.2.3'), '1.2.3') assert.equal(artifactVersionForTag('v1.2.3'), '1.2.3') @@ -52,12 +111,13 @@ test('derives one GUI/TUI version pair from stable and Daily tags', () => { assert.equal(artifactVersionForTag('dev-20260729.1200'), '20260729.1200') }) -test('collects exactly four same-version standalone TUI targets', async () => { +test('collects exactly five same-version standalone TUI targets', async () => { const directory = await mkdtemp(join(tmpdir(), 'publish-r2-tui-')) try { const definitions = [ ['darwin-arm64', 'mac', 'arm64', 'tar.gz'], ['darwin-x64', 'mac', 'x64', 'tar.gz'], + ['linux-arm64', 'linux', 'arm64', 'tar.gz'], ['linux-x64', 'linux', 'x64', 'tar.gz'], ['win32-x64', 'win', 'x64', 'zip'] ] @@ -98,7 +158,7 @@ test('collects exactly four same-version standalone TUI targets', async () => { publicBaseUrl: 'https://downloads.example.test' } }) - assert.equal(release.files.length, 6) + assert.equal(release.files.length, 7) assert.deepEqual( release.manifest.artifacts.map((artifact) => artifact.target).sort(), definitions.map(([target]) => target).sort() @@ -108,15 +168,30 @@ test('collects exactly four same-version standalone TUI targets', async () => { } }) -test('gates joint promotion on all GUI platforms and all four TUI targets', () => { +test('gates joint promotion on all GUI platforms and all five TUI targets', () => { const platforms = ['mac', 'win', 'linux'] const platformManifests = platforms.map((platform) => ({ version: '1.2.3', tag: 'v1.2.3', channel: 'stable', platform, - files: [], - downloads: [] + files: platform === 'linux' + ? [ + 'Kun-1.2.3-linux-x86_64.AppImage', + 'Kun-1.2.3-linux-amd64.deb', + 'Kun-1.2.3-linux-arm64.AppImage', + 'Kun-1.2.3-linux-arm64.deb', + 'latest-linux.yml', + 'latest-linux-arm64.yml' + ].map((fileName) => ({ fileName })) + : [], + downloads: [], + updateMetadata: platform === 'linux' + ? { + fileName: 'latest-linux.yml', + alternates: [{ fileName: 'latest-linux-arm64.yml' }] + } + : { fileName: platform === 'mac' ? 'latest-mac.yml' : 'latest.yml' } })) const tuiManifest = { version: '1.2.3', @@ -126,6 +201,7 @@ test('gates joint promotion on all GUI platforms and all four TUI targets', () = artifacts: [ { target: 'darwin-arm64' }, { target: 'darwin-x64' }, + { target: 'linux-arm64' }, { target: 'linux-x64' }, { target: 'win32-x64' } ] @@ -157,4 +233,16 @@ test('gates joint promotion on all GUI platforms and all four TUI targets', () = }, requireTui: true }), /TUI manifest is incompatible/) + assert.throws(() => validatePromotionContract({ + tag: 'v1.2.3', + channel: 'stable', + platforms, + platformManifests: platformManifests.map((manifest) => ( + manifest.platform === 'linux' + ? { ...manifest, files: manifest.files.filter((file) => !file.fileName.includes('arm64')) } + : manifest + )), + tuiManifest, + requireTui: true + }), /Linux GUI manifest is missing required x64\/ARM64 release files/) }) diff --git a/scripts/release-win.ps1 b/scripts/release-win.ps1 index b03b06e9a..30b6c7946 100644 --- a/scripts/release-win.ps1 +++ b/scripts/release-win.ps1 @@ -294,6 +294,7 @@ if ($Publish -or $PromoteR2) { "Kun-TUI-$ReleaseVersion-mac-arm64.tar.gz", "Kun-TUI-$ReleaseVersion-mac-x64.tar.gz", "Kun-TUI-$ReleaseVersion-win-x64.zip", + "Kun-TUI-$ReleaseVersion-linux-arm64.tar.gz", "Kun-TUI-$ReleaseVersion-linux-x64.tar.gz", 'release-tui.json', 'SHA256SUMS-tui.txt' diff --git a/scripts/release-win.sh b/scripts/release-win.sh index 79f9a35d9..b89c1ee7a 100755 --- a/scripts/release-win.sh +++ b/scripts/release-win.sh @@ -166,6 +166,7 @@ verify_tui_github_assets() { "Kun-TUI-${RELEASE_VERSION}-mac-arm64.tar.gz" \ "Kun-TUI-${RELEASE_VERSION}-mac-x64.tar.gz" \ "Kun-TUI-${RELEASE_VERSION}-win-x64.zip" \ + "Kun-TUI-${RELEASE_VERSION}-linux-arm64.tar.gz" \ "Kun-TUI-${RELEASE_VERSION}-linux-x64.tar.gz" \ "release-tui.json" \ "SHA256SUMS-tui.txt"; do diff --git a/scripts/smoke-packaged-extension-desktop-cases/process-and-release.cjs b/scripts/smoke-packaged-extension-desktop-cases/process-and-release.cjs index 1b287f949..1492f550e 100644 --- a/scripts/smoke-packaged-extension-desktop-cases/process-and-release.cjs +++ b/scripts/smoke-packaged-extension-desktop-cases/process-and-release.cjs @@ -164,8 +164,8 @@ test('automated release workflows use build gates while local release paths reta if (buildOnlyCi) { for (const [label, workflow, jobs] of [ - ['stable release', release, ['build-macos', 'build-windows', 'build-linux', 'build-tui']], - ['daily prerelease', daily, ['build-macos', 'build-windows', 'build-linux', 'build-tui']] + ['stable release', release, ['build-macos', 'build-windows', 'build-linux', 'build-linux-arm64', 'build-tui']], + ['daily prerelease', daily, ['build-macos', 'build-windows', 'build-linux', 'build-linux-arm64', 'build-tui']] ]) { assert.equal(workflow.jobs.validate, undefined, `${label} must not define a validation job`) assert.equal(workflow.jobs['verify-macos-x64'], undefined, `${label} must not define a macOS verification job`) @@ -195,7 +195,12 @@ test('automated release workflows use build gates while local release paths reta } } const prFailureNeeds = pr.jobs['request-changes-on-failure']?.needs ?? [] - assert.deepEqual(prFailureNeeds.sort(), ['package', 'package-macos', 'package-windows']) + assert.deepEqual(prFailureNeeds.sort(), [ + 'package', + 'package-linux-arm64', + 'package-macos', + 'package-windows' + ]) } else { assertPublishDependencies(release, 'stable release') assertPublishDependencies(daily, 'daily prerelease') diff --git a/scripts/verify-extension-native-evidence.mjs b/scripts/verify-extension-native-evidence.mjs index 5f221e71a..df7833641 100644 --- a/scripts/verify-extension-native-evidence.mjs +++ b/scripts/verify-extension-native-evidence.mjs @@ -16,9 +16,16 @@ const TUI_RELEASE_ASSET = new RegExp( `^Kun-TUI-(${VERSION_PART})-(?:` + 'mac-(?:arm64|x64)\\.tar\\.gz|' + 'win-x64\\.zip|' + - 'linux-x64\\.tar\\.gz' + + 'linux-(?:arm64|x64)\\.tar\\.gz' + ')(?:\\.sha256|\\.json)?$' ) +// Linux ARM64 packages are validated host-natively by +// verify-linux-package-architecture.mjs; this legacy Extension evidence bundle +// remains bound to the original three platform jobs and treats those canonical +// assets as a separate same-version release contract. +const LINUX_ARM64_RELEASE_ASSET = new RegExp( + `^Kun-(${VERSION_PART})-linux-arm64\\.(?:AppImage(?:\\.blockmap)?|deb)$` +) const FINAL_ARTIFACTS = [ { @@ -119,6 +126,7 @@ export async function verifyNativeEvidenceBundle({ const finalFiles = new Map() const ancillaryFiles = [] const tuiFiles = [] + const linuxArm64Files = [] for (const file of files) { const name = basename(file) if (TUI_NAMED_RELEASE_ASSET.test(name)) { @@ -127,6 +135,11 @@ export async function verifyNativeEvidenceBundle({ tuiFiles.push({ name, version: match[1] }) continue } + const linuxArm64 = name.match(LINUX_ARM64_RELEASE_ASSET) + if (linuxArm64) { + linuxArm64Files.push({ name, version: linuxArm64[1] }) + continue + } if (!KUN_NAMED_RELEASE_ASSET.test(name)) continue const matches = FINAL_ARTIFACTS.filter((rule) => rule.pattern.test(name)) if (matches.length > 1) throw new Error(`Ambiguous final native artifact name: ${name}`) @@ -206,6 +219,11 @@ export async function verifyNativeEvidenceBundle({ throw new Error(`Standalone TUI asset version does not match GUI artifacts: ${tui.name}`) } } + for (const asset of linuxArm64Files) { + if (asset.version !== version) { + throw new Error(`Linux ARM64 asset version does not match native evidence: ${asset.name}`) + } + } for (const ancillary of ancillaryFiles) { const ancillaryVersion = ancillary.name.match(ancillary.rule.ancillaryPattern)?.[1] if (ancillaryVersion !== version) { @@ -222,7 +240,8 @@ export async function verifyNativeEvidenceBundle({ relative(root, (byBasename.get(`extension-native-evidence-${platform}.json`) ?? [])[0]) .split(sep).join('/') ), - artifacts: [...recordedFiles].sort() + artifacts: [...recordedFiles].sort(), + supplementalArtifacts: linuxArm64Files.map(({ name }) => name).sort() } } diff --git a/scripts/verify-extension-native-evidence.test.mjs b/scripts/verify-extension-native-evidence.test.mjs index 6582f6ed8..5b4b72b83 100644 --- a/scripts/verify-extension-native-evidence.test.mjs +++ b/scripts/verify-extension-native-evidence.test.mjs @@ -153,7 +153,6 @@ test('rejects missing, duplicate, and symlinked downloaded release files', async test('rejects every extra native-looking release asset outside the seven-file allowlist', async (t) => { for (const name of [ - 'Kun-1.2.3-linux-arm64.AppImage', 'Kun-1.2.3-win-arm64.exe', 'Kun-1.2.3-win-x64.EXE', 'Kun-1.2.3-win-x64.MSI', @@ -175,6 +174,33 @@ test('rejects every extra native-looking release asset outside the seven-file al } }) +test('allows canonical same-version Linux ARM64 assets under their native package gate', async (t) => { + const root = await fixture(t) + for (const name of [ + 'Kun-1.2.3-linux-arm64.AppImage', + 'Kun-1.2.3-linux-arm64.AppImage.blockmap', + 'Kun-1.2.3-linux-arm64.deb' + ]) await writeFile(join(root, name), 'verified by the Linux ARM64 package gate') + + const result = await verifyNativeEvidenceBundle({ + directory: root, + expectedCommit: COMMIT, + expectedVersion: VERSION + }) + assert.deepEqual(result.supplementalArtifacts, [ + 'Kun-1.2.3-linux-arm64.AppImage', + 'Kun-1.2.3-linux-arm64.AppImage.blockmap', + 'Kun-1.2.3-linux-arm64.deb' + ]) + + await writeFile(join(root, 'Kun-9.9.9-linux-arm64.deb'), 'stale ARM package') + await assert.rejects(verifyNativeEvidenceBundle({ + directory: root, + expectedCommit: COMMIT, + expectedVersion: VERSION + }), /Linux ARM64 asset version does not match/) +}) + test('allows only canonical same-version blockmaps as unrecorded ancillary assets', async (t) => { const root = await fixture(t) await writeFile(join(root, 'Kun-1.2.3-win-x64.exe.blockmap'), 'canonical blockmap') @@ -200,6 +226,7 @@ test('allows canonical same-version standalone TUI assets for separate contract 'Kun-TUI-1.2.3-mac-arm64.tar.gz.json', 'Kun-TUI-1.2.3-mac-x64.tar.gz', 'Kun-TUI-1.2.3-win-x64.zip', + 'Kun-TUI-1.2.3-linux-arm64.tar.gz', 'Kun-TUI-1.2.3-linux-x64.tar.gz' ]) { await writeFile(join(root, name), 'verified by the standalone TUI release contract') @@ -218,10 +245,4 @@ test('allows canonical same-version standalone TUI assets for separate contract }), /TUI asset version does not match GUI artifacts/) await rm(join(root, 'Kun-TUI-9.9.9-win-x64.zip')) - await writeFile(join(root, 'Kun-TUI-1.2.3-linux-arm64.tar.gz'), 'unsupported TUI') - await assert.rejects(verifyNativeEvidenceBundle({ - directory: root, - expectedCommit: COMMIT, - expectedVersion: VERSION - }), /unexpected Kun-named asset/) }) diff --git a/scripts/verify-linux-package-architecture.mjs b/scripts/verify-linux-package-architecture.mjs new file mode 100644 index 000000000..2a436b2d4 --- /dev/null +++ b/scripts/verify-linux-package-architecture.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import { readFile, readdir, stat } from 'node:fs/promises' +import { basename, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ARCHITECTURES = { + x64: { + appImage: 'x86_64', + deb: 'amd64', + unpacked: 'linux-unpacked', + filePattern: /(?:x86-64|x86_64)/i + }, + arm64: { + appImage: 'arm64', + deb: 'arm64', + unpacked: 'linux-arm64-unpacked', + filePattern: /(?:ARM aarch64|ARM64|aarch64)/i + } +} + +export function linuxPackageNames(version, arch) { + const target = ARCHITECTURES[arch] + if (!target) throw new Error(`Unsupported Linux package architecture: ${arch}`) + return { + appImage: `Kun-${version}-linux-${target.appImage}.AppImage`, + deb: `Kun-${version}-linux-${target.deb}.deb`, + update: arch === 'x64' ? 'latest-linux.yml' : `latest-linux-${arch}.yml`, + unpacked: target.unpacked + } +} + +export function assertArchitectureDescription(description, arch, label) { + const target = ARCHITECTURES[arch] + if (!target?.filePattern.test(description)) { + throw new Error(`${label} is not Linux ${arch}: ${description}`) + } +} + +export function assertUpdateMetadata(source, appImageName, arch) { + if (!source.includes(`url: ${appImageName}`)) { + throw new Error(`Linux ${arch} update metadata does not reference ${appImageName}`) + } + const opposite = arch === 'arm64' ? /-linux-x86_64\.AppImage/u : /-linux-arm64\.AppImage/u + if (opposite.test(source)) { + throw new Error(`Linux ${arch} update metadata references the opposite architecture`) + } +} + +async function regularFile(path, label) { + const details = await stat(path) + if (!details.isFile() || details.size <= 0) throw new Error(`${label} is not a non-empty file: ${path}`) + return path +} + +async function collectNativeModules(root) { + const modules = [] + const visit = async (directory) => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + if (entry.isDirectory()) await visit(path) + else if (entry.isFile() && entry.name.endsWith('.node')) modules.push(path) + } + } + await visit(root) + return modules +} + +function normalizedModulePath(path) { + return path.replaceAll('\\', '/') +} + +function targetNativeModuleGroups(arch) { + return [ + { + label: 'better-sqlite3', + pattern: /\/node_modules\/better-sqlite3\/build\/Release\/better_sqlite3\.node$/u + }, + { + label: 'node-pty', + pattern: /\/node_modules\/node-pty\/build\/Release\/pty\.node$/u + }, + { + label: '@napi-rs/canvas', + pattern: new RegExp(`/node_modules/@napi-rs/canvas-linux-${arch}-gnu/[^/]+\\.node$`, 'u') + }, + { + label: 'sharp', + pattern: new RegExp(`/node_modules/@img/sharp-linux-${arch}/lib/[^/]+\\.node$`, 'u') + }, + { + label: 'keytar', + pattern: new RegExp(`/node_modules/@github/keytar/prebuilds/linux-${arch}/keytar\\.node$`, 'u') + }, + ...(arch === 'x64' + ? [{ + label: '@computer-use/libnut-linux', + pattern: /\/node_modules\/@computer-use\/libnut-linux\/build\/Release\/libnut\.node$/u + }] + : []) + ] +} + +export function selectTargetNativeModules(paths, arch) { + if (!ARCHITECTURES[arch]) throw new Error(`Unsupported Linux package architecture: ${arch}`) + const modules = paths.map((path) => ({ path, normalized: normalizedModulePath(path) })) + if (arch === 'arm64') { + const incompatible = modules.find(({ normalized }) => ( + /\/node_modules\/@computer-use\/libnut-linux\/.*\.node$/u.test(normalized) + )) + if (incompatible) { + throw new Error(`Linux ARM64 package contains the upstream x64-only libnut binding: ${incompatible.path}`) + } + } + + const selected = [] + for (const group of targetNativeModuleGroups(arch)) { + const matches = modules.filter(({ normalized }) => group.pattern.test(normalized)) + if (matches.length === 0) { + throw new Error(`Packaged Linux ${arch} application is missing required ${group.label} native module`) + } + selected.push(...matches.map(({ path }) => path)) + } + return [...new Set(selected)] +} + +function fileDescription(path) { + return execFileSync('file', ['-b', path], { encoding: 'utf8' }).trim() +} + +export async function verifyLinuxPackageArchitecture({ distDirectory, version, arch }) { + const root = resolve(distDirectory) + const names = linuxPackageNames(version, arch) + const appImage = await regularFile(join(root, names.appImage), 'AppImage') + const deb = await regularFile(join(root, names.deb), 'deb package') + const update = await regularFile(join(root, names.update), 'update metadata') + const unpacked = join(root, names.unpacked) + const electron = await regularFile(join(unpacked, 'kun-gui.electron-bin'), 'Electron executable') + const resources = join(unpacked, 'resources') + const officeCli = await regularFile(join(resources, 'officecli', 'officecli'), 'OfficeCLI executable') + const whisper = await regularFile( + join(resources, 'whisper', `linux-${arch}`, 'whisper-cli'), + 'Whisper executable' + ) + const nativeModules = await collectNativeModules(join(resources, 'app.asar.unpacked')) + if (nativeModules.length === 0) throw new Error('Packaged Linux application contains no native modules') + const selectedNativeModules = selectTargetNativeModules(nativeModules, arch) + + for (const [path, label] of [ + [appImage, 'AppImage runtime'], + [electron, 'Electron executable'], + [officeCli, 'OfficeCLI executable'], + [whisper, 'Whisper executable'], + ...selectedNativeModules.map((path) => [path, `native module ${basename(path)}`]) + ]) { + assertArchitectureDescription(fileDescription(path), arch, label) + } + const debArch = execFileSync('dpkg-deb', ['-f', deb, 'Architecture'], { encoding: 'utf8' }).trim() + if (debArch !== ARCHITECTURES[arch].deb) { + throw new Error(`deb package architecture is ${debArch}, expected ${ARCHITECTURES[arch].deb}`) + } + assertUpdateMetadata(await readFile(update, 'utf8'), names.appImage, arch) + const selectedOfficeCli = JSON.parse(await readFile(join(resources, 'officecli', 'selected.json'), 'utf8')) + if (selectedOfficeCli?.platform !== 'linux' || selectedOfficeCli?.arch !== arch) { + throw new Error(`OfficeCLI selected target does not match linux-${arch}`) + } + return { appImage, deb, update, nativeModuleCount: selectedNativeModules.length } +} + +function parseArgs(argv) { + const flags = new Map() + for (let index = 0; index < argv.length; index += 2) { + const name = argv[index] + const value = argv[index + 1] + if (!name?.startsWith('--') || !value) throw new Error(`Invalid argument near ${name ?? '(end)'}`) + flags.set(name.slice(2), value) + } + const version = flags.get('version') + const arch = flags.get('arch') + if (!version || !arch) throw new Error('Usage: verify-linux-package-architecture --version --arch x64|arm64 [--dist dist]') + return { version, arch, distDirectory: flags.get('dist') ?? 'dist' } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + verifyLinuxPackageArchitecture(parseArgs(process.argv.slice(2))) + .then((result) => process.stdout.write(`${JSON.stringify(result)}\n`)) + .catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + }) +} diff --git a/scripts/verify-linux-package-architecture.test.mjs b/scripts/verify-linux-package-architecture.test.mjs new file mode 100644 index 000000000..aa0864f46 --- /dev/null +++ b/scripts/verify-linux-package-architecture.test.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + assertArchitectureDescription, + assertUpdateMetadata, + linuxPackageNames, + selectTargetNativeModules +} from './verify-linux-package-architecture.mjs' + +function targetModules(arch) { + return [ + '/app/node_modules/better-sqlite3/build/Release/better_sqlite3.node', + '/app/node_modules/node-pty/build/Release/pty.node', + `/app/node_modules/@napi-rs/canvas-linux-${arch}-gnu/skia.linux-${arch}-gnu.node`, + `/app/node_modules/@img/sharp-linux-${arch}/lib/sharp-linux-${arch}.node`, + `/app/kun/node_modules/@github/keytar/prebuilds/linux-${arch}/keytar.node`, + ...(arch === 'x64' + ? ['/app/node_modules/@computer-use/libnut-linux/build/Release/libnut.node'] + : []) + ] +} + +test('maps canonical x64 and ARM64 Linux release artifacts', () => { + assert.deepEqual(linuxPackageNames('1.2.3', 'x64'), { + appImage: 'Kun-1.2.3-linux-x86_64.AppImage', + deb: 'Kun-1.2.3-linux-amd64.deb', + update: 'latest-linux.yml', + unpacked: 'linux-unpacked' + }) + assert.deepEqual(linuxPackageNames('1.2.3', 'arm64'), { + appImage: 'Kun-1.2.3-linux-arm64.AppImage', + deb: 'Kun-1.2.3-linux-arm64.deb', + update: 'latest-linux-arm64.yml', + unpacked: 'linux-arm64-unpacked' + }) +}) + +test('accepts only matching native architecture descriptions', () => { + assert.doesNotThrow(() => assertArchitectureDescription('ELF 64-bit, ARM aarch64', 'arm64', 'fixture')) + assert.doesNotThrow(() => assertArchitectureDescription('ELF 64-bit, x86-64', 'x64', 'fixture')) + assert.throws( + () => assertArchitectureDescription('ELF 64-bit, x86-64', 'arm64', 'fixture'), + /is not Linux arm64/ + ) +}) + +test('rejects cross-architecture Linux update metadata', () => { + assert.doesNotThrow(() => assertUpdateMetadata( + 'files:\n - url: Kun-1.2.3-linux-arm64.AppImage\n', + 'Kun-1.2.3-linux-arm64.AppImage', + 'arm64' + )) + assert.throws(() => assertUpdateMetadata( + 'files:\n - url: Kun-1.2.3-linux-x86_64.AppImage\n', + 'Kun-1.2.3-linux-arm64.AppImage', + 'arm64' + ), /does not reference/) +}) + +test('selects native modules used by the target Linux runtime', () => { + const armModules = targetModules('arm64') + const foreignOptionalPrebuild = '/app/node_modules/node-pty/prebuilds/win32-x64/pty.node' + + assert.deepEqual( + selectTargetNativeModules([...armModules, foreignOptionalPrebuild], 'arm64'), + armModules + ) + assert.deepEqual(selectTargetNativeModules(targetModules('x64'), 'x64'), targetModules('x64')) +}) + +test('rejects the upstream x64-only libnut binding in an ARM64 package', () => { + assert.throws(() => selectTargetNativeModules([ + ...targetModules('arm64'), + '/app/node_modules/@computer-use/libnut-linux/build/Release/libnut.node' + ], 'arm64'), /contains the upstream x64-only libnut binding/) +}) + +test('fails when a target-selected native module is missing', () => { + assert.throws( + () => selectTargetNativeModules( + targetModules('arm64').filter((path) => !path.includes('better-sqlite3')), + 'arm64' + ), + /missing required better-sqlite3 native module/ + ) +}) diff --git a/src/main/gui-updater-support.ts b/src/main/gui-updater-support.ts index 23c69894a..61723bea5 100644 --- a/src/main/gui-updater-support.ts +++ b/src/main/gui-updater-support.ts @@ -298,9 +298,12 @@ export function isVersionGreater(latest: string, current: string): boolean { return false } -export function platformManifestName(): string { - if (process.platform === 'darwin') return 'latest-mac.yml' - if (process.platform === 'linux') return 'latest-linux.yml' +export function platformManifestName( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch +): string { + if (platform === 'darwin') return 'latest-mac.yml' + if (platform === 'linux') return arch === 'arm64' ? 'latest-linux-arm64.yml' : 'latest-linux.yml' return 'latest.yml' } diff --git a/src/main/gui-updater.test.ts b/src/main/gui-updater.test.ts index 55a4a710e..b6ba3f2c8 100644 --- a/src/main/gui-updater.test.ts +++ b/src/main/gui-updater.test.ts @@ -106,6 +106,12 @@ function platformManifestName(): string { } describe('checkGuiUpdate feed URL', () => { + it('uses architecture-specific Linux update metadata', async () => { + const { platformManifestName: manifestName } = await import('./gui-updater-support') + expect(manifestName('linux', 'x64')).toBe('latest-linux.yml') + expect(manifestName('linux', 'arm64')).toBe('latest-linux-arm64.yml') + }) + it('prefers the kun-agent update feed when metadata is reachable', async () => { process.env.DEEPSEEK_GUI_ALLOW_UNSIGNED_UPDATES = '1' const fetchMock = vi.fn().mockResolvedValue({ ok: true }) diff --git a/src/main/main-migrations.ts b/src/main/main-migrations.ts index 85418849e..7f1abcffd 100644 --- a/src/main/main-migrations.ts +++ b/src/main/main-migrations.ts @@ -34,7 +34,7 @@ import { ManagerRevisionedDocumentClient, readManagerRuntime, requestManagerJson, - resolveServiceManager, + resolveServiceManagerForMigration, type ServiceManagerConnection } from '../../kun/src/manager/manager-client.js' import { @@ -214,7 +214,8 @@ function managerProcessIsAlive(pid: number): boolean { } async function drainCanonicalRuntimeMigrationWriters(): Promise { - const manager = await resolveServiceManager(defaultKunControlDir(), fetch) + const controlDir = defaultKunControlDir() + const manager = await resolveServiceManagerForMigration(controlDir, fetch) if (manager) { await interruptStorageRelocationWork(manager) await Promise.all((['production', 'development'] as const).map((runtimeFlavor) => @@ -222,7 +223,7 @@ async function drainCanonicalRuntimeMigrationWriters(): Promise { )) await shutdownServiceManagerAndWait(manager) } else { - const unresolved = await readManagerDiscovery(defaultKunControlDir()).catch(() => null) + const unresolved = await readManagerDiscovery(controlDir).catch(() => null) if (unresolved && managerProcessIsAlive(unresolved.pid)) { throw new Error( `active_writer: Kun Service Manager ${unresolved.pid} is alive but could not be ` + diff --git a/src/main/packaging-config.test.ts b/src/main/packaging-config.test.ts index d7f570c59..a2170a7c5 100644 --- a/src/main/packaging-config.test.ts +++ b/src/main/packaging-config.test.ts @@ -268,6 +268,17 @@ describe('electron-builder Kun packaging', () => { ])) }) + it('excludes the upstream x64-only libnut binary from Linux ARM64 packages', () => { + const unsupportedLibnutPattern = '!**/node_modules/@computer-use/libnut-linux/**/*' + const armConfig = loadBuilderConfigWithEnv({ KUN_LINUX_BUILD_ARCH: 'arm64' }) + const x64Config = loadBuilderConfigWithEnv({ KUN_LINUX_BUILD_ARCH: 'x64' }) + + expect(armConfig.files).toContain(unsupportedLibnutPattern) + expect(x64Config.files).not.toContain(unsupportedLibnutPattern) + expect(() => loadBuilderConfigWithEnv({ KUN_LINUX_BUILD_ARCH: 'ia32' })) + .toThrow(/KUN_LINUX_BUILD_ARCH must be "x64" or "arm64"/) + }) + it('ships third-party notices with packaged applications', () => { expect(builderConfig.extraResources).toEqual(expect.arrayContaining([{ from: 'THIRD_PARTY_NOTICES.md', @@ -306,6 +317,7 @@ describe('electron-builder Kun packaging', () => { expect(Object.keys(manifest.assets).sort()).toEqual([ 'darwin-arm64', 'darwin-x64', + 'linux-arm64', 'linux-x64', 'win32-x64' ]) diff --git a/src/renderer/src/components/InitialSetupDialog.test.ts b/src/renderer/src/components/InitialSetupDialog.test.ts index 7fbdb817a..1eba3028d 100644 --- a/src/renderer/src/components/InitialSetupDialog.test.ts +++ b/src/renderer/src/components/InitialSetupDialog.test.ts @@ -358,4 +358,27 @@ describe('InitialSetupDialog completion flow', () => { ))).toBe(true) expect(isUnreadableCredentialKeyError(new Error('Kun runtime is offline'))).toBe(false) }) + + it('preserves unreadable credential identity from an HTTP 0 registry failure', async () => { + const request = vi.fn(async () => ({ + ok: false, + status: 0, + body: JSON.stringify({ + code: 'credential_key_unreadable', + message: 'existing DPAPI-protected OAuth key could not be decrypted' + }) + })) + const deepseek = defaultModelProviderSettings().providers[0]! + + const result = commitInitialSetupRegistryCredentials({ + deepseek: { apiKey: 'new-key', baseUrl: 'https://api.deepseek.com' } + }, { + profiles: [deepseek], + selectedProviderId: deepseek.id, + selectedModel: deepseek.models[0]! + }, request) + + await expect(result).rejects.toSatisfy(isUnreadableCredentialKeyError) + await expect(result).rejects.not.toThrow('Shared model connection request failed (HTTP 0)') + }) }) diff --git a/src/renderer/src/components/InitialSetupDialog.tsx b/src/renderer/src/components/InitialSetupDialog.tsx index 5a01dad41..c53320690 100644 --- a/src/renderer/src/components/InitialSetupDialog.tsx +++ b/src/renderer/src/components/InitialSetupDialog.tsx @@ -92,6 +92,15 @@ export function InitialSetupDialog(): ReactElement { setForm(next) } + const reportSetupError = (setupError: unknown): void => { + if (isUnreadableCredentialKeyError(setupError)) { + setCredentialRecoveryRequired(true) + setError(t('firstRunCredentialRecoveryError')) + return + } + setError(setupError instanceof Error ? setupError.message : String(setupError)) + } + useEffect(() => { let cancelled = false void rendererRuntimeClient @@ -138,7 +147,7 @@ export function InitialSetupDialog(): ReactElement { probeRuntime, closeInitialSetup }).catch((e: unknown) => { - setError(e instanceof Error ? e.message : String(e)) + reportSetupError(e) }).finally(() => { setSaving(false) }) @@ -232,12 +241,7 @@ export function InitialSetupDialog(): ReactElement { fallbackRuntimeError: t('common:runtimeFetchFailed') }) } catch (e) { - if (isUnreadableCredentialKeyError(e)) { - setCredentialRecoveryRequired(true) - setError(t('firstRunCredentialRecoveryError')) - } else { - setError(e instanceof Error ? e.message : String(e)) - } + reportSetupError(e) } finally { setSaving(false) } diff --git a/src/renderer/src/components/initial-setup-dialog-support.ts b/src/renderer/src/components/initial-setup-dialog-support.ts index bfa261469..b798d24c5 100644 --- a/src/renderer/src/components/initial-setup-dialog-support.ts +++ b/src/renderer/src/components/initial-setup-dialog-support.ts @@ -145,6 +145,23 @@ function initialSetupModelConnectionResponse(body: string): InitialSetupModelCon return initialSetupModelConnectionsSnapshot(JSON.parse(body)) } +function initialSetupModelConnectionRequestError(response: { + status: number + body: string +}): Error { + try { + const parsed = JSON.parse(response.body) as { code?: unknown; message?: unknown } + const code = typeof parsed.code === 'string' ? parsed.code : '' + const message = typeof parsed.message === 'string' ? parsed.message : '' + if (code === UNREADABLE_CREDENTIAL_KEY_ERROR_CODE || message.includes(UNREADABLE_CREDENTIAL_KEY_ERROR_CODE)) { + return new Error(`${UNREADABLE_CREDENTIAL_KEY_ERROR_CODE}: ${message || 'protected credential key is unreadable'}`) + } + } catch { + // Preserve the existing status-only error for malformed or unrelated response bodies. + } + return new Error(`Shared model connection request failed (HTTP ${response.status})`) +} + export async function commitInitialSetupRegistryCredentials( drafts: InitialSetupDrafts, options: { @@ -169,7 +186,7 @@ export async function commitInitialSetupRegistryCredentials( async (operationToken) => { const listed = await request('/v1/model-connections', 'GET') if (!listed.ok) { - throw new Error(`Shared model connection request failed (HTTP ${listed.status})`) + throw initialSetupModelConnectionRequestError(listed) } let snapshot = initialSetupModelConnectionResponse(listed.body) if (!snapshot.providers.some((provider) => provider.id === providerId)) return @@ -181,7 +198,7 @@ export async function commitInitialSetupRegistryCredentials( ) if (fenced.ok) return if (fenced.status !== 409 || attempt === 1) { - throw new Error(`Shared model connection request failed (HTTP ${fenced.status})`) + throw initialSetupModelConnectionRequestError(fenced) } const conflict = JSON.parse(fenced.body) as { snapshot?: unknown } snapshot = initialSetupModelConnectionsSnapshot(conflict.snapshot) @@ -199,7 +216,7 @@ export async function commitInitialSetupRegistryCredentials( replacement.generation, async (credential, operationToken, isCurrent) => { const listed = await request('/v1/model-connections', 'GET') - if (!listed.ok) throw new Error(`Shared model connection request failed (HTTP ${listed.status})`) + if (!listed.ok) throw initialSetupModelConnectionRequestError(listed) let snapshot = initialSetupModelConnectionResponse(listed.body) for (let attempt = 0; attempt < 2; attempt += 1) { if (!isCurrent()) return snapshot @@ -211,7 +228,7 @@ export async function commitInitialSetupRegistryCredentials( JSON.stringify({ expectedRevision: snapshot.revision, operationToken }) ) if (!fenced.ok) { - throw new Error(`Shared model connection request failed (HTTP ${fenced.status})`) + throw initialSetupModelConnectionRequestError(fenced) } snapshot = initialSetupModelConnectionResponse(fenced.body) if (!isCurrent()) return snapshot @@ -260,13 +277,13 @@ export async function commitInitialSetupRegistryCredentials( } if (response.ok) return initialSetupModelConnectionResponse(response.body) if (response.status !== 409) { - throw new Error(`Shared model connection request failed (HTTP ${response.status})`) + throw initialSetupModelConnectionRequestError(response) } const conflict = JSON.parse(response.body) as { snapshot?: unknown } snapshot = initialSetupModelConnectionsSnapshot(conflict.snapshot) if (!isCurrent()) return snapshot if (attempt === 1) { - throw new Error(`Shared model connection request failed (HTTP ${response.status})`) + throw initialSetupModelConnectionRequestError(response) } } return snapshot @@ -275,7 +292,7 @@ export async function commitInitialSetupRegistryCredentials( } await enqueueSharedModelMutation(async () => { const listed = await request('/v1/model-connections', 'GET') - if (!listed.ok) throw new Error(`Shared model connection request failed (HTTP ${listed.status})`) + if (!listed.ok) throw initialSetupModelConnectionRequestError(listed) let snapshot = initialSetupModelConnectionResponse(listed.body) for (let attempt = 0; attempt < 2; attempt += 1) { const selected = snapshot.providers.find((provider) => provider.id === options.selectedProviderId) @@ -288,7 +305,7 @@ export async function commitInitialSetupRegistryCredentials( })) if (response.ok) return initialSetupModelConnectionResponse(response.body) if (response.status !== 409 || attempt === 1) { - throw new Error(`Shared model connection request failed (HTTP ${response.status})`) + throw initialSetupModelConnectionRequestError(response) } const conflict = JSON.parse(response.body) as { snapshot?: unknown } snapshot = initialSetupModelConnectionsSnapshot(conflict.snapshot)