Skip to content

Commit a98b04e

Browse files
authored
fix(core): load every .env on the search path, not just the first found (INT-3256) (#382)
loadEnvFile() returned as soon as it found ANY .env file, so a project with its own (older) .env never even looked at the global ~/.config/openswarm/.env — any credential that lives only in the global file was invisible to that project. ATLAS_CLOUD_API_KEY is global-only, so `openswarm review --max` from a repo with its own .env (e.g. STONKS) failed every subagent's auth instantly, project- wide, while the same command worked fine from a repo whose .env already had every key. Now iterates the full search path and applies each file's keys only when not already set, preserving shell-wins/first-file-wins precedence but no longer stopping after the first file that exists. EnvLoadResult.path (string | null) -> paths (string[]); the one real caller (src/index.ts) updated to log the joined list.
1 parent 9c855bb commit a98b04e

3 files changed

Lines changed: 102 additions & 17 deletions

File tree

src/core/envFile.test.ts

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1-
import { afterEach, describe, expect, it } from 'vitest';
2-
import { mkdtempSync, readFileSync, writeFileSync, statSync, rmSync } from 'node:fs';
3-
import { tmpdir, platform } from 'node:os';
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, statSync, rmSync } from 'node:fs';
3+
import { tmpdir, platform, homedir as realHomedir } from 'node:os';
44
import { join } from 'node:path';
55
import { writeEnvVars } from './envFile.js';
66

7+
vi.mock('node:os', async (importOriginal) => {
8+
const actual = await importOriginal<typeof import('node:os')>();
9+
return { ...actual, homedir: () => mockedHome };
10+
});
11+
12+
// Reassigned per-test by loadEnvFileWith(); the node:os mock above reads it
13+
// live, so tests never touch the real home directory.
14+
let mockedHome = '';
15+
16+
const { loadEnvFile } = await import('./envFile.js');
17+
718
let dir: string | null = null;
819
function freshEnvPath(): string {
920
dir = mkdtempSync(join(tmpdir(), 'env-write-'));
@@ -55,3 +66,66 @@ describe('writeEnvVars', () => {
5566
expect(statSync(p).mode & 0o777).toBe(0o600);
5667
});
5768
});
69+
70+
// Regression for INT-3247: a project with its own (older) .env silently hid
71+
// every key that only lived in the global ~/.config/openswarm/.env, because
72+
// loadEnvFile returned after the FIRST file it found instead of layering
73+
// all of them. `openswarm review --max` from such a project failed every
74+
// subagent instantly — ATLASCLOUD_API_KEY was global-only.
75+
describe('loadEnvFile', () => {
76+
let projectDir: string;
77+
const savedEnv: Record<string, string | undefined> = {};
78+
79+
function stashEnv(...keys: string[]) {
80+
for (const k of keys) savedEnv[k] = process.env[k];
81+
}
82+
83+
afterEach(() => {
84+
if (projectDir) rmSync(projectDir, { recursive: true, force: true });
85+
if (mockedHome && mockedHome !== realHomedir()) rmSync(mockedHome, { recursive: true, force: true });
86+
for (const [k, v] of Object.entries(savedEnv)) {
87+
if (v === undefined) delete process.env[k];
88+
else process.env[k] = v;
89+
}
90+
vi.restoreAllMocks();
91+
});
92+
93+
it('falls back to a global .env key the project-local .env does not define', () => {
94+
projectDir = mkdtempSync(join(tmpdir(), 'env-project-'));
95+
mockedHome = mkdtempSync(join(tmpdir(), 'env-home-'));
96+
const configDir = join(mockedHome, '.config', 'openswarm');
97+
mkdirSync(configDir, { recursive: true });
98+
99+
writeFileSync(join(projectDir, '.env'), 'PROJECT_ONLY=local\n');
100+
writeFileSync(join(configDir, '.env'), 'GLOBAL_ONLY=global\nPROJECT_ONLY=should-not-win\n');
101+
102+
stashEnv('PROJECT_ONLY', 'GLOBAL_ONLY', 'OPENSWARM_ENV', 'OPENSWARM_CONFIG');
103+
delete process.env.OPENSWARM_ENV;
104+
delete process.env.OPENSWARM_CONFIG;
105+
delete process.env.PROJECT_ONLY;
106+
delete process.env.GLOBAL_ONLY;
107+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
108+
109+
const result = loadEnvFile();
110+
111+
expect(process.env.PROJECT_ONLY).toBe('local'); // local file wins the shared key
112+
expect(process.env.GLOBAL_ONLY).toBe('global'); // global-only key still gets picked up
113+
expect(result.paths).toEqual([join(projectDir, '.env'), join(configDir, '.env')]);
114+
});
115+
116+
it('a pre-existing process.env value beats every file', () => {
117+
projectDir = mkdtempSync(join(tmpdir(), 'env-project-'));
118+
mockedHome = mkdtempSync(join(tmpdir(), 'env-home-'));
119+
writeFileSync(join(projectDir, '.env'), 'SHELL_WINS=from-file\n');
120+
121+
stashEnv('SHELL_WINS', 'OPENSWARM_ENV', 'OPENSWARM_CONFIG');
122+
delete process.env.OPENSWARM_ENV;
123+
delete process.env.OPENSWARM_CONFIG;
124+
process.env.SHELL_WINS = 'from-shell';
125+
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
126+
127+
loadEnvFile();
128+
129+
expect(process.env.SHELL_WINS).toBe('from-shell');
130+
});
131+
});

