diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f2c3877d..3296bf8b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,6 @@ name: Release on: - push: - tags: - - 'v*' workflow_dispatch: inputs: release_tag: @@ -20,13 +17,13 @@ permissions: contents: write concurrency: - group: release-${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || inputs.release_tag || github.ref }} + group: release-${{ inputs.release_tag || github.ref }} cancel-in-progress: false env: - IS_RELEASE_BUILD: ${{ startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' }} - RELEASE_TAG: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || inputs.release_tag }} - PUBLISH_RELEASE: ${{ startsWith(github.ref, 'refs/tags/v') || inputs.publish_release }} + IS_RELEASE_BUILD: ${{ github.event_name == 'workflow_dispatch' }} + RELEASE_TAG: ${{ inputs.release_tag }} + PUBLISH_RELEASE: ${{ inputs.publish_release }} jobs: build: @@ -81,8 +78,8 @@ jobs: - name: Build app env: NODE_OPTIONS: '--max-old-space-size=8192' - SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }} - SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }} + SENTRY_DSN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_DSN || '' }} + SENTRY_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_AUTH_TOKEN || '' }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} run: pnpm build @@ -102,8 +99,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - TAG="${GITHUB_REF#refs/tags/}" - TAG="${RELEASE_TAG:-$TAG}" + TAG="${RELEASE_TAG}" existing_is_draft="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft' 2>/dev/null || true)" if [ -n "$existing_is_draft" ]; then if [ "${PUBLISH_RELEASE}" != "true" ] && [ "$existing_is_draft" != "true" ]; then @@ -155,8 +151,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - TAG="${GITHUB_REF#refs/tags/}" - TAG="${RELEASE_TAG:-$TAG}" + TAG="${RELEASE_TAG}" existing_is_draft="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft' 2>/dev/null || true)" if [ -n "$existing_is_draft" ]; then if [ "${PUBLISH_RELEASE}" != "true" ] && [ "$existing_is_draft" != "true" ]; then @@ -184,8 +179,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - TAG="${GITHUB_REF#refs/tags/}" - TAG="${RELEASE_TAG:-$TAG}" + TAG="${RELEASE_TAG}" existing="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' 2>/dev/null || true)" missing=0 while IFS= read -r asset; do @@ -278,8 +272,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - TAG="${GITHUB_REF#refs/tags/}" - TAG="${RELEASE_TAG:-$TAG}" + TAG="${RELEASE_TAG}" for attempt in $(seq 1 12); do existing="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' 2>/dev/null || true)" all_found=1 @@ -377,8 +370,8 @@ jobs: - name: Build app (macOS ${{ matrix.arch }}) env: NODE_OPTIONS: '--max-old-space-size=8192' - SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }} - SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }} + SENTRY_DSN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_DSN || '' }} + SENTRY_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_AUTH_TOKEN || '' }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} run: pnpm build @@ -494,8 +487,8 @@ jobs: - name: Build app (Windows) env: NODE_OPTIONS: '--max-old-space-size=8192' - SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }} - SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }} + SENTRY_DSN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_DSN || '' }} + SENTRY_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_AUTH_TOKEN || '' }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} run: pnpm build @@ -614,8 +607,8 @@ jobs: - name: Build app (Linux) env: NODE_OPTIONS: '--max-old-space-size=8192' - SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }} - SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }} + SENTRY_DSN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_DSN || '' }} + SENTRY_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_AUTH_TOKEN || '' }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} run: pnpm build @@ -670,7 +663,7 @@ jobs: needs: [release-mac, release-win, release-linux] runs-on: ubuntu-latest timeout-minutes: 30 - if: ${{ startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' }} + if: ${{ github.event_name == 'workflow_dispatch' }} steps: - name: Upload stable download aliases @@ -794,7 +787,7 @@ jobs: gh release upload "${TAG}" latest.yml latest-linux.yml latest-mac.yml --repo "${REPO}" --clobber - name: Publish release - if: ${{ startsWith(github.ref, 'refs/tags/v') || inputs.publish_release }} + if: ${{ inputs.publish_release }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | diff --git a/src/features/codex-runtime-installer/main/infrastructure/CodexRuntimeInstallerService.ts b/src/features/codex-runtime-installer/main/infrastructure/CodexRuntimeInstallerService.ts index 7490f215..6bbf34ad 100644 --- a/src/features/codex-runtime-installer/main/infrastructure/CodexRuntimeInstallerService.ts +++ b/src/features/codex-runtime-installer/main/infrastructure/CodexRuntimeInstallerService.ts @@ -352,7 +352,11 @@ export function extractCodexRuntimePackageFilesFromTarball( ): CodexRuntimePackageFile[] { const tar = gunzipSync(tarball, { maxOutputLength: MAX_UNPACKED_BYTES }); const targetPrefix = `package/vendor/${vendorTarget}/`; - const targetBinaryName = `${targetPrefix}codex/${executableName}`; + const targetBinaryNames = new Set( + getCodexRuntimeBinaryRelativePathCandidates(executableName).map( + (relativePath) => `${targetPrefix}${relativePath}` + ) + ); const files: CodexRuntimePackageFile[] = []; let foundBinary = false; let offset = 0; @@ -383,7 +387,7 @@ export function extractCodexRuntimePackageFilesFromTarball( data: Buffer.from(tar.subarray(dataStart, dataEnd)), mode, }); - foundBinary = foundBinary || fullName === targetBinaryName; + foundBinary = foundBinary || targetBinaryNames.has(fullName); } } else if ( fullName.startsWith(targetPrefix) && @@ -398,11 +402,31 @@ export function extractCodexRuntimePackageFilesFromTarball( } if (!foundBinary) { - throw new Error(`Codex package did not contain ${targetBinaryName}`); + throw new Error( + `Codex package did not contain one of ${Array.from(targetBinaryNames).join(', ')}` + ); } return files; } +function getCodexRuntimeBinaryRelativePathCandidates(executableName: string): string[] { + return [`bin/${executableName}`, `codex/${executableName}`]; +} + +function resolveCodexRuntimeBinaryRelativePath( + files: readonly CodexRuntimePackageFile[], + executableName = getExecutableName() +): string { + const filePaths = new Set(files.map((file) => file.relativePath)); + const binaryPath = getCodexRuntimeBinaryRelativePathCandidates(executableName).find((candidate) => + filePaths.has(candidate) + ); + if (!binaryPath) { + throw new Error(`Extracted Codex package is missing ${executableName}`); + } + return binaryPath; +} + async function readCurrentManifest(): Promise { try { const raw = await fsp.readFile(getCurrentManifestPath(), 'utf8'); @@ -592,6 +616,7 @@ export class CodexRuntimeInstallerService implements CodexRuntimeInstallerPort { this.publishProgress({ phase: 'installing', detail: 'Extracting Codex runtime...' }); const files = extractCodexRuntimePackageFilesFromTarball(tarball, selected.vendorTarget); + const binaryRelativePath = resolveCodexRuntimeBinaryRelativePath(files); const runtimeRoot = getRuntimeRootPath(); tempDir = path.join(runtimeRoot, `installing-${process.pid}-${randomUUID()}`); const versionDir = path.join( @@ -600,14 +625,14 @@ export class CodexRuntimeInstallerService implements CodexRuntimeInstallerPort { platformMetadata.version!, selected.vendorTarget ); - const binaryPath = path.join(versionDir, 'codex', getExecutableName()); + const binaryPath = path.join(versionDir, binaryRelativePath); await fsp.rm(tempDir, { recursive: true, force: true }); await fsp.mkdir(tempDir, { recursive: true }); await writePackageFiles(tempDir, files); this.publishProgress({ phase: 'installing', detail: 'Verifying Codex binary...' }); - const tempBinaryPath = path.join(tempDir, 'codex', getExecutableName()); + const tempBinaryPath = path.join(tempDir, binaryRelativePath); const { stdout } = await execCli(tempBinaryPath, ['--version'], { timeout: VERSION_TIMEOUT_MS, windowsHide: true, diff --git a/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts b/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts index dada93fb..8ed11f66 100644 --- a/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts +++ b/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts @@ -233,7 +233,6 @@ async function probeFirstWorkingPathOpenCodeBinary( const shellEnv = await resolveInteractiveShellEnvBestEffort({ timeoutMs: options.shellEnvTimeoutMs ?? RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS, fallbackEnv: process.env, - background: false, }); const shellProbe = await probeFirstWorkingOpenCodeBinaryCandidate( collectPathOpenCodeBinaryCandidates([shellEnv], { diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 683c5178..4405fa72 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -220,6 +220,26 @@ function getNodeRuntimeCommandCandidates(): string[] { }); } +function shouldPreferShellNodeProbe(): boolean { + if (process.platform === 'win32') { + return false; + } + + const pathValue = process.env.PATH?.trim(); + if (!pathValue) { + return true; + } + + const minimalGuiPathEntries = new Set(['/usr/bin', '/bin', '/usr/sbin', '/sbin']); + const entries = pathValue + .split(path.delimiter) + .map((entry) => entry.trim()) + .filter(Boolean); + return ( + entries.length > 0 && entries.every((entry) => minimalGuiPathEntries.has(path.resolve(entry))) + ); +} + function mergePathValues(...values: (string | undefined)[]): string | undefined { const seen = new Set(); const merged: string[] = []; @@ -281,23 +301,9 @@ async function probeNodeRuntimePath( return { ok: false, error: lastError ?? 'no Node.js candidates were available' }; } -/** - * Find the real `node` binary path. In Electron, process.execPath is the - * Electron binary — NOT node — so we must resolve node separately. - * Uses the user's shell/enriched PATH so packaged GUI launches do not depend - * on the minimal Finder/Dock PATH. - */ -async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise { - if (_resolvedNodePath) return _resolvedNodePath; - - emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...'); - const fastProbe = await probeNodeRuntimePath(buildNodeResolveEnv({})); - if (fastProbe.ok) { - _resolvedNodePath = fastProbe.path; - emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...'); - return _resolvedNodePath; - } - +async function probeShellNodeRuntimePath( + options?: McpLaunchSpecResolveOptions +): Promise<{ ok: true; path: string } | { ok: false; error: unknown }> { let shellEnv: NodeJS.ProcessEnv = {}; try { shellEnv = await resolveInteractiveShellEnv({ @@ -310,8 +316,36 @@ async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise { + if (_resolvedNodePath) return _resolvedNodePath; + + emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...'); + if (shouldPreferShellNodeProbe()) { + const shellProbe = await probeShellNodeRuntimePath(options); + if (shellProbe.ok) { + _resolvedNodePath = shellProbe.path; + emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...'); + return _resolvedNodePath; + } + } + + const fastProbe = await probeNodeRuntimePath(buildNodeResolveEnv({})); + if (fastProbe.ok) { + _resolvedNodePath = fastProbe.path; + emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...'); + return _resolvedNodePath; + } + + const shellProbe = await probeShellNodeRuntimePath(options); if (shellProbe.ok) { _resolvedNodePath = shellProbe.path; emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...'); diff --git a/test/main/features/codex-runtime-installer/CodexRuntimeInstallerService.test.ts b/test/main/features/codex-runtime-installer/CodexRuntimeInstallerService.test.ts index 602f3463..c595dce1 100644 --- a/test/main/features/codex-runtime-installer/CodexRuntimeInstallerService.test.ts +++ b/test/main/features/codex-runtime-installer/CodexRuntimeInstallerService.test.ts @@ -278,6 +278,29 @@ describe('CodexRuntimeInstallerService package safety helpers', () => { ); }); + it('extracts the current Codex platform package layout', () => { + const tarball = createTarball([ + { name: 'package/vendor/x86_64-unknown-linux-musl/bin/codex', data: 'codex-binary' }, + { name: 'package/vendor/x86_64-unknown-linux-musl/codex-path/rg', data: 'rg-binary' }, + { name: 'package/vendor/x86_64-unknown-linux-musl/codex-resources/bwrap', data: 'bwrap' }, + ]); + + const files = extractCodexRuntimePackageFilesFromTarball( + tarball, + 'x86_64-unknown-linux-musl', + 'codex' + ); + + expect(files.map((file) => file.relativePath).sort((a, b) => a.localeCompare(b))).toEqual([ + 'bin/codex', + 'codex-path/rg', + 'codex-resources/bwrap', + ]); + expect(files.find((file) => file.relativePath === 'bin/codex')?.data.toString()).toBe( + 'codex-binary' + ); + }); + it('rejects tar path traversal before extraction', () => { const tarball = createTarball([ { name: '../codex', data: 'unsafe' },