@johpaz/hive-sdk 0.5.0 → 0.5.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1
4
+
5
+ ### Seguridad — claves aisladas por inquilino
6
+
7
+ - **Secretos filtrados entre inquilinos por la caché en memoria.** El
8
+ almacén de secretos cacheaba cada valor descifrado en un `Map` del proceso
9
+ indexado sólo por nombre (`provider:openai:api_key`), y el llavero del SO
10
+ también es de toda la máquina. La colección `secrets` sí está particionada,
11
+ pero la caché la tapaba: después de que un inquilino leyera o guardara su
12
+ clave, `loadProviderApiKey` le devolvía esa misma clave a cualquier otro
13
+ inquilino del proceso, y `resolveProviderConfig` la usaba para cobrarle a
14
+ la cuenta equivocada cuando la llamada no traía `credentials`. Ahora la
15
+ caché se indexa por inquilino y, con un inquilino activo, el llavero del SO
16
+ no se lee, no se escribe ni se borra. Sin inquilino (escritorio) todo sigue
17
+ igual.
18
+ - **La clave del entorno ya no se usa en nombre de un inquilino.** Con un
19
+ inquilino activo y sin `credentials` ni clave guardada, `resolveProviderConfig`
20
+ caía en `<PROVEEDOR>_API_KEY` del proceso, que es la cuenta de la plataforma;
21
+ lo mismo OCR (`vision-service`), voz (STT/TTS), `computer_use` y Jev. Ahora
22
+ todos pasan por `envSecret(nombre)`, que dentro de un inquilino devuelve
23
+ `undefined`. Los adaptadores de Anthropic, Gemini y Gemini Live pasan siempre
24
+ una cadena al SDK del proveedor, porque con `undefined` esos SDK leían la
25
+ variable de entorno por su cuenta. **Cambio de comportamiento:** un host
26
+ multi-inquilino que dependía de ese respaldo tiene que pasar la clave en
27
+ `credentials` o guardarla en los secretos del inquilino; sin eso la llamada
28
+ falla por falta de clave en vez de cobrarse a la plataforma. Sin inquilino
29
+ (escritorio) el entorno sigue siendo el último respaldo.
30
+ - Nuevo `envSecret(nombre)` en `@johpaz/hive-sdk/storage`: la variable de
31
+ entorno sólo fuera de un inquilino. Úsalo en tus propias tools en lugar de
32
+ `process.env.X_API_KEY`.
33
+ - Quitado `loadDurableProviderApiKey` (interno, agregado en 0.5.0 y nunca
34
+ exportado por un barrel): `loadProviderApiKey` ya es seguro por inquilino.
35
+
3
36
  ## 0.5.0
4
37
 
5
38
  ### Jev — plano de decisión (OpenRouter Decisions)
package/README.md CHANGED
@@ -25,6 +25,8 @@ bun add @johpaz/hive-sdk
25
25
  - **Runtime**: ejecución paralela de tools vía Bun Workers.
26
26
  - **Gateway**: servidor HTTP/WebSocket para exponer agentes como API.
27
27
  - **Memoria y estado**: HiveDB (colecciones + índice BM25), scratchpad, context compiler con compactación.
