osborn 0.9.222 → 0.9.224

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ /**
2
+ * boot-secrets.ts — side-effect module that hydrates user secrets from the Fly
3
+ * volume into process.env at the EARLIEST possible point.
4
+ *
5
+ * Why a separate module instead of a call in index.ts: ES module imports are
6
+ * hoisted and their bodies run in source order BEFORE any statement in the
7
+ * importing module. Some consumers read their keys at module-load time
8
+ * (recall-client.ts reads RECALL_REGION into a top-level const; claude-auth is
9
+ * called later but still expects env present). Importing THIS module
10
+ * immediately after `dotenv/config` — and before every other agent import —
11
+ * guarantees hydrateSecretsIntoEnv() runs before those bodies evaluate, so the
12
+ * volume-persisted user keys win over machine-env defaults.
13
+ *
14
+ * Keep this import positioned second in index.ts (right after dotenv/config).
15
+ */
16
+ export {};
@@ -0,0 +1,17 @@
1
+ /**
2
+ * boot-secrets.ts — side-effect module that hydrates user secrets from the Fly
3
+ * volume into process.env at the EARLIEST possible point.
4
+ *
5
+ * Why a separate module instead of a call in index.ts: ES module imports are
6
+ * hoisted and their bodies run in source order BEFORE any statement in the
7
+ * importing module. Some consumers read their keys at module-load time
8
+ * (recall-client.ts reads RECALL_REGION into a top-level const; claude-auth is
9
+ * called later but still expects env present). Importing THIS module
10
+ * immediately after `dotenv/config` — and before every other agent import —
11
+ * guarantees hydrateSecretsIntoEnv() runs before those bodies evaluate, so the
12
+ * volume-persisted user keys win over machine-env defaults.
13
+ *
14
+ * Keep this import positioned second in index.ts (right after dotenv/config).
15
+ */
16
+ import { hydrateSecretsIntoEnv } from './secrets-store.js';
17
+ hydrateSecretsIntoEnv();
@@ -0,0 +1,40 @@
1
+ /**
2
+ * env-keys.ts — the agent's source-of-truth list of environment variables it
3
+ * expects to find in process.env at runtime, mirroring the frontend's
4
+ * platform-env.ts injection spec.
5
+ *
6
+ * Today the agent reads these directly from process.env (some explicitly, some
7
+ * implicitly via the LiveKit plugin SDKs which read env internally). This module
8
+ * gives that set ONE name so:
9
+ * • a future runtime path can hydrate process.env from a cloud/user config
10
+ * layer before the plugins read it, using this list as the set to fetch, and
11
+ * • startup can log which expected keys are missing for diagnosis.
12
+ *
13
+ * Keep in sync with frontend/src/lib/platform-env.ts (PLATFORM_ENV_SPEC).
14
+ */
15
+ export interface AgentEnvKey {
16
+ key: string;
17
+ required: boolean;
18
+ /** true = read directly via process.env.X in agent source; false = consumed
19
+ * implicitly by a plugin SDK (Deepgram/Soniox/Gemini realtime) reading env. */
20
+ explicit: boolean;
21
+ description: string;
22
+ }
23
+ export declare const AGENT_ENV_KEYS: AgentEnvKey[];
24
+ /** Keys the agent treats as required — absence should be surfaced at startup. */
25
+ export declare const REQUIRED_AGENT_ENV_KEYS: string[];
26
+ /** Return the required keys that are missing from the given env (default process.env). */
27
+ export declare function missingRequiredEnv(env?: NodeJS.ProcessEnv): string[];
28
+ /**
29
+ * Keys a user may add/rotate live (mid-session, no reboot) via the /secrets
30
+ * endpoint. These are read by osborn's OWN code (not baked into a plugin SDK at
31
+ * import time), so writing process.env[KEY] + persisting to the volume takes
32
+ * effect on the next read — see secrets-store.ts. This is the allowlist: the
33
+ * /secrets endpoint rejects any key not in this set, so a user can never inject
34
+ * platform/infra keys (FLY_*, LIVEKIT_*, SMITHERY_API_KEY, tokens, etc.).
35
+ *
36
+ * Mirrors the scope:'user' entries in frontend/src/lib/platform-env.ts.
37
+ */
38
+ export declare const USER_SECRET_KEYS: string[];
39
+ /** True if `key` is a user-manageable secret (allowlisted for /secrets writes). */
40
+ export declare function isUserSecretKey(key: string): boolean;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * env-keys.ts — the agent's source-of-truth list of environment variables it
3
+ * expects to find in process.env at runtime, mirroring the frontend's
4
+ * platform-env.ts injection spec.
5
+ *
6
+ * Today the agent reads these directly from process.env (some explicitly, some
7
+ * implicitly via the LiveKit plugin SDKs which read env internally). This module
8
+ * gives that set ONE name so:
9
+ * • a future runtime path can hydrate process.env from a cloud/user config
10
+ * layer before the plugins read it, using this list as the set to fetch, and
11
+ * • startup can log which expected keys are missing for diagnosis.
12
+ *
13
+ * Keep in sync with frontend/src/lib/platform-env.ts (PLATFORM_ENV_SPEC).
14
+ */
15
+ export const AGENT_ENV_KEYS = [
16
+ // Voice transport
17
+ { key: 'LIVEKIT_URL', required: true, explicit: true, description: 'LiveKit server URL.' },
18
+ { key: 'LIVEKIT_API_KEY', required: true, explicit: true, description: 'LiveKit API key.' },
19
+ { key: 'LIVEKIT_API_SECRET', required: true, explicit: true, description: 'LiveKit API secret.' },
20
+ // STT / inference (plugin-consumed unless noted)
21
+ { key: 'DEEPGRAM_API_KEY', required: true, explicit: false, description: 'Deepgram STT (LiveKit plugin).' },
22
+ { key: 'SONIOX_API_KEY', required: false, explicit: false, description: 'Soniox STT (LiveKit plugin).' },
23
+ { key: 'GROQ_API_KEY', required: false, explicit: true, description: 'Groq fast-brain inference.' },
24
+ { key: 'OPENAI_API_KEY', required: true, explicit: true, description: 'OpenAI realtime + pipeline.' },
25
+ { key: 'GOOGLE_API_KEY', required: false, explicit: false, description: 'Google Gemini realtime (plugin).' },
26
+ // LLM auth (Claude vs OpenRouter switch)
27
+ { key: 'ANTHROPIC_API_KEY', required: false, explicit: true, description: 'Anthropic/Claude auth (per-user; may be OAuth file instead).' },
28
+ { key: 'OPENROUTER_API_KEY', required: false, explicit: true, description: 'OpenRouter model routing.' },
29
+ // Integrations
30
+ { key: 'RECALL_API_KEY', required: false, explicit: true, description: 'Recall.ai meeting bot.' },
31
+ { key: 'RECALL_REGION', required: false, explicit: true, description: 'Recall.ai region.' },
32
+ { key: 'SMITHERY_API_KEY', required: false, explicit: true, description: 'Smithery hosted-MCP catalog.' },
33
+ // Platform / infra
34
+ { key: 'OSBORN_API_PORT', required: true, explicit: true, description: 'Agent HTTP server port.' },
35
+ { key: 'OSBORN_CWD', required: false, explicit: true, description: 'Agent working directory.' },
36
+ { key: 'OSBORN_SYNC_TOKEN', required: false, explicit: true, description: 'Session export/import auth token.' },
37
+ { key: 'OSBORN_FRONTEND_URL', required: false, explicit: true, description: 'Frontend base URL for artifact upload.' },
38
+ { key: 'DEV_DOMAIN', required: false, explicit: true, description: 'Wildcard dev-routing domain.' },
39
+ ];
40
+ /** Keys the agent treats as required — absence should be surfaced at startup. */
41
+ export const REQUIRED_AGENT_ENV_KEYS = AGENT_ENV_KEYS
42
+ .filter((k) => k.required)
43
+ .map((k) => k.key);
44
+ /** Return the required keys that are missing from the given env (default process.env). */
45
+ export function missingRequiredEnv(env = process.env) {
46
+ return REQUIRED_AGENT_ENV_KEYS.filter((k) => !env[k]);
47
+ }
48
+ /**
49
+ * Keys a user may add/rotate live (mid-session, no reboot) via the /secrets
50
+ * endpoint. These are read by osborn's OWN code (not baked into a plugin SDK at
51
+ * import time), so writing process.env[KEY] + persisting to the volume takes
52
+ * effect on the next read — see secrets-store.ts. This is the allowlist: the
53
+ * /secrets endpoint rejects any key not in this set, so a user can never inject
54
+ * platform/infra keys (FLY_*, LIVEKIT_*, SMITHERY_API_KEY, tokens, etc.).
55
+ *
56
+ * Mirrors the scope:'user' entries in frontend/src/lib/platform-env.ts.
57
+ */
58
+ export const USER_SECRET_KEYS = [
59
+ 'ANTHROPIC_API_KEY',
60
+ 'OPENROUTER_API_KEY',
61
+ 'RECALL_API_KEY',
62
+ 'RECALL_REGION',
63
+ ];
64
+ /** True if `key` is a user-manageable secret (allowlisted for /secrets writes). */
65
+ export function isUserSecretKey(key) {
66
+ return USER_SECRET_KEYS.includes(key);
67
+ }
package/dist/index.d.ts CHANGED
@@ -1 +1,2 @@
1
1
  import 'dotenv/config';
