Skip to content

Extensions fail to load — SEA cache directory path mismatch (universal/ vs darwin-arm64/) #2890

Closed

Description

@pbleisch

Describe the bug

Project extensions (.github/extensions/) fail to load when both universal/
and platform-specific (e.g. darwin-arm64/) cache directories exist under
~/Library/Caches/copilot/pkg/. The extension subprocess immediately exits with:

error: Invalid command format.
Did you mean: copilot -i "<path>/extension_bootstrap.mjs"?

What I think is happening

From reading the minified index.js in the cache, it looks like the SEA
binary's import.meta.url resolves to the platform-specific cache path
(darwin-arm64/<ver>/). I'll call this Z (the variable name in the minified
code).

There's a security check early in index.js that validates extension bootstrap
paths against Z:

var Z = dirname(fileURLToPath(import.meta.url));  // → darwin-arm64/<ver>/
var K = process.argv.find(e => basename(e) === "extension_bootstrap.mjs");
var Q = K ? resolve(K) : undefined;
var Y = Q?.startsWith(resolve(Z, "preloads") + sep) ? Q : undefined;

But the bootstrap path that app.js passes to fork() comes from universal/,
not darwin-arm64/. So the prefix check fails and Y ends up undefined.

It appears that when COPILOT_RUN_APP=1 is set (which it is, inherited from
the parent process), the code that loads app.js searches only universal/
subdirectories:

let dirs = D().map(i => join(i, "universal"));
let found = await M("app.js", ...dirs);

Once app.js loads from universal/<ver>/, its own import.meta.url becomes
universal/<ver>/app.js, so cliDistDir = universal/<ver>/. When it forks
extensions, the bootstrap path is universal/<ver>/preloads/extension_bootstrap.mjs.
The forked SEA child then rejects this because Z is still darwin-arm64/<ver>/.

I also noticed that COPILOT_RUN_APP=1 appears to leak into the forked
extension environment — the env proxy in the fork call doesn't seem to block it.

Affected version

GitHub Copilot CLI 1.0.34

Steps to reproduce the behavior

  1. Install copilot (either method):

    npm install -g @github/copilot
    # or: brew install --cask copilot-cli
  2. Ensure both cache directories exist under ~/Library/Caches/copilot/pkg/:

    ls ~/Library/Caches/copilot/pkg/
    # Should show both: darwin-arm64/  universal/

How these directories get created: The SEA binary extracts its embedded JS assets to darwin-arm64/<version>/ (using the platform and architecture in the path). Separately, the auto-update mechanism in app.js downloads and writes new versions to universal/<version>/.

On my machine, darwin-arm64/1.0.34/ was created on Apr 20 at 19:54 (likely when the SEA first ran after an update), and universal/1.0.34/ appeared on Apr 21 at 07:59 (likely from the auto-update check). Both directories contain identical file contents — the path difference is the only thing that matters.

If universal/ doesn't exist yet, you can trigger it by running copilot a few times or waiting for an auto-update cycle. Or copy it manually:

cp -r ~/Library/Caches/copilot/pkg/darwin-arm64/1.0.34 \
      ~/Library/Caches/copilot/pkg/universal/1.0.34
  1. Create a minimal project extension:

    mkdir -p /tmp/copilot-ext-repro/.github/extensions/hello-ext
    cd /tmp/copilot-ext-repro
    git init

    Create .github/extensions/hello-ext/extension.mjs:

    import { joinSession } from "@github/copilot-sdk";
    const session = await joinSession();
    session.onPreToolUse("*", (event) => {
      console.error("[hello-ext] tool called:", event.toolName);
      return { proceed: true };
    });
    console.error("[hello-ext] Extension loaded successfully");
  2. Run copilot:

    copilot -p "say hello"
  3. Check the process log in ~/.copilot/logs/:

    [INFO] Launching extension: .github/extensions/hello-ext/extension.mjs
    [INFO] [extension:...] error: Invalid command format.
    [INFO] [extension:...] Did you mean: copilot -i ".../universal/.../extension_bootstrap.mjs"?
    [ERROR] Extension failed during startup: ... (code=1, signal=null)
    

Expected behavior

The extension should load and run. [hello-ext] Extension loaded successfully
should appear in stderr, and the onPreToolUse hook should fire when tools are
called.

Additional context

  • OS: macOS (Darwin arm64)
  • Shell: zsh
  • Node.js (embedded in SEA): v24.11.1

What I observed to confirm this

I ran the SEA binary directly with each bootstrap path:

SEA=<path-to-sea-binary>

# Bootstrap from universal/ (what app.js currently does) → FAILS
$ COPILOT_RUN_APP=1 $SEA ~/Library/Caches/copilot/pkg/universal/1.0.34/preloads/extension_bootstrap.mjs
# error: Invalid command format.

# Bootstrap from darwin-arm64/ (matching where Z points) → WORKS
$ COPILOT_RUN_APP=1 $SEA ~/Library/Caches/copilot/pkg/darwin-arm64/1.0.34/preloads/extension_bootstrap.mjs
# [extension-bootstrap] No extension path provided  ← bootstrap loaded!

I also placed a test script in darwin-arm64/1.0.34/preloads/ that exits with
code 42. Running it through the SEA binary confirmed the security check passes
for paths under darwin-arm64/. The same script placed in universal/preloads/
was rejected.

Workaround

Deleting the universal/ cache forces app.js to load from darwin-arm64/
(matching Z), which makes the paths align:

rm -rf ~/Library/Caches/copilot/pkg/universal/

Not persistent — the universal/ directory may be recreated by subsequent
copilot runs or auto-updates.

