Skip to content

Commit 6ecaf33

Browse files
committed
fix(opencode): inherit user shell PATH when spawning opencode serve
macOS GUI launches inherit launchd's minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin), which prevents opencode serve and any tool it spawns (e.g. rust-analyzer for the oh-my-opencode plugin's lsp_diagnostics tool) from finding user-installed binaries. The fork already has this exact problem solved for the extension host via getResolvedShellEnv(); we just weren't using it for the OpenCode backend. Inject IEnvironmentMainService into OpencodeServeManager and call getResolvedShellEnv() before spawning. The result is cached at module level so subsequent restarts (respawn) reuse it without re-spawning the shell. Windows and CLI launches short-circuit inside getResolvedShellEnv, so they keep their existing behavior. Also thread the merged env into findBinaryOnPath()'s 'which opencode' execSync so binary discovery sees the same PATH the spawn will use.
1 parent 3ca39f2 commit 6ecaf33

2 files changed

Lines changed: 48 additions & 5 deletions

File tree

src/vs/workbench/contrib/opencode/electron-main/opencodeServeManager.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@ import {
2121
type IDisposable,
2222
} from "../../../../base/common/lifecycle.js";
2323
import { IConfigurationService } from "../../../../platform/configuration/common/configuration.js";
24+
import { IEnvironmentMainService } from "../../../../platform/environment/electron-main/environmentMainService.js";
2425
import {
2526
InstantiationType,
2627
registerSingleton,
2728
} from "../../../../platform/instantiation/common/extensions.js";
2829
import { createDecorator } from "../../../../platform/instantiation/common/instantiation.js";
2930
import { ILogService } from "../../../../platform/log/common/log.js";
31+
import { getResolvedShellEnv } from "../../../../platform/shell/node/shellEnv.js";
3032

3133
const STARTUP_TIMEOUT = 15_000;
3234
const HEALTH_TIMEOUT = 2_000;
@@ -96,6 +98,7 @@ export class OpencodeServeManager
9698
constructor(
9799
private readonly configurationService: IConfigurationService,
98100
private readonly logService: ILogService,
101+
private readonly environmentMainService: IEnvironmentMainService,
99102
) {
100103
super();
101104

@@ -197,15 +200,20 @@ export class OpencodeServeManager
197200

198201
await this.killStaleServer(port);
199202

200-
const binaryPath = this.findBinaryPath();
203+
// macOS GUI launches inherit launchd's minimal PATH; resolve the user's login-shell
204+
// env so opencode serve and its child processes (e.g. rust-analyzer spawned by the
205+
// oh-my-opencode plugin) see the same PATH the user has in their terminal.
206+
const mergedEnv = await this.getMergedEnv();
207+
208+
const binaryPath = this.findBinaryPath(mergedEnv);
201209
if (!binaryPath) {
202210
throw new Error(
203211
"Failed to find an opencode binary. Configure opencode.binaryPath or add opencode to PATH.",
204212
);
205213
}
206214

207215
const deadline = Date.now() + STARTUP_TIMEOUT;
208-
const env = { ...process.env };
216+
const env = { ...mergedEnv };
209217
delete env.OPENCODE_SERVER_PASSWORD;
210218
env.OPENCODE_SERVER_PASSWORD = this.password;
211219
const proc = this.spawnProcess(
@@ -464,7 +472,7 @@ export class OpencodeServeManager
464472
});
465473
}
466474

467-
private findBinaryPath(): string | undefined {
475+
private findBinaryPath(env: NodeJS.ProcessEnv = process.env): string | undefined {
468476
const configuredPath = this.getBinaryPath();
469477
if (configuredPath) {
470478
const resolvedPath = this.resolveConfiguredBinaryPath(configuredPath);
@@ -478,7 +486,7 @@ export class OpencodeServeManager
478486
);
479487
}
480488

481-
const pathBinary = this.findBinaryOnPath();
489+
const pathBinary = this.findBinaryOnPath(env);
482490
if (pathBinary) {
483491
return pathBinary;
484492
}
@@ -495,6 +503,24 @@ export class OpencodeServeManager
495503
return undefined;
496504
}
497505