2
+ import './boot-secrets.js';
package/dist/index.js CHANGED
@@ -1,5 +1,9 @@
1
1
  // Load environment variables FIRST before any other imports
2
2
  import 'dotenv/config';
3
+ // Then hydrate volume-persisted user secrets into process.env, BEFORE any
4
+ // module that reads its keys at load time (recall-client's RECALL_REGION const,
5
+ // claude-auth fallback). Side-effect import — must stay right after dotenv.
6
+ import './boot-secrets.js';
3
7
  import { voice, initializeLogger } from '@livekit/agents';
4
8
  import { CloudTurnDetector } from './turn-detector-shim.js';
5
9
  import { Room, RoomEvent, } from '@livekit/rtc-node';
@@ -40,6 +44,8 @@ import { DIRECT_MODE_PROMPT } from './prompts.js';
40
44
  import { MCP_CATALOG } from './config.js';
41
45
  import { getRecallClient } from './recall-client.js';
42
46
  import { MeetingTranscriptPoller } from './meeting-transcript-poller.js';
47
+ import { missingRequiredEnv } from './env-keys.js';
48
+ import { listSecretStatus, setSecrets, deleteSecret } from './secrets-store.js';
43
49
  import { llm } from '@livekit/agents';
44
50
  // ============================================================
