mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* fix(daemon): confine sandbox project and host discovery * fix(daemon): resolve sandbox data dir for toolchain discovery * fix(daemon): resolve sandbox data dir for agent env * fix(daemon): fail fast for sandbox imported folders * test(daemon): assert sandbox imported folder rejection * fix(daemon): keep sandbox import guard at run start * fix(daemon): reject sandbox imported project file roots * fix(daemon): preserve imported project detail roots * test(daemon): expect sandbox profiles to stay scoped * fix(daemon): bypass proxies for agent tool callbacks * test(daemon): isolate media policy route memory extraction * fix(daemon): keep loopback no-proxy scoped to sandbox
541 lines
21 KiB
TypeScript
541 lines
21 KiB
TypeScript
// Per-provider credentials for the media dispatcher.
|
|
//
|
|
// The frontend Settings dialog pushes API keys here via PUT
|
|
// /api/media/config; the daemon persists them to .od/media-config.json
|
|
// and reads them at generation time. Environment variables override the
|
|
// stored values so power users can keep keys out of the workspace
|
|
// folder altogether (`OD_OPENAI_API_KEY=… node daemon/cli.js`).
|
|
//
|
|
// Storage location (precedence high → low):
|
|
// 1. OD_MEDIA_CONFIG_DIR=DIR → <DIR>/media-config.json
|
|
// 2. OD_DATA_DIR=DIR → <DIR>/media-config.json
|
|
// 3. (default) → <projectRoot>/.od/media-config.json
|
|
// The default is unchanged for workspace-local installs. (1) lets a
|
|
// supervisor relocate just the credentials file. (2) means installs
|
|
// that already set OD_DATA_DIR for the rest of the daemon's runtime
|
|
// state (Nix-store / immutable-image installs, the packaged daemon at
|
|
// apps/packaged/src/sidecars.ts:createPackagedDaemonManagedPathEnv,
|
|
// the Home Manager / NixOS modules) get media-config there too without
|
|
// any extra plumbing. Both env values are resolved with the same
|
|
// semantics as OD_DATA_DIR in server.ts:resolveDataDir(): the shared
|
|
// expandHomePrefix() helper handles `~`, `$HOME`, and `${HOME}` (with
|
|
// either `/` or `\` separator), then relative paths anchor to
|
|
// <projectRoot> (NOT process.cwd, which is unrelated to the workspace
|
|
// when systemd or launchd starts the daemon).
|
|
//
|
|
// Migration note: a workspace install that sets a custom OD_DATA_DIR
|
|
// AND has a pre-existing `<projectRoot>/.od/media-config.json` will
|
|
// start reading from `<OD_DATA_DIR>/media-config.json` instead. Move
|
|
// the file once or set OD_MEDIA_CONFIG_DIR=<projectRoot>/.od to keep
|
|
// the old location.
|
|
//
|
|
// The file is intentionally simple JSON — no encryption, no schema
|
|
// versioning yet. The daemon listens on 127.0.0.1 only and the workspace
|
|
// is already trusted, so adding a vault here would mostly be theatre.
|
|
// We DO mask keys when reading via the GET endpoint so the UI doesn't
|
|
// echo secrets back into the DOM.
|
|
|
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { MEDIA_PROVIDERS } from './media-models.js';
|
|
import { expandHomePrefix } from './home-expansion.js';
|
|
import { resolveXAIBearer } from './xai-credentials.js';
|
|
import { isSandboxModeEnabled } from './sandbox-mode.js';
|
|
|
|
const PROVIDER_IDS = MEDIA_PROVIDERS.map((p) => p.id);
|
|
type ProviderEntry = { apiKey?: string; baseUrl?: string; model?: string };
|
|
type ProviderMap = Record<string, ProviderEntry>;
|
|
type ModelAliasMap = Record<string, string>;
|
|
type JsonRecord = Record<string, unknown>;
|
|
type OAuthCredential = { apiKey: string; source: string };
|
|
|
|
// Single env var carries the full alias map as JSON so we don't have
|
|
// to dynamically lift `OD_MEDIA_MODEL_ALIAS_<id>=value` into a record
|
|
// with all the env-var-name escaping that entails (Windows cmd.exe in
|
|
// particular rejects hyphens). The shape mirrors the on-disk
|
|
// `aliases` map so users can switch storage layers without rewriting
|
|
// their workflow:
|
|
//
|
|
// OD_MEDIA_MODEL_ALIASES='{"doubao-seedream-3-0-t2i-250415":"doubao-seedream-5-0"}'
|
|
const ENV_MODEL_ALIASES = 'OD_MEDIA_MODEL_ALIASES';
|
|
|
|
function isRecord(value: unknown): value is JsonRecord {
|
|
return value !== null && typeof value === 'object';
|
|
}
|
|
|
|
function errorCode(err: unknown): string | undefined {
|
|
return isRecord(err) && typeof err.code === 'string' ? err.code : undefined;
|
|
}
|
|
|
|
const ENV_KEYS: Record<string, string[]> = {
|
|
// OPENAI_API_KEY is the canonical env for the standard OpenAI API.
|
|
// AZURE_API_KEY / AZURE_OPENAI_API_KEY are the canonical envs Azure
|
|
// OpenAI examples use — we share the openai provider slot so a user
|
|
// who pastes an Azure deployment URL into the OpenAI Base URL field
|
|
// gets the credential picked up automatically.
|
|
openai: [
|
|
'OD_OPENAI_API_KEY',
|
|
'OPENAI_API_KEY',
|
|
'AZURE_API_KEY',
|
|
'AZURE_OPENAI_API_KEY',
|
|
],
|
|
volcengine: ['OD_VOLCENGINE_API_KEY', 'ARK_API_KEY', 'VOLCENGINE_API_KEY'],
|
|
// OD_GROK_API_KEY first (the project-reserved override, same shape as
|
|
// every other provider above), then XAI_API_KEY as the canonical
|
|
// upstream env per docs.x.ai quickstart — so users who already export
|
|
// it for the official SDK don't have to re-paste into Settings.
|
|
grok: ['OD_GROK_API_KEY', 'XAI_API_KEY'],
|
|
nanobanana: ['OD_NANOBANANA_API_KEY', 'GOOGLE_API_KEY', 'GEMINI_API_KEY'],
|
|
imagerouter: ['OD_IMAGEROUTER_API_KEY', 'IMAGEROUTER_API_KEY'],
|
|
'custom-image': ['OD_CUSTOM_IMAGE_API_KEY', 'CUSTOM_IMAGE_API_KEY'],
|
|
bfl: ['OD_BFL_API_KEY', 'BFL_API_KEY'],
|
|
fal: ['OD_FAL_KEY', 'FAL_KEY'],
|
|
replicate: ['OD_REPLICATE_API_TOKEN', 'REPLICATE_API_TOKEN'],
|
|
google: ['OD_GOOGLE_API_KEY', 'GOOGLE_API_KEY', 'GEMINI_API_KEY'],
|
|
kling: ['OD_KLING_API_KEY', 'KLING_API_KEY'],
|
|
midjourney: ['OD_MIDJOURNEY_API_KEY'],
|
|
minimax: ['OD_MINIMAX_API_KEY', 'MINIMAX_API_KEY'],
|
|
suno: ['OD_SUNO_API_KEY'],
|
|
udio: ['OD_UDIO_API_KEY'],
|
|
elevenlabs: ['OD_ELEVENLABS_API_KEY', 'ELEVENLABS_API_KEY'],
|
|
fishaudio: ['OD_FISHAUDIO_API_KEY', 'FISH_AUDIO_API_KEY'],
|
|
senseaudio: ['OD_SENSEAUDIO_API_KEY', 'SENSEAUDIO_API_KEY'],
|
|
tavily: ['OD_TAVILY_API_KEY', 'TAVILY_API_KEY'],
|
|
leonardo: ['OD_LEONARDO_API_KEY', 'LEONARDO_API_KEY'],
|
|
};
|
|
|
|
// Resolve an `OD_*_DIR` env override using the same semantics as
|
|
// `resolveDataDir()` in server.ts: expandHomePrefix() handles the `~`,
|
|
// `$HOME`, and `${HOME}` shorthands (with either `/` or `\` separator),
|
|
// then relative paths anchor to <projectRoot>, not process.cwd, since
|
|
// the daemon is often launched from a directory that has nothing to do
|
|
// with the workspace, e.g. systemd's `/`. The writability check that
|
|
// resolveDataDir does on startup is intentionally NOT replicated here:
|
|
// configFile() is on the read path and a missing/unwritable directory
|
|
// is a normal "no config yet" condition handled by readStored(); the
|
|
// write path's mkdir(recursive) creates the directory on first use.
|
|
function resolveOverrideDir(raw: string, projectRoot: string): string {
|
|
// Share expandHomePrefix with resolveDataDir (server.ts) so OD_DATA_DIR
|
|
// and OD_MEDIA_CONFIG_DIR cannot split state under a $HOME-style value.
|
|
// A launcher passing OD_DATA_DIR=$HOME/.open-design without a shell to
|
|
// expand it would otherwise route SQLite/projects/artifacts to the
|
|
// expanded path while media-config.json stayed under
|
|
// <projectRoot>/$HOME/.open-design, leaving stored credentials
|
|
// unreachable on the next read.
|
|
const expanded = expandHomePrefix(raw);
|
|
return path.isAbsolute(expanded)
|
|
? expanded
|
|
: path.resolve(projectRoot, expanded);
|
|
}
|
|
|
|
function envOverrideDir(envName: string, projectRoot: string): string | null {
|
|
const raw = process.env[envName];
|
|
if (typeof raw !== 'string') return null;
|
|
const trimmed = raw.trim();
|
|
return trimmed ? resolveOverrideDir(trimmed, projectRoot) : null;
|
|
}
|
|
|
|
/**
|
|
* Resolve the directory media-config.json (and credentials living next to
|
|
* it, like xai-tokens.json) actually live in. Precedence: explicit
|
|
* media-config override > general data dir > default.
|
|
*/
|
|
export function mediaConfigDir(projectRoot: string): string {
|
|
return (
|
|
envOverrideDir('OD_MEDIA_CONFIG_DIR', projectRoot)
|
|
?? envOverrideDir('OD_DATA_DIR', projectRoot)
|
|
?? path.join(projectRoot, '.od')
|
|
);
|
|
}
|
|
|
|
function configFile(projectRoot: string): string {
|
|
return path.join(mediaConfigDir(projectRoot), 'media-config.json');
|
|
}
|
|
|
|
/**
|
|
* Normalise an arbitrary unknown into a string-to-string map, dropping
|
|
* keys that have empty / non-string values. Shared by the env-var
|
|
* parser and the on-disk reader so both layers reject malformed
|
|
* entries the same way.
|
|
*/
|
|
function coerceAliasMap(raw: unknown): ModelAliasMap {
|
|
if (!isRecord(raw)) return {};
|
|
const out: ModelAliasMap = {};
|
|
for (const [k, v] of Object.entries(raw)) {
|
|
if (typeof k !== 'string' || !k.trim()) continue;
|
|
if (typeof v !== 'string' || !v.trim()) continue;
|
|
out[k.trim()] = v.trim();
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function readStoredFile(projectRoot: string): Promise<JsonRecord> {
|
|
try {
|
|
const raw = await readFile(configFile(projectRoot), 'utf8');
|
|
const parsed = JSON.parse(raw);
|
|
return isRecord(parsed) ? parsed : {};
|
|
} catch (err) {
|
|
if (errorCode(err) === 'ENOENT') return {};
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function readStored(projectRoot: string): Promise<ProviderMap> {
|
|
const parsed = await readStoredFile(projectRoot);
|
|
return isRecord(parsed.providers) ? (parsed.providers as ProviderMap) : {};
|
|
}
|
|
|
|
async function readStoredAliases(projectRoot: string): Promise<ModelAliasMap> {
|
|
const parsed = await readStoredFile(projectRoot);
|
|
return coerceAliasMap(parsed.aliases);
|
|
}
|
|
|
|
async function writeStored(
|
|
projectRoot: string,
|
|
providers: ProviderMap,
|
|
aliases?: ModelAliasMap,
|
|
): Promise<void> {
|
|
const file = configFile(projectRoot);
|
|
await mkdir(path.dirname(file), { recursive: true });
|
|
// Preserve any existing aliases when the caller doesn't pass them.
|
|
// The Settings UI writes providers only; without this, every
|
|
// provider edit would silently wipe the user's model aliases (issue
|
|
// #1277 introduces aliases but the Settings UI surface for editing
|
|
// them lands in a follow-up PR).
|
|
const resolvedAliases = aliases ?? (await readStoredAliases(projectRoot));
|
|
const body: JsonRecord = { providers };
|
|
if (Object.keys(resolvedAliases).length > 0) {
|
|
body.aliases = resolvedAliases;
|
|
}
|
|
await writeFile(file, JSON.stringify(body, null, 2), 'utf8');
|
|
}
|
|
|
|
function readEnvAliases(): ModelAliasMap {
|
|
const raw = process.env[ENV_MODEL_ALIASES];
|
|
if (typeof raw !== 'string' || !raw.trim()) return {};
|
|
try {
|
|
return coerceAliasMap(JSON.parse(raw));
|
|
} catch {
|
|
// Malformed JSON is non-fatal — the user can fix the env var
|
|
// without restarting the daemon mid-generation, and silent fall-
|
|
// through to the on-disk map matches the precedent of the rest
|
|
// of the env / stored config resolution in this module.
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve a registered model id to the wire-name the provider should
|
|
* actually receive on the network. Env wins over stored, mirroring
|
|
* the precedence the rest of media-config uses for `apiKey` (issue
|
|
* #1277). Pass-through when no alias is configured.
|
|
*/
|
|
export async function resolveModelAlias(
|
|
projectRoot: string,
|
|
modelId: string,
|
|
): Promise<string> {
|
|
const envAliases = readEnvAliases();
|
|
if (envAliases[modelId]) return envAliases[modelId]!;
|
|
const stored = await readStoredAliases(projectRoot);
|
|
return stored[modelId] ?? modelId;
|
|
}
|
|
|
|
/**
|
|
* Read the merged alias map (env + stored). Exposed for the
|
|
* `/api/media/config` GET endpoint so the Settings UI can display
|
|
* which aliases are active and where they came from.
|
|
*/
|
|
export async function readAliasMap(
|
|
projectRoot: string,
|
|
): Promise<{ effective: ModelAliasMap; env: ModelAliasMap; stored: ModelAliasMap }> {
|
|
const env = readEnvAliases();
|
|
const stored = await readStoredAliases(projectRoot);
|
|
const effective: ModelAliasMap = { ...stored, ...env };
|
|
return { effective, env, stored };
|
|
}
|
|
|
|
function readEnvKey(providerId: string): string | null {
|
|
const keys = ENV_KEYS[providerId];
|
|
if (!keys) return null;
|
|
for (const k of keys) {
|
|
const v = process.env[k];
|
|
if (typeof v === 'string' && v.trim()) return v.trim();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function readNestedString(obj: unknown, keys: string[]): string {
|
|
let cur: unknown = obj;
|
|
for (const key of keys) {
|
|
if (!isRecord(cur)) return '';
|
|
cur = cur[key];
|
|
}
|
|
return typeof cur === 'string' && cur.trim() ? cur.trim() : '';
|
|
}
|
|
|
|
async function readJsonIfPresent(file: string): Promise<JsonRecord | null> {
|
|
try {
|
|
const raw = await readFile(file, 'utf8');
|
|
const parsed = JSON.parse(raw);
|
|
return isRecord(parsed) ? parsed : null;
|
|
} catch (err) {
|
|
if (errorCode(err) === 'ENOENT') return null;
|
|
// Auth files are best-effort fallbacks. A malformed local auth cache
|
|
// should not break the Settings page or hide stored provider config.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function apiKeyFromCodexAuth(data: unknown): string {
|
|
return readNestedString(data, ['OPENAI_API_KEY']);
|
|
}
|
|
|
|
async function resolveOpenAIAuthFileCredential(): Promise<OAuthCredential | null> {
|
|
if (isSandboxModeEnabled(process.env)) return null;
|
|
const home = os.homedir();
|
|
const codexAuth = await readJsonIfPresent(
|
|
path.join(home, '.codex', 'auth.json'),
|
|
);
|
|
const apiKey = apiKeyFromCodexAuth(codexAuth);
|
|
if (apiKey) {
|
|
return { apiKey, source: 'codex-auth' };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function resolveXAIOAuthCredential(
|
|
projectRoot: string,
|
|
): Promise<OAuthCredential | null> {
|
|
// 1. OD-native xAI OAuth tokens (written by the daemon's own
|
|
// xai-oauth.ts client when the user authorizes inside OD).
|
|
const odBearer = await resolveXAIBearer(mediaConfigDir(projectRoot)).catch(
|
|
() => null,
|
|
);
|
|
if (odBearer) {
|
|
return {
|
|
apiKey: odBearer.accessToken,
|
|
source: `oauth-xai-${odBearer.source}`,
|
|
};
|
|
}
|
|
|
|
if (isSandboxModeEnabled(process.env)) return null;
|
|
|
|
// 2. Borrow the xAI OAuth token Hermes wrote to ~/.hermes/auth.json
|
|
// when the user ran `hermes auth add xai-oauth`. A user who has already authorized
|
|
// Hermes doesn't have to run a second OAuth dance inside OD.
|
|
// (No proactive refresh here — Hermes itself maintains the token,
|
|
// and we only borrow what is currently fresh.)
|
|
const home = os.homedir();
|
|
const hermesAuth = await readJsonIfPresent(
|
|
path.join(home, '.hermes', 'auth.json'),
|
|
);
|
|
const hermesXaiToken = readNestedString(hermesAuth, [
|
|
'providers',
|
|
'xai-oauth',
|
|
'tokens',
|
|
'access_token',
|
|
]);
|
|
if (hermesXaiToken) {
|
|
return { apiKey: hermesXaiToken, source: 'oauth-hermes-xai' };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Resolve credentials for a provider. Env vars win, then stored config,
|
|
* then provider-specific external credential stores. OpenAI only trusts
|
|
* explicit API keys from Codex auth files; Codex/Hermes OAuth tokens are
|
|
* not valid proof that the Images API can be called.
|
|
* Returns { apiKey, baseUrl } where either may be empty string.
|
|
*/
|
|
export async function resolveProviderConfig(projectRoot: string, providerId: string): Promise<ProviderEntry> {
|
|
const stored = await readStored(projectRoot);
|
|
const entry = stored[providerId] || {};
|
|
const envKey = readEnvKey(providerId);
|
|
const needsExternalCredential = !envKey && !entry.apiKey;
|
|
const externalCredential = needsExternalCredential
|
|
? providerId === 'openai'
|
|
? await resolveOpenAIAuthFileCredential()
|
|
: providerId === 'grok'
|
|
? await resolveXAIOAuthCredential(projectRoot)
|
|
: null
|
|
: null;
|
|
return {
|
|
apiKey: envKey || entry.apiKey || externalCredential?.apiKey || '',
|
|
baseUrl: entry.baseUrl || '',
|
|
...(typeof entry.model === 'string' && entry.model.trim()
|
|
? { model: entry.model.trim() }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Read the full config for the GET endpoint. API keys are masked so the
|
|
* frontend can show "••••" + a "configured" indicator without leaking
|
|
* the secret back into the DOM.
|
|
*/
|
|
export interface MaskedConfigResponse {
|
|
providers: Record<string, { configured: boolean; source: string; apiKeyTail: string; baseUrl: string; model?: string }>;
|
|
/**
|
|
* Effective alias map plus source attribution. The Settings UI can
|
|
* show "from env" vs "from media-config.json" badges next to each
|
|
* entry without needing a second endpoint. Empty maps mean no
|
|
* aliases are configured (issue #1277).
|
|
*/
|
|
aliases: { effective: ModelAliasMap; env: ModelAliasMap; stored: ModelAliasMap };
|
|
}
|
|
|
|
export async function readMaskedConfig(projectRoot: string): Promise<MaskedConfigResponse> {
|
|
const stored = await readStored(projectRoot);
|
|
const providers: MaskedConfigResponse['providers'] = {};
|
|
for (const id of PROVIDER_IDS) {
|
|
const entry = stored[id] || {};
|
|
const envKey = readEnvKey(id);
|
|
const hasStoredKey = typeof entry.apiKey === 'string' && entry.apiKey.length > 0;
|
|
const needsExternalCredential = !envKey && !hasStoredKey;
|
|
const externalCredential = needsExternalCredential
|
|
? id === 'openai'
|
|
? await resolveOpenAIAuthFileCredential()
|
|
: id === 'grok'
|
|
? await resolveXAIOAuthCredential(projectRoot)
|
|
: null
|
|
: null;
|
|
providers[id] = {
|
|
configured: Boolean(envKey || hasStoredKey || externalCredential?.apiKey),
|
|
source: envKey ? 'env' : hasStoredKey ? 'stored' : externalCredential?.source || 'unset',
|
|
// Show last 4 chars only when stored locally; never echo env-var
|
|
// or borrowed auth-file/OAuth secrets so power users don't
|
|
// accidentally see them in the DOM.
|
|
apiKeyTail: hasStoredKey && entry.apiKey ? entry.apiKey.slice(-4) : '',
|
|
baseUrl: entry.baseUrl || '',
|
|
...(typeof entry.model === 'string' && entry.model.trim()
|
|
? { model: entry.model.trim() }
|
|
: {}),
|
|
};
|
|
}
|
|
const aliases = await readAliasMap(projectRoot);
|
|
return { providers, aliases };
|
|
}
|
|
|
|
/**
|
|
* Write the supplied {providerId: {apiKey, baseUrl}} map. Empty
|
|
* apiKey deletes the entry. Unknown provider IDs are ignored. We
|
|
* deliberately replace the whole map rather than merging so the
|
|
* UI's "clear key" affordance just sends an empty string.
|
|
*
|
|
* Safety: if the incoming payload is empty but the on-disk config
|
|
* currently has providers, we log a WARN to stderr. This catches
|
|
* accidental wipes (e.g. a fresh-localStorage browser bootstrap
|
|
* pushing `{providers: {}}` onto a daemon that had keys from a
|
|
* previous session) without silently destroying the user's data.
|
|
*/
|
|
export async function writeConfig(projectRoot: string, body: unknown) {
|
|
const incoming = isRecord(body) && isRecord(body.providers) ? body.providers : {};
|
|
const force = Boolean(isRecord(body) && body.force === true);
|
|
const prior = await readStored(projectRoot);
|
|
const next: ProviderMap = {};
|
|
for (const id of PROVIDER_IDS) {
|
|
const entry = incoming[id];
|
|
if (!isRecord(entry)) continue;
|
|
const incomingApiKey =
|
|
typeof entry.apiKey === 'string' && entry.apiKey.trim()
|
|
? entry.apiKey.trim()
|
|
: '';
|
|
const preserveApiKey = entry.preserveApiKey === true;
|
|
const priorApiKey =
|
|
typeof prior[id]?.apiKey === 'string' && prior[id].apiKey.trim()
|
|
? prior[id].apiKey.trim()
|
|
: '';
|
|
const apiKey = incomingApiKey || (preserveApiKey ? priorApiKey : '');
|
|
const baseUrl =
|
|
typeof entry.baseUrl === 'string' && entry.baseUrl.trim()
|
|
? entry.baseUrl.trim()
|
|
: '';
|
|
const model =
|
|
typeof entry.model === 'string' && entry.model.trim()
|
|
? entry.model.trim()
|
|
: '';
|
|
if (!apiKey && !baseUrl && !model) continue;
|
|
next[id] = {
|
|
apiKey,
|
|
baseUrl,
|
|
...(model ? { model } : {}),
|
|
};
|
|
}
|
|
if (Object.keys(next).length === 0) {
|
|
const priorIds = Object.keys(prior).filter(
|
|
(id) => prior[id] && (prior[id].apiKey || prior[id].baseUrl),
|
|
);
|
|
if (priorIds.length > 0) {
|
|
if (!force) {
|
|
const err = new Error(
|
|
`refusing to wipe ${priorIds.length} configured provider(s) without force=true: ${priorIds.join(', ')}`,
|
|
) as Error & { status: number };
|
|
err.status = 409;
|
|
throw err;
|
|
}
|
|
try {
|
|
console.error(
|
|
`[media-config] WARN: incoming PUT empty, would wipe ${priorIds.length} configured provider(s): ${priorIds.join(', ')}`,
|
|
);
|
|
} catch {
|
|
// best-effort logging only
|
|
}
|
|
}
|
|
}
|
|
await writeStored(projectRoot, next);
|
|
return readMaskedConfig(projectRoot);
|
|
}
|
|
|
|
/**
|
|
* Idempotent "seed if empty" write for a single provider slot. The chat
|
|
* proxy uses this to mirror a BYOK key into media-config so the agent's
|
|
* image / TTS path picks up the same credential without the user having
|
|
* to paste it twice. Strict rules:
|
|
* * No-op when an apiKey is ALREADY stored for `providerId` (the user
|
|
* may have configured Media independently and we never overwrite).
|
|
* * No-op when an env-var key resolves for `providerId` (env wins
|
|
* regardless of disk state — seeding would be invisible).
|
|
* * No-op when the incoming `apiKey` is empty (we only seed values
|
|
* the chat layer has just verified upstream).
|
|
* * Otherwise merge `{ [providerId]: entry }` into the existing
|
|
* provider map and persist. All other provider slots and aliases
|
|
* are preserved byte-for-byte.
|
|
*
|
|
* Returns `true` when a write happened (caller can log), `false` when
|
|
* the call was a no-op. Errors are surfaced — the caller decides
|
|
* whether to swallow them (fire-and-forget) or propagate.
|
|
*/
|
|
export async function seedProviderIfMissing(
|
|
projectRoot: string,
|
|
providerId: string,
|
|
entry: { apiKey?: string; baseUrl?: string; model?: string },
|
|
): Promise<boolean> {
|
|
if (!PROVIDER_IDS.includes(providerId)) return false;
|
|
const apiKey = entry.apiKey?.trim() ?? '';
|
|
if (!apiKey) return false;
|
|
// Env var wins at resolution time, so seeding when env is set would
|
|
// be invisible to the user. Skip to avoid confusing on-disk state.
|
|
if (readEnvKey(providerId)) return false;
|
|
|
|
const prior = await readStored(projectRoot);
|
|
const priorApiKey =
|
|
typeof prior[providerId]?.apiKey === 'string' && prior[providerId].apiKey.trim()
|
|
? prior[providerId].apiKey.trim()
|
|
: '';
|
|
if (priorApiKey) return false;
|
|
|
|
const baseUrl = entry.baseUrl?.trim() ?? '';
|
|
const model = entry.model?.trim() ?? '';
|
|
const next: ProviderMap = { ...prior };
|
|
next[providerId] = {
|
|
apiKey,
|
|
...(baseUrl ? { baseUrl } : {}),
|
|
...(model ? { model } : {}),
|
|
};
|
|
await writeStored(projectRoot, next);
|
|
return true;
|
|
}
|