core: make account login upgrades safe while adding multi-account workspace auth (#15487)

Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dax
2026-03-10 12:53:47 -04:00
committed by GitHub
parent 9c4325bcf8
commit 613562f504
41 changed files with 4793 additions and 918 deletions

View File

@@ -0,0 +1,196 @@
import { cmd } from "./cmd"
import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui"
import { runtime } from "@/effect/runtime"
import { AccountID, AccountService, OrgID, PollExpired, type PollResult } from "@/account/service"
import { type AccountError } from "@/account/schema"
import * as Prompt from "../effect/prompt"
import open from "open"
const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() => undefined))
const println = (msg: string) => Effect.sync(() => UI.println(msg))
const loginEffect = Effect.fn("login")(function* (url: string) {
const service = yield* AccountService
yield* Prompt.intro("Log in")
const login = yield* service.login(url)
yield* Prompt.log.info("Go to: " + login.url)
yield* Prompt.log.info("Enter code: " + login.user)
yield* openBrowser(login.url)
const s = Prompt.spinner()
yield* s.start("Waiting for authorization...")
const poll = (wait: number): Effect.Effect<PollResult, AccountError> =>
Effect.gen(function* () {
yield* Effect.sleep(wait)
const result = yield* service.poll(login)
if (result._tag === "PollPending") return yield* poll(wait)
if (result._tag === "PollSlow") return yield* poll(wait + 5000)
return result
})
const result = yield* poll(login.interval * 1000).pipe(
Effect.timeout(Duration.seconds(login.expiry)),
Effect.catchTag("TimeoutError", () => Effect.succeed(new PollExpired())),
)
yield* Match.valueTags(result, {
PollSuccess: (r) =>
Effect.gen(function* () {
yield* s.stop("Logged in as " + r.email)
yield* Prompt.outro("Done")
}),
PollExpired: () => s.stop("Device code expired", 1),
PollDenied: () => s.stop("Authorization denied", 1),
PollError: (r) => s.stop("Error: " + String(r.cause), 1),
PollPending: () => s.stop("Unexpected state", 1),
PollSlow: () => s.stop("Unexpected state", 1),
})
})
const logoutEffect = Effect.fn("logout")(function* (email?: string) {
const service = yield* AccountService
const accounts = yield* service.list()
if (accounts.length === 0) return yield* println("Not logged in")
if (email) {
const match = accounts.find((a) => a.email === email)
if (!match) return yield* println("Account not found: " + email)
yield* service.remove(match.id)
yield* Prompt.outro("Logged out from " + email)
return
}
const active = yield* service.active()
const activeID = Option.map(active, (a) => a.id)
yield* Prompt.intro("Log out")
const opts = accounts.map((a) => {
const isActive = Option.isSome(activeID) && activeID.value === a.id
const server = UI.Style.TEXT_DIM + a.url + UI.Style.TEXT_NORMAL
return {
value: a,
label: isActive
? `${a.email} ${server}` + UI.Style.TEXT_DIM + " (active)"
: `${a.email} ${server}`,
}
})
const selected = yield* Prompt.select({ message: "Select account to log out", options: opts })
if (Option.isNone(selected)) return
yield* service.remove(selected.value.id)
yield* Prompt.outro("Logged out from " + selected.value.email)
})
interface OrgChoice {
orgID: OrgID
accountID: AccountID
label: string
}
const switchEffect = Effect.fn("switch")(function* () {
const service = yield* AccountService
const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("Not logged in")
const active = yield* service.active()
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
const opts = groups.flatMap((group) =>
group.orgs.map((org) => {
const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
return {
value: { orgID: org.id, accountID: group.account.id, label: org.name },
label: isActive
? `${org.name} (${group.account.email})` + UI.Style.TEXT_DIM + " (active)"
: `${org.name} (${group.account.email})`,
}
}),
)
if (opts.length === 0) return yield* println("No orgs found")
yield* Prompt.intro("Switch org")
const selected = yield* Prompt.select<OrgChoice>({ message: "Select org", options: opts })
if (Option.isNone(selected)) return
const choice = selected.value
yield* service.use(choice.accountID, Option.some(choice.orgID))
yield* Prompt.outro("Switched to " + choice.label)
})
const orgsEffect = Effect.fn("orgs")(function* () {
const service = yield* AccountService
const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("No accounts found")
if (!groups.some((group) => group.orgs.length > 0)) return yield* println("No orgs found")
const active = yield* service.active()
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
for (const group of groups) {
for (const org of group.orgs) {
const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " "
const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
const email = UI.Style.TEXT_DIM + group.account.email + UI.Style.TEXT_NORMAL
const id = UI.Style.TEXT_DIM + org.id + UI.Style.TEXT_NORMAL
yield* println(` ${dot} ${name} ${email} ${id}`)
}
}
})
export const LoginCommand = cmd({
command: "login <url>",
describe: false,
builder: (yargs) =>
yargs.positional("url", {
describe: "server URL",
type: "string",
demandOption: true,
}),
async handler(args) {
UI.empty()
await runtime.runPromise(loginEffect(args.url))
},
})
export const LogoutCommand = cmd({
command: "logout [email]",
describe: false,
builder: (yargs) =>
yargs.positional("email", {
describe: "account email to log out from",
type: "string",
}),
async handler(args) {
UI.empty()
await runtime.runPromise(logoutEffect(args.email))
},
})
export const SwitchCommand = cmd({
command: "switch",
describe: false,
async handler() {
UI.empty()
await runtime.runPromise(switchEffect())
},
})
export const OrgsCommand = cmd({
command: "orgs",
describe: false,
async handler() {
UI.empty()
await runtime.runPromise(orgsEffect())
},
})

