App Keys 密钥治理 深度解析
这页拆 Rome 的 write-only app key:值能写入 app 环境,但 API 和 UI 都不能读回明文。
导入链与契约
共享契约在 packages/api-types/src/app-keys.ts:6 到 packages/api-types/src/app-keys.ts:15 先规定 wire DTO 不带 value;packages/web/src/lib/app-keys-api.ts:6 到 packages/web/src/lib/app-keys-api.ts:25 让前端只调用列表、保存、删除三个 API;packages/web/src/components/connections/app-keys-section.tsx:19 到 packages/web/src/components/connections/app-keys-section.tsx:22 又把 replace 表单设计成永远重输 value。packages/core/src/api/routes/app-keys.ts:16 是只读列表入口,packages/core/src/api/routes/app-keys.ts:28 是写入入口,packages/core/src/api/routes/app-keys.ts:54 负责持久化,packages/core/src/api/routes/app-keys.ts:71 负责删除后刷新;其余校验行分散在后文逐段解释,避免把源码证据堆成不可读的行号清单。 这些路由行共同证明秘密值没有读回路径。
1// App keys — guardian-entered named values injected into the Rome process 2// environment for apps to read. Shared contract between core's routes, the 3// dashboard, and mock mode. Name validation lives here rather than in core so 4// the mock rejects exactly the names the server rejects. 5 6/** The value-free wire shape. Values never travel: the API returns names and 7 * labels only, and `overridden` says a real environment variable is shadowing 8 * the stored value (operator env always wins). */ 9export interface AppKeyDto { 10 name: string; 11 label: string; 12 /** ISO-8601 timestamp of the last write. */ 13 updatedAt: string; 14 overridden: boolean; 15} 16 17export interface AppKeysListResponse { 18 keys: AppKeyDto[]; 19} 20 21export const APP_KEY_MAX_NAME_LENGTH = 64; 22export const APP_KEY_MAX_LABEL_LENGTH = 120; 23export const APP_KEY_MAX_VALUE_LENGTH = 32768; 24 25const NAME_PATTERN = /^[A-Z][A-Z0-9_]*$/; 26 27// An app key lands in `process.env`, so a name that collides with platform 28// configuration could reroute the database, the API ports, or an upstream 29// credential. Config env names are only expressed as inline `env.X` reads in 30// core's `config.ts` (`envToRawConfig`) — there is no enumerable schema to
核心实现,逐段拆
▸API route 只返回投影
packages/core/src/api/routes/app-keys.ts:16 到 packages/core/src/api/routes/app-keys.ts:25 把 repository rows 映射成 name、label、updatedAt、overridden;packages/core/src/api/routes/app-keys.ts:37 到 packages/core/src/api/routes/app-keys.ts:45 拒绝空值和超长值;packages/core/src/api/routes/app-keys.ts:54 到 packages/core/src/api/routes/app-keys.ts:61 写入后按 injector 返回结果决定是否刷新 runtime。
16 app.get("/app-keys", async (c) => { 17 const rows = await deps.appKeysRepo.list(); 18 return c.json({ 19 keys: rows.map((row) => ({ 20 name: row.name, 21 label: row.label, 22 updatedAt: row.updatedAt.toISOString(), 23 overridden: deps.appKeyInjector.isOverridden(row.name), 24 })), 25 }); 26 }); 27 28 app.put("/app-keys/:name", async (c) => { 29 const name = c.req.param("name"); 30 const nameError = appKeyNameError(name); 31 if (nameError) return c.json({ error: nameError }, 400); 32 33 const body = await c.req 34 .json<{ label?: unknown; value?: unknown }>() 35 .catch(() => ({}) as { label?: unknown; value?: unknown }); 36 37 if (typeof body.value !== "string" || body.value.length === 0) { 38 return c.json({ error: "Value is required." }, 400); 39 } 40 if (body.value.length > APP_KEY_MAX_VALUE_LENGTH) { 41 return c.json( 42 { error: `Value must be at most ${APP_KEY_MAX_VALUE_LENGTH} characters.` }, 43 400, 44 ); 45 } 46 const label = typeof body.label === "string" && body.label.trim() ? body.label.trim() : name; 47 if (label.length > APP_KEY_MAX_LABEL_LENGTH) { 48 return c.json( 49 { error: `Label must be at most ${APP_KEY_MAX_LABEL_LENGTH} characters.` }, 50 400, 51 ); 52 } 53 54 await deps.appKeysRepo.upsert({ name, label, value: body.value }); 55 const live = deps.appKeyInjector.apply(name, body.value); 56 // Running app code captured the old environment: warm workers hold a 57 // fork-time snapshot, and cached main-process modules (API handlers, 58 // hooks) may have read env at module scope. Refresh them whenever the 59 // environment actually changed (an overridden save changes nothing). 60 if (live) await deps.refreshAppRuntime(); 61 return c.json({ ok: true, overridden: !live }); 62 }); 63 64 app.delete("/app-keys/:name", async (c) => { 65 const name = c.req.param("name"); 66 const existing = await deps.appKeysRepo.get(name); 67 if (!existing) return c.json({ error: "Unknown app key." }, 404); 68 await deps.appKeysRepo.delete(name); 69 // Same staleness as the save path: without a refresh, running app code 70 // keeps serving the deleted secret until Rome restarts. 71 if (deps.appKeyInjector.remove(name)) await deps.refreshAppRuntime(); 72 return c.json({ ok: true }); 73 }); 74 75 return app; 76}
▸Repository 保留 value-free list
packages/core/src/db/repositories/app-keys.ts:13 到 packages/core/src/db/repositories/app-keys.ts:21 明确 summary 不带 value,packages/core/src/db/repositories/app-keys.ts:26 到 packages/core/src/db/repositories/app-keys.ts:35 的 list 只 select 四个字段;只有 packages/core/src/db/repositories/app-keys.ts:38 到 packages/core/src/db/repositories/app-keys.ts:40 的 listWithValues 能读出明文,注释限定给启动和注入路径使用。
13/** The value-free projection the API returns. The secret only leaves this 14 * repository through `listWithValues`, whose sole consumer is the boot/save 15 * injector. */ 16export interface AppKeySummary { 17 name: string; 18 label: string; 19 createdAt: Date; 20 updatedAt: Date; 21} 22 23export class AppKeysRepository { 24 constructor(private db: DrizzleDb) {} 25 26 async list(): Promise<AppKeySummary[]> { 27 const rows = await this.db 28 .select({ 29 name: appKeys.name, 30 label: appKeys.label, 31 createdAt: appKeys.createdAt, 32 updatedAt: appKeys.updatedAt, 33 }) 34 .from(appKeys); 35 return rows.sort((a, b) => a.name.localeCompare(b.name)); 36 } 37 38 async listWithValues(): Promise<AppKeyRow[]> { 39 return await this.db.select().from(appKeys); 40 } 41 42 async get(name: string): Promise<AppKeyRow | null> { 43 const rows = await this.db.select().from(appKeys).where(eq(appKeys.name, name)); 44 return rows[0] ?? null; 45 } 46 47 // One atomic upsert rather than a read-then-branch: two concurrent writers to 48 // the same name would both observe an empty read and race into a duplicate 49 // insert on the `app_keys.name` primary key. 50 async upsert(input: { name: string; label: string; value: string }): Promise<void> { 51 const now = new Date(); 52 await this.db 53 .insert(appKeys) 54 .values({ ...input, createdAt: now, updatedAt: now }) 55 .onConflictDoUpdate({ 56 target: appKeys.name, 57 set: { label: input.label, value: input.value, updatedAt: now }, 58 }); 59 } 60 61 async delete(name: string): Promise<void> { 62 await this.db.delete(appKeys).where(eq(appKeys.name, name)); 63 } 64}
▸Injector 尊重 operator env
packages/core/src/app-keys/injector.ts:5 到 packages/core/src/app-keys/injector.ts:15 写出两个不变量:真实环境变量优先,以及只删除自己写过的键。packages/core/src/app-keys/injector.ts:25、packages/core/src/app-keys/injector.ts:32、packages/core/src/app-keys/injector.ts:40、packages/core/src/app-keys/injector.ts:42 是这个状态机的关键转移点。
5/** 6 * Owns the stored-key slice of `process.env`. Two invariants: 7 * 8 * - The real process environment always wins. A name that already had a value 9 * before injection is never overwritten — operator config (env files, CI, 10 * container env) cannot be shadowed from the dashboard. Such keys are 11 * reported as overridden instead of silently going live. 12 * - Only names this injector set are ever deleted or scrubbed. It tracks its 13 * own writes, so removing a stored key can never unset an operator-set var 14 * that happens to share the name. 15 */ 16export class AppKeyInjector { 17 private injected = new Set<string>(); 18 private overridden = new Set<string>(); 19 20 constructor(private env: NodeJS.ProcessEnv = process.env) {} 21 22 /** Applies a stored key. Returns true when the value went live, false when a 23 * pre-existing environment value kept precedence. */ 24 apply(name: string, value: string): boolean { 25 if (!this.injected.has(name) && this.env[name] !== undefined) { 26 this.overridden.add(name); 27 logger.warn( 28 `App key ${name} is overridden by the process environment; the stored value is not live`, 29 ); 30 return false; 31 } 32 this.env[name] = value; 33 this.injected.add(name); 34 return true; 35 } 36 37 /** Removes a stored key from the environment, only if this injector set it. 38 * Returns true when the environment actually changed. */ 39 remove(name: string): boolean { 40 this.overridden.delete(name); 41 if (!this.injected.has(name)) return false; 42 delete this.env[name]; 43 this.injected.delete(name); 44 return true; 45 } 46 47 isOverridden(name: string): boolean { 48 return this.overridden.has(name); 49 } 50}
关键流程
UI form → /api/app-keys/:name → AppKeysRepository.upsert → AppKeyInjector.apply → refreshAppRuntime delete → repository.delete → injector.remove → refresh only when env changed
UI 侧 packages/web/src/components/connections/app-keys-section.tsx:77 到 packages/web/src/components/connections/app-keys-section.tsx:89 先做客户端校验和 upper-case name;packages/web/src/components/connections/app-keys-section.tsx:157 到 packages/web/src/components/connections/app-keys-section.tsx:163 显示 overridden/set 状态;packages/web/src/components/connections/app-keys-section.tsx:210 到 packages/web/src/components/connections/app-keys-section.tsx:220 使用 password input 且 value 不回填。这个流程把用户体验和服务端安全模型对齐。
77 const submit = () => { 78 const trimmedName = name.trim(); 79 const nameError = appKeyNameError(trimmedName); 80 if (nameError) { 81 setFormError(nameError); 82 return; 83 } 84 if (!value) { 85 setFormError(t("appKeys.form.valueRequired")); 86 return; 87 } 88 saveMutation.mutate({ name: trimmedName, label: label.trim() || trimmedName, value }); 89 };
边界 / 失败模式
packages/core/src/api/routes/app-keys.test.ts:33到packages/core/src/api/routes/app-keys.test.ts:48验证 list 不泄漏 hunter2。packages/core/src/api/routes/app-keys.test.ts:71到packages/core/src/api/routes/app-keys.test.ts:84验证 operator env 覆盖 dashboard 值。packages/core/src/api/routes/app-keys.test.ts:86到packages/core/src/api/routes/app-keys.test.ts:157验证 warm worker pool 因密钥变化重启。packages/core/src/app-keys/reserved.test.ts:21到packages/core/src/app-keys/reserved.test.ts:30验证 PATH、ROME、ANTHROPIC、DATABASE 等保留名被拒绝。