45
51
  // PIPELINE MODE: STT (Deepgram) → Claude Agent SDK → TTS
@@ -303,6 +309,7 @@ const AGENT_ROUTE_PREFIXES = [
303
309
  '/canvas-stream', '/events', '/room-code', '/connect-room', '/leave-room',
304
310
  '/report-bug', '/restart', '/tts', '/webhook', '/sessions/export',
305
311
  '/sessions/import', '/sessions/manifest',
312
+ '/secrets',
306
313
  '/editor',
307
314
  ];
308
315
  // Set true when code-server is confirmed ready; false when stopped/not running.
@@ -1518,6 +1525,65 @@ function startApiServer(workingDir, port) {
1518
1525
  }
1519
1526
  return;
1520
1527
  }
1528
+ // ── /secrets ── user-manageable API keys (live, no reboot) ───────────────
1529
+ // GET → status of each user key (set?/masked/source) — NEVER raw values.
1530
+ // POST → { KEY: value, ... } set/rotate; empty string deletes. Allowlist-
1531
+ // filtered (USER_SECRET_KEYS); writes the volume store + process.env.
1532
+ // DELETE ?key=KEY → remove one key.
1533
+ // Auth is REQUIRED here (unlike the soft export/import gate): if the machine
1534
+ // has no OSBORN_SYNC_TOKEN we refuse rather than expose an open write path.
1535
+ if (url.pathname === '/secrets') {
1536
+ if (!syncToken) {
1537
+ res.writeHead(503, { 'Content-Type': 'application/json' });
1538
+ res.end(JSON.stringify({ error: 'Secrets endpoint unavailable: no sync token configured' }));
1539
+ return;
1540
+ }
1541
+ const authHeader = req.headers['authorization'] ?? '';
1542
+ if (authHeader !== `Bearer ${syncToken}`) {
1543
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1544
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
1545
+ return;
1546
+ }
1547
+ if (req.method === 'GET') {
1548
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1549
+ res.end(JSON.stringify({ secrets: listSecretStatus() }));
1550
+ return;
1551
+ }
1552
+ if (req.method === 'POST') {
1553
+ let body = '';
1554
+ req.on('data', (chunk) => { body += chunk.toString(); });
1555
+ req.on('end', () => {
1556
+ try {
1557
+ const parsed = JSON.parse(body || '{}');
1558
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1559
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1560
+ res.end(JSON.stringify({ error: 'Body must be a JSON object of { KEY: value }' }));
1561
+ return;
1562
+ }
1563
+ const result = setSecrets(parsed);
1564
+ console.log(`🔑 /secrets POST — set:[${result.set.join(',')}] deleted:[${result.deleted.join(',')}] rejected:[${result.rejected.join(',')}]`);
1565
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1566
+ res.end(JSON.stringify({ ...result, secrets: listSecretStatus() }));
1567
+ }
1568
+ catch {
1569
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1570
+ res.end(JSON.stringify({ error: 'Invalid JSON body' }));
1571
+ }
1572
+ });
1573
+ return;
1574
+ }
1575
+ if (req.method === 'DELETE') {
1576
+ const key = url.searchParams.get('key') ?? '';
1577
+ const existed = deleteSecret(key);
1578
+ console.log(`🔑 /secrets DELETE ${key} — ${existed ? 'removed' : 'not present / not allowed'}`);
1579
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1580
+ res.end(JSON.stringify({ deleted: existed, key, secrets: listSecretStatus() }));
1581
+ return;
1582
+ }
1583
+ res.writeHead(405, { 'Content-Type': 'application/json' });
1584
+ res.end(JSON.stringify({ error: 'Method not allowed' }));
1585
+ return;
1586
+ }
1521
1587
  // ── /editor entry point ──────────────────────────────────────────────────
