Skip to content
Merged
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
36 changes: 36 additions & 0 deletions src/lib/copilot-preflight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Pre-flight a GitHub token against Copilot BEFORE adopting it as the active
* account — used by both the gh-reuse adopt path and the multi-account switch
* path, so it lives here rather than in either route module.
*
* Mirrors what boot does (the live identity check): a stale/revoked token →
* GitHub rejects it (401); an account with no Copilot entitlement →
* /copilot_internal/user 403/404. Returns a specific, user-facing message so
* the UI can say WHY synchronously — instead of writing the token, rebooting,
* and surfacing a generic "came back unauthenticated" 20s later. Returns null
* when the token is usable. The `usage` lookup is injectable for tests.
*/

import { getCopilotUsage } from "~/services/github/get-copilot-usage"

import { HTTPError } from "./error"

export async function preflightCopilotError(
token: string,
login: string,
usage: (token: string) => Promise<unknown> = getCopilotUsage,
): Promise<string | null> {
try {
await usage(token)
return null
} catch (error) {
const status = error instanceof HTTPError ? error.response.status : 0
if (status === 401) {
return `GitHub rejected ${login}'s token — it may be expired or revoked. Run \`gh auth login\` and try again, or sign in with a code.`
}
if (status === 403 || status === 404) {
return `${login} doesn't have access to GitHub Copilot. Pick another account, or sign in with a code.`
}
return `Couldn't verify ${login} with GitHub${status ? ` (HTTP ${status})` : ""}. Check your connection and try again.`
}
}
27 changes: 27 additions & 0 deletions src/lib/settings-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,33 @@ export const AuthStatus = z.object({
})
export type AuthStatus = z.infer<typeof AuthStatus>

// ---------------------------------------------------------------------------
// Multi-account roster — Settings → Account quick-switch (slice 3).
//
// The persisted accounts maximal can switch between. Tokens are NEVER
// included — only identity + provenance, like the auth status above redacts
// the credential.
// ---------------------------------------------------------------------------

export const AccountSummary = z.object({
/** Stable identity key, `login@host`. */
key: z.string(),
login: z.string(),
host: z.string(),
/** How this account entered the registry. */
added_via: z.enum(["device-code", "gh-cli", "migration"]),
obtained_at: z.string(),
/** Whether this is the account the proxy is (or will boot) signed in as. */
active: z.boolean(),
})
export type AccountSummary = z.infer<typeof AccountSummary>

export const AccountsListResponse = z.object({
accounts: z.array(AccountSummary),
active_key: z.string().nullable(),
})
export type AccountsListResponse = z.infer<typeof AccountsListResponse>

/**
* An API-key entry as managed by Settings → API clients. The key value
* is returned in full to the local Settings UI — the endpoint is
Expand Down
108 changes: 108 additions & 0 deletions src/routes/settings/accounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Multi-account roster endpoints — /settings/api/accounts/*.
*
* Quick-switch over maximal's PERSISTED accounts: list them, set the active
* one, or forget one. Inherits the /settings/api auth gate. Switching and
* removing only edit maximal's own on-disk registry — they never touch `gh`,
* the keyring, or any GitHub session (the HARD ISOLATION INVARIANT). After a
* switch/remove the shell reboots the sidecar so it boots into the new active
* config; we don't mutate the running auth state here.
*
* Token values are never returned by any endpoint here.
*/

import { Hono } from "hono"

import { preflightCopilotError } from "~/lib/copilot-preflight"
import { forwardError } from "~/lib/error"
import {
listAccounts,
readDefaultRegistry,
removeAccount,
setActive,
writeDefaultRegistry,
} from "~/lib/github-token-store"

export const accountsRoutes = new Hono()

/** Read `{ key }` from the JSON body, or null if it isn't a non-empty string. */
async function readKey(c: {
req: { json: () => Promise<unknown> }
}): Promise<string | null> {
const body = (await c.req.json().catch(() => null)) as {
key?: unknown
} | null
const key = body?.key
return typeof key === "string" && key ? key : null
}

accountsRoutes.get("/", async (c) => {
try {
const reg = await readDefaultRegistry()
const accounts = listAccounts(reg).map((a) => ({
key: a.key,
login: a.login,
host: a.host,
added_via: a.addedVia,
obtained_at: a.obtainedAt,
active: a.active,
}))
return c.json({ accounts, active_key: reg.activeKey })
} catch (error) {
return await forwardError(c, error)
}
})