Activity

  1. added theissue type on Apr 22, 2026
  2. added
    area:pluginsPlugin system, marketplace, hooks, skills, extensions, and custom agents
    area:installationInstalling, updating, versioning, PATH setup, and binary distribution
    on Apr 22, 2026
  3. polajenko commented on Apr 24, 2026

    @polajenko

    Windows (npm install) confirmation + proposed fix

    Confirming this is also broken on Windows when installed via npm. Same security check, different path topology.

    Environment:

    • OS: Windows 11 (x64)
    • Installation method: npm install -g @github/copilot
    • CLI version: 1.0.35 (binary: copilot-win32-x64)
    • Node.js: v22.22.2 (not used directly — SEA binary)

    Path mismatch on Windows (npm install):

    The SEA binary lives inside the npm package:

    ...\AppData\Roaming\npm\node_modules\@github\copilot\node_modules\@github\copilot-win32-x64\copilot.exe
    

    There is no preloads/ directory in the SEA binary's package — it only contains copilot.exe, package.json, LICENSE.md, and README.md.

    The preloads/ directory only exists in the auto-update pkg cache:

    ~\.copilot\pkg\universal\1.0.34\preloads\extension_bootstrap.mjs
    

    So when app.js (loaded from universal/1.0.34/) calls fork() with the bootstrap path from universal/, the child process's security check resolves Z to the SEA binary directory and rejects the bootstrap path:

    Expected prefix: ...\copilot-win32-x64\preloads\   (doesn't exist)
    Actual path:     ...\.copilot\pkg\universal\1.0.34\preloads\extension_bootstrap.mjs
    

    Log evidence:

    [INFO]  Found 2 extension(s), launching 2 (0 disabled)
    [INFO]  Launching extension: ...\.copilot\extensions\xlsx\extension.mjs
    [INFO]  Launching extension: ...\.github\extensions\aegis\extension.mjs
    [INFO]  [extension:xlsx] error: Invalid command format.
            Did you mean: copilot -i "...\.copilot\pkg\universal\1.0.34\preloads\extension_bootstrap.mjs"?
    [ERROR] Extension failed during startup: xlsx (code=1, signal=null)
    [INFO]  [extension:aegis] error: Invalid command format.
    [ERROR] Extension failed during startup: aegis (code=1, signal=null)
    

    Both user-level (~/.copilot/extensions/xlsx/) and project-level (.github/extensions/aegis/) extensions fail identically.

    Key difference from macOS report: On macOS, the mismatch is between darwin-arm64/ and universal/ subdirs in the same cache root. On Windows with npm install, the mismatch is between the npm node_modules directory (where the SEA binary lives) and the ~/.copilot/pkg/universal/ cache (where auto-updated assets live). The preloads/ directory never exists alongside the SEA binary, so no workaround is possible short of patching index.js.

    The macOS workaround (deleting universal/) does not apply here because on Windows the SEA binary's own directory (copilot-win32-x64/) never contains preloads/.


    Proposed fix

    The root cause is that launchExtension() builds the bootstrap path from cliDistDir (the pkg cache), but the forked child's security check in index.js expects it under dirname(process.execPath) (the SEA binary dir).

    Option A — Fix in launchExtension() (smallest change):

    In the unminified source, launchExtension currently does something like:

    const bootstrapPath = path.join(this.options.cliDistDir, "preloads", "extension_bootstrap.mjs");
    const child = fork(bootstrapPath, [], { ... });

    Change to resolve the bootstrap relative to the SEA binary, since that's what the child process validates against:

    const seaPreloads = path.join(path.dirname(process.execPath), "preloads", "extension_bootstrap.mjs");
    const cachePreloads = path.join(this.options.cliDistDir, "preloads", "extension_bootstrap.mjs");
    const bootstrapPath = fs.existsSync(seaPreloads) ? seaPreloads : cachePreloads;
    const child = fork(bootstrapPath, [], { ... });

    This requires shipping preloads/ alongside the platform binary in the npm package (i.e., adding extension_bootstrap.mjs and extension_sdk_resolver.mjs to @github/copilot-win32-x64, @github/copilot-darwin-arm64, etc.).

    Option B — Fix in index.js security check (more robust):

    Instead of only trusting dirname(import.meta.url) + "/preloads/", also trust the cliDistDir from the pkg cache. For example, accept bootstrap paths from any trusted ~/.copilot/pkg/ subdirectory, or pass the trusted directory as an env var from the parent:

    // Parent (app.js) sets this when forking:
    env: { COPILOT_TRUSTED_DIST_DIR: this.options.cliDistDir, ... }
    
    // Child (index.js) checks:
    const trustedDirs = [resolve(z, "preloads")];
    if (process.env.COPILOT_TRUSTED_DIST_DIR) {
        trustedDirs.push(resolve(process.env.COPILOT_TRUSTED_DIST_DIR, "preloads"));
    }
    const q = trustedDirs.some(d => Q?.startsWith(d + sep)) ? Q : undefined;

    Option B is more robust because it doesn't require shipping preloads in every platform package, but Option A is simpler and doesn't change the security boundary.

    I verified Option A works locally by copying preloads/ to the SEA dir and patching the minified app.js to prefer dirname(process.execPath). Both user-level and project-level extensions load successfully after the fix.

  4. copilot-cli-bot commented on Sep 16, 2026

    @copilot-cli-bot

    This appears to be resolved as of GitHub Copilot CLI v1.0.85. Please update to that release or a newer stable version. We're closing this as completed. If it still happens, please comment with your version and reproduction steps.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:installationInstalling, updating, versioning, PATH setup, and binary distributionarea:pluginsPlugin system, marketplace, hooks, skills, extensions, and custom agents

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions