From 63332ef1a0654fb1e1432010a1a548a42321ced7 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 12:31:37 -0700 Subject: [PATCH 1/2] feat(web): onboarding offers Codex and xAI sign-in again (CL-8589) Codex and xAI are subscription providers with no API key, so the provider step now offers "Continue with Codex" and "Continue with xAI" beside the key and local options. Clicking one starts a login on the hub, opens the authorize URL in a new tab, polls the login, and then mints the offering over the credential the hub stored -- the same catalog chain the API-key path walks, differing only in where the secret came from. The hub mounts @corbits/oauth-core's new hub subpath inside the Corbits block and registers the two providers out of the already-pinned provider packages, so the loopback PKCE flow, the callback listener and the token exchange all stay in the hub process and the browser never holds a token. The sidecar needs the matching Responses adapters to serve inference with those credentials: .env.example now carries the manifest, the process provisioner forwards it to every sidecar it spawns, and docs/local-dev.md says what the flow needs and that nothing refreshes an expired token yet. --- .env.example | 7 + apps/hub/package.json | 1 + apps/hub/src/provisioners/process.ts | 4 + apps/hub/src/server.ts | 38 ++++ .../src/onboarding/provider-connect-step.tsx | 206 +++++++++++++++--- apps/web/src/settings/inference/api.test.ts | 2 +- apps/web/src/settings/inference/api.ts | 36 ++- apps/web/src/settings/inference/index.ts | 10 +- .../web/src/settings/inference/oauth-login.ts | 80 +++++++ bun.lock | 7 +- docs/local-dev.md | 18 ++ 11 files changed, 366 insertions(+), 43 deletions(-) create mode 100644 apps/web/src/settings/inference/oauth-login.ts diff --git a/.env.example b/.env.example index c20c6c1e4..81f55dde1 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,13 @@ SIDECAR_CREDENTIAL_ENCRYPTION_KEY=2222222222222222222222222222222222222222222222 # RERANK_MAX_DOC_CHARS= # RERANK_TIMEOUT_MS= +# Adapters a sidecar loads for providers the stock runtime does not serve. +# A JSON list of {provider, specifier, export}; the specifier is imported at +# boot, so it is operator config only, never tenant or deploy data. These two +# entries are what "Continue with Codex" and "Continue with xAI" need in +# order to run inference once the sign-in has stored its credential. +SIDECAR_ADAPTER_MANIFEST=[{"provider":"codex","specifier":"@corbits/codex-provider","export":"createCodexResponsesAdapter"},{"provider":"xai","specifier":"@corbits/xai-provider","export":"createXaiResponsesAdapter"}] + # Sidecars run as child processes of the hub. Both optional. # PROCESS_PROVISIONER_SIDECAR_ENTRY= # PROCESS_PROVISIONER_RUNTIME= diff --git a/apps/hub/package.json b/apps/hub/package.json index f07f21626..84e81bcdd 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -20,6 +20,7 @@ "@corbits/error-sink": "workspace:*", "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#65590a85fa143251b3ac20ba2eca92fc35e70e51", "@corbits/memory": "github:corbitsdev/corbits-memory#e74da20f148a302dff5400915fe504ee2395e913", + "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core#eb97fa7fd826467f688a32be3695a950181b07bd", "@corbits/url-path": "workspace:*", "@corbits/webhooks": "github:corbitsdev/webhooks#3f4f83147fd826d3aec5bcac7ef4b0ae7f75c00d", "@corbits/workflows": "workspace:*", diff --git a/apps/hub/src/provisioners/process.ts b/apps/hub/src/provisioners/process.ts index 310322838..07113025c 100644 --- a/apps/hub/src/provisioners/process.ts +++ b/apps/hub/src/provisioners/process.ts @@ -315,6 +315,9 @@ function sidecarEnvFor( } const home = process.env["HOME"]; const tmpdir = process.env["TMPDIR"]; + // Operator config, not tenant data: without it a sidecar has no adapter + // for a provider the stock runtime does not serve (Codex, xAI). + const adapterManifest = process.env["SIDECAR_ADAPTER_MANIFEST"]; return { SIDECAR_DATA_DIR: sidecarDataDir, HUB_WS_URL: args.hubWebSocketUrl, @@ -324,6 +327,7 @@ function sidecarEnvFor( PATH: path, ...(home === undefined ? {} : { HOME: home }), ...(tmpdir === undefined ? {} : { TMPDIR: tmpdir }), + ...(adapterManifest === undefined ? {} : { SIDECAR_ADAPTER_MANIFEST: adapterManifest }), }; } diff --git a/apps/hub/src/server.ts b/apps/hub/src/server.ts index 0e5a45382..35ef40602 100644 --- a/apps/hub/src/server.ts +++ b/apps/hub/src/server.ts @@ -57,6 +57,9 @@ import { mountMailbox, } from "@corbits/mailbox"; import { createMemory, loadMemoryConfig } from "@corbits/memory"; +import { mountOAuthLogin } from "@corbits/oauth-core/hub"; +import { CODEX_PROVIDER, codexOAuthConfig, exchangeCodexCode } from "@corbits/codex-provider"; +import { XAI_PROVIDER, xaiOAuthConfig, exchangeXaiCode } from "@corbits/xai-provider"; import { createCronTicker, createRunTriggerCronDeliver, mountCron } from "@corbits/cron"; import { createHubMailboxAuthorizeSender, @@ -632,6 +635,41 @@ export async function createHubServer({ app.route("/", memoryApp); } + { + // "Continue with Codex"/"Continue with xAI": the whole loopback PKCE + // flow runs here, so the verifier and the callback listener never + // leave this process and the browser only learns a credential id. + const oauthLoginApi = new Hono(); + const requireGrant = createRequireGrant({ + grantStore, + conditionRegistry: grantConditionRegistry, + }); + mountOAuthLogin(oauthLoginApi, { + db, + cipher: credentialCipher, + requireGrant: requireGrant("credential:*", "create"), + providers: { + [CODEX_PROVIDER]: { + oauthConfig: codexOAuthConfig, + exchange: (code, verifier, now) => exchangeCodexCode(code, verifier, now), + // The Codex backend rejects inference without this header value. + metadata: (tokens) => + "accountId" in tokens && typeof tokens.accountId === "string" + ? { accountId: tokens.accountId } + : {}, + }, + [XAI_PROVIDER]: { + oauthConfig: xaiOAuthConfig, + exchange: (code, verifier, now) => exchangeXaiCode(code, verifier, now), + }, + }, + onError: (error, { provider }) => { + reportError(error, { operation: "hub.oauth-login", extra: { provider } }); + }, + }); + app.route(TENANT_PREFIX, oauthLoginApi); + } + await installWebhooks({ app, db, diff --git a/apps/web/src/onboarding/provider-connect-step.tsx b/apps/web/src/onboarding/provider-connect-step.tsx index 26eeb6ee7..fc10b66d9 100644 --- a/apps/web/src/onboarding/provider-connect-step.tsx +++ b/apps/web/src/onboarding/provider-connect-step.tsx @@ -2,10 +2,18 @@ // derives the single offering it mints; a tenant that already resolves an // offering skips this step (see `resolveExistingOffering`). import { Button, Input, RadioGroup, RadioOption, Select } from "@corbits/react-ui"; -import { getResolvedCatalog, shadowOffering } from "@/settings/inference"; +import { + cancelProviderLogin, + credentialNameFor, + ensureProviderRow, + getResolvedCatalog, + readProviderLogin, + shadowOffering, + startProviderLogin, +} from "@/settings/inference"; import { reportError } from "@corbits/error-sink"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; import type { FormEvent } from "react"; import type { ModelProviderPlugin } from "@intx/types"; @@ -13,6 +21,9 @@ import type { ModelProviderPlugin } from "@intx/types"; import { fetchOllamaTags } from "./ollama-tags"; export type ProviderOption = { + /** Stable option id: two OAuth providers share one plugin, so the plugin + * cannot identify a row on its own. */ + readonly id: string; readonly plugin: ModelProviderPlugin; readonly label: string; readonly description: string; @@ -22,12 +33,16 @@ export type ProviderOption = { readonly keyHint: string; /** A server on this machine: base URL and model are typed in, no key needed. */ readonly local: boolean; + /** Set when this provider signs in instead of taking a key: the name the + * hub registered its OAuth login under. */ + readonly oauthProvider?: string; }; // Each hosted option names one canonical model so connecting mints exactly // one offering and the flow never asks "which model". export const PROVIDER_OPTIONS: readonly ProviderOption[] = [ { + id: "anthropic", plugin: "anthropic", label: "Anthropic", description: "Claude models, direct from Anthropic.", @@ -38,6 +53,7 @@ export const PROVIDER_OPTIONS: readonly ProviderOption[] = [ local: false, }, { + id: "openai", plugin: "openai", label: "OpenAI", description: "GPT models, direct from OpenAI.", @@ -48,6 +64,7 @@ export const PROVIDER_OPTIONS: readonly ProviderOption[] = [ local: false, }, { + id: "google-genai", plugin: "google-genai", label: "Google", description: "Gemini models, direct from Google.", @@ -58,6 +75,34 @@ export const PROVIDER_OPTIONS: readonly ProviderOption[] = [ local: false, }, { + id: "codex", + plugin: "openai-responses", + label: "Codex", + description: "GPT models on your ChatGPT subscription. Sign in, no key.", + canonicalName: "gpt-5.5", + modelDisplayName: "GPT-5.5 (Codex)", + // The ChatGPT backend the subscription token authenticates against — + // not platform.openai.com, which only takes API keys. + baseURL: "https://chatgpt.com/backend-api", + keyHint: "", + local: false, + oauthProvider: "codex", + }, + { + id: "xai", + plugin: "openai-responses", + label: "xAI", + description: "Grok models on your xAI account. Sign in, no key.", + canonicalName: "grok-4.6", + modelDisplayName: "Grok 4.6 (xAI)", + // The grok-cli chat proxy; api.x.ai rejects these tokens outright. + baseURL: "https://cli-chat-proxy.grok.com/v1", + keyHint: "", + local: false, + oauthProvider: "xai", + }, + { + id: "ollama", plugin: "openai-compatible", label: "Ollama (local)", description: "A model served by Ollama on this machine. No key needed.", @@ -107,6 +152,15 @@ export async function resolveExistingOffering(tenantId: string): Promise void; readonly onError: (message: string) => void; }) { - const [selected, setSelected] = useState( - PROVIDER_OPTIONS[0]?.plugin ?? "anthropic", - ); + const [selected, setSelected] = useState(PROVIDER_OPTIONS[0]?.id ?? "anthropic"); const [apiKey, setApiKey] = useState(""); const [baseURL, setBaseURL] = useState(""); const [modelName, setModelName] = useState(""); const [submitting, setSubmitting] = useState(false); + const [loginId, setLoginId] = useState(null); const option = - PROVIDER_OPTIONS.find((candidate) => candidate.plugin === selected) ?? PROVIDER_OPTIONS[0]; + PROVIDER_OPTIONS.find((candidate) => candidate.id === selected) ?? PROVIDER_OPTIONS[0]; const isLocal = option?.local === true; + const oauthProvider = option?.oauthProvider; // Fetched straight from the browser to the user-supplied base URL — this // never touches the hub. Validates the base URL is actually an Ollama @@ -142,22 +196,106 @@ export function ProviderConnectStep({ const tags = tagsQuery.data ?? []; const modelKnown = !isLocal || tags.includes(modelName); - const ready = isLocal - ? baseURL.trim().length > 0 && modelName.trim().length > 0 && modelKnown - : apiKey.trim().length > 0; + const ready = + oauthProvider !== undefined || + (isLocal + ? baseURL.trim().length > 0 && modelName.trim().length > 0 && modelKnown + : apiKey.trim().length > 0); + + function fail(cause: unknown, operation: string) { + const refId = reportError(cause, { operation, tenantId }); + onError(`${cause instanceof Error ? cause.message : String(cause)} (ref ${refId})`); + } - function selectOption(plugin: ModelProviderPlugin) { - setSelected(plugin); - const next = PROVIDER_OPTIONS.find((candidate) => candidate.plugin === plugin); + function selectOption(id: string) { + const next = PROVIDER_OPTIONS.find((candidate) => candidate.id === id); + if (loginId !== null) { + // Abandoning a login must free the fixed loopback port it holds. + void cancelProviderLogin(tenantId, loginId).catch((cause: unknown) => { + reportError(cause, { operation: "onboarding.cancel-provider-login", tenantId }); + }); + setLoginId(null); + } + setSelected(id); setBaseURL(next?.local === true ? next.baseURL : ""); setModelName(""); } + // Starting a login is the hub's job end to end: it runs the loopback PKCE + // flow and stores the tokens, and hands back only a URL to open. + const startLogin = useMutation({ + mutationFn: async (target: { option: ProviderOption; provider: string }) => { + const providerId = await ensureProviderRow(tenantId, { + providerName: target.option.label, + plugin: target.option.plugin, + baseURL: target.option.baseURL, + }); + return startProviderLogin(tenantId, { + provider: target.provider, + providerId, + credentialName: credentialNameFor(target.option.label), + }); + }, + onSuccess: (started) => { + setLoginId(started.loginId); + window.open(started.authorizeUrl, "_blank", "noopener,noreferrer"); + }, + onError: (cause: unknown) => { + fail(cause, "onboarding.start-provider-login"); + }, + }); + + // Polls the login the hub is hosting and, the moment it lands, mints the + // offering over the credential the hub stored — the same catalog chain + // the API-key path walks, differing only in where the secret came from. + const login = useQuery({ + queryKey: ["onboarding", "oauth-login", tenantId, loginId], + enabled: loginId !== null && option !== undefined, + queryFn: async () => { + if (loginId === null || option === undefined) throw new Error("no login in flight"); + const state = await readProviderLogin(tenantId, loginId); + if (state.status !== "completed") return state; + const created = await shadowOffering(tenantId, { + canonicalName: option.canonicalName, + modelDisplayName: option.modelDisplayName, + providerName: option.label, + plugin: option.plugin, + baseURL: option.baseURL, + credential: { credentialId: state.credentialId }, + priority: 0, + }); + return { + status: "connected" as const, + offering: offeringFromOption(option, option.canonicalName, created.id), + }; + }, + refetchInterval: (query) => (query.state.data?.status === "pending" ? 2000 : false), + }); + + const loginState = login.data; + const loginError = login.error; + + // Handing the finished offering to the parent is the only thing left, and + // it is a callback, not a fetch — every hub call above is a query. + useEffect(() => { + if (loginState?.status === "connected") onConnected(loginState.offering); + }, [loginState, onConnected]); + + useEffect(() => { + if (loginError !== null) fail(loginError, "onboarding.provider-login"); + // `fail` closes over props that are stable for this step's lifetime. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loginError]); + async function handleSubmit(event: FormEvent) { event.preventDefault(); if (option === undefined || !ready || submitting) { return; } + if (oauthProvider !== undefined) { + startLogin.mutate({ option, provider: oauthProvider }); + return; + } setSubmitting(true); try { const canonicalName = isLocal ? modelName.trim() : option.canonicalName; @@ -167,43 +305,51 @@ export function ProviderConnectStep({ providerName: option.label, plugin: option.plugin, baseURL: isLocal ? baseURL.trim() : option.baseURL, - apiKey: isLocal ? LOCAL_PLACEHOLDER_KEY : apiKey.trim(), + credential: { apiKey: isLocal ? LOCAL_PLACEHOLDER_KEY : apiKey.trim() }, priority: 0, }); - onConnected({ - sourceOfferingIds: [created.id], - defaultSourceOfferingId: created.id, - declaredSources: [{ provider: option.plugin, model: canonicalName }], - }); + onConnected(offeringFromOption(option, canonicalName, created.id)); } catch (cause) { - const refId = reportError(cause, { - operation: "onboarding.connect-provider", - tenantId, - }); - onError(`${cause instanceof Error ? cause.message : String(cause)} (ref ${refId})`); + fail(cause, "onboarding.connect-provider"); } finally { setSubmitting(false); } } + const waiting = startLogin.isPending || loginState?.status === "pending"; + const submitLabel = + oauthProvider !== undefined + ? waiting + ? "Waiting for sign-in…" + : `Continue with ${option?.label ?? ""}` + : submitting + ? "Connecting…" + : "Connect"; + return (
void handleSubmit(event)}> selectOption(value as ModelProviderPlugin)} + onValueChange={selectOption} > {PROVIDER_OPTIONS.map((candidate) => ( ))} - {isLocal ? ( + {oauthProvider !== undefined ? ( +

+ {waiting + ? "Finish signing in on the tab that opened, then come back here." + : "A new tab opens to sign in. Your workbench stores the result; no key to paste."} +

+ ) : isLocal ? ( <> )} -
); diff --git a/apps/web/src/settings/inference/api.test.ts b/apps/web/src/settings/inference/api.test.ts index 51234a628..21a1a3467 100644 --- a/apps/web/src/settings/inference/api.test.ts +++ b/apps/web/src/settings/inference/api.test.ts @@ -35,7 +35,7 @@ const INPUT = { providerName: "anthropic", plugin: "anthropic" as const, baseURL: "https://api.anthropic.com", - apiKey: "sk-test", + credential: { apiKey: "sk-test" }, priority: 2, }; diff --git a/apps/web/src/settings/inference/api.ts b/apps/web/src/settings/inference/api.ts index 35d2beeff..bc83be218 100644 --- a/apps/web/src/settings/inference/api.ts +++ b/apps/web/src/settings/inference/api.ts @@ -236,13 +236,20 @@ export function updateOwnOffering( ); } -export type ShadowOfferingInput = { - readonly canonicalName: string; - readonly modelDisplayName: string | null; +/** The provider identity a credential and a catalog entry hang off. */ +export type ProviderIdentity = { readonly providerName: string; readonly plugin: typeof ModelProviderPlugin.infer; readonly baseURL: string; - readonly apiKey: string; +}; + +export type ShadowOfferingInput = ProviderIdentity & { + readonly canonicalName: string; + readonly modelDisplayName: string | null; + /** The key to store, or the id of a credential the hub already stored — + * an OAuth login mints its own row hub-side, so the browser never sees + * a token to pass here. */ + readonly credential: { readonly apiKey: string } | { readonly credentialId: string }; /** The exact priority of the offering being shadowed — this row takes * over its slot in resolution (`listVisibleOfferings`'s leaf-wins-by-name * cascade), so it must sort exactly where that offering did, never @@ -348,8 +355,10 @@ async function ensureCredential( input: ShadowOfferingInput, fetchImpl: FetchImpl, ): Promise { - const providerRow = await ensureCredentialProvider(tenantId, input, fetchImpl); - const credentialName = `${input.providerName}-workbench`; + if ("credentialId" in input.credential) return input.credential.credentialId; + const apiKey = input.credential.apiKey; + const providerRow = await ensureProviderRow(tenantId, input, fetchImpl); + const credentialName = credentialNameFor(input.providerName); const created = await fetchImpl(`/api/tenants/${tenantId}/credentials`, { method: "POST", headers: { "content-type": "application/json" }, @@ -358,7 +367,7 @@ async function ensureCredential( providerId: providerRow, name: credentialName, type: "api_key", - secret: input.apiKey, + secret: apiKey, }), ), }); @@ -393,10 +402,17 @@ async function ensureCredential( return existing.id; } -async function ensureCredentialProvider( +/** The one credential name a workbench files a provider's key or token under. */ +export function credentialNameFor(providerName: string): string { + return `${providerName}-workbench`; +} + +/** Mints (or resolves) this workbench's own `provider` row, the row a + * credential must reference before it can be stored. */ +export async function ensureProviderRow( tenantId: string, - input: ShadowOfferingInput, - fetchImpl: FetchImpl, + input: ProviderIdentity, + fetchImpl: FetchImpl = fetch, ): Promise { const created = await fetchImpl(`/api/tenants/${tenantId}/providers`, { method: "POST", diff --git a/apps/web/src/settings/inference/index.ts b/apps/web/src/settings/inference/index.ts index 106ade59a..52d5be861 100644 --- a/apps/web/src/settings/inference/index.ts +++ b/apps/web/src/settings/inference/index.ts @@ -7,7 +7,15 @@ export { shadowOffering, updateOwnOffering, } from "./api"; -export type { ModelInfo, ModelOfferingInfo, ShadowOfferingInput } from "./api"; +export { credentialNameFor, ensureProviderRow } from "./api"; +export type { ModelInfo, ModelOfferingInfo, ProviderIdentity, ShadowOfferingInput } from "./api"; +export { + cancelProviderLogin, + readProviderLogin, + startProviderLogin, + type LoginState, + type StartedLogin, +} from "./oauth-login"; export { buildEffectiveInferenceRows, chatCapableModels, diff --git a/apps/web/src/settings/inference/oauth-login.ts b/apps/web/src/settings/inference/oauth-login.ts new file mode 100644 index 000000000..9d173b288 --- /dev/null +++ b/apps/web/src/settings/inference/oauth-login.ts @@ -0,0 +1,80 @@ +// The browser's half of a hub-hosted OAuth login (`@corbits/oauth-core/hub`). +// The hub runs the loopback PKCE flow and stores the tokens itself, so +// nothing here ever holds a token — only a login id and, at the end, the id +// of the credential the hub wrote. + +import { type } from "arktype"; + +import { InferenceSettingsApiError, type FetchImpl } from "./api"; + +const StartedLogin = type({ loginId: "string", authorizeUrl: "string" }); +export type StartedLogin = typeof StartedLogin.infer; + +const LoginState = type({ status: "'pending'" }) + .or({ status: "'completed'", credentialId: "string" }) + .or({ status: "'failed'", message: "string" }) + .or({ status: "'cancelled'" }); +export type LoginState = typeof LoginState.infer; + +async function parsed( + response: Response, + schema: (data: unknown) => T | type.errors, + verb: string, +): Promise { + if (!response.ok) { + throw new InferenceSettingsApiError( + `The server answered ${String(response.status)} while ${verb}.`, + response.status, + ); + } + const body: unknown = await response.json().catch(() => undefined); + const result = schema(body); + if (result instanceof type.errors) { + throw new InferenceSettingsApiError( + `Unexpected response shape while ${verb}: ${result.summary}`, + ); + } + return result; +} + +/** Starts a login for `provider`, filing the credential it will mint under + * `providerId`/`credentialName`. Returns the URL to send the person to. */ +export async function startProviderLogin( + tenantId: string, + input: { + readonly provider: string; + readonly providerId: string; + readonly credentialName: string; + }, + fetchImpl: FetchImpl = fetch, +): Promise { + return parsed( + await fetchImpl(`/api/tenants/${tenantId}/oauth-logins`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }), + StartedLogin, + "starting the sign-in", + ); +} + +export async function readProviderLogin( + tenantId: string, + loginId: string, + fetchImpl: FetchImpl = fetch, +): Promise { + return parsed( + await fetchImpl(`/api/tenants/${tenantId}/oauth-logins/${loginId}`), + LoginState, + "checking the sign-in", + ); +} + +export async function cancelProviderLogin( + tenantId: string, + loginId: string, + fetchImpl: FetchImpl = fetch, +): Promise { + await fetchImpl(`/api/tenants/${tenantId}/oauth-logins/${loginId}`, { method: "DELETE" }); +} diff --git a/bun.lock b/bun.lock index 2a4120036..3c7b4d244 100644 --- a/bun.lock +++ b/bun.lock @@ -61,6 +61,7 @@ "@corbits/error-sink": "workspace:*", "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#65590a85fa143251b3ac20ba2eca92fc35e70e51", "@corbits/memory": "github:corbitsdev/corbits-memory#e74da20f148a302dff5400915fe504ee2395e913", + "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core#eb97fa7fd826467f688a32be3695a950181b07bd", "@corbits/url-path": "workspace:*", "@corbits/webhooks": "github:corbitsdev/webhooks#3f4f83147fd826d3aec5bcac7ef4b0ae7f75c00d", "@corbits/workflows": "workspace:*", @@ -635,7 +636,7 @@ "@corbits/myra": ["@corbits/myra@workspace:agents/myra"], - "@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#1b7f9fb", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-1b7f9fb", "sha512-PscyiDAl46umTM44NQPN1q5+PsGUV53Rxr4Wz/Ofz+k9LOOz6FBKamRFtWR8YWdKJt9I8sPOLOyEcMNNN2i+YQ=="], + "@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#eb97fa7", { "dependencies": { "@intx/db": "^0.3.0", "@intx/hub-api": "^0.3.0", "@intx/hub-common": "^0.3.0", "@intx/types": "^0.3.0", "arktype": "2.2.3", "drizzle-orm": "^0.45.2", "hono": "^4.11.9" } }, "corbitsdev-corbits-oauth-core-eb97fa7", "sha512-GsVBqwzuDCoVUk4jAAll5hIVGOWb9UMPlQbqdZQVNNvaoeNaEbjfsVyE/s8eJWL53PAXVxkqx2AahBbAmnmyXg=="], "@corbits/ollama-adapter": ["@corbits/ollama-adapter@github:corbitsdev/corbits-ollama-adapter#0387eb1", { "dependencies": { "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-ollama-adapter-0387eb1", "sha512-O1dl5lSwWzNGJibQLlpYDFKJZWpstgHX519bl8oe1ALL/KHszT/tBDlpsD9jN3hmQD3jV2L1jNwvOwLOBLtpyw=="], @@ -1837,8 +1838,12 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@corbits/codex-provider/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#1b7f9fb", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-1b7f9fb", "sha512-PscyiDAl46umTM44NQPN1q5+PsGUV53Rxr4Wz/Ofz+k9LOOz6FBKamRFtWR8YWdKJt9I8sPOLOyEcMNNN2i+YQ=="], + "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], + "@corbits/xai-provider/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#1b7f9fb", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-1b7f9fb", "sha512-PscyiDAl46umTM44NQPN1q5+PsGUV53Rxr4Wz/Ofz+k9LOOz6FBKamRFtWR8YWdKJt9I8sPOLOyEcMNNN2i+YQ=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@npmcli/agent/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], diff --git a/docs/local-dev.md b/docs/local-dev.md index 6665e9964..64155bc70 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -49,3 +49,21 @@ both are set a reranker outage degrades search quietly instead of breaking it. See `.env.example` for every memory-plane variable. + +## Signing in to Codex and xAI + +Both are subscription providers with no API key: onboarding offers +"Continue with Codex" and "Continue with xAI", and the hub runs the whole +loopback OAuth flow in its own process — the browser only ever sees the +authorize URL and, at the end, the id of the credential the hub stored. +Each provider pins its own loopback port (1455 for Codex, 1456 for xAI), +so those ports must be free on the machine running the hub, and the +browser must be on that same machine. + +Serving inference with one of those credentials also needs the matching +adapter in the sidecar, which is what `SIDECAR_ADAPTER_MANIFEST` in +`.env.example` is for; the hub forwards it to every sidecar it spawns. +Leave it unset to run without those two providers. + +Nothing refreshes an expired OAuth credential today. When one lapses, sign +in again from Settings to replace it. From de829e5648d70e1983ed8ca4ad33b4a2a92822ed Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 12:48:58 -0700 Subject: [PATCH 2/2] chore(hub): pin @corbits/oauth-core to the merged main sha (CL-8589) --- apps/hub/package.json | 2 +- bun.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/hub/package.json b/apps/hub/package.json index 84e81bcdd..01b04fe36 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -20,7 +20,7 @@ "@corbits/error-sink": "workspace:*", "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#65590a85fa143251b3ac20ba2eca92fc35e70e51", "@corbits/memory": "github:corbitsdev/corbits-memory#e74da20f148a302dff5400915fe504ee2395e913", - "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core#eb97fa7fd826467f688a32be3695a950181b07bd", + "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core#e97d563b823506d72f83dead478dd9fa9dccfd4c", "@corbits/url-path": "workspace:*", "@corbits/webhooks": "github:corbitsdev/webhooks#3f4f83147fd826d3aec5bcac7ef4b0ae7f75c00d", "@corbits/workflows": "workspace:*", diff --git a/bun.lock b/bun.lock index 3c7b4d244..036a9cea6 100644 --- a/bun.lock +++ b/bun.lock @@ -61,7 +61,7 @@ "@corbits/error-sink": "workspace:*", "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#65590a85fa143251b3ac20ba2eca92fc35e70e51", "@corbits/memory": "github:corbitsdev/corbits-memory#e74da20f148a302dff5400915fe504ee2395e913", - "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core#eb97fa7fd826467f688a32be3695a950181b07bd", + "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core#e97d563b823506d72f83dead478dd9fa9dccfd4c", "@corbits/url-path": "workspace:*", "@corbits/webhooks": "github:corbitsdev/webhooks#3f4f83147fd826d3aec5bcac7ef4b0ae7f75c00d", "@corbits/workflows": "workspace:*", @@ -636,7 +636,7 @@ "@corbits/myra": ["@corbits/myra@workspace:agents/myra"], - "@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#eb97fa7", { "dependencies": { "@intx/db": "^0.3.0", "@intx/hub-api": "^0.3.0", "@intx/hub-common": "^0.3.0", "@intx/types": "^0.3.0", "arktype": "2.2.3", "drizzle-orm": "^0.45.2", "hono": "^4.11.9" } }, "corbitsdev-corbits-oauth-core-eb97fa7", "sha512-GsVBqwzuDCoVUk4jAAll5hIVGOWb9UMPlQbqdZQVNNvaoeNaEbjfsVyE/s8eJWL53PAXVxkqx2AahBbAmnmyXg=="], + "@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e97d563", { "dependencies": { "@intx/db": "^0.3.0", "@intx/hub-api": "^0.3.0", "@intx/hub-common": "^0.3.0", "@intx/types": "^0.3.0", "arktype": "2.2.3", "drizzle-orm": "^0.45.2", "hono": "^4.11.9" } }, "corbitsdev-corbits-oauth-core-e97d563", "sha512-YbW3CXt/W+fw+0RHBm8hWXg9m0Gckt/iTDJJ/c60SYhOVOvPVaQ+HscRgq+UxMThtyer9xIBkkL8B8YgdEBfgQ=="], "@corbits/ollama-adapter": ["@corbits/ollama-adapter@github:corbitsdev/corbits-ollama-adapter#0387eb1", { "dependencies": { "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-ollama-adapter-0387eb1", "sha512-O1dl5lSwWzNGJibQLlpYDFKJZWpstgHX519bl8oe1ALL/KHszT/tBDlpsD9jN3hmQD3jV2L1jNwvOwLOBLtpyw=="], @@ -1962,13 +1962,13 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - "@workbench/hub/@corbits/codex-provider/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#1b7f9fb", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-1b7f9fb", "sha512-PscyiDAl46umTM44NQPN1q5+PsGUV53Rxr4Wz/Ofz+k9LOOz6FBKamRFtWR8YWdKJt9I8sPOLOyEcMNNN2i+YQ=="], + "@workbench/hub/@corbits/codex-provider/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e97d563", { "dependencies": { "@intx/db": "^0.3.0", "@intx/hub-api": "^0.3.0", "@intx/hub-common": "^0.3.0", "@intx/types": "^0.3.0", "arktype": "2.2.3", "drizzle-orm": "^0.45.2", "hono": "^4.11.9" } }, "corbitsdev-corbits-oauth-core-e97d563", "sha512-YbW3CXt/W+fw+0RHBm8hWXg9m0Gckt/iTDJJ/c60SYhOVOvPVaQ+HscRgq+UxMThtyer9xIBkkL8B8YgdEBfgQ=="], "@workbench/hub/@corbits/codex-provider/@corbits/openai-responses": ["@corbits/openai-responses@github:corbitsdev/corbits-openai-responses#7efe000", { "dependencies": { "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-openai-responses-7efe000", "sha512-pH6C8T1WDJjqM+YbnQpofd7HgP5krEj48JCCQK16KI98a6kaz+gffpgdOdQpU3aNVZnFlyADXod1Z4tqUG/3ww=="], "@workbench/hub/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "@workbench/hub/@corbits/xai-provider/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#1b7f9fb", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-1b7f9fb", "sha512-PscyiDAl46umTM44NQPN1q5+PsGUV53Rxr4Wz/Ofz+k9LOOz6FBKamRFtWR8YWdKJt9I8sPOLOyEcMNNN2i+YQ=="], + "@workbench/hub/@corbits/xai-provider/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e97d563", { "dependencies": { "@intx/db": "^0.3.0", "@intx/hub-api": "^0.3.0", "@intx/hub-common": "^0.3.0", "@intx/types": "^0.3.0", "arktype": "2.2.3", "drizzle-orm": "^0.45.2", "hono": "^4.11.9" } }, "corbitsdev-corbits-oauth-core-e97d563", "sha512-YbW3CXt/W+fw+0RHBm8hWXg9m0Gckt/iTDJJ/c60SYhOVOvPVaQ+HscRgq+UxMThtyer9xIBkkL8B8YgdEBfgQ=="], "@workbench/hub/@corbits/xai-provider/@corbits/openai-responses": ["@corbits/openai-responses@github:corbitsdev/corbits-openai-responses#7efe000", { "dependencies": { "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-openai-responses-7efe000", "sha512-pH6C8T1WDJjqM+YbnQpofd7HgP5krEj48JCCQK16KI98a6kaz+gffpgdOdQpU3aNVZnFlyADXod1Z4tqUG/3ww=="],