Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions nodejs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1029,6 +1029,50 @@ try {
}
```

## Electron Usage

When the SDK is loaded inside an **Electron main process**, `process.execPath`
resolves to the Electron binary rather than a Node executable. Naively
spawning the bundled `.js` CLI with the Electron binary fails in two ways:

1. **Bare spawn** — Electron treats it as a second app launch. Hosts that
call `app.requestSingleInstanceLock()` (the common pattern) cause the
second instance to exit immediately with code 0. The SDK's startup
handshake then throws `CLI server exited unexpectedly with code 0`, which
looks like an authentication problem but is actually a process-launch issue.

2. **`ELECTRON_RUN_AS_NODE=1` alone** — Electron behaves as Node, but
`process.versions.electron` is still set inside the child process.
Commander auto-detects this and uses Electron argv parsing, misclassifying
`--headless` as a positional argument and aborting with
`error: too many arguments`.

### Automatic fix (no configuration needed)

The SDK detects `process.versions.electron !== undefined` at spawn time and
automatically injects `ELECTRON_RUN_AS_NODE=1` and `COPILOT_CLI_RUN_AS_NODE=1`
into the child process environment. You do **not** need to set these manually.

Existing values in `options.env` are never overwritten, so you can always
override the injected defaults explicitly if needed.

### `nodeExecPath` escape hatch

If your Electron app bundles or locates a real Node binary, you can point the
SDK at it with the `nodeExecPath` option. The SDK will use that binary
directly instead of the Electron executable, bypassing the need for the
injected env-vars entirely:

```typescript
import { CopilotClient } from "@github/copilot-sdk";

