From a1c09cdcb7f7477aa1fead73c21d31d03dc3a586 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+its-janghoon@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:45:47 +0000 Subject: [PATCH] Read the Redrob Launcher shared account as the last console-key fallback Redrob Launcher signs a computer in once and writes the workspace key to a shared account.json (Linux $XDG_CONFIG_HOME/redrob-shared, macOS ~/Library/Application Support/ai.redrob.shared, Windows %APPDATA%\Redrob\Shared; REDROB_SHARED_DIR replaces the directory). The contract is redrob-launcher docs/shared-account.md: read-only, read fresh on every use, the app's own key wins, empty/missing/malformed means not signed in, never log the key. core/shared-account.ts is the one reader: path resolution, parsing, and a read() that hits the disk every call. It never writes or deletes the file. Every console-key consumer now ends its chain at that file: - ConsoleKey.resolve keeps its signature and gains resolveWithSource, which reports where the key came from: env | credential | api | launcher. - ModelsDev.resolveApiKey reads it fourth, after REDROB_API_KEY, the Credential store and auth.json, so the dynamic catalogue is served to a launcher-only sign-in instead of the six-id fallback. - The V1 provider list merges the redrob provider with source "launcher" and the key when nothing of the app's own is present (no env, no auth.json entry, no config apiKey), and the redrob loader counts it as a credential so the "public" placeholder is not set. GET /provider therefore reports the redrob entry with source: "launcher" and a resolved key, which is what a host app reads to show "Signed in with Redrob Launcher". The Info schema and the generated SDK/plugin types gain the literal. DELETE /auth/redrob still removes only the engine's auth.json entry; the next read falls back to the shared file, which stays untouched. Both test preloads point REDROB_SHARED_DIR at a scratch directory: the core "no key" ModelsDev tests failed on this machine for the same reason the preload already isolates XDG_DATA_HOME, a real launcher sign-in in ~/.config. New tests cover no key anywhere, shared only, env wins, auth.json wins, malformed/empty files, and a file removed between two resolves. --- packages/core/src/console-key.ts | 51 ++++- packages/core/src/models-dev.ts | 12 +- packages/core/src/shared-account.ts | 94 ++++++++++ packages/core/test/console-key.test.ts | 177 ++++++++++++++++++ packages/core/test/preload.ts | 9 + packages/plugin/src/index.ts | 2 +- packages/redrob/src/provider/provider.ts | 26 ++- packages/redrob/test/preload.ts | 4 + .../redrob/test/provider/provider.test.ts | 118 ++++++++++++ packages/sdk/js/src/gen/types.gen.ts | 2 +- packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- 11 files changed, 481 insertions(+), 16 deletions(-) create mode 100644 packages/core/src/shared-account.ts create mode 100644 packages/core/test/console-key.test.ts diff --git a/packages/core/src/console-key.ts b/packages/core/src/console-key.ts index 1b542b5dfe..02adc134fb 100644 --- a/packages/core/src/console-key.ts +++ b/packages/core/src/console-key.ts @@ -5,20 +5,24 @@ import { Effect } from "effect" import { Global } from "./global" import { Integration } from "./integration" +import { SharedAccount } from "./shared-account" /** * The console API key, from wherever the user actually put it. * - * THREE ORIGINS, and they are not interchangeable historically: + * FOUR ORIGINS, and they are not interchangeable historically: * * - `REDROB_API_KEY` is what a terminal user exports. * - The Credential store is what `redrob providers login` writes. It is SQL. * - `auth.json` is what `PUT /auth/redrob` writes, which is the route the DESKTOP APP uses for both of * its connect paths -- the pasted key and "Connect Redrob". That flow ends by handing the key to this * same route, so the app never populates the Credential store at all. + * - Redrob Launcher's shared `account.json` is what the launcher writes when it signs the whole computer + * in. It is read-only here and it is the LAST fallback: any key of our own wins over it, and deleting + * `auth.json` leaves it alone, so a disconnect falls back to it rather than deleting it. * - * Nothing bridges the last two: one is SQL, the other a file, and no migration copies either into the - * other. So every consumer has to read all three. + * Nothing bridges the middle two: one is SQL, the other a file, and no migration copies either into the + * other. So every consumer has to read all four. * * This lives in its own module because reading only some of them is a bug that has already shipped once: * the dynamic model catalogue checked the environment and the Credential store, so a user who connected @@ -47,15 +51,31 @@ function fromAuthStore(): Effect.Effect { } /** - * Resolve the key, in the order a user would expect to win. + * Where a resolved key came from. These are the `source` values the provider report exposes for the + * `redrob` provider, so a host app can say "Signed in with Redrob Launcher" without ever seeing the key: + * `env` is `REDROB_API_KEY`, `credential` the SQL store, `api` the engine's own `auth.json`, and + * `launcher` the shared file. + */ +export type Source = "env" | "credential" | "api" | "launcher" + +export interface Resolved { + readonly key: string + readonly source: Source +} + +const found = (key: string, source: Source): Resolved => ({ key, source }) + +/** + * Resolve the key and say where it came from, in the order a user would expect to win. * * The environment first, because an explicitly exported value is the one a person set most recently and * most deliberately -- overriding a stored credential for one run is a thing people do, and the reverse - * would make that impossible. + * would make that impossible. The launcher's shared file last, because it is not ours: it is the sign-in + * for the whole computer, and anything this app holds itself is the more specific choice. */ -export const resolve = Effect.fn("ConsoleKey.resolve")(function* () { +export const resolveWithSource = Effect.fn("ConsoleKey.resolveWithSource")(function* () { const fromEnv = process.env["REDROB_API_KEY"] - if (fromEnv) return fromEnv + if (fromEnv) return found(fromEnv, "env") /* Read through `Integration`, not `Credential` directly. Both reach the same store, but `Credential` is not @@ -69,8 +89,21 @@ export const resolve = Effect.fn("ConsoleKey.resolve")(function* () { const value = yield* integration.connection .resolve(connection) .pipe(Effect.orElseSucceed(() => undefined)) - if (value?.type === "key" && value.key.trim()) return value.key + if (value?.type === "key" && value.key.trim()) return found(value.key, "credential") } - return yield* fromAuthStore() + const fromFile = yield* fromAuthStore() + if (fromFile) return found(fromFile, "api") + + // Read fresh every time, per the contract: the person may sign in or out of the launcher while we run. + const shared = yield* Effect.promise(() => SharedAccount.apiKey()) + if (shared) return found(shared, "launcher") + + return undefined +}) + +/** The key alone, for callers that only need to spend it. */ +export const resolve = Effect.fn("ConsoleKey.resolve")(function* () { + const resolved = yield* resolveWithSource() + return resolved?.key }) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 4a7b17d587..5068ca2d0b 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -8,6 +8,7 @@ import { InstallationChannel, InstallationVersion } from "./installation/version import { Credential } from "./credential" import { EventV2 } from "./event" import { Integration } from "./integration" +import { SharedAccount } from "./shared-account" import { makeGlobalNode } from "./effect/app-node" import { httpClient } from "./effect/app-node-platform" import { @@ -505,7 +506,7 @@ const layer = Layer.effect( /** * The console key, from wherever the user actually put it. * - * Three origins, and they are not interchangeable historically. `REDROB_API_KEY` is the one a + * Four origins, and they are not interchangeable historically. `REDROB_API_KEY` is the one a * terminal user exports. The Credential store is what `redrob providers login` writes. And * `auth.json` is what `PUT /auth/redrob` writes, which is the route the DESKTOP APP uses for both * of its connect paths, the pasted key and "Connect Redrob" - that flow ends by handing the key to @@ -520,6 +521,9 @@ const layer = Layer.effect( * Note the shapes differ as well as the locations: the Credential store discriminates on * `type: "key"`, auth.json on `type: "api"`. Matching only one of those spellings was the second * half of the same bug. + * + * The fourth is Redrob Launcher's shared `account.json`, the sign-in for the whole computer. It is + * read-only and it is last: anything this app holds itself wins over it. */ const resolveApiKey = Effect.fn("ModelsDev.resolveApiKey")(function* () { const fromEnv = process.env["REDROB_API_KEY"] @@ -527,7 +531,11 @@ const layer = Layer.effect( const stored = yield* credential.list(Integration.ID.make("redrob")) const fromCredential = stored.flatMap((item) => (item.value.type === "key" ? [item.value.key] : []))[0] if (fromCredential) return fromCredential - return yield* authStoreApiKey() + const fromFile = yield* authStoreApiKey() + if (fromFile) return fromFile + // Fourth and last: Redrob Launcher's shared sign-in for the whole computer. Read fresh on every + // call, never cached, and only when nothing of our own is present (see shared-account.ts). + return yield* Effect.promise(() => SharedAccount.apiKey()) }) const populate = Effect.gen(function* () { diff --git a/packages/core/src/shared-account.ts b/packages/core/src/shared-account.ts new file mode 100644 index 0000000000..ce7821557d --- /dev/null +++ b/packages/core/src/shared-account.ts @@ -0,0 +1,94 @@ +export * as SharedAccount from "./shared-account" + +import os from "node:os" +import path from "node:path" +import fs from "node:fs/promises" + +/** + * Redrob Launcher's shared sign-in, read as this app's LAST fallback for the console key. + * + * The launcher signs a computer in to a workspace once and writes the key to one file every Redrob app + * may read. The contract is redrob-launcher `docs/shared-account.md`; the rules a reader follows: + * + * 1. Read, never write. Nothing here creates, rewrites or deletes the file. `DELETE /auth/redrob` removes + * the engine's own `auth.json` entry and nothing else, after which the shared file is the fallback again. + * 2. Read it when it is needed, not once at startup. The person may sign in or out of the launcher while + * the engine runs, so there is no cache here: every call hits the disk. + * 3. A missing file, unreadable JSON, a non-object, or an empty `apiKey` all mean "not signed in". + * 4. The key is never logged. Only `accountName` and `apiKeyName` are for showing to a person. + * 5. The app's own key wins. `REDROB_API_KEY`, the Credential store and `auth.json` are consulted first by + * every caller; this module is only ever the end of that chain. + */ + +export const FILE = "account.json" + +/** Replaces the directory on every platform when set and not empty (tests, portable setups). */ +export const DIR_ENV = "REDROB_SHARED_DIR" + +export interface Info { + readonly apiKey: string + readonly apiKeyName?: string + readonly accountName?: string + readonly apiBaseUrl?: string +} + +/** Where the launcher writes the file on this platform. Resolved on every call so the env override is live. */ +export function directory(): string { + const override = process.env[DIR_ENV] + if (override && override.trim()) return override + if (process.platform === "win32") { + const appdata = process.env["APPDATA"] || path.join(os.homedir(), "AppData", "Roaming") + return path.join(appdata, "Redrob", "Shared") + } + if (process.platform === "darwin") { + return path.join(os.homedir(), "Library", "Application Support", "ai.redrob.shared") + } + const xdg = process.env["XDG_CONFIG_HOME"] + const base = xdg && xdg.trim() ? xdg : path.join(os.homedir(), ".config") + return path.join(base, "redrob-shared") +} + +export function file(): string { + return path.join(directory(), FILE) +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined +} + +/** + * The file's text as the app's own shape. Every field is `unknown` on the way in because this is the + * boundary: a half-written or hand-edited file is not a key. + */ +export function parse(text: string): Info | undefined { + let raw: unknown + try { + raw = JSON.parse(text) + } catch { + return undefined + } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined + const json = raw as Record + const apiKey = asString(json["apiKey"]) + if (!apiKey) return undefined + return { + apiKey, + apiKeyName: asString(json["apiKeyName"]), + accountName: asString(json["accountName"]), + apiBaseUrl: asString(json["apiBaseUrl"]), + } +} + +/** The launcher's sign-in right now; `undefined` when there is none. Never throws. */ +export async function read(): Promise { + try { + return parse(await fs.readFile(file(), "utf8")) + } catch { + return undefined + } +} + +/** The key alone, for callers that only need to spend it. */ +export async function apiKey(): Promise { + return (await read())?.apiKey +} diff --git a/packages/core/test/console-key.test.ts b/packages/core/test/console-key.test.ts new file mode 100644 index 0000000000..17b86b0851 --- /dev/null +++ b/packages/core/test/console-key.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { Effect } from "effect" +import { Credential } from "@redrob-code/core/credential" +import { AppNodeBuilder } from "@redrob-code/core/effect/app-node-builder" +import { LayerNode } from "@redrob-code/core/effect/layer-node" +import { EventV2 } from "@redrob-code/core/event" +import { Global } from "@redrob-code/core/global" +import { Integration } from "@redrob-code/core/integration" +import { ConsoleKey } from "@redrob-code/core/console-key" +import { SharedAccount } from "@redrob-code/core/shared-account" +import { testEffect } from "./lib/effect" + +/** + * Redrob Launcher's shared `account.json` as the LAST fallback for the console key. + * + * Everything here points `REDROB_SHARED_DIR` at a scratch directory, so no test ever reads or writes + * the real `~/.config/redrob-shared`. `auth.json` lives under `Global.Path.data`, which the preload has + * already moved into a temp directory for the same reason. + */ + +const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node]))) + +const authPath = path.join(Global.Path.data, "auth.json") + +let sharedDir: string +let savedEnvKey: string | undefined +let savedSharedDir: string | undefined + +const sharedFile = () => path.join(sharedDir, SharedAccount.FILE) + +const writeShared = (body: unknown) => + fs.writeFile(sharedFile(), typeof body === "string" ? body : JSON.stringify(body)) + +const launcherAccount = { + apiKey: "rk_from_launcher", + apiKeyName: "Redrob Launcher", + accountName: "Acme", + apiBaseUrl: "https://console.redrob.ai/api/backend/v1", +} + +beforeEach(async () => { + sharedDir = await fs.mkdtemp(path.join(os.tmpdir(), "redrob-shared-test-")) + savedEnvKey = process.env["REDROB_API_KEY"] + savedSharedDir = process.env[SharedAccount.DIR_ENV] + delete process.env["REDROB_API_KEY"] + process.env[SharedAccount.DIR_ENV] = sharedDir + await fs.rm(authPath, { force: true }) +}) + +afterEach(async () => { + if (savedEnvKey === undefined) delete process.env["REDROB_API_KEY"] + else process.env["REDROB_API_KEY"] = savedEnvKey + if (savedSharedDir === undefined) delete process.env[SharedAccount.DIR_ENV] + else process.env[SharedAccount.DIR_ENV] = savedSharedDir + await fs.rm(authPath, { force: true }) + await fs.rm(sharedDir, { recursive: true, force: true }) +}) + +describe("SharedAccount", () => { + test("REDROB_SHARED_DIR replaces the directory, and the file name stays account.json", () => { + expect(SharedAccount.directory()).toBe(sharedDir) + expect(SharedAccount.file()).toBe(path.join(sharedDir, "account.json")) + }) + + test("a blank REDROB_SHARED_DIR is not an override", () => { + process.env[SharedAccount.DIR_ENV] = " " + expect(SharedAccount.directory()).not.toBe(" ") + expect(SharedAccount.directory().endsWith(process.platform === "win32" ? "Shared" : "redrob-shared")).toBe(true) + }) + + test("parses the launcher's shape and drops blanks", () => { + expect(SharedAccount.parse(JSON.stringify(launcherAccount))).toEqual(launcherAccount) + expect(SharedAccount.parse(JSON.stringify({ apiKey: "rk_x", accountName: " " }))).toEqual({ + apiKey: "rk_x", + apiKeyName: undefined, + accountName: undefined, + apiBaseUrl: undefined, + }) + }) + + test("an empty apiKey, a non-object, or unreadable JSON all mean not signed in", () => { + expect(SharedAccount.parse(JSON.stringify({ apiKey: "" }))).toBeUndefined() + expect(SharedAccount.parse(JSON.stringify({ apiKey: " " }))).toBeUndefined() + expect(SharedAccount.parse(JSON.stringify({ apiKeyName: "Redrob Launcher" }))).toBeUndefined() + expect(SharedAccount.parse(JSON.stringify([launcherAccount]))).toBeUndefined() + expect(SharedAccount.parse("null")).toBeUndefined() + expect(SharedAccount.parse('"rk_not_an_object"')).toBeUndefined() + expect(SharedAccount.parse("{ not json")).toBeUndefined() + expect(SharedAccount.parse("")).toBeUndefined() + }) + + test("a missing file reads as not signed in rather than an error", async () => { + expect(await SharedAccount.read()).toBeUndefined() + expect(await SharedAccount.apiKey()).toBeUndefined() + }) + + test("reads the file fresh every time: a launcher sign-out is seen on the next read", async () => { + await writeShared(launcherAccount) + expect(await SharedAccount.apiKey()).toBe("rk_from_launcher") + await fs.rm(sharedFile()) + expect(await SharedAccount.apiKey()).toBeUndefined() + await writeShared({ ...launcherAccount, apiKey: "rk_second_sign_in" }) + expect(await SharedAccount.apiKey()).toBe("rk_second_sign_in") + }) +}) + +describe("ConsoleKey with the shared account as the last fallback", () => { + it.effect("resolves nothing when no source holds a key", () => + Effect.gen(function* () { + expect(yield* ConsoleKey.resolveWithSource()).toBeUndefined() + expect(yield* ConsoleKey.resolve()).toBeUndefined() + }), + ) + + it.effect("falls back to the launcher's shared file and says so", () => + Effect.gen(function* () { + yield* Effect.promise(() => writeShared(launcherAccount)) + expect(yield* ConsoleKey.resolveWithSource()).toEqual({ key: "rk_from_launcher", source: "launcher" }) + expect(yield* ConsoleKey.resolve()).toBe("rk_from_launcher") + }), + ) + + it.effect("REDROB_API_KEY wins over the shared file", () => + Effect.gen(function* () { + yield* Effect.promise(() => writeShared(launcherAccount)) + process.env["REDROB_API_KEY"] = "rk_from_env" + expect(yield* ConsoleKey.resolveWithSource()).toEqual({ key: "rk_from_env", source: "env" }) + }), + ) + + it.effect("the app's own auth.json wins over the shared file", () => + Effect.gen(function* () { + yield* Effect.promise(() => writeShared(launcherAccount)) + yield* Effect.promise(() => + fs.writeFile(authPath, JSON.stringify({ redrob: { type: "api", key: "rk_from_auth_json" } })), + ) + expect(yield* ConsoleKey.resolveWithSource()).toEqual({ key: "rk_from_auth_json", source: "api" }) + }), + ) + + it.effect("deleting auth.json leaves the shared file alone and falls back to it", () => + Effect.gen(function* () { + yield* Effect.promise(() => writeShared(launcherAccount)) + yield* Effect.promise(() => + fs.writeFile(authPath, JSON.stringify({ redrob: { type: "api", key: "rk_from_auth_json" } })), + ) + expect((yield* ConsoleKey.resolveWithSource())?.source).toBe("api") + // What `DELETE /auth/redrob` does to the engine's own store. The shared file is not ours to touch. + yield* Effect.promise(() => fs.rm(authPath)) + expect(yield* ConsoleKey.resolveWithSource()).toEqual({ key: "rk_from_launcher", source: "launcher" }) + expect(yield* Effect.promise(() => fs.readFile(sharedFile(), "utf8"))).toBe(JSON.stringify(launcherAccount)) + }), + ) + + it.effect("a malformed or empty shared file is not signed in", () => + Effect.gen(function* () { + yield* Effect.promise(() => writeShared("{ half a file")) + expect(yield* ConsoleKey.resolveWithSource()).toBeUndefined() + yield* Effect.promise(() => writeShared({ apiKey: "" })) + expect(yield* ConsoleKey.resolveWithSource()).toBeUndefined() + yield* Effect.promise(() => writeShared("")) + expect(yield* ConsoleKey.resolveWithSource()).toBeUndefined() + }), + ) + + it.effect("a file removed between two resolves is seen as gone: nothing is cached", () => + Effect.gen(function* () { + yield* Effect.promise(() => writeShared(launcherAccount)) + expect((yield* ConsoleKey.resolveWithSource())?.source).toBe("launcher") + yield* Effect.promise(() => fs.rm(sharedFile())) + expect(yield* ConsoleKey.resolveWithSource()).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/preload.ts b/packages/core/test/preload.ts index 6d945b0282..acf921b93e 100644 --- a/packages/core/test/preload.ts +++ b/packages/core/test/preload.ts @@ -23,6 +23,15 @@ import path from "path" const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "redrob-core-test-data-")) process.env.XDG_DATA_HOME = dataDir +/* + The same leak, one source further down the chain. `resolveApiKey()` now ends at Redrob Launcher's shared + `account.json` (`~/.config/redrob-shared`), and a developer whose launcher is signed in would otherwise + hand the "no key" tests a real credential from there. `REDROB_SHARED_DIR` is the override the contract + defines for exactly this, so point it at an empty directory; the suites that exercise the fallback set + it to their own scratch directory per test. +*/ +process.env.REDROB_SHARED_DIR = await fs.mkdtemp(path.join(os.tmpdir(), "redrob-core-test-shared-")) + process.env.REDROB_DB = ":memory:" // Gate the console /models fetch off so core tests stay offline/deterministic; the // ModelsDev.Service then serves the static console fallback catalog (redrob/auto and the rest of diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index a9e479c71b..01d9a2ee70 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -18,7 +18,7 @@ import { type ToolDefinition } from "./tool.js" export * from "./tool.js" export type ProviderContext = { - source: "env" | "config" | "custom" | "api" + source: "env" | "config" | "custom" | "api" | "launcher" info: Provider options: Record } diff --git a/packages/redrob/src/provider/provider.ts b/packages/redrob/src/provider/provider.ts index a9b9a8e013..65eeedf393 100644 --- a/packages/redrob/src/provider/provider.ts +++ b/packages/redrob/src/provider/provider.ts @@ -17,6 +17,7 @@ import { Env } from "../env" import { InstallationVersion } from "@redrob-code/core/installation/version" import { iife } from "@/util/iife" import { Global } from "@redrob-code/core/global" +import { SharedAccount } from "@redrob-code/core/shared-account" import path from "path" import { pathToFileURL } from "url" import { Effect, Layer, Context, Schema, Types } from "effect" @@ -211,7 +212,11 @@ function custom(dep: CustomDep): Record { const ok = hasKey || Boolean(yield* dep.auth(input.id)) || - Boolean((yield* dep.config()).provider?.["redrob"]?.options?.apiKey) + Boolean((yield* dep.config()).provider?.["redrob"]?.options?.apiKey) || + // Redrob Launcher's shared sign-in is a real credential too: with it present the SDK gets the + // key (merged in by `list` as source `launcher`), not the "public" placeholder a host app reads + // as "not connected". + Boolean(yield* Effect.promise(() => SharedAccount.apiKey())) // Without a credential the SDK still needs a non-empty apiKey to instantiate, so it gets a // placeholder. What used to happen here was that every model priced above 0 was deleted from @@ -1109,7 +1114,12 @@ export type Model = Types.DeepMutable> export const Info = Schema.Struct({ id: ProviderV2.ID, name: Schema.String, - source: Schema.Literals(["env", "config", "custom", "api"]), + /** + * Where the provider's credential came from. `launcher` is Redrob Launcher's shared `account.json`, + * the sign-in for the whole computer, used only when nothing of this app's own is present; a host app + * reads it off `GET /provider` to show "Signed in with Redrob Launcher". + */ + source: Schema.Literals(["env", "config", "custom", "api", "launcher"]), env: Schema.Array(Schema.String), key: optional(Schema.String), options: Schema.Record(Schema.String, Schema.Any), @@ -1745,6 +1755,18 @@ const layer = Layer.effect( } } + // load Redrob Launcher's shared account - the LAST fallback for the console key. Only when this + // app holds nothing of its own: no REDROB_API_KEY, no auth.json entry, no config apiKey. Read + // fresh on every list(), never cached, so a launcher sign-in or sign-out lands on the next call. + { + const providerID = ProviderV2.ID.redrob + const own = providers[providerID]?.key || cfg.provider?.["redrob"]?.options?.apiKey + if (!disabled.has(providerID) && !own) { + const shared = yield* Effect.promise(() => SharedAccount.apiKey()) + if (shared) mergeProvider(providerID, { source: "launcher", key: shared }) + } + } + // plugin auth loader - database now has entries for config providers for (const plugin of plugins) { if (!plugin.auth) continue diff --git a/packages/redrob/test/preload.ts b/packages/redrob/test/preload.ts index 7aeb7ad295..741ce813f3 100644 --- a/packages/redrob/test/preload.ts +++ b/packages/redrob/test/preload.ts @@ -35,6 +35,10 @@ process.env["XDG_DATA_HOME"] = path.join(dir, "share") process.env["XDG_CACHE_HOME"] = path.join(dir, "cache") process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") process.env["XDG_STATE_HOME"] = path.join(dir, "state") +// Redrob Launcher's shared account.json is the console key's last fallback. XDG_CONFIG_HOME above already +// moves it on Linux; the override moves it on every platform, so a signed-in launcher on a developer's +// machine never satisfies a "no credential" branch here. +process.env["REDROB_SHARED_DIR"] = path.join(dir, "shared") process.env["REDROB_DISABLE_MODELS_FETCH"] = "true" // Feed the provider tests a realistic multi-provider catalog (with paid redrob models) through // the test-only fixture seam instead of the console /models network fetch. diff --git a/packages/redrob/test/provider/provider.test.ts b/packages/redrob/test/provider/provider.test.ts index dca3d8a558..55f8363426 100644 --- a/packages/redrob/test/provider/provider.test.ts +++ b/packages/redrob/test/provider/provider.test.ts @@ -2173,3 +2173,121 @@ it.effect("redrob loader lists the same models with or without an auth.json cred expect(none).toBe(keyedCount) }).pipe(provideMultiInstance), ) + +/* + Redrob Launcher's shared `account.json` as the LAST fallback for the console key, and how the provider + report says so. A host app reads `GET /provider` and shows "Signed in with Redrob Launcher" from + `source: "launcher"`; it reads `options.apiKey === "public"` as "not connected", so with the shared file + present that placeholder must not be set. Every case points REDROB_SHARED_DIR at a scratch directory. +*/ +const withSharedAccount = (body: unknown) => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const saved = process.env["REDROB_SHARED_DIR"] + yield* Effect.acquireRelease( + Effect.sync(() => { + rememberEnv("REDROB_SHARED_DIR") + process.env["REDROB_SHARED_DIR"] = dir + }), + () => + Effect.sync(() => { + if (saved === undefined) delete process.env["REDROB_SHARED_DIR"] + else process.env["REDROB_SHARED_DIR"] = saved + }), + ) + if (body !== undefined) { + yield* Effect.promise(() => Filesystem.write(path.join(dir, "account.json"), JSON.stringify(body))) + } + return dir + }) + +/** + * Pin the engine's own auth.json to `entry` for the test (`undefined` = absent) and put it back after. + * Files in one bun process share Global.Path.data, and the device-connect suite leaves a redrob entry + * there, which would otherwise win over the shared file and turn these into tests of that leak. + */ +const withOwnAuth = (entry: unknown) => + Effect.gen(function* () { + const authPath = path.join(Global.Path.data, "auth.json") + const original = yield* Effect.promise(() => Filesystem.readText(authPath).catch(() => undefined)) + yield* Effect.acquireRelease( + Effect.promise(async () => { + if (entry === undefined) await unlink(authPath).catch(() => undefined) + else await Filesystem.write(authPath, JSON.stringify({ redrob: entry })) + }), + () => + Effect.promise(async () => { + if (original !== undefined) await Filesystem.write(authPath, original) + else await unlink(authPath).catch(() => undefined) + }), + ) + return authPath + }) + +const launcherAccount = { + apiKey: "rk_from_launcher", + apiKeyName: "Redrob Launcher", + accountName: "Acme", + apiBaseUrl: "https://console.redrob.ai/api/backend/v1", +} + +const listIn = (directory: string) => + Provider.use + .list() + .pipe(provideInstanceEffect(directory)) + .pipe(Effect.provide(instanceStoreLayer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))) + +it.effect("redrob provider reports source launcher when only the shared file holds a key", () => + Effect.gen(function* () { + yield* withOwnAuth(undefined) + yield* withSharedAccount(launcherAccount) + const providers = yield* listIn(yield* tmpdirScoped()) + const redrob = providers[ProviderV2.ID.redrob] + expect(redrob).toBeDefined() + expect(redrob.source).toBe("launcher") + expect(redrob.key).toBe("rk_from_launcher") + expect(redrob.options.apiKey).toBeUndefined() + }).pipe(provideMultiInstance), +) + +it.effect("redrob provider stays keyless with the public placeholder when the shared file is absent or empty", () => + Effect.gen(function* () { + yield* withOwnAuth(undefined) + yield* withSharedAccount({ apiKey: "" }) + const providers = yield* listIn(yield* tmpdirScoped()) + const redrob = providers[ProviderV2.ID.redrob] + expect(redrob).toBeDefined() + expect(redrob.source).toBe("custom") + expect(redrob.key).toBeUndefined() + expect(redrob.options.apiKey).toBe("public") + }).pipe(provideMultiInstance), +) + +it.effect("REDROB_API_KEY wins over the shared file in the provider report", () => + Effect.gen(function* () { + yield* withOwnAuth(undefined) + yield* withSharedAccount(launcherAccount) + yield* setProcessEnv("REDROB_API_KEY", "rk_from_env") + const providers = yield* listIn(yield* tmpdirScoped()) + const redrob = providers[ProviderV2.ID.redrob] + expect(redrob.source).toBe("env") + expect(redrob.key).toBe("rk_from_env") + }).pipe(provideMultiInstance), +) + +it.effect("the app's own auth.json wins over the shared file, and removing it falls back to the launcher", () => + Effect.gen(function* () { + yield* withSharedAccount(launcherAccount) + const authPath = yield* withOwnAuth({ type: "api", key: "rk_from_auth_json" }) + + const own = (yield* listIn(yield* tmpdirScoped()))[ProviderV2.ID.redrob] + expect(own.source).toBe("api") + expect(own.key).toBe("rk_from_auth_json") + + // What `DELETE /auth/redrob` does. The shared file is not touched, so the next list() falls back to it. + yield* Effect.promise(() => unlink(authPath)) + const fallback = (yield* listIn(yield* tmpdirScoped()))[ProviderV2.ID.redrob] + expect(fallback.source).toBe("launcher") + expect(fallback.key).toBe("rk_from_launcher") + }).pipe(provideMultiInstance), +) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 2222044e00..3ea6999154 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1514,7 +1514,7 @@ export type Model = { export type Provider = { id: string name: string - source: "env" | "config" | "custom" | "api" + source: "env" | "config" | "custom" | "api" | "launcher" env: Array key?: string options: { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index cf3c1f128b..589ba83663 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2208,7 +2208,7 @@ export type Model = { export type Provider = { id: string name: string - source: "env" | "config" | "custom" | "api" + source: "env" | "config" | "custom" | "api" | "launcher" env: Array key?: string options: {