Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
42a3ec2
fix: stop stale deep links minting phantom projects and emptying the …
KB-syntheticsciences Aug 2, 2026
efd1f68
fix: publish storage records by atomic rename
KB-syntheticsciences Aug 2, 2026
d3c8780
fix: skip sandbox masks for credential files that do not exist
KB-syntheticsciences Aug 2, 2026
8914c71
test: give spawned children the suite's sandboxed environment
KB-syntheticsciences Aug 2, 2026
c0172b0
feat: light text fields on focus instead of outlining them
KB-syntheticsciences Aug 2, 2026
27911fd
fix(provider): managed billing wins OpenRouter routing over a stored …
KB-syntheticsciences Aug 2, 2026
0453927
fix(provider): map managed source to Inference "managed" and narrow 1…
KB-syntheticsciences Aug 2, 2026
98a80d5
fix(provider): apply billing.llm changes at runtime, auto-flip to byo…
KB-syntheticsciences Aug 2, 2026
66999e2
fix(provider): move billing.llm auto-flip into Auth.set, await dispos…
KB-syntheticsciences Aug 2, 2026
5b94bdd
fix(provider): rewire isAtlasApiKey call sites to Auth, add malformed…
KB-syntheticsciences Aug 2, 2026
da0037b
fix(settings): surface the managed inference route in Settings
KB-syntheticsciences Aug 2, 2026
abe0564
test(settings): pin ManagedInference's write-then-refresh ordering
KB-syntheticsciences Aug 2, 2026
54af8d0
test(settings): make commitBilling's ordering test sensitive to a dro…
KB-syntheticsciences Aug 2, 2026
82b220e
fix(storage): stop publish staging files surfacing as records
KB-syntheticsciences Aug 2, 2026
6c785bd
fix(provider): drop BYOK-credentialed providers under an explicit man…
KB-syntheticsciences Aug 2, 2026
fd15725
fix(settings): repaint the mode toggle when a saved key flips billing…
KB-syntheticsciences Aug 2, 2026
b6abd94
refactor(cli): use Auth.isAtlasApiKey for the models routing label
KB-syntheticsciences Aug 2, 2026
224eb81
test: restore tmpdir fixture cleanup
KB-syntheticsciences Aug 2, 2026
38687ea
fix(settings): show the Atlas-carried OpenRouter route in the keys panel
KB-syntheticsciences Aug 2, 2026
b4b07ea
fix(ui): stop framed fields painting a second focus ring
KB-syntheticsciences Aug 3, 2026
e7c51c1
fix(cli): invalidate the provider cache before announcing a config write
KB-syntheticsciences Aug 3, 2026
e3e95a1
fix(workspace): do not report a stale catalog as a failed credential …
KB-syntheticsciences Aug 3, 2026
abc194f
fix: catch two residual write-then-refresh gaps
KB-syntheticsciences Aug 3, 2026
2dc4cff
style: wrap the provider-invalidate catch to Prettier width
KB-syntheticsciences Aug 3, 2026
75a97b4
fix(settings): keep the Atlas-carried route out of the provider keys …
KB-syntheticsciences Aug 3, 2026
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
53 changes: 53 additions & 0 deletions backend/cli/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,25 @@ import path from "path"
import { Global } from "../global"
import { JsonStore } from "../util/jsonstore"
import z from "zod"
import { Config } from "../config/config"
import { Log } from "../util/log"

export const OAUTH_DUMMY_KEY = "synsc-oauth-dummy-key"

const log = Log.create({ service: "auth" })

