Skip to content

Commit 0dc6483

Browse files
committed
fix(cli): release the pin when config set writes the selection order
`ocx config set` is fully generic -- it setPaths any dotted path, validates, and saves -- so writing codexAccountPriorities through it stored the new order and left activeCodexAccountPinned untouched. Nothing self-heals that: releaseDrainedCodexAccountPin only fires on a pin whose account is drained, so a pin on a healthy account kept capping the tier ceiling and the order just written had no visible effect. The documented rule that any priorities write releases the pin was true only through `ocx account priority` and the management route. `import` is deliberately not covered. That file supplies its own pin, so it is a statement rather than a leftover. Found by CodeRabbit on #715.
1 parent b4b3cf6 commit 0dc6483

2 files changed

Lines changed: 46 additions & 0 deletions

File tree

src/cli/config-command.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { readFileSync, writeFileSync } from "node:fs";
2+
import { clearCodexAccountPin } from "../codex/account-priority";
23
import { getConfigPath, readConfigDiagnostics, saveConfig, validateConfigCandidate } from "../config";
34
import type { OcxConfig } from "../types";
45
import { CliUsageError, printData, rejectArgs, runCliAction, takeFlag } from "./runtime-api";
@@ -102,6 +103,14 @@ export async function handleConfigCommand(argv: string[]): Promise<number> {
102103
setPath(candidate, path, raw === undefined ? undefined : parseValue(raw), action === "unset");
103104
const config = validate(candidate);
104105
const savedValue = action === "unset" ? null : getPath(config, path);
106+
// Setting the order here is the operator restating it, exactly as through
107+
// `ocx account priority` or the management route, so it releases the manual pin
108+
// for the same reason those do: a pin made before any order existed would
109+
// otherwise outrank every order set afterwards, capping the pool at the pinned
110+
// account's tier with nothing on any surface explaining why. `import` is
111+
// deliberately not covered — that file supplies its own pin, so there is no
112+
// stale one to release.
113+
if (pathSegments(path)[0] === "codexAccountPriorities") clearCodexAccountPin(config);
105114
saveConfig(config);
106115
printData({ ok: true, path, value: redact(savedValue, path.split(".").at(-1)) }, wantsJson,
107116
[`${action === "unset" ? "Unset" : "Set"} ${path}.`]);

tests/cli-headless-parity.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,4 +178,41 @@ describe("headless GUI parity CLI", () => {
178178
rmSync(home, { recursive: true, force: true });
179179
}
180180
});
181+
182+
test("config set releases the manual pin when it writes the selection order", async () => {
183+
const home = mkdtempSync(join(tmpdir(), "ocx-cli-priority-pin-"));
184+
const previous = process.env.OPENCODEX_HOME;
185+
process.env.OPENCODEX_HOME = home;
186+
const configPath = join(home, "config.json");
187+
const base = {
188+
port: 10100,
189+
providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } },
190+
defaultProvider: "openai",
191+
activeCodexAccountPinned: "work",
192+
};
193+
const readPin = () => JSON.parse(readFileSync(configPath, "utf8")).activeCodexAccountPinned;
194+
try {
195+
// The whole map, one entry, and a removal are all the operator restating the
196+
// order, so each releases the pin -- otherwise it keeps capping the tier
197+
// ceiling at "work" and the order just written has no visible effect.
198+
for (const argv of [
199+
["set", "codexAccountPriorities", '{"work":1}'],
200+
["set", "codexAccountPriorities.work", "2"],
201+
["unset", "codexAccountPriorities"],
202+
]) {
203+
writeFileSync(configPath, JSON.stringify({ ...base, codexAccountPriorities: { work: 0 } }));
204+
expect(await handleConfigCommand([...argv, "--json"])).toBe(0);
205+
expect(readPin()).toBeUndefined();
206+
}
207+
208+
// An unrelated field is not a statement about ordering, so the pin survives.
209+
writeFileSync(configPath, JSON.stringify({ ...base, codexAccountPriorities: { work: 1 } }));
210+
expect(await handleConfigCommand(["set", "autoSwitchThreshold", "50", "--json"])).toBe(0);
211+
expect(readPin()).toBe("work");
212+
} finally {
213+
if (previous === undefined) delete process.env.OPENCODEX_HOME;
214+
else process.env.OPENCODEX_HOME = previous;
215+
rmSync(home, { recursive: true, force: true });
216+
}
217+
});
181218
});

0 commit comments

Comments
 (0)