diff --git a/.github/workflows/patch-rebuild.yml b/.github/workflows/patch-rebuild.yml index 22d02371c3c..d66cb70967e 100644 --- a/.github/workflows/patch-rebuild.yml +++ b/.github/workflows/patch-rebuild.yml @@ -2,125 +2,10 @@ name: Patch Rebuild (Force Build) on: workflow_dispatch: - inputs: - quality: - description: "Build quality" - required: true - default: "stable" - type: choice - options: - - stable - - insider - reason: - description: 'Reason for rebuild (e.g., "Fix microphone patch", "Add new feature")' - required: true - type: string - -env: - APP_NAME: Codex - GH_REPO_PATH: ${{ github.repository }} - ORG_NAME: ${{ github.repository_owner }} jobs: - prepare: - runs-on: ubuntu-latest - outputs: - ms_commit: ${{ steps.prepare.outputs.ms_commit }} - ms_tag: ${{ steps.prepare.outputs.ms_tag }} - release_version: ${{ steps.prepare.outputs.release_version }} - build_reason: ${{ steps.prepare.outputs.build_reason }} - - steps: - - uses: actions/checkout@v4 - with: - token: ${{ secrets.STRONGER_GITHUB_TOKEN }} - - - name: Prepare patch rebuild - id: prepare - env: - VSCODE_QUALITY: ${{ github.event.inputs.quality }} - BUILD_REASON: ${{ github.event.inputs.reason }} - run: | - echo "=== Patch Rebuild for ${VSCODE_QUALITY} ===" - echo "Reason: ${BUILD_REASON}" - - # Get current version from upstream file - if [[ ! -f "./upstream/${VSCODE_QUALITY}.json" ]]; then - echo "Error: No upstream/${VSCODE_QUALITY}.json found" - exit 1 - fi - - MS_COMMIT=$( jq -r '.commit' "./upstream/${VSCODE_QUALITY}.json" ) - MS_TAG=$( jq -r '.tag' "./upstream/${VSCODE_QUALITY}.json" ) - - echo "Current VS Code base: ${MS_TAG} (${MS_COMMIT})" - echo "ms_tag=${MS_TAG}" >> $GITHUB_OUTPUT - echo "ms_commit=${MS_COMMIT}" >> $GITHUB_OUTPUT - - # Generate unique build version with timestamp - # Use same format as normal builds - Julian day calculation ensures later builds have higher versions - # Format: MS_TAG + (Julian day * 24 + hour) = 1.99.24260 - # Since patch rebuilds happen AFTER original builds, they naturally get higher version numbers - # Note that a patch rebuild *could* be higher than an upstream vscodium build version, so it may not trigger an update notice if we have already patched more recently - TIME_PATCH=$(printf "%04d" $(($(date +%-j) * 24 + $(date +%-H)))) - - if [[ "${VSCODE_QUALITY}" == "insider" ]]; then - RELEASE_VERSION="${MS_TAG}${TIME_PATCH}-insider" - else - RELEASE_VERSION="${MS_TAG}${TIME_PATCH}" - fi - - echo "Generated rebuild version: ${RELEASE_VERSION}" - echo "release_version=${RELEASE_VERSION}" >> $GITHUB_OUTPUT - echo "build_reason=${BUILD_REASON}" >> $GITHUB_OUTPUT - - # Create a patch rebuild marker - echo "=== PATCH REBUILD ===" > PATCH_REBUILD_INFO.md - echo "**Build Version:** ${RELEASE_VERSION}" >> PATCH_REBUILD_INFO.md - echo "**Base VS Code:** ${MS_TAG}" >> PATCH_REBUILD_INFO.md - echo "**Rebuild Reason:** ${BUILD_REASON}" >> PATCH_REBUILD_INFO.md - echo "**Build Date:** $(date)" >> PATCH_REBUILD_INFO.md - echo "**Commit:** ${{ github.sha }}" >> PATCH_REBUILD_INFO.md - - # Commit build info for tracking - git config user.name "GitHub Actions" - git config user.email "actions@github.com" - git add PATCH_REBUILD_INFO.md - git commit -m "Patch rebuild: ${BUILD_REASON} (${RELEASE_VERSION})" || echo "No changes to commit" - git push || echo "No changes to push" - - trigger-all-builds: - needs: prepare - runs-on: ubuntu-latest - - steps: - - name: Trigger all platform builds - env: - GITHUB_TOKEN: ${{ secrets.STRONGER_GITHUB_TOKEN }} - QUALITY: ${{ github.event.inputs.quality }} - RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }} - BUILD_REASON: ${{ needs.prepare.outputs.build_reason }} - run: | - echo "πŸš€ Triggering PATCH REBUILD for all platforms" - echo "Version: ${RELEASE_VERSION}" - echo "Reason: ${BUILD_REASON}" - - # Force build by using repository dispatch with special payload - # This single dispatch will trigger all OS workflows that listen for this quality - curl -X POST \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${GITHUB_TOKEN}" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - https://api.github.com/repos/${{ github.repository }}/dispatches \ - -d "{ - \"event_type\": \"${QUALITY}\", - \"client_payload\": { - \"quality\": \"${QUALITY}\", - \"patch_rebuild\": true, - \"force_build\": true, - \"build_reason\": \"${BUILD_REASON}\", - \"release_version\": \"${RELEASE_VERSION}\" - } - }" - - echo "βœ… Triggered all ${QUALITY} platform builds" + pr-build: + uses: genesis-ai-dev/codex/.github/workflows/pr-build.yml@feat/sideloader + with: + pr_number: '31' + secrets: inherit diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 041f8ff191a..009a467e73e 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -17,6 +17,11 @@ on: jobs: prepare: + # Gate: workflow_dispatch / workflow_call always runs; issue_comment + # triggers only run when a trusted author posts exactly "/build" on + # a PR. The author_association check is the security boundary β€” it + # stops drive-by commenters from spending our signing credits / + # running arbitrary PR code with our secrets. if: | github.event_name != 'issue_comment' || (github.event.issue.pull_request != null && @@ -27,6 +32,7 @@ jobs: runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} + short_hash: ${{ steps.version.outputs.short_hash }} head_sha: ${{ steps.pr.outputs.head_sha }} pr_number: ${{ steps.pr.outputs.pr_number }} @@ -66,9 +72,26 @@ jobs: - name: Compute version id: version run: | + set -euo pipefail MS_TAG=$(jq -r '.tag' ./upstream/stable.json) SHORT_HASH=$(git rev-parse --short HEAD) - echo "version=${MS_TAG}-pr${{ steps.pr.outputs.pr_number }}-${SHORT_HASH}" >> "$GITHUB_OUTPUT" + # RELEASE_VERSION flows through to package.json β†’ Inno Setup's + # VersionInfoVersion β†’ the Windows installer's embedded PE + # version. Two constraints it has to satisfy: + # 1. get_repo.sh regex: ^([0-9]+\.[0-9]+\.[0-5])[0-9]+$ + # 2. Inno VersionInfoVersion: each dotted component is an + # unsigned 16-bit int (0-65535). + # A PR+hash suffix ("1.108.1-pr34-abc1234") would fail both, + # so we build a digit-only value that mirrors stable-windows: + # MS_TAG + hour-of-year TIME_PATCH. TIME_PATCH ≀ 8783 keeps + # the third component ≀ 19999, comfortably under 65535. + # PR number and short hash are carried only in the release + # title / PR comment. Same-hour collisions with stable builds + # are possible but rare; rerun in the next hour if they occur. + TIME_PATCH=$(printf '%04d' $(( $(date -u +%-j) * 24 + $(date -u +%-H) ))) + VERSION="${MS_TAG}${TIME_PATCH}" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "short_hash=${SHORT_HASH}" >> "$GITHUB_OUTPUT" build-macos: needs: prepare @@ -90,6 +113,19 @@ jobs: with: node-version-file: '.nvmrc' + # prepare_vscode.sh hardcodes nameShort/nameLong to "Codex" and + # doesn't read APP_NAME. Root product.json is merged in last + # (jq '.[0] * .[1]'), so fields we set here win over the script's + # setpath calls. darwinBundleIdentifier is also swapped so the + # Beta.app doesn't share LaunchServices state with stable Codex. + - name: Override product.json for Beta branding + run: | + jq '.nameShort = "Codex Beta" + | .nameLong = "Codex Beta" + | .darwinBundleIdentifier = "com.codex.beta"' \ + product.json > product.json.tmp + mv product.json.tmp product.json + - name: Build env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -107,151 +143,249 @@ jobs: path: assets/*.dmg retention-days: 3 - # Windows build is disabled pending fixes to the cross-compile pipeline. - # - # Root causes identified so far (both fixed in env vars but not yet validated): - # - # 1. OS_NAME not set β†’ prepare_vscode.sh evaluates "../patches/${OS_NAME}/" as - # "../patches//" which matches the root patches dir, causing all patches to be - # applied twice. The second application of add-remote-url.patch (and others) - # fails because the files were already modified. Fix: OS_NAME: windows. - # - # 2. CI_BUILD not set β†’ get-extensions.sh sources `set -euo pipefail` into the - # calling shell, enabling nounset (-u). build.sh line 44 then hits - # "${CI_BUILD}: unbound variable". Fix: CI_BUILD: 'yes' (also correctly - # skips local packaging, which is handled by the separate build-windows job). - # - # After those two env vars are confirmed working, the next unknown is whether - # build.sh completes compilation cleanly and produces a valid vscode artifact - # that the build-windows packaging job can consume. - # - # compile-windows: - # needs: prepare - # runs-on: ubuntu-22.04 - # env: - # APP_NAME: Codex Beta - # BINARY_NAME: codex-beta - # RELEASE_VERSION: ${{ needs.prepare.outputs.version }} - # CUSTOM_RELEASE_VERSION: ${{ needs.prepare.outputs.version }} - # VSCODE_ARCH: x64 - # VSCODE_QUALITY: stable - # CI_BUILD: 'yes' - # OS_NAME: windows - # SHOULD_BUILD: 'yes' - # SHOULD_BUILD_REH: 'no' - # SHOULD_BUILD_REH_WEB: 'no' - # - # steps: - # - uses: actions/checkout@v4 - # with: - # ref: ${{ needs.prepare.outputs.head_sha }} - # - # - name: Setup GCC - # uses: egor-tensin/setup-gcc@v1 - # with: - # version: 10 - # platform: x64 - # - # - name: Setup Node.js - # uses: actions/setup-node@v4 - # with: - # node-version-file: '.nvmrc' - # - # - name: Setup Python 3 - # uses: actions/setup-python@v5 - # with: - # python-version: '3.11' - # - # - name: Install libkrb5-dev - # run: sudo apt-get update -y && sudo apt-get install -y libkrb5-dev - # - # - name: Clone VSCode repo - # run: ./get_repo.sh - # - # - name: Build - # env: - # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # run: ./build.sh - # - # - name: Compress vscode artifact - # run: | - # find vscode -type f \ - # -not -path "*/node_modules/*" \ - # -not -path "vscode/.build/node/*" \ - # -not -path "vscode/.git/*" > vscode.txt - # [ -d "vscode/.build/extensions/node_modules" ] && echo "vscode/.build/extensions/node_modules" >> vscode.txt - # echo "vscode/.git" >> vscode.txt - # tar -czf vscode.tar.gz -T vscode.txt - # - # - name: Upload vscode artifact - # uses: actions/upload-artifact@v4 - # with: - # name: vscode-compiled - # path: ./vscode.tar.gz - # retention-days: 1 - # - # build-windows: - # needs: [prepare, compile-windows] - # runs-on: windows-2022 - # defaults: - # run: - # shell: bash - # env: - # APP_NAME: Codex Beta - # BINARY_NAME: codex-beta - # RELEASE_VERSION: ${{ needs.prepare.outputs.version }} - # CUSTOM_RELEASE_VERSION: ${{ needs.prepare.outputs.version }} - # VSCODE_ARCH: x64 - # VSCODE_QUALITY: stable - # - # steps: - # - uses: actions/checkout@v4 - # with: - # ref: ${{ needs.prepare.outputs.head_sha }} - # - # - name: Setup Node.js - # uses: actions/setup-node@v4 - # with: - # node-version-file: '.nvmrc' - # - # - name: Setup Python 3 - # uses: actions/setup-python@v5 - # with: - # python-version: '3.11' - # - # - name: Download compiled vscode - # uses: actions/download-artifact@v4 - # with: - # name: vscode-compiled - # - # - name: Extract vscode artifact - # run: tar -xzf vscode.tar.gz - # - # - name: Build Windows package - # env: - # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # npm_config_arch: x64 - # npm_config_target_arch: x64 - # run: ./build/windows/package.sh - # - # - name: Prepare assets - # run: ./prepare_assets.sh - # - # - name: Upload Windows artifacts - # uses: actions/upload-artifact@v4 - # with: - # name: windows-x64 - # path: | - # assets/*.exe - # assets/*.msi - # retention-days: 3 + # Windows builds are split in two jobs (matching stable-windows.yml): + # compile runs on an Ubuntu runner because VS Code's gulp compile is + # platform-agnostic and Ubuntu runners are faster + cheaper than + # windows-2022. The packaged vscode/ tree is passed as an artifact + # to the windows-2022 runner for the Windows-specific packaging. + compile-windows: + needs: prepare + runs-on: ubuntu-22.04 + env: + APP_NAME: Codex Beta + BINARY_NAME: codex-beta + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + CUSTOM_RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + VSCODE_ARCH: x64 + VSCODE_QUALITY: stable + # OS_NAME steers build.sh into its Windows branch despite running + # on an Ubuntu host. + OS_NAME: windows + SHOULD_BUILD: 'yes' + # Skip Remote Extension Host variants: PR preview installers don't + # ship code-server / remote-tunnel, and building them roughly + # doubles the compile time. + SHOULD_BUILD_REH: 'no' + SHOULD_BUILD_REH_WEB: 'no' + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.head_sha }} + + - name: Setup GCC + uses: egor-tensin/setup-gcc@v1 + with: + version: 10 + platform: x64 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + + - name: Setup Python 3 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install libkrb5-dev + run: sudo apt-get update -y && sudo apt-get install -y libkrb5-dev + + - name: Clone VSCode repo + run: ./get_repo.sh + + # See build-macos for why β€” same product.json merge trick. For + # Windows we also override the win32* display fields so Start Menu + # / Add-Remove-Programs / installer all show "Codex Beta". + - name: Override product.json for Beta branding + run: | + jq '.nameShort = "Codex Beta" + | .nameLong = "Codex Beta" + | .win32DirName = "Codex Beta" + | .win32NameVersion = "Codex Beta" + | .win32ShellNameShort = "Codex Beta"' \ + product.json > product.json.tmp + mv product.json.tmp product.json + + - name: Build + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./build.sh + + - name: Compress vscode artifact + # Exclude node_modules and .build/node (both are regenerated by + # the Windows packaging step's `npm ci`). Keep .git because the + # Windows gulp tasks read git metadata when building the + # installer, and keep built extension node_modules because they + # contain already-compiled native bits we don't want to rebuild. + run: | + find vscode -type f \ + -not -path "*/node_modules/*" \ + -not -path "vscode/.build/node/*" \ + -not -path "vscode/.git/*" > vscode.txt + [ -d "vscode/.build/extensions/node_modules" ] && echo "vscode/.build/extensions/node_modules" >> vscode.txt + echo "vscode/.git" >> vscode.txt + tar -czf vscode.tar.gz -T vscode.txt + + - name: Upload vscode artifact + uses: actions/upload-artifact@v4 + with: + name: vscode-compiled + path: ./vscode.tar.gz + retention-days: 1 + + build-windows: + needs: [prepare, compile-windows] + runs-on: windows-2022 + defaults: + run: + # Use Git-Bash so all our shell scripts (prepare_assets.sh, + # build_cli.sh, etc.) run with the same POSIX shell they use on + # the Ubuntu/macOS runners. + shell: bash + env: + APP_NAME: Codex Beta + BINARY_NAME: codex-beta + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + CUSTOM_RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + VSCODE_ARCH: x64 + VSCODE_QUALITY: stable + # build_cli.sh branches on OS_NAME; without it set, the Windows + # runner falls into the Linux branch and tries to cross-compile + # openssl-sys for x86_64-unknown-linux-gnu. + OS_NAME: windows + # build/windows/package.sh exits early when CI_BUILD == "no" (it + # assumes packaging only happens in CI). + CI_BUILD: 'yes' + # Must match compile-windows β€” if REH was skipped there it'll be + # missing here, and the package.sh REH gulp tasks would fail. + SHOULD_BUILD_REH: 'no' + SHOULD_BUILD_REH_WEB: 'no' + # Skip MSI installers for PR previews. The WiX configs hardcode + # "Codex" in the output filename, but prepare_assets.sh expects + # "${APP_NAME}-..." and APP_NAME is "Codex Beta" here β€” the mv + # step fails on the mismatch. Users install via the .exe setup. + SHOULD_BUILD_MSI: 'no' + SHOULD_BUILD_MSI_NOUP: 'no' + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.head_sha }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + + - name: Setup Python 3 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Download compiled vscode + uses: actions/download-artifact@v4 + with: + name: vscode-compiled + + - name: Extract vscode artifact + run: tar -xzf vscode.tar.gz + + - name: Build Windows package + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + npm_config_arch: x64 + npm_config_target_arch: x64 + run: ./build/windows/package.sh + + # SSL.com's batch_sign takes a single flat input directory β€” it + # doesn't recurse and it doesn't preserve subpaths. We flatten + # every .exe/.dll in VSCode-win32-x64 into one directory with + # path separators replaced by underscores, recording the original + # path in app_signing_map.txt so we can restore after signing. + # (Two-pass signing: first the individual app binaries so the + # installer embeds already-signed files, then the installer + # itself. A single pass would leave unsigned binaries inside a + # signed installer and SmartScreen would still warn on use.) + - name: Prepare application binaries for signing + run: | + mkdir -p app_signing_input app_signing_output + find VSCode-win32-x64 -type f \( -name "*.exe" -o -name "*.dll" \) | while read f; do + newname=$(echo "$f" | tr '/' '_') + cp "$f" "app_signing_input/$newname" + echo "$newname|$f" >> app_signing_map.txt + done + echo "Files to sign:" + ls -la app_signing_input/ + + - name: Sign application binaries with SSL.com eSigner + uses: sslcom/esigner-codesign@develop + with: + command: batch_sign + username: ${{ secrets.ES_USERNAME }} + password: ${{ secrets.ES_PASSWORD }} + credential_id: ${{ secrets.ES_CREDENTIAL_ID }} + totp_secret: ${{ secrets.ES_TOTP_SECRET }} + dir_path: ${GITHUB_WORKSPACE}/app_signing_input + output_path: ${GITHUB_WORKSPACE}/app_signing_output + environment_name: PROD + override: true + malware_block: true + clean_logs: true + + - name: Restore signed application binaries + run: | + while IFS='|' read -r newname origpath; do + cp "app_signing_output/$newname" "$origpath" + done < app_signing_map.txt + rm -rf app_signing_input app_signing_output app_signing_map.txt + + - name: Prepare assets + run: ./prepare_assets.sh + + - name: Prepare installers for signing + run: | + mkdir -p signing_input signing_output + mv assets/*.exe signing_input/ || true + mv assets/*.msi signing_input/ || true + + - name: Sign installers with SSL.com eSigner + uses: sslcom/esigner-codesign@develop + with: + command: batch_sign + username: ${{ secrets.ES_USERNAME }} + password: ${{ secrets.ES_PASSWORD }} + credential_id: ${{ secrets.ES_CREDENTIAL_ID }} + totp_secret: ${{ secrets.ES_TOTP_SECRET }} + dir_path: ${GITHUB_WORKSPACE}/signing_input + output_path: ${GITHUB_WORKSPACE}/signing_output + environment_name: PROD + override: true + malware_block: true + clean_logs: true + + - name: Move signed installers back + run: | + mv signing_output/*.exe assets/ || true + mv signing_output/*.msi assets/ || true + rm -rf signing_input signing_output + + - name: Upload Windows artifacts + uses: actions/upload-artifact@v4 + with: + name: windows-x64 + path: assets/*.exe + retention-days: 3 release: - needs: [prepare, build-macos] + needs: [prepare, build-macos, build-windows] runs-on: ubuntu-latest env: GH_TOKEN: ${{ secrets.STRONGER_GITHUB_TOKEN }} VERSION: ${{ needs.prepare.outputs.version }} + SHORT_HASH: ${{ needs.prepare.outputs.short_hash }} + PR_NUMBER: ${{ needs.prepare.outputs.pr_number }} steps: - uses: actions/checkout@v4 @@ -263,22 +397,30 @@ jobs: with: path: artifacts/ + # Release tag carries the PR number + short hash so multiple + # builds of the same PR don't collide and each one is traceable + # back to the exact commit. The app-internal version strings stay + # as digits-only VERSION (Windows installer version fields reject + # non-numeric components) β€” the tag is purely a GH-side label. - name: Create prerelease run: | - gh release create "$VERSION" \ + RELEASE_TAG="${VERSION}-pr${PR_NUMBER}-${SHORT_HASH}" + echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_ENV" + gh release create "$RELEASE_TAG" \ --target "${{ needs.prepare.outputs.head_sha }}" \ --prerelease \ - --title "Codex Beta $VERSION" \ + --title "Codex Beta $VERSION (PR #${PR_NUMBER} @ ${SHORT_HASH})" \ --generate-notes \ - artifacts/macos-arm64/*.dmg + artifacts/macos-arm64/*.dmg \ + artifacts/windows-x64/*.exe - name: Comment on PR env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - RELEASE_URL="https://github.com/${{ github.repository }}/releases/tag/${VERSION}" - gh pr comment "${{ needs.prepare.outputs.pr_number }}" \ - --body "Pre-release: ${VERSION} ${RELEASE_URL}" + RELEASE_URL="https://github.com/${{ github.repository }}/releases/tag/${RELEASE_TAG}" + gh pr comment "${PR_NUMBER}" \ + --body "Pre-release: ${VERSION} (${SHORT_HASH}) ${RELEASE_URL}" - name: React with rocket on success if: success() && github.event_name == 'issue_comment' @@ -289,7 +431,7 @@ jobs: --method POST -f content='rocket' notify-failure: - needs: [prepare, build-macos, release] + needs: [prepare, build-macos, compile-windows, build-windows, release] if: always() && contains(needs.*.result, 'failure') runs-on: ubuntu-latest steps: @@ -305,7 +447,7 @@ jobs: fi if [[ -n "$PR_NUMBER" ]]; then - gh pr comment "${PR_NUMBER}" --body "Build failed: ${RUN_URL}" + gh pr comment "${PR_NUMBER}" --repo "${{ github.repository }}" --body "Build failed: ${RUN_URL}" fi if [ "${{ github.event_name }}" = "issue_comment" ]; then diff --git a/.github/workflows/stable-linux.yml b/.github/workflows/stable-linux.yml index 527d5bc049f..054749a03de 100644 --- a/.github/workflows/stable-linux.yml +++ b/.github/workflows/stable-linux.yml @@ -129,6 +129,7 @@ jobs: env: SHOULD_BUILD_REH: 'no' SHOULD_BUILD_REH_WEB: 'no' + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: ./build.sh if: env.SHOULD_BUILD == 'yes' diff --git a/.gitignore b/.gitignore index 9ebe964fddb..cc7f2fdedba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,46 @@ -vscode* -VS*/* -VSCode* -Codex* -.DS_Store -**/*/.DS_Store +# Cloned upstream VS Code source (generated by build pipeline) vscode/ -.aider* -*.env -assets/ + +# Build output β€” compiled Codex app bundles and platform packages +# /Codex* is root-anchored to avoid matching src/…/codexConductor on case-insensitive filesystems +VSCode* +VS*/* +/Codex* + +# Linux AppImage build intermediates build/linux/appimage/out build/linux/appimage/pkg2appimage.AppDir build/linux/appimage/pkg2appimage-*.AppImage build/linux/appimage/pkg2appimage.AppImage build/linux/appimage/squashfs-root build/linux/appimage/Codex + +# Windows MSI build intermediates build/windows/msi/releasedir build/windows/msi/Files*.wxs build/windows/msi/Files*.wixobj -sourcemaps/ + +# Snap store packages stores/snapcraft/insider/*.snap stores/snapcraft/stable/*.snap + +# Bundled extension assets (downloaded at build time) +assets/ +sourcemaps/ + +# Node dependencies node_modules yarn.lock + +# VS Code workspace state databases +*.vscdb + +# macOS metadata +.DS_Store +**/*/.DS_Store + +# Local dev tooling +.aider* +*.env +.claude/ +fv diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..ba4a0e22cb7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,248 @@ +# Codex Development Guide + +This repository builds **Codex**, a freely-licensed VS Code distribution for scripture translation. It is a fork of [VSCodium](https://github.com/VSCodium/vscodium) with custom branding, patches, and bundled extensions. The build clones Microsoft's VS Code, applies patches and source overlays, bundles extensions, and compiles platform-specific binaries. + +## Upstream Relationship + +``` +Microsoft/vscode (source code) + ↓ (cloned at specific commit) +VSCodium/vscodium (origin) ──patches──→ VSCodium binaries + ↓ (forked) +This repo (Codex) ──patches──→ Codex binaries +``` + +**Remotes:** +- `origin` = VSCodium/vscodium (upstream we sync from) +- `nexus` = BiblioNexus-Foundation/codex (our main repo) + +## Repository Structure + +``` +patches/ # Patch files applied to vscode source (alphabetical order) + *.patch # Core patches applied to all builds + insider/ # Insider-only patches + osx/ linux/ windows/# Platform-specific patches + user/ # Optional user patches (hide-activity-bar, microphone, etc.) +src/stable/ # Source overlay β€” copied into vscode/ before patches + cli/src/commands/ # Rust CLI additions (e.g. pin.rs) + src/vs/workbench/contrib/ # Workbench contributions (e.g. codexConductor/) + resources/ # Branding assets (icons, desktop files) +extensions/ # Built-in extensions compiled with the VS Code build +bundle-extensions.json# Extensions downloaded from GitHub Releases during build +dev/ # Development helper scripts +vscode/ # Cloned vscode repo (gitignored, generated during build) +``` + +## Building + +### Local Development Build + +```bash +./dev/build.sh +``` + +This runs the full pipeline: clone vscode β†’ copy source overlays β†’ apply patches β†’ `npm ci` β†’ compile β†’ bundle extensions β†’ produce platform binary. + +**Flags:** +- `-s` β€” Skip source clone (reuse existing `vscode/`). Patches and overlays are still re-applied. +- `-o` β€” Prep source only, skip compilation. +- `-l` β€” Use latest VS Code version from Microsoft's update API. +- `-i` β€” Build insider variant. +- `-p` β€” Include asset packaging (installers). + +Flags combine: `./dev/build.sh -sl` skips clone and uses latest. + +### Build Pipeline + +``` +dev/build.sh + β”œβ”€ get_repo.sh # Clone vscode at commit from upstream/stable.json + β”œβ”€ version.sh # Compute release version (e.g. 1.108.12007) + β”œβ”€ prepare_vscode.sh # Copy src/stable/* overlay, merge product.json, + β”‚ # apply patches/*.patch, run npm ci + β”œβ”€ build.sh # gulp compile, webpack extensions, minify, + β”‚ β”œβ”€ get-extensions.sh # Download VSIXs from bundle-extensions.json + β”‚ └─ gulp vscode-{platform}-{arch}-min-ci + └─ prepare_assets.sh # Create installers (only with -p flag) +``` + +### What Gets Modified vs What's New + +There are two ways to add Codex-specific code to the VS Code source: + +- **Source overlays** (`src/stable/`): For **new files**. Copied verbatim into `vscode/` before patches run. Use for new workbench contributions, new Rust CLI modules, new resources. +- **Patches** (`patches/`): For **modifying existing VS Code files**. Small, surgical diffs. Use for adding imports, registering contributions, changing config values. + +### Extension Bundling + +Extensions reach the final build three ways: + +| Method | Config | When | +|--------|--------|------| +| **Built-in** (compiled from source) | `vscode/extensions/` | Compiled by gulp during build | +| **Downloaded** (pre-built VSIX) | `bundle-extensions.json` | Downloaded from GitHub Releases by `get-extensions.sh` | +| **Sideloaded** (runtime install) | `product.json` `codexSideloadExtensions` | Installed on first launch by `CodexSideloader` shell contribution (from gallery or direct VSIX URL) | + +### Output + +| Platform | Output | +|----------|--------| +| macOS | `VSCode-darwin-{arch}/Codex.app` | +| Linux | `VSCode-linux-{arch}/` | +| Windows | `VSCode-win32-{arch}/` | + +On macOS: `open VSCode-darwin-arm64/Codex.app` + +## Working with Patches + +### Key Rules + +1. **Never edit patch files by hand.** Always generate them with `git diff --staged` inside `vscode/`. Hand-written patches fail with "corrupt patch" errors. +2. **Patches are applied alphabetically.** A patch can depend on patches that sort before it (e.g. `feat-cli-pinning.patch` depends on `binary-name.patch`). +3. **Patches use placeholder variables** (`!!APP_NAME!!`, `!!BINARY_NAME!!`, `!!GH_REPO_PATH!!`, etc.) that are substituted during application. +4. **New files go in the source overlay**, not in patches. Only use patches to modify existing VS Code files. + +### Creating or Updating a Patch + +Use `dev/patch.sh` to ensure the correct baseline: + +```bash +# Edit feat-cli-pinning.patch, which depends on binary-name.patch: +./dev/patch.sh binary-name feat-cli-pinning + +# The script: +# 1. Resets vscode/ to pristine upstream +# 2. Applies binary-name.patch as the baseline +# 3. Applies feat-cli-pinning.patch (with --reject if it partially fails) +# 4. Waits for you to make changes in vscode/ +# 5. Press any key β†’ regenerates the patch from git diff --staged -U1 +``` + +The last argument is the patch being edited. All preceding arguments are prerequisites that form the baseline. **Always list all patches your target depends on.** + +### Manual Patch Workflow + +If `dev/patch.sh` isn't suitable (e.g. non-interactive environment): + +```bash +cd vscode +git reset --hard HEAD # Clean state + +# Apply prerequisites +git apply --ignore-whitespace ../patches/binary-name.patch +git add . && git commit --no-verify -q -m "baseline" + +# Make your changes to existing VS Code files +# ... + +# Generate the patch +git add . +git diff --staged -U1 > ../patches/my-feature.patch +``` + +### Validating Patches + +```bash +# Test all patches apply cleanly in sequence: +./dev/update_patches.sh + +# Or manually test one: +cd vscode +git apply --check ../patches/my-feature.patch +``` + +### Patch Dependencies + +Some Codex patches modify files that earlier patches also touch. When this happens, the later patch must be generated against a tree that includes the earlier patch. Current known dependencies: + +| Patch | Depends on | +|-------|-----------| +| `feat-cli-pinning.patch` | `binary-name.patch` (both modify `nativeHostMainService.ts`) | +| `feat-codex-sideloader.patch` | `feat-codex-conductor.patch` (both add imports to `workbench.common.main.ts`) | + +If a patch fails to apply with "patch does not apply", check whether a prerequisite patch changed the same file. Regenerate using `dev/patch.sh` with the prerequisite listed first. + +## Codex-Specific Components + +### CodexConductor (Workbench Contribution) + +**Location:** `src/stable/src/vs/workbench/contrib/codexConductor/` +**Patch:** `patches/feat-codex-conductor.patch` (adds the import to `workbench.common.main.ts`) +**Robustness Patch:** `patches/zzz-authoritative-reload.patch` (enables `forceProfile` in window reloads) + +Enforces project-scoped extension version pins. Reads `pinnedExtensions` from project `metadata.json` or Frontier's `workspaceState`, downloads VSIXs from GitHub Release URLs, installs into deterministic VS Code profiles, and switches the extension host. + +**Key Robustness Features:** +- **Authoritative Reload:** Uses a patched `reload({ forceProfile: name })` IPC command to ensure the Main process opens the new window in the correct profile, bypassing persistence race conditions and dev-mode restrictions. +- **Initialization Yielding:** Works in tandem with `codex-editor` which returns early from `activate()` if a mismatch is detected, showing a "pins applying" message on the splash screen. +- **Duplicate Prevention:** Explicitly calls `resetWorkspaces()` before associating a profile to ensure lookup consistency. +- **Loop Guard:** Includes a 3-cycle circuit breaker to prevent infinite reload loops if enforcement fails. +- **Lifecycle Management:** Automatic cleanup of orphaned profiles every 14 days. + +### CodexSideloader (Workbench Contribution) + +**Location:** `src/stable/src/vs/workbench/contrib/codexSideloader/` +**Patch:** `patches/feat-codex-sideloader.patch` (adds import to `workbench.common.main.ts`, depends on `feat-codex-conductor.patch`) + +Ensures global extensions are installed on startup. Reads the `codexSideloadExtensions` array from `product.json`. Entries can be a string (gallery install from Open VSX) or an object with `id`, `vsix`, and `version` fields (direct VSIX install via shared process IPC, bypassing the marketplace). String entries are skipped if the extension is already installed at any version; object entries are reinstalled whenever the installed version doesn't match `version`. Replaces the standalone `extension-sideloader` extension. + +### CLI Pin Commands (Rust) + +**Overlay:** `src/stable/cli/src/commands/pin.rs` +**Patch:** `patches/feat-cli-pinning.patch` (registers the `pin` subcommand in args/argv, adds `PinningError`, refactors macOS shell command install for `codex-cli` symlink) + +Adds `codex pin list/add/remove/sync/reset` to the Rust CLI. The `add` command downloads a remote VSIX, extracts the extension ID and version, and writes the pin to `metadata.json`. The `sync` command stages and commits `metadata.json` locally for the next Frontier sync. The `reset` command discards uncommitted pin changes via `git checkout -- metadata.json`. + +### Extension Bundling + +**Config:** `bundle-extensions.json` +**Script:** `get-extensions.sh` + +Declarative JSON config for extensions downloaded as pre-built VSIXs from GitHub Releases during the build. + +## Key Scripts + +| Script | Purpose | +|--------|---------| +| `dev/build.sh` | Local development build (main entry point) | +| `dev/patch.sh` | Apply prerequisite patches + edit a target patch | +| `dev/update_patches.sh` | Validate/fix all patches sequentially | +| `dev/clean_codex.sh` | Remove all Codex app data from macOS (reset to clean state) | +| `get_repo.sh` | Clone vscode at the commit specified in `upstream/stable.json` | +| `prepare_vscode.sh` | Copy overlays, merge product.json, apply patches, npm ci | +| `build.sh` | Compile (gulp), bundle extensions, produce platform binary | +| `get-extensions.sh` | Download VSIXs listed in `bundle-extensions.json` | + +## Version Tracking + +The target VS Code version is in `upstream/stable.json`: + +```json +{ + "tag": "1.108.1", + "commit": "585eba7c0c34fd6b30faac7c62a42050bfbc0086" +} +``` + +The Codex release version appends a time-based patch number: `{tag}.{day*24+hour}` (e.g. `1.108.12007`). + +## Syncing with Upstream VSCodium + +### Codex-Specific Customizations to Preserve + +1. **Branding** β€” `src/stable/`, `src/insider/`, `icons/` +2. **GitHub Workflows** β€” Simplified vs VSCodium. Custom: `docker-build-push.yml`, `patch-rebuild.yml`, `manual-release.yml` +3. **Windows MSI** β€” `build/windows/msi/codex.*` (renamed from `vscodium.*`) +4. **Product config** β€” `prepare_vscode.sh` (URLs, app names) +5. **Custom patches** β€” `patches/feat-*` (Codex features), `patches/user/*` (microphone, UI tweaks) +6. **Windows code signing** β€” SSL.com eSigner in `stable-windows.yml` +7. **Extension bundling** β€” `bundle-extensions.json`, `get-extensions.sh` +8. **Workbench contributions** β€” `src/stable/src/vs/workbench/contrib/codexConductor/` +9. **Rust CLI additions** β€” `src/stable/cli/src/commands/pin.rs` + +### Merge Strategy + +For small gaps: `git merge origin/master`, resolve conflicts. +For large gaps: cherry-pick patch updates from upstream, re-apply Codex customizations. +After merging: `./dev/update_patches.sh` then `./dev/build.sh` to validate. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index b02583892a3..00000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,332 +0,0 @@ -# Codex Development Guide - -This repository builds Codex, a freely-licensed VS Code distribution. It is a fork of [VSCodium](https://github.com/VSCodium/vscodium) with custom branding and configuration. The build process clones Microsoft's vscode repository and modifies it via git patches. - -## Upstream Relationship - -``` -Microsoft/vscode (source code) - ↓ (cloned at specific commit) -VSCodium/vscodium (origin) ──patches──→ VSCodium binaries - ↓ (forked) -This repo (Codex) ──patches──→ Codex binaries -``` - -**Remotes:** -- `origin` = VSCodium/vscodium (upstream we sync from) -- `nexus` = BiblioNexus-Foundation/codex (our main repo) - -## Repository Structure - -``` -patches/ # All patch files that modify vscode source - *.patch # Core patches applied to all builds - insider/ # Patches specific to insider builds - osx/ # macOS-specific patches - linux/ # Linux-specific patches - windows/ # Windows-specific patches - user/ # Optional user patches - -vscode/ # Cloned vscode repository (gitignored, generated) -dev/ # Development helper scripts -src/ # Brand assets and configuration overlays -``` - -## Working with Patches - -### Understanding the Patch Workflow - -1. **Patches are the source of truth** - Never commit direct changes to the `vscode/` directory. All modifications to VS Code source must be captured as `.patch` files in the `patches/` directory. - -2. **Patches are applied sequentially** - Order matters. Core patches are applied first, then platform-specific patches. - -3. **Patches use placeholder variables** - Patches can use placeholders like `!!APP_NAME!!`, `!!BINARY_NAME!!`, etc. that get replaced during application. - -### Making Changes to VS Code Source - -#### Step 1: Set Up Working Environment - -```bash -# Fresh clone of vscode at the correct commit -./get_repo.sh - -# Or use dev/build.sh which does this automatically -./dev/build.sh -``` - -#### Step 2: Apply Existing Patches - -To work on an existing patch: -```bash -# Apply prerequisite patches + the target patch for editing -./dev/patch.sh prerequisite1 prerequisite2 target-patch - -# Example: To modify the brand.patch -./dev/patch.sh brand -``` - -The `dev/patch.sh` script: -- Resets vscode to clean state -- Applies the helper settings patch -- Applies all listed prerequisite patches -- Applies the target patch (last argument) -- Waits for you to make changes -- Regenerates the patch file when you press a key - -#### Step 3: Making Changes - -After running `dev/patch.sh`: -1. Edit files in `vscode/` as needed -2. Press any key in the terminal when done -3. The script regenerates the patch file automatically - -#### Manual Patch Creation/Update - -If working manually: -```bash -cd vscode - -# Make your changes to the source files -# ... - -# Stage and generate diff -git add . -git diff --staged -U1 > ../patches/your-patch-name.patch -``` - -### Testing Patches - -#### Validate All Patches Apply Cleanly - -```bash -./dev/update_patches.sh -``` - -This script: -- Iterates through all patches -- Attempts to apply each one -- If a patch fails, it applies with `--reject` and pauses for manual resolution -- Regenerates any patches that needed fixing - -#### Full Build Test - -```bash -# Run a complete local build -./dev/build.sh - -# Options: -# -i Build insider version -# -l Use latest vscode version -# -o Skip build (only prepare source) -# -s Skip source preparation (use existing vscode/) -``` - -### Common Development Tasks - -#### Creating a New Patch - -1. Apply all prerequisite patches that your change depends on -2. Make your changes in `vscode/` -3. Generate the patch: - ```bash - cd vscode - git add . - git diff --staged -U1 > ../patches/my-new-feature.patch - ``` -4. Add the patch to the appropriate location in `prepare_vscode.sh` if it should be applied during builds - -#### Updating a Patch After Upstream Changes - -When VS Code updates and a patch no longer applies: -```bash -# Run update script - it will pause on failing patches -./dev/update_patches.sh - -# Fix the conflicts in vscode/, then press any key -# The script regenerates the fixed patch -``` - -#### Debugging Patch Application - -```bash -cd vscode -git apply --check ../patches/problem.patch # Dry run -git apply --reject ../patches/problem.patch # Apply with .rej files for conflicts -``` - -## Key Scripts Reference - -| Script | Purpose | -|--------|---------| -| `get_repo.sh` | Clone vscode at correct version | -| `prepare_vscode.sh` | Apply patches and prepare for build | -| `build.sh` | Main build script | -| `dev/build.sh` | Local development build | -| `dev/patch.sh` | Apply patches for editing a single patch | -| `dev/update_patches.sh` | Validate/update all patches | -| `dev/clean_codex.sh` | Remove all Codex app data from macOS user dirs (reset to clean state; macOS only) | -| `utils.sh` | Common functions including `apply_patch` | - -## Build Environment - -The build process: -1. `get_repo.sh` - Fetches vscode source at a specific commit -2. `prepare_vscode.sh` - Applies patches, copies branding, runs npm install -3. `build.sh` - Compiles the application - -Environment variables: -- `VSCODE_QUALITY`: "stable" or "insider" -- `OS_NAME`: "osx", "linux", or "windows" -- `VSCODE_ARCH`: CPU architecture - -### Version Tracking - -The VS Code version to build is determined by: - -1. **`upstream/stable.json`** (or `insider.json`) - Contains the target VS Code tag and commit: - ```json - { - "tag": "1.100.0", - "commit": "19e0f9e681ecb8e5c09d8784acaa601316ca4571" - } - ``` - -2. **`VSCODE_LATEST=yes`** - If set, queries Microsoft's update API for the latest version instead - -When syncing upstream, update these JSON files to match VSCodium's versions to ensure patches are compatible. - -## Syncing with Upstream VSCodium - -This is the most challenging maintenance task. VSCodium regularly updates their patches and build scripts to support new VS Code versions. - -### Check Current Status - -```bash -git fetch origin -git log --oneline origin/master -5 # See upstream's recent changes -git rev-list --count $(git merge-base HEAD origin/master)..origin/master # Commits behind -``` - -### Codex-Specific Customizations to Preserve - -When merging upstream, these are our key customizations that must be preserved: - -1. **Branding** (`src/stable/`, `src/insider/`, `icons/`) - - Custom icons and splash screens - - Keep all Codex assets - -2. **GitHub Workflows** (`.github/workflows/`) - - Simplified compared to VSCodium - - Uses different release repos (genesis-ai-dev/codex, BiblioNexus-Foundation/codex) - - Has custom workflows: `docker-build-push.yml`, `patch-rebuild.yml`, `manual-release.yml` - -3. **Windows MSI Files** (`build/windows/msi/`) - - Files renamed from `vscodium.*` to `codex.*` - - References updated for Codex branding - -4. **Product Configuration** (`product.json`, `prepare_vscode.sh`) - - URLs point to genesis-ai-dev/codex repos - - App names, identifiers set to Codex - -5. **Custom Patches** (`patches/`) - - `patches/user/microphone.patch` - Codex-specific - - Minor modifications to other patches for branding - -6. **Windows Code Signing** (`.github/workflows/stable-windows.yml`) - - SSL.com eSigner integration for code signing - - Signs application binaries (.exe, .dll) before packaging - - Signs installer packages (.exe, .msi) after packaging - - Required secrets: `ES_USERNAME`, `ES_PASSWORD`, `ES_CREDENTIAL_ID`, `ES_TOTP_SECRET` - - **Must preserve**: The signing steps between "Build" and "Prepare assets", and after "Upload unsigned artifacts" - -### Merge Strategy - -#### Option A: Incremental Merge (Recommended for small gaps) - -```bash -# Create a working branch -git checkout -b upstream-sync - -# Merge upstream -git merge origin/master - -# Resolve conflicts - most will be in: -# - .github/workflows/ (keep ours, incorporate new build steps if needed) -# - patches/*.patch (need careful merge - see below) -# - build/windows/msi/ (keep our codex.* files) -# - prepare_vscode.sh (keep our branding, adopt new build logic) -``` - -#### Option B: Cherry-pick Patch Updates (Recommended for large gaps) - -When far behind (like 1.99 β†’ 1.108), it's often easier to: - -1. **Identify patch update commits** in upstream: - ```bash - git log origin/master --oneline --grep="update patches" - ``` - -2. **Cherry-pick or manually apply** the patch changes: - ```bash - # See what patches changed in a specific upstream commit - git show -- patches/ - ``` - -3. **Copy updated patches** from upstream, then re-apply our branding changes - -#### Option C: Reset and Re-apply Customizations - -For very large gaps, it may be cleanest to: - -1. Create a fresh branch from upstream -2. Re-apply Codex customizations on top -3. This ensures we get all upstream fixes cleanly - -### Resolving Patch Conflicts - -When upstream updates patches that we've also modified: - -1. **Compare the patches:** - ```bash - git diff origin/master -- patches/brand.patch - ``` - -2. **Accept upstream's patch structure** (they've adapted to new VS Code) - -3. **Re-apply our branding on top:** - - Our changes are usually just `VSCodium` β†’ `Codex` type substitutions - - The placeholder system (`!!APP_NAME!!`) handles most of this automatically - -### After Merging: Validate Everything - -```bash -# 1. Update upstream/stable.json to new version if needed -# 2. Test patches apply cleanly -./dev/update_patches.sh - -# 3. Run a full local build -./dev/build.sh -l # -l uses latest VS Code version - -# 4. If patches fail, fix them one by one -# The update_patches.sh script will pause on failures -``` - -### Common Conflict Patterns - -| File/Area | Typical Resolution | -|-----------|-------------------| -| `.github/workflows/*.yml` | Keep our simplified versions, cherry-pick important CI fixes | -| `.github/workflows/stable-windows.yml` | **Preserve code signing steps** - keep SSL.com eSigner integration intact | -| `patches/*.patch` | Take upstream's version, verify our branding placeholders work | -| `prepare_vscode.sh` | Keep our branding URLs/names, adopt new build logic | -| `build/windows/msi/` | Keep our `codex.*` files, apply equivalent changes from `vscodium.*` | -| `README.md` | Keep ours | -| `product.json` | Keep ours (merged at build time anyway) | - -## Tips - -- Always work from a clean vscode state when creating patches -- Keep patches focused and minimal - one logical change per patch -- Test patches apply to a fresh clone before committing -- The `vscode/` directory is gitignored - your patch files are the persistent record -- When syncing upstream, focus on patch files first - they're the core of the build diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000000..47dc3e3d863 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index af9cdb5fb49..789b9feabe0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ ENV PATH="/root/.cargo/bin:${PATH}" COPY . /opt/vscodium WORKDIR /opt/vscodium -RUN ./dev/build.sh && \ +RUN SHOULD_BUILD_REH=yes SHOULD_BUILD_REH_WEB=yes ./dev/build.sh && \ mkdir ./vscode-reh-web-linux-x64/scripts && \ cp ./vscode/scripts/code-server.js ./vscode-reh-web-linux-x64/scripts/code-server.cjs && \ cp -r ./vscode/node_modules ./vscode-reh-web-linux-x64/ diff --git a/build.sh b/build.sh index c0dfd2f5473..3ea9f2e29dc 100755 --- a/build.sh +++ b/build.sh @@ -22,7 +22,7 @@ if [[ "${SHOULD_BUILD}" == "yes" ]]; then npm run gulp compile-extensions-build npm run gulp minify-vscode - . ../get-extensions.sh + ../get-extensions.sh if [[ "${OS_NAME}" == "osx" ]]; then # remove win32 node modules diff --git a/build_cli.sh b/build_cli.sh index 746f27b2d9f..04311f2d9c7 100755 --- a/build_cli.sh +++ b/build_cli.sh @@ -19,7 +19,7 @@ TUNNEL_APPLICATION_NAME="$(node -p "require(\"../product.json\").tunnelApplicati NAME_SHORT="$(node -p "require(\"../product.json\").nameShort")" npm pack @vscode/openssl-prebuilt@0.0.11 -mkdir openssl +mkdir -p openssl tar -xvzf vscode-openssl-prebuilt-0.0.11.tgz --strip-components=1 --directory=openssl if [[ "${OS_NAME}" == "osx" ]]; then diff --git a/bundle-extensions.json b/bundle-extensions.json new file mode 100644 index 00000000000..95d1e64aa58 --- /dev/null +++ b/bundle-extensions.json @@ -0,0 +1,3 @@ +{ + "bundle": [] +} diff --git a/create-pr-release b/create-pr-release new file mode 100755 index 00000000000..9f71da3bc4b --- /dev/null +++ b/create-pr-release @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Ensure we're in the right directory +if [[ ! -f product.json ]]; then + echo "Error: no product.json in current directory. Run this from the root of the codex repository." >&2 + exit 1 +fi + +if [[ -n "$(git status --porcelain 2>/dev/null)" ]]; then + printf '\033[33mWarning: git working tree is dirty.\033[0m\n' >&2 +fi + +# --------------------------------------------------------------------------- +# Resolve VERSION and RELEASE_TAG +# --------------------------------------------------------------------------- +# Get the base version from upstream/stable.json (the MS tag) +MS_TAG=$(jq -r '.tag' "./upstream/stable.json") +SHORT_HASH="$(git rev-parse --short HEAD)" + +# RELEASE_VERSION flows through to package.json and installer fields. +# For Windows and VS Code version compatibility, this must be digit-only. +# We use the hour-of-year pattern (TIME_PATCH) from stable-windows.yml. +TIME_PATCH=$(printf '%04d' $(( $(date -u +%-j) * 24 + $(date -u +%-H) ))) +VERSION="${MS_TAG}${TIME_PATCH}" + +# Try to get PR number from current branch or gh +if ! command -v gh &>/dev/null; then + echo "Error: 'gh' CLI is required." >&2 + exit 1 +fi + +PR_NUMBER="$(gh pr view --json number -q .number 2>/dev/null || true)" + +if [[ -z "$PR_NUMBER" ]]; then + echo "No open PR detected for the current branch." + RELEASE_TAG="${VERSION}-dev-${SHORT_HASH}" +else + RELEASE_TAG="${VERSION}-pr${PR_NUMBER}-${SHORT_HASH}" +fi + +echo "Generated app version: $VERSION" +echo "Generated release tag: $RELEASE_TAG" + +# --------------------------------------------------------------------------- +# Setup environment variables +# --------------------------------------------------------------------------- +export APP_NAME="Codex Beta" +export BINARY_NAME="codex-beta" +export RELEASE_VERSION="${VERSION}" +export CUSTOM_RELEASE_VERSION="${VERSION}" # Hook for get_repo.sh +export SKIP_SOURCE="no" +export SKIP_ASSETS="no" # We want the DMG +export VSCODE_QUALITY="stable" + +echo "==> Starting build for $APP_NAME ($VERSION)" +# dev/build.sh is the main entry point for local builds +./dev/build.sh -sp + +# --------------------------------------------------------------------------- +# Release on GitHub +# --------------------------------------------------------------------------- + +# Determine architecture +UNAME_ARCH=$( uname -m ) +if [[ "${UNAME_ARCH}" == "aarch64" || "${UNAME_ARCH}" == "arm64" ]]; then + ARCH="arm64" +else + ARCH="x64" +fi + +# The filename is constructed in prepare_assets.sh using VERSION +DMG_FILE="assets/${APP_NAME}.${ARCH}.${VERSION}.dmg" + +if [[ ! -f "$DMG_FILE" ]]; then + echo "Error: DMG artifact not found at $DMG_FILE" >&2 + echo "Listing assets directory:" + ls -l assets/ + exit 1 +fi + +echo "==> Creating GitHub prerelease: $RELEASE_TAG" +gh release create "$RELEASE_TAG" \ + --target "$(git rev-parse HEAD)" \ + --prerelease \ + --title "$APP_NAME $VERSION (PR #${PR_NUMBER:-dev} @ $SHORT_HASH)" \ + --generate-notes \ + "./$DMG_FILE" + +REPO_URL="$(gh repo view --json url -q .url)" +RELEASE_URL="${REPO_URL}/releases/tag/${RELEASE_TAG}" +echo "Done! Prerelease $RELEASE_TAG created with $DMG_FILE" + +if [[ -n "${PR_NUMBER:-}" ]]; then + gh pr comment "$PR_NUMBER" --body "Pre-release: ${VERSION} (${SHORT_HASH}) ${RELEASE_URL}" + echo "Commented PR #${PR_NUMBER}" +fi + +# Only open if in a terminal +if [[ -t 1 ]]; then + open "$REPO_URL/releases" +fi diff --git a/dev/build.sh b/dev/build.sh index d8e56e9869a..4ff893da985 100755 --- a/dev/build.sh +++ b/dev/build.sh @@ -5,14 +5,16 @@ # to run with Bash: "C:\Program Files\Git\bin\bash.exe" ./dev/build.sh ### -export APP_NAME="Codex" -export ASSETS_REPOSITORY="BiblioNexus-Foundation/codex" -export BINARY_NAME="codex" +export APP_NAME="${APP_NAME:-Codex}" +export ASSETS_REPOSITORY="${ASSETS_REPOSITORY:-BiblioNexus-Foundation/codex}" +export BINARY_NAME="${BINARY_NAME:-codex}" export CI_BUILD="no" -export GH_REPO_PATH="genesis-ai-dev/codex" -export ORG_NAME="Codex" +export GH_REPO_PATH="${GH_REPO_PATH:-genesis-ai-dev/codex}" +export ORG_NAME="${ORG_NAME:-Codex}" export SHOULD_BUILD="yes" export SKIP_ASSETS="yes" +export SHOULD_BUILD_REH="${SHOULD_BUILD_REH:-no}" +export SHOULD_BUILD_REH_WEB="${SHOULD_BUILD_REH_WEB:-no}" export SKIP_BUILD="no" export SKIP_SOURCE="no" export VSCODE_LATEST="no" @@ -156,7 +158,7 @@ if [[ "${SKIP_ASSETS}" == "no" ]]; then fi if [[ "${OS_NAME}" == "osx" && -f "dev/osx/codesign.env" ]]; then - . dev/osx/macos-codesign.env + . dev/osx/codesign.env echo "CERTIFICATE_OSX_ID: ${CERTIFICATE_OSX_ID}" fi diff --git a/dev/build_linux_docker.sh b/dev/build_linux_docker.sh new file mode 100755 index 00000000000..7fc5f402135 --- /dev/null +++ b/dev/build_linux_docker.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# Run a Linux Codex build inside a locally-built Ubuntu container. +# +# Usage: +# ./dev/build_linux_docker.sh [-a x64|arm64|armhf] [-s] [-i] [-l] [-o] [-p] +# +# Flags: +# -a ARCH Target architecture (default: host native, from uname -m) +# -s Skip source clone (reuse existing vscode/ in the volume) +# -i Build the insider variant +# -l Use the latest VS Code version from Microsoft's update API +# -o Prep source only, skip compilation +# -p Also produce packaged assets (installers, tarballs) +# +# All flags except -a are passed through to dev/build.sh unchanged. +# +# Why a custom image instead of vscodium/vscodium-linux-build-agent: +# The VSCodium build-agent images (focal-x64, focal-arm64, focal-armhf) are +# all linux/amd64 images; the -arm64 / -armhf tags are amd64 images with +# cross-compilers targeting those architectures. Running them on an arm64 +# host therefore requires linux/amd64 qemu-user emulation, which reliably +# segfaults during the gulp compile phase because Go's runtime (used by +# esbuild) mistranslates futex/epoll syscalls under qemu. +# +# Instead this wrapper builds a small Ubuntu 22.04 image from +# dev/linux-build.dockerfile with the union of packages needed by the CI +# compile + build jobs, and runs it at the host's native architecture. +# +# How state is isolated from the host mac build: +# - The repo is bind-mounted at /work, so patches/, src/stable/, dev/, +# bundle-extensions.json, upstream/*.json etc. are shared live. +# - /work/vscode is backed by a per-arch named volume +# (codex-vscode-linux-), so the Linux checkout + node_modules live in +# container storage and do not clobber the host's mac vscode/ tree. +# - /work/patches is overlay-mounted (podman :O) so that apply_patch in +# utils.sh can rewrite placeholder values (!!APP_NAME!! etc.) in place +# without touching the host patches/ directory. All writes go to an +# ephemeral upper layer that is discarded when the container exits. +# (Docker does not support :O; under docker the host patches/ must be +# writable by the container user, which is usually fine on Docker Desktop.) +# - The final VSCode-linux-/ output still lands on the host bind mount, +# so you can run the binary directly from the repo root. +# +# To nuke the per-arch Linux build state (forces a full reclone + npm ci): +# podman volume rm codex-vscode-linux-arm64 # or whichever arch +# +# To force a rebuild of the builder image itself (e.g. after bumping Node): +# podman image rm codex-linux-build:arm64 # or whichever arch +# +# Notes: +# - Works with either `docker` or `podman` (whichever is on PATH). +# - Default arch is the host's native arch so no emulation is involved. + +set -euo pipefail + +# Default to the host's native architecture so a build on arm64 macOS or +# arm64 Linux runs without qemu emulation (which is slow and has known Go +# runtime segfault issues around futex/epoll translation). +case "$(uname -m)" in + arm64|aarch64) ARCH="arm64" ;; + x86_64) ARCH="x64" ;; + armv7l) ARCH="armhf" ;; + *) ARCH="x64" ;; # unknown host; x64 is the safest fallback +esac +PASSTHROUGH_FLAGS=() + +while getopts ":a:silop" opt; do + case "$opt" in + a) + ARCH="$OPTARG" + ;; + s|i|l|o|p) + PASSTHROUGH_FLAGS+=("-$opt") + ;; + \?) + echo "Unknown flag: -$OPTARG" >&2 + exit 2 + ;; + :) + echo "Flag -$OPTARG requires an argument" >&2 + exit 2 + ;; + esac +done + +case "$ARCH" in + x64) + PLATFORM="linux/amd64" + ;; + arm64) + # Ubuntu's arm64 manifest entry is tagged linux/arm64/v8; podman's strict + # variant matching needs the /v8 suffix to resolve it. + PLATFORM="linux/arm64/v8" + ;; + armhf) + PLATFORM="linux/arm/v7" + ;; + *) + echo "Unsupported arch: $ARCH (want x64, arm64, or armhf)" >&2 + exit 2 + ;; +esac + +IMAGE="codex-linux-build:${ARCH}" +VOLUME="codex-vscode-linux-${ARCH}" + +if command -v docker >/dev/null 2>&1; then + RUNTIME="docker" +elif command -v podman >/dev/null 2>&1; then + RUNTIME="podman" +else + echo "Neither docker nor podman found on PATH" >&2 + exit 1 +fi + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DOCKERFILE="$REPO_ROOT/dev/linux-build.dockerfile" + +echo "Runtime: $RUNTIME" +echo "Image: $IMAGE" +echo "Platform: $PLATFORM" +echo "Arch: $ARCH" +echo "Volume: $VOLUME -> /work/vscode" +echo "Flags: ${PASSTHROUGH_FLAGS[*]:-(none)}" +echo "Repo: $REPO_ROOT" +echo + +# Build the builder image if it does not already exist locally. +if ! "$RUNTIME" image exists "$IMAGE" 2>/dev/null && \ + ! "$RUNTIME" image inspect "$IMAGE" >/dev/null 2>&1; then + echo "Builder image $IMAGE not found; building from $DOCKERFILE ..." + "$RUNTIME" build \ + --platform "$PLATFORM" \ + -t "$IMAGE" \ + -f "$DOCKERFILE" \ + "$REPO_ROOT/dev" + echo +fi + +PATCHES_MOUNT=("-v" "$REPO_ROOT/patches:/work/patches") +if [[ "$RUNTIME" == "podman" ]]; then + PATCHES_MOUNT=("-v" "$REPO_ROOT/patches:/work/patches:O") +fi + +exec "$RUNTIME" run --rm -it \ + --platform "$PLATFORM" \ + -v "$REPO_ROOT":/work \ + -v "$VOLUME":/work/vscode \ + "${PATCHES_MOUNT[@]}" \ + -w /work \ + "$IMAGE" \ + bash ./dev/build.sh "${PASSTHROUGH_FLAGS[@]}" diff --git a/dev/linux-build.dockerfile b/dev/linux-build.dockerfile new file mode 100644 index 00000000000..33b1de8c248 --- /dev/null +++ b/dev/linux-build.dockerfile @@ -0,0 +1,61 @@ +# syntax=docker/dockerfile:1 +# +# Linux build image for Codex. +# +# Used by dev/build_linux_docker.sh. The wrapper builds this for the host's +# native architecture so the resulting container runs without qemu-user +# emulation (which causes Go runtime segfaults during the gulp compile phase +# under the VSCodium cross-build images, which are amd64-only and force +# emulation on an arm64 host). +# +# Packages are derived from the union of: +# - .github/workflows/stable-linux.yml compile job setup steps +# - build/linux/deps.sh +# - the libraries needed to rebuild vscode's native node modules and run +# the Rust CLI build in build_cli.sh + +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive +ENV NODE_VERSION=22.21.1 +ENV RUSTUP_HOME=/usr/local/rustup +ENV CARGO_HOME=/usr/local/cargo +ENV PATH=/usr/local/cargo/bin:/usr/local/bin:/usr/bin:/bin + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl git jq unzip xz-utils \ + build-essential gcc-10 g++-10 \ + python3 python3-pip python-is-python3 pkg-config \ + libkrb5-dev \ + libx11-dev libxkbfile-dev libsecret-1-dev \ + libnss3 libgtk-3-dev libgbm-dev libasound2 \ + fakeroot rpm \ + && rm -rf /var/lib/apt/lists/* + +# CI pins GCC 10; match it to avoid native-module build surprises. +RUN update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-10 100 \ + && update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-10 100 + +# Install Node.js from the official tarball so the version matches .nvmrc +# exactly across architectures. +RUN set -eux; \ + arch="$(dpkg --print-architecture)"; \ + case "$arch" in \ + amd64) nodeArch="x64" ;; \ + arm64) nodeArch="arm64" ;; \ + armhf) nodeArch="armv7l" ;; \ + *) echo "unsupported arch: $arch" >&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${nodeArch}.tar.xz" -o /tmp/node.tar.xz; \ + tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 --no-same-owner; \ + rm /tmp/node.tar.xz; \ + node --version; \ + npm --version + +# Rust toolchain for build_cli.sh (Codex CLI pinning feature). +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ + sh -s -- -y --default-toolchain stable --profile minimal --no-modify-path \ + && chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \ + && rustc --version + +WORKDIR /work diff --git a/get-extensions.sh b/get-extensions.sh index 5badf7fdc3a..8f69129f04c 100755 --- a/get-extensions.sh +++ b/get-extensions.sh @@ -1,32 +1,50 @@ #!/usr/bin/env bash +# Downloads and unpacks bundled extensions into ./extensions/. +# Sourced from build.sh while CWD is vscode/. -# Exit early if SKIP_EXTENSIONS is set -if [[ -n "$SKIP_EXTENSIONS" ]]; then - return 0 +set -euo pipefail + +if [[ -n "${SKIP_EXTENSIONS:-}" ]]; then + exit 0 fi -jsonfile=$(curl -s https://raw.githubusercontent.com/genesis-ai-dev/extension-sideloader/refs/heads/main/extensions.json) -extensions_dir=./.build/extensions -base_dir=$(pwd) - -count=$(jq -r '.builtin | length' <<< ${jsonfile}) -for i in $(seq $count); do - url=$( jq -r ".builtin[$i-1].url" <<< ${jsonfile}) - name=$( jq -r ".builtin[$i-1].name" <<< ${jsonfile}) - echo $name $url - if [[ -d ${extensions_dir}/"$name" ]]; then - rm -rf ${extensions_dir}/"$name" - fi - mkdir -p ${extensions_dir}/"$name" - curl -Lso "$name".zip "$url" - unzip -q "$name".zip -d ${extensions_dir}/"$name" - mv ${extensions_dir}/"$name"/extension/* ${extensions_dir}/"$name"/ - cp -r ${extensions_dir}/"$name" ./extensions/ - rm "$name".zip -done +BUNDLE_JSON="../bundle-extensions.json" +EXTENSIONS_DIR="./extensions" + +TMP_DIR=$(mktemp -d) +trap 'rm -rf "${TMP_DIR}"' EXIT + +install_vsix() { + local name="$1" + local zip_file="$2" + local dest="${EXTENSIONS_DIR}/${name}" + + echo "[get-extensions] Installing ${name}..." + mkdir -p "${TMP_DIR}/${name}" + unzip -q "${zip_file}" -d "${TMP_DIR}/${name}" + rm -rf "${dest}" + mv "${TMP_DIR}/${name}/extension" "${dest}" + echo "[get-extensions] Installed ${name}" +} + +count=$(jq -r '.bundle | length' "${BUNDLE_JSON}") -# name="test" -# cp -r /Users/andrew.denhertog/Documents/Projects/andrewhertog/test-extension/test-extension-0.0.1.vsix ./ext.zip -# unzip -q ext.zip -d ${extensions_dir}/"$name" -# mv ${extensions_dir}/"$name"/extension/* ${extensions_dir}/"$name"/ -# rm ext.zip +if [[ "${count}" -eq 0 ]]; then + echo "[get-extensions] No bundled extensions to download." + exit 0 +fi + +for i in $(seq 0 $((count - 1))); do + name=$(jq -r ".bundle[$i].name" "${BUNDLE_JSON}") + repo=$(jq -r ".bundle[$i].github_release" "${BUNDLE_JSON}") + tag=$(jq -r ".bundle[$i].tag" "${BUNDLE_JSON}") + zip_file="${TMP_DIR}/${name}.vsix" + + echo "[get-extensions] Downloading ${name} from ${repo}@${tag}..." + gh release download "${tag}" \ + --repo "${repo}" \ + --pattern "*.vsix" \ + --output "${zip_file}" + + install_vsix "${name}" "${zip_file}" +done diff --git a/get_repo.sh b/get_repo.sh index f2e03d50200..e0a87f32b39 100755 --- a/get_repo.sh +++ b/get_repo.sh @@ -44,6 +44,8 @@ else if [[ "${VSCODE_QUALITY}" == "insider" ]]; then if [[ "${RELEASE_VERSION}" =~ ^([0-9]+\.[0-9]+\.[0-5])[0-9]+-insider$ ]]; then MS_TAG="${BASH_REMATCH[1]}" + elif [[ "${RELEASE_VERSION}" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-pr[0-9]+ ]]; then + MS_TAG="${BASH_REMATCH[1]}" else echo "Error: Bad RELEASE_VERSION: ${RELEASE_VERSION}" exit 1 @@ -51,6 +53,8 @@ else else if [[ "${RELEASE_VERSION}" =~ ^([0-9]+\.[0-9]+\.[0-5])[0-9]+$ ]]; then MS_TAG="${BASH_REMATCH[1]}" + elif [[ "${RELEASE_VERSION}" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-pr[0-9]+ ]]; then + MS_TAG="${BASH_REMATCH[1]}" else echo "Error: Bad RELEASE_VERSION: ${RELEASE_VERSION}" exit 1 diff --git a/patches/binary-name.patch b/patches/binary-name.patch index b8214dfd3cd..8d254ba8451 100644 --- a/patches/binary-name.patch +++ b/patches/binary-name.patch @@ -1,5 +1,5 @@ diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts -index d3ab651..63cd71f 100644 +index ac70ecb..9b7c25f 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -369,3 +369,3 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d @@ -7,6 +7,45 @@ index d3ab651..63cd71f 100644 - .pipe(rename('bin/code')); + .pipe(rename('bin/' + product.applicationName)); const policyDest = gulp.src('.build/policies/darwin/**', { base: '.build/policies/darwin' }) +diff --git a/cli/src/desktop/version_manager.rs b/cli/src/desktop/version_manager.rs +index e9cd1a1..535c403 100644 +--- a/cli/src/desktop/version_manager.rs ++++ b/cli/src/desktop/version_manager.rs +@@ -11,2 +11,3 @@ use std::{ + ++use const_format::concatcp; + use lazy_static::lazy_static; +@@ -16,3 +17,3 @@ use serde::{Deserialize, Serialize}; + use crate::{ +- constants::{PRODUCT_DOWNLOAD_URL, QUALITY, QUALITYLESS_PRODUCT_NAME}, ++ constants::{APPLICATION_NAME, PRODUCT_DOWNLOAD_URL, QUALITY, QUALITYLESS_PRODUCT_NAME}, + log, +@@ -245,3 +246,3 @@ pub fn prompt_to_install(version: &RequestedVersion) { + fn detect_installed_program(log: &log::Logger) -> io::Result> { +- use crate::constants::PRODUCT_NAME_LONG; ++ use crate::constants::{APPLICATION_NAME, PRODUCT_NAME_LONG}; + +@@ -251,3 +252,3 @@ fn detect_installed_program(log: &log::Logger) -> io::Result> { + if probable.exists() { +- probable.extend(["Contents/Resources", "app", "bin", "code"]); ++ probable.extend(["Contents/Resources", "app", "bin", APPLICATION_NAME]); + return Ok(vec![probable]); +@@ -296,3 +297,3 @@ fn detect_installed_program(log: &log::Logger) -> io::Result> { + output.push( +- [suffix.trim(), "Contents/Resources", "app", "bin", "code"] ++ [suffix.trim(), "Contents/Resources", "app", "bin", APPLICATION_NAME] + .iter() +@@ -401,7 +402,7 @@ fn detect_installed_program(log: &log::Logger) -> io::Result> { + const DESKTOP_CLI_RELATIVE_PATH: &str = if cfg!(target_os = "macos") { +- "Contents/Resources/app/bin/code" ++ concatcp!("Contents/Resources/app/bin/", APPLICATION_NAME) + } else if cfg!(target_os = "windows") { +- "bin/code.cmd,bin/code-insiders.cmd,bin/code-exploration.cmd" ++ concatcp!("bin/", APPLICATION_NAME, ".cmd") + } else { +- "bin/code,bin/code-insiders,bin/code-exploration" ++ concatcp!("bin/", APPLICATION_NAME) + }; diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index 2c3b710..8041f08 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts diff --git a/patches/feat-cli-pinning.patch b/patches/feat-cli-pinning.patch new file mode 100644 index 00000000000..31e2bd73b89 --- /dev/null +++ b/patches/feat-cli-pinning.patch @@ -0,0 +1,234 @@ +diff --git a/cli/src/bin/code/main.rs b/cli/src/bin/code/main.rs +index b73d0aa..d60d6be 100644 +--- a/cli/src/bin/code/main.rs ++++ b/cli/src/bin/code/main.rs +@@ -10,3 +10,3 @@ use clap::Parser; + use cli::{ +- commands::{args, serve_web, tunnels, update, version, CommandContext}, ++ commands::{args, pin, serve_web, tunnels, update, version, CommandContext}, + constants::get_default_user_agent, +@@ -67,2 +67,3 @@ async fn main() -> Result<(), std::convert::Infallible> { + args::StandaloneCommands::Update(args) => update::update(context!(), args).await, ++ args::StandaloneCommands::Pin(args) => pin::pin(context!(), args).await, + }, +diff --git a/cli/src/commands.rs b/cli/src/commands.rs +index 0277169..d4dfe66 100644 +--- a/cli/src/commands.rs ++++ b/cli/src/commands.rs +@@ -8,2 +8,3 @@ mod context; + pub mod args; ++pub mod pin; + pub mod serve_web; +diff --git a/cli/src/commands/args.rs b/cli/src/commands/args.rs +index 6301bdd..692e06b 100644 +--- a/cli/src/commands/args.rs ++++ b/cli/src/commands/args.rs +@@ -154,2 +154,39 @@ pub enum StandaloneCommands { + Update(StandaloneUpdateArgs), ++ /// Manage extension version pins for Codex projects. ++ Pin(PinArgs), ++} ++ ++#[derive(Args, Debug, Clone)] ++pub struct PinArgs { ++ /// The project name or ID. If not provided, lists all projects. ++ pub project: Option, ++ ++ #[clap(subcommand)] ++ pub subcommand: Option, ++} ++ ++#[derive(Subcommand, Debug, Clone)] ++pub enum PinSubcommand { ++ /// List pins for the project (default). ++ List, ++ /// Pin an extension to a specific version via VSIX URL. ++ Add(PinAddArgs), ++ /// Remove a version pin. ++ Remove(PinRemoveArgs), ++ /// Undo metadata.json changes. ++ Reset, ++ /// Sync pin changes with remote. ++ Sync, ++} ++ ++#[derive(Args, Debug, Clone)] ++pub struct PinAddArgs { ++ /// URL to the VSIX artifact (typically a GitHub Release asset). ++ pub url: String, ++} ++ ++#[derive(Args, Debug, Clone)] ++pub struct PinRemoveArgs { ++ /// The extension identifier to unpin (e.g. 'publisher.name'). ++ pub id: String, + } +diff --git a/cli/src/util/errors.rs b/cli/src/util/errors.rs +index b7ed029..6ed4439 100644 +--- a/cli/src/util/errors.rs ++++ b/cli/src/util/errors.rs +@@ -437,2 +437,11 @@ impl Display for DbusConnectFailedError { + ++#[derive(Debug)] ++pub struct PinningError(pub String); ++ ++impl std::fmt::Display for PinningError { ++ fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { ++ write!(f, "extension version pinning error: {}", self.0) ++ } ++} ++ + /// Internal errors in the VS Code CLI. +@@ -550,2 +559,3 @@ makeAnyError!( + InvalidRpcDataError, ++ PinningError, + CodeError, +diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts +index a10f4c9..c75e211 100644 +--- a/src/vs/platform/environment/common/argv.ts ++++ b/src/vs/platform/environment/common/argv.ts +@@ -26,2 +26,5 @@ export interface NativeParsedArgs { + 'serve-web'?: INativeCliOptions; ++ pin?: { ++ _: string[]; ++ }; + chat?: { +diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts +index 35a833d..590ef12 100644 +--- a/src/vs/platform/environment/node/argv.ts ++++ b/src/vs/platform/environment/node/argv.ts +@@ -47,3 +47,3 @@ export type OptionDescriptions = { + +-export const NATIVE_CLI_COMMANDS = ['tunnel', 'serve-web'] as const; ++export const NATIVE_CLI_COMMANDS = ['tunnel', 'serve-web', 'pin'] as const; + +@@ -94,2 +94,9 @@ export const OPTIONS: OptionDescriptions> = { + }, ++ 'pin': { ++ type: 'subcommand', ++ description: localize('pinExtension', "Manage extension version pins for Codex projects."), ++ options: { ++ _: { type: 'string[]' } ++ } ++ }, + 'diff': { type: 'boolean', cat: 'o', alias: 'd', args: ['file', 'file'], description: localize('diff', "Compare two files with each other.") }, +diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts +index 8041f08..3c3d891 100644 +--- a/src/vs/platform/native/electron-main/nativeHostMainService.ts ++++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts +@@ -423,23 +423,34 @@ export class NativeHostMainService extends Disposable implements INativeHostMain + async installShellCommand(windowId: number | undefined): Promise { +- const { source, target } = await this.getShellCommandLink(); +- +- // Only install unless already existing +- try { +- const { symbolicLink } = await SymlinkSupport.stat(source); +- if (symbolicLink && !symbolicLink.dangling) { +- const linkTargetRealPath = await Promises.realpath(source); +- if (target === linkTargetRealPath) { +- return; ++ const links = await this.getShellCommandLinks(); ++ ++ // Only install unless all already existing ++ let allExist = true; ++ for (const link of links) { ++ try { ++ const { symbolicLink } = await SymlinkSupport.stat(link.source); ++ if (symbolicLink && !symbolicLink.dangling) { ++ const linkTargetRealPath = await Promises.realpath(link.source); ++ if (link.target === linkTargetRealPath) { ++ continue; ++ } + } ++ allExist = false; ++ break; ++ } catch (error) { ++ if (error.code !== 'ENOENT') { ++ throw error; ++ } ++ allExist = false; ++ break; + } +- } catch (error) { +- if (error.code !== 'ENOENT') { +- throw error; // throw on any error but file not found +- } + } + +- await this.installShellCommandWithPrivileges(windowId, source, target); ++ if (allExist) { ++ return; ++ } ++ ++ await this.installShellCommandWithPrivileges(windowId, links); + } + +- private async installShellCommandWithPrivileges(windowId: number | undefined, source: string, target: string): Promise { ++ private async installShellCommandWithPrivileges(windowId: number | undefined, links: { source: string; target: string }[]): Promise { + const { response } = await this.showMessageBox(windowId, { +@@ -458,6 +469,7 @@ export class NativeHostMainService extends Disposable implements INativeHostMain + try { +- const command = `osascript -e "do shell script \\"mkdir -p /usr/local/bin && ln -sf \'${target}\' \'${source}\'\\" with administrator privileges"`; ++ const commands = links.map(link => `ln -sf '${link.target}' '${link.source}'`).join(' && '); ++ const command = `osascript -e "do shell script \\"mkdir -p /usr/local/bin && ${commands}\\" with administrator privileges"`; + await promisify(exec)(command); + } catch (error) { +- throw new Error(localize('cantCreateBinFolder', "Unable to install the shell command '{0}'.", source)); ++ throw new Error(localize('cantCreateBinFolder', "Unable to install the shell command.")); + } +@@ -466,6 +478,8 @@ export class NativeHostMainService extends Disposable implements INativeHostMain + async uninstallShellCommand(windowId: number | undefined): Promise { +- const { source } = await this.getShellCommandLink(); ++ const links = await this.getShellCommandLinks(); + + try { +- await fs.promises.unlink(source); ++ for (const link of links) { ++ await fs.promises.unlink(link.source); ++ } + } catch (error) { +@@ -487,6 +501,7 @@ export class NativeHostMainService extends Disposable implements INativeHostMain + try { +- const command = `osascript -e "do shell script \\"rm \'${source}\'\\" with administrator privileges"`; ++ const commands = links.map(link => `rm -f '${link.source}'`).join(' && '); ++ const command = `osascript -e "do shell script \\"${commands}\\" with administrator privileges"`; + await promisify(exec)(command); + } catch (error) { +- throw new Error(localize('cantUninstall', "Unable to uninstall the shell command '{0}'.", source)); ++ throw new Error(localize('uninstallFailed', "Unable to uninstall the shell command.")); + } +@@ -502,13 +517,26 @@ export class NativeHostMainService extends Disposable implements INativeHostMain + +- private async getShellCommandLink(): Promise<{ readonly source: string; readonly target: string }> { +- const target = resolve(this.environmentMainService.appRoot, 'bin', this.productService.applicationName); +- const source = `/usr/local/bin/${this.productService.applicationName}`; ++ private async getShellCommandLinks(): Promise<{ readonly source: string; readonly target: string }[]> { ++ const links: { source: string; target: string }[] = []; ++ ++ // Main 'codex' command ++ const mainTarget = resolve(this.environmentMainService.appRoot, 'bin', this.productService.applicationName); ++ const mainSource = `/usr/local/bin/${this.productService.applicationName}`; ++ if (await Promises.exists(mainTarget)) { ++ links.push({ source: mainSource, target: mainTarget }); ++ } ++ ++ // 'codex-cli' command pointing to 'codex-tunnel' ++ if (this.productService.tunnelApplicationName) { ++ const tunnelTarget = resolve(this.environmentMainService.appRoot, 'bin', this.productService.tunnelApplicationName); ++ const tunnelSource = '/usr/local/bin/codex-cli'; ++ if (await Promises.exists(tunnelTarget)) { ++ links.push({ source: tunnelSource, target: tunnelTarget }); ++ } ++ } + +- // Ensure source exists +- const sourceExists = await Promises.exists(target); +- if (!sourceExists) { +- throw new Error(localize('sourceMissing', "Unable to find shell script in '{0}'", target)); ++ if (links.length === 0) { ++ throw new Error(localize('sourceMissing', "Unable to find shell scripts in '{0}'", resolve(this.environmentMainService.appRoot, 'bin'))); + } + +- return { source, target }; ++ return links; + } diff --git a/patches/feat-codex-allow-profile-extension-updates.patch b/patches/feat-codex-allow-profile-extension-updates.patch new file mode 100644 index 00000000000..1ee96eeacfa --- /dev/null +++ b/patches/feat-codex-allow-profile-extension-updates.patch @@ -0,0 +1,12 @@ +diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +index f6c294e..b57e29a 100644 +--- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts ++++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +@@ -151,3 +151,2 @@ Registry.as(ConfigurationExtensions.Configuration) + default: true, +- scope: ConfigurationScope.APPLICATION, + tags: ['usesOnlineServices'] +@@ -158,3 +157,2 @@ Registry.as(ConfigurationExtensions.Configuration) + default: true, +- scope: ConfigurationScope.APPLICATION, + tags: ['usesOnlineServices'] diff --git a/patches/feat-codex-conductor.patch b/patches/feat-codex-conductor.patch new file mode 100644 index 00000000000..6aebb937d47 --- /dev/null +++ b/patches/feat-codex-conductor.patch @@ -0,0 +1,10 @@ +diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts +index e7c16a7..5ede7d5 100644 +--- a/src/vs/workbench/workbench.common.main.ts ++++ b/src/vs/workbench/workbench.common.main.ts +@@ -325,2 +325,5 @@ import './contrib/keybindings/browser/keybindings.contribution.js'; + ++// Codex ++import './contrib/codexConductor/browser/codexConductor.contribution.js'; ++ + // Snippets diff --git a/patches/feat-codex-sideloader.patch b/patches/feat-codex-sideloader.patch new file mode 100644 index 00000000000..2dc8fdb6b2a --- /dev/null +++ b/patches/feat-codex-sideloader.patch @@ -0,0 +1,8 @@ +diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts +index 5ede7d5..89fcb25 100644 +--- a/src/vs/workbench/workbench.common.main.ts ++++ b/src/vs/workbench/workbench.common.main.ts +@@ -327,2 +327,3 @@ import './contrib/keybindings/browser/keybindings.contribution.js'; + import './contrib/codexConductor/browser/codexConductor.contribution.js'; ++import './contrib/codexSideloader/browser/codexSideloader.contribution.js'; + diff --git a/patches/zzz-authoritative-reload.patch b/patches/zzz-authoritative-reload.patch new file mode 100644 index 00000000000..291ba87ac01 --- /dev/null +++ b/patches/zzz-authoritative-reload.patch @@ -0,0 +1,113 @@ +diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts +index 75a302b..5c91eac 100644 +--- a/src/vs/platform/native/common/native.ts ++++ b/src/vs/platform/native/common/native.ts +@@ -204,7 +204,7 @@ export interface ICommonNativeHostService { + // Lifecycle + notifyReady(): Promise; + relaunch(options?: { addArgs?: string[]; removeArgs?: string[] }): Promise; +- reload(options?: { disableExtensions?: boolean }): Promise; ++ reload(options?: { disableExtensions?: boolean; forceProfile?: string }): Promise; + closeWindow(options?: INativeHostOptions): Promise; + quit(): Promise; + exit(code: number): Promise; +diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts +index 2c3b710..121e545 100644 +--- a/src/vs/platform/native/electron-main/nativeHostMainService.ts ++++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts +@@ -934,7 +934,7 @@ export class NativeHostMainService extends Disposable implements INativeHostMain + return this.lifecycleMainService.relaunch(options); + } + +- async reload(windowId: number | undefined, options?: { disableExtensions?: boolean }): Promise { ++ async reload(windowId: number | undefined, options?: { disableExtensions?: boolean; forceProfile?: string }): Promise { + const window = this.codeWindowById(windowId); + if (window) { + +@@ -954,7 +954,11 @@ export class NativeHostMainService extends Disposable implements INativeHostMain + } + + // Proceed normally to reload the window +- return this.lifecycleMainService.reload(window, options?.disableExtensions !== undefined ? { _: [], 'disable-extensions': options.disableExtensions } : undefined); ++ return this.lifecycleMainService.reload(window, { ++ _: [], ++ 'disable-extensions': options?.disableExtensions, ++ 'profile': options?.forceProfile ++ }); + } + } + +diff --git a/src/vs/platform/windows/electron-main/windowImpl.ts b/src/vs/platform/windows/electron-main/windowImpl.ts +index 63652a5..3511ecd 100644 +--- a/src/vs/platform/windows/electron-main/windowImpl.ts ++++ b/src/vs/platform/windows/electron-main/windowImpl.ts +@@ -1271,9 +1271,22 @@ export class CodeWindow extends BaseWindow implements ICodeWindow { + configuration.isInitialStartup = false; // since this is a reload + configuration.policiesData = this.policyService.serialize(); // set policies data again + configuration.continueOn = this.environmentMainService.continueOn; ++ ++ const ws = configuration.workspace; ++ let profile: IUserDataProfile | undefined; ++ if (cli?.profile) { ++ profile = this.userDataProfilesService.profiles.find(p => p.name === cli.profile); ++ } ++ if (!profile && ws) { ++ const revivedWS = isSingleFolderWorkspaceIdentifier(ws) ? { id: ws.id, uri: URI.revive(ws.uri) } : ws; ++ profile = this.userDataProfilesService.getProfileForWorkspace(revivedWS); ++ } ++ ++ profile = profile || this.profile || this.userDataProfilesService.defaultProfile; ++ + configuration.profiles = { + all: this.userDataProfilesService.profiles, +- profile: this.profile || this.userDataProfilesService.defaultProfile, ++ profile, + home: this.userDataProfilesService.profilesHome + }; + configuration.logLevel = this.loggerMainService.getLogLevel(); +diff --git a/src/vs/platform/windows/electron-main/windowsMainService.ts b/src/vs/platform/windows/electron-main/windowsMainService.ts +index 117dfd2..68a9c06 100644 +--- a/src/vs/platform/windows/electron-main/windowsMainService.ts ++++ b/src/vs/platform/windows/electron-main/windowsMainService.ts +@@ -1669,12 +1669,9 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic + const profile = profilePromise instanceof Promise ? await profilePromise : profilePromise; + configuration.profiles.profile = profile; + +- if (!configuration.extensionDevelopmentPath) { +- // Associate the configured profile to the workspace +- // unless the window is for extension development, +- // where we do not persist the associations +- await this.userDataProfilesMainService.setProfileForWorkspace(workspace, profile); +- } ++ // Associate the configured profile to the workspace. ++ // For Codex, we want this to persist even during extension development. ++ await this.userDataProfilesMainService.setProfileForWorkspace(workspace, profile); + + // Load it + window.load(configuration); +diff --git a/src/vs/workbench/services/host/browser/host.ts b/src/vs/workbench/services/host/browser/host.ts +index 4ac35c9..23e7bab 100644 +--- a/src/vs/workbench/services/host/browser/host.ts ++++ b/src/vs/workbench/services/host/browser/host.ts +@@ -111,7 +111,7 @@ export interface IHostService { + /** + * Reload the currently active main window. + */ +- reload(options?: { disableExtensions?: boolean }): Promise; ++ reload(options?: { disableExtensions?: boolean; forceProfile?: string }): Promise; + + /** + * Attempt to close the active main window. +diff --git a/src/vs/workbench/services/host/electron-browser/nativeHostService.ts b/src/vs/workbench/services/host/electron-browser/nativeHostService.ts +index 9ca38b2..dd7cf9b 100644 +--- a/src/vs/workbench/services/host/electron-browser/nativeHostService.ts ++++ b/src/vs/workbench/services/host/electron-browser/nativeHostService.ts +@@ -187,7 +187,7 @@ class WorkbenchHostService extends Disposable implements IHostService { + return this.nativeHostService.relaunch(); + } + +- reload(options?: { disableExtensions?: boolean }): Promise { ++ reload(options?: { disableExtensions?: boolean; forceProfile?: string }): Promise { + return this.nativeHostService.reload(options); + } + diff --git a/prepare_assets.sh b/prepare_assets.sh index 269cefac265..dffb8fd806e 100755 --- a/prepare_assets.sh +++ b/prepare_assets.sh @@ -4,6 +4,30 @@ set -e APP_NAME_LC="$( echo "${APP_NAME}" | awk '{print tolower($0)}' )" +YELLOW=$'\033[33m' +RESET=$'\033[0m' + +# Local dev packaging often runs without CI-exported asset toggles or macOS +# signing secrets. Default optional inputs so sourcing this script remains safe. +CERTIFICATE_OSX_P12_DATA="${CERTIFICATE_OSX_P12_DATA:-}" +CERTIFICATE_OSX_P12_PASSWORD="${CERTIFICATE_OSX_P12_PASSWORD:-}" +CERTIFICATE_OSX_ID="${CERTIFICATE_OSX_ID:-}" +CERTIFICATE_OSX_TEAM_ID="${CERTIFICATE_OSX_TEAM_ID:-}" +CERTIFICATE_OSX_APP_PASSWORD="${CERTIFICATE_OSX_APP_PASSWORD:-}" +SHOULD_BUILD_ZIP="${SHOULD_BUILD_ZIP:-yes}" +SHOULD_BUILD_DMG="${SHOULD_BUILD_DMG:-yes}" +SHOULD_BUILD_SRC="${SHOULD_BUILD_SRC:-no}" +SHOULD_BUILD_TAR="${SHOULD_BUILD_TAR:-yes}" +SHOULD_BUILD_DEB="${SHOULD_BUILD_DEB:-yes}" +SHOULD_BUILD_RPM="${SHOULD_BUILD_RPM:-yes}" +SHOULD_BUILD_APPIMAGE="${SHOULD_BUILD_APPIMAGE:-yes}" +SHOULD_BUILD_EXE_SYS="${SHOULD_BUILD_EXE_SYS:-yes}" +SHOULD_BUILD_EXE_USR="${SHOULD_BUILD_EXE_USR:-yes}" +SHOULD_BUILD_MSI="${SHOULD_BUILD_MSI:-yes}" +SHOULD_BUILD_MSI_NOUP="${SHOULD_BUILD_MSI_NOUP:-yes}" +SHOULD_BUILD_REH="${SHOULD_BUILD_REH:-no}" +SHOULD_BUILD_REH_WEB="${SHOULD_BUILD_REH_WEB:-no}" +SHOULD_BUILD_CLI="${SHOULD_BUILD_CLI:-yes}" mkdir -p assets @@ -71,10 +95,17 @@ if [[ "${OS_NAME}" == "osx" ]]; then cd .. fi - if [[ -n "${CERTIFICATE_OSX_P12_DATA}" && "${SHOULD_BUILD_DMG}" != "no" ]]; then + if [[ "${SHOULD_BUILD_DMG}" != "no" ]]; then echo "Building and moving DMG" pushd "VSCode-darwin-${VSCODE_ARCH}" - npx create-dmg ./*.app . + if [[ -z "${CERTIFICATE_OSX_P12_DATA}" ]]; then + printf '%s\n' "${YELLOW}Warning: generating an unsigned macOS DMG because no Developer ID signing certificate is configured. Team members may see Gatekeeper warnings when opening it.${RESET}" + fi + npx create-dmg ./*.app . || true + if ! ls ./*.dmg 1>/dev/null 2>&1; then + echo "Error: DMG creation failed β€” no .dmg file was produced" >&2 + exit 1 + fi mv ./*.dmg "../assets/${APP_NAME}.${VSCODE_ARCH}.${RELEASE_VERSION}.dmg" popd fi diff --git a/prepare_vscode.sh b/prepare_vscode.sh index 5d1f7890546..fab809f9160 100755 --- a/prepare_vscode.sh +++ b/prepare_vscode.sh @@ -90,16 +90,16 @@ if [[ "${VSCODE_QUALITY}" == "insider" ]]; then setpath "product" "win32ContextMenu.x64.clsid" "90AAD229-85FD-43A3-B82D-8598A88829CF" setpath "product" "win32ContextMenu.arm64.clsid" "7544C31C-BDBF-4DDF-B15E-F73A46D6723D" else - setpath "product" "nameShort" "Codex" - setpath "product" "nameLong" "Codex" - setpath "product" "applicationName" "codex" - setpath "product" "linuxIconName" "codex" + setpath "product" "nameShort" "${APP_NAME}" + setpath "product" "nameLong" "${APP_NAME}" + setpath "product" "applicationName" "${BINARY_NAME}" + setpath "product" "linuxIconName" "${BINARY_NAME}" setpath "product" "quality" "stable" - setpath "product" "dataFolderName" ".codex" - setpath "product" "urlProtocol" "codex" - setpath "product" "serverApplicationName" "codex-server" - setpath "product" "serverDataFolderName" ".codex-server" - setpath "product" "darwinBundleIdentifier" "com.codex" + setpath "product" "dataFolderName" ".${BINARY_NAME}" + setpath "product" "urlProtocol" "${BINARY_NAME}" + setpath "product" "serverApplicationName" "${BINARY_NAME}-server" + setpath "product" "serverDataFolderName" ".${BINARY_NAME}-server" + setpath "product" "darwinBundleIdentifier" "com.${BINARY_NAME}" setpath "product" "win32AppUserModelId" "Codex.Codex" setpath "product" "win32DirName" "Codex" setpath "product" "win32MutexName" "codex" diff --git a/product.json b/product.json index 2f75e161d31..fa195c4d3d6 100644 --- a/product.json +++ b/product.json @@ -598,5 +598,11 @@ "gruntfuggly.todo-tree": { "default": false } - } + }, + "codexSideloadExtensions": [ + "project-accelerate.codex-editor-extension", + "project-accelerate.shared-state-store", + "project-accelerate.vscode-edit-table", + "frontier-rnd.frontier-authentication" + ] } diff --git a/src/stable/cli/src/commands/pin.rs b/src/stable/cli/src/commands/pin.rs new file mode 100644 index 00000000000..d95f5a02c6e --- /dev/null +++ b/src/stable/cli/src/commands/pin.rs @@ -0,0 +1,491 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +use crate::{ + commands::args::{PinAddArgs, PinArgs, PinRemoveArgs, PinSubcommand}, + log, + util::errors::{wrap, AnyError, PinningError}, +}; +use serde::{Deserialize, Serialize}; +use std::{ + fs, + io::Read, + path::{Path, PathBuf}, + process::Command, +}; + +use super::context::CommandContext; + +const CODEX_PROJECTS_DIR: &str = ".codex-projects"; + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct ProjectMetadata { + #[serde(rename = "projectName", default)] + project_name: String, + #[serde(rename = "projectId", default)] + project_id: String, + #[serde(default)] + meta: Meta, + #[serde(flatten)] + extra: serde_json::Value, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +struct Meta { + #[serde(rename = "requiredExtensions", default)] + required_extensions: std::collections::HashMap, + #[serde(rename = "pinnedExtensions", default)] + pinned_extensions: std::collections::HashMap, + #[serde(flatten)] + extra: serde_json::Value, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct PinnedExtension { + version: String, + url: String, +} + +struct ProjectInfo { + path: PathBuf, + metadata: ProjectMetadata, +} + +pub async fn pin(ctx: CommandContext, args: PinArgs) -> Result { + match (&args.project, &args.subcommand) { + (None, _) | (Some(_), Some(PinSubcommand::List)) | (Some(_), None) => { + let project_filter = if let Some(p) = &args.project { + Some(resolve_project(&ctx, p)?) + } else { + None + }; + list_pins(&ctx, project_filter)?; + } + (Some(p), Some(PinSubcommand::Add(add_args))) => add_pin(ctx, p.clone(), add_args.clone()).await?, + (Some(p), Some(PinSubcommand::Remove(remove_args))) => remove_pin(ctx, p.clone(), remove_args.clone())?, + (Some(p), Some(PinSubcommand::Reset)) => reset_pin(ctx, p.clone())?, + (Some(p), Some(PinSubcommand::Sync)) => sync_pin(ctx, p.clone()).await?, + } + + Ok(0) +} + +fn discover_projects(ctx: &CommandContext) -> Result, AnyError> { + // Use LauncherPaths root to find home directory reliably + let home_dir = ctx.paths.root().parent() + .and_then(|p| p.parent()) + .map(|p| p.to_path_buf()) + .or_else(dirs::home_dir) + .ok_or_else(|| AnyError::PinningError(PinningError("Could not find home directory".to_string())))?; + + let projects_dir = home_dir.join(CODEX_PROJECTS_DIR); + + let mut projects = Vec::new(); + + if projects_dir.exists() && projects_dir.is_dir() { + for entry in fs::read_dir(projects_dir).map_err(|e| wrap(e, "Failed to read projects directory"))? { + let entry = entry.map_err(|e| wrap(e, "Failed to read directory entry"))?; + let path = entry.path(); + + if path.is_dir() { + let metadata_path = path.join("metadata.json"); + if metadata_path.exists() { + match read_metadata(&metadata_path) { + Ok(metadata) => projects.push(ProjectInfo { path, metadata }), + Err(e) => { + log::emit(log::Level::Warn, "pin", &format!("Failed to read metadata at {}: {}", metadata_path.display(), e)); + } + } + } + } + } + } + + Ok(projects) +} + +fn read_metadata(path: &Path) -> Result { + let file = fs::File::open(path).map_err(|e| wrap(e, "Failed to open metadata.json"))?; + let metadata: ProjectMetadata = serde_json::from_reader(file).map_err(|e| wrap(e, "Failed to parse metadata.json"))?; + Ok(metadata) +} + +fn write_metadata(path: &Path, metadata: &ProjectMetadata) -> Result<(), AnyError> { + use std::io::Write; + let mut file = fs::File::create(path).map_err(|e| wrap(e, "Failed to create metadata.json"))?; + let formatter = serde_json::ser::PrettyFormatter::with_indent(b" "); + let mut ser = serde_json::Serializer::with_formatter(&mut file, formatter); + metadata.serialize(&mut ser).map_err(|e| wrap(e, "Failed to write metadata.json"))?; + file.write_all(b"\n").map_err(|e| wrap(e, "Failed to write trailing newline"))?; + Ok(()) +} + +fn truncate_url(url: &str) -> String { + if let Ok(parsed_url) = url::Url::parse(url) { + let mut segments = parsed_url.path_segments().map(|c| c.collect::>()).unwrap_or_default(); + if segments.len() > 3 { + let filename = segments.pop().unwrap_or(""); + let first_two = segments.iter().take(2).copied().collect::>().join("/"); + format!("{}://{}/{}/.../{}", parsed_url.scheme(), parsed_url.host_str().unwrap_or(""), first_two, filename) + } else { + url.to_string() + } + } else { + url.to_string() + } +} + +fn has_git() -> bool { + Command::new("git").arg("--version").output().is_ok() +} + +fn is_metadata_dirty(project_path: &Path) -> bool { + Command::new("git") + .arg("status") + .arg("--porcelain") + .arg("metadata.json") + .current_dir(project_path) + .output() + .map(|o| !o.stdout.is_empty()) + .unwrap_or(false) +} + +fn list_pins(ctx: &CommandContext, project_filter: Option) -> Result<(), AnyError> { + let projects = if let Some(p) = project_filter { + vec![p] + } else { + discover_projects(ctx)? + }; + + let git_available = has_git(); + if !git_available { + println!("Warning: 'git' not found in PATH. Skipping dirty checks."); + } + + for project in projects { + println!( + "{} {} {}", + project.metadata.project_name, + project.metadata.project_id, + project.path.display() + ); + + if !project.metadata.meta.required_extensions.is_empty() { + let mut reqs = String::new(); + let mut ids: Vec<_> = project.metadata.meta.required_extensions.keys().collect(); + ids.sort(); + for id in ids { + let version = &project.metadata.meta.required_extensions[id]; + reqs.push_str(&format!("βš“ {} {} ", id, version)); + } + println!(" {}", reqs.trim_end()); + } + + let mut pinned_ids: Vec<_> = project.metadata.meta.pinned_extensions.keys().collect(); + pinned_ids.sort(); + for id in pinned_ids { + let pin = &project.metadata.meta.pinned_extensions[id]; + println!(" πŸ“Œ {} {} {}", id, pin.version, pin.url); + } + + if git_available && is_metadata_dirty(&project.path) { + println!(" πŸ“€ metadata.json has changes, please sync or reset: codex-cli pin {} sync", project.metadata.project_id); + } + println!(); + } + + println!("Usage:"); + println!(" codex pin List all projects and pins"); + println!(" codex pin List pins for a project"); + println!(" codex pin add Add a version pin"); + println!(" codex pin remove Remove a version pin"); + println!(" codex pin reset Undo metadata.json changes"); + println!(" codex pin sync Sync pin changes with remote"); + + Ok(()) +} + +fn resolve_project(ctx: &CommandContext, project_identifier: &str) -> Result { + let projects = discover_projects(ctx)?; + let mut matches: Vec = projects + .into_iter() + .filter(|p| p.metadata.project_id == project_identifier || p.metadata.project_name == project_identifier) + .collect(); + + if matches.is_empty() { + return Err(AnyError::PinningError(PinningError(format!("No project found matching '{}'", project_identifier)))); + } else if matches.len() > 1 { + let mut msg = format!("Multiple projects found matching '{}'. Please use the ID:\n", project_identifier); + for m in matches { + msg.push_str(&format!("- {} ({})\n", m.metadata.project_name, m.metadata.project_id)); + } + return Err(AnyError::PinningError(PinningError(msg))); + } + + Ok(matches.remove(0)) +} + +/// Resolves a GitHub release page URL to a direct VSIX download URL. +/// If the URL is already a direct URL (not a release page), returns it unchanged. +/// +/// Matches: https://github.com/{owner}/{repo}/releases/tag/{tag} +async fn resolve_vsix_url(client: &reqwest::Client, url: &str) -> Result { + let url = url.trim(); + const PREFIX: &str = "https://github.com/"; + const RELEASES_TAG: &str = "/releases/tag/"; + + if !url.starts_with(PREFIX) { + return Ok(url.to_string()); + } + + let after_host = &url[PREFIX.len()..]; + let tag_pos = match after_host.find(RELEASES_TAG) { + Some(pos) => pos, + None => return Ok(url.to_string()), + }; + + let owner_repo = &after_host[..tag_pos]; + let tag = &after_host[tag_pos + RELEASES_TAG.len()..]; + + if owner_repo.is_empty() || tag.is_empty() || owner_repo.matches('/').count() != 1 { + return Ok(url.to_string()); + } + + // Percent-encode characters that are unsafe in URL path segments. + // Tags are typically semver (0.24.1-pr123) so only + is a realistic risk. + let encoded_tag = tag.replace('%', "%25").replace(' ', "%20").replace('+', "%2B"); + let api_url = format!("https://api.github.com/repos/{}/releases/tags/{}", owner_repo, encoded_tag); + log::emit(log::Level::Info, "pin", &format!("Resolving release page: {}", api_url)); + + let resp = client + .get(&api_url) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "codex-cli") + .send() + .await + .map_err(|e| wrap(e, "Failed to query GitHub API"))? + .error_for_status() + .map_err(|e| wrap(e, "GitHub API returned an error"))?; + + let release: serde_json::Value = resp.json().await.map_err(|e| wrap(e, "Failed to parse GitHub API response"))?; + + let assets = release["assets"] + .as_array() + .ok_or_else(|| AnyError::PinningError(PinningError("No assets found in GitHub release".to_string())))?; + + let vsix_asset = assets + .iter() + .find(|a| a["name"].as_str().map_or(false, |n| n.ends_with(".vsix"))) + .ok_or_else(|| AnyError::PinningError(PinningError("No .vsix asset found in GitHub release".to_string())))?; + + let download_url = vsix_asset["browser_download_url"] + .as_str() + .ok_or_else(|| AnyError::PinningError(PinningError("Missing download URL for .vsix asset".to_string())))?; + + log::emit(log::Level::Info, "pin", &format!("Resolved to: {}", download_url)); + Ok(download_url.to_string()) +} + +async fn add_pin(ctx: CommandContext, project_id: String, args: PinAddArgs) -> Result<(), AnyError> { + let mut project_info = resolve_project(&ctx, &project_id)?; + + // Resolve release page URLs to direct VSIX download URLs + let resolved_url = resolve_vsix_url(&ctx.http, &args.url).await?; + + log::emit(log::Level::Info, "pin", &format!("Inspecting VSIX at {}...", truncate_url(&resolved_url))); + + let (extension_id, version) = get_vsix_metadata_full(&ctx.http, &resolved_url).await?; + + log::emit(log::Level::Info, "pin", &format!("βœ” Identified: {} (v{})", extension_id, version)); + + // Update metadata + project_info.metadata.meta.pinned_extensions.insert( + extension_id.clone(), + PinnedExtension { + version: version.to_string(), + url: resolved_url, + }, + ); + + let metadata_path = project_info.path.join("metadata.json"); + write_metadata(&metadata_path, &project_info.metadata)?; + + log::emit(log::Level::Info, "pin", &format!("βœ” Updated metadata.json for \"{}\"", project_info.metadata.project_name)); + println!("Pinned {} to {}", extension_id, version); + + Ok(()) +} + +// TODO: Range-based VSIX metadata extraction. +// +// The idea is to avoid downloading the entire VSIX (~50 MB) just to read +// extension/package.json (~2 KB). ZIP's central directory is stored at the +// end of the file, so fetching the last ~16 KB via an HTTP Range request +// would give us the file index. From that we could locate the +// extension/package.json entry and fetch only its byte range. +// +// Steps that would be needed: +// 1. HEAD request β†’ get Content-Length +// 2. GET with Range: bytes=(len-16384)-(len-1) β†’ central directory +// 3. Parse the EOCD / CD entries to find extension/package.json offset+size +// 4. GET with Range for just that entry β†’ decompress β†’ parse JSON +// +// Removed the previous stub (get_vsix_metadata_smart) because it was making +// real HEAD + Range requests to GitHub on every `pin add` invocation and then +// unconditionally falling through to the full download anyway β€” wasting two +// round-trips per call. Until the CD parsing is implemented, we call +// get_vsix_metadata_full() directly. + +async fn get_vsix_metadata_full(client: &reqwest::Client, url: &str) -> Result<(String, String), AnyError> { + let response = client.get(url).send().await?.error_for_status()?; + let bytes = response.bytes().await?; + + let reader = std::io::Cursor::new(bytes); + let mut zip = zip::ZipArchive::new(reader).map_err(|e| wrap(e, "Failed to read VSIX as ZIP"))?; + + let mut package_json_bytes = Vec::new(); + let mut found = false; + + for i in 0..zip.len() { + let mut file = zip.by_index(i).map_err(|e| wrap(e, "Failed to read file from ZIP"))?; + if file.name() == "extension/package.json" { + file.read_to_end(&mut package_json_bytes).map_err(|e| wrap(e, "Failed to read package.json from ZIP"))?; + found = true; + break; + } + } + + if !found { + return Err(AnyError::PinningError(PinningError("Could not find extension/package.json in VSIX".to_string()))); + } + + let package_json: serde_json::Value = serde_json::from_slice(&package_json_bytes).map_err(|e| wrap(e, "Failed to parse package.json"))?; + + let publisher = package_json["publisher"] + .as_str() + .ok_or_else(|| AnyError::PinningError(PinningError("Missing publisher in package.json".to_string())))?; + let name = package_json["name"] + .as_str() + .ok_or_else(|| AnyError::PinningError(PinningError("Missing name in package.json".to_string())))?; + let version = package_json["version"] + .as_str() + .ok_or_else(|| AnyError::PinningError(PinningError("Missing version in package.json".to_string())))?; + + Ok((format!("{}.{}", publisher, name), version.to_string())) +} + +fn remove_pin(ctx: CommandContext, project_id: String, args: PinRemoveArgs) -> Result<(), AnyError> { + let mut project_info = resolve_project(&ctx, &project_id)?; + + if project_info.metadata.meta.pinned_extensions.remove(&args.id).is_some() { + let metadata_path = project_info.path.join("metadata.json"); + write_metadata(&metadata_path, &project_info.metadata)?; + log::emit(log::Level::Info, "pin", &format!("βœ” Removed pin for {}", args.id)); + } else { + log::emit(log::Level::Warn, "pin", &format!("No pin found for {} in project {}", args.id, project_info.metadata.project_name)); + } + + Ok(()) +} + +fn reset_pin(ctx: CommandContext, project_id: String) -> Result<(), AnyError> { + if !has_git() { + return Err(AnyError::PinningError(PinningError("'git' not found in PATH".to_string()))); + } + + let project_info = resolve_project(&ctx, &project_id)?; + + log::emit(log::Level::Info, "pin", &format!("Resetting metadata.json for {}...", project_info.metadata.project_name)); + + let status = Command::new("git") + .arg("checkout") + .arg("--") + .arg("metadata.json") + .current_dir(&project_info.path) + .status() + .map_err(|e| wrap(e, "Failed to execute git checkout"))?; + + if !status.success() { + return Err(AnyError::PinningError(PinningError(format!("git checkout failed with exit code {}", status.code().unwrap_or(-1))))); + } + + log::emit(log::Level::Info, "pin", "βœ” Reset successful"); + Ok(()) +} + +async fn sync_pin(ctx: CommandContext, project_id: String) -> Result<(), AnyError> { + if !has_git() { + return Err(AnyError::PinningError(PinningError("'git' not found in PATH".to_string()))); + } + + let project_info = resolve_project(&ctx, &project_id)?; + + if is_metadata_dirty(&project_info.path) { + log::emit(log::Level::Info, "pin", &format!("Syncing changes for {}...", project_info.metadata.project_name)); + + // git add metadata.json + let status = Command::new("git") + .arg("add") + .arg("metadata.json") + .current_dir(&project_info.path) + .status() + .map_err(|e| wrap(e, "Failed to execute git add"))?; + if !status.success() { + return Err(AnyError::PinningError(PinningError("git add failed".to_string()))); + } + + // git commit -m "Update extension pins" + let status = Command::new("git") + .arg("commit") + .arg("-m") + .arg("Update extension pins") + .current_dir(&project_info.path) + .status() + .map_err(|e| wrap(e, "Failed to execute git commit"))?; + if !status.success() { + return Err(AnyError::PinningError(PinningError("git commit failed".to_string()))); + } + + // git pull --rebase + let status = Command::new("git") + .arg("pull") + .arg("--rebase") + .current_dir(&project_info.path) + .status() + .map_err(|e| wrap(e, "Failed to execute git pull"))?; + if !status.success() { + return Err(AnyError::PinningError(PinningError("git pull --rebase failed".to_string()))); + } + + // git push + let status = Command::new("git") + .arg("push") + .current_dir(&project_info.path) + .status() + .map_err(|e| wrap(e, "Failed to execute git push"))?; + if !status.success() { + return Err(AnyError::PinningError(PinningError("git push failed".to_string()))); + } + + log::emit(log::Level::Info, "pin", "βœ” Sync successful"); + } else { + log::emit(log::Level::Info, "pin", &format!("No local changes to sync for {}. Fetching remote updates...", project_info.metadata.project_name)); + + // git pull --rebase + let status = Command::new("git") + .arg("pull") + .arg("--rebase") + .current_dir(&project_info.path) + .status() + .map_err(|e| wrap(e, "Failed to execute git pull"))?; + if !status.success() { + return Err(AnyError::PinningError(PinningError("git pull --rebase failed".to_string()))); + } + + log::emit(log::Level::Info, "pin", "βœ” Sync successful"); + } + + Ok(()) +} diff --git a/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexConductor.contribution.ts b/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexConductor.contribution.ts new file mode 100644 index 00000000000..f2852b2f743 --- /dev/null +++ b/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexConductor.contribution.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Frontier R&D Ltd. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; +import { CodexConductorContribution } from './codexConductor.js'; +import './codexPinManager.js'; + +registerWorkbenchContribution2(CodexConductorContribution.ID, CodexConductorContribution, WorkbenchPhase.AfterRestored); diff --git a/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexConductor.ts b/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexConductor.ts new file mode 100644 index 00000000000..091a7445a09 --- /dev/null +++ b/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexConductor.ts @@ -0,0 +1,963 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Frontier R&D Ltd. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IWorkspaceContextService, WorkbenchState, toWorkspaceIdentifier, isSingleFolderWorkspaceIdentifier } from '../../../../platform/workspace/common/workspace.js'; +import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js'; +import { IUserDataProfile, IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js'; +import { IWorkbenchExtensionManagementService } from '../../../services/extensionManagement/common/extensionManagement.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { URI } from '../../../../base/common/uri.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { parse as parseJsonc } from '../../../../base/common/json.js'; +import { applyEdits, setProperty } from '../../../../base/common/jsonEdit.js'; +import { FormattingOptions } from '../../../../base/common/jsonFormatter.js'; +import { joinPath } from '../../../../base/common/resources.js'; +import { IHostService } from '../../../services/host/browser/host.js'; +import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; +import { ISharedProcessService } from '../../../../platform/ipc/electron-browser/services.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { OS, OperatingSystem } from '../../../../base/common/platform.js'; +import { timeout } from '../../../../base/common/async.js'; +import { PinnedExtensions, RequiredExtensions, ProjectMetadata, parsePinnedExtensions } from './codexTypes.js'; + +/** Maps profile name β†’ array of project folder URIs that reference it. */ +type ProfileAssociations = Record; + +const CODEX_EDITOR_EXTENSION_ID = 'project-accelerate.codex-editor-extension'; +const CIRCUIT_BREAKER_KEY = 'codex.conductor.enforcementAttempts'; +const CIRCUIT_BREAKER_MAX = 3; +const CIRCUIT_BREAKER_WINDOW_MS = 30_000; +const CONDUCTOR_PROFILE_ICON = 'repo-pinned'; +const FRONTIER_EXTENSION_ID = 'frontier-rnd.frontier-authentication'; +const PROFILE_ASSOCIATIONS_KEY = 'codex.conductor.profileAssociations'; +const LAST_CLEANUP_KEY = 'codex.conductor.lastCleanup'; +const CLEANUP_INTERVAL_MS = 14 * 24 * 60 * 60 * 1000; // 14 days + +const ADMIN_PINNED_EXTENSIONS_KEY = 'codex.conductor.adminPinnedExtensions'; +const REMOTE_PINNED_EXTENSIONS_KEY = 'codex.conductor.remotePinnedExtensions'; +const SYNC_COMPLETED_AT_KEY = 'codex.conductor.syncCompletedAt'; + +/** Strip publisher prefix and common suffixes to get a short profile-friendly name. */ +function shortName(extensionId: string): string { + const afterDot = extensionId.includes('.') ? extensionId.slice(extensionId.indexOf('.') + 1) : extensionId; + return afterDot.replace(/-extension$/, ''); +} + +export class CodexConductorContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.codexConductor'; + + private metadataUri: URI | undefined; + private lastSeenPinsSnapshot: string | undefined; + private readonly syncCompletionListener = this._register(new DisposableStore()); + + constructor( + @IFileService private readonly fileService: IFileService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @IUserDataProfileService private readonly userDataProfileService: IUserDataProfileService, + @IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService, + @IWorkbenchExtensionManagementService private readonly extensionManagementService: IWorkbenchExtensionManagementService, + @IStorageService private readonly storageService: IStorageService, + @INotificationService private readonly notificationService: INotificationService, + @IHostService private readonly hostService: IHostService, + @ILogService private readonly logService: ILogService, + @ISharedProcessService private readonly sharedProcessService: ISharedProcessService, + @IDialogService private readonly dialogService: IDialogService, + @IClipboardService private readonly clipboardService: IClipboardService, + @IProductService private readonly productService: IProductService, + ) { + super(); + + this._register(CommandsRegistry.registerCommand('codex.conductor.cleanupProfiles', () => this.runProfileCleanup())); + this._register(CommandsRegistry.registerCommand('codex.conductor.getEffectivePinnedExtensions', () => this.readEffectivePinsInternal())); + + this._register(CommandsRegistry.registerCommand('codex.conductor.setAdminPinIntent', (_accessor, pins: PinnedExtensions) => { + this.storageService.store(ADMIN_PINNED_EXTENSIONS_KEY, JSON.stringify(pins), StorageScope.WORKSPACE, StorageTarget.MACHINE); + })); + + this._register(CommandsRegistry.registerCommand('codex.conductor.clearAdminPinIntent', () => { + this.storageService.remove(ADMIN_PINNED_EXTENSIONS_KEY, StorageScope.WORKSPACE); + })); + + this._register(CommandsRegistry.registerCommand('codex.conductor.hasAdminPinIntent', () => { + const raw = this.storageService.get(ADMIN_PINNED_EXTENSIONS_KEY, StorageScope.WORKSPACE); + return !!raw; + })); + + this._register(CommandsRegistry.registerCommand('codex.conductor.setRemotePins', (_accessor, pins: PinnedExtensions | null | undefined) => { + if (pins && Object.keys(pins).length > 0) { + this.storageService.store(REMOTE_PINNED_EXTENSIONS_KEY, JSON.stringify(pins), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } else { + this.storageService.remove(REMOTE_PINNED_EXTENSIONS_KEY, StorageScope.WORKSPACE); + } + })); + + this._register(CommandsRegistry.registerCommand('codex.conductor.getPinMismatches', async () => { + const pins = await this.readEffectivePinsInternal(); + if (!pins) { return []; } + + const installed = await this.extensionManagementService.getInstalled(); + const mismatches: { extensionId: string; pinnedVersion: string; runningVersion: string | null }[] = []; + for (const [id, pin] of Object.entries(pins)) { + const ext = installed.find(e => e.identifier.id.toLowerCase() === id.toLowerCase()); + if (!ext || ext.manifest.version !== pin.version) { + mismatches.push({ extensionId: id, pinnedVersion: pin.version, runningVersion: ext?.manifest.version || null }); + } + } + return mismatches; + })); + + this._register(CommandsRegistry.registerCommand('codex.conductor.setSyncCompletedAt', (_accessor, timestamp: number) => { + this.storageService.store(SYNC_COMPLETED_AT_KEY, timestamp, StorageScope.WORKSPACE, StorageTarget.MACHINE); + })); + + this._register(this.workspaceContextService.onDidChangeWorkbenchState(() => this.initialize())); + + this.initialize(); + } + + private async initialize(): Promise { + if (this.workspaceContextService.getWorkbenchState() !== WorkbenchState.FOLDER) { + this.metadataUri = undefined; + await this.revertIfPatchBuild(); + return; + } + + const workspaceFolder = this.workspaceContextService.getWorkspace().folders[0]; + this.metadataUri = joinPath(workspaceFolder.uri, 'metadata.json'); + + // Snapshot current pins before enforcement + this.lastSeenPinsSnapshot = await this.readPinsSnapshot(); + + // Backfill: if we're already sitting on a conductor profile (no reload + // needed this session), make sure its settings still disable update + // checks. Handles users whose profiles were created before this change. + const currentProfile = this.userDataProfileService.currentProfile; + if (currentProfile.icon === CONDUCTOR_PROFILE_ICON) { + await this.seedProfileSettings(currentProfile); + } + + // Run initial enforcement + await this.enforce(); + + // Periodic profile cleanup (every 14 days) + await this.maybeCleanupOrphanedProfiles(); + + // Listen for sync completions from Frontier + this.listenForSyncCompletion(); + + await this.logStartupExtensionState(); + } + + // ── Mid-session signals ──────────────────────────────────────────── + + /** + * Listens for Frontier's workspace state changes via IStorageService. + * When Frontier writes to its workspaceState (e.g. after a sync), this fires. + * We then check if pinnedExtensions in metadata.json have changed and prompt + * the user to reload if so. + */ + private listenForSyncCompletion(): void { + this.syncCompletionListener.clear(); + + const storageListener = this.storageService.onDidChangeValue( + StorageScope.WORKSPACE, + undefined, // listen to all keys in this scope + this.syncCompletionListener + )((e) => { + if (e.key === REMOTE_PINNED_EXTENSIONS_KEY || e.key === SYNC_COMPLETED_AT_KEY || e.key === ADMIN_PINNED_EXTENSIONS_KEY) { + this.checkForPinChanges(); + } + }); + + this.syncCompletionListener.add(storageListener); + } + + private async checkForPinChanges(): Promise { + const currentSnapshot = await this.readPinsSnapshot(); + if (currentSnapshot === this.lastSeenPinsSnapshot) { + return; + } + + this.lastSeenPinsSnapshot = currentSnapshot; + + if (!currentSnapshot) { + // Pins were removed β€” prompt reload to revert to default profile. + // Use switchProfileAndReload() so the workspace-profile association + // is persisted (not just the immediate reload target). + const defaultProfile = this.userDataProfilesService.profiles.find(p => p.isDefault); + if (!defaultProfile) { return; } + this.notificationService.prompt( + Severity.Info, + 'Extension version pins have been removed. Reload to revert to the default profile.', + [{ + label: 'Reload Codex', + run: () => this.switchProfileAndReload(defaultProfile) + }] + ); + return; + } + + // New or changed pins β€” need to prepare the profile before reloading. + let pins: PinnedExtensions; + try { + const parsed = parsePinnedExtensions(JSON.parse(currentSnapshot)); + if (!parsed) { return; } + pins = parsed; + } catch { + return; + } + + const targetProfileName = this.resolveProfileName(pins); + const existingProfile = this.userDataProfilesService.profiles.find(p => p.name === targetProfileName); + + if (existingProfile && await this.validateProfileExtensions(existingProfile, pins)) { + // Profile already exists and is complete β€” prompt reload via switchProfileAndReload() + // which persists the workspace-profile association before reloading. + this.notificationService.prompt( + Severity.Info, + 'Pinned extension installed. Reload to apply.', + [{ + label: 'Reload Codex', + run: () => this.switchProfileAndReload(existingProfile) + }] + ); + return; + } + + if (existingProfile) { + this.logService.warn(`[CodexConductor] Profile "${targetProfileName}" exists but is missing pinned extensions β€” repairing`); + } + + // Profile doesn't exist or is incomplete β€” download and install, then prompt. + // Show progress notification with "Reload Codex When Ready" option. + let reloadWhenReady = false; + + const handle = this.notificationService.prompt( + Severity.Info, + 'Installing pinned extension\u2026', + [{ + label: 'Reload Codex When Ready', + run: () => { reloadWhenReady = true; } + }] + ); + handle.progress.infinite(); + + try { + // Reuse the existing incomplete profile or create a new one. + const profile = existingProfile + ?? await this.userDataProfilesService.createNamedProfile(targetProfileName, { icon: CONDUCTOR_PROFILE_ICON }); + + try { + await this.installPinnedExtensions(pins, profile); + } catch (e: unknown) { + // Installation failed after all retries β€” cleanup the incomplete profile + // (only if it's not the current profile, which cannot be deleted). + if (profile.id !== this.userDataProfileService.currentProfile.id) { + try { + await this.userDataProfilesService.removeProfile(profile); + this.logService.info(`[CodexConductor] Cleaned up incomplete profile "${targetProfileName}" after installation failure`); + } catch (cleanupError) { + this.logService.warn(`[CodexConductor] Failed to clean up incomplete profile "${targetProfileName}": ${cleanupError}`); + } + } + throw e; + } + + handle.close(); + + if (reloadWhenReady) { + // User already opted in β€” reload immediately + await this.switchProfileAndReload(profile); + } else { + // Show completion notification with reload button + this.notificationService.prompt( + Severity.Info, + 'Pinned extension installed. Reload to apply.', + [{ + label: 'Reload Codex', + run: () => this.switchProfileAndReload(profile) + }] + ); + } + } catch (e: unknown) { + handle.close(); + this.notificationService.prompt( + Severity.Error, + 'Failed to install pinned extension.', + [{ + label: 'Copy Error Report', + run: () => this.showErrorReport(pins, e) + }] + ); + } + } + + private async installPinnedExtensions(pins: PinnedExtensions, profile: IUserDataProfile): Promise { + // Use the shared process 'extensions' IPC channel directly to bypass + // NativeExtensionManagementService.downloadVsix(), which downloads in the + // renderer using browser fetch() β€” that fails for GitHub release URLs due + // to CORS on the 302 redirect. The shared process downloads via Node.js + // networking which handles redirects without CORS restrictions. + const channel = this.sharedProcessService.getChannel('extensions'); + + for (const [id, pin] of Object.entries(pins)) { + let lastError: Error | undefined; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + this.logService.info(`[CodexConductor] Installing pinned VSIX for "${id}" v${pin.version} from ${pin.url} (attempt ${attempt}/3)`); + + await channel.call('install', [URI.parse(pin.url), { + installGivenVersion: true, + pinned: true, + profileLocation: profile.extensionsResource + }]); + lastError = undefined; + break; // Success + } catch (e: unknown) { + lastError = e instanceof Error ? e : new Error(String(e)); + (lastError as any).extensionId = id; + (lastError as any).url = pin.url; + const code = (lastError as any).code ? ` [Code: ${(lastError as any).code}]` : ''; + const stack = lastError.stack ? `\nStack: ${lastError.stack}` : ''; + this.logService.error(`[CodexConductor] Failed to install pinned extension ${id} from ${pin.url} (attempt ${attempt}/3) [Online: ${navigator.onLine}]: ${lastError.message}${code}${stack}`); + console.error(`[CodexConductor] Installation error for ${id} (attempt ${attempt}/3):`, lastError); + + if (attempt < 3) { + const delay = Math.pow(2, attempt) * 1000; + await timeout(delay); + } + } + } + + if (lastError) { + throw lastError; + } + } + } + + /** + * Returns a stable JSON snapshot of currently active pins from the prioritized + * source (local metadata.json or remote storage). + */ + private async readPinsSnapshot(): Promise { + const pins = await this.readEffectivePinsInternal(); + if (!pins) { return undefined; } + // Canonicalize both top-level key order and nested entry field order + // so the snapshot is stable regardless of parse/write iteration order. + const sorted = Object.keys(pins).sort().reduce((acc, k) => { + const e = pins[k]; + acc[k] = { url: e.url, version: e.version }; + return acc; + }, {}); + return JSON.stringify(sorted); + } + + private async logStartupExtensionState(): Promise { + const installed = await this.extensionManagementService.getInstalled(); + const codexEditorVersion = installed.find(e => e.identifier.id.toLowerCase() === CODEX_EDITOR_EXTENSION_ID)?.manifest.version ?? 'not installed'; + const frontierAuthVersion = installed.find(e => e.identifier.id.toLowerCase() === FRONTIER_EXTENSION_ID)?.manifest.version ?? 'not installed'; + const currentProfileName = this.userDataProfileService.currentProfile.name; + const requiredExtensions = await this.readRequiredExtensionsFromMetadata(); + const pinnedExtensions = await this.readEffectivePinnedExtensions(); + + this.logService.info( + `[CodexConductor] Startup extension state β€” profile=${currentProfileName}, ${CODEX_EDITOR_EXTENSION_ID}=${codexEditorVersion}, ${FRONTIER_EXTENSION_ID}=${frontierAuthVersion}, pinnedExtensions=${this.formatObjectForLog(pinnedExtensions)}, requiredExtensions=${this.formatObjectForLog(requiredExtensions)}` + ); + } + + /** + * Reads project metadata from metadata.json on disk. + */ + private async readProjectMetadata(): Promise { + if (!this.metadataUri) { + return undefined; + } + + try { + const content = await this.fileService.readFile(this.metadataUri); + try { + return JSON.parse(content.value.toString()) as ProjectMetadata; + } catch (parseError) { + this.logService.warn('[CodexConductor] metadata.json contains invalid JSON β€” extension pinning disabled'); + return undefined; + } + } catch { + return undefined; + } + } + + /** + * Reads the effective pinned extensions by considering: + * 1. Admin Intent (adminPinnedExtensions in storage) - Absolute precedence. + * 2. Remote Pins (remotePinnedExtensions in storage) - Authoritative for users. + * 3. Local Pins (metadata.json on disk) - Fallback. + */ + private async readEffectivePinsInternal(): Promise { + // 1. Check Admin Intent (highest precedence) + const rawAdmin = this.storageService.get(ADMIN_PINNED_EXTENSIONS_KEY, StorageScope.WORKSPACE); + if (rawAdmin) { + try { + const adminIntent = parsePinnedExtensions(JSON.parse(rawAdmin)); + if (adminIntent) { + // We only honor the intent if it matches what's currently running. + // This prevents "intent leakage" if the admin manually changes + // extensions without using the conductor. + const installed = await this.extensionManagementService.getInstalled(); + let matchesRunning = true; + for (const [id, pin] of Object.entries(adminIntent)) { + const ext = installed.find(e => e.identifier.id.toLowerCase() === id.toLowerCase()); + if (!ext || ext.manifest.version !== pin.version) { + matchesRunning = false; + break; + } + } + + if (matchesRunning) { + this.logService.trace('[CodexConductor] Admin intent active and matches running version β€” prioritizing.'); + return adminIntent; + } + } + } catch { + this.logService.warn('[CodexConductor] Malformed admin intent in storage'); + } + } + + // 2. Check Remote Pins (authoritative for users) + const rawRemote = this.storageService.get(REMOTE_PINNED_EXTENSIONS_KEY, StorageScope.WORKSPACE); + if (rawRemote) { + try { + const remotePins = parsePinnedExtensions(JSON.parse(rawRemote)); + if (remotePins) { + this.logService.trace('[CodexConductor] Remote pins found in storage β€” prioritizing over metadata.json'); + return remotePins; + } + } catch { + this.logService.warn('[CodexConductor] Malformed remote pins in storage'); + } + } + + // 3. Fall back to metadata.json on disk + const metadata = await this.readProjectMetadata(); + return parsePinnedExtensions(metadata?.meta?.pinnedExtensions); + } + + private async readRequiredExtensionsFromMetadata(): Promise { + const metadata = await this.readProjectMetadata(); + return metadata?.meta?.requiredExtensions || {}; + } + + private async readEffectivePinnedExtensions(): Promise { + return (await this.readEffectivePinsInternal()) || {}; + } + + private formatObjectForLog(value: T): string { + const sortedEntries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right)); + return JSON.stringify(Object.fromEntries(sortedEntries)); + } + + // ── Enforcement ──────────────────────────────────────────────────── + + private async enforce(): Promise { + if (!this.metadataUri) { + return; + } + + const workspaceFolder = this.workspaceContextService.getWorkspace().folders[0]; + const pins = await this.readEffectivePinsInternal(); + + if (!pins) { + // No active pins β€” remove this project from any profile associations + this.removeCurrentProjectFromAssociations(); + await this.revertIfPatchBuild(); + return; + } + + await this.enforcePins(pins, workspaceFolder.uri); + } + + private async enforcePins(pins: PinnedExtensions, workspaceUri: URI): Promise { + const installed = await this.extensionManagementService.getInstalled(); + const mismatches: string[] = []; + + for (const [id, pin] of Object.entries(pins)) { + const ext = installed.find(e => e.identifier.id.toLowerCase() === id.toLowerCase()); + if (!ext || ext.manifest.version !== pin.version) { + mismatches.push(`${id}: expected ${pin.version}, found ${ext?.manifest.version || 'none'}`); + } + } + + if (mismatches.length === 0) { + return; + } + + if (this.checkCircuitBreaker()) { + this.notificationService.prompt( + Severity.Error, + 'Something went wrong while switching profiles.', + [{ + label: 'Open in Default Profile', + run: () => this.switchToDefaultProfile() + }, { + label: 'Copy Error Report', + run: () => this.showErrorReport(pins, undefined, mismatches) + }] + ); + return; + } + + const targetProfileName = this.resolveProfileName(pins); + this.recordAttempt(); + + // Track this project's association with the profile + this.addProfileAssociation(targetProfileName, workspaceUri.toString()); + + this.logService.info(`[CodexConductor] Switching to profile "${targetProfileName}" β€” version pin active`); + + const existingProfile = this.userDataProfilesService.profiles.find(p => p.name === targetProfileName); + if (existingProfile) { + if (await this.validateProfileExtensions(existingProfile, pins)) { + // Profile is complete β€” just switch. + this.logService.info(`[CodexConductor] Profile "${targetProfileName}" already exists and is complete β€” switching without download`); + await this.switchProfileAndReload(existingProfile); + return; + } + // Profile exists but is incomplete (interrupted install?) β€” repair it. + this.logService.warn(`[CodexConductor] Profile "${targetProfileName}" exists but is missing pinned extensions β€” repairing`); + } + + // Reuse the existing incomplete profile or create a new one. + const profile = existingProfile + ?? await this.userDataProfilesService.createNamedProfile(targetProfileName, { icon: CONDUCTOR_PROFILE_ICON }); + + try { + await this.installPinnedExtensions(pins, profile); + } catch (e: unknown) { + // Installation failed after all retries β€” cleanup the incomplete profile + // (only if it's not the current profile, which cannot be deleted). + if (profile.id !== this.userDataProfileService.currentProfile.id) { + try { + await this.userDataProfilesService.removeProfile(profile); + this.logService.info(`[CodexConductor] Cleaned up incomplete profile "${targetProfileName}" after installation failure`); + } catch (cleanupError) { + this.logService.warn(`[CodexConductor] Failed to clean up incomplete profile "${targetProfileName}": ${cleanupError}`); + } + } + + this.notificationService.prompt( + Severity.Error, + 'Failed to install pinned extension.', + [{ + label: 'Open in Default Profile', + run: () => this.switchToDefaultProfile() + }, { + label: 'Copy Error Report', + run: () => this.showErrorReport(pins, e) + }] + ); + return; + } + + await this.switchProfileAndReload(profile); + } + + private async revertIfPatchBuild(): Promise { + if (this.userDataProfileService.currentProfile.isDefault) { + return; + } + + // Only revert if the current profile was created by the conductor + const currentProfile = this.userDataProfileService.currentProfile; + if (currentProfile.icon !== CONDUCTOR_PROFILE_ICON) { + return; + } + + const defaultProfile = this.userDataProfilesService.profiles.find(p => p.isDefault); + if (defaultProfile) { + this.logService.info(`[CodexConductor] No active pins β€” reverting from "${currentProfile.name}" to default profile`); + await this.switchProfileAndReload(defaultProfile); + } + } + + // ── Profile lifecycle cleanup ────────────────────────────────────── + + /** + * Runs cleanup if at least CLEANUP_INTERVAL_MS has passed since the last run. + */ + private async maybeCleanupOrphanedProfiles(): Promise { + const lastCleanup = this.storageService.getNumber(LAST_CLEANUP_KEY, StorageScope.APPLICATION, 0); + if (Date.now() - lastCleanup < CLEANUP_INTERVAL_MS) { + return; + } + await this.runProfileCleanup(); + } + + /** + * Cleans up conductor-managed profiles that are no longer referenced by any + * project on disk. Can be called directly via the + * `codex.conductor.cleanupProfiles` command for testing. + * + * For each conductor profile, checks every associated project path: + * - If the project's metadata.json is unreadable (deleted, moved), remove + * the association. + * - If the project's pins no longer resolve to this profile name, remove + * the association. + * - If no associations remain, delete the profile. + */ + async runProfileCleanup(): Promise { + const associations = this.getProfileAssociations(); + const conductorProfiles = this.userDataProfilesService.profiles.filter( + p => !p.isDefault && p.icon === CONDUCTOR_PROFILE_ICON + ); + + if (conductorProfiles.length === 0) { + this.storageService.store(LAST_CLEANUP_KEY, Date.now(), StorageScope.APPLICATION, StorageTarget.MACHINE); + return; + } + + let removedCount = 0; + + for (const profile of conductorProfiles) { + // Don't remove the profile we're currently using + if (profile.id === this.userDataProfileService.currentProfile.id) { + continue; + } + + const projectPaths = associations[profile.name] || []; + const stillReferenced = await this.isProfileReferencedByAnyProject(profile.name, projectPaths); + + if (!stillReferenced) { + try { + await this.userDataProfilesService.removeProfile(profile); + delete associations[profile.name]; + removedCount++; + } catch { + // Profile may be in use by another window β€” skip silently + } + } + } + + this.storeProfileAssociations(associations); + this.storageService.store(LAST_CLEANUP_KEY, Date.now(), StorageScope.APPLICATION, StorageTarget.MACHINE); + + this.logService.info(`[CodexConductor] Profile cleanup complete β€” removed ${removedCount} orphaned profile${removedCount !== 1 ? 's' : ''}, ${conductorProfiles.length - removedCount} retained`); + } + + /** + * Checks if any of the given project paths still have pins that resolve + * to the given profile name. + */ + private async isProfileReferencedByAnyProject(profileName: string, projectPaths: string[]): Promise { + for (const projectPath of projectPaths) { + try { + const metadataUri = joinPath(URI.parse(projectPath), 'metadata.json'); + const content = await this.fileService.readFile(metadataUri); + const metadata = JSON.parse(content.value.toString()); + const pins = parsePinnedExtensions(metadata?.meta?.pinnedExtensions); + + if (pins && this.resolveProfileName(pins) === profileName) { + return true; + } + } catch { + // Project unreadable (deleted, moved) β€” not referencing + } + } + return false; + } + + // ── Profile association tracking ─────────────────────────────────── + + private getProfileAssociations(): ProfileAssociations { + const raw = this.storageService.get(PROFILE_ASSOCIATIONS_KEY, StorageScope.APPLICATION); + if (!raw) { return {}; } + try { + return JSON.parse(raw); + } catch { + return {}; + } + } + + private storeProfileAssociations(associations: ProfileAssociations): void { + this.storageService.store(PROFILE_ASSOCIATIONS_KEY, JSON.stringify(associations), StorageScope.APPLICATION, StorageTarget.MACHINE); + } + + private addProfileAssociation(profileName: string, projectUri: string): void { + const associations = this.getProfileAssociations(); + const paths = associations[profileName] || []; + if (!paths.includes(projectUri)) { + paths.push(projectUri); + } + associations[profileName] = paths; + this.storeProfileAssociations(associations); + } + + private removeCurrentProjectFromAssociations(): void { + const workspaceFolder = this.workspaceContextService.getWorkspace().folders[0]; + if (!workspaceFolder) { return; } + + const projectUri = workspaceFolder.uri.toString(); + const associations = this.getProfileAssociations(); + let changed = false; + + for (const profileName of Object.keys(associations)) { + const paths = associations[profileName]; + const idx = paths.indexOf(projectUri); + if (idx !== -1) { + paths.splice(idx, 1); + changed = true; + if (paths.length === 0) { + delete associations[profileName]; + } + } + } + + if (changed) { + this.storeProfileAssociations(associations); + } + } + + // ── Error reporting ──────────────────────────────────────────────── + + private async showErrorReport(pins: PinnedExtensions, error?: unknown, mismatches?: string[]): Promise { + const osName = OS === OperatingSystem.Macintosh ? 'macOS' : OS === OperatingSystem.Windows ? 'Windows' : 'Linux'; + const workspaceFolder = this.workspaceContextService.getWorkspace().folders[0]; + + const report = [ + '--- Codex Conductor Error Report ---', + '', + `Codex Version: ${this.productService.version || 'unknown'} (${this.productService.commit?.slice(0, 8) || 'unknown'})`, + `OS: ${osName}`, + `Profile: ${this.userDataProfileService.currentProfile.name}`, + `Project: ${workspaceFolder?.name || 'unknown'}`, + `Online: ${navigator.onLine}`, + '', + ]; + + if (error) { + const message = error instanceof Error ? error.message : String(error); + const code = (error as any).code ? ` [Code: ${(error as any).code}]` : ''; + const extensionId = (error as any).extensionId ? ` [Extension: ${(error as any).extensionId}]` : ''; + const url = (error as any).url ? ` [URL: ${(error as any).url}]` : ''; + + report.push('Error:'); + report.push(` - ${message}${code}${extensionId}${url}`); + report.push(''); + } + + if (mismatches && mismatches.length > 0) { + report.push('Mismatches:'); + report.push(...mismatches.map(m => ` - ${m}`)); + report.push(''); + } + + report.push('Pinned Extensions:'); + report.push(...Object.entries(pins).map(([id, pin]) => + ` - ${id}: v${pin.version} (${pin.url})` + )); + report.push(''); + report.push('---'); + + const fullReport = report.join('\n'); + + const { result } = await this.dialogService.prompt({ + type: Severity.Error, + message: 'Something went wrong while switching profiles', + detail: fullReport, + buttons: [ + { label: 'Copy to Clipboard', run: () => true }, + ], + cancelButton: 'Close', + }); + + if (await result) { + await this.clipboardService.writeText(fullReport); + } + } + + // ── Utilities ────────────────────────────────────────────────────── + + /** + * Validates that all pinned extensions are actually installed in the given + * profile. Uses `getInstalled(type, profileLocation)` to inspect a profile's + * extensions without switching to it. Returns false if any pinned extension + * is missing or at the wrong version (e.g. interrupted install left an + * incomplete profile). + */ + private async validateProfileExtensions(profile: IUserDataProfile, pins: PinnedExtensions): Promise { + try { + const installed = await this.extensionManagementService.getInstalled(undefined, profile.extensionsResource); + for (const [id, pin] of Object.entries(pins)) { + const ext = installed.find(e => e.identifier.id.toLowerCase() === id.toLowerCase()); + if (!ext || ext.manifest.version !== pin.version) { + return false; + } + } + return true; + } catch { + return false; + } + } + + private resolveProfileName(pins: PinnedExtensions): string { + const ids = Object.keys(pins).sort(); + const firstId = ids[0]; + const base = `${shortName(firstId)}-v${pins[firstId].version}`; + if (ids.length === 1) { return base; } + + // Simple hash of all id@version pairs for deterministic multi-pin names + let h = 5381; + const str = ids.map(id => `${id}@${pins[id].version}`).join(','); + for (let i = 0; i < str.length; i++) { h = (((h << 5) + h) ^ str.charCodeAt(i)) >>> 0; } + return `${base}+${h.toString(16).slice(0, 4)}`; + } + + private checkCircuitBreaker(): boolean { + const raw = this.storageService.get(CIRCUIT_BREAKER_KEY, StorageScope.WORKSPACE); + if (!raw) { return false; } + try { + const attempts: number[] = JSON.parse(raw); + const now = Date.now(); + const recent = attempts.filter(t => now - t < CIRCUIT_BREAKER_WINDOW_MS); + return recent.length >= CIRCUIT_BREAKER_MAX; + } catch { + return false; + } + } + + private recordAttempt(): void { + const raw = this.storageService.get(CIRCUIT_BREAKER_KEY, StorageScope.WORKSPACE); + let attempts: number[]; + try { + attempts = raw ? JSON.parse(raw) : []; + } catch { + attempts = []; + } + attempts.push(Date.now()); + // Prune old entries to prevent unbounded growth + const now = Date.now(); + attempts = attempts.filter(t => now - t < CIRCUIT_BREAKER_WINDOW_MS); + this.storageService.store(CIRCUIT_BREAKER_KEY, JSON.stringify(attempts), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + + /** + * Seeds a conductor-managed profile's settings.json with the keys needed to + * keep pinned extensions stable: disables the marketplace update check and + * auto-update for that profile only. Idempotent β€” skips the write when the + * desired values are already present. Requires the companion patch that + * drops APPLICATION scope from `extensions.autoCheckUpdates` / + * `extensions.autoUpdate` so that profile settings can override the + * user-level defaults. + * + * Uses jsonEdit.setProperty + applyEdits instead of a full parse/rewrite + * so any user-authored content in the file (comments, trailing commas, + * unrelated keys, custom formatting) is preserved byte-for-byte. + */ + private async seedProfileSettings(profile: IUserDataProfile): Promise { + if (profile.icon !== CONDUCTOR_PROFILE_ICON) { + return; + } + + const uri = profile.settingsResource; + let original = ''; + try { + const buf = await this.fileService.readFile(uri); + original = buf.value.toString(); + } catch { + // No file yet β€” writeFile below will create it. + } + + // parseJsonc tolerates JSONC. If it returns anything other than a + // plain object (malformed / array / scalar), fall back to an empty + // document so applyEdits has a valid structure to work on. + const parsed: unknown = original.trim() ? parseJsonc(original, []) : undefined; + const asObject = parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : undefined; + + if ( + asObject + && asObject['extensions.autoCheckUpdates'] === false + && asObject['extensions.autoUpdate'] === false + ) { + return; + } + + let text = asObject ? original : '{}'; + const formattingOptions: FormattingOptions = { tabSize: 4, insertSpaces: true, eol: '\n' }; + for (const [key, value] of [ + ['extensions.autoCheckUpdates', false], + ['extensions.autoUpdate', false], + ] as const) { + text = applyEdits(text, setProperty(text, [key], value, formattingOptions)); + } + + try { + await this.fileService.writeFile(uri, VSBuffer.fromString(text)); + this.logService.info(`[CodexConductor] Seeded profile "${profile.name}" settings β€” update checks disabled`); + } catch (e: unknown) { + this.logService.warn(`[CodexConductor] Failed to seed profile settings for "${profile.name}": ${e instanceof Error ? e.message : String(e)}`); + } + } + + /** + * switchProfile() for folder workspaces only persists the profile association + * (via setProfileForWorkspace) β€” it does NOT restart the extension host or + * change the active profile in the current session. A window reload is needed + * to make the switch effective. If the extension host restart is vetoed (e.g. + * a custom editor like Startup Flow is open), switchProfile() throws + * CancellationError and reverts the association β€” reload handles that too. + */ + private async switchProfileAndReload(profile: IUserDataProfile): Promise { + const workspace = this.workspaceContextService.getWorkspace(); + const workspaceIdentifier = toWorkspaceIdentifier(workspace); + const originalProfileId = this.userDataProfileService.currentProfile.id; + const currentProfileName = this.userDataProfileService.currentProfile.name; + + this.logService.info(`[CodexConductor] switchProfileAndReload: current=${currentProfileName}, target=${profile.name}`); + + // Ensure the target conductor profile has update-checks disabled before + // the reload commits. Harmless (no-op) for the default profile. + await this.seedProfileSettings(profile); + this.logService.info(`[CodexConductor] Workspace ID: ${workspaceIdentifier.id}`); + if (isSingleFolderWorkspaceIdentifier(workspaceIdentifier)) { + this.logService.info(`[CodexConductor] Workspace URI: ${workspaceIdentifier.uri.toString()}`); + } + + // Explicitly set the association for the workspace. + // For folder workspaces, this is the primary way VS Code associates a profile. + // updateProfile() cascades β€” assigning the workspace to this profile implicitly + // removes it from any other profile that still claims it, so no pre-cleanup is + // required. (Previously called resetWorkspaces() here, but that wiped every + // open project's associations globally.) + this.logService.info(`[CodexConductor] Calling setProfileForWorkspace...`); + await this.userDataProfilesService.setProfileForWorkspace(workspaceIdentifier, profile); + this.logService.info(`[CodexConductor] setProfileForWorkspace completed`); + + // Compare against the profile ID captured BEFORE setProfileForWorkspace. + // setProfileForWorkspace may internally trigger changeCurrentProfile which + // updates currentProfile even if the extension host vetos the switch. Using + // the post-call currentProfile.id would incorrectly skip the reload. + if (originalProfileId !== profile.id) { + this.logService.info(`[CodexConductor] Profile mismatch (${currentProfileName} != ${profile.name}) β€” triggering authoritative reload`); + this.hostService.reload({ forceProfile: profile.name }); + } else { + this.logService.info(`[CodexConductor] Already on target profile ${profile.name} β€” no reload needed`); + } + } + + private async switchToDefaultProfile(): Promise { + const profile = this.userDataProfilesService.profiles.find(p => p.isDefault); + if (profile) { + await this.switchProfileAndReload(profile); + } + } +} diff --git a/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexPinManager.ts b/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexPinManager.ts new file mode 100644 index 00000000000..573fb220d46 --- /dev/null +++ b/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexPinManager.ts @@ -0,0 +1,422 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Frontier R&D Ltd. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { ISharedProcessService } from '../../../../platform/ipc/electron-browser/services.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IProgressService, ProgressLocation } from '../../../../platform/progress/common/progress.js'; +import { asJson, IRequestService } from '../../../../platform/request/common/request.js'; +import { URI } from '../../../../base/common/uri.js'; +import { joinPath } from '../../../../base/common/resources.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ProjectMetadata } from './codexTypes.js'; + +interface GitHubRelease { + assets?: Array<{ + name: string; + browser_download_url: string; + }>; +} + +interface PinActionItem extends IQuickPickItem { + action: 'add' | 'remove' | 'sync' | 'info'; + extensionId?: string; +} + +/** Services needed by pin management sub-flows. */ +interface PinManagerContext { + readonly quickInputService: IQuickInputService; + readonly fileService: IFileService; + readonly notificationService: INotificationService; + readonly logService: ILogService; + readonly sharedProcessService: ISharedProcessService; + readonly requestService: IRequestService; + readonly dialogService: IDialogService; + readonly progressService: IProgressService; + readonly metadataUri: URI; +} + +const RELEASE_PAGE_PATTERN = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/releases\/tag\/(.+)$/; + +/** JSON indentation used by codex-editor for metadata.json. */ +const METADATA_INDENT = 4; + +/** + * Resolves a GitHub release page URL to a direct VSIX download URL. + * If the URL is not a release page, returns it unchanged. + */ +async function resolveVsixUrl(requestService: IRequestService, url: string, logService: ILogService): Promise { + const match = RELEASE_PAGE_PATTERN.exec(url.trim()); + if (!match) { + return url.trim(); + } + + const [, owner, repo, tag] = match; + const apiUrl = `https://api.github.com/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`; + + logService.info(`[CodexPinManager] Resolving release page: ${apiUrl}`); + + const context = await requestService.request( + { type: 'GET', url: apiUrl, headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': 'codex-pin-manager' } }, + CancellationToken.None + ); + const release = await asJson(context); + if (!release?.assets) { + throw new Error(localize('managePins.noAssets', 'No assets found in GitHub release "{0}"', tag)); + } + + const vsixAsset = release.assets.find(a => a.name.endsWith('.vsix')); + if (!vsixAsset) { + throw new Error(localize('managePins.noVsix', 'No .vsix asset found in GitHub release "{0}"', tag)); + } + + logService.info(`[CodexPinManager] Resolved to: ${vsixAsset.browser_download_url}`); + return vsixAsset.browser_download_url; +} + +function truncateUrl(url: string): string { + try { + const parsed = new URL(url); + const segments = parsed.pathname.split('/').filter(Boolean); + if (segments.length > 3) { + const first2 = segments.slice(0, 2).join('/'); + const last = segments[segments.length - 1]; + return `${parsed.origin}/${first2}/.../${last}`; + } + return url; + } catch { + return url; + } +} + +registerAction2(class ManageExtensionPinsAction extends Action2 { + constructor() { + super({ + id: 'codex.conductor.managePins', + title: localize2('managePins', 'Manage Extension Pins'), + category: localize2('codex', 'Codex'), + f1: true, + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const ctx: PinManagerContext = { + quickInputService: accessor.get(IQuickInputService), + fileService: accessor.get(IFileService), + notificationService: accessor.get(INotificationService), + logService: accessor.get(ILogService), + sharedProcessService: accessor.get(ISharedProcessService), + requestService: accessor.get(IRequestService), + dialogService: accessor.get(IDialogService), + progressService: accessor.get(IProgressService), + metadataUri: undefined!, + }; + + const workspaceService = accessor.get(IWorkspaceContextService); + const commandService = accessor.get(ICommandService); + + if (workspaceService.getWorkbenchState() !== WorkbenchState.FOLDER) { + ctx.notificationService.info(localize('managePins.noFolder', 'Open a project folder to manage extension pins.')); + return; + } + + const workspaceFolder = workspaceService.getWorkspace().folders[0]; + (ctx as { metadataUri: URI }).metadataUri = joinPath(workspaceFolder.uri, 'metadata.json'); + + // Hub loop β€” re-opens after each action until dismissed + while (true) { + const metadata = await readMetadata(ctx); + if (!metadata) { + ctx.notificationService.info(localize('managePins.noMetadata', 'Could not read metadata.json from the workspace.')); + return; + } + + const action = await showHub(ctx.quickInputService, metadata); + if (!action) { + return; // User dismissed + } + + switch (action.action) { + case 'add': + await addPin(ctx, commandService); + break; + case 'remove': + await removePin(ctx, metadata, commandService); + break; + case 'sync': + await syncChanges(commandService, ctx.notificationService, ctx.logService); + break; // Continue loop β€” re-read and show hub with post-sync state + case 'info': + break; // Re-show hub + } + } + } +}); + +async function readMetadata(ctx: PinManagerContext): Promise { + try { + const content = await ctx.fileService.readFile(ctx.metadataUri); + return JSON.parse(content.value.toString()) as ProjectMetadata; + } catch { + return undefined; + } +} + +async function writeMetadata(ctx: PinManagerContext, updater: (metadata: ProjectMetadata) => void): Promise { + const content = await ctx.fileService.readFile(ctx.metadataUri); + const metadata = JSON.parse(content.value.toString()) as ProjectMetadata; + + if (!metadata.meta) { + metadata.meta = {}; + } + if (!metadata.meta.pinnedExtensions) { + metadata.meta.pinnedExtensions = {}; + } + + updater(metadata); + + const updated = JSON.stringify(metadata, null, METADATA_INDENT) + '\n'; + await ctx.fileService.writeFile(ctx.metadataUri, VSBuffer.fromString(updated)); +} + +function showHub(quickInputService: IQuickInputService, metadata: ProjectMetadata): Promise { + return new Promise((resolve) => { + const disposables = new DisposableStore(); + const picker = quickInputService.createQuickPick({ useSeparators: true }); + disposables.add(picker); + + picker.title = localize('managePins.title', 'Manage Extension Pins'); + picker.placeholder = localize('managePins.placeholder', 'Select an action'); + picker.matchOnDescription = true; + picker.matchOnDetail = true; + + const items: (PinActionItem | IQuickPickSeparator)[] = []; + + // Required Extensions section + const required = metadata.meta?.requiredExtensions; + if (required) { + const entries: [string, string][] = []; + if (required.codexEditor) { entries.push(['codexEditor', required.codexEditor]); } + if (required.frontierAuthentication) { entries.push(['frontierAuthentication', required.frontierAuthentication]); } + + if (entries.length > 0) { + items.push({ type: 'separator', label: localize('managePins.required', 'Required Extensions') }); + entries.sort(([a], [b]) => a.localeCompare(b)); + for (const [id, version] of entries) { + items.push({ + label: `$(lock) ${id}`, + description: version, + action: 'info', + }); + } + } + } + + // Pinned Extensions section + const pinned = metadata.meta?.pinnedExtensions; + if (pinned && Object.keys(pinned).length > 0) { + items.push({ type: 'separator', label: localize('managePins.pinned', 'Pinned Extensions') }); + const sortedIds = Object.keys(pinned).sort(); + for (const id of sortedIds) { + const pin = pinned[id]; + items.push({ + label: `$(pinned) ${id}`, + description: `v${pin.version}`, + detail: truncateUrl(pin.url), + action: 'info', + extensionId: id, + }); + } + } + + // Actions section + items.push({ type: 'separator', label: localize('managePins.actions', 'Actions') }); + items.push({ label: localize('managePins.addAction', '$(add) Pin an Extension...'), action: 'add' }); + if (pinned && Object.keys(pinned).length > 0) { + items.push({ label: localize('managePins.removeAction', '$(trash) Remove a Pin...'), action: 'remove' }); + } + items.push({ label: localize('managePins.syncAction', '$(sync) Sync Changes'), action: 'sync' }); + + picker.items = items; + + let result: PinActionItem | undefined; + + disposables.add(picker.onDidAccept(() => { + const selected = picker.selectedItems[0]; + if (!selected || selected.action === 'info') { + return; // Keep picker open for non-actionable items + } + result = selected; + picker.hide(); + })); + + disposables.add(picker.onDidHide(() => { + disposables.dispose(); + resolve(result); + })); + + picker.show(); + }); +} + +async function updateAdminIntent(commandService: ICommandService, pins: Record | undefined, logService: ILogService): Promise { + try { + if (pins && Object.keys(pins).length > 0) { + await commandService.executeCommand('codex.conductor.setAdminPinIntent', pins); + } else { + await commandService.executeCommand('codex.conductor.clearAdminPinIntent'); + } + } catch (e: unknown) { + logService.warn(`[CodexPinManager] Failed to update admin pin intent: ${e}`); + } +} + +async function addPin(ctx: PinManagerContext, commandService: ICommandService): Promise { + // Step 1: Get URL from user + const url = await ctx.quickInputService.input({ + title: localize('managePins.addTitle', 'Pin an Extension'), + placeHolder: localize('managePins.addPlaceholder', 'https://github.com/.../releases/tag/0.24.1 or direct .vsix URL'), + prompt: localize('managePins.addPrompt', 'Enter a GitHub release page URL or direct VSIX download URL'), + }); + + if (!url) { + return; + } + + // Step 2: Resolve URL (release page β†’ VSIX download URL) and extract manifest + let extensionId: string; + let version: string; + let resolvedUrl: string; + + try { + const result = await ctx.progressService.withProgress( + { location: ProgressLocation.Notification, title: localize('managePins.inspecting', 'Inspecting VSIX...') }, + async () => { + const resolved = await resolveVsixUrl(ctx.requestService, url, ctx.logService); + const channel = ctx.sharedProcessService.getChannel('extensions'); + const manifest: { publisher?: string; name?: string; version?: string } = + await channel.call('getManifest', [URI.parse(resolved)]); + return { resolved, manifest }; + } + ); + + resolvedUrl = result.resolved; + const manifest = result.manifest; + + if (!manifest.publisher || !manifest.name || !manifest.version) { + ctx.notificationService.error(localize('managePins.badVsix', 'VSIX is missing publisher, name, or version in package.json.')); + return; + } + + extensionId = `${manifest.publisher}.${manifest.name}`; + version = manifest.version; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + ctx.notificationService.error(localize('managePins.inspectFailed', 'Failed to inspect VSIX: {0}', msg)); + return; + } + + // Step 3: Confirm + const { confirmed } = await ctx.dialogService.confirm({ + message: localize('managePins.confirmPin', 'Pin {0} at v{1}?', extensionId, version), + detail: localize('managePins.confirmPinDetail', 'This will pin {0} to version {1} for this project.', extensionId, version), + }); + + if (!confirmed) { + return; + } + + // Step 4: Write to metadata.json and signal intent + try { + let updatedPins: Record | undefined; + await writeMetadata(ctx, (m) => { + m.meta!.pinnedExtensions![extensionId] = { version, url: resolvedUrl }; + updatedPins = m.meta!.pinnedExtensions; + }); + await updateAdminIntent(commandService, updatedPins, ctx.logService); + ctx.logService.info(`[CodexPinManager] Pinned ${extensionId} to v${version}`); + ctx.notificationService.info(localize('managePins.pinned', 'Pinned {0} to v{1}.', extensionId, version)); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + ctx.notificationService.error(localize('managePins.writeFailed', 'Failed to update metadata.json: {0}', msg)); + } +} + +async function removePin(ctx: PinManagerContext, metadata: ProjectMetadata, commandService: ICommandService): Promise { + const pinned = metadata.meta?.pinnedExtensions; + if (!pinned || Object.keys(pinned).length === 0) { + ctx.notificationService.info(localize('managePins.noPins', 'No pinned extensions to remove.')); + return; + } + + // Step 1: Pick which pin to remove + const items: (IQuickPickItem & { extensionId: string })[] = Object.keys(pinned).sort().map(id => ({ + label: id, + description: `v${pinned[id].version}`, + extensionId: id, + })); + + const selected = await ctx.quickInputService.pick(items, { + title: localize('managePins.removeTitle', 'Remove a Pin'), + placeHolder: localize('managePins.removePlaceholder', 'Select a pinned extension to remove'), + }); + + if (!selected) { + return; + } + + const extensionId = (selected as typeof items[0]).extensionId; + + // Step 2: Confirm + const { confirmed } = await ctx.dialogService.confirm({ + message: localize('managePins.confirmRemove', 'Remove pin for {0}?', extensionId), + detail: localize('managePins.confirmRemoveDetail', 'This will unpin {0} from v{1}.', extensionId, pinned[extensionId].version), + }); + + if (!confirmed) { + return; + } + + // Step 3: Update metadata.json and signal intent + try { + let updatedPins: Record | undefined; + await writeMetadata(ctx, (m) => { + delete m.meta!.pinnedExtensions![extensionId]; + updatedPins = m.meta!.pinnedExtensions; + }); + await updateAdminIntent(commandService, updatedPins, ctx.logService); + ctx.logService.info(`[CodexPinManager] Removed pin for ${extensionId}`); + ctx.notificationService.info(localize('managePins.removed', 'Removed pin for {0}.', extensionId)); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + ctx.notificationService.error(localize('managePins.writeFailed', 'Failed to update metadata.json: {0}', msg)); + } +} + +async function syncChanges( + commandService: ICommandService, + notificationService: INotificationService, + logService: ILogService, +): Promise { + try { + logService.info('[CodexPinManager] Triggering Frontier sync...'); + await commandService.executeCommand('frontier.syncChanges'); + logService.info('[CodexPinManager] Frontier sync completed'); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logService.warn(`[CodexPinManager] Failed to trigger Frontier sync: ${msg}`); + notificationService.info(localize('managePins.syncFallback', 'Sync manually to share pin changes with your team.')); + } +} diff --git a/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexTypes.ts b/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexTypes.ts new file mode 100644 index 00000000000..db995306a3c --- /dev/null +++ b/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexTypes.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Frontier R&D Ltd. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface PinnedExtensionEntry { + version: string; + url: string; +} + +export type PinnedExtensions = Record; +export interface RequiredExtensions { + codexEditor?: string; + frontierAuthentication?: string; +} + +export interface ProjectMetadata { + meta?: { + pinnedExtensions?: PinnedExtensions; + requiredExtensions?: RequiredExtensions; + }; + [key: string]: unknown; +} + +/** + * Validates and extracts well-formed pinned extension entries from an unknown + * parsed JSON value. Returns only entries where the value has string `version` + * and `url` fields. Malformed entries are silently dropped. + */ +export function parsePinnedExtensions(value: unknown): PinnedExtensions | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + const result: PinnedExtensions = {}; + for (const [key, entry] of Object.entries(value as Record)) { + if ( + entry && typeof entry === 'object' && + typeof (entry as Record).version === 'string' && + typeof (entry as Record).url === 'string' + ) { + result[key] = entry as PinnedExtensionEntry; + } + } + return Object.keys(result).length > 0 ? result : undefined; +} diff --git a/src/stable/src/vs/workbench/contrib/codexSideloader/browser/codexSideloader.contribution.ts b/src/stable/src/vs/workbench/contrib/codexSideloader/browser/codexSideloader.contribution.ts new file mode 100644 index 00000000000..ec5a3e76271 --- /dev/null +++ b/src/stable/src/vs/workbench/contrib/codexSideloader/browser/codexSideloader.contribution.ts @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Frontier R&D Ltd. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; +import { CodexSideloaderContribution } from './codexSideloader.js'; + +registerWorkbenchContribution2(CodexSideloaderContribution.ID, CodexSideloaderContribution, WorkbenchPhase.AfterRestored); diff --git a/src/stable/src/vs/workbench/contrib/codexSideloader/browser/codexSideloader.ts b/src/stable/src/vs/workbench/contrib/codexSideloader/browser/codexSideloader.ts new file mode 100644 index 00000000000..ce293f9faf1 --- /dev/null +++ b/src/stable/src/vs/workbench/contrib/codexSideloader/browser/codexSideloader.ts @@ -0,0 +1,189 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Frontier R&D Ltd. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { IWorkbenchExtensionManagementService } from '../../../services/extensionManagement/common/extensionManagement.js'; +import { IExtensionGalleryService } from '../../../../platform/extensionManagement/common/extensionManagement.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { ISharedProcessService } from '../../../../platform/ipc/electron-browser/services.js'; +import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js'; +import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { ExtensionType } from '../../../../platform/extensions/common/extensions.js'; +import { URI } from '../../../../base/common/uri.js'; + +const TAG = '[CodexSideloader]'; + +/** A string means "install from gallery by ID". An object with `vsix` means "install directly from URL". */ +interface SideloadVsixEntry { + id: string; + vsix: string; + version: string; +} + +type SideloadEntry = string | SideloadVsixEntry; + +function parseSideloadEntries(raw: unknown[]): SideloadEntry[] { + const entries: SideloadEntry[] = []; + for (const item of raw) { + if (typeof item === 'string') { + entries.push(item); + } else if ( + item && typeof item === 'object' && + typeof (item as Record).id === 'string' && + typeof (item as Record).vsix === 'string' && + typeof (item as Record).version === 'string' + ) { + entries.push(item as SideloadVsixEntry); + } + } + return entries; +} + +export class CodexSideloaderContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.codexSideloader'; + + constructor( + @IWorkbenchExtensionManagementService private readonly extensionManagementService: IWorkbenchExtensionManagementService, + @IExtensionGalleryService private readonly extensionGalleryService: IExtensionGalleryService, + @IProductService private readonly productService: IProductService, + @ILogService private readonly logService: ILogService, + @INotificationService private readonly notificationService: INotificationService, + @ISharedProcessService private readonly sharedProcessService: ISharedProcessService, + @IUserDataProfileService private readonly userDataProfileService: IUserDataProfileService, + @IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService, + ) { + super(); + + // Only run sideload in the default profile. All sideload installs + // target the global extension location (defaultProfile.extensionsResource), + // which is visible in all profiles, so there is no benefit to running + // again in a pin-profile window. + if (!this.userDataProfileService.currentProfile.isDefault) { + return; + } + + const configured = (this.productService as unknown as Record)['codexSideloadExtensions']; + if (!Array.isArray(configured) || configured.length === 0) { + this.logService.info(`${TAG} No sideload extensions configured in product.json`); + return; + } + + const entries = parseSideloadEntries(configured); + if (entries.length === 0) { + return; + } + + this.ensureExtensions(entries).catch(err => { + this.logService.error(`${TAG} Unhandled error during sideload`, err); + }); + } + + private async ensureExtensions(entries: SideloadEntry[]): Promise { + const installed = await this.extensionManagementService.getInstalled(ExtensionType.User); + + const missingGallery: string[] = []; + const missingVsix: SideloadVsixEntry[] = []; + + for (const entry of entries) { + if (typeof entry === 'string') { + // Gallery entry: skip if ID is present (any version) + const found = installed.some(e => e.identifier.id.toLowerCase() === entry.toLowerCase()); + if (!found) { + missingGallery.push(entry); + } + } else { + // VSIX entry: skip only if ID AND version match + const installedExt = installed.find(e => e.identifier.id.toLowerCase() === entry.id.toLowerCase()); + if (!installedExt || installedExt.manifest.version !== entry.version) { + missingVsix.push(entry); + } + } + } + + if (missingGallery.length === 0 && missingVsix.length === 0) { + this.logService.info(`${TAG} All sideload extensions already installed`); + return; + } + + await Promise.all([ + this.installFromGallery(missingGallery), + this.installFromVsix(missingVsix), + ]); + } + + private async installFromGallery(ids: string[]): Promise { + if (ids.length === 0) { + return; + } + + this.logService.info(`${TAG} Installing ${ids.length} extension(s) from gallery: ${ids.join(', ')}`); + + if (!this.extensionGalleryService.isEnabled()) { + this.logService.warn(`${TAG} Extension gallery is not available β€” skipping gallery installs`); + return; + } + + const galleryExtensions = await this.extensionGalleryService.getExtensions( + ids.map(id => ({ id })), + CancellationToken.None + ); + + const resolved = new Map(galleryExtensions.map(ext => [ext.identifier.id.toLowerCase(), ext])); + + for (const id of ids) { + const galleryExt = resolved.get(id.toLowerCase()); + if (!galleryExt) { + this.logService.warn(`${TAG} Extension "${id}" not found in gallery β€” skipping`); + continue; + } + + try { + await this.extensionManagementService.installFromGallery(galleryExt, { isMachineScoped: true }); + this.logService.info(`${TAG} Installed "${id}" v${galleryExt.version}`); + } catch (err) { + this.logService.error(`${TAG} Failed to install "${id}"`, err); + this.notificationService.notify({ + severity: Severity.Warning, + message: `Codex: Failed to install extension "${id}". It may be installed manually from the Extensions view.`, + }); + } + } + } + + private async installFromVsix(entries: SideloadVsixEntry[]): Promise { + if (entries.length === 0) { + return; + } + + this.logService.info(`${TAG} Installing ${entries.length} extension(s) from VSIX: ${entries.map(e => e.id).join(', ')}`); + + // Use the shared process 'extensions' IPC channel to download via + // Node.js networking, bypassing renderer CORS restrictions on redirects. + const channel = this.sharedProcessService.getChannel('extensions'); + + for (const entry of entries) { + try { + await channel.call('install', [URI.parse(entry.vsix), { + installGivenVersion: true, + pinned: true, + isMachineScoped: true, + profileLocation: this.userDataProfilesService.defaultProfile.extensionsResource, + }]); + this.logService.info(`${TAG} Installed "${entry.id}" from VSIX ${entry.vsix}`); + } catch (err) { + this.logService.error(`${TAG} Failed to install "${entry.id}" from VSIX ${entry.vsix}`, err); + this.notificationService.notify({ + severity: Severity.Warning, + message: `Codex: Failed to install extension "${entry.id}" from VSIX. It may be installed manually from the Extensions view.`, + }); + } + } + } +}