export namespace Auth {
/** A managed Atlas wallet credential (`thk_*`), as opposed to a user-owned
* (BYOK) key. Canonical home: `auth/index.ts` is a near-leaf module (only
* path/global/jsonstore/zod besides this file's own Config import), so
* `provider.ts` - which already imports Auth - depends on this instead of
* Auth duplicating or importing from Provider (a much heavier module: all
* the AI SDK loaders, plus Provider already imports Auth AND Config, so
* an Auth -> Provider edge would close two cycles through it at once). */
export function isAtlasApiKey(key: unknown): key is string {
return typeof key === "string" && key.startsWith("thk_")
}

export const Oauth = z
.object({
type: z.literal("oauth"),
Expand Down Expand Up @@ -57,6 +72,44 @@ export namespace Auth {

export async function set(key: string, info: Info) {
await JsonStore.update(filepath, (data) => ({ ...data, [key]: info }))

// Adding a real (non-Atlas) OpenRouter key while Managed spend is on
// means the user is bringing their own key - flip the toggle to Own
// keys so the added key actually wins routing immediately, instead of
// sitting unused behind the managed route until the user finds the
// Settings toggle. This is the ONE choke point both `openscience auth
// login` (CLI - calls Auth.set directly, see cli/cmd/auth.ts) and the
// Settings UI (PUT /auth/:providerID -> Auth.set) go through, so it
// belongs here rather than in the HTTP route. A `thk_` Atlas token is
// never "own key" material and must not flip the mode; other providers
// and OAuth credentials are untouched.
if (key === "openrouter" && info.type === "api" && !isAtlasApiKey(info.key)) {
try {
// Reads the GLOBAL config specifically (not the merged project+global
// Config.get(), which requires an active Instance/project context that
// most Auth.set callers - including every CLI auth command - don't
// have). billing.llm can also be set at project scope; a project-level
// override is invisible to this check, same asymmetry the byok guard
// in provider.ts lives with when read outside a project context.
const cfg = await Config.getGlobal()
if (cfg.billing?.llm === "managed") {
await Config.updateGlobal({ billing: { llm: "byok" } })
}
} catch (e) {
// A malformed global config (a hand-edited openscience.jsonc with a
// trailing comma, say) makes Config.getGlobal()/updateGlobal() throw.
// That must not take down Auth.set - the credential above is already
// persisted, and Auth.set has 11 call sites, at least one with no
// try/catch of its own (the CLI's "paste the code" OAuth branch,
// cli/cmd/auth.ts:143-170). Degrade to "key saved, mode not flipped"
// rather than losing the key the user just added - but log it at
// warn: silently swallowing would leave the user's mode silently
// disagreeing with the key they just added, with no signal at all.
log.warn("failed to flip billing.llm to byok after adding an OpenRouter key", {
error: e instanceof Error ? e.message : String(e),
})
}
}
}

export async function remove(key: string) {
Expand Down
7 changes: 5 additions & 2 deletions backend/cli/src/cli/cmd/models.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Argv } from "yargs"
import { Auth } from "../../auth"
import { Instance } from "../../project/instance"
import { Provider } from "../../provider/provider"
import { ModelsDev } from "../../provider/models"
Expand Down Expand Up @@ -30,7 +31,7 @@ const PROVIDER_LABELS: Record<string, string> = {
*
* Detection rules:
* - openai-codex routes via OAuth (Sign in with ChatGPT), neither.
* - key starts with "thk_" → managed (the proxy thumbprint Atlas hands
* - Auth.isAtlasApiKey(key) → managed (the proxy thumbprint Atlas hands
* out on /api/cli/sync when the user has no BYOK key set).
* - options.baseURL points at Atlas (/api/llm/proxy/) → managed.
* - anything else with a key → BYOK.
Expand All @@ -45,7 +46,9 @@ function routingLabel(providerID: string, provider: Provider.Info): string {
// demo sentinel is not a real credential.
const effective = Provider.effectiveKey(provider)
const baseURL = (provider.options?.baseURL as string | undefined) ?? ""
if ((effective ?? "").toLowerCase().startsWith("thk_")) return "managed"
if (Auth.isAtlasApiKey(effective)) return "managed"
// Kept as a separate signal: a stale synced ANTHROPIC_BASE_URL can point at
// the Atlas proxy while the key itself is not a thk_ token.
if (baseURL.includes("/api/llm/proxy/")) return "managed"
// A config-registered local endpoint stores its key under options.apiKey (not
// provider.key), so it would otherwise read as "unconfigured".
Expand Down
79 changes: 45 additions & 34 deletions backend/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1611,6 +1611,48 @@ export namespace Config {
}, input)
}

/**
* Dispose every open project instance after a GLOBAL config write and
* announce it. Awaited (not fire-and-forget): the per-directory
* Config.state cache (config.ts's `state`, backed by Instance.state) is
* only invalidated by Instance.dispose()/disposeAll() — resetting the
* `global` lazy singleton above is not enough on its own for an
* already-instantiated project directory. Callers of setMcp/setProvider/
* setSandbox/unsetGlobal/updateGlobal/replaceGlobal rely on the write
* being visible to the very next Config.get(), not eventually-after-a-
* fire-and-forget-settles visible.
*
* The provider cache is dropped here too, and specifically BEFORE the
* announcement. Provider memoises the resolved provider/SDK map at module
* scope keyed only by directory + trust, which Instance.disposeAll() does
* not touch and this write does not change — so it outlives the write. The
* SPA refetches GET /provider the instant it sees `global.disposed`, and a
* refetch that lands in the gap re-memoises the PRE-write map (the key just
* added still missing, billing still reading managed) with nothing left to
* invalidate it afterwards. Announcing a disposal that the provider map has
* not honoured yet is the bug; the two belong together.
*/
async function disposeGlobalInstances() {
await Instance.disposeAll().catch(() => undefined)
// Lazy because provider.ts imports Config — the same cycle-break
// provider/models.ts and openscience/index.ts already use to reach it.
// Best-effort like the disposal above: the config file is already written
// by the time this runs, so a throw here (e.g. provider module init
// failing) must not turn a landed write into a rejected one.
await import("../provider/provider")
.then((m) => m.Provider.invalidate())
.catch((e) =>
log.warn("failed to invalidate provider cache", { error: e instanceof Error ? e.message : String(e) }),
)
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Event.Disposed.type,
properties: {},
},
})
}

async function patchConfigPath(scope: Scope, target: string[], value: unknown) {
const filepath = scope === "global" ? globalConfigFile() : projectConfigFile()
const before = await Bun.file(filepath)
Expand All @@ -1631,17 +1673,7 @@ export namespace Config {
const parsed = parseConfig(updated, filepath)
global.reset()
if (scope === "global") {
void Instance.disposeAll()
.catch(() => undefined)
.finally(() => {
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Event.Disposed.type,
properties: {},
},
})
})
await disposeGlobalInstances()
} else {
await Instance.dispose()
}
Expand Down Expand Up @@ -1728,17 +1760,7 @@ export namespace Config {
await fs.mkdir(path.dirname(filepath), { recursive: true })
await Bun.write(filepath, content)
global.reset()
void Instance.disposeAll()
.catch(() => undefined)
.finally(() => {
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Event.Disposed.type,
properties: {},
},
})
})
await disposeGlobalInstances()
return parsed
}