1522
1588
  // Explicit door into the IDE. When code-server is ready this route mints the
1523
1589
  // osborn_ide session cookie and 302s to / (where the proxy then forwards). If
@@ -1816,6 +1882,16 @@ async function main() {
1816
1882
  console.error('\nSet these in your .env file or environment.');
1817
1883
  process.exit(1);
1818
1884
  }
1885
+ // Non-fatal diagnostic: surface any OTHER expected-required keys that are
1886
+ // missing (per the shared env-keys.ts manifest — mirror of the frontend's
1887
+ // platform-env.ts injection spec). The LiveKit trio above is the only
1888
+ // hard-fail; the rest degrade gracefully (a plugin falls back, a feature
1889
+ // no-ops), so we log rather than exit — but a missing OPENAI/DEEPGRAM key is
1890
+ // the usual root cause of "voice works but STT/realtime is dead", worth naming.
1891
+ const missingEnv = missingRequiredEnv().filter((k) => !['LIVEKIT_URL', 'LIVEKIT_API_KEY', 'LIVEKIT_API_SECRET'].includes(k));
1892
+ if (missingEnv.length > 0) {
1893
+ console.warn(`⚠️ Missing expected env keys (non-fatal): ${missingEnv.join(', ')}`);
1894
+ }
1819
1895
  // Parse CLI args
