Skip to content

Commit 4d8d9be

Browse files
KirachonCopilot
andcommitted
feat(providers): add fail-closed env config guardrails
Adds src/ai/providers/env.ts with readProviderEnvConfig() that validates CE_AI_PROVIDER, CE_AI_ENABLE_EXPERIMENTAL_PROVIDERS, CE_AI_ENABLE_SHADOW, and CE_AI_ENABLE_CANARY independently. Non-openai_session providers, shadow mode, and canary routing all require the experimental gate to be enabled separately, so CE_AI_PROVIDER alone cannot activate them. Pure module -- not yet wired into factory.ts. Parity fence remains green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0f4e781 commit 4d8d9be

2 files changed

Lines changed: 204 additions & 0 deletions

File tree

src/ai/providers/env.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Fail-closed environment configuration for the multi-provider framework.
3+
*
4+
* Pure module: no I/O, no mutation of the input env. Other slices will consume
5+
* this to decide which provider to construct and whether shadow/canary lanes
6+
* are eligible. CE_AI_PROVIDER alone is intentionally insufficient to activate
7+
* any experimental behavior; the experimental gate must be set independently.
8+
*/
9+
10+
const DEFAULT_PROVIDER_ID = 'openai_session';
11+
const EXPERIMENTAL_FLAG = 'CE_AI_ENABLE_EXPERIMENTAL_PROVIDERS';
12+
const SHADOW_FLAG = 'CE_AI_ENABLE_SHADOW';
13+
const CANARY_FLAG = 'CE_AI_ENABLE_CANARY';
14+
const PROVIDER_KEY = 'CE_AI_PROVIDER';
15+
16+
export interface ProviderEnvConfig {
17+
readonly providerId: 'openai_session' | string;
18+
readonly experimentalEnabled: boolean;
19+
readonly shadowEnabled: boolean;
20+
readonly canaryEnabled: boolean;
21+
}
22+
23+
function readTrimmed(env: NodeJS.ProcessEnv, key: string): string | undefined {
24+
const raw = env[key];
25+
if (typeof raw !== 'string') return undefined;
26+
const trimmed = raw.trim();
27+
return trimmed.length === 0 ? undefined : trimmed;
28+
}
29+
30+
function readBool(env: NodeJS.ProcessEnv, key: string): boolean {
31+
const v = readTrimmed(env, key);
32+
if (v === undefined) return false;
33+
const lower = v.toLowerCase();
34+
return lower === '1' || lower === 'true';
35+
}
36+
37+
export function readProviderEnvConfig(env: NodeJS.ProcessEnv = process.env): ProviderEnvConfig {
38+
const experimentalEnabled = readBool(env, EXPERIMENTAL_FLAG);
39+
40+
const rawProvider = readTrimmed(env, PROVIDER_KEY);
41+
const providerId = rawProvider ?? DEFAULT_PROVIDER_ID;
42+
43+
if (providerId !== DEFAULT_PROVIDER_ID && !experimentalEnabled) {
44+
throw new Error(
45+
`Provider '${providerId}' requires ${EXPERIMENTAL_FLAG}=1 to be enabled. ` +
46+
`CE_AI_PROVIDER alone cannot activate non-${DEFAULT_PROVIDER_ID} providers.`,
47+
);
48+
}
49+
50+
const shadowRequested = readBool(env, SHADOW_FLAG);
51+
if (shadowRequested && !experimentalEnabled) {
52+
throw new Error(
53+
`${SHADOW_FLAG} requires ${EXPERIMENTAL_FLAG}=1 to be enabled separately.`,
54+
);
55+
}
56+
const shadowEnabled = shadowRequested && experimentalEnabled;
57+
58+
const canaryRequested = readBool(env, CANARY_FLAG);
59+
if (canaryRequested && !experimentalEnabled) {
60+
throw new Error(
61+
`${CANARY_FLAG} requires ${EXPERIMENTAL_FLAG}=1 to be enabled separately.`,
62+
);
63+
}
64+
const canaryEnabled = canaryRequested && experimentalEnabled;
65+
66+
return Object.freeze({
67+
providerId,
68+
experimentalEnabled,
69+
shadowEnabled,
70+
canaryEnabled,
71+
});
72+
}

