fix(opencode): preserve packaged runtime env

This commit is contained in:
777genius 2026-05-18 21:48:06 +03:00
parent 3bb8e18982
commit 2b50def03e
13 changed files with 782 additions and 46 deletions

View file

@ -0,0 +1,166 @@
// electron.vite.config.ts
import { defineConfig } from "electron-vite";
import { sentryVitePlugin } from "@sentry/vite-plugin";
import react from "@vitejs/plugin-react";
import { readFileSync } from "fs";
import { resolve } from "path";
var __electron_vite_injected_dirname = "/Users/belief/dev/projects/claude/claude_team";
var pkg = JSON.parse(readFileSync(resolve(__electron_vite_injected_dirname, "package.json"), "utf-8"));
var prodDeps = Object.keys(pkg.dependencies || {});
var runtimeExternalDeps = /* @__PURE__ */ new Set([
"node-pty",
"agent-teams-controller",
"fastify",
"@fastify/cors",
"@fastify/static"
]);
var bundledDeps = prodDeps.filter((d) => !runtimeExternalDeps.has(d));
function nativeModuleStub() {
const STUB_ID = "\0native-stub";
const NODE_MODULE_RE = /\.node(?:\?.*)?$/;
return {
name: "native-module-stub",
enforce: "pre",
resolveId(source) {
if (NODE_MODULE_RE.test(source)) return `${STUB_ID}:${source}`;
return null;
},
load(id) {
if (id.startsWith(STUB_ID) || NODE_MODULE_RE.test(id)) return "export default {}";
return null;
}
};
}
var sentrySourceMapTargets = {
main: {
assets: ["./dist-electron/main/**/*.{js,cjs,mjs,map}"],
filesToDeleteAfterUpload: ["./dist-electron/main/**/*.map"]
},
renderer: {
assets: ["./out/renderer/**/*.{js,cjs,mjs,map}"],
filesToDeleteAfterUpload: ["./out/renderer/**/*.map"]
}
};
function createSentryPlugins(target) {
if (!process.env.SENTRY_AUTH_TOKEN) return [];
return [
sentryVitePlugin({
org: process.env.SENTRY_ORG ?? "quant-jump-pro",
project: process.env.SENTRY_PROJECT ?? "electron",
authToken: process.env.SENTRY_AUTH_TOKEN,
telemetry: false,
release: { name: `agent-teams-ai@${pkg.version}` },
sourcemaps: sentrySourceMapTargets[target]
})
];
}
var electron_vite_config_default = defineConfig({
main: {
plugins: [
nativeModuleStub(),
...createSentryPlugins("main")
],
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
// Inject DSN at compile time - process.env.SENTRY_DSN is NOT available
// at runtime in packaged Electron apps (only during CI build).
"process.env.SENTRY_DSN": JSON.stringify(process.env.SENTRY_DSN ?? "")
},
resolve: {
alias: {
"@features": resolve(__electron_vite_injected_dirname, "src/features"),
"@main": resolve(__electron_vite_injected_dirname, "src/main"),
"@shared": resolve(__electron_vite_injected_dirname, "src/shared"),
"@preload": resolve(__electron_vite_injected_dirname, "src/preload")
}
},
build: {
externalizeDeps: {
exclude: bundledDeps
},
commonjsOptions: {
strictRequires: [/node_modules\/.*ssh2\//]
},
sourcemap: "hidden",
outDir: "dist-electron/main",
rollupOptions: {
input: {
index: resolve(__electron_vite_injected_dirname, "src/main/index.ts"),
"team-fs-worker": resolve(__electron_vite_injected_dirname, "src/main/workers/team-fs-worker.ts"),
"task-change-worker": resolve(__electron_vite_injected_dirname, "src/main/workers/task-change-worker.ts"),
"team-data-worker": resolve(__electron_vite_injected_dirname, "src/main/workers/team-data-worker.ts")
},
output: {
// CJS format so bundled deps can use __dirname/require.
// Use .cjs extension since package.json has "type": "module".
format: "cjs",
entryFileNames: "[name].cjs",
// Set UV_THREADPOOL_SIZE before any module code runs.
// Must be in the banner because ESM→CJS hoists imports above top-level code.
// On Windows, fs.watch({recursive:true}) occupies a UV pool thread per watcher;
// with 3+ watchers + concurrent fs/DNS/spawn, the default 4 threads deadlock.
banner: `if(!process.env.UV_THREADPOOL_SIZE){process.env.UV_THREADPOOL_SIZE='24'}`
}
}
}
},
preload: {
resolve: {
alias: {
"@features": resolve(__electron_vite_injected_dirname, "src/features"),
"@preload": resolve(__electron_vite_injected_dirname, "src/preload"),
"@shared": resolve(__electron_vite_injected_dirname, "src/shared"),
"@main": resolve(__electron_vite_injected_dirname, "src/main")
}
},
build: {
outDir: "dist-electron/preload",
rollupOptions: {
input: {
index: resolve(__electron_vite_injected_dirname, "src/preload/index.ts")
},
output: {
format: "cjs",
entryFileNames: "[name].js"
}
}
}
},
renderer: {
cacheDir: resolve(__electron_vite_injected_dirname, "node_modules/.vite/electron-renderer"),
optimizeDeps: {
include: ["@codemirror/language-data"],
exclude: ["@claude-teams/agent-graph"]
},
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
// Pass SENTRY_DSN to renderer as VITE_SENTRY_DSN (Vite replaces at compile time)
"import.meta.env.VITE_SENTRY_DSN": JSON.stringify(process.env.SENTRY_DSN ?? "")
},
resolve: {
alias: {
"@features": resolve(__electron_vite_injected_dirname, "src/features"),
"@renderer": resolve(__electron_vite_injected_dirname, "src/renderer"),
"@shared": resolve(__electron_vite_injected_dirname, "src/shared"),
"@main": resolve(__electron_vite_injected_dirname, "src/main"),
"@radix-ui/react-compose-refs": resolve(
__electron_vite_injected_dirname,
"src/renderer/vendor/radixComposeRefs.ts"
),
"@claude-teams/agent-graph": resolve(__electron_vite_injected_dirname, "packages/agent-graph/src/index.ts")
}
},
plugins: [react(), ...createSentryPlugins("renderer")],
build: {
sourcemap: "hidden",
rollupOptions: {
input: {
index: resolve(__electron_vite_injected_dirname, "src/renderer/index.html")
}
}
}
}
});
export {
electron_vite_config_default as default
};