506+
protected async getMergedEnv(): Promise<NodeJS.ProcessEnv> {
507+
let shellEnv: typeof process.env = {};
508+
try {
509+
shellEnv = await getResolvedShellEnv(
510+
this.configurationService,
511+
this.logService,
512+
this.environmentMainService.args,
513+
process.env,
514+
);
515+
} catch (error) {
516+
this.logService.error(
517+
"[opencode] failed to resolve shell environment for opencode serve",
518+
error,
519+
);
520+
}
521+
return { ...process.env, ...shellEnv };
522+
}
523+
498524
protected async killStaleServer(port: number): Promise<void> {
499525
const url = `http://127.0.0.1:${port}/`;
500526

@@ -579,14 +605,15 @@ export class OpencodeServeManager
579605
return undefined;
580606
}
581607

582-
private findBinaryOnPath(): string | undefined {
608+
private findBinaryOnPath(env: NodeJS.ProcessEnv = process.env): string | undefined {
583609
const command =
584610
platform() === "win32" ? "where opencode" : "which opencode";
585611

586612
try {
587613
const output = execSync(command, {
588614
encoding: "utf8",
589615
stdio: ["ignore", "pipe", "ignore"],
616+
env,
590617
}).trim();
591618
const path = output
592619
.split(/\r?\n/)
@@ -721,6 +748,7 @@ function isHealthResponse(value: unknown): value is { healthy: true } {
721748

722749
IConfigurationService(OpencodeServeManager, "", 0);
723750
ILogService(OpencodeServeManager, "", 1);
751+
IEnvironmentMainService(OpencodeServeManager, "", 2);
724752

725753
registerSingleton(
726754
IOpencodeServeManager,

src/vs/workbench/contrib/opencode/test/electron-main/opencodeServeManager.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { tmpdir } from 'os';
1919
import { join } from 'path';
2020
import { OpencodeServeManager } from '../../electron-main/opencodeServeManager.js';
2121
import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js';
22+
import type { IEnvironmentMainService } from '../../../../../platform/environment/electron-main/environmentMainService.js';
2223
import { NullLogService } from '../../../../../platform/log/common/log.js';
2324

2425
type MockBackend = { port: number; close: () => Promise<void>; getRequests: () => number };
@@ -137,9 +138,18 @@ function fakeSpawn(opts: { exitOnSigterm?: boolean } = {}) {
137138
};
138139
}
139140

141+
const stubEnvironmentMainService: IEnvironmentMainService = { args: {} } as IEnvironmentMainService;
142+
140143
class TestableOpencodeServeManager extends OpencodeServeManager {
141144
spawnCalls: { command: string; args: readonly string[]; options: SpawnOptions }[] = [];
142145
nextProcess: ChildProcess | FakeChildProcess | undefined;
146+
constructor(
147+
configurationService: TestConfigurationService,
148+
logService: NullLogService,
149+
environmentMainService: IEnvironmentMainService = stubEnvironmentMainService,
150+
) {
151+
super(configurationService, logService, environmentMainService);
152+
}
143153
protected override spawnProcess(command: string, args: readonly string[], options: SpawnOptions): ChildProcess {
144154
this.spawnCalls.push({ command, args, options });
145155
if (!this.nextProcess) {
@@ -154,6 +164,11 @@ class TestableOpencodeServeManager extends OpencodeServeManager {
154164
protected override async findProcessOnPort(_port: number): Promise<number | undefined> {
155165
return undefined;
156166
}
167+
protected override async getMergedEnv(): Promise<NodeJS.ProcessEnv> {
168+
// Tests must not spawn the user's login shell; reuse the test process env so
169+
// findBinaryPath() PATH-lookup tests still see the env they manipulate directly.
170+
return { ...process.env };
171+
}
157172
get testState() { return this._testState; }
158173
get testWeStarted() { return this._testWeStarted; }
159174
}

0 commit comments

Comments
 (0)