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: {