View file

@ -1,3 +1,5 @@
import path from 'node:path';
import { applyAgentTeamsIdentityEnv } from '@main/services/identity/AgentTeamsIdentityStore';
import { buildEnrichedEnv } from '@main/utils/cliEnv';
import { getShellPreferredHome } from '@main/utils/shellEnv';
@ -21,6 +23,7 @@ export interface BuildRuntimeBaseEnvOptions {
providerBackendId?: string | null;
shellEnv?: NodeJS.ProcessEnv | null;
env?: NodeJS.ProcessEnv;
mergePathFallbacks?: boolean;
}
function getFirstNonEmptyEnvValue(...values: (string | null | undefined)[]): string | undefined {
@ -32,18 +35,43 @@ function getFirstNonEmptyEnvValue(...values: (string | null | undefined)[]): str
return undefined;
}
function mergePathValues(...values: (string | null | undefined)[]): string | undefined {
const seen = new Set<string>();
const merged: string[] = [];
for (const value of values) {
for (const entry of value?.split(path.delimiter) ?? []) {
const normalized = entry.trim();
if (normalized && !seen.has(normalized)) {
seen.add(normalized);
merged.push(normalized);
}
}
}
return merged.length > 0 ? merged.join(path.delimiter) : undefined;
}
export function buildRuntimeBaseEnv(options: BuildRuntimeBaseEnvOptions = {}): {
env: NodeJS.ProcessEnv;
resolvedProviderId: CliProviderId | null;
} {
const shellEnv = options.shellEnv ?? {};
const enrichedEnv = buildEnrichedEnv(options.binaryPath);
const mergedPath = options.mergePathFallbacks
? mergePathValues(options.env?.PATH, shellEnv.PATH, enrichedEnv.PATH, process.env.PATH)
: undefined;
const env = {
...buildEnrichedEnv(options.binaryPath),
...enrichedEnv,
...shellEnv,
};
if (mergedPath) {
env.PATH = mergedPath;
}
applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime);
Object.assign(env, options.env ?? {});
if (mergedPath) {
env.PATH = mergedPath;
}
applyAgentTeamsIdentityEnv(env);
const policyAppliedEnv = applyOpenCodeAutoUpdatePolicy(env);
if (policyAppliedEnv.OPENCODE_DISABLE_AUTOUPDATE === undefined) {

View file

@ -43,6 +43,7 @@ export async function buildProviderAwareCliEnv(
providerBackendId: options.providerBackendId,
shellEnv,
env: options.env,
mergePathFallbacks: true,
});
if (!resolvedProviderId || resolvedProviderId === 'opencode') {
const openCodeBinary = await resolveVerifiedOpenCodeRuntimeBinaryPath();

View file

@ -1,9 +1,11 @@
import { execCli } from '@main/utils/childProcess';
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
import {
getClaudeBasePath,
getMcpConfigsBasePath,
getMcpServerBasePath,
} from '@main/utils/pathDecoder';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { createLogger } from '@shared/utils/logger';
import { randomUUID } from 'crypto';
import * as fs from 'fs';
@ -30,6 +32,7 @@ const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
const logger = createLogger('Service:TeamMcpConfigBuilder');
const MCP_CONFIG_PREFIX = 'agent-teams-mcp-';
const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const;
const NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000;
/**
* Stale configs older than this are removed on startup (best-effort).
* 7 days is intentionally long: respawnAfterAuthFailure() reuses saved
@ -175,32 +178,113 @@ function emitProgress(
options?.onProgress?.({ phase, message });
}
function stringifyError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function looksLikeNodeBinaryPath(candidate: string | undefined): candidate is string {
if (!candidate?.trim()) {
return false;
}
return /^node(?:-\d+)?(?:\.exe)?$/i.test(path.basename(candidate.trim()));
}
function getNodeRuntimeCommandCandidates(): string[] {
const candidates = [
process.env.NODE_BINARY,
process.env.npm_node_execpath,
'node',
looksLikeNodeBinaryPath(process.execPath) ? process.execPath : undefined,
];
const seen = new Set<string>();
return candidates.flatMap((candidate) => {
const normalized = candidate?.trim();
if (!normalized || seen.has(normalized)) {
return [];
}
seen.add(normalized);
return [normalized];
});
}
function mergePathValues(...values: (string | undefined)[]): string | undefined {
const seen = new Set<string>();
const merged: string[] = [];
for (const value of values) {
if (!value) {
continue;
}
for (const segment of value.split(path.delimiter)) {
if (!segment || seen.has(segment)) {
continue;
}
seen.add(segment);
merged.push(segment);
}
}
return merged.length > 0 ? merged.join(path.delimiter) : undefined;
}
function buildNodeResolveEnv(shellEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...process.env,
...shellEnv,
};
const mergedPath = mergePathValues(shellEnv.PATH, buildMergedCliPath(), process.env.PATH);
if (mergedPath) {
env.PATH = mergedPath;
}
return env;
}
/**
* Find the real `node` binary path. In Electron, process.execPath is the
* Electron binary NOT node so we must resolve node separately.
* Uses async execFile('node', ...) which is cross-platform (no /usr/bin/env dependency).
* Uses the user's shell/enriched PATH so packaged GUI launches do not depend
* on the minimal Finder/Dock PATH.
*/
async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<string> {
if (_resolvedNodePath) return _resolvedNodePath;
let shellEnv: NodeJS.ProcessEnv = {};
try {
emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...');
const { stdout } = await execCli('node', ['-e', 'process.stdout.write(process.execPath)'], {
encoding: 'utf-8',
timeout: 5000,
shellEnv = await resolveInteractiveShellEnv({
onProgress: options?.onProgress
? ({ phase, message }) => emitProgress(options, `shell-${phase}`, message)
: undefined,
});
const resolved = stdout.trim();
if (resolved) {
} catch (error) {
logger.warn(`Failed to resolve shell env before Node.js lookup: ${stringifyError(error)}`);
}
const env = buildNodeResolveEnv(shellEnv);
let lastError: unknown = null;
for (const command of getNodeRuntimeCommandCandidates()) {
try {
const { stdout } = await execCli(command, ['-e', 'process.stdout.write(process.execPath)'], {
encoding: 'utf-8',
timeout: NODE_RUNTIME_PROBE_TIMEOUT_MS,
env,
});
const resolved = stdout.trim();
if (!resolved) {
throw new Error(`${command} did not report process.execPath`);
}
_resolvedNodePath = resolved;
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
return _resolvedNodePath;
} catch (error) {
lastError = error;
}
} catch {
// node not found or timed out — use bare 'node' and let the OS resolve it
}
_resolvedNodePath = 'node';
emitProgress(options, 'node-runtime-fallback', 'Using system Node.js command...');
return _resolvedNodePath;
emitProgress(options, 'node-runtime-missing', 'Node.js runtime for MCP server was not found.');
throw new Error(
`Node.js runtime for Agent Teams MCP was not found. Ensure Node.js is installed and available from the login shell PATH. Last error: ${
lastError ? stringifyError(lastError) : 'no Node.js candidates were available'
}`
);
}
/**

View file

@ -8130,6 +8130,18 @@ export class TeamProvisioningService {
return typeof deliveredUserMessageId === 'string' && deliveredUserMessageId.trim().length > 0;
}
private hasOpenCodeAcceptedRuntimePrompt(
ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null
): boolean {
return Boolean(
ledgerRecord?.acceptedAt ||
ledgerRecord?.runtimePromptMessageId?.trim() ||
ledgerRecord?.lastRuntimePromptMessageId?.trim() ||
ledgerRecord?.deliveredUserMessageId?.trim() ||
(ledgerRecord?.runtimePromptMessageIds ?? []).some((messageId) => messageId.trim())
);
}
private isOpenCodeDeliveryRetryablePendingResponse(input: {
ledgerRecord: OpenCodePromptDeliveryLedgerRecord;
visibleReply?: OpenCodeVisibleReplyProof | null;
@ -8138,6 +8150,12 @@ export class TeamProvisioningService {
if (input.readAllowed) {
return false;
}
if (
input.ledgerRecord.responseState === 'session_stale' &&
this.hasOpenCodeAcceptedRuntimePrompt(input.ledgerRecord)
) {
return false;
}
if (isOpenCodePromptDeliveryRetryableResponseState(input.ledgerRecord.responseState)) {
return true;
}
@ -9405,6 +9423,54 @@ export class TeamProvisioningService {
const now = nowIso();
const sessionRefreshRetry =
input.retry && this.isOpenCodeSessionRefreshRetryRecord(input.ledgerRecord, input.reason);
const acceptedPromptSessionStaleObservation =
!input.retry &&
input.ledgerRecord.responseState === 'session_stale' &&
this.hasOpenCodeAcceptedRuntimePrompt(input.ledgerRecord);
if (acceptedPromptSessionStaleObservation) {
const maxSessionRefreshAttempts =
input.ledgerRecord.maxSessionRefreshAttempts ??
OPENCODE_PROMPT_DELIVERY_SESSION_REFRESH_MAX_ATTEMPTS;
if ((input.ledgerRecord.sessionRefreshAttempts ?? 0) >= maxSessionRefreshAttempts) {
return await input.ledger.markFailedTerminal({
id: input.ledgerRecord.id,
reason: 'opencode_session_stale_observe_loop_after_accepted_prompt',
diagnostics: [
input.reason,
`OpenCode session stayed stale while observing an accepted prompt after ${maxSessionRefreshAttempts} attempt(s).`,
],
failedAt: now,
});
}
const delayMs = OPENCODE_PROMPT_DELIVERY_RETRY_DELAY_MS;
const nextAttemptAt = new Date(Date.now() + delayMs).toISOString();
const ledgerRecord = await input.ledger.markSessionStaleObservationScheduled({
id: input.ledgerRecord.id,
nextAttemptAt,
reason: input.reason,
scheduledAt: now,
maxSessionRefreshAttempts,
diagnostics: ['opencode_session_stale_observe_scheduled_after_accepted_prompt'],
});
this.logOpenCodePromptDeliveryEvent(
'opencode_prompt_delivery_response_observed',
ledgerRecord,
{
retry: false,
reason: input.reason,
observeOnlyAfterAcceptedPrompt: true,
sessionRefreshAttempts: ledgerRecord.sessionRefreshAttempts ?? 0,
maxSessionRefreshAttempts,
}
);
this.scheduleOpenCodePromptDeliveryWatchdog({
teamName: input.teamName,
memberName: input.memberName,
messageId: input.ledgerRecord.inboxMessageId,
delayMs,
});
return ledgerRecord;
}
if (sessionRefreshRetry) {
const maxSessionRefreshAttempts =
input.ledgerRecord.maxSessionRefreshAttempts ??
@ -10659,6 +10725,7 @@ export class TeamProvisioningService {
const retryShouldRefreshSessionBeforeObserve =
retryDueBeforeObserve &&
ledgerRecord.status === 'retry_scheduled' &&
!this.hasOpenCodeAcceptedRuntimePrompt(ledgerRecord) &&
this.isOpenCodeSessionRefreshRetryRecord(ledgerRecord, ledgerRecord.lastReason);
if (
ledgerRecord.status !== 'pending' &&
@ -10825,6 +10892,23 @@ export class TeamProvisioningService {
retry: retryable,
reason: pendingReason,
});
if (ledgerRecord.status === 'failed_terminal') {
return {
delivered: false,
accepted: true,
responsePending: false,
responseState: ledgerRecord.responseState,
ledgerStatus: ledgerRecord.status,
ledgerRecordId: ledgerRecord.id,
laneId: laneIdentity.laneId,
visibleReplyMessageId: ledgerRecord.visibleReplyMessageId ?? undefined,
visibleReplyCorrelation: ledgerRecord.visibleReplyCorrelation ?? undefined,
reason: ledgerRecord.lastReason ?? 'opencode_prompt_delivery_failed_terminal',
diagnostics: ledgerRecord.diagnostics.length
? ledgerRecord.diagnostics
: [ledgerRecord.lastReason ?? 'opencode_prompt_delivery_failed_terminal'],
};
}
return {
delivered: true,
accepted: true,
@ -10865,6 +10949,7 @@ export class TeamProvisioningService {
if (
!forceOpenCodeSessionRefreshReason &&
ledgerRecord?.status === 'retry_scheduled' &&
!this.hasOpenCodeAcceptedRuntimePrompt(ledgerRecord) &&
isOpenCodePromptDeliveryAttemptDue(ledgerRecord) &&
this.isOpenCodeSessionRefreshRetryRecord(ledgerRecord, ledgerRecord.lastReason)
) {
@ -16505,9 +16590,10 @@ export class TeamProvisioningService {
...(input.configuredMember.agentType
? ['--agent-type', input.configuredMember.agentType]
: []),
'--setting-sources',
'user,project,local',
'--mcp-config',
mcpConfigPath,
'--strict-mcp-config',
'--disallowedTools',
APP_TEAM_RUNTIME_DISALLOWED_TOOLS,
...(input.run.request.skipPermissions !== false
@ -16686,9 +16772,10 @@ export class TeamProvisioningService {
...(input.configuredMember.agentType
? ['--agent-type', input.configuredMember.agentType]
: []),
'--setting-sources',
'user,project,local',
'--mcp-config',
mcpConfigPath,
'--strict-mcp-config',
'--disallowedTools',
APP_TEAM_RUNTIME_DISALLOWED_TOOLS,
...(input.run.request.skipPermissions !== false

View file

@ -583,6 +583,38 @@ export class OpenCodePromptDeliveryLedgerStore {
});
}
async markSessionStaleObservationScheduled(input: {
id: string;
nextAttemptAt: string;
reason: string;
scheduledAt: string;
maxSessionRefreshAttempts?: number;
diagnostics?: string[];
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
return await this.updateExisting(input.id, (record) => {
const maxSessionRefreshAttempts =
record.maxSessionRefreshAttempts ??
input.maxSessionRefreshAttempts ??
OPENCODE_PROMPT_DELIVERY_SESSION_REFRESH_MAX_ATTEMPTS;
const sessionRefreshAttempts = (record.sessionRefreshAttempts ?? 0) + 1;
return {
...record,
status: 'accepted',
responseState: 'session_stale',
nextAttemptAt: input.nextAttemptAt,
sessionRefreshAttempts,
maxSessionRefreshAttempts,
lastSessionRefreshReason: input.reason,
lastReason: input.reason,
diagnostics: mergeDiagnostics(record.diagnostics, [
input.reason,
...(input.diagnostics ?? []),
]),
updatedAt: input.scheduledAt,
};
});
}
async markRetryAttempted(input: {
id: string;
attemptedAt: string;

View file

@ -37,11 +37,9 @@ export function buildMergedCliPath(binaryPath?: string | null): string {
const cachedEnv = getCachedShellEnv();
if (cachedEnv?.PATH) {
extraDirs.push(...cachedEnv.PATH.split(sep).filter(Boolean));
extraDirs.push(vendorBinDir);
if (process.platform !== 'win32') {
extraDirs.push(pathPosix.join(home, '.bun', 'bin'));
}
} else if (process.platform === 'win32') {
}
if (process.platform === 'win32') {
extraDirs.push(
vendorBinDir,
pathWin32.join(home, 'AppData', 'Roaming', 'npm'),

View file

@ -66,6 +66,7 @@ describePosix('OpenCode packaged-runtime preflight integration', () => {
let originalPath: string | undefined;
let originalShell: string | undefined;
let originalFakeOpenCodeBinDir: string | undefined;
let originalFakeNodePath: string | undefined;
beforeEach(async () => {
tempDir = await mkdtemp(path.join(os.tmpdir(), 'opencode-prod-preflight-'));
@ -75,6 +76,7 @@ describePosix('OpenCode packaged-runtime preflight integration', () => {
originalPath = process.env.PATH;
originalShell = process.env.SHELL;
originalFakeOpenCodeBinDir = process.env.FAKE_OPENCODE_BIN_DIR;
originalFakeNodePath = process.env.FAKE_NODE_PATH;
process.env.PATH = '';
vi.clearAllMocks();
@ -99,6 +101,11 @@ describePosix('OpenCode packaged-runtime preflight integration', () => {
} else {
process.env.FAKE_OPENCODE_BIN_DIR = originalFakeOpenCodeBinDir;
}
if (originalFakeNodePath === undefined) {
delete process.env.FAKE_NODE_PATH;
} else {
process.env.FAKE_NODE_PATH = originalFakeNodePath;
}
if (tempDir) {
await rm(tempDir, { recursive: true, force: true });
@ -127,6 +134,26 @@ describePosix('OpenCode packaged-runtime preflight integration', () => {
return { binDir, binaryPath };
}
async function createFakeNodeBinary(binDir: string): Promise<string> {
const binaryPath = path.join(binDir, 'node');
process.env.FAKE_NODE_PATH = binaryPath;
await writeFile(
binaryPath,
[
'#!/bin/sh',
'if [ "$1" = "-e" ]; then',
' printf "%s" "$FAKE_NODE_PATH"',
' exit 0',
'fi',
'echo "unexpected node args: $*" >&2',
'exit 2',
].join('\n'),
'utf8'
);
await chmod(binaryPath, 0o755);
return binaryPath;
}
async function createFakeInteractiveShell(binDir: string): Promise<string> {
const shellPath = path.join(tempDir!, 'fake-login-shell');
process.env.FAKE_OPENCODE_BIN_DIR = binDir;
@ -196,4 +223,26 @@ describePosix('OpenCode packaged-runtime preflight integration', () => {
});
expect(version.stdout.trim()).toBe('opencode 9.9.9');
});
it('resolves the Agent Teams MCP command to shell Node when GUI PATH is empty', async () => {
const { binDir } = await createFakeOpenCodeBinary();
const nodePath = await createFakeNodeBinary(binDir);
process.env.SHELL = await createFakeInteractiveShell(binDir);
const providerEnv = await buildProviderAwareCliEnv({
providerId: 'opencode',
connectionMode: 'augment',
shellEnv: {},
env: {
PATH: '',
},
});
expect(providerEnv.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND).toBe(nodePath);
expect(providerEnv.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND).not.toBe('node');
expect(providerEnv.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY).toBeTruthy();
expect(providerEnv.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON).toContain(
providerEnv.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY ?? ''
);
});
});

View file

@ -140,6 +140,41 @@ describe('buildProviderAwareCliEnv', () => {
expect(result.providerArgs).toEqual([]);
});
it('keeps enriched PATH entries when a provider shell env has a narrower PATH', async () => {
buildEnrichedEnvMock.mockReturnValue({
PATH: ['/mock/runtime/bin', '/usr/local/bin', '/usr/bin'].join(path.delimiter),
});
const { buildProviderAwareCliEnv } =
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
const result = await buildProviderAwareCliEnv({
binaryPath: '/mock/claude',
providerId: 'codex',
shellEnv: {
PATH: ['/usr/bin', '/bin'].join(path.delimiter),
},
});
expect(result.env.PATH?.split(path.delimiter).slice(0, 4)).toEqual([
'/usr/bin',
'/bin',
'/mock/runtime/bin',
'/usr/local/bin',
]);
const appliedEnv = applyConfiguredConnectionEnvMock.mock.calls[0]?.[0] as NodeJS.ProcessEnv;
expect(appliedEnv.PATH?.split(path.delimiter).slice(0, 4)).toEqual([
'/usr/bin',
'/bin',
'/mock/runtime/bin',
'/usr/local/bin',
]);
expect(applyConfiguredConnectionEnvMock).toHaveBeenCalledWith(
expect.any(Object),
'codex',
undefined
);
});
it('passes metadata-only stored API key access through provider env building', async () => {
const { buildProviderAwareCliEnv } =
await import('../../../../src/main/services/runtime/providerAwareCliEnv');

View file

@ -770,6 +770,57 @@ describe('OpenCodePromptDeliveryLedger', () => {
).toBe(true);
});
it('tracks observe-only stale sessions without scheduling another prompt send', async () => {
const store = createStore();
const record = await store.ensurePending({
teamName: 'team-a',
memberName: 'jack',
laneId: 'secondary:opencode:jack',
inboxMessageId: 'msg-session-stale-observe-only',
inboxTimestamp: '2026-04-25T09:59:00.000Z',
source: 'watcher',
replyRecipient: 'user',
payloadHash: 'sha256:session-stale-observe-only',
now: '2026-04-25T10:00:00.000Z',
});
const accepted = await store.applyDeliveryResult({
id: record.id,
accepted: true,
attempted: true,
sessionId: 'oc-session-1',
runtimePromptMessageId: 'msg_prompt_1',
deliveryAttemptId: 'attempt-1',
responseObservation: {
state: 'pending',
deliveredUserMessageId: 'msg_prompt_1',
assistantMessageId: null,
toolCallNames: [],
visibleMessageToolCallId: null,
visibleReplyMessageId: null,
visibleReplyCorrelation: null,
latestAssistantPreview: null,
reason: 'assistant_response_pending',
},
now: '2026-04-25T10:00:01.000Z',
});
const scheduled = await store.markSessionStaleObservationScheduled({
id: accepted.id,
nextAttemptAt: '2026-04-25T10:00:10.000Z',
reason: 'resolved_behavior_changed:old->new',
scheduledAt: '2026-04-25T10:00:06.000Z',
diagnostics: ['opencode_session_stale_observe_scheduled_after_accepted_prompt'],
});
expect(scheduled.status).toBe('accepted');
expect(scheduled.attempts).toBe(1);
expect(scheduled.sessionRefreshAttempts).toBe(1);
expect(scheduled.runtimePromptMessageIds).toEqual(['msg_prompt_1']);
expect(buildOpenCodePromptDeliveryAttemptId(scheduled)).toBe(
`${record.id}:2:${record.payloadHash.slice(0, 12)}:refresh1`
);
});
it('does not treat session_stale with action-required diagnostics as a refresh retry', async () => {
const store = createStore();
const record = await store.ensurePending({

View file

@ -4,12 +4,28 @@ import Module from 'module';
import * as os from 'os';
import * as path from 'path';
type ExecCliMock = (
binaryPath: string | null,
args: string[],
options?: {
encoding?: BufferEncoding;
timeout?: number;
env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
}
) => Promise<{ stdout: string; stderr: string }>;
type ResolveInteractiveShellEnvMock = (options?: unknown) => Promise<NodeJS.ProcessEnv>;
const hoisted = vi.hoisted(() => ({
electronState: {
isPackaged: false,
version: '9.9.9-test',
},
execCliMock: vi.fn(async () => ({ stdout: '/mock/node', stderr: '' })),
execCliMock: vi.fn<ExecCliMock>(async () => ({ stdout: '/mock/node', stderr: '' })),
cachedShellEnv: null as NodeJS.ProcessEnv | null,
resolveInteractiveShellEnvMock: vi.fn<ResolveInteractiveShellEnvMock>(
async () => ({} as NodeJS.ProcessEnv)
),
}));
let mockHomeDir = '';
@ -33,6 +49,15 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
};
});
vi.mock('@main/utils/shellEnv', async (importOriginal) => {
const actual = await importOriginal<typeof import('@main/utils/shellEnv')>();
return {
...actual,
getCachedShellEnv: () => hoisted.cachedShellEnv,
resolveInteractiveShellEnv: hoisted.resolveInteractiveShellEnvMock,
};
});
import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import {
TeamMcpConfigBuilder,
@ -178,6 +203,10 @@ describe('TeamMcpConfigBuilder', () => {
setPackagedMode(false);
setResourcesPath(undefined);
hoisted.execCliMock.mockClear();
hoisted.execCliMock.mockResolvedValue({ stdout: '/mock/node', stderr: '' });
hoisted.cachedShellEnv = null;
hoisted.resolveInteractiveShellEnvMock.mockClear();
hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({});
});
afterEach(() => {
@ -282,7 +311,72 @@ describe('TeamMcpConfigBuilder', () => {
expect(hoisted.execCliMock).toHaveBeenCalledWith(
'node',
['-e', 'process.stdout.write(process.execPath)'],
expect.objectContaining({ encoding: 'utf-8', timeout: 5000 })
expect.objectContaining({
encoding: 'utf-8',
timeout: 5000,
env: expect.objectContaining({ PATH: expect.any(String) }),
})
);
});
it('resolves packaged MCP Node through shell PATH instead of writing a bare node command', async () => {
setPackagedMode(true, '2.0.0');
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
createdDirs.push(resourcesDir);
createPackagedServerBundle(resourcesDir, '// packaged server');
setResourcesPath(resourcesDir);
hoisted.cachedShellEnv = {
PATH: ['/mock-shell-node-bin', '/usr/bin'].join(path.delimiter),
HOME: '/Users/tester',
};
hoisted.resolveInteractiveShellEnvMock.mockResolvedValue(hoisted.cachedShellEnv);
hoisted.execCliMock.mockImplementationOnce(async (command, _args, options) => {
expect(command).toBe('node');
const env = options?.env as NodeJS.ProcessEnv | undefined;
expect(env?.PATH?.split(path.delimiter)[0]).toBe('/mock-shell-node-bin');
return { stdout: '/mock-shell-node-bin/node', stderr: '' };
});
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
expect(readGeneratedServer(configPath)?.command).toBe('/mock-shell-node-bin/node');
expect(readGeneratedServer(configPath)?.command).not.toBe('node');
expect(hoisted.resolveInteractiveShellEnvMock).toHaveBeenCalledWith(expect.any(Object));
});
it('prefers an explicit NODE_BINARY over PATH-based node lookup', async () => {
mockBuiltWorkspaceEntryAvailable();
const previousNodeBinary = process.env.NODE_BINARY;
process.env.NODE_BINARY = '/explicit/node';
hoisted.execCliMock.mockImplementationOnce(async (command) => {
expect(command).toBe('/explicit/node');
return { stdout: '/explicit/node', stderr: '' };
});
try {
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
expect(readGeneratedServer(configPath)?.command).toBe('/explicit/node');
} finally {
if (previousNodeBinary === undefined) {
delete process.env.NODE_BINARY;
} else {
process.env.NODE_BINARY = previousNodeBinary;
}
}
});
it('fails fast when Node cannot be resolved instead of emitting a broken bare node command', async () => {
mockBuiltWorkspaceEntryAvailable();
hoisted.execCliMock.mockRejectedValue(new Error('spawn node ENOENT'));
const builder = new TeamMcpConfigBuilder();
await expect(builder.writeConfigFile()).rejects.toThrow(
'Node.js runtime for Agent Teams MCP was not found'
);
});

View file

@ -5354,7 +5354,9 @@ describe('TeamProvisioningService', () => {
expect(command).toContain("'--agent-id' 'bob@forge-labs-10'");
expect(command).toContain("'--team-name' 'forge-labs-10'");
expect(command).toContain("'--parent-session-id' 'lead-session-1'");
expect(command).toContain("'--setting-sources' 'user,project,local'");
expect(command).toContain("'--mcp-config' '/mock/mcp-config.json'");
expect(command).not.toContain('--strict-mcp-config');
expect(command).toContain("'--model' 'gpt-5.4'");
expect(command).toContain("'--effort' 'high'");
expect(command).toContain('__CLAUDE_TEAMMATE_EXIT__');
@ -7721,7 +7723,9 @@ describe('TeamProvisioningService', () => {
);
});
it('retries due stale OpenCode sessions instead of observing forever', async () => {
it('observes due stale OpenCode sessions without duplicating accepted prompts', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-25T10:00:00.000Z'));
const svc = new TeamProvisioningService();
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
ok: true,
@ -7859,7 +7863,7 @@ describe('TeamProvisioningService', () => {
delivered: true,
responsePending: true,
responseState: 'session_stale',
ledgerStatus: 'retry_scheduled',
ledgerStatus: 'accepted',
});
expect(observeMessageDelivery).toHaveBeenCalledTimes(1);
@ -7874,6 +7878,7 @@ describe('TeamProvisioningService', () => {
const scheduledEnvelope = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')) as {
data: Array<{
status?: string;
nextAttemptAt: string | null;
diagnostics?: string[];
lastReason?: string | null;
@ -7888,6 +7893,7 @@ describe('TeamProvisioningService', () => {
maxAttempts: 3,
sessionRefreshAttempts: 1,
});
scheduledEnvelope.data[0].status = 'retry_scheduled';
scheduledEnvelope.data[0].nextAttemptAt = '2000-01-01T00:00:00.000Z';
scheduledEnvelope.data[0].lastReason = 'resolved_behavior_changed:old->new';
scheduledEnvelope.data[0].lastSessionRefreshReason = 'resolved_behavior_changed:old->new';
@ -7909,19 +7915,26 @@ describe('TeamProvisioningService', () => {
).resolves.toMatchObject({
delivered: true,
responsePending: true,
responseState: 'pending',
responseState: 'session_stale',
ledgerStatus: 'accepted',
});
expect(sendMessageToMember).toHaveBeenCalledTimes(2);
expect(sendMessageToMember.mock.calls[1]?.[0]).toMatchObject({
runId: 'opencode-run-bob',
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
memberName: 'bob',
cwd: '/repo',
messageId: 'msg-stale-session',
deliveryAttemptId: expect.stringContaining(':refresh1'),
forceSessionRefreshReason: 'resolved_behavior_changed:old->new',
expect(sendMessageToMember).toHaveBeenCalledTimes(1);
expect(observeMessageDelivery).toHaveBeenCalledTimes(2);
expect(sendMessageToMember).not.toHaveBeenCalledWith(
expect.objectContaining({
deliveryAttemptId: expect.stringContaining(':refresh'),
forceSessionRefreshReason: 'resolved_behavior_changed:old->new',
})
);
const rescheduledEnvelope = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')) as {
data: Array<Record<string, unknown>>;
};
expect(rescheduledEnvelope.data[0]).toMatchObject({
status: 'accepted',
responseState: 'session_stale',
sessionRefreshAttempts: 2,
nextAttemptAt: '2026-04-25T10:00:15.000Z',
});
});
@ -8590,12 +8603,23 @@ describe('TeamProvisioningService', () => {
});
});
it('stops repeated OpenCode session refresh loops at the refresh cap', async () => {
it.each([
{
label: 'resolved behavior changes',
staleReason: 'resolved_behavior_changed:old->new',
staleDiagnostics: ['OpenCode session reconcile skipped because the stored session is stale'],
},
{
label: 'action-required reasons',
staleReason: 'permission denied',
staleDiagnostics: ['permission denied'],
},
])('bounds accepted OpenCode session-stale observations for $label', async (scenario) => {
const svc = new TeamProvisioningService();
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
const sendMessageToMember = vi.fn(async (deliveryInput: Record<string, unknown>) => ({
ok: true,
providerId: 'opencode',
memberName: String(input.memberName),
memberName: String(deliveryInput.memberName),
sessionId: 'oc-session-bob',
runtimePromptMessageId: 'msg_prompt_refresh_cap',
prePromptCursor: 'cursor-refresh-cap',
@ -8612,10 +8636,10 @@ describe('TeamProvisioningService', () => {
},
diagnostics: [],
}));
const observeMessageDelivery = vi.fn(async (input: Record<string, unknown>) => ({
const observeMessageDelivery = vi.fn(async (deliveryInput: Record<string, unknown>) => ({
ok: true,
providerId: 'opencode',
memberName: String(input.memberName),
memberName: String(deliveryInput.memberName),
sessionId: 'oc-session-bob',
responseObservation: {
state: 'session_stale',
@ -8626,9 +8650,9 @@ describe('TeamProvisioningService', () => {
visibleReplyMessageId: null,
visibleReplyCorrelation: null,
latestAssistantPreview: null,
reason: 'resolved_behavior_changed:old->new',
reason: scenario.staleReason,
},
diagnostics: ['OpenCode session reconcile skipped because the stored session is stale'],
diagnostics: scenario.staleDiagnostics,
}));
await configureOpenCodeBobDeliveryService({
svc,
@ -8663,7 +8687,7 @@ describe('TeamProvisioningService', () => {
status: 'accepted',
responseState: 'session_stale',
nextAttemptAt: '2000-01-01T00:00:00.000Z',
lastReason: 'resolved_behavior_changed:old->new',
lastReason: scenario.staleReason,
sessionRefreshAttempts: 5,
maxSessionRefreshAttempts: 5,
});
@ -8683,7 +8707,7 @@ describe('TeamProvisioningService', () => {
responsePending: false,
responseState: 'session_stale',
ledgerStatus: 'failed_terminal',
reason: 'opencode_session_refresh_loop_after_resolved_behavior_changed',
reason: 'opencode_session_stale_observe_loop_after_accepted_prompt',
});
expect(sendMessageToMember).toHaveBeenCalledTimes(1);
@ -8696,7 +8720,7 @@ describe('TeamProvisioningService', () => {
attempts: 1,
sessionRefreshAttempts: 5,
maxSessionRefreshAttempts: 5,
lastReason: 'opencode_session_refresh_loop_after_resolved_behavior_changed',
lastReason: 'opencode_session_stale_observe_loop_after_accepted_prompt',
});
});
@ -14825,6 +14849,92 @@ describe('TeamProvisioningService', () => {
expect(run.pendingMemberRestarts.has('forge')).toBe(true);
});
it('launches direct process teammate restarts with normal MCP settings inheritance', async () => {
const teamName = 'process-flags-team';
const projectPath = path.join(tempProjectsBase, 'process-flags-project');
fs.mkdirSync(projectPath, { recursive: true });
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
const child = Object.assign(new EventEmitter(), {
pid: 4567,
stdin: { on: vi.fn(), unref: vi.fn() },
stdout: { pipe: vi.fn(), unref: vi.fn() },
stderr: { pipe: vi.fn(), unref: vi.fn() },
unref: vi.fn(),
});
vi.mocked(spawnCli).mockReturnValue(child as any);
const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
writeConfigFile: vi.fn(async () => '/mock/mcp-config.json'),
} as any);
const run = createMemberSpawnRun({
teamName,
expectedMembers: ['forge'],
memberSpawnStatuses: new Map(),
});
run.child = { pid: 111 };
run.request = { providerId: 'codex', skipPermissions: true };
run.detectedSessionId = 'lead-session-1';
const configuredMember = {
name: 'forge',
role: 'Developer',
providerId: 'codex',
model: 'gpt-5.4',
effort: 'medium',
agentType: 'general-purpose',
};
const config = {
name: 'Process Flags Team',
projectPath,
leadSessionId: 'lead-session-1',
members: [{ name: 'team-lead', agentType: 'team-lead' }, configuredMember],
};
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
env: { CODEX_API_KEY: 'test-openai-key' },
authSource: 'openai_api_key',
providerArgs: [],
}));
(svc as any).buildTeamRuntimeLaunchArgsPlan = vi.fn(async () => ({
fastModeArgs: [],
runtimeTurnSettledHookArgs: [],
providerArgs: [],
settingsArgs: [],
extraArgs: [],
}));
(svc as any).materializeDirectProcessNativeBootstrapContext = vi.fn(async () => ({}));
(svc as any).updateDirectTmuxRestartMemberConfig = vi.fn(async () => {});
(svc as any).enqueueDirectRestartPrompt = vi.fn();
(svc as any).appendDirectProcessRuntimeEvent = vi.fn(async () => {});
await (svc as any).launchDirectProcessMemberRestart({
run,
teamName,
displayName: 'Process Flags Team',
leadName: 'team-lead',
memberName: 'forge',
config,
configuredMember,
persistedRuntimeMembers: [],
});
child.emit('close', 0, null);
await new Promise((resolve) => setTimeout(resolve, 25));
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
expect(launchArgs).toEqual(
expect.arrayContaining([
'--teammate-runtime',
'headless',
'--setting-sources',
'user,project,local',
'--mcp-config',
'/mock/mcp-config.json',
])
);
expect(launchArgs).not.toContain('--strict-mcp-config');
});
it('rejects a second restart request while the first restart is still in flight', async () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({

View file

@ -93,7 +93,7 @@ describe('buildMergedCliPath', () => {
expect(parts[parts.length - 1]).toBe('/usr/bin');
});
it('when shell cache has PATH, uses that instead of static fallback dirs', () => {
it('when shell cache has PATH, keeps it first and still adds static fallback dirs', () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
mockGetCachedShellEnv.mockReturnValue({ PATH: '/opt/custom/bin:/bin' });
const p = buildMergedCliPath(null);
@ -101,8 +101,9 @@ describe('buildMergedCliPath', () => {
expect(p).toContain('/bin');
expect(p).toContain('/home/testuser/.claude/local/node_modules/.bin');
expect(p).toContain('/home/testuser/.bun/bin');
expect(p).toContain('/home/testuser/.local/bin');
expect(p).toContain('/usr/local/bin');
expect(p).toContain('/usr/bin');
expect(p).not.toContain('/home/testuser/.local/bin');
});
it('prepends binary directory when binaryPath is set', () => {