Skip to content
Merged
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
45 changes: 33 additions & 12 deletions src/providers/devin-provider-merge-migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,33 +195,54 @@ const DEFAULT_DEPS: DevinProviderMergeStartupDeps = {
* the snapshot is taken strictly before the save, and a backup failure throws
* rather than writing without a rollback point.
*
* The auth half is deliberately detached. `startServer` is synchronous — an
* `await` in the boot window would suspend the composition root — and
* Both destination slots are inspected before either account-bound file is
* changed. The auth write itself is deliberately detached. `startServer` is
* synchronous — an `await` in the boot window would suspend the composition root — and
* `mutateStore` is async-only, so the rekey is fired after its snapshot and
* its outcome is logged when it lands. That is safe here: the credential is
* valid under either slot name while the `devin-cli` alias exists, a conflict
* refuses by design, and a failed rekey simply retries on the next boot.
* its outcome is logged when it lands. A late concurrent conflict refuses by
* design, and a failed rekey simply retries on the next boot.
*/
export function runDevinProviderMergeStartupMigration(
config: OcxConfig,
deps: DevinProviderMergeStartupDeps = DEFAULT_DEPS,
): OcxConfig {
const projection = deps.project(config);
// Warnings are emitted even on a no-op: the collision case IS the warning.
const hasSourceConfig = config.providers?.[FROM_ID] !== undefined;
const hasSourceAuth = deps.hasAuthSlot(FROM_ID);
const hasDestinationAuth = deps.hasAuthSlot(TO_ID);

// A configured provider and its credentials are one account-bound unit. Do
// not move either half if the config projection refused, or if the target
// credential slot could belong to another account.
if (hasSourceConfig && (!projection.changed || hasDestinationAuth)) {
// Projection warnings still matter on a no-op: a config collision is the warning.
for (const warning of projection.warnings) console.warn(`[devin-provider-merge] ${warning}`);
if (projection.changed && hasDestinationAuth) {
console.warn(
`[devin-provider-merge] auth.json already has a "${TO_ID}" credential slot; `
+ `provider "${FROM_ID}" and both credential slots were left untouched. Remove the `
+ "unused destination credential manually, then restart.",
);
}
return config;
}

for (const warning of projection.warnings) console.warn(`[devin-provider-merge] ${warning}`);

let result = config;
if (projection.changed) {
// Snapshot both account-bound files before changing either one.
if (hasSourceAuth) deps.backupAuth();
deps.backupConfig();
deps.save(projection.config);
result = projection.config;
}

// The auth rekey runs even when the config half refused or had nothing to
// do: a `devin-cli` credential slot is orphaned state regardless of whether
// a provider row still points at it, and the conflict check inside the
// rekey is the same refuse-on-occupied rule the config half applies.
if (!deps.hasAuthSlot(FROM_ID)) return result;
deps.backupAuth();
// With no legacy config row, a `devin-cli` credential slot is orphaned and
// can still be rekeyed under the helper's refuse-on-occupied rule. A refused
// config migration returned above so its account-bound slot stays put.
if (!hasSourceAuth) return result;
if (!projection.changed) deps.backupAuth();
void deps.rekey(FROM_ID, TO_ID).then(outcome => {
if (outcome === "conflict") {
console.warn(
Expand Down
10 changes: 6 additions & 4 deletions structure/providers/xai-grok.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ The shared Responses path follows the [bounded multipart recovery contract](../s

- **Reasoning folding:** the Responses parser folds `reasoning` items into the FOLLOWING
assistant turn (`pendingReasoning` in `src/responses/parser.ts`) so the Grok chat wire carries
ONE assistant message with `reasoning_content` — exact-prefix cache stability. Unsigned
ONE assistant message with `reasoning_content` ??exact-prefix cache stability. Unsigned
siblings newline-join; `ocxr1`-signed siblings stay separate parts (Anthropic replay keeps
each signature on its own text); boundaries (user/tool-result/agent) clear pending state;
call items fold pending reasoning into the same turn.
Expand All @@ -53,7 +53,7 @@ The shared Responses path follows the [bounded multipart recovery contract](../s
- **Two-lock refresh transaction:** per-provider+account intent lock held across the IdP
exchange plus a short global store-write lock + async mutation funnel around every
`auth.json` load-merge-persist (`src/oauth/store.ts`); generation-guarded persist
(`expectedGeneration` → superseded adoption), conditional `needsReauth`, bounded jittered
(`expectedGeneration` ??superseded adoption), conditional `needsReauth`, bounded jittered
retry for transient token-endpoint failures.
Newly created legacy-store recovery copies follow the [backup ownership contract](../config.md#restore);
an ownership-registration failure (a `false` return or thrown error) warns without discarding downgrade recovery.
Expand Down Expand Up @@ -85,7 +85,7 @@ malformed, gapped, oversized, contradictory, failed, or incomplete streams stay
- **Transport:** Binary gRPC-Web over HTTP/1.1 or HTTP/2 with 5-byte frame envelope (`0x00` data / `0x80` trailers) and protobuf wire format. Plain JSON is rejected with empty responses upstream.
- **Authentication:** `Authorization: Bearer <xai OIDC access token>` + `X-XAI-Token-Auth: xai-grok-cli`. No cookies required.
- **Safety & Idempotency:** Managed via `src/grok/reset-coupon-ledger.ts` using UUIDv4 operation tracking before upstream dispatch to prevent duplicate consumption during network flakes.
- **Surfaces:** `ocx account grok-reset-coupons` in the terminal, and the dashboard at Providers > xAI Grok > Accounts, where each OAuth row carries a ticket badge with its remaining count and opens a redemption dialog (`gui/src/hooks/useGrokResetCoupons.ts`, `gui/src/components/provider-workspace/GrokResetCoupons.tsx`). The dashboard reads one `GET /api/grok/reset-coupons` per account with at most three in flight, always sends an explicit `tokenId` and a client-minted `operationId`, and treats redemption truth as the settled `code` rather than HTTP 200 — a replayed *failure* returns 200 with `replayed: true`. After a request times out it issues no further consume call, because a redemption whose ledger record is still `open` re-executes.
- **Surfaces:** `ocx account grok-reset-coupons` in the terminal, and the dashboard at Providers > xAI Grok > Accounts, where each OAuth row carries a ticket badge with its remaining count and opens a redemption dialog (`gui/src/hooks/useGrokResetCoupons.ts`, `gui/src/components/provider-workspace/GrokResetCoupons.tsx`). The dashboard reads one `GET /api/grok/reset-coupons` per account with at most three in flight, always sends an explicit `tokenId` and a client-minted `operationId`, and treats redemption truth as the settled `code` rather than HTTP 200 ??a replayed *failure* returns 200 with `replayed: true`. After a request times out it issues no further consume call, because a redemption whose ledger record is still `open` re-executes.

Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](../gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. Upstream API-key usage follows the [physical-attempt account attribution contract](../gui-and-management-api.md#upstream-key-account-attribution), independently of subscription quota observations.

Expand Down Expand Up @@ -150,7 +150,7 @@ grok-composer-2.5-fast each echoed `priority` upstream. The registry entry class
that set in `modelSupportsServiceTier` and declares `chatServiceTier: true`, so the OAuth lane
resolves Fast-eligible per model: `--fast` synthetic rows publish, `fastMode` can force the
tier, and a caller-sent tier forwards (the Codex fast-toggle path). grok-4.20-multi-agent-0309
is deliberately excluded — the gateway answers `service_tier: "default"` when sent
is deliberately excluded ??the gateway answers `service_tier: "default"` when sent
`priority`, so it keeps `forwardCallerServiceTier: false` and publishes no fast row.
Classification reaches saved configs through the fill-only enrich backfill
(`src/providers/derive.ts`); an explicit config value always wins, and a config saved while
Expand All @@ -174,4 +174,6 @@ Native steering retains fixed phase deadlines and reconciled replay output; see

Native steering generation overrides, explicit public-API eligibility and the consent-gated wire probe follow the [shared control contract](../transports/streaming-health.md#steering-settings-public-api-and-diagnostic-probe); this owner does not change routing or execute diagnostic tools.

Shared startup provider-id migration preserves the account binding between configuration and OAuth credentials; see the [runtime contract](../runtime.md).

Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting).
2 changes: 1 addition & 1 deletion structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ Cline CLI joins the existing export/client integration registries. Explicit CLI
Config JSON preserves the boolean; only literal true activates the role-changing transform.
The lightweight top-level CLI help counts Cline CLI among the fifteen registered export clients; registry parity remains covered by the client help and integration tests.

Devin CLI credential path composition in `src/oauth/devin/cli-import.ts` follows the selected platform: Windows uses Win32 APPDATA paths, other platforms use POSIX XDG-data paths. The explicit absolute override remains verbatim; credential parsing and login behavior are unchanged.
Devin CLI credential path composition in `src/oauth/devin/cli-import.ts` follows the selected platform: Windows uses Win32 APPDATA paths, other platforms use POSIX XDG-data paths. The explicit absolute override remains verbatim; credential parsing and login behavior are unchanged. The `src/providers/devin-provider-merge-migration.ts` startup migration treats the legacy provider row and its OAuth slot as one account-bound unit: an occupied destination or a refused config projection leaves both unchanged, and both backups complete before either file changes.

Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary.
Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates.
Expand Down
30 changes: 16 additions & 14 deletions structure/subagents.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ resolver enforces source identity and expiry before those credentials reach rout

| Mode | Behavior |
| --- | --- |
| `"v1"` | Force ALL entries to `multi_agent_version = "v1"` — overrides upstream pins (sol/terra included). |
| `"default"` | Respect upstream model pins (sol/terra=v2, luna=v1, others=null → codex feature flag decides). On sync, stale forced values are cleared and upstream pins restored. |
| `"v2"` | Force ALL entries to `multi_agent_version = "v2"` — overrides upstream pins (luna included). |
| `"v1"` | Force ALL entries to `multi_agent_version = "v1"` ??overrides upstream pins (sol/terra included). |
| `"default"` | Respect upstream model pins (sol/terra=v2, luna=v1, others=null ??codex feature flag decides). On sync, stale forced values are cleared and upstream pins restored. |
| `"v2"` | Force ALL entries to `multi_agent_version = "v2"` ??overrides upstream pins (luna included). |

The override is applied as a final pass in both `buildCatalogEntries` (live `/v1/models` path) and
`mergeCatalogEntriesForSync` (on-disk sync), AFTER all normalization and visibility processing. This
Expand All @@ -66,7 +66,7 @@ native-to-routed child task is undeliverable ciphertext. The repair and salvage
`src/config/diagnostics.ts` pin `multiAgentMode` and `multiAgentSurfaceAdvisoryVersion` to the stored
document, because spreading the defaults underneath would repair an unrelated missing field
into a surface change its operator never made.
An absent key still means `"default"`, because selecting base deletes the key — absence cannot be
An absent key still means `"default"`, because selecting base deletes the key ??absence cannot be
read as "never configured". An install that predates that change is therefore not rewritten; it is
asked once. `multiAgentSurfaceAdvisoryRequired()` is true while the resolved mode is not v1 and
the stored `multiAgentSurfaceAdvisoryVersion` is below `MULTI_AGENT_SURFACE_ADVISORY_VERSION`, and
Expand Down Expand Up @@ -101,23 +101,23 @@ Three different numbers, often conflated:
| --- | --- | --- |
| Models **advertised** as overrides | `min(5, picker-visible eligible rows)` | `multi_agents_spec.rs:785-790` |
| Models **eligible** as targets | no numeric cap (only `"disabled"` is excluded, and only on V2) | `multi_agents_common.rs:36-42` |
| **Concurrent** subagents | V1 6 children (root excluded); V2 total 4 including root → 3 children | `config/mod.rs:211-212`, `:1497-1506` |
| **Concurrent** subagents | V1 6 children (root excluded); V2 total 4 including root ??3 children | `config/mod.rs:211-212`, `:1497-1506` |

**The cap is the same 5 on both surfaces, but the window's contents are not.** The eligibility
filter runs *before* `.take(5)`, and it behaves differently per surface: on a V1 call
`model_supports_multi_agent_backend` short-circuits true for every row (including `disabled`
ones), while a V2 call drops `Some(Disabled)` first — which lets a later row move into the five.
ones), while a V2 call drops `Some(Disabled)` first ??which lets a later row move into the five.
Same catalog, different advertised list:

| # | Model | pin | V1 advertises | V2 advertises |
| ---: | --- | --- | :---: | :---: |
| 1 | `v2-a` | `v2` | ✅ | ✅ |
| 2 | `disabled-a` | `disabled` | ✅ | — |
| 3 | `v1-a` | `v1` | ✅ | ✅ |
| 4 | `null-a` | absent | ✅ | ✅ |
| 5 | `v2-b` | `v2` | ✅ | ✅ |
| 6 | `disabled-b` | `disabled` | — | — |
| 7 | `null-b` | absent | — | ✅ |
| 1 | `v2-a` | `v2` | ??| ??|
| 2 | `disabled-a` | `disabled` | ??| ??|
| 3 | `v1-a` | `v1` | ??| ??|
| 4 | `null-a` | absent | ??| ??|
| 5 | `v2-b` | `v2` | ??| ??|
| 6 | `disabled-b` | `disabled` | ??| ??|
| 7 | `null-b` | absent | ??| ??|

opencodex already matches this: `effectiveSubagentRoster` filters with
`surface !== "v2" || isEligibleV2SubagentEntry(entry)`, so the V1 path skips the eligibility
Expand Down Expand Up @@ -293,7 +293,7 @@ authority, task-scope and collaboration-tool rules remain applicable. This is gu
not an enforcement mechanism or a change to native settings or tool access.

Replay deduplication compares the latest exact generated developer text separately for
each tag family, preserving built-in → custom → built-in transitions without duplicating
each tag family, preserving built-in ??custom ??built-in transitions without duplicating
unchanged proxy metadata after a native policy change. Native and legacy-tagged history
remain intact: tags do not establish historical authorship or revoke old instructions,
and mixed-version transition detection is not guaranteed.
Expand Down Expand Up @@ -408,4 +408,6 @@ Native steering retains fixed phase deadlines and reconciled replay output; see

Native steering generation overrides, explicit public-API eligibility and the consent-gated wire probe follow the [shared control contract](transports/streaming-health.md#steering-settings-public-api-and-diagnostic-probe); this owner does not change routing or execute diagnostic tools.

Startup provider-id migration preserves the account binding between configuration and OAuth credentials; see the [runtime contract](runtime.md).

Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](gui-and-management-api.md#fast-selector-rows-setting).
2 changes: 2 additions & 0 deletions structure/transports/inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ Native steering retains fixed phase deadlines and reconciled replay output; see

Native steering generation overrides, explicit public-API eligibility and the consent-gated wire probe follow the [shared control contract](streaming-health.md#steering-settings-public-api-and-diagnostic-probe); this owner does not change routing or execute diagnostic tools.

Startup provider-id migration preserves the account binding between configuration and OAuth credentials; see the [runtime contract](../runtime.md).

Unicode pattern normalization uses [copy-on-write traversal](byte-accounting.md#unicode-pattern-normalization) while preserving the existing schema and wire semantics.

## Model-family-aware OAuth headroom
Expand Down
36 changes: 32 additions & 4 deletions tests/providers/devin-provider-merge-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,15 @@ describe("devin provider merge startup runner", () => {
backupConfig: () => { order.push("backupConfig"); },
backupAuth: () => { order.push("backupAuth"); },
save: () => { order.push("save"); },
hasAuthSlot: () => opts.hasAuthSlot ?? false,
hasAuthSlot: (provider: string) => provider === "devin-cli" && (opts.hasAuthSlot ?? false),
rekey: async (from: string, to: string) => { order.push(`rekey:${from}->${to}`); return opts.rekey ? opts.rekey() : "moved" as const; },
};
}

test("snapshots config strictly before saving, and rekeys the auth slot", async () => {
const order: string[] = [];
const result = runDevinProviderMergeStartupMigration(migratableConfig(), depsWith(order, { hasAuthSlot: true }));
expect(order.slice(0, 2)).toEqual(["backupConfig", "save"]);
expect(order.slice(0, 3)).toEqual(["backupAuth", "backupConfig", "save"]);
expect(order).toContain("backupAuth");
expect(order).toContain("rekey:devin-cli->devin");
expect(result.providers!['devin']).toBeDefined();
Expand Down Expand Up @@ -178,13 +178,41 @@ describe("devin provider merge startup runner", () => {
expect(warnings.join(" ")).toContain("[devin-provider-merge]");
});

test("a rekey conflict warns rather than throwing out of startup", async () => {
test("an auth destination collision refuses both halves of the migration", () => {
const order: string[] = [];
const warnings: string[] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(" ")); };
try {
runDevinProviderMergeStartupMigration(migratableConfig(), depsWith(order, { hasAuthSlot: true, rekey: async () => "conflict" }));
const deps = depsWith(order, { hasAuthSlot: true });
deps.hasAuthSlot = provider => provider === "devin-cli" || provider === "devin";
const config = migratableConfig();
const result = runDevinProviderMergeStartupMigration(config, deps);
expect(result).toBe(config);
} finally {
console.warn = originalWarn;
}
expect(order).toEqual([]);
expect(warnings.join(" ")).toContain('auth.json already has a "devin" credential slot');
});

test("a config collision never independently rekeys credentials", () => {
const order: string[] = [];
const config = migratableConfig();
config.providers!["devin"] = { adapter: "devin" } as never;
runDevinProviderMergeStartupMigration(config, depsWith(order, { hasAuthSlot: true }));
expect(order).toEqual([]);
});

test("a late rekey conflict warns rather than throwing out of startup", async () => {
const order: string[] = [];
const warnings: string[] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(" ")); };
try {
const deps = depsWith(order, { hasAuthSlot: true, rekey: async () => "conflict" });
deps.hasAuthSlot = provider => provider === "devin-cli";
runDevinProviderMergeStartupMigration(migratableConfig(), deps);
// The detached promise needs a real tick, not one microtask.
await new Promise(resolve => setTimeout(resolve, 0));
} finally {
Expand Down
Loading