diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index e7d01c670..1505b6568 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -25,6 +25,9 @@ export function inject(content: string | undefined) { injected = content } +/** Whether reads currently come from a snapshot rather than the file. */ +const snapshotActive = () => injected !== undefined || process.env.MIMOCODE_AUTH_CONTENT !== undefined + const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause }) export class Oauth extends Schema.Class("OAuth")({ @@ -88,14 +91,23 @@ export const layer = Layer.effect( return (yield* all())[providerID] }) + // A mutation through this service is authoritative, so the snapshot has to move with the write. + // Otherwise a write while `inject` (or the env fallback) is active reaches only the file while + // every later read keeps returning the pre-write state: an OAuth refresh, a key rotation or a + // logout looks like it succeeded and changes nothing. Updated only after the write lands, so a + // failed write cannot leave memory and disk disagreeing. The host re-injects from the file on its + // own schedule, which converges on the same content. + const persist = Effect.fn("Auth.persist")(function* (data: Record) { + yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data"))) + if (snapshotActive()) injected = JSON.stringify(data) + }) + const set = Effect.fn("Auth.set")(function* (key: string, info: Info) { const norm = key.replace(/\/+$/, "") const data = yield* all() if (norm !== key) delete data[key] delete data[norm + "/"] - yield* fsys - .writeJson(file, { ...data, [norm]: info }, 0o600) - .pipe(Effect.mapError(fail("Failed to write auth data"))) + yield* persist({ ...data, [norm]: info }) }) const remove = Effect.fn("Auth.remove")(function* (key: string) { @@ -103,7 +115,7 @@ export const layer = Layer.effect( const data = yield* all() delete data[key] delete data[norm] - yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data"))) + yield* persist(data) }) return Service.of({ get, all, set, remove }) diff --git a/packages/opencode/test/auth/auth.test.ts b/packages/opencode/test/auth/auth.test.ts index d0d45beb1..48318e31d 100644 --- a/packages/opencode/test/auth/auth.test.ts +++ b/packages/opencode/test/auth/auth.test.ts @@ -1,6 +1,9 @@ import { describe, expect } from "bun:test" +import path from "path" +import fs from "fs/promises" import { Effect, Layer } from "effect" import { Auth } from "../../src/auth" +import { Global } from "../../src/global" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -112,4 +115,69 @@ describe("Auth", () => { }), ), ) + + // A write through this service is authoritative. If `inject` (or the env fallback) is active and a + // mutation only reaches the file, every later read keeps returning the pre-write snapshot — an + // OAuth refresh, a key rotation or a logout looks like it succeeded and changes nothing. + it.live("set is visible to later reads while inject is active", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + Auth.inject(JSON.stringify({ xiaomi: { type: "api", key: "sk-old" } })) + const auth = yield* Auth.Service + yield* auth.set("xiaomi", { type: "api", key: "sk-new" }) + expect((yield* auth.all())["xiaomi"]).toEqual({ type: "api", key: "sk-new" }) + Auth.inject(undefined) + }), + ), + ) + + it.live("remove is visible to later reads while inject is active", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + Auth.inject(JSON.stringify({ xiaomi: { type: "api", key: "sk-old" }, other: { type: "api", key: "sk-keep" } })) + const auth = yield* Auth.Service + yield* auth.remove("xiaomi") + const data = yield* auth.all() + expect(data["xiaomi"]).toBeUndefined() + expect(data["other"]).toEqual({ type: "api", key: "sk-keep" }) + Auth.inject(undefined) + }), + ), + ) + + // Same defect on the env channel, which predates `inject`: the var shadows the file, so a token + // refresh inside a workspace child was invisible to that same child. + it.live("set is visible to later reads while the env channel is active", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + process.env.MIMOCODE_AUTH_CONTENT = JSON.stringify({ xiaomi: { type: "api", key: "sk-env" } }) + const auth = yield* Auth.Service + yield* auth.set("xiaomi", { type: "api", key: "sk-rotated" }) + expect((yield* auth.all())["xiaomi"]).toEqual({ type: "api", key: "sk-rotated" }) + delete process.env.MIMOCODE_AUTH_CONTENT + Auth.inject(undefined) + }), + ), + ) + + // The snapshot moves only after the write lands. If it moved first, a failed write would leave the + // in-memory credentials ahead of the file — the next process would silently run on the old ones. + it.live("a failed write leaves the snapshot untouched", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const file = path.join(Global.Path.data, "auth.json") + yield* Effect.promise(async () => { + await fs.rm(file, { force: true }) + await fs.mkdir(file, { recursive: true }) // writing over a directory fails + }) + Auth.inject(JSON.stringify({ xiaomi: { type: "api", key: "sk-old" } })) + const auth = yield* Auth.Service + const result = yield* Effect.result(auth.set("xiaomi", { type: "api", key: "sk-new" })) + expect(result._tag).toBe("Failure") + expect((yield* auth.all())["xiaomi"]).toEqual({ type: "api", key: "sk-old" }) + Auth.inject(undefined) + yield* Effect.promise(() => fs.rm(file, { recursive: true, force: true })) + }), + ), + ) })