/**
* Set the active account. Pre-flights the target token against Copilot BEFORE
* flipping the pointer (the token is already in the registry, so a bad switch
* would otherwise cost a full reboot to discover) — returns a specific 422 if
* the account is no longer usable. The shell reboots on a 2xx.
*/
accountsRoutes.post("/switch", async (c) => {
try {
const key = await readKey(c)
if (!key) {
return c.json({ error: { message: "Expected { key } string." } }, 400)
}
const reg = await readDefaultRegistry()
if (!(key in reg.accounts)) {
return c.json({ error: { message: `No account ${key}.` } }, 404)
}
const target = reg.accounts[key]
const preflightError = await preflightCopilotError(
target.token,
target.login,
)
if (preflightError) {
return c.json({ error: { message: preflightError } }, 422)
}
await writeDefaultRegistry(setActive(reg, key))
return c.json({ ok: true, key })
} catch (error) {
return await forwardError(c, error)
}
})

/**
* Forget an account — deletes maximal's OWN copy of its token from the
* registry. gh is untouched. If the removed account was active, `activeKey`
* falls to null and the shell reboots into unauthenticated.
*/
accountsRoutes.post("/remove", async (c) => {
try {
const key = await readKey(c)
if (!key) {
return c.json({ error: { message: "Expected { key } string." } }, 400)
}
const reg = await readDefaultRegistry()
if (!(key in reg.accounts)) {
return c.json({ error: { message: `No account ${key}.` } }, 404)
}
const wasActive = reg.activeKey === key
await writeDefaultRegistry(removeAccount(reg, key))
return c.json({ ok: true, key, was_active: wasActive })
} catch (error) {
return await forwardError(c, error)
}
})
2 changes: 2 additions & 0 deletions src/routes/settings/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import { state } from "~/lib/state"
import { getGitVersion, shortSha } from "~/lib/version"

import { accountsRoutes } from "./accounts"
import { apiKeysRoutes } from "./api-keys"
import { appsRoutes } from "./apps"
import { authRoutes } from "./auth"
Expand Down Expand Up @@ -89,6 +90,7 @@ settingsApiRoutes.get("/diagnostics", (c) => {

settingsApiRoutes.route("/auth/github", authRoutes)
settingsApiRoutes.route("/gh", ghRoutes)
settingsApiRoutes.route("/accounts", accountsRoutes)
settingsApiRoutes.route("/api-keys", apiKeysRoutes)
settingsApiRoutes.route("/clients", clientsRoutes)
settingsApiRoutes.route("/apps", appsRoutes)
33 changes: 2 additions & 31 deletions src/routes/settings/gh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,42 +8,13 @@

import { Hono } from "hono"

import { forwardError, HTTPError } from "~/lib/error"
import { preflightCopilotError } from "~/lib/copilot-preflight"
import { forwardError } from "~/lib/error"
import {
addAccountToDefaultRegistry,
makeAccountRecord,
} from "~/lib/github-token-store"
import { detectGhCli, getGhAccountToken } from "~/services/gh-cli"
import { getCopilotUsage } from "~/services/github/get-copilot-usage"

/**
* Verify a gh account's token actually works for Copilot BEFORE we adopt it.
* Mirrors what boot does (the live identity check that was failing silently):
* a stale/revoked token → GitHub rejects it; an account with no Copilot →
* /copilot_internal/user 403/404. Returns a specific, user-facing message so
* the UI can say WHY synchronously, instead of writing the token, rebooting,
* and surfacing a generic "came back unauthenticated" 20s later. Returns null
* when the token is usable.
*/
export async function preflightCopilotError(
token: string,
login: string,
usage: (token: string) => Promise<unknown> = getCopilotUsage,
): Promise<string | null> {
try {
await usage(token)
return null
} catch (error) {
const status = error instanceof HTTPError ? error.response.status : 0
if (status === 401) {
return `GitHub rejected ${login}'s token — it may be expired or revoked. Run \`gh auth login\` and try again, or sign in with a code.`
}
if (status === 403 || status === 404) {
return `${login} doesn't have access to GitHub Copilot. Pick another account, or sign in with a code.`
}
return `Couldn't verify ${login} with GitHub${status ? ` (HTTP ${status})` : ""}. Check your connection and try again.`
}
}

export const ghRoutes = new Hono()

Expand Down
Loading
Loading