diff --git a/bun.lock b/bun.lock index 43f419a191..902d76f47e 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ }, "packages/adapters": { "name": "@archon/adapters", - "version": "0.1.0", + "version": "0.3.5", "dependencies": { "@archon/core": "workspace:*", "@archon/git": "workspace:*", @@ -41,7 +41,7 @@ }, "packages/cli": { "name": "@archon/cli", - "version": "0.2.13", + "version": "0.3.5", "bin": { "archon": "./src/cli.ts", }, @@ -62,7 +62,7 @@ }, "packages/core": { "name": "@archon/core", - "version": "0.2.0", + "version": "0.3.5", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.89", "@archon/git": "workspace:*", @@ -83,7 +83,7 @@ }, "packages/docs-web": { "name": "@archon/docs-web", - "version": "0.2.12", + "version": "0.3.5", "dependencies": { "@astrojs/starlight": "^0.38.0", "astro": "^6.1.0", @@ -92,7 +92,7 @@ }, "packages/git": { "name": "@archon/git", - "version": "0.1.0", + "version": "0.3.5", "dependencies": { "@archon/paths": "workspace:*", }, @@ -102,7 +102,7 @@ }, "packages/isolation": { "name": "@archon/isolation", - "version": "0.1.0", + "version": "0.3.5", "dependencies": { "@archon/git": "workspace:*", "@archon/paths": "workspace:*", @@ -113,8 +113,9 @@ }, "packages/paths": { "name": "@archon/paths", - "version": "0.2.0", + "version": "0.3.5", "dependencies": { + "dotenv": "^17.2.3", "pino": "^9", "pino-pretty": "^13", }, @@ -124,7 +125,7 @@ }, "packages/server": { "name": "@archon/server", - "version": "0.2.0", + "version": "0.3.5", "dependencies": { "@archon/adapters": "workspace:*", "@archon/core": "workspace:*", @@ -142,7 +143,7 @@ }, "packages/web": { "name": "@archon/web", - "version": "0.2.0", + "version": "0.3.5", "dependencies": { "@dagrejs/dagre": "^2.0.4", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -194,7 +195,7 @@ }, "packages/workflows": { "name": "@archon/workflows", - "version": "0.1.0", + "version": "0.3.5", "dependencies": { "@archon/git": "workspace:*", "@archon/paths": "workspace:*", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 96c0209666..ca2214bc49 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -7,21 +7,23 @@ * archon workflow run [msg] Run a workflow * archon version Show version info */ +// Design rule: the CLI must never load target repo env. +// +// Bun's runtime auto-loads CWD `.env` files before any user code runs. When +// `archon` is invoked from inside a target repo, that repo's `.env` leaks into +// `process.env`. Side-effect import strips those keys during module load — +// MUST be the first import so that downstream modules reading env at init +// time (e.g. the Pino logger's LOG_LEVEL) see a clean environment. +import '@archon/paths/strip-cwd-env-boot'; + import { parseArgs } from 'util'; import { config } from 'dotenv'; import { resolve } from 'path'; import { existsSync } from 'fs'; -// Load .env from global Archon config (override: true so ~/.archon/.env -// always wins over any Bun-auto-loaded CWD vars). -// -// Credential safety: target repo .env keys that Bun auto-loads from CWD -// cannot leak into AI subprocesses — SUBPROCESS_ENV_ALLOWLIST blocks them. -// The env-leak gate provides a second layer by scanning target repos before -// spawning. No CWD stripping needed. const globalEnvPath = resolve(process.env.HOME ?? '~', '.archon', '.env'); if (existsSync(globalEnvPath)) { - const result = config({ path: globalEnvPath, override: true }); + const result = config({ path: globalEnvPath }); if (result.error) { // Logger may not be available yet (early startup), so use console for user-facing error console.error(`Error loading .env from ${globalEnvPath}: ${result.error.message}`); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index e24a5526a3..2575d08f86 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -55,12 +55,14 @@ export async function serveCommand(opts: ServeOptions): Promise { } // Import server and start (dynamic import keeps CLI startup fast for other commands) + // Platform adapters (Telegram, Discord, Slack, GitHub, Gitea, GitLab) auto-start + // when their respective tokens are present in ~/.archon/.env. Users who only + // want the web UI simply leave those tokens unset. try { const { startServer } = await import('@archon/server'); await startServer({ webDistPath: webDistDir, port: opts.port, - skipPlatformAdapters: true, }); } catch (err) { const error = toError(err); diff --git a/packages/paths/package.json b/packages/paths/package.json index 047f1e87c6..d84ca33544 100644 --- a/packages/paths/package.json +++ b/packages/paths/package.json @@ -5,13 +5,16 @@ "main": "./src/index.ts", "types": "./src/index.ts", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./strip-cwd-env": "./src/strip-cwd-env.ts", + "./strip-cwd-env-boot": "./src/strip-cwd-env-boot.ts" }, "scripts": { "test": "bun test src/", "type-check": "bun x tsc --noEmit" }, "dependencies": { + "dotenv": "^17.2.3", "pino": "^9", "pino-pretty": "^13" }, diff --git a/packages/paths/src/index.ts b/packages/paths/src/index.ts index 99a254f4ca..c71ef6c1a3 100644 --- a/packages/paths/src/index.ts +++ b/packages/paths/src/index.ts @@ -43,3 +43,6 @@ export { parseLatestRelease, } from './update-check'; export type { UpdateCheckResult } from './update-check'; + +// CWD env isolation +export { stripCwdEnv } from './strip-cwd-env'; diff --git a/packages/paths/src/strip-cwd-env-boot.ts b/packages/paths/src/strip-cwd-env-boot.ts new file mode 100644 index 0000000000..f324e9561f --- /dev/null +++ b/packages/paths/src/strip-cwd-env-boot.ts @@ -0,0 +1,18 @@ +/** + * Side-effect boot module: strip Bun-auto-loaded CWD `.env` keys immediately + * on import. Import this as the FIRST import in CLI/server entry points to + * guarantee the strip runs before any module that reads `process.env` at + * load time (e.g. the Pino logger in `@archon/paths/logger`). + * + * Usage: + * import '@archon/paths/strip-cwd-env-boot'; // must be the first import + * // ...other imports... + * + * The separation between `strip-cwd-env.ts` (pure function, testable) and + * this boot file (side-effect wrapper) keeps the stripping logic unit-testable + * while still providing the "runs before everything else" guarantee that + * entry points need. + */ +import { stripCwdEnv } from './strip-cwd-env'; + +stripCwdEnv(); diff --git a/packages/paths/src/strip-cwd-env.test.ts b/packages/paths/src/strip-cwd-env.test.ts new file mode 100644 index 0000000000..1dbda482bc --- /dev/null +++ b/packages/paths/src/strip-cwd-env.test.ts @@ -0,0 +1,107 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { stripCwdEnv } from './strip-cwd-env'; + +describe('stripCwdEnv', () => { + let tmpDir: string; + let originalCwd: string; + const testKeys = [ + 'STRIP_TEST_MARKER_A', + 'STRIP_TEST_MARKER_B', + 'STRIP_TEST_LOCAL_MARKER', + 'STRIP_TEST_DEV_MARKER', + 'STRIP_TEST_PROD_MARKER', + 'STRIP_TEST_OVERLAP_KEY', + 'STRIP_TEST_PRESERVED_KEY', + 'STRIP_TEST_MALFORMED_KEY', + ]; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'strip-cwd-env-')); + originalCwd = process.cwd(); + process.chdir(tmpDir); + // Clean any leaked keys from earlier runs + for (const key of testKeys) { + delete process.env[key]; + } + }); + + afterEach(() => { + process.chdir(originalCwd); + rmSync(tmpDir, { recursive: true, force: true }); + for (const key of testKeys) { + delete process.env[key]; + } + }); + + test('strips keys present in CWD .env', () => { + writeFileSync(join(tmpDir, '.env'), 'STRIP_TEST_MARKER_A=from_target_repo\n'); + // Simulate Bun's auto-load + process.env.STRIP_TEST_MARKER_A = 'from_target_repo'; + + const stripped = stripCwdEnv(); + + expect(process.env.STRIP_TEST_MARKER_A).toBeUndefined(); + expect(stripped).toContain('STRIP_TEST_MARKER_A'); + }); + + test('strips keys from .env.local, .env.development, .env.production', () => { + writeFileSync(join(tmpDir, '.env.local'), 'STRIP_TEST_LOCAL_MARKER=local\n'); + writeFileSync(join(tmpDir, '.env.development'), 'STRIP_TEST_DEV_MARKER=dev\n'); + writeFileSync(join(tmpDir, '.env.production'), 'STRIP_TEST_PROD_MARKER=prod\n'); + process.env.STRIP_TEST_LOCAL_MARKER = 'local'; + process.env.STRIP_TEST_DEV_MARKER = 'dev'; + process.env.STRIP_TEST_PROD_MARKER = 'prod'; + + const stripped = stripCwdEnv(); + + expect(process.env.STRIP_TEST_LOCAL_MARKER).toBeUndefined(); + expect(process.env.STRIP_TEST_DEV_MARKER).toBeUndefined(); + expect(process.env.STRIP_TEST_PROD_MARKER).toBeUndefined(); + expect(stripped).toContain('STRIP_TEST_LOCAL_MARKER'); + expect(stripped).toContain('STRIP_TEST_DEV_MARKER'); + expect(stripped).toContain('STRIP_TEST_PROD_MARKER'); + }); + + test('does nothing when no CWD .env files exist', () => { + process.env.STRIP_TEST_PRESERVED_KEY = 'should_remain'; + + const stripped = stripCwdEnv(); + + expect(process.env.STRIP_TEST_PRESERVED_KEY).toBe('should_remain'); + expect(stripped).toEqual([]); + }); + + test('preserves keys not present in any CWD .env', () => { + writeFileSync(join(tmpDir, '.env'), 'STRIP_TEST_MARKER_A=from_target\n'); + process.env.STRIP_TEST_MARKER_A = 'from_target'; + process.env.STRIP_TEST_PRESERVED_KEY = 'should_remain'; + + stripCwdEnv(); + + expect(process.env.STRIP_TEST_MARKER_A).toBeUndefined(); + expect(process.env.STRIP_TEST_PRESERVED_KEY).toBe('should_remain'); + }); + + test('ignores parse errors in target repo .env', () => { + // Write a .env with syntactically dubious content; dotenv's parser is + // lenient but we still want to verify nothing throws. + writeFileSync(join(tmpDir, '.env'), 'STRIP_TEST_MALFORMED_KEY="unterminated\n=noKey\n \n'); + process.env.STRIP_TEST_MALFORMED_KEY = 'set_before_strip'; + + // Should not throw + expect(() => stripCwdEnv()).not.toThrow(); + }); + + test('does not strip keys that dotenv parses but are absent from process.env', () => { + writeFileSync(join(tmpDir, '.env'), 'STRIP_TEST_MARKER_A=only_in_file\n'); + // Intentionally do NOT set process.env.STRIP_TEST_MARKER_A + // (simulates a .env file that Bun didn't auto-load — e.g. wrong CWD) + + const stripped = stripCwdEnv(); + + expect(stripped).not.toContain('STRIP_TEST_MARKER_A'); + }); +}); diff --git a/packages/paths/src/strip-cwd-env.ts b/packages/paths/src/strip-cwd-env.ts new file mode 100644 index 0000000000..0dd2c2cbc1 --- /dev/null +++ b/packages/paths/src/strip-cwd-env.ts @@ -0,0 +1,49 @@ +import { parse } from 'dotenv'; +import { readFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; + +/** + * Strip Bun-auto-loaded CWD `.env` keys from `process.env`. + * + * Bun's runtime (and compiled binaries) auto-load `.env` files from the current + * working directory before any user code runs. When `archon` is invoked from + * inside a target repo, that repo's `.env` leaks into the Archon process env, + * contaminating logging, config, and any `process.env.X` reads. + * + * The design rule is: **the CLI must never load target repo env**. Call this + * function at the very top of the CLI/server entry point — before loading + * `~/.archon/.env` — to undo Bun's auto-load. + * + * Files checked (matches Bun's auto-load set): `.env.local`, `.env.development`, + * `.env.production`, `.env`. For each existing file, parsed keys are deleted + * from `process.env`. Parse errors are ignored — a broken target repo `.env` + * is not our concern; we only need to strip keys, not validate them. + * + * Returns the list of keys that were stripped (useful for tests and debug logs). + */ +export function stripCwdEnv(): string[] { + const cwdEnvFiles = ['.env.local', '.env.development', '.env.production', '.env']; + const stripped: string[] = []; + + for (const filename of cwdEnvFiles) { + const path = resolve(process.cwd(), filename); + if (!existsSync(path)) continue; + try { + const parsed = parse(readFileSync(path)); + for (const key of Object.keys(parsed)) { + if (key in process.env) { + // Dynamic delete is required: keys come from the target repo's .env + // at runtime, so they cannot be known statically. + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete process.env[key]; + stripped.push(key); + } + } + } catch { + // Ignore parse errors — we're only trying to undo Bun's auto-load, + // not validate the target repo's .env file. + } + } + + return stripped; +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 7152aec8b4..5cafc97c73 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -3,12 +3,15 @@ * Multi-platform AI coding assistant (Telegram, Discord, Slack, GitHub, Gitea) */ -// Load environment variables FIRST — before any application imports. -// -// Credential safety: target repo `.env` keys (like CLAUDE_API_KEY) that Bun -// auto-loads from CWD cannot leak into AI subprocesses because -// SUBPROCESS_ENV_ALLOWLIST blocks them. The env-leak gate provides a second -// layer by scanning target repos before spawning. No CWD stripping needed. +// Design rule: the server must never load target repo env. Bun's runtime +// auto-loads CWD `.env` files before any user code runs — the side-effect +// import below strips those keys during module load, BEFORE any module that +// reads env at init time (e.g. the Pino logger's LOG_LEVEL). Must be the +// first import. +import '@archon/paths/strip-cwd-env-boot'; + +// Load environment variables — before any application imports that depend +// on env vars being set. import { config } from 'dotenv'; import { resolve } from 'path'; import { existsSync } from 'fs'; @@ -18,7 +21,7 @@ import { BUNDLED_IS_BINARY } from '@archon/paths'; // import.meta.dir is frozen at build time, so skip in compiled binaries. const envPath = BUNDLED_IS_BINARY ? undefined : resolve(import.meta.dir, '..', '..', '..', '.env'); -if (envPath) { +if (envPath && existsSync(envPath)) { const dotenvResult = config({ path: envPath }); if (dotenvResult.error) { // Use console.error since logger depends on env vars (LOG_LEVEL) @@ -27,9 +30,9 @@ if (envPath) { } } -// Load ~/.archon/.env with override — Archon's config always wins over any -// Bun-auto-loaded CWD vars. In binary mode this is the single source of truth. -// In dev mode it overrides CWD vars for keys like DATABASE_URL. +// Load ~/.archon/.env — Archon's config is the single source of truth. +// In binary mode this is the only .env loaded. In dev mode it overrides the +// repo root .env (with override:true) for keys like DATABASE_URL. const globalEnvPath = resolve(process.env.HOME ?? '~', '.archon', '.env'); if (existsSync(globalEnvPath)) { const globalResult = config({ path: globalEnvPath, override: true });