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
9 changes: 8 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import os from 'os';
import path from 'path';

import { readEnvFile } from './env.js';
import { promoteEnvFlags, readEnvFile } from './env.js';
import { getContainerImageBase, getDefaultContainerImage, getInstallSlug } from './install-slug.js';
import { isValidTimezone } from './timezone.js';

// Non-secret NANOCLAW_* flags from .env become process.env so any module can
// read them directly (launchd/systemd services get no shell env). config.ts
// is evaluated before every flag reader in the import graph, so top-level
// `process.env.NANOCLAW_*` consts (e.g. egress-lockdown.ts) see the values.
// Secrets stay out of process.env via readEnvFile below.
promoteEnvFlags();

// Read config values from .env (falls back to process.env).
const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'ONECLI_URL', 'ONECLI_API_KEY', 'TZ']);

Expand Down
67 changes: 67 additions & 0 deletions src/env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import fs from 'fs';
import os from 'os';
import path from 'path';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { promoteEnvFlags } from './env.js';

describe('promoteEnvFlags', () => {
let dir: string;
let originalCwd: string;
let savedVitest: string | undefined;

beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-env-'));
originalCwd = process.cwd();
process.chdir(dir);
// The function no-ops under vitest so a developer's live .env can't steer
// test behavior; lift the guard only inside these tests, which use a
// fixture .env in a temp cwd.
savedVitest = process.env.VITEST;
delete process.env.VITEST;
delete process.env.NANOCLAW_TEST_FLAG;
delete process.env.NANOCLAW_TEST_PRESET;
});

afterEach(() => {
process.chdir(originalCwd);
if (savedVitest !== undefined) process.env.VITEST = savedVitest;
delete process.env.NANOCLAW_TEST_FLAG;
delete process.env.NANOCLAW_TEST_PRESET;
fs.rmSync(dir, { recursive: true, force: true });
});

it('promotes prefixed flags from .env into process.env', () => {
fs.writeFileSync('.env', 'NANOCLAW_TEST_FLAG=true\nOTHER_KEY=secret\n# NANOCLAW_COMMENTED=x\n');
promoteEnvFlags();
expect(process.env.NANOCLAW_TEST_FLAG).toBe('true');
expect(process.env.OTHER_KEY).toBeUndefined();
expect(process.env.NANOCLAW_COMMENTED).toBeUndefined();
});

it('never overrides values already in process.env', () => {
process.env.NANOCLAW_TEST_PRESET = 'from-service-env';
fs.writeFileSync('.env', 'NANOCLAW_TEST_PRESET=from-dotenv\n');
promoteEnvFlags();
expect(process.env.NANOCLAW_TEST_PRESET).toBe('from-service-env');
});

it('strips surrounding quotes', () => {
fs.writeFileSync('.env', 'NANOCLAW_TEST_FLAG="quoted value"\n');
promoteEnvFlags();
expect(process.env.NANOCLAW_TEST_FLAG).toBe('quoted value');
});

it('is a no-op without a .env file', () => {
expect(() => promoteEnvFlags()).not.toThrow();
expect(process.env.NANOCLAW_TEST_FLAG).toBeUndefined();
});

it('is a no-op under vitest (hermetic tests)', () => {
process.env.VITEST = 'true';
fs.writeFileSync('.env', 'NANOCLAW_TEST_FLAG=true\n');
promoteEnvFlags();
expect(process.env.NANOCLAW_TEST_FLAG).toBeUndefined();
});
});
39 changes: 39 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,42 @@ export function readEnvFile(keys: string[]): Record<string, string> {

return result;
}

/**
* Promote non-secret operational flags from .env into process.env (existing
* process.env values win). Scoped to the NANOCLAW_ prefix by convention:
* those are feature flags and tuning knobs (e.g. NANOCLAW_EGRESS_LOCKDOWN),
* never credentials — secrets keep using readEnvFile so they stay out of
* the process environment (see the module doc above). Services started by
* launchd/systemd don't get a shell env, so without this a NANOCLAW_* flag
* added to .env silently never reaches modules that read process.env
* directly.
*/
export function promoteEnvFlags(prefixes = ['NANOCLAW_']): void {
// Tests must be hermetic: a developer's live-install .env flags must not
// steer test behavior.
if (process.env.VITEST) return;
const envFile = path.join(process.cwd(), '.env');
let content: string;
try {
content = fs.readFileSync(envFile, 'utf-8');
} catch {
return;
}
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
if (!prefixes.some((p) => key.startsWith(p)) || process.env[key] !== undefined) continue;
let value = trimmed.slice(eqIdx + 1).trim();
if (
value.length >= 2 &&
((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
) {
value = value.slice(1, -1);
}
if (value) process.env[key] = value;
}
}
Loading