tests/ai/providers/env.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { describe, it, expect } from '@jest/globals';
2+
import { readProviderEnvConfig } from '../../../src/ai/providers/env.js';
3+
4+
describe('readProviderEnvConfig', () => {
5+
it('returns defaults when env is empty', () => {
6+
const cfg = readProviderEnvConfig({});
7+
expect(cfg.providerId).toBe('openai_session');
8+
expect(cfg.experimentalEnabled).toBe(false);
9+
expect(cfg.shadowEnabled).toBe(false);
10+
expect(cfg.canaryEnabled).toBe(false);
11+
});
12+
13+
it('treats explicit openai_session as default-safe', () => {
14+
const cfg = readProviderEnvConfig({ CE_AI_PROVIDER: 'openai_session' });
15+
expect(cfg.providerId).toBe('openai_session');
16+
expect(cfg.experimentalEnabled).toBe(false);
17+
});
18+
19+
it('treats whitespace-only CE_AI_PROVIDER as unset', () => {
20+
const cfg = readProviderEnvConfig({ CE_AI_PROVIDER: ' ' });
21+
expect(cfg.providerId).toBe('openai_session');
22+
});
23+
24+
it('throws when non-default provider requested without experimental gate', () => {
25+
expect(() => readProviderEnvConfig({ CE_AI_PROVIDER: 'copilot' })).toThrow(
26+
/copilot[\s\S]*CE_AI_ENABLE_EXPERIMENTAL_PROVIDERS|CE_AI_ENABLE_EXPERIMENTAL_PROVIDERS[\s\S]*copilot/
27+
);
28+
try {
29+
readProviderEnvConfig({ CE_AI_PROVIDER: 'copilot' });
30+
} catch (err) {
31+
const msg = (err as Error).message;
32+
expect(msg).toContain('copilot');
33+
expect(msg).toContain('CE_AI_ENABLE_EXPERIMENTAL_PROVIDERS');
34+
}
35+
});
36+
37+
it('allows non-default provider when experimental=1', () => {
38+
const cfg = readProviderEnvConfig({
39+
CE_AI_PROVIDER: 'copilot',
40+
CE_AI_ENABLE_EXPERIMENTAL_PROVIDERS: '1',
41+
});
42+
expect(cfg.providerId).toBe('copilot');
43+
expect(cfg.experimentalEnabled).toBe(true);
44+
});
45+
46+
it('accepts true (case-insensitive) for experimental flag', () => {
47+
expect(readProviderEnvConfig({ CE_AI_ENABLE_EXPERIMENTAL_PROVIDERS: 'true' }).experimentalEnabled).toBe(true);
48+
expect(readProviderEnvConfig({ CE_AI_ENABLE_EXPERIMENTAL_PROVIDERS: 'TRUE' }).experimentalEnabled).toBe(true);
49+
expect(readProviderEnvConfig({ CE_AI_ENABLE_EXPERIMENTAL_PROVIDERS: 'True' }).experimentalEnabled).toBe(true);
50+
expect(readProviderEnvConfig({ CE_AI_ENABLE_EXPERIMENTAL_PROVIDERS: '1' }).experimentalEnabled).toBe(true);
51+
});
52+
53+
it('rejects 0/false/yes/garbage for experimental flag', () => {
54+
expect(readProviderEnvConfig({ CE_AI_ENABLE_EXPERIMENTAL_PROVIDERS: '0' }).experimentalEnabled).toBe(false);
55+
expect(readProviderEnvConfig({ CE_AI_ENABLE_EXPERIMENTAL_PROVIDERS: 'false' }).experimentalEnabled).toBe(false);
56+
expect(readProviderEnvConfig({ CE_AI_ENABLE_EXPERIMENTAL_PROVIDERS: 'yes' }).experimentalEnabled).toBe(false);
57+
expect(readProviderEnvConfig({ CE_AI_ENABLE_EXPERIMENTAL_PROVIDERS: 'garbage' }).experimentalEnabled).toBe(false);
58+
expect(readProviderEnvConfig({ CE_AI_ENABLE_EXPERIMENTAL_PROVIDERS: '' }).experimentalEnabled).toBe(false);
59+
});
60+
61+
it('throws when shadow enabled without experimental', () => {
62+
try {
63+
readProviderEnvConfig({ CE_AI_ENABLE_SHADOW: '1' });
64+
throw new Error('expected throw');
65+
} catch (err) {
66+
const msg = (err as Error).message;
67+
expect(msg).toContain('CE_AI_ENABLE_SHADOW');
68+
expect(msg).toContain('CE_AI_ENABLE_EXPERIMENTAL_PROVIDERS');
69+
}
70+
});
71+
72+
it('enables shadow when both shadow and experimental are set', () => {
73+
const cfg = readProviderEnvConfig({
74+
CE_AI_ENABLE_SHADOW: '1',
75+
CE_AI_ENABLE_EXPERIMENTAL_PROVIDERS: '1',
76+
});
77+
expect(cfg.shadowEnabled).toBe(true);
78+
expect(cfg.experimentalEnabled).toBe(true);
79+
});
80+
81+
it('throws when canary enabled without experimental', () => {
82+
try {
83+
readProviderEnvConfig({ CE_AI_ENABLE_CANARY: '1' });
84+
throw new Error('expected throw');
85+
} catch (err) {
86+
const msg = (err as Error).message;
87+
expect(msg).toContain('CE_AI_ENABLE_CANARY');
88+
expect(msg).toContain('CE_AI_ENABLE_EXPERIMENTAL_PROVIDERS');
89+
}
90+
});
91+
92+
it('enables canary when both canary and experimental are set', () => {
93+
const cfg = readProviderEnvConfig({
94+
CE_AI_ENABLE_CANARY: '1',
95+
CE_AI_ENABLE_EXPERIMENTAL_PROVIDERS: '1',
96+
});
97+
expect(cfg.canaryEnabled).toBe(true);
98+
expect(cfg.experimentalEnabled).toBe(true);
99+
});
100+
101+
it('does not mutate the input env object', () => {
102+
const env = {
103+
CE_AI_PROVIDER: 'openai_session',
104+
CE_AI_ENABLE_EXPERIMENTAL_PROVIDERS: '1',
105+
CE_AI_ENABLE_SHADOW: '1',
106+
CE_AI_ENABLE_CANARY: '1',
107+
OTHER: 'keep',
108+
};
109+
const before = JSON.stringify(env);
110+
const beforeKeys = Object.keys(env).sort();
111+
readProviderEnvConfig(env);
112+
expect(JSON.stringify(env)).toBe(before);
113+
expect(Object.keys(env).sort()).toEqual(beforeKeys);
114+
});
115+
116+
it('reads from process.env when called with no args', () => {
117+
const KEY = 'CE_AI_ENABLE_EXPERIMENTAL_PROVIDERS';
118+
const original = process.env[KEY];
119+
try {
120+
process.env[KEY] = '1';
121+
expect(readProviderEnvConfig().experimentalEnabled).toBe(true);
122+
delete process.env[KEY];
123+
expect(readProviderEnvConfig().experimentalEnabled).toBe(false);
124+
} finally {
125+
if (original === undefined) {
126+
delete process.env[KEY];
127+
} else {
128+
process.env[KEY] = original;
129+
}
130+
}
131+
});
132+
});

0 commit comments

Comments
 (0)