Skip to content
Draft
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
53 changes: 13 additions & 40 deletions src/grok/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,10 +454,6 @@ function isDisabledProviderModelId(
* remote host is left alone.
* - `x-opencodex-grok = "1"` in generated inline/child extra_headers, OR the historical
* chat_completions + `name = "OCX <model>"` + deterministic generated alias shape.
* - PROVIDER-INHERITANCE shape: `model_provider = "opencodex"` with no api_key/base_url of
* its own, adopting the verdict of the `[model_providers.opencodex]` table it references
* (the current block shape carries no per-model evidence; this mirrors Codex-side
* classifyCodexRouting).
* A loopback base_url ALONE is not enough: aiming your own model at the local proxy is a
* legitimate thing to do.
*
Expand All @@ -468,10 +464,10 @@ function isDisabledProviderModelId(
* span) and may interleave user tables between the parent and its children, so each
* provider's body is FOLDED with all its same-provider descendants before judging, and
* the removal span covers them by their exact ranges. The fenced provider is excluded
* from that sweep (the splice owns it) but still counts as ownership evidence, so
* teardown does not orphan models that inherit from it. The
* durable marker lives on the provider (never on the inheriting model), so the sweep is
* what keeps explicit ownership of inherited entries verifiable after a rewrite.
* from that sweep because the regular splice owns it. The provider marker grants
* ownership only over that provider table. Model tables must
* carry their own marker: inheriting a managed provider is supported for user-authored
* models and therefore cannot safely grant deletion authority over the model.
*/
function findOpencodexOrphans(content: string, region: ManagedRegion | null): OrphanTable[] {
const orphans: OrphanTable[] = [];
Expand All @@ -488,20 +484,15 @@ function findOpencodexOrphans(content: string, region: ManagedRegion | null): Or
// Collect every table header first: a table body runs to the NEXT header, whatever it is.
const headers = analyzeTomlStructure(content).headers;
// [model_providers.<id>] tables outside the fence, folded with their own sub-tables (see
// the function doc). A table passing the predicate (our api_key literal + a loopback
// base_url + the durable marker inline or in a re-serialized child) contributes to
// `ownedProviderIds` for the model scan below; one with OUR id is additionally swept as
// an orphan of a previous managed block (a leftover here collides with the regenerated
// the function doc). A table with OUR id that passes the predicate (our api_key literal
// + a loopback base_url + the durable marker inline or in a re-serialized child) is
// swept as an orphan of a previous managed block (a leftover here collides with the regenerated
// block's provider table — duplicate key — and alias rewriting skips provider orphans
// because they have no alias and no model id). The dot-terminated prefix keeps a user's
// `[model_providers.opencodex_backup]` out of scope.
const ownedProviderIds = new Set<string>();
for (const [position, header] of headers.entries()) {
if (header.array || header.segments.length !== 2 || header.segments[0] !== "model_providers") continue;
// Inside the fence the regular splice owns the table, but it is still ownership
// evidence: models kept outside the fence after a Grok rewrite (retired ids) inherit
// their verdict from the fenced provider, so classification must happen while the
// fence still exists or teardown leaves them with a dangling model_provider reference.
// Inside the fence the regular splice owns the table, so do not add it as an orphan.
const insideRegion = region !== null
&& header.index >= region.start && header.index < region.end;
const end = clampEnd(header.index, headers[position + 1]?.index ?? content.length);
Expand Down Expand Up @@ -532,7 +523,6 @@ function findOpencodexOrphans(content: string, region: ManagedRegion | null): Or
// shows it as a bare `x-opencodex-grok = "1"` assignment. Both forms decide.
if (!hasInlineOwnershipMarker(keys.get("extra_headers"))
&& keys.get(OPENCODEX_GROK_MARKER) !== "1") continue;
ownedProviderIds.add(header.segments[1]!);
if (insideRegion) continue;
if (header.segments[1] === OPENCODEX_PROVIDER_ID) {
orphans.push({
Expand All @@ -553,23 +543,6 @@ function findOpencodexOrphans(content: string, region: ManagedRegion | null): Or
const keys = tableBodyKeys(content.slice(header.index + header.length, bodyEnd));
const modelId = keys.get("model");
if (!modelId) continue;
// Two shapes carry our ownership signal. The current managed block routes every model
// through a shared provider table (`model_provider = "opencodex"`), so a re-serialized
// unfenced entry has NO api_key/base_url of its own — the evidence lives on the provider
// table it references (Codex-side precedent: classifyCodexRouting follows model_provider
// for the same reason). Inheritance is accepted only from a provider that itself passed
// the strict predicate above, and only for rows whose alias carries the generated
// fingerprint: a user is free to reference the managed provider from their own
// [model.*] table, and inheritance alone must not grant removal authority over it.
const providerId = keys.get("model_provider");
const inheritedOwned =
providerId === OPENCODEX_PROVIDER_ID
&& ownedProviderIds.has(OPENCODEX_PROVIDER_ID)
&& isGeneratedAliasForModel(header.segments[1]!, modelId);
if (!inheritedOwned) {
if (keys.get("api_key") !== OPENCODEX_API_KEY) continue;
if (!isLoopbackBaseUrl(keys.get("base_url"))) continue;
}
let hasOwnershipMarker = hasInlineOwnershipMarker(keys.get("extra_headers"));
// Swallow the entry's OWN sub-tables (`[model.<alias>.extra_headers]`, and after #1756
// `[[model.<alias>.reasoning_efforts]]`). Grok may re-serialize them non-contiguously,
Expand All @@ -591,13 +564,12 @@ function findOpencodexOrphans(content: string, region: ManagedRegion | null): Or
}
}
const legacyGenerated = isLegacyGeneratedTable(header.segments[1]!, keys);
// An inherited model has no per-model marker; its verdict comes from the provider
// table it references, which only lands here when that provider proved durable
// ownership. A legacy-fingerprint model keeps dev's conservative classification.
const ownership: "explicit" | "legacy" = inheritedOwned || hasOwnershipMarker
// A marker is durable deletion authority. A legacy-fingerprint model keeps dev's
// conservative classification and is migrated only when this write replaces it.
const ownership: "explicit" | "legacy" = hasOwnershipMarker
? "explicit"
: "legacy";
if (!hasOwnershipMarker && !legacyGenerated && !inheritedOwned) continue;
if (!hasOwnershipMarker && !legacyGenerated) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore endpoint checks before adopting legacy Grok models

When an unmarked user table has the legacy-looking alias/name/backend shape and its model ID is emitted by the current catalog, this condition now classifies it as a legacy orphan without checking api_key or base_url. A normal Grok sync will therefore delete and replace even a user-authored remote model such as [model.ocx-gpt-5-6-sol] with api_backend = "chat_completions", name = "OCX gpt-5.6-sol", a remote URL, and a user secret, losing its custom settings. Keep the former OpenCodex-key and loopback-URL checks for the legacyGenerated path, while allowing only the durable per-model marker to bypass them.

AGENTS.md reference: src/AGENTS.md:L10-L10

Useful? React with 👍 / 👎.

orphans.push({
alias: header.segments[1]!,
modelId,
Expand Down Expand Up @@ -1036,6 +1008,7 @@ export function buildGrokManagedBlock(
`model = ${tomlString(model.id)}`,
`model_provider = ${tomlString(OPENCODEX_PROVIDER_ID)}`,
`name = ${tomlString(model.name ?? `OCX ${model.id}`)}`,
'extra_headers = { "x-opencodex-grok" = "1" }',
);
if (Number.isFinite(model.contextWindow) && (model.contextWindow ?? 0) > 0) {
lines.push(`context_window = ${model.contextWindow}`);
Expand Down
6 changes: 4 additions & 2 deletions tests/grok-attribution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ import type { OcxConfig } from "../src/types";
test("the managed fence stamps the grok attribution header on every model", () => {
const block = buildGrokManagedBlock(10100, [{ id: "kimi/k3", contextWindow: 262_144 }]);
expect(block).toContain('extra_headers = { "x-opencodex-grok" = "1" }');
// The header lives in the shared [model_providers.opencodex] block, inherited by every
// [model.*] table that references it via model_provider.
// The provider carries the request header, and each model repeats it as durable
// per-table ownership evidence in case Grok moves the table outside the fence.
const providerBlock = block.slice(block.indexOf("[model_providers.opencodex]"), block.indexOf("[model."));
expect(providerBlock).toContain("x-opencodex-grok");
const modelBlock = block.slice(block.indexOf("[model."));
expect(modelBlock).toContain("x-opencodex-grok");
});

test("the fence survives a write and keeps the header line parseable", () => {
Expand Down
1 change: 1 addition & 0 deletions tests/grok-config-inject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ describe("Grok config injection", () => {
const table = block.slice(block.indexOf("[model.ocx-cursor-grok-4-5]"));
expect(table).toContain('model = "cursor/grok-4.5"');
expect(table).toContain('model_provider = "opencodex"');
expect(table).toContain('extra_headers = { "x-opencodex-grok" = "1" }');
expect(table).not.toContain('base_url =');
expect(table).not.toContain('api_key =');
expect(table).not.toContain('api_backend =');
Expand Down
52 changes: 31 additions & 21 deletions tests/grok-orphan-adoption.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1405,6 +1405,7 @@ describe("Grok orphan adoption — fence boundary (#511 follow-up)", () => {
'model = "gpt-5.6-sol"',
'model_provider = "opencodex"',
'name = "OCX gpt-5.6-sol"',
OWNERSHIP_MARKER,
"",
"[models]",
'default = "ocx-gpt-5-6-sol"',
Expand Down Expand Up @@ -1449,11 +1450,9 @@ describe("Grok orphan adoption — fence boundary (#511 follow-up)", () => {
expect(() => Bun.TOML.parse(content)).not.toThrow();
});

// The migration's real regression: model tables in the provider-inheritance shape carry
// NO api_key/base_url of their own, so the legacy predicate missed them and every sync
// after a Grok rewrite allocated a -2 duplicate beside the stale original. Adoption
// must follow the model_provider reference to the owned provider table.
test("adopts model_provider-referencing entries left unfenced by a Grok rewrite", () => {
// Modern model tables carry their own marker so a Grok rewrite can move them outside
// the fence without making ownership ambiguous.
test("adopts marked model_provider-referencing entries left unfenced by a Grok rewrite", () => {
writeFileSync(configPath, [
"[ui]",
'fork_secondary_model = "grok-build"',
Expand All @@ -1469,11 +1468,13 @@ describe("Grok orphan adoption — fence boundary (#511 follow-up)", () => {
'model = "gpt-5.6-sol"',
'model_provider = "opencodex"',
'name = "OCX gpt-5.6-sol"',
OWNERSHIP_MARKER,
"",
"[model.ocx-gpt-5-6-terra]",
'model = "gpt-5.6-terra"',
'model_provider = "opencodex"',
'name = "OCX gpt-5.6-terra"',
OWNERSHIP_MARKER,
"",
"[models]",
'default = "ocx-gpt-5-6-sol"',
Expand Down Expand Up @@ -1544,6 +1545,7 @@ describe("Grok orphan adoption — fence boundary (#511 follow-up)", () => {
'model = "gpt-5.6-sol"',
'model_provider = "opencodex"',
'name = "OCX gpt-5.6-sol"',
OWNERSHIP_MARKER,
"",
"[models]",
'default = "ocx-gpt-5-6-sol"',
Expand All @@ -1561,10 +1563,9 @@ describe("Grok orphan adoption — fence boundary (#511 follow-up)", () => {
expect(() => Bun.TOML.parse(content)).not.toThrow();
});

test("teardown resolves inherited ownership through the fenced provider", () => {
// A retired model kept outside the fence inherits its verdict from the provider table
// INSIDE it. Classification must see the fenced provider, or strip removes the fence
// but leaves the model with a dangling `model_provider = "opencodex"` reference.
test("teardown removes a marked inherited model outside the fence", () => {
// A retired generated model remains removable after Grok moves it outside the fence
// because the model retains its own durable ownership marker.
writeFileSync(configPath, [
"[ui]",
'fork_secondary_model = "grok-build"',
Expand All @@ -1586,6 +1587,7 @@ describe("Grok orphan adoption — fence boundary (#511 follow-up)", () => {
'model = "gpt-5.6-terra"',
'model_provider = "opencodex"',
'name = "OCX gpt-5.6-terra"',
OWNERSHIP_MARKER,
"",
"[models]",
'default = "ocx-gpt-5-6-terra"',
Expand All @@ -1602,43 +1604,51 @@ describe("Grok orphan adoption — fence boundary (#511 follow-up)", () => {
expect(() => Bun.TOML.parse(content)).not.toThrow();
});

test("does not adopt a user-written model that references the managed provider", () => {
// Inheritance must not grant removal authority over every model that references
// opencodex: a user is free to write their own [model.*] table that inherits the
// managed provider, and adoption without a generated alias deletes it.
test("preserves generated-looking user models that inherit the managed provider", () => {
// Neither the inherited provider nor a predictable alias proves that this model was
// generated. In particular, an upstream catalog id must not grant deletion authority.
for (const operation of ["inject", "teardown"] as const) {
writeFileSync(configPath, [
"[models]",
'default = "ocx-evil-foo"',
"",
BEGIN_MARKER,
"[model_providers.opencodex]",
'base_url = "http://127.0.0.1:10100/v1"',
'api_backend = "responses"',
'api_key = "opencodex-loopback"',
OWNERSHIP_MARKER,
"",
"[model.ocx-gpt-5-6-sol]",
'model = "gpt-5.6-sol"',
'model_provider = "opencodex"',
'name = "OCX gpt-5.6-sol"',
END_MARKER,
"",
"[model.custom-variant]",
'model = "gpt-5.6-sol"',
"[model.ocx-evil-foo]",
'model = "evil/foo"',
'model_provider = "opencodex"',
'name = "my fast variant"',
"context_window = 128000",
"custom_user_setting = true",
"",
"[model.ocx-evil-foo.extra_headers]",
'x-user-custom = "keep-me"',
"",
].join("\n"));

if (operation === "inject") {
expect(injectGrokConfig(10100, MODELS, { grokHome }))
expect(injectGrokConfig(10100, MODELS, {
grokHome,
catalogModelIds: new Set(["gpt-5.6-sol", "evil/foo"]),
}))
.toMatchObject({ ok: true, changed: true });
} else {
expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: true, changed: true });
}
const content = readFileSync(configPath, "utf8");
expect(content).toContain("[model.custom-variant]");
expect(content).toContain("[model.ocx-evil-foo]");
expect(content).toContain('name = "my fast variant"');
expect(content).toContain("context_window = 128000");
expect(content).toContain("custom_user_setting = true");
expect(content).toContain('x-user-custom = "keep-me"');
expect(content).toContain('default = "ocx-evil-foo"');
expect(() => Bun.TOML.parse(content)).not.toThrow();
}
});
Expand Down
Loading