28
+ - **Jev (opcional)**: plano de decisión sobre la API Decisions de OpenRouter. Por turno elige qué historial, tools, skills, notas y reglas entran al contexto, poda resultados viejos entre iteraciones y decide si un lote de tools corre en paralelo. Sin clave de OpenRouter no existe y todo corre igual. Ver [API-AGENTS.md](./docs/API-AGENTS.md#jev-plano-de-decisión).
29
+ - **Multi-inquilino**: varios enjambres en una sola HiveDB con `runInTenant`; credenciales y clave de Jev por llamada (`credentials`, `jev`), sin que la clave de un inquilino ni la de la plataforma se usen en nombre de otro.
28
30
  - **Servicios**: CRUD tipado de agentes, enjambres, skills, modelos, MCP y cron para montarle **la interfaz que quieras** — móvil, web o escritorio. Ver [API-SERVICES.md](./docs/API-SERVICES.md).
29
31
  - **Sesiones**: un hilo por canal y por contacto, con historial, resumen y reanudación tras un corte.
30
32
  - **Imágenes**: redimensionar y convertir con `Bun.Image`, sin dependencias nativas. Las imágenes entrantes se normalizan antes de llegar al modelo — una foto de cámara pasa de 217 KB a 4 KB.
@@ -197,7 +199,10 @@ capturar. El SDK lo resuelve con `resolvePort`: avisa y sigue con el default.
197
199
 
198
200
  La API key de cada provider se guarda cifrada en la base. Como alternativa, el
199
201
  SDK cae a `<PROVIDER>_API_KEY` del entorno, en mayúsculas y con el id del
200
- provider tal cual:
202
+ provider tal cual. **Sólo sin inquilino** (app de escritorio, un proceso por
203
+ instalación): dentro de `runInTenant` el entorno es de la plataforma, no del
204
+ cliente, y no se usa nunca — la clave llega en `credentials` o desde los
205
+ secretos del inquilino. Ver [UPGRADING.md](./docs/UPGRADING.md#051-claves-aisladas-por-inquilino).
201
206
 
202
207
  ```bash
203
208
  OPENAI_API_KEY=sk-...
@@ -205,7 +210,7 @@ ANTHROPIC_API_KEY=sk-ant-...
205
210
  GOOGLE_API_KEY=... # provider "gemini"
206
211
  MODELSCOPE_API_KEY=ms-...
207
212
  NVIDIA_API_KEY=nvapi-...
208
- OPENROUTER_API_KEY=sk-or-...
213
+ OPENROUTER_API_KEY=sk-or-... # también activa Jev (con el provider openrouter habilitado)
209
214
  ```
210
215
 
211
216
  ## Tests
@@ -260,7 +265,7 @@ npm view @johpaz/hive-sdk dist-tags # verificar después del release
260
265
 
261
266
  | Documento | Descripción |
262
267
  |-----------|-------------|
263
- | [API-AGENTS.md](docs/API-AGENTS.md) | createAgent, AgentLoop, Tool/Skill Selector, los 16 LLM Providers |
268
+ | [API-AGENTS.md](docs/API-AGENTS.md) | createAgent, AgentLoop, Tool/Skill Selector, los 16 LLM Providers, multi-inquilino y Jev |
264
269
  | [API-CONTEXT-COMPILER.md](docs/API-CONTEXT-COMPILER.md) | Context Compiler, historial, Scratchpad, EthicsGuard, ACE |
265
270
  | [API-TOOLS-SKILLS-CHANNELS.md](docs/API-TOOLS-SKILLS-CHANNELS.md) | Tools, Skills, MCP, Gateway, Channels, Tool Runtime, Storage |
266
271
  | [API-DAG-SCHEDULER.md](docs/API-DAG-SCHEDULER.md) | DAGScheduler, TaskGraph, TaskNode, estrategias, presets |
@@ -271,4 +276,4 @@ npm view @johpaz/hive-sdk dist-tags # verificar después del release
271
276
 
272
277
  ---
273
278
 
274
- *Hive SDK v0.5.0 — MIT*
279
+ *Hive SDK v0.5.1 — MIT*
@@ -7,6 +7,8 @@
7
7
  3. [Tool Selector](#tool-selector)
8
8
  4. [Skill Selector](#skill-selector)
9
9
  5. [LLM Providers](#llm-providers)
10
+ 6. [Multi-inquilino: credenciales y Jev](#multi-inquilino-credenciales-y-jev)
11
+ 7. [Jev: plano de decisión](#jev-plano-de-decisión)
10
12
 
11
13
  ---
12
14
 
@@ -240,6 +242,74 @@ const result = await runAgentIsolated({
240
242
  });
241
243
  ```
242
244
 
245
+ ### Multi-inquilino: credenciales y Jev
246
+
247
+ Un host que sirve a varios clientes desde un mismo proceso corre cada turno
248
+ dentro de `runInTenant(tenantKey, …)` y pasa las claves **por llamada**:
249
+
250
+ ```typescript
251
+ import { runAgent } from "@johpaz/hive-sdk";
252
+ import { runInTenant } from "@johpaz/hive-sdk/storage";
253
+
254
+ await runInTenant(tenantKey, async () => {
255
+ for await (const chunk of runAgent({
256
+ agentId, threadId, userMessage,
257
+ credentials: { apiKey: claveDelModelo, baseUrl }, // gana y corta ahí
258
+ jev: claveOpenRouter ? { apiKey: claveOpenRouter } : false,
259
+ onStep: async (paso) => { /* text · tool_call · tool_result · jev_decision */ },
260
+ })) { /* … */ }
261
+ });
262
+ ```
263
+
264
+ Reglas con un inquilino activo (desde 0.5.1):
265
+
266
+ - `credentials.apiKey` gana. Sin ella, la clave sale de los secretos **de ese
267
+ inquilino** (`storeProviderApiKey` dentro de su `runInTenant`).
268
+ - Nunca se usa `<PROVIDER>_API_KEY` del entorno ni el llavero del SO: son de la
269
+ máquina, no del cliente. Si no hay clave, la llamada falla en vez de cobrarse
270
+ a la plataforma. Para tus propias tools usa `envSecret(nombre)`
271
+ (`@johpaz/hive-sdk/storage`), que aplica la misma regla.
272
+ - `credentials` y `jev` se propagan a `runAgentIsolated`, `runRoleSwarm` y
273
+ `runSwarm`.
274
+
275
+ ### Jev: plano de decisión
276
+
277
+ Jev hace preguntas acotadas a la API Decisions de OpenRouter
278
+ (`typesafe/jev-1.13`) y usa las respuestas para recortar lo que recibe el
279
+ modelo principal. **Es opcional**: sin clave no existe, no se hace ninguna
280
+ llamada y el turno corre exactamente igual.
281
+
282
+ | Momento | Qué decide |
283
+ |---|---|
284
+ | Al compilar el contexto | Qué mensajes previos, tools, skills, notas del scratchpad y reglas del playbook entran; a qué especialista conviene delegar, y si depende de un MCP apagado. Los últimos 4 mensajes siempre quedan. |
285
+ | Entre iteraciones | Qué resultados viejos de tools se omiten y la siguiente acción (continuar, delegar, descubrir, cerrar). Sólo si hay ≥ 4 000 caracteres podables. |
286
+ | Antes de ejecutar tools | Si un lote de lecturas independientes, o de delegaciones a workers distintos, corre en paralelo. |
287
+
288
+ Lo omitido se puede recuperar: el prompt lista los ids y el coordinador tiene
289
+ `conversation_read`.
290
+
291
+ **Activación**, por orden:
292
+
293
+ 1. `jev: { apiKey, mcpSettingsPath? }` en la llamada. `mcpSettingsPath` es el
294
+ texto con el que el coordinador le dice al usuario dónde encender un MCP
295
+ (por defecto «Ajustes → Entorno → MCP Servers»).
296
+ 2. `jev: false` lo apaga.
297
+ 3. Sin la opción: el provider `openrouter` habilitado y activo, con su clave
298
+ guardada; sin inquilino, también `OPENROUTER_API_KEY`.
299
+
300
+ **Qué ve el host.** Cada decisión llega por `onStep` como
301
+ `{ type: "jev_decision", message, jev }`, con agente, tipo (`context`,
302
+ `iteration`, `parallel`), resumen, tokens ahorrados estimados, latencia, costo,
303
+ especialista recomendado y MCP apagados. También se emite
304
+ `canvas:jev_decision`. `getUsageStats().jev` suma decisiones, costo y ahorro
305
+ (total y por agente). Fallos y cooldown se llevan por inquilino.
306
+
307
+ **Privacidad.** Se envían a OpenRouter, con la clave de la llamada, extractos
308
+ acotados: el objetivo del turno, fragmentos de mensajes previos, nombres y
309
+ descripciones de tools y skills, notas y reglas, fragmentos de resultados de
310
+ tools y el mapa del enjambre (nombres, no ids con prefijo de inquilino). Nunca
311
+ credenciales ni adjuntos. El detalle está en el [CHANGELOG](../CHANGELOG.md).
312
+
243
313
  ---
244
314
 
245
315
  ## Tool Selector
package/docs/INDEX.md CHANGED
@@ -12,7 +12,7 @@
12
12
  | [API-RESILIENCE.md](./API-RESILIENCE.md) | Reintentos con backoff y circuit breakers |
13
13
  | [API-SESSIONS.md](./API-SESSIONS.md) | Sesiones por canal, historial, reanudación tras un corte |
14
14
  | [API-SERVICES.md](./API-SERVICES.md) | **La superficie para una UI** — CRUD de agentes, enjambres, skills, modelos, MCP, cron |
15
- | [API-AGENTS.md](./API-AGENTS.md) | createAgent, AgentLoop, Tool/Skill Selector, LLM Providers |
15
+ | [API-AGENTS.md](./API-AGENTS.md) | createAgent, AgentLoop, Tool/Skill Selector, LLM Providers, multi-inquilino y Jev |
16
16
  | [API-DAG-SCHEDULER.md](./API-DAG-SCHEDULER.md) | DAGScheduler, TaskGraph, Estrategias, Presets |
17
17
  | [API-CRON.md](./API-CRON.md) | Tareas programadas: expresiones, zona horaria, misfires, motor sin dependencias |
18
18
  | [API-WORKERS-EVENTS.md](./API-WORKERS-EVENTS.md) | **Bun Workers**, createWorker, WorkerPool, AgentBus, EventBus, Canvas |
@@ -181,6 +181,10 @@ ANTHROPIC_API_KEY=sk-ant-... # Anthropic
181
181
  LOG_LEVEL=info # debug | info | warn | error
182
182
  ```
183
183
 
184
+ Las `*_API_KEY` del entorno sólo se usan sin inquilino; dentro de
185
+ `runInTenant` la clave llega en `credentials` o desde los secretos del
186
+ inquilino (ver [UPGRADING.md](./UPGRADING.md#051-claves-aisladas-por-inquilino)).
187
+
184
188
  ---
185
189
 
186
190
  ## Tests
package/docs/UPGRADING.md CHANGED
@@ -74,6 +74,26 @@ sigue sólo importa con el log causal encendido (`HIVE_CAUSAL_LOG=true` o
74
74
  crudo y el SDK lo califica. Los eventos que entrega traen en `agentId` la
75
75
  clave del shard; `formatCausalEvent` la muestra sin el tenant.
76
76
 
77
+ ## 0.5.1: claves aisladas por inquilino
78
+
79
+ Sólo cambia algo si corres turnos dentro de `runInTenant` (un host
80
+ multi-inquilino). Sin inquilino todo sigue igual.
81
+
82
+ - **La caché de secretos y el llavero del SO ya no se comparten.** Antes, en
83
+ cuanto un inquilino leía o guardaba `provider:<id>:api_key`, los demás del
84
+ mismo proceso recibían esa clave. Ahora la caché es por inquilino y el
85
+ llavero no se toca dentro de un inquilino. No hay que cambiar código.
86
+ - **El entorno ya no es respaldo dentro de un inquilino.** Si un turno no
87
+ trae `credentials` y el inquilino no tiene clave guardada, antes se usaba
88
+ `<PROVIDER>_API_KEY` del proceso (la cuenta de la plataforma); ahora la
89
+ llamada falla por falta de clave. Aplica al modelo principal, OCR, voz,
90
+ `computer_use` y Jev. Qué hacer: pasar la clave en `credentials`, o
91
+ guardarla con `storeProviderApiKey` dentro del `runInTenant` del cliente. Si
92
+ quieres que un cliente use la cuenta de la plataforma, pásala tú en
93
+ `credentials`, a propósito.
94
+ - Para tus propias tools: `envSecret("MI_API_KEY")` en lugar de
95
+ `process.env.MI_API_KEY`.
96
+
77
97
  ## Compatibilidad y CI
78
98
 
79
99
  Los workflows fijan Bun 1.4.2, instalan con `--frozen-lockfile`, ejecutan el
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johpaz/hive-sdk",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "private": false,
5
5
  "description": "Hive SDK — The Agent Harness SDK. Build, deploy, and scale AI agent applications with multi-channel support, context engineering, and swarm orchestration.",
6
6
  "license": "MIT",
@@ -1,7 +1,7 @@
1
1
  /** Optional decision plane. Jev is never used through the chat completions API. */
2
2
  import { col } from "../storage/hive.ts"
3
3
  import type { ProviderDoc } from "../storage/collections.ts"
4
- import { loadDurableProviderApiKey, loadProviderApiKey } from "../storage/crypto.ts"
4
+ import { envSecret, loadProviderApiKey } from "../storage/crypto.ts"
5
5
  import { recordJevDecision, recordUsage } from "../storage/usage.ts"
6
6
  import { catalogModelKey } from "../storage/model-id.ts"
7
7
  import { currentTenant } from "../storage/tenant.ts"
@@ -71,17 +71,15 @@ export interface JevResult {
71
71
  /**
72
72
  * The OpenRouter key Jev would use, or null when Jev is off.
73
73
  *
74
- * With a tenant in scope the key comes only from that tenant's `secrets`
75
- * partition — never from `OPENROUTER_API_KEY`, which is the platform's, nor
76
- * from the process-wide secret cache, which is not partitioned.
74
+ * With a tenant in scope the key comes only from that tenant's secrets —
75
+ * never from `OPENROUTER_API_KEY`, which is the platform's.
77
76
  */
78
77
  export async function getJevKey(option?: JevOption): Promise<string | null> {
79
78
  if (option === false) return null
80
79
  if (option) return option.apiKey || null
81
80
  const provider = await (await col<ProviderDoc>("providers")).get("openrouter")
82
81
  if (!provider?.doc.enabled || !provider.doc.active) return null
83
- if (currentTenant()) return (await loadDurableProviderApiKey("openrouter")) || null
84
- return (await loadProviderApiKey("openrouter")) || process.env.OPENROUTER_API_KEY || null
82
+ return (await loadProviderApiKey("openrouter")) || envSecret("OPENROUTER_API_KEY") || null
85
83
  }
86
84
 
87
85
  export interface JevStatus {
@@ -309,7 +309,7 @@ export async function resolveProviderConfig(
309
309
  credentials?: ProviderCredentials
310
310
  ): Promise<Pick<LLMCallOptions, "provider" | "model" | "apiKey" | "baseUrl" | "numCtx" | "numGpu" | "contextWindow">> {
311
311
  const { col } = await import("../storage/hive.ts")
312
- const { loadProviderApiKey } = await import("../storage/crypto.ts")
312
+ const { envSecret, loadProviderApiKey } = await import("../storage/crypto.ts")
313
313
  const providersCol = await col<import("../storage/collections.ts").ProviderDoc>("providers")
314
314
  const modelsCol = await col<import("../storage/collections.ts").ModelDoc>("models")
315
315
 
@@ -327,7 +327,7 @@ export async function resolveProviderConfig(
327
327
  apiKey = await loadProviderApiKey(providerId)
328
328
  }
329
329
  if (!apiKey) {
330
- apiKey = process.env[`${providerId.toUpperCase()}_API_KEY`] || ""
330
+ apiKey = envSecret(`${providerId.toUpperCase()}_API_KEY`) || ""
331
331
  }
332
332
 
333
333
  return {
@@ -1,3 +1,4 @@
1
+ import { envSecret } from "../../storage/crypto.ts"
1
2
  import { logger } from "../../utils/logger.ts"
2
3
  import { normalizeToolName, resolveMaxTokens, ensureArrayItems } from "./interface.ts"
3
4
  import type { LLMCallOptions, LLMProvider, LLMResponse, LLMToolCall, ThinkingBlock } from "./interface.ts"
@@ -74,7 +75,7 @@ export class AnthropicProvider implements LLMProvider {
74
75
 
75
76
  async call(options: LLMCallOptions): Promise<LLMResponse> {
76
77
  const Anthropic = await import("@anthropic-ai/sdk")
77
- const client = new Anthropic.default({ apiKey: options.apiKey })
78
+ const client = new Anthropic.default({ apiKey: options.apiKey || envSecret("ANTHROPIC_API_KEY") || "" })
78
79
 
79
80
  // Anthropic requires tool names to match ^[a-zA-Z0-9_-]{1,128}$
80
81
  // Native Hive tools use dots (e.g. cron.create) which violate this.
@@ -1,3 +1,4 @@
1
+ import { envSecret } from "../../storage/crypto.ts"
1
2
  import { logger } from "../../utils/logger.ts"
2
3
  import { sanitizeMessages, resolveMaxTokens, ensureArrayItems } from "./interface.ts"
3
4
  import type { LLMCallOptions, LLMProvider, LLMResponse, LLMToolCall } from "./interface.ts"
@@ -100,7 +101,8 @@ export class GeminiProvider implements LLMProvider {
100
101
  async call(options: LLMCallOptions): Promise<LLMResponse> {
101
102
  const { GoogleGenAI } = await import("@google/genai")
102
103
 
103
- const clientOpts: any = { apiKey: options.apiKey }
104
+ // A string always: undefined makes @google/genai read GEMINI_API_KEY itself, inside a tenant too.
105
+ const clientOpts: any = { apiKey: options.apiKey || envSecret("GEMINI_API_KEY") || envSecret("GOOGLE_API_KEY") || "" }
104
106
  if (options.baseUrl?.trim()) clientOpts.httpOptions = { baseUrl: options.baseUrl.trim() }
105
107
 
106
108
  const ai = new GoogleGenAI(clientOpts)
@@ -10,6 +10,7 @@
10
10
  * al contexto inicial en 3.x, así que no se usa acá.
11
11
  */
12
12
 
13
+ import { envSecret } from "../../storage/crypto.ts"
13
14
  import { logger } from "../../utils/logger.ts";
14
15
  import { ensureArrayItems } from "../llm-providers/interface.ts";
15
16
  import type {
@@ -106,7 +107,7 @@ export class GeminiLiveProvider implements RealtimeProvider {
106
107
 
107
108
  async connect(options: RealtimeSessionOptions): Promise<RealtimeSession> {
108
109
  const { GoogleGenAI } = await import("@google/genai");
109
- const ai = new GoogleGenAI({ apiKey: options.apiKey });
110
+ const ai = new GoogleGenAI({ apiKey: options.apiKey || envSecret("GEMINI_API_KEY") || envSecret("GOOGLE_API_KEY") || "" });
110
111
  const cb = options.callbacks;
111
112
 
112
113
  const config: Record<string, unknown> = {
@@ -1,6 +1,6 @@
1
1
  import { col } from "../storage/hive.ts"
2
2
  import type { ChannelDoc, ModelDoc, ProviderDoc } from "../storage/collections.ts"
3
- import { loadProviderApiKey } from "../storage/crypto.ts"
3
+ import { envSecret, loadProviderApiKey } from "../storage/crypto.ts"
4
4
  import { logger } from "../utils/logger.ts"
5
5
  import type { ImageInput, DocumentInput, VisionConfig } from "./types.ts"
6
6
  import type { ContentPart } from "./types.ts"
@@ -177,7 +177,7 @@ class MultimodalService {
177
177
  }
178
178
 
179
179
  private async ocrWithOpenAI(image: ImageInput): Promise<string> {
180
- const key = await this.getProviderApiKey("openai") || process.env.OPENAI_API_KEY
180
+ const key = await this.getProviderApiKey("openai") || envSecret("OPENAI_API_KEY")
181
181
  if (!key) throw new Error("OPENAI_API_KEY not configured for OCR")
182
182
 
183
183
  const imageUrl = await this.resolveImageUrl(image)
@@ -208,7 +208,7 @@ class MultimodalService {
208
208
  }
209
209
 
210
210
  private async ocrWithGemini(image: ImageInput): Promise<string> {
211
- const key = await this.getProviderApiKey("gemini") || process.env.GEMINI_API_KEY
211
+ const key = await this.getProviderApiKey("gemini") || envSecret("GEMINI_API_KEY")
212
212
  if (!key) throw new Error("GEMINI_API_KEY not configured for OCR")
213
213
 
214
214
  let imagePart: any
@@ -243,7 +243,7 @@ class MultimodalService {
243
243
  }
244
244
 
245
245
  private async ocrWithAnthropic(image: ImageInput): Promise<string> {
246
- const key = await this.getProviderApiKey("anthropic") || process.env.ANTHROPIC_API_KEY
246
+ const key = await this.getProviderApiKey("anthropic") || envSecret("ANTHROPIC_API_KEY")
247
247
  if (!key) throw new Error("ANTHROPIC_API_KEY not configured for OCR")
248
248
 
249
249
  const imageUrl = await this.resolveImageUrl(image)
@@ -299,9 +299,9 @@ class MultimodalService {
299
299
  ])
300
300
 
301
301
  return {
302
- openai: openai || !!(process.env.OPENAI_API_KEY),
303
- gemini: gemini || !!(process.env.GEMINI_API_KEY),
304
- anthropic: anthropic || !!(process.env.ANTHROPIC_API_KEY),
302
+ openai: openai || !!(envSecret("OPENAI_API_KEY")),
303
+ gemini: gemini || !!(envSecret("GEMINI_API_KEY")),
304
+ anthropic: anthropic || !!(envSecret("ANTHROPIC_API_KEY")),
305
305
  }
306
306
  }
307
307
 
@@ -4,6 +4,7 @@ import * as path from "node:path"
4
4
  import { getHiveDir } from "../config/loader.ts"
5
5
  import { logger } from "../utils/logger.ts"
6
6
  import { col } from "./hive.ts"
7
+ import { currentTenant } from "./tenant.ts"
7
8
 
8
9
  const log = logger.child("crypto")
9
10
  const SERVICE = "hive"
@@ -25,8 +26,28 @@ interface SecretDoc {
25
26
  // written *only* there does not survive a server restart. That is exactly
26
27
  // what was wiping every provider API key, channel token and MCP header on
27
28
  // restart in production.
28
-
29
+ //
30
+ // Multi-tenant: the `secrets` collection is partitioned by tenant through
31
+ // `col()`, but this process's memory and the OS keychain are not. So the
32
+ // in-memory cache is keyed by tenant as well, and with a tenant in scope the
33
+ // keychain is never read or written — a machine-wide entry named
34
+ // `provider:openai:api_key` belongs to no tenant in particular, and serving it
35
+ // (or overwriting it) on a tenant's behalf would hand one customer another's
36
+ // key.
37
+
38
+ /** Decrypted values, keyed by {@link _cacheKey}: never shared across tenants. */
29
39
  const _mem = new Map<string, string>()
40
+
41
+ /** `name` for the desktop (no tenant); `<tenant>\0name` inside a tenant. */
42
+ function _cacheKey(name: string): string {
43
+ const tenant = currentTenant()
44
+ return tenant ? `${tenant}\0${name}` : name
45
+ }
46
+
47
+ /** The OS keychain only serves the tenant-less (desktop) scope. */
48
+ function _keychainInScope(): boolean {
49
+ return currentTenant() === null
50
+ }
30
51
  let _keychainOk: boolean | null = null // null = untested
31
52
 
32
53
  let _keychainApi: unknown = undefined
@@ -46,19 +67,21 @@ function _getKeychainApi(): any {
46
67
  }
47
68
 
48
69
  async function _get(name: string): Promise<string | null> {
49
- const cached = _mem.get(name)
70
+ const cacheKey = _cacheKey(name)
71
+ const cached = _mem.get(cacheKey)
50
72
  if (cached !== undefined) return cached
51
73
 
52
74
  // Durable store first — it is the one every write goes to.
53
75
  const stored = await _readCollectionSecret(name)
54
76
  if (stored) {
55
- _mem.set(name, stored)
77
+ _mem.set(cacheKey, stored)
56
78
  return stored
57
79
  }
58
80
 
59
81
  // Legacy/desktop installs may only have the value in the OS keychain.
82
+ if (!_keychainInScope()) return null
60
83
  const fromKeychain = await _keychainGet(name)
61
- if (fromKeychain) _mem.set(name, fromKeychain)
84
+ if (fromKeychain) _mem.set(cacheKey, fromKeychain)
62
85
  return fromKeychain
63
86
  }
64
87
 
@@ -69,9 +92,9 @@ async function _get(name: string): Promise<string | null> {
69
92
  * silently accepting a secret that dies with the process.
70
93
  */
71
94
  async function _set(name: string, value: string): Promise<boolean> {
72
- _mem.set(name, value)
95
+ _mem.set(_cacheKey(name), value)
73
96
  const durable = await persistSecretToCollection(name, value)
74
- const mirrored = await _keychainSet(name, value)
97
+ const mirrored = _keychainInScope() ? await _keychainSet(name, value) : false
75
98
  if (!durable && !mirrored) {
76
99
  log.error(`[secrets] ${name} could not be persisted — it will be lost on restart`)
77
100
  }
@@ -87,7 +110,6 @@ async function _readCollectionSecret(name: string): Promise<string | null> {
87
110
  const secrets = await col<SecretDoc>("secrets")
88
111
  const entry = await secrets.get(name)
89
112
  if (!entry) return null
90
- // `_get` caches it; `loadDurableProviderApiKey` must not.
91
113
  return decryptSecret(entry.doc.ciphertext, entry.doc.iv) || null
92
114
  } catch {
93
115
  return null
@@ -134,11 +156,13 @@ async function _keychainSet(name: string, value: string): Promise<boolean> {
134
156
  }
135
157
 
136
158
  async function _del(name: string): Promise<void> {
137
- _mem.delete(name)
138
- try {
139
- await (Bun as any).secrets.delete({ service: SERVICE, name })
140
- } catch {
141
- // ignore — might not exist or keychain unavailable
159
+ _mem.delete(_cacheKey(name))
160
+ if (_keychainInScope()) {
161
+ try {
162
+ await (Bun as any).secrets.delete({ service: SERVICE, name })
163
+ } catch {
164
+ // ignore — might not exist or keychain unavailable
165
+ }
142
166
  }
143
167
  try {
144
168
  const secrets = await col<SecretDoc>("secrets")
@@ -195,12 +219,14 @@ export async function loadProviderApiKey(id: string): Promise<string> {
195
219
  }
196
220
 
197
221
  /**
198
- * The provider key from the durable `secrets` collection only. That collection
199
- * is partitioned by tenant; the in-memory cache and the OS keychain are not, so
200
- * a multi-tenant caller that must never see another tenant's key reads here.
222
+ * A credential from the process environment (`OPENAI_API_KEY`, …) — only
223
+ * outside a tenant. The environment is the host's: inside a tenant it belongs
224
+ * to the platform, not the customer, and using it would bill the platform's
225
+ * account for a customer's call (or let one customer run on another's key).
226
+ * A tenant's credentials come from its own secrets or from `credentials`.
201
227
  */
202
- export async function loadDurableProviderApiKey(id: string): Promise<string> {
203
- return (await _readCollectionSecret(`provider:${id}:api_key`)) ?? ""
228
+ export function envSecret(name: string): string | undefined {
229
+ return currentTenant() ? undefined : process.env[name] || undefined
204
230
  }
205
231
 
206
232
  export async function storeProviderHeaders(id: string, headers: Record<string, unknown>): Promise<boolean> {
@@ -96,6 +96,7 @@ export {
96
96
  deleteSecret,
97
97
  storeProviderApiKey,
98
98
  loadProviderApiKey,
99
+ envSecret,
99
100
  storeProviderHeaders,
100
101
  loadProviderHeaders,
101
102
  deleteProviderSecrets,
@@ -28,7 +28,7 @@
28
28
  import type { Tool } from "../types.ts";
29
29
  import { logger } from "../../utils/logger.ts";
30
30
  import { getBrowserService } from "./browser-service.ts";
31
- import { loadProviderApiKey } from "../../storage/crypto.ts";
31
+ import { envSecret, loadProviderApiKey } from "../../storage/crypto.ts";
32
32
 
33
33
  const log = logger.child("computer-use");
34
34
 
@@ -417,7 +417,7 @@ export const computerUseTaskTool: Tool = {
417
417
  const maxPasos = Math.max(1, Math.min(30, Number(params.max_pasos) || MAX_PASOS));
418
418
  const confirmado = params.confirmado === true;
419
419
 
420
- const apiKey = (await loadProviderApiKey("gemini")) || process.env.GEMINI_API_KEY;
420
+ const apiKey = (await loadProviderApiKey("gemini")) || envSecret("GEMINI_API_KEY");
421
421
  if (!apiKey) {
422
422
  return { ok: false, error: "Falta la API key de Gemini (Ajustes → Proveedores)." };
423
423
  }
@@ -1,7 +1,7 @@
1
1
  import { resolvePort } from "../utils/port.ts";
2
2
  import { col } from "../storage/hive.ts";
3
3
  import type { ChannelDoc, ModelDoc } from "../storage/collections.ts";
4
- import { loadProviderApiKey } from "../storage/crypto.ts";
4
+ import { envSecret, loadProviderApiKey } from "../storage/crypto.ts";
5
5
  import { logger } from "../utils/logger.ts";
6
6
 
7
7
  export interface VoiceConfig {
@@ -160,7 +160,7 @@ class VoiceService {
160
160
  }
161
161
 
162
162
  private async transcribeWithGroq(audio: AudioInput, modelId: string): Promise<string> {
163
- const key = await this.getProviderApiKey("groq") || process.env.GROQ_API_KEY;
163
+ const key = await this.getProviderApiKey("groq") || envSecret("GROQ_API_KEY");
164
164
  if (!key) {
165
165
  throw new Error("GROQ_API_KEY not configured. Configúrala en Proveedores o en las variables de entorno.");
166
166
  }
@@ -212,7 +212,7 @@ class VoiceService {
212
212
  }
213
213
 
214
214
  private async transcribeWithOpenAIWhisper(audio: AudioInput): Promise<string> {
215
- const key = await this.getProviderApiKey("openai") || process.env.OPENAI_API_KEY;
215
+ const key = await this.getProviderApiKey("openai") || envSecret("OPENAI_API_KEY");
216
216
  if (!key) {
217
217
  throw new Error("OPENAI_API_KEY not configured. Configúrala en Proveedores o en las variables de entorno.");
218
218
  }
@@ -302,7 +302,7 @@ class VoiceService {
302
302
 
303
303
  private async speakWithElevenLabs(text: string, modelId: string, voiceId?: string): Promise<AudioOutput> {
304
304
  const apiKey = await this.getProviderApiKey("elevenlabs");
305
- const key = apiKey || process.env.ELEVENLABS_API_KEY;
305
+ const key = apiKey || envSecret("ELEVENLABS_API_KEY");
306
306
 
307
307
  if (!key) {
308
308
  throw new Error("ELEVENLABS_API_KEY not configured");
@@ -341,7 +341,7 @@ class VoiceService {
341
341
 
342
342
  private async speakWithOpenAI(text: string, modelId: string = "gpt-4o-mini-tts", voiceId?: string): Promise<AudioOutput> {
343
343
  const apiKey = await this.getProviderApiKey("openai-tts");
344
- const key = apiKey || process.env.OPENAI_API_KEY;
344
+ const key = apiKey || envSecret("OPENAI_API_KEY");
345
345
 
346
346
  if (!key) {
347
347
  throw new Error("OPENAI_API_KEY not configured");
@@ -377,7 +377,7 @@ class VoiceService {
377
377
  }
378
378
 
379
379
  private async speakWithGemini(text: string, modelId: string, voiceId?: string): Promise<AudioOutput> {
380
- const key = process.env.GEMINI_API_KEY;
380
+ const key = envSecret("GEMINI_API_KEY");
381
381
 
382
382
  if (!key) {
383
383
  throw new Error("GEMINI_API_KEY not configured");
@@ -431,7 +431,7 @@ class VoiceService {
431
431
  }
432
432
 
433
433
  private async speakWithQwen(text: string, modelId: string, voiceId?: string): Promise<AudioOutput> {
434
- const key = process.env.DASHSCOPE_API_KEY;
434
+ const key = envSecret("DASHSCOPE_API_KEY");
435
435
 
436
436
  if (!key) {
437
437
  throw new Error("DASHSCOPE_API_KEY not configured");
@@ -485,11 +485,11 @@ class VoiceService {
485
485
  ]);
486
486
 
487
487
  return {
488
- groq: groq || !!(process.env.GROQ_API_KEY),
489
- elevenlabs: elevenlabs || !!(process.env.ELEVENLABS_API_KEY),
490
- openai: openai || !!(process.env.OPENAI_API_KEY),
491
- gemini: gemini || !!(process.env.GEMINI_API_KEY),
492
- qwen: qwen || !!(process.env.DASHSCOPE_API_KEY),
488
+ groq: groq || !!(envSecret("GROQ_API_KEY")),
489
+ elevenlabs: elevenlabs || !!(envSecret("ELEVENLABS_API_KEY")),
490
+ openai: openai || !!(envSecret("OPENAI_API_KEY")),
491
+ gemini: gemini || !!(envSecret("GEMINI_API_KEY")),
492
+ qwen: qwen || !!(envSecret("DASHSCOPE_API_KEY")),
493
493
  };
494
494
  }
495
495
 
@@ -561,7 +561,7 @@ class VoiceService {
561
561
 
562
562
  async getElevenLabsVoices(): Promise<Array<{ id: string; name: string; category: string }>> {
563
563
  const apiKey = await this.getProviderApiKey("elevenlabs");
564
- const key = apiKey || process.env.ELEVENLABS_API_KEY;
564
+ const key = apiKey || envSecret("ELEVENLABS_API_KEY");
565
565
 
566
566
  if (!key) {
567
567
  throw new Error("ELEVENLABS_API_KEY not configured");