Skip to content
Open
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
20 changes: 16 additions & 4 deletions packages/opencode/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>("OAuth")({
Expand Down Expand Up @@ -88,22 +91,31 @@ 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<string, Info>) {
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) {
const norm = key.replace(/\/+$/, "")
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 })
Expand Down
68 changes: 68 additions & 0 deletions packages/opencode/test/auth/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 }))
}),
),
)
})
Loading