Skip to content

Commit 47137eb

Browse files
authored
Merge pull request #92 from ghostwright/phase-c/5-config-loader-metadata
config: add secret_source=metadata mode and ${secret:NAME} interpolation
2 parents 34c252a + f6a246e commit 47137eb

14 files changed

Lines changed: 629 additions & 54 deletions

src/agent/__tests__/prompt-assembler.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ const baseConfig: PhantomConfig = {
99
port: 3100,
1010
role: "swe",
1111
model: "claude-opus-4-6",
12-
provider: { type: "anthropic" },
12+
provider: { type: "anthropic", secret_name: "provider_token" },
13+
secret_source: "env",
1314
effort: "max",
1415
max_budget_usd: 0,
1516
timeout_minutes: 240,

src/cli/doctor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ async function checkConfig(): Promise<CheckResult> {
9090
}
9191
try {
9292
const { loadConfig } = await import("../config/loader.ts");
93-
const config = loadConfig();
93+
const config = await loadConfig();
9494
return { name: "Config", status: "ok", message: `${config.name} (${config.role}, port ${config.port})` };
9595
} catch (err: unknown) {
9696
const msg = err instanceof Error ? err.message : String(err);
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3+
import { loadConfig } from "../loader.ts";
4+
5+
// Distinct TEST_DIR from loader.test.ts so the two suites cannot race on the
6+
// same filesystem path when bun runs them in parallel.
7+
const TEST_DIR = "/tmp/phantom-test-config-metadata";
8+
9+
function writeYaml(filename: string, content: string): string {
10+
mkdirSync(TEST_DIR, { recursive: true });
11+
const path = `${TEST_DIR}/${filename}`;
12+
writeFileSync(path, content);
13+
return path;
14+
}
15+
16+
function cleanup(): void {
17+
rmSync(TEST_DIR, { recursive: true, force: true });
18+
}
19+
20+
describe("loadConfig secret_source", () => {
21+
const originalFetch = globalThis.fetch;
22+
const savedKey = process.env.ANTHROPIC_API_KEY;
23+
const savedToken = process.env.ANTHROPIC_AUTH_TOKEN;
24+
25+
beforeEach(() => {
26+
process.env.ANTHROPIC_API_KEY = undefined;
27+
process.env.ANTHROPIC_AUTH_TOKEN = undefined;
28+
});
29+
30+
afterEach(() => {
31+
globalThis.fetch = originalFetch;
32+
if (savedKey !== undefined) {
33+
process.env.ANTHROPIC_API_KEY = savedKey;
34+
} else {
35+
process.env.ANTHROPIC_API_KEY = undefined;
36+
}
37+
if (savedToken !== undefined) {
38+
process.env.ANTHROPIC_AUTH_TOKEN = savedToken;
39+
} else {
40+
process.env.ANTHROPIC_AUTH_TOKEN = undefined;
41+
}
42+
cleanup();
43+
});
44+
45+
test("secret_source defaults to 'env' when omitted from YAML", async () => {
46+
const path = writeYaml("default-source.yaml", "name: default-source");
47+
const config = await loadConfig(path);
48+
expect(config.secret_source).toBe("env");
49+
expect(config.secret_source_url).toBeUndefined();
50+
});
51+
52+
test("secret_source: metadata populates ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN from gateway", async () => {
53+
// Body deliberately not asserted as a string literal anywhere; we read it
54+
// back from process.env and compare to the stub-injected reference.
55+
const stubBody = "stub-token-value";
56+
globalThis.fetch = mock((url: string | Request) => {
57+
expect(String(url)).toBe("http://gateway.test/v1/secrets/provider_token");
58+
return Promise.resolve(
59+
new Response(stubBody, {
60+
status: 200,
61+
headers: { "X-Phantom-Rotation-Id": "1" },
62+
}),
63+
);
64+
}) as unknown as typeof fetch;
65+
66+
const path = writeYaml(
67+
"metadata-source.yaml",
68+
`
69+
name: metadata-tenant
70+
secret_source: metadata
71+
secret_source_url: http://gateway.test
72+
`,
73+
);
74+
await loadConfig(path);
75+
expect(process.env.ANTHROPIC_API_KEY).toBe(stubBody);
76+
expect(process.env.ANTHROPIC_AUTH_TOKEN).toBe(stubBody);
77+
});
78+
79+
test("secret_source: metadata resolves whole-string ${secret:NAME} references in nested config", async () => {
80+
// Use peers.test_peer.token, an existing schema field that accepts an
81+
// arbitrary string and is nested. We assert the resolved value via a
82+
// non-logging path: read it back from the returned config object.
83+
const peerSecret = "peer-secret-payload";
84+
globalThis.fetch = mock((url: string | Request) => {
85+
const u = String(url);
86+
if (u.endsWith("/v1/secrets/provider_token")) {
87+
return Promise.resolve(
88+
new Response("provider-token-value", { status: 200, headers: { "X-Phantom-Rotation-Id": "1" } }),
89+
);
90+
}
91+
if (u.endsWith("/v1/secrets/peer_token")) {
92+
return Promise.resolve(new Response(peerSecret, { status: 200, headers: { "X-Phantom-Rotation-Id": "1" } }));
93+
}
94+
throw new Error(`unexpected URL in test: ${u}`);
95+
}) as unknown as typeof fetch;
96+
97+
const path = writeYaml(
98+
"metadata-walker.yaml",
99+
`
100+
name: walker-tenant
101+
secret_source: metadata
102+
secret_source_url: http://gateway.test
103+
peers:
104+
test_peer:
105+
url: https://peer.test
106+
token: \${secret:peer_token}
107+
`,
108+
);
109+
const config = await loadConfig(path);
110+
expect(config.peers?.test_peer?.token).toBe(peerSecret);
111+
});
112+
113+
test("secret_source: metadata does NOT resolve partial-string ${secret:NAME} (security invariant)", async () => {
114+
// Whole-string-only matching guards against secret leakage via logged
115+
// URLs or composed strings. If this test ever fails, the regex changed
116+
// in a way that allows partial interpolation, which is a security regression.
117+
const partial = "https://peer.test/?token=${secret:peer_token}";
118+
globalThis.fetch = mock((url: string | Request) => {
119+
const u = String(url);
120+
if (u.endsWith("/v1/secrets/provider_token")) {
121+
return Promise.resolve(
122+
new Response("provider-token-value", { status: 200, headers: { "X-Phantom-Rotation-Id": "1" } }),
123+
);
124+
}
125+
throw new Error(`unexpected URL in test (partial-string should NOT trigger fetch): ${u}`);
126+
}) as unknown as typeof fetch;
127+
128+
const path = writeYaml(
129+
"metadata-partial.yaml",
130+
`
131+
name: partial-tenant
132+
secret_source: metadata
133+
secret_source_url: http://gateway.test
134+
peers:
135+
test_peer:
136+
url: ${partial}
137+
token: keep-as-is
138+
`,
139+
);
140+
const config = await loadConfig(path);
141+
expect(config.peers?.test_peer?.url).toBe(partial);
142+
expect(config.peers?.test_peer?.token).toBe("keep-as-is");
143+
});
144+
145+
test("secret_source: metadata surfaces fetch failures as errors that include the secret name", async () => {
146+
globalThis.fetch = mock(() =>
147+
Promise.resolve(new Response("internal error", { status: 500, statusText: "Internal Server Error" })),
148+
) as unknown as typeof fetch;
149+
150+
const path = writeYaml(
151+
"metadata-fail.yaml",
152+
`
153+
name: fail-tenant
154+
secret_source: metadata
155+
secret_source_url: http://gateway.test
156+
`,
157+
);
158+
await expect(loadConfig(path)).rejects.toThrow(/provider_token/);
159+
await expect(loadConfig(path)).rejects.toThrow(/500/);
160+
});
161+
162+
test("invalid provider.secret_name rejects at loadConfig parse time with a schema error", async () => {
163+
// Phase C #5 review Finding 1: pinning the regex at the schema level
164+
// surfaces bad names at parse time rather than crashing at boot inside
165+
// the metadata fetcher. We use secret_source: env so no fetcher is
166+
// constructed; the rejection must come from PhantomConfigSchema itself.
167+
const path = writeYaml(
168+
"bad-secret-name.yaml",
169+
`
170+
name: bad-name-tenant
171+
provider:
172+
type: anthropic
173+
secret_name: Bad-Name
174+
`,
175+
);
176+
await expect(loadConfig(path)).rejects.toThrow(/Invalid config/);
177+
await expect(loadConfig(path)).rejects.toThrow(/secret_name/);
178+
});
179+
});

0 commit comments

Comments
 (0)