1820
1896
  const cliArgs = parseArgs();
1821
1897
  // Load configuration
@@ -0,0 +1,61 @@
1
+ /**
2
+ * secrets-store.ts — volume-persisted store for user-manageable API keys.
3
+ *
4
+ * Osborn's platform keys (LiveKit, Deepgram, Smithery, Fly infra) are injected
5
+ * as machine env by the frontend and only change on reboot — fine, they rarely
6
+ * rotate. But a *user* adding their own Anthropic / OpenRouter / Recall key
7
+ * mid-conversation must take effect WITHOUT a reboot (the OS can't hot-inject
8
+ * env into a running process). So those keys live here instead of in machine
9
+ * env: a JSON file on the Fly volume (`~/.osborn/secrets.json` →
10
+ * `/workspace/.osborn/secrets.json`, survives restart AND recreate) that we
11
+ * ALSO mirror into process.env so osborn's own code (which reads process.env)
12
+ * picks them up on the next read.
13
+ *
14
+ * Security:
15
+ * • Writes are allowlist-filtered by USER_SECRET_KEYS — a user can never set
16
+ * a platform/infra key (FLY_*, LIVEKIT_*, tokens…) through this path.
17
+ * • File is written mode 0600.
18
+ * • Secret VALUES are never returned by listSecretStatus() — status only.
19
+ *
20
+ * Keep the allowlist in sync with env-keys.ts (USER_SECRET_KEYS) and the
21
+ * frontend platform-env.ts scope:'user' entries.
22
+ */
23
+ export interface SecretStatus {
24
+ key: string;
25
+ /** true if a non-empty value is currently set (in the store or process.env). */
26
+ set: boolean;
27
+ /** true if this key holds a credential (masked); false = plain config (e.g. region). */
28
+ secret: boolean;
29
+ /** masked preview — never the raw value; empty string when unset. */
30
+ masked: string;
31
+ /** where the current value came from, for the UI. */
32
+ source: 'store' | 'env' | 'none';
33
+ }
34
+ export interface SetSecretsResult {
35
+ set: string[];
36
+ deleted: string[];
37
+ rejected: string[];
38
+ }
39
+ /** Return the current store (allowlist-filtered), without touching env. */
40
+ export declare function loadSecrets(): Record<string, string>;
41
+ /**
42
+ * Copy stored user secrets into process.env so osborn's own code sees them.
43
+ * Call ONCE at bootstrap, BEFORE applyAuthFallback() and before recall-client
44
+ * is imported (both read these keys at module/boot time). Store values win over
45
+ * pre-existing machine env only when non-empty. Returns the keys hydrated.
46
+ */
47
+ export declare function hydrateSecretsIntoEnv(): string[];
48
+ /**
49
+ * Set (or delete) user secrets. An empty-string value deletes the key.
50
+ * Writes the volume file AND mirrors into process.env for live effect.
51
+ * Keys not in USER_SECRET_KEYS are rejected (never written).
52
+ */
53
+ export declare function setSecrets(entries: Record<string, unknown>): SetSecretsResult;
54
+ /** Delete a single user secret. */
55
+ export declare function deleteSecret(key: string): boolean;
56
+ /**
57
+ * Report which user keys are set — WITHOUT returning raw values. Considers both
58
+ * the store and the live process.env (so a key injected as machine env still
59
+ * shows as "set", sourced 'env'). Values are masked.
60
+ */
61
+ export declare function listSecretStatus(): SecretStatus[];
@@ -0,0 +1,168 @@
1
+ /**
2
+ * secrets-store.ts — volume-persisted store for user-manageable API keys.
3
+ *
4
+ * Osborn's platform keys (LiveKit, Deepgram, Smithery, Fly infra) are injected
5
+ * as machine env by the frontend and only change on reboot — fine, they rarely
6
+ * rotate. But a *user* adding their own Anthropic / OpenRouter / Recall key
7
+ * mid-conversation must take effect WITHOUT a reboot (the OS can't hot-inject
8
+ * env into a running process). So those keys live here instead of in machine
9
+ * env: a JSON file on the Fly volume (`~/.osborn/secrets.json` →
10
+ * `/workspace/.osborn/secrets.json`, survives restart AND recreate) that we
11
+ * ALSO mirror into process.env so osborn's own code (which reads process.env)
12
+ * picks them up on the next read.
13
+ *
14
+ * Security:
15
+ * • Writes are allowlist-filtered by USER_SECRET_KEYS — a user can never set
16
+ * a platform/infra key (FLY_*, LIVEKIT_*, tokens…) through this path.
17
+ * • File is written mode 0600.
18
+ * • Secret VALUES are never returned by listSecretStatus() — status only.
19
+ *
20
+ * Keep the allowlist in sync with env-keys.ts (USER_SECRET_KEYS) and the
21
+ * frontend platform-env.ts scope:'user' entries.
22
+ */
23
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync } from 'fs';
24
+ import { homedir } from 'os';
25
+ import { join } from 'path';
26
+ import { USER_SECRET_KEYS, isUserSecretKey } from './env-keys.js';
27
+ const SECRETS_DIR = join(homedir(), '.osborn');
28
+ const SECRETS_FILE = join(SECRETS_DIR, 'secrets.json');
29
+ /** Keys whose values must be masked in any status readout (all except RECALL_REGION). */
30
+ const NON_SECRET_KEYS = new Set(['RECALL_REGION']);
31
+ /** Read the on-disk store. Returns {} if missing/unreadable. */
32
+ function readStore() {
33
+ if (!existsSync(SECRETS_FILE))
34
+ return {};
35
+ try {
36
+ const parsed = JSON.parse(readFileSync(SECRETS_FILE, 'utf-8'));
37
+ if (parsed && typeof parsed === 'object')
38
+ return parsed;
39
+ return {};
40
+ }
41
+ catch (err) {
42
+ console.error(`❌ Failed to read secrets store: ${err.message}`);
43
+ return {};
44
+ }
45
+ }
46
+ /** Write the store atomically-ish with 0600 perms. */
47
+ function writeStore(store) {
48
+ if (!existsSync(SECRETS_DIR)) {
49
+ mkdirSync(SECRETS_DIR, { recursive: true });
50
+ }
51
+ writeFileSync(SECRETS_FILE, JSON.stringify(store, null, 2), { encoding: 'utf-8', mode: 0o600 });
52
+ try {
53
+ chmodSync(SECRETS_FILE, 0o600);
54
+ }
55
+ catch {
56
+ // best-effort; some filesystems ignore chmod
57
+ }
58
+ }
59
+ /** Return the current store (allowlist-filtered), without touching env. */
60
+ export function loadSecrets() {
61
+ const store = readStore();
62
+ const clean = {};
63
+ for (const [k, v] of Object.entries(store)) {
64
+ if (isUserSecretKey(k) && typeof v === 'string' && v.length > 0)
65
+ clean[k] = v;
66
+ }
67
+ return clean;
68
+ }
69
+ /**
70
+ * Copy stored user secrets into process.env so osborn's own code sees them.
71
+ * Call ONCE at bootstrap, BEFORE applyAuthFallback() and before recall-client
72
+ * is imported (both read these keys at module/boot time). Store values win over
73
+ * pre-existing machine env only when non-empty. Returns the keys hydrated.
74
+ */
75
+ export function hydrateSecretsIntoEnv() {
76
+ const store = loadSecrets();
77
+ const applied = [];
78
+ for (const [k, v] of Object.entries(store)) {
79
+ process.env[k] = v;
80
+ applied.push(k);
81
+ }
82
+ if (applied.length > 0) {
83
+ console.log(`🔑 Hydrated ${applied.length} user secret(s) from volume: ${applied.join(', ')}`);
84
+ }
85
+ return applied;
86
+ }
87
+ /**
88
+ * Set (or delete) user secrets. An empty-string value deletes the key.
89
+ * Writes the volume file AND mirrors into process.env for live effect.
90
+ * Keys not in USER_SECRET_KEYS are rejected (never written).
91
+ */
92
+ export function setSecrets(entries) {
93
+ const store = readStore();
94
+ const result = { set: [], deleted: [], rejected: [] };
95
+ for (const [rawKey, rawVal] of Object.entries(entries)) {
96
+ const key = rawKey.trim();
97
+ if (!isUserSecretKey(key)) {
98
+ result.rejected.push(key);
99
+ continue;
100
+ }
101
+ const val = typeof rawVal === 'string' ? rawVal.trim() : '';
102
+ if (val.length === 0) {
103
+ // delete
104
+ delete store[key];
105
+ delete process.env[key];
106
+ result.deleted.push(key);
107
+ }
108
+ else {
109
+ store[key] = val;
110
+ process.env[key] = val;
111
+ result.set.push(key);
112
+ }
113
+ }
114
+ // Only persist allowlisted keys (drop any stale non-allowlisted entries too).
115
+ const persisted = {};
116
+ for (const [k, v] of Object.entries(store)) {
117
+ if (isUserSecretKey(k) && typeof v === 'string' && v.length > 0)
118
+ persisted[k] = v;
119
+ }
120
+ writeStore(persisted);
121
+ return result;
122
+ }
123
+ /** Delete a single user secret. */
124
+ export function deleteSecret(key) {
125
+ if (!isUserSecretKey(key))
126
+ return false;
127
+ const store = readStore();
128
+ const existed = key in store;
129
+ delete store[key];
130
+ delete process.env[key];
131
+ const persisted = {};
132
+ for (const [k, v] of Object.entries(store)) {
133
+ if (isUserSecretKey(k) && typeof v === 'string' && v.length > 0)
134
+ persisted[k] = v;
135
+ }
136
+ writeStore(persisted);
137
+ return existed;
138
+ }
139
+ function mask(value) {
140
+ if (!value)
141
+ return '';
142
+ if (value.length <= 8)
143
+ return '••••';
144
+ return `${value.slice(0, 4)}••••${value.slice(-4)}`;
145
+ }
146
+ /**
147
+ * Report which user keys are set — WITHOUT returning raw values. Considers both
148
+ * the store and the live process.env (so a key injected as machine env still
149
+ * shows as "set", sourced 'env'). Values are masked.
150
+ */
151
+ export function listSecretStatus() {
152
+ const store = readStore();
153
+ return USER_SECRET_KEYS.map((key) => {
154
+ const inStore = typeof store[key] === 'string' && store[key].length > 0;
155
+ const envVal = process.env[key];
156
+ const inEnv = typeof envVal === 'string' && envVal.length > 0;
157
+ const value = inStore ? store[key] : inEnv ? envVal : '';
158
+ const isSecret = !NON_SECRET_KEYS.has(key);
159
+ return {
160
+ key,
161
+ set: inStore || inEnv,
162
+ secret: isSecret,
163
+ // Non-secret config (region) can be shown in full; credentials are masked.
164
+ masked: value ? (isSecret ? mask(value) : value) : '',
165
+ source: inStore ? 'store' : inEnv ? 'env' : 'none',
166
+ };
167
+ });
168
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.222",
3
+ "version": "0.9.224",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {