fix(ci): stabilize runtime release checks
This commit is contained in:
parent
c45e860d69
commit
ad984a859b
5 changed files with 124 additions and 50 deletions
43
.github/workflows/release.yml
vendored
43
.github/workflows/release.yml
vendored
|
|
@ -1,9 +1,6 @@
|
||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
release_tag:
|
release_tag:
|
||||||
|
|
@ -20,13 +17,13 @@ permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
concurrency:
|
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
|
cancel-in-progress: false
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IS_RELEASE_BUILD: ${{ startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' }}
|
IS_RELEASE_BUILD: ${{ github.event_name == 'workflow_dispatch' }}
|
||||||
RELEASE_TAG: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || inputs.release_tag }}
|
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||||
PUBLISH_RELEASE: ${{ startsWith(github.ref, 'refs/tags/v') || inputs.publish_release }}
|
PUBLISH_RELEASE: ${{ inputs.publish_release }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
@ -81,8 +78,8 @@ jobs:
|
||||||
- name: Build app
|
- name: Build app
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||||
SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }}
|
SENTRY_DSN: ${{ 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_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_AUTH_TOKEN || '' }}
|
||||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
@ -102,8 +99,7 @@ jobs:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TAG="${GITHUB_REF#refs/tags/}"
|
TAG="${RELEASE_TAG}"
|
||||||
TAG="${RELEASE_TAG:-$TAG}"
|
|
||||||
existing_is_draft="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft' 2>/dev/null || true)"
|
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 [ -n "$existing_is_draft" ]; then
|
||||||
if [ "${PUBLISH_RELEASE}" != "true" ] && [ "$existing_is_draft" != "true" ]; then
|
if [ "${PUBLISH_RELEASE}" != "true" ] && [ "$existing_is_draft" != "true" ]; then
|
||||||
|
|
@ -155,8 +151,7 @@ jobs:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TAG="${GITHUB_REF#refs/tags/}"
|
TAG="${RELEASE_TAG}"
|
||||||
TAG="${RELEASE_TAG:-$TAG}"
|
|
||||||
existing_is_draft="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft' 2>/dev/null || true)"
|
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 [ -n "$existing_is_draft" ]; then
|
||||||
if [ "${PUBLISH_RELEASE}" != "true" ] && [ "$existing_is_draft" != "true" ]; then
|
if [ "${PUBLISH_RELEASE}" != "true" ] && [ "$existing_is_draft" != "true" ]; then
|
||||||
|
|
@ -184,8 +179,7 @@ jobs:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TAG="${GITHUB_REF#refs/tags/}"
|
TAG="${RELEASE_TAG}"
|
||||||
TAG="${RELEASE_TAG:-$TAG}"
|
|
||||||
existing="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' 2>/dev/null || true)"
|
existing="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' 2>/dev/null || true)"
|
||||||
missing=0
|
missing=0
|
||||||
while IFS= read -r asset; do
|
while IFS= read -r asset; do
|
||||||
|
|
@ -278,8 +272,7 @@ jobs:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TAG="${GITHUB_REF#refs/tags/}"
|
TAG="${RELEASE_TAG}"
|
||||||
TAG="${RELEASE_TAG:-$TAG}"
|
|
||||||
for attempt in $(seq 1 12); do
|
for attempt in $(seq 1 12); do
|
||||||
existing="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' 2>/dev/null || true)"
|
existing="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' 2>/dev/null || true)"
|
||||||
all_found=1
|
all_found=1
|
||||||
|
|
@ -377,8 +370,8 @@ jobs:
|
||||||
- name: Build app (macOS ${{ matrix.arch }})
|
- name: Build app (macOS ${{ matrix.arch }})
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||||
SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }}
|
SENTRY_DSN: ${{ 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_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_AUTH_TOKEN || '' }}
|
||||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
@ -494,8 +487,8 @@ jobs:
|
||||||
- name: Build app (Windows)
|
- name: Build app (Windows)
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||||
SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }}
|
SENTRY_DSN: ${{ 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_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_AUTH_TOKEN || '' }}
|
||||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
@ -614,8 +607,8 @@ jobs:
|
||||||
- name: Build app (Linux)
|
- name: Build app (Linux)
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||||
SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }}
|
SENTRY_DSN: ${{ 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_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_AUTH_TOKEN || '' }}
|
||||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
@ -670,7 +663,7 @@ jobs:
|
||||||
needs: [release-mac, release-win, release-linux]
|
needs: [release-mac, release-win, release-linux]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
if: ${{ startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Upload stable download aliases
|
- 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
|
gh release upload "${TAG}" latest.yml latest-linux.yml latest-mac.yml --repo "${REPO}" --clobber
|
||||||
|
|
||||||
- name: Publish release
|
- name: Publish release
|
||||||
if: ${{ startsWith(github.ref, 'refs/tags/v') || inputs.publish_release }}
|
if: ${{ inputs.publish_release }}
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
|
|
@ -352,7 +352,11 @@ export function extractCodexRuntimePackageFilesFromTarball(
|
||||||
): CodexRuntimePackageFile[] {
|
): CodexRuntimePackageFile[] {
|
||||||
const tar = gunzipSync(tarball, { maxOutputLength: MAX_UNPACKED_BYTES });
|
const tar = gunzipSync(tarball, { maxOutputLength: MAX_UNPACKED_BYTES });
|
||||||
const targetPrefix = `package/vendor/${vendorTarget}/`;
|
const targetPrefix = `package/vendor/${vendorTarget}/`;
|
||||||
const targetBinaryName = `${targetPrefix}codex/${executableName}`;
|
const targetBinaryNames = new Set(
|
||||||
|
getCodexRuntimeBinaryRelativePathCandidates(executableName).map(
|
||||||
|
(relativePath) => `${targetPrefix}${relativePath}`
|
||||||
|
)
|
||||||
|
);
|
||||||
const files: CodexRuntimePackageFile[] = [];
|
const files: CodexRuntimePackageFile[] = [];
|
||||||
let foundBinary = false;
|
let foundBinary = false;
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
|
|
@ -383,7 +387,7 @@ export function extractCodexRuntimePackageFilesFromTarball(
|
||||||
data: Buffer.from(tar.subarray(dataStart, dataEnd)),
|
data: Buffer.from(tar.subarray(dataStart, dataEnd)),
|
||||||
mode,
|
mode,
|
||||||
});
|
});
|
||||||
foundBinary = foundBinary || fullName === targetBinaryName;
|
foundBinary = foundBinary || targetBinaryNames.has(fullName);
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
fullName.startsWith(targetPrefix) &&
|
fullName.startsWith(targetPrefix) &&
|
||||||
|
|
@ -398,11 +402,31 @@ export function extractCodexRuntimePackageFilesFromTarball(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!foundBinary) {
|
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;
|
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<CodexRuntimeManifest | null> {
|
async function readCurrentManifest(): Promise<CodexRuntimeManifest | null> {
|
||||||
try {
|
try {
|
||||||
const raw = await fsp.readFile(getCurrentManifestPath(), 'utf8');
|
const raw = await fsp.readFile(getCurrentManifestPath(), 'utf8');
|
||||||
|
|
@ -592,6 +616,7 @@ export class CodexRuntimeInstallerService implements CodexRuntimeInstallerPort {
|
||||||
|
|
||||||
this.publishProgress({ phase: 'installing', detail: 'Extracting Codex runtime...' });
|
this.publishProgress({ phase: 'installing', detail: 'Extracting Codex runtime...' });
|
||||||
const files = extractCodexRuntimePackageFilesFromTarball(tarball, selected.vendorTarget);
|
const files = extractCodexRuntimePackageFilesFromTarball(tarball, selected.vendorTarget);
|
||||||
|
const binaryRelativePath = resolveCodexRuntimeBinaryRelativePath(files);
|
||||||
const runtimeRoot = getRuntimeRootPath();
|
const runtimeRoot = getRuntimeRootPath();
|
||||||
tempDir = path.join(runtimeRoot, `installing-${process.pid}-${randomUUID()}`);
|
tempDir = path.join(runtimeRoot, `installing-${process.pid}-${randomUUID()}`);
|
||||||
const versionDir = path.join(
|
const versionDir = path.join(
|
||||||
|
|
@ -600,14 +625,14 @@ export class CodexRuntimeInstallerService implements CodexRuntimeInstallerPort {
|
||||||
platformMetadata.version!,
|
platformMetadata.version!,
|
||||||
selected.vendorTarget
|
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.rm(tempDir, { recursive: true, force: true });
|
||||||
await fsp.mkdir(tempDir, { recursive: true });
|
await fsp.mkdir(tempDir, { recursive: true });
|
||||||
await writePackageFiles(tempDir, files);
|
await writePackageFiles(tempDir, files);
|
||||||
|
|
||||||
this.publishProgress({ phase: 'installing', detail: 'Verifying Codex binary...' });
|
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'], {
|
const { stdout } = await execCli(tempBinaryPath, ['--version'], {
|
||||||
timeout: VERSION_TIMEOUT_MS,
|
timeout: VERSION_TIMEOUT_MS,
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
|
|
|
||||||
|
|
@ -233,7 +233,6 @@ async function probeFirstWorkingPathOpenCodeBinary(
|
||||||
const shellEnv = await resolveInteractiveShellEnvBestEffort({
|
const shellEnv = await resolveInteractiveShellEnvBestEffort({
|
||||||
timeoutMs: options.shellEnvTimeoutMs ?? RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS,
|
timeoutMs: options.shellEnvTimeoutMs ?? RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS,
|
||||||
fallbackEnv: process.env,
|
fallbackEnv: process.env,
|
||||||
background: false,
|
|
||||||
});
|
});
|
||||||
const shellProbe = await probeFirstWorkingOpenCodeBinaryCandidate(
|
const shellProbe = await probeFirstWorkingOpenCodeBinaryCandidate(
|
||||||
collectPathOpenCodeBinaryCandidates([shellEnv], {
|
collectPathOpenCodeBinaryCandidates([shellEnv], {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
function mergePathValues(...values: (string | undefined)[]): string | undefined {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const merged: string[] = [];
|
const merged: string[] = [];
|
||||||
|
|
@ -281,23 +301,9 @@ async function probeNodeRuntimePath(
|
||||||
return { ok: false, error: lastError ?? 'no Node.js candidates were available' };
|
return { ok: false, error: lastError ?? 'no Node.js candidates were available' };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function probeShellNodeRuntimePath(
|
||||||
* Find the real `node` binary path. In Electron, process.execPath is the
|
options?: McpLaunchSpecResolveOptions
|
||||||
* Electron binary — NOT node — so we must resolve node separately.
|
): Promise<{ ok: true; path: string } | { ok: false; error: unknown }> {
|
||||||
* 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<string> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
let shellEnv: NodeJS.ProcessEnv = {};
|
let shellEnv: NodeJS.ProcessEnv = {};
|
||||||
try {
|
try {
|
||||||
shellEnv = await resolveInteractiveShellEnv({
|
shellEnv = await resolveInteractiveShellEnv({
|
||||||
|
|
@ -310,8 +316,36 @@ async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<s
|
||||||
logger.warn(`Failed to resolve shell env before Node.js lookup: ${stringifyError(error)}`);
|
logger.warn(`Failed to resolve shell env before Node.js lookup: ${stringifyError(error)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = buildNodeResolveEnv(shellEnv);
|
return probeNodeRuntimePath(buildNodeResolveEnv(shellEnv));
|
||||||
const shellProbe = await probeNodeRuntimePath(env);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string> {
|
||||||
|
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) {
|
if (shellProbe.ok) {
|
||||||
_resolvedNodePath = shellProbe.path;
|
_resolvedNodePath = shellProbe.path;
|
||||||
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
|
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
it('rejects tar path traversal before extraction', () => {
|
||||||
const tarball = createTarball([
|
const tarball = createTarball([
|
||||||
{ name: '../codex', data: 'unsafe' },
|
{ name: '../codex', data: 'unsafe' },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue