open-design/apps/daemon/src/media-config.ts
Denis Redozubov f4c5d22f22
fix(daemon): confine sandbox project roots and host discovery (#3243)
* 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
2026-05-30 16:57:04 +00:00

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;
}