fix(setup): restore credentials before recovery reads auth

A failed sign-in activation can publish restored config before rolling back its
credential. Recovery may then capture auth rows that the rollback invalidates,
leaving the old connection restored on disk but unavailable in the Gateway.

Restore the credential during the recovery application's existing guarded
preparation, before auth capture. Consume its receipt once so the fallback after
config undo cannot invalidate that recovery read again. Preserve config
supersession, credential ownership, and stale-read rejection without retries.

Strengthen the existing integration case with a real auth-read/rollback barrier.
The unmodified original seven-file replay passed 79 tests; the deterministic
regression then reproduced the exact stale-auth recovery failure before the fix.
The final original-group replay passed 79 tests in 52.70s with eight workers,
and 136 adjacent wizard/setup/auth tests passed in 215.39s wrapper time.
Local proof used Node 24.21.0; original CI used Node 24.19.0. No passing CI
outcome is inferred from these local results.

Core and focused test types, graph boundaries, lint, formatting, diff checks,
size/env/assertion ratchets, and independent P2 review passed. No schema,
configuration, permission, dependency, or installed-updater contract changes.
This commit is contained in:
Peter Steinberger 2026-09-29 13:40:46 -07:00
parent e9571d77e7
commit cd75f44f9c
3 changed files with 49 additions and 6 deletions

View file

@ -65,9 +65,11 @@ another agent. Only the owning setup operation can test the selected credential.
After one successful tool-free turn, setup asks whether to activate it. Declining
or failing the test keeps the saved credential inactive and preserves the current
connection. Model Setup offers the same saved sign-in for a fresh test without
another login. Gateway activation waits for config application; a required restart
keeps the replacement inactive until setup is retried. Ordinary login remains
immediate. The descriptor retains the selected model and connection settings for retry after
another login. If applying a replacement fails, recovery restores its inactive
state before rebuilding the previous connection. Gateway activation waits for
config application; a required restart keeps the replacement inactive until setup
is retried. Ordinary login remains immediate. The descriptor retains the selected
model and connection settings for retry after
restart, without caching a verification result. This adds no database schema or
migration; older runtimes do not enforce the inactive state. Before downgrading,
remove saved inactive replacements or restore the state from before setup.

View file

@ -5,7 +5,9 @@ import fs from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../test/helpers/promise.js";
import { resolveApiKeyForProfile } from "../agents/auth-profiles/oauth.js";
import { runtimeAuthProfileRowsCache } from "../agents/auth-profiles/runtime-snapshots.js";
import { loadAuthProfileStoreWithoutExternalProfiles } from "../agents/auth-profiles/store-runtime.js";
import * as authProfileStore from "../agents/auth-profiles/store.js";
import type { AuthProfileCredential } from "../agents/auth-profiles/types.js";
import {
prepareModelRuntimeSnapshot,
@ -220,6 +222,32 @@ describe("setup activation reload ownership", () => {
const completion = createDeferred<() => Promise<boolean>>();
const applied = createDeferred<ReturnType<typeof createRuntimeConfigWriteApplication>>();
let recoveryApplication: ReturnType<typeof createRuntimeConfigWriteApplication> | undefined;
// Recovery can capture auth before the config write returns to its rollback caller.
const prepareRows = runtimeAuthProfileRowsCache.prepare.bind(runtimeAuthProfileRowsCache);
const rowRead = vi
.spyOn(runtimeAuthProfileRowsCache, "prepare")
.mockImplementation((...args) => {
const reader = prepareRows(...args);
if (outcome !== "runtime-failed" || !recoveryApplication?.claimed) {
return reader;
}
return {
...reader,
async read() {
const rows = await reader.read();
captureEntered.resolve();
await releaseCapture.promise;
return rows;
},
};
});
const restoreAuth = authProfileStore.restoreAuthProfileStorePersistenceSnapshot;
const rollback = vi
.spyOn(authProfileStore, "restoreAuthProfileStorePersistenceSnapshot")
.mockImplementation((...args) => {
restoreAuth(...args);
releaseCapture.resolve();
});
try {
await reloader.ready;
if (scenario === "superseded") {
@ -261,9 +289,13 @@ describe("setup activation reload ownership", () => {
writeOptions,
transform: (_current, context) => {
const undo = captureSetupInferenceFileUndo(context.snapshot, candidate);
captureUndo((options) => {
captureUndo(async (options) => {
recoveryApplication = getRuntimeConfigWriteApplication(options);
return undo(options);
const restored = await undo(options);
if (outcome === "runtime-failed") {
await captureEntered.promise;
}
return restored;
});
return { nextConfig: candidate };
},
@ -419,6 +451,8 @@ describe("setup activation reload ownership", () => {
try {
await reloader.stop();
} finally {
rowRead.mockRestore();
rollback.mockRestore();
configFileAdapter.mockRestore();
}
}

View file

@ -134,10 +134,17 @@ export async function commitSetupInferenceActivation(params: {
})
: undefined;
const restore = async () => {
const rollbackCredential = () => {
credential?.rollback();
credential = undefined;
};
const restoredApplication = application
? createRuntimeConfigWriteApplication(continuation, {
prepare: async (assertCurrent) => {
assertApplicationCurrent = assertCurrent;
assertCurrent();
// Recovery must capture auth only after restoring this activation's credential.
rollbackCredential();
},
})
: undefined;
@ -145,7 +152,7 @@ export async function commitSetupInferenceActivation(params: {
configCommitted && undoConfig
? await undoConfig(attachRuntimeConfigWriteApplication({}, restoredApplication))
: { config: (await params.configTarget.read()).config, written: false };
credential?.rollback();
rollbackCredential();
if (restoredApplication && restored.written) {
if (!restoredApplication.claimed || (await restoredApplication.result) !== "applied") {
throw new Error(