Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 42 additions & 9 deletions packages/core/src/console-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -47,15 +51,31 @@ function fromAuthStore(): Effect.Effect<string | undefined> {
}

/**
* 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
Expand All @@ -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
})
12 changes: 10 additions & 2 deletions packages/core/src/models-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -520,14 +521,21 @@ 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"]
if (fromEnv) return fromEnv
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* () {
Expand Down
94 changes: 94 additions & 0 deletions packages/core/src/shared-account.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
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<Info | undefined> {
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<string | undefined> {
return (await read())?.apiKey
}
Loading
Loading