Skip to content

Commit e60971f

Browse files
Fail closed when a named profile file is missing (#875)
* Fail closed when a named profile file is missing * Drive the named-profile merge test through resolveProfile
1 parent dfe1856 commit e60971f

3 files changed

Lines changed: 61 additions & 40 deletions

File tree

docs/IMPLEMENTATION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ prerequisites; Ollama installation remains outside this flow.
337337
Profiles supply per-project or named-profile overrides for `model` and `systemPromptExtensions` (the only allowed keys; any other key is rejected on load).
338338

339339
- Project profile: `.corbits/profile.json` in the repo root — committed, credential-free.
340-
- Named profiles: `~/.corbits/profiles/<name>.json` — user-level overrides, inherited via the `profile` key or the `--profile` flag.
340+
- Named profiles: `~/.corbits/profiles/<name>.json` — user-level overrides, inherited via the `profile` key or the `--profile` flag. A missing named file fails closed. A missing project `profile.json` overlay is optional.
341341

342342
```json
343343
{

src/config/profiles.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,19 @@ export async function loadProfile(path: string): Promise<ProfileConfig | null> {
7474
export async function resolveProfile(
7575
cwd: string,
7676
profileName?: string,
77+
home: string = homedir(),
7778
): Promise<ProfileConfig> {
7879
const projectProfile = await loadProfile(projectProfilePath(cwd));
7980

8081
const namedProfileName = profileName ?? projectProfile?.profile;
8182

8283
let namedProfile: ProfileConfig | null = null;
8384
if (namedProfileName !== undefined) {
84-
const namedPath = join(profilesDir(), `${namedProfileName}.json`);
85+
const namedPath = join(profilesDir(home), `${namedProfileName}.json`);
8586
namedProfile = await loadProfile(namedPath);
87+
if (namedProfile === null) {
88+
throw new Error(`Profile not found: ${namedPath}`);
89+
}
8690
}
8791

8892
// Merge: project profile fields override named profile fields.

src/profiles.test.ts

Lines changed: 55 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ function makeTmp(): string {
1616
);
1717
}
1818

19+
async function writeJson(path: string, value: unknown): Promise<void> {
20+
await writeFile(path, JSON.stringify(value));
21+
}
22+
1923
test("profilesDir returns ~/.corbits/profiles", () => {
2024
const result = profilesDir("/home/user");
2125
expect(result).toBe("/home/user/.corbits/profiles");
@@ -35,7 +39,7 @@ test("loadProfile parses valid profile", async () => {
3539
const dir = makeTmp();
3640
await mkdir(dir, { recursive: true });
3741
const path = join(dir, "profile.json");
38-
await writeFile(path, JSON.stringify({ model: "claude-opus-4-8" }));
42+
await writeJson(path, { model: "claude-opus-4-8" });
3943
const result = await loadProfile(path);
4044
expect(result).toEqual({ model: "claude-opus-4-8" });
4145
});
@@ -44,10 +48,9 @@ test("loadProfile parses systemPromptExtensions", async () => {
4448
const dir = makeTmp();
4549
await mkdir(dir, { recursive: true });
4650
const path = join(dir, "profile.json");
47-
await writeFile(
48-
path,
49-
JSON.stringify({ systemPromptExtensions: ["no-destructive-migrations"] }),
50-
);
51+
await writeJson(path, {
52+
systemPromptExtensions: ["no-destructive-migrations"],
53+
});
5154
const result = await loadProfile(path);
5255
expect(result).toEqual({
5356
systemPromptExtensions: ["no-destructive-migrations"],
@@ -58,15 +61,15 @@ test("loadProfile rejects unknown keys", async () => {
5861
const dir = makeTmp();
5962
await mkdir(dir, { recursive: true });
6063
const path = join(dir, "profile.json");
61-
await writeFile(path, JSON.stringify({ model: "x", unknownKey: true }));
64+
await writeJson(path, { model: "x", unknownKey: true });
6265
await expect(loadProfile(path)).rejects.toThrow(/unknownKey must be removed/);
6366
});
6467

6568
test("loadProfile rejects non-array systemPromptExtensions", async () => {
6669
const dir = makeTmp();
6770
await mkdir(dir, { recursive: true });
6871
const path = join(dir, "profile.json");
69-
await writeFile(path, JSON.stringify({ systemPromptExtensions: "bad" }));
72+
await writeJson(path, { systemPromptExtensions: "bad" });
7073
await expect(loadProfile(path)).rejects.toThrow(/systemPromptExtensions/);
7174
});
7275

@@ -85,58 +88,72 @@ test("resolveProfile returns empty object when no profile files exist", async ()
8588
expect(result).toEqual({});
8689
});
8790

91+
test("resolveProfile throws when --profile names a missing file", async () => {
92+
const home = makeTmp();
93+
const cwd = makeTmp();
94+
await mkdir(cwd, { recursive: true });
95+
const name = "does-not-exist";
96+
const missingPath = join(profilesDir(home), `${name}.json`);
97+
expect(await loadProfile(missingPath)).toBeNull();
98+
await expect(resolveProfile(cwd, name, home)).rejects.toThrow(missingPath);
99+
});
100+
101+
test("resolveProfile loads a valid named profile", async () => {
102+
const home = makeTmp();
103+
const cwd = makeTmp();
104+
await mkdir(cwd, { recursive: true });
105+
const namedDir = join(home, ".corbits", "profiles");
106+
await mkdir(namedDir, { recursive: true });
107+
await writeJson(join(namedDir, "work.json"), { model: "named-model" });
108+
const result = await resolveProfile(cwd, "work", home);
109+
expect(result.model).toBe("named-model");
110+
expect(result.profile).toBe("work");
111+
});
112+
88113
test("resolveProfile applies project profile fields", async () => {
89114
const cwd = makeTmp();
90115
const dir = join(cwd, ".corbits");
91116
await mkdir(dir, { recursive: true });
92-
await writeFile(
93-
join(dir, "profile.json"),
94-
JSON.stringify({
95-
model: "claude-sonnet",
96-
systemPromptExtensions: ["ext1"],
97-
}),
98-
);
117+
await writeJson(join(dir, "profile.json"), {
118+
model: "claude-sonnet",
119+
systemPromptExtensions: ["ext1"],
120+
});
99121
const result = await resolveProfile(cwd);
100122
expect(result.model).toBe("claude-sonnet");
101123
expect(result.systemPromptExtensions).toEqual(["ext1"]);
102124
});
103125

104-
test("resolveProfile surfaces profile name when set", async () => {
126+
test("resolveProfile throws when a named profile key points at a missing file", async () => {
127+
const home = makeTmp();
105128
const cwd = makeTmp();
106129
const dir = join(cwd, ".corbits");
107130
await mkdir(dir, { recursive: true });
108-
await writeFile(
109-
join(dir, "profile.json"),
110-
JSON.stringify({ profile: "work" }),
131+
const name = "no-such-named-profile";
132+
await writeJson(join(dir, "profile.json"), { profile: name });
133+
const missingPath = join(profilesDir(home), `${name}.json`);
134+
await expect(resolveProfile(cwd, undefined, home)).rejects.toThrow(
135+
missingPath,
111136
);
112-
const result = await resolveProfile(cwd);
113-
expect(result.profile).toBe("work");
114137
});
115138

116139
test("resolveProfile: project profile fields override named profile fields", async () => {
117140
const home = makeTmp();
118141
const cwd = makeTmp();
119142
const namedDir = join(home, ".corbits", "profiles");
120143
await mkdir(namedDir, { recursive: true });
121-
await writeFile(
122-
join(namedDir, "work.json"),
123-
JSON.stringify({ model: "base-model", systemPromptExtensions: ["ext1"] }),
124-
);
144+
await writeJson(join(namedDir, "work.json"), {
145+
model: "base-model",
146+
systemPromptExtensions: ["ext1"],
147+
});
125148
const localDir = join(cwd, ".corbits");
126149
await mkdir(localDir, { recursive: true });
127-
await writeFile(
128-
join(localDir, "profile.json"),
129-
JSON.stringify({ profile: "work", model: "override-model" }),
130-
);
150+
await writeJson(join(localDir, "profile.json"), {
151+
profile: "work",
152+
model: "override-model",
153+
});
131154

132-
// We can't easily inject profilesDir home in resolveProfile without additional plumbing,
133-
// so test the merge logic directly via the exported functions.
134-
// Project profile model should win over named profile model.
135-
const projectProfile = await loadProfile(join(localDir, "profile.json"));
136-
const namedProfile = await loadProfile(join(namedDir, "work.json"));
137-
const merged = { ...namedProfile };
138-
if (projectProfile?.model !== undefined) merged.model = projectProfile.model;
139-
expect(merged.model).toBe("override-model");
140-
// systemPromptExtensions not in project profile so named profile value survives
141-
expect(merged.systemPromptExtensions).toEqual(["ext1"]);
155+
const result = await resolveProfile(cwd, undefined, home);
156+
expect(result.model).toBe("override-model");
157+
expect(result.systemPromptExtensions).toEqual(["ext1"]);
158+
expect(result.profile).toBe("work");
142159
});

0 commit comments

Comments
 (0)