diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 0a0e9fd59..fccfd657a 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -356,7 +356,7 @@ "packages/opencode/src/project/instance-store.ts": "c22d3e2499e63dd8d9395d58189df75444d306774df833e746f22e327edcf2ab", "packages/opencode/src/provider/auth.ts": "b0ab1322c1039e27176bf9e3500603eae27506065ac141a69710220f3361f6b8", "packages/opencode/src/provider/error.ts": "7f3c8210c775e2ce98140bf015dc97e61192912af062c75e18ecb09ee2736a0f", - "packages/opencode/src/provider/provider.ts": "435a2838291d2aa32de4d1ecab1778b443f9ec9b3b5ed409b94d3b2de0142853", + "packages/opencode/src/provider/provider.ts": "b89bc0ca0a12ea0cfaf5c956a4b731a0397d4c407e787a16cdd66fb7f54ee476", "packages/opencode/src/provider/transform.ts": "70c7492d4a23a30ebc340a5605702b1602960f4b412e18557534e02962c137b8", "packages/opencode/src/question/index.ts": "8317348c2ca27dd9e4f7d18fdc126a4d355c20495c1b86668430ad477a68e9b3", "packages/opencode/src/server/auth.ts": "a054aff2ea06a303a99f326275332a68089c58916356a26d66af44f048f597cc", @@ -405,7 +405,7 @@ "packages/opencode/test/project/worktree-rename.test.ts": "3ec168c9f8595249b75832895a251483ec1f3641ce44ad0bd5a664b5bbd6dad1", "packages/opencode/test/provider/combine-abort-signals.test.ts": "0a997303e717184e3e237e4b978b64a34ab8022724c47245b25f32e90738d205", "packages/opencode/test/provider/harmoniqs-tools.test.ts": "0c626daa51c312eb81dd854509a2e1944712ee9620a36eb4db66078ab80d6b82", - "packages/opencode/test/provider/provider.test.ts": "5b2cbed8994d3bbc968a72d01459460c1de72b6117a0795c2bc6a10abbf02926", + "packages/opencode/test/provider/provider.test.ts": "37ed7d4dbf68da214a96cb66dacda71c6e82498382fcd28c3272ec4d33462b20", "packages/opencode/test/provider/transform.test.ts": "ecf9dd1bdec36c2126f3140d43125728c06211b9771d47619ff65f7453ff37fd", "packages/opencode/test/question/question-store.test.ts": "9186b84cb1b390f47e8e8c30b6e2448ebe5096335aee38fe4c12c897d27d2150", "packages/opencode/test/server/amicode-connections-routes.test.ts": "de8ddeaabe23e20a0dc33eef6c70232ba20cca0301cbea08fe9fd751a07d6649", @@ -1011,7 +1011,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-10-22T13:11:47.632Z", + "extracted_at": "2026-10-23T12:21:18.499Z", "per_package": { "packages/app": { "A": 254, diff --git a/packages/app-bundle/overlay/packages/opencode/src/provider/provider.ts b/packages/app-bundle/overlay/packages/opencode/src/provider/provider.ts index a51bcfb41..d7a9989de 100644 --- a/packages/app-bundle/overlay/packages/opencode/src/provider/provider.ts +++ b/packages/app-bundle/overlay/packages/opencode/src/provider/provider.ts @@ -1291,6 +1291,7 @@ interface State { sdk: Map modelLoaders: Record varsLoaders: Record + authStamp: string | undefined } export class Service extends Context.Service()("@opencode/Provider") {} @@ -1476,8 +1477,24 @@ const layer = Layer.effect( const modelsDevSvc = yield* ModelsDev.Service const runtimeFlags = yield* RuntimeFlags.Service + // #1848: the stored-credential fingerprint the provider state was built + // from. auth.json is shared by every engine process on the host (the + // fleet's shards), but the provider state is cached per instance per + // process — a connect landing on one shard left the others serving the + // pre-connect provider list until restart. Every state read compares the + // on-disk fingerprint and rebuilds on drift, so an auth write from ANY + // process (or the CLI) reaches every engine without an instance dispose + // (which would cancel the instance's running sessions). + const authStamp = auth.all().pipe( + Effect.map((all) => + Hash.fast(JSON.stringify(Object.keys(all).sort().map((key) => [key, all[key]]))), + ), + Effect.orElseSucceed(() => undefined), + ) + const state = yield* InstanceState.make(() => Effect.gen(function* () { + const builtFrom = yield* authStamp const bridge = yield* EffectBridge.make() const cfg = yield* config.get() const modelsDev = yield* modelsDevSvc.get() @@ -1804,11 +1821,25 @@ const layer = Layer.effect( sdk, modelLoaders, varsLoaders, + authStamp: builtFrom, } }), ) - const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers)) + // #1848: the state, rebuilt first when stored credentials changed on disk + // since it was built. An unreadable auth file never forces a rebuild. + const current = Effect.fn("Provider.current")(function* () { + const s = yield* InstanceState.get(state) + const stamp = yield* authStamp + if (stamp === undefined || stamp === s.authStamp) return s + yield* Effect.logInfo("stored credentials changed on disk; rebuilding provider state") + yield* InstanceState.invalidate(state) + return yield* InstanceState.get(state) + }) + + const list = Effect.fn("Provider.list")(function* () { + return (yield* current()).providers + }) async function resolveSDK(model: Model, s: State, envs: Record) { try { @@ -1958,12 +1989,12 @@ const layer = Layer.effect( } } - const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderV2.ID) => - InstanceState.use(state, (s) => s.providers[providerID]), - ) + const getProvider = Effect.fn("Provider.getProvider")(function* (providerID: ProviderV2.ID) { + return (yield* current()).providers[providerID] + }) const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderV2.ID, modelID: ModelV2.ID) { - const s = yield* InstanceState.get(state) + const s = yield* current() const provider = s.providers[providerID] if (!provider) { const catalogProvider = s.catalog[providerID] @@ -1987,7 +2018,7 @@ const layer = Layer.effect( }) const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) { - const s = yield* InstanceState.get(state) + const s = yield* current() const envs = yield* env.all() const key = `${model.providerID}/${model.id}` if (s.models.has(key)) return s.models.get(key)! @@ -2018,7 +2049,7 @@ const layer = Layer.effect( }) const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderV2.ID, query: string[]) { - const s = yield* InstanceState.get(state) + const s = yield* current() const provider = s.providers[providerID] if (!provider) return undefined for (const item of query) { @@ -2039,7 +2070,7 @@ const layer = Layer.effect( ) } - const s = yield* InstanceState.get(state) + const s = yield* current() const provider = s.providers[providerID] if (!provider) return undefined @@ -2100,7 +2131,7 @@ const layer = Layer.effect( const defaultModel = Effect.fn("Provider.defaultModel")(function* () { const cfg = yield* config.get() - const s = yield* InstanceState.get(state) + const s = yield* current() if (cfg.model) { const parsed = parseModel(cfg.model) diff --git a/packages/app-bundle/overlay/packages/opencode/test/provider/provider.test.ts b/packages/app-bundle/overlay/packages/opencode/test/provider/provider.test.ts index df1d82371..0d95c91e1 100644 --- a/packages/app-bundle/overlay/packages/opencode/test/provider/provider.test.ts +++ b/packages/app-bundle/overlay/packages/opencode/test/provider/provider.test.ts @@ -2077,3 +2077,40 @@ it.effect("opencode loader keeps paid models when auth exists", () => expect(keyedCount).toBeGreaterThan(0) }).pipe(provideMultiInstance), ) + +// #1848: the fleet's engine shards share one auth.json but each caches its +// provider state per instance. A connect landing on ANOTHER process (a sibling +// shard, the CLI) writes the file directly; this instance must see the new +// provider — and lose a removed one — on its next read, with no dispose. +it.instance("stored credentials written by another process reach a live instance", () => + Effect.gen(function* () { + yield* remove("GROQ_API_KEY") + const groq = ProviderV2.ID.make("groq") + const authPath = path.join(Global.Path.data, "auth.json") + const original = yield* Effect.promise(() => Filesystem.readText(authPath).catch(() => undefined)) + const base = original ? (JSON.parse(original) as Record) : {} + const { groq: _ignored, ...without } = base + const writeAuth = (data: Record) => + Effect.promise(() => Filesystem.write(authPath, JSON.stringify(data))) + + yield* Effect.acquireRelease(writeAuth(without), () => + Effect.promise(async () => { + if (original !== undefined) await Filesystem.write(authPath, original) + else await unlink(authPath).catch(() => undefined) + }), + ) + + // the state is built (and cached) before the "other shard" connects + expect((yield* list)[groq]).toBeUndefined() + + yield* writeAuth({ ...without, groq: { type: "api", key: "sibling-shard-key" } }) + const connected = yield* list + expect(connected[groq]).toBeDefined() + expect(connected[groq].source).toBe("api") + expect(yield* Provider.Service.use((svc) => svc.getProvider(groq))).toBeDefined() + + // and a disconnect on the sibling drops it here too (the stale-openrouter half) + yield* writeAuth(without) + expect((yield* list)[groq]).toBeUndefined() + }), +)