src/core/envFile.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,21 @@
22
// OpenSwarm - .env auto-loader
33
// ============================================
44
//
5-
// Minimal, zero-dependency .env loader. Populates process.env with entries
6-
// from the first .env file found, searching locations parallel to the config
5+
// Minimal, zero-dependency .env loader. Populates process.env by layering
6+
// every .env file found across the search path (project-local first, then
7+
// the global fallbacks), searching locations parallel to the config
78
// resolver. Existing process.env values are never overwritten — a shell
8-
// export always wins over the file.
9+
// export always wins over any file, and a key already set by an
10+
// earlier (more specific) file wins over the same key in a later,
11+
// more general one.
912

1013
import { existsSync, readFileSync } from 'node:fs';
1114
import { homedir } from 'node:os';
1215
import { dirname, join } from 'node:path';
1316
import { atomicWriteFileSync } from '../support/atomicFile.js';
1417

1518
export interface EnvLoadResult {
16-
path: string | null;
19+
paths: string[];
1720
loadedKeys: string[];
1821
}
1922

@@ -124,17 +127,27 @@ export function writeEnvVars(path: string, kv: Record<string, string>): void {
124127
}
125128

126129
/**
127-
* Load the first discovered .env file into process.env without overwriting
128-
* existing values. Returns the path loaded (or null) and the list of keys
129-
* that were newly applied — callers can log this for diagnostics.
130+
* Load every discovered .env file into process.env without overwriting
131+
* existing values. A project-local `.env` shadows the global fallback ones
132+
* key-by-key, not file-by-file — a repo whose own `.env` predates a
133+
* credential that only lives in `~/.config/openswarm/.env` still picks that
134+
* credential up, instead of the global file being skipped entirely because
135+
* the local one was found first. (INT-3256: `ATLASCLOUD_API_KEY` set only in
136+
* the global .env was invisible to every run from a repo with its own,
137+
* older `.env` — every subagent failed auth instantly, project-wide.)
138+
*
139+
* Returns the paths actually read (in precedence order) and the list of
140+
* keys that were newly applied — callers can log this for diagnostics.
130141
*/
131142
export function loadEnvFile(): EnvLoadResult {
143+
const paths: string[] = [];
144+
const loadedKeys: string[] = [];
145+
132146
for (const path of getSearchPaths()) {
133147
if (!existsSync(path)) continue;
148+
paths.push(path);
134149

135150
const content = readFileSync(path, 'utf8');
136-
const loadedKeys: string[] = [];
137-
138151
for (const rawLine of content.split(/\r?\n/)) {
139152
const parsed = parseLine(rawLine);
140153
if (parsed === null) continue;
@@ -143,9 +156,7 @@ export function loadEnvFile(): EnvLoadResult {
143156
process.env[key] = value;
144157
loadedKeys.push(key);
145158
}
146-
147-
return { path, loadedKeys };
148159
}
149160

150-
return { path: null, loadedKeys: [] };
161+
return { paths, loadedKeys };
151162
}

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ dns.setDefaultResultOrder('ipv4first');
1212
// launched from a non-interactive shell without those vars exported.
1313
import { loadEnvFile } from './core/envFile.js';
1414
const envLoad = loadEnvFile();
15-
if (envLoad.path !== null) {
16-
console.log(`Loaded env from: ${envLoad.path} (${envLoad.loadedKeys.length} keys)`);
15+
if (envLoad.paths.length > 0) {
16+
console.log(`Loaded env from: ${envLoad.paths.join(', ')} (${envLoad.loadedKeys.length} keys)`);
1717
}
1818

1919
// Strip Claude Code session markers so child processes (worker, planner) can launch Claude CLI

0 commit comments

Comments
 (0)