View File

@@ -10,7 +10,7 @@ import { ShareNext } from "../../share/share-next"
import { EOL } from "os"
import { Filesystem } from "../../util/filesystem"
/** Discriminated union returned by the ShareNext API (GET /api/share/:id/data) */
/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */
export type ShareData =
| { type: "session"; data: SDKSession }
| { type: "message"; data: Message }
@@ -24,6 +24,14 @@ export function parseShareUrl(url: string): string | null {
return match ? match[1] : null
}
export function shouldAttachShareAuthHeaders(shareUrl: string, accountBaseUrl: string): boolean {
try {
return new URL(shareUrl).origin === new URL(accountBaseUrl).origin
} catch {
return false
}
}
/**
* Transform ShareNext API response (flat array) into the nested structure for local file storage.
*
@@ -97,8 +105,21 @@ export const ImportCommand = cmd({
return
}
const baseUrl = await ShareNext.url()
const response = await fetch(`${baseUrl}/api/share/${slug}/data`)
const parsed = new URL(args.file)
const baseUrl = parsed.origin
const req = await ShareNext.request()
const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {}
const dataPath = req.api.data(slug)
let response = await fetch(`${baseUrl}${dataPath}`, {
headers,
})
if (!response.ok && dataPath !== `/api/share/${slug}/data`) {
response = await fetch(`${baseUrl}/api/share/${slug}/data`, {
headers,
})
}
if (!response.ok) {
process.stdout.write(`Failed to fetch share data: ${response.statusText}`)

View File

@@ -13,14 +13,9 @@ import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process"
import { text } from "node:stream/consumers"
import { setTimeout as sleep } from "node:timers/promises"
type PluginAuth = NonNullable<Hooks["auth"]>
/**
* Handle plugin-based authentication flow.
* Returns true if auth was handled, false if it should fall through to default handling.
*/
async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise<boolean> {
let index = 0
if (methodName) {
@@ -33,7 +28,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
}
index = match
} else if (plugin.auth.methods.length > 1) {
const selected = await prompts.select({
const method = await prompts.select({
message: "Login method",
options: [
...plugin.auth.methods.map((x, index) => ({
@@ -42,13 +37,12 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
})),
],
})
if (prompts.isCancel(selected)) throw new UI.CancelledError()
index = parseInt(selected)
if (prompts.isCancel(method)) throw new UI.CancelledError()
index = parseInt(method)
}
const method = plugin.auth.methods[index]
// Handle prompts for all auth types
await sleep(10)
await new Promise((r) => setTimeout(r, 10))
const inputs: Record<string, string> = {}
if (method.prompts) {
for (const prompt of method.prompts) {
@@ -171,11 +165,6 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
return false
}
/**
* Build a deduplicated list of plugin-registered auth providers that are not
* already present in models.dev, respecting enabled/disabled provider lists.
* Pure function with no side effects; safe to test without mocking.
*/
export function resolvePluginProviders(input: {
hooks: Hooks[]
existingProviders: Record<string, unknown>
@@ -203,19 +192,20 @@ export function resolvePluginProviders(input: {
return result
}
export const AuthCommand = cmd({
command: "auth",
describe: "manage credentials",
export const ProvidersCommand = cmd({
command: "providers",
aliases: ["auth"],
describe: "manage AI providers and credentials",
builder: (yargs) =>
yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(),
yargs.command(ProvidersListCommand).command(ProvidersLoginCommand).command(ProvidersLogoutCommand).demandCommand(),
async handler() {},
})
export const AuthListCommand = cmd({
export const ProvidersListCommand = cmd({
command: "list",
aliases: ["ls"],
describe: "list providers",
async handler() {
describe: "list providers and credentials",
async handler(_args) {
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
@@ -231,7 +221,6 @@ export const AuthListCommand = cmd({
prompts.outro(`${results.length} credentials`)
// Environment variables section
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
for (const [providerID, provider] of Object.entries(database)) {
@@ -258,7 +247,7 @@ export const AuthListCommand = cmd({
},
})
export const AuthLoginCommand = cmd({
export const ProvidersLoginCommand = cmd({
command: "login [url]",
describe: "log in to a provider",
builder: (yargs) =>
@@ -356,7 +345,7 @@ export const AuthLoginCommand = cmd({
value: x.id,
hint: {
opencode: "recommended",
anthropic: "Claude Max or API key",
anthropic: "API key",
openai: "ChatGPT Plus/Pro or API key",
}[x.id],
})),
@@ -409,7 +398,6 @@ export const AuthLoginCommand = cmd({
if (prompts.isCancel(custom)) throw new UI.CancelledError()
provider = custom.replace(/^@ai-sdk\//, "")
// Check if a plugin provides auth for this custom provider
const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
if (customPlugin && customPlugin.auth) {
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
@@ -461,10 +449,10 @@ export const AuthLoginCommand = cmd({
},
})
export const AuthLogoutCommand = cmd({
export const ProvidersLogoutCommand = cmd({
command: "logout",
describe: "log out from a configured provider",
async handler() {
async handler(_args) {
UI.empty()
const credentials = await Auth.all().then((x) => Object.entries(x))
prompts.intro("Remove credential")

View File

@@ -36,7 +36,7 @@ export function createDialogProviderOptions() {
value: provider.id,
description: {
opencode: "(Recommended)",
anthropic: "(Claude Max or API key)",
anthropic: "(API key)",
openai: "(ChatGPT Plus/Pro or API key)",
"opencode-go": "Low cost subscription for everyone",
}[provider.id],

View File

@@ -383,7 +383,12 @@ export function Session() {
sessionID: route.sessionID,
})
.then((res) => copy(res.data!.share!.url))
.catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
.catch((error) => {
toast.show({
message: error instanceof Error ? error.message : "Failed to share session",
variant: "error",
})
})
dialog.clear()
},
},
@@ -486,7 +491,12 @@ export function Session() {
sessionID: route.sessionID,
})
.then(() => toast.show({ message: "Session unshared successfully", variant: "success" }))
.catch(() => toast.show({ message: "Failed to unshare session", variant: "error" }))
.catch((error) => {
toast.show({
message: error instanceof Error ? error.message : "Failed to unshare session",
variant: "error",
})
})
dialog.clear()
},
},

View File

@@ -0,0 +1,25 @@
import * as prompts from "@clack/prompts"
import { Effect, Option } from "effect"
export const intro = (msg: string) => Effect.sync(() => prompts.intro(msg))
export const outro = (msg: string) => Effect.sync(() => prompts.outro(msg))
export const log = {
info: (msg: string) => Effect.sync(() => prompts.log.info(msg)),
}
export const select = <Value>(opts: Parameters<typeof prompts.select<Value>>[0]) =>
Effect.tryPromise(() => prompts.select(opts)).pipe(
Effect.map((result) => {
if (prompts.isCancel(result)) return Option.none<Value>()
return Option.some(result)
}),
)
export const spinner = () => {
const s = prompts.spinner()
return {
start: (msg: string) => Effect.sync(() => s.start(msg)),
stop: (msg: string, code?: number) => Effect.sync(() => s.stop(msg, code)),
}
}