Expand Down Expand Up @@ -1800,18 +1822,7 @@ export namespace Config {
})()

global.reset()

void Instance.disposeAll()
.catch(() => undefined)
.finally(() => {
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Event.Disposed.type,
properties: {},
},
})
})
await disposeGlobalInstances()

return next
}
Expand Down
37 changes: 34 additions & 3 deletions backend/cli/src/project/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ export namespace Project {
}),
)

export const DirectoryError = NamedError.create(
"ProjectDirectoryError",
z.object({
directory: z.string(),
}),
)

export const Info = z
.object({
id: z.string(),
Expand Down Expand Up @@ -169,12 +176,24 @@ export namespace Project {
* A caller may include a legacy directory while migrating, but it must remain
* inside the selected project's recorded roots.
*/
/**
* Only a genuinely absent record means the project is gone. Any other read
* failure — a torn file from a concurrent writer, a transient fs error — must
* propagate, because reporting it as 410 tells the caller to stop asking
* about a project that is in fact fine, and the client empties the surfaces
* that depend on it.
*/
function absent(error: unknown) {
if (Storage.NotFoundError.isInstance(error)) return undefined
throw error
}

export async function resolve(projectID: string, directory?: string) {
const direct = await Storage.read<Info>(["project", projectID]).catch(() => undefined)
const link = await Storage.read<z.infer<typeof Alias>>(["project_alias", projectID]).catch(() => undefined)
const direct = await Storage.read<Info>(["project", projectID]).catch(absent)
const link = await Storage.read<z.infer<typeof Alias>>(["project_alias", projectID]).catch(absent)
if (!direct && !link) throw new UnknownError({ projectID })

const linked = link ? await Storage.read<Info>(["project", link.projectID]).catch(() => undefined) : undefined
const linked = link ? await Storage.read<Info>(["project", link.projectID]).catch(absent) : undefined
const redirected = !!linked && (!direct || !projectID.startsWith("prj_"))
const project = redirected ? linked : (direct ?? linked)
if (!project) {
Expand Down Expand Up @@ -210,6 +229,18 @@ export namespace Project {
}
}

/**
* Guard a caller-supplied root before it can mint a project. Anything that is
* not an absolute path gets resolved against the server's cwd, which turned
* junk from a stale deep link into a real-looking folder under the user's
* home and left a phantom project on their home list.
*/
export async function assertDirectory(input: string) {
if (!path.isAbsolute(input)) throw new DirectoryError({ directory: input })
const stat = await fs.stat(input).catch(() => undefined)
if (!stat?.isDirectory()) throw new DirectoryError({ directory: input })
}

export async function fromDirectory(input: string) {
const directory = canonicalize(input)
log.info("fromDirectory", { directory })
Expand Down
7 changes: 6 additions & 1 deletion backend/cli/src/provider/inference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export namespace Inference {
export function classify(input: {
providerID: string
billing?: "managed" | "byok" | null
providerSource?: "env" | "config" | "custom" | "api"
providerSource?: "env" | "config" | "custom" | "api" | "managed"
baseURL?: string
auth?: Auth.Info["type"]
}): Source {
Expand All @@ -35,6 +35,11 @@ export namespace Inference {
if (input.providerID === "openrouter" && input.billing === "managed") return "managed"
if (input.auth === "oauth") return "oauth"
if (input.auth === "api" || input.auth === "wellknown") return "byok"
// Auto-detect (billing unset) never sets `billing === "managed"` above, but a
// synced thk_ token with no own key still genuinely routes through the Atlas
// proxy — provider.source already says "managed" (provider.ts's openrouter
// loader), so trust it here too instead of falling through to "unknown".
if (input.providerSource === "managed") return "managed"
if (input.providerSource === "env" || input.providerSource === "config" || input.providerSource === "api") {
return "byok"
}
Expand Down
Loading