open-design/apps/daemon/src/media-config.ts
marco 5dd70b5016
Some checks failed
ci / Validate workspace (push) Successful in 12m32s
landing-page-ci / Validate landing page (push) Successful in 9m41s
landing-page-deploy / Deploy landing page (push) Failing after 5m23s
github-metrics / Generate repository metrics SVG (push) Failing after 2m3s
refresh-contributors-wall / Refresh contributors wall cache bust (push) Failing after 11s
Initial import: open-design source for helix-mind.ai distribution
This repository contains the open-design daemon CLI source code, built
and packaged at https://helix-mind.ai/cli/open-design/latest.tgz for use
by the HelixMind /design slash command.

Licenses: Apache-2.0 (root) + MIT (skills/*)
2026-05-06 20:50:24 +02:00

333 lines
12 KiB
TypeScript

// @ts-nocheck
// 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() — `~/` expands
// to the user's home, and 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 { homedir } from 'node:os';
import path from 'node:path';
import { MEDIA_PROVIDERS } from './media-models.js';
const PROVIDER_IDS = MEDIA_PROVIDERS.map((p) => p.id);
const ENV_KEYS = {
// 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'],
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'],
};
// Resolve an `OD_*_DIR` env override using the same semantics as
// `resolveDataDir()` in server.ts: leading `~/` expands to the user's
// home, and relative paths anchor to <projectRoot> (NOT process.cwd —
// 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, projectRoot) {
const expanded = raw.startsWith('~/')
? path.join(homedir(), raw.slice(2))
: raw;
return path.isAbsolute(expanded)
? expanded
: path.resolve(projectRoot, expanded);
}
function envOverrideDir(envName, projectRoot) {
const raw = process.env[envName];
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
return trimmed ? resolveOverrideDir(trimmed, projectRoot) : null;
}
function configFile(projectRoot) {
// Precedence: explicit media-config override > general data dir > default.
const dir =
envOverrideDir('OD_MEDIA_CONFIG_DIR', projectRoot)
?? envOverrideDir('OD_DATA_DIR', projectRoot)
?? path.join(projectRoot, '.od');
return path.join(dir, 'media-config.json');
}
async function readStored(projectRoot) {
try {
const raw = await readFile(configFile(projectRoot), 'utf8');
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && parsed.providers) {
return parsed.providers;
}
return {};
} catch (err) {
if (err && err.code === 'ENOENT') return {};
throw err;
}
}
async function writeStored(projectRoot, providers) {
const file = configFile(projectRoot);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, JSON.stringify({ providers }, null, 2), 'utf8');
}
function readEnvKey(providerId) {
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, keys) {
let cur = obj;
for (const key of keys) {
if (!cur || typeof cur !== 'object') return '';
cur = cur[key];
}
return typeof cur === 'string' && cur.trim() ? cur.trim() : '';
}
async function readJsonIfPresent(file) {
try {
const raw = await readFile(file, 'utf8');
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch (err) {
if (err && err.code === '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 tokenFromHermesAuth(data) {
const providerToken = readNestedString(data, [
'providers',
'openai-codex',
'tokens',
'access_token',
]);
if (providerToken) return providerToken;
const pool =
data && typeof data === 'object'
? data.credential_pool && data.credential_pool['openai-codex']
: null;
if (Array.isArray(pool)) {
for (const item of pool) {
const token = readNestedString(item, ['access_token']);
if (token) return token;
}
}
return '';
}
function tokenFromCodexAuth(data) {
const oauthToken = readNestedString(data, ['tokens', 'access_token']);
if (oauthToken) return { token: oauthToken, source: 'oauth-codex' };
const apiKey = readNestedString(data, ['OPENAI_API_KEY']);
if (apiKey) return { token: apiKey, source: 'codex-auth' };
return null;
}
async function resolveOpenAIOAuthCredential() {
const home = homedir();
const hermesAuth = await readJsonIfPresent(
path.join(home, '.hermes', 'auth.json'),
);
const hermesToken = tokenFromHermesAuth(hermesAuth);
if (hermesToken) {
return { apiKey: hermesToken, source: 'oauth-hermes' };
}
const codexAuth = await readJsonIfPresent(
path.join(home, '.codex', 'auth.json'),
);
const codexToken = tokenFromCodexAuth(codexAuth);
if (codexToken) {
return { apiKey: codexToken.token, source: codexToken.source };
}
return null;
}
/**
* Resolve credentials for a provider. Env vars win, then stored config,
* then OpenAI/Codex OAuth for the OpenAI media provider.
* Returns { apiKey, baseUrl } where either may be empty string.
*/
export async function resolveProviderConfig(projectRoot, providerId) {
const stored = await readStored(projectRoot);
const entry = stored[providerId] || {};
const envKey = readEnvKey(providerId);
const oauth =
providerId === 'openai' && !envKey && !entry.apiKey
? await resolveOpenAIOAuthCredential()
: null;
return {
apiKey: envKey || entry.apiKey || oauth?.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 async function readMaskedConfig(projectRoot) {
const stored = await readStored(projectRoot);
const 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 oauth =
id === 'openai' && !envKey && !hasStoredKey
? await resolveOpenAIOAuthCredential()
: null;
providers[id] = {
configured: Boolean(envKey || hasStoredKey || oauth?.apiKey),
source: envKey ? 'env' : hasStoredKey ? 'stored' : oauth?.source || 'unset',
// Show last 4 chars only when stored locally; never echo env-var
// or OAuth secrets so power users don't accidentally see them in
// the DOM.
apiKeyTail: hasStoredKey ? entry.apiKey.slice(-4) : '',
baseUrl: entry.baseUrl || '',
...(typeof entry.model === 'string' && entry.model.trim()
? { model: entry.model.trim() }
: {}),
};
}
return { providers };
}
/**
* 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, body) {
const incoming = body && typeof body === 'object' ? body.providers || {} : {};
const force = Boolean(body && typeof body === 'object' && body.force === true);
const next = {};
for (const id of PROVIDER_IDS) {
const entry = incoming[id];
if (!entry || typeof entry !== 'object') continue;
const apiKey =
typeof entry.apiKey === 'string' && entry.apiKey.trim()
? entry.apiKey.trim()
: '';
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 prior = await readStored(projectRoot);
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(', ')}`,
);
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);
}