const client = new CopilotClient({
// Path to a bundled / system Node binary, not the Electron executable.
nodeExecPath: process.env.MY_APP_NODE_PATH ?? "node",
});
await client.start();
```

## Requirements

- Node.js >= 18.0.0
Expand Down
55 changes: 48 additions & 7 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,13 +221,45 @@ function extractTransformCallbacks(systemMessage: SessionConfig["systemMessage"]
return { wirePayload, transformCallbacks };
}

function getNodeExecPath(): string {
/**
* Gets the path to the Node executable for spawning the CLI.
* Prefers `override` when provided, then the Electron-aware default, then
* `process.execPath`.
*
* @internal Exported for unit testing only.
*/
export function getNodeExecPath(override?: string): string {
if (override !== undefined) {
return override;
}
if (process.versions.bun) {
return "node";
}
return process.execPath;
}

/**
* Mutates `env` to add the env-vars needed to spawn a `.js` CLI from an
* Electron main process. No-ops when the host is not Electron or when the
* CLI is not a `.js` file. Does not overwrite values the caller already set.
*
* - `ELECTRON_RUN_AS_NODE=1` — makes the Electron binary behave as Node.
* - `COPILOT_CLI_RUN_AS_NODE=1` — tells the CLI to use `{ from: "node" }`
* argv parsing so Commander does not misclassify `--headless` as a
* positional argument.
*
* @internal Exported for unit testing only.
*/
export function applyElectronSpawnEnv(
env: Record<string, string | undefined>,
isJsFile: boolean,
): void {
if (isJsFile && process.versions.electron !== undefined) {
env.ELECTRON_RUN_AS_NODE ??= "1";
env.COPILOT_CLI_RUN_AS_NODE ??= "1";
}
}

/**
* Gets the path to the bundled CLI from the @github/copilot package.
* Uses index.js directly rather than npm-loader.js (which spawns the native binary).
Expand Down Expand Up @@ -321,6 +353,7 @@ export class CopilotClient {
sessionIdleTimeoutSeconds: number;
enableRemoteSessions: boolean;
mode: CopilotClientMode;
nodeExecPath?: string;
};
private isExternalServer: boolean = false;
private forceStopping: boolean = false;
Expand Down Expand Up @@ -469,6 +502,7 @@ export class CopilotClient {
sessionIdleTimeoutSeconds: options.sessionIdleTimeoutSeconds ?? 0,
enableRemoteSessions: options.enableRemoteSessions ?? false,
mode: options.mode ?? "copilot-cli",
nodeExecPath: options.nodeExecPath,
};

// Empty mode: validate at construction time that the app supplied a
Expand Down Expand Up @@ -1953,13 +1987,20 @@ export class CopilotClient {

// For .js files, spawn node explicitly; for executables, spawn directly
const isJsFile = this.resolvedCliPath.endsWith(".js");
// When running inside an Electron host, inject the env-vars needed to
// make spawn behave correctly (see applyElectronSpawnEnv for details).
applyElectronSpawnEnv(envWithoutNodeDebug, isJsFile);
if (isJsFile) {
this.cliProcess = spawn(getNodeExecPath(), [this.resolvedCliPath, ...args], {
stdio: stdioConfig,
cwd: this.options.workingDirectory,
env: envWithoutNodeDebug,
windowsHide: true,
});
this.cliProcess = spawn(
getNodeExecPath(this.options.nodeExecPath),
[this.resolvedCliPath, ...args],
{
stdio: stdioConfig,
cwd: this.options.workingDirectory,
env: envWithoutNodeDebug,
windowsHide: true,
},
);
} else {
this.cliProcess = spawn(this.resolvedCliPath, args, {
stdio: stdioConfig,
Expand Down
18 changes: 18 additions & 0 deletions nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,24 @@ export interface CopilotClientOptions {
*/
enableRemoteSessions?: boolean;

/**
* Path to a Node.js executable used when spawning the bundled `.js` CLI.
*
* In an Electron main process, `process.execPath` resolves to the Electron
* binary, not Node, which breaks the spawn. Set this to a real `node`
* binary path (e.g. from `process.env.MY_APP_NODE_PATH` or a
* fixed path shipped with the app) to bypass the issue entirely.
*
* When omitted and the SDK detects an Electron host
* (`process.versions.electron` is defined), it automatically injects
* `ELECTRON_RUN_AS_NODE=1` and `COPILOT_CLI_RUN_AS_NODE=1` into the spawn
* environment so the CLI starts correctly without a separate Node binary.
*
* Has no effect when connecting to an existing runtime via
* {@link RuntimeConnection.forUri}.
*/
nodeExecPath?: string;

/**
* @internal Hook used by `joinSession()` to construct a client that talks
* to its parent process over stdio. Not part of the public API.
Expand Down
88 changes: 88 additions & 0 deletions nodejs/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
RuntimeConnection,
type ModelInfo,
} from "../src/index.js";
import { applyElectronSpawnEnv, getNodeExecPath } from "../src/client.js";
import { CopilotSession } from "../src/session.js";
import { defaultJoinSessionPermissionHandler } from "../src/types.js";

Expand Down Expand Up @@ -2020,6 +2021,93 @@ describe("CopilotClient", () => {
});
});

describe("Electron spawn environment", () => {
it("injects ELECTRON_RUN_AS_NODE and COPILOT_CLI_RUN_AS_NODE when process.versions.electron is set", () => {
// Temporarily set process.versions.electron to simulate an Electron host.
const versions = process.versions as Record<string, string>;
const original = versions["electron"];
try {
versions["electron"] = "28.0.0";

const env: Record<string, string | undefined> = { PATH: "/usr/bin" };
applyElectronSpawnEnv(env, /* isJsFile */ true);

expect(env.ELECTRON_RUN_AS_NODE).toBe("1");
expect(env.COPILOT_CLI_RUN_AS_NODE).toBe("1");
// Unrelated keys are untouched
expect(env.PATH).toBe("/usr/bin");
} finally {
if (original === undefined) {
delete versions["electron"];
} else {
versions["electron"] = original;
}
}
});

it("does not overwrite caller-provided ELECTRON_RUN_AS_NODE / COPILOT_CLI_RUN_AS_NODE", () => {
const versions = process.versions as Record<string, string>;
const original = versions["electron"];
try {
versions["electron"] = "28.0.0";

const env: Record<string, string | undefined> = {
ELECTRON_RUN_AS_NODE: "0",
COPILOT_CLI_RUN_AS_NODE: "custom",
};
applyElectronSpawnEnv(env, /* isJsFile */ true);

// Caller values must be preserved
expect(env.ELECTRON_RUN_AS_NODE).toBe("0");
expect(env.COPILOT_CLI_RUN_AS_NODE).toBe("custom");
} finally {
if (original === undefined) {
delete versions["electron"];
} else {
versions["electron"] = original;
}
}
});

it("does not inject env vars when the CLI is not a .js file", () => {
const versions = process.versions as Record<string, string>;
const original = versions["electron"];
try {
versions["electron"] = "28.0.0";

const env: Record<string, string | undefined> = {};
applyElectronSpawnEnv(env, /* isJsFile */ false);

expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined();
expect(env.COPILOT_CLI_RUN_AS_NODE).toBeUndefined();
} finally {
if (original === undefined) {
delete versions["electron"];
} else {
versions["electron"] = original;
}
}
});

it("does not inject env vars when not running under Electron", () => {
// process.versions.electron should be undefined in Node test runner
const env: Record<string, string | undefined> = {};
applyElectronSpawnEnv(env, /* isJsFile */ true);

expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined();
expect(env.COPILOT_CLI_RUN_AS_NODE).toBeUndefined();
});

it("nodeExecPath overrides process.execPath", () => {
expect(getNodeExecPath("/custom/node")).toBe("/custom/node");
});

it("nodeExecPath stored in client options", () => {
const client = new CopilotClient({ nodeExecPath: "/my/node" });
expect((client as any).options.nodeExecPath).toBe("/my/node");
});
});

describe("hooks dispatcher", () => {
// Direct unit tests for CopilotSession._handleHooksInvoke. The hook
// dispatch logic maps the CLI-emitted hook type (string) to the
Expand Down