浏览代码

feat: API Key 只能使用指定的 Profile,权限模式由 Profile 决定

API Key
- 每个 Key 配置可用的 Profile(至少一个)和默认 Profile(必须在可用列表中)
- GET /v1/profiles 只返回该 Key 可用的 Profile,isDefault 表示该平台的默认 Profile
- 创建会话不指定 Profile 时使用 Key 的默认 Profile;使用不可用的 Profile
  返回 403 profile_not_allowed
- 每次发送消息前检查会话的 Profile 是否仍对该 Key 开放,撤销后拒绝继续运行
- 后台 API Keys 页:签发时选择 Profile,新增"配置 Profile"对话框,
  列表中显示可用的和默认的 Profile

模型与权限模式
- 会话可以自选模型,限定在该引擎的可选模型和 Profile 的模型范围内;
  Profile 只决定默认模型
- 权限模式完全由 Profile 决定:平台创建或修改会话时传 permissionMode
  返回 400;会话每次运行时读取 Profile 当前的设置
  (修复了平台可以把权限模式改为 auto 来跳过审批的问题)
- 后台 Playground 仍可以为单个会话临时覆盖权限模式,并可选回"跟随 Profile"

Profile
- 新增 builtin 标记:内置的"通用助手"不可删除
- 被任一 API Key 引用的 Profile 不可删除,删除时会提示是哪些 Key 在用

数据库迁移 0001_api_key_profiles
- api_keys 新增 allowed_profile_ids、default_profile_id;profiles 新增 builtin;
  sessions.permission_mode 改为可空(空表示跟随 Profile)
- 数据升级:通用助手标记为内置;已有 Key 只开放通用助手并设为默认;
  平台会话以及与 Profile 一致的后台会话改为跟随 Profile

文档:新增 ADR-0015,更新 04-api、05-data-model

测试:40 个通过(新增 Profile 可见范围与 403、模型校验、权限模式不可覆盖且
修改 Profile 后立即生效、撤销后拒绝运行、内置 Profile 和被引用 Profile 不可删除)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 1 周之前
父节点
当前提交
1782804006

+ 3 - 2
apps/admin/src/lib/api.ts

@@ -1,7 +1,7 @@
 // 后台 API:同源 Cookie 鉴权,复用 AgentClient 的请求与错误处理
 import { AgentClient } from '@agent-server/client'
 import type {
-  ApiKeyDto, AppSettings, DeepPartial, FileAccessPolicy, KnowledgeRootDto, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput,
+  ApiKeyDto, ApiKeyProfiles, AppSettings, DeepPartial, FileAccessPolicy, KnowledgeRootDto, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput,
   ProviderStatusDto, ResolvedAccessDto, SkillInfo,
 } from '@agent-server/shared'
 
@@ -40,7 +40,8 @@ export const admin = {
   updateKnowledge: (id: string, k: KnowledgeInput) => req<KnowledgeRootDto>('PUT', `/knowledge/${id}`, k),
   deleteKnowledge: (id: string) => req<void>('DELETE', `/knowledge/${id}`),
   apiKeys: () => req<ApiKeyDto[]>('GET', '/api-keys'),
-  createApiKey: (name: string) => req<{ key: string; apiKey: ApiKeyDto }>('POST', '/api-keys', { name }),
+  createApiKey: (name: string, access: ApiKeyProfiles) => req<{ key: string; apiKey: ApiKeyDto }>('POST', '/api-keys', { name, ...access }),
+  setApiKeyProfiles: (id: string, access: ApiKeyProfiles) => req<{ ok: true }>('PUT', `/api-keys/${id}/profiles`, access),
   setApiKeyEnabled: (id: string, enabled: boolean) => req<{ ok: true }>('PATCH', `/api-keys/${id}`, { enabled }),
   deleteApiKey: (id: string) => req<void>('DELETE', `/api-keys/${id}`),
   audit: (q: { kind?: string; sessionId?: string; limit?: number } = {}) => {

+ 96 - 25
apps/admin/src/views/ApiKeysView.vue

@@ -1,27 +1,62 @@
 <script setup lang="ts">
-import { onMounted, ref } from 'vue'
-import type { ApiKeyDto } from '@agent-server/shared'
+import { computed, onMounted, reactive, ref } from 'vue'
+import type { ApiKeyDto, ProfileDto } from '@agent-server/shared'
 import PageHeader from '../components/PageHeader.vue'
 import { admin, errorMessage } from '../lib/api'
 import { formatTime, toast } from '../lib/ui'
 
 const keys = ref<ApiKeyDto[]>([])
-const name = ref('')
+const profiles = ref<ProfileDto[]>([])
 const created = ref<{ key: string; name: string } | null>(null)
+const builtinId = computed(() => profiles.value.find(p => p.builtin)?.id ?? profiles.value[0]?.id ?? '')
+const profileName = (id: string | null) => profiles.value.find(p => p.id === id)?.name ?? '(已删除)'
+
+/** 新建或编辑:可用的 Profile(至少一个)+ 默认 Profile */
+const editor = reactive<{ open: boolean; id: string | null; name: string; allowed: string[]; def: string; saving: boolean }>({
+  open: false, id: null, name: '', allowed: [], def: '', saving: false,
+})
+const editorValid = computed(() => editor.allowed.length > 0 && editor.allowed.includes(editor.def) && (editor.id !== null || editor.name.trim().length > 0))
 
 async function load() {
-  keys.value = await admin.apiKeys().catch(err => (toast(errorMessage(err), 'error'), []))
+  try {
+    ;[keys.value, profiles.value] = await Promise.all([admin.apiKeys(), admin.profiles()])
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+function openCreate() {
+  Object.assign(editor, { open: true, id: null, name: '', allowed: [builtinId.value], def: builtinId.value })
+}
+
+function openEdit(k: ApiKeyDto) {
+  Object.assign(editor, { open: true, id: k.id, name: k.name, allowed: [...k.allowedProfileIds], def: k.defaultProfileId ?? k.allowedProfileIds[0] ?? '' })
+}
+
+function toggleAllowed(id: string) {
+  editor.allowed = editor.allowed.includes(id) ? editor.allowed.filter(x => x !== id) : [...editor.allowed, id]
+  // 默认 Profile 必须在可用列表中
+  if (!editor.allowed.includes(editor.def)) editor.def = editor.allowed[0] ?? ''
 }
 
-async function create() {
-  if (!name.value.trim()) return
+async function save() {
+  if (!editorValid.value) return
+  editor.saving = true
+  const access = { allowedProfileIds: editor.allowed, defaultProfileId: editor.def }
   try {
-    const r = await admin.createApiKey(name.value.trim())
-    created.value = { key: r.key, name: r.apiKey.name }
-    name.value = ''
+    if (editor.id) {
+      await admin.setApiKeyProfiles(editor.id, access)
+      toast('已保存', 'success')
+    } else {
+      const r = await admin.createApiKey(editor.name.trim(), access)
+      created.value = { key: r.key, name: r.apiKey.name }
+    }
+    editor.open = false
     await load()
   } catch (err) {
     toast(errorMessage(err), 'error')
+  } finally {
+    editor.saving = false
   }
 }
 
@@ -50,13 +85,10 @@ onMounted(load)
 
 <template>
   <div class="h-full flex flex-col">
-    <PageHeader title="API Keys" subtitle="每个接入的业务平台一个 Key,调用时放在 Authorization: Bearer 头中" />
+    <PageHeader title="API Keys" subtitle="每个接入的业务平台一个 Key,只能使用为它勾选的 Profile">
+      <button type="button" class="btn-primary" @click="openCreate"><span class="codicon codicon-add" />签发 API Key</button>
+    </PageHeader>
     <div class="flex-1 overflow-y-auto p-6 flex flex-col gap-5 max-w-240">
-      <form class="flex gap-2" @submit.prevent="create">
-        <input v-model="name" class="field max-w-80" placeholder="平台名称,例如:家庭记账" aria-label="名称">
-        <button type="submit" class="btn-primary" :disabled="!name.trim()"><span class="codicon codicon-add" />签发</button>
-      </form>
-
       <div v-if="created" class="card p-4 flex flex-col gap-2 border-accent">
         <div class="font-600">「{{ created.name }}」的 API Key(只显示这一次,请立即保存)</div>
         <div class="flex gap-2">
@@ -69,25 +101,64 @@ onMounted(load)
       <table class="w-full border-collapse text-[12.5px]">
         <thead>
           <tr class="text-left text-fg-muted">
-            <th class="py-2 font-500">名称</th><th class="font-500">前缀</th><th class="font-500">状态</th>
-            <th class="font-500">创建时间</th><th class="font-500">最近使用</th><th />
+            <th class="py-2 font-500">名称</th><th class="font-500">可用的 Profile</th><th class="font-500">状态</th>
+            <th class="font-500">最近使用</th><th />
           </tr>
         </thead>
         <tbody>
-          <tr v-for="k in keys" :key="k.id" class="border-t border-t-solid border-border">
-            <td class="py-2">{{ k.name }}</td>
-            <td class="font-mono">{{ k.prefix }}…</td>
-            <td><span class="badge" :class="k.enabled ? 'bg-success text-white' : 'bg-code text-fg-muted'">{{ k.enabled ? '启用' : '停用' }}</span></td>
-            <td>{{ formatTime(k.createdAt) }}</td>
-            <td>{{ formatTime(k.lastUsedAt) }}</td>
-            <td class="text-right whitespace-nowrap">
+          <tr v-for="k in keys" :key="k.id" class="border-t border-t-solid border-border align-top">
+            <td class="py-2">
+              <div>{{ k.name }}</div>
+              <div class="font-mono hint">{{ k.prefix }}…</div>
+            </td>
+            <td class="py-2">
+              <div class="flex flex-wrap gap-1">
+                <span v-for="pid in k.allowedProfileIds" :key="pid" class="badge bg-code" :class="{ '!text-accent': pid === k.defaultProfileId }">
+                  {{ profileName(pid) }}<template v-if="pid === k.defaultProfileId"> · 默认</template>
+                </span>
+              </div>
+            </td>
+            <td class="py-2"><span class="badge" :class="k.enabled ? 'bg-success text-white' : 'bg-code text-fg-muted'">{{ k.enabled ? '启用' : '停用' }}</span></td>
+            <td class="py-2">{{ formatTime(k.lastUsedAt) }}</td>
+            <td class="py-1 text-right whitespace-nowrap">
+              <button type="button" class="btn-ghost" @click="openEdit(k)">配置 Profile</button>
               <button type="button" class="btn-ghost" @click="toggle(k)">{{ k.enabled ? '停用' : '启用' }}</button>
               <button type="button" class="icon-btn" title="删除" @click="remove(k)"><span class="codicon codicon-trash" /></button>
             </td>
           </tr>
-          <tr v-if="!keys.length"><td colspan="6" class="py-4 hint">还没有 API Key</td></tr>
+          <tr v-if="!keys.length"><td colspan="5" class="py-4 hint">还没有 API Key</td></tr>
         </tbody>
       </table>
     </div>
+
+    <div v-if="editor.open" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="editor.open = false">
+      <form class="w-[520px] card bg-input-alt p-5 flex flex-col gap-4 shadow-xl" @submit.prevent="save">
+        <div class="font-600 text-[14px]">{{ editor.id ? `配置「${editor.name}」可用的 Profile` : '签发 API Key' }}</div>
+        <div v-if="!editor.id">
+          <label class="label" for="key-name">平台名称</label>
+          <input id="key-name" v-model="editor.name" class="field" placeholder="例如:家庭记账" autofocus>
+        </div>
+        <div>
+          <div class="label">可用的 Profile(至少选一个)与默认 Profile</div>
+          <div class="flex flex-col gap-1 max-h-72 overflow-y-auto">
+            <div v-for="p in profiles" :key="p.id" class="flex items-center gap-2 px-2 h-8 rounded-md hover:bg-hover">
+              <label class="flex items-center gap-2 flex-1 min-w-0 cursor-pointer">
+                <input type="checkbox" :checked="editor.allowed.includes(p.id)" @change="toggleAllowed(p.id)">
+                <span class="truncate">{{ p.name }}</span>
+                <span class="hint truncate">{{ p.description }}</span>
+              </label>
+              <label class="flex items-center gap-1 text-[12px] flex-none" :class="editor.allowed.includes(p.id) ? 'cursor-pointer' : 'opacity-40'">
+                <input v-model="editor.def" type="radio" name="default-profile" :value="p.id" :disabled="!editor.allowed.includes(p.id)">默认
+              </label>
+            </div>
+          </div>
+          <div class="hint mt-1">平台调用 <code class="font-mono">GET /v1/profiles</code> 只会看到勾选的 Profile;创建会话不指定 Profile 时使用默认的那个。权限模式由 Profile 决定,平台不能修改。</div>
+        </div>
+        <div class="flex justify-end gap-2">
+          <button type="button" class="btn-secondary" @click="editor.open = false">取消</button>
+          <button type="submit" class="btn-primary" :disabled="!editorValid || editor.saving">{{ editor.id ? '保存' : '签发' }}</button>
+        </div>
+      </form>
+    </div>
   </div>
 </template>

+ 9 - 3
apps/admin/src/views/PlaygroundView.vue

@@ -76,7 +76,12 @@ async function newSession(profileId?: string) {
   }
 }
 
-async function update(patch: { model?: string; permissionMode?: PermissionMode; profileId?: string; systemPromptAppend?: string; title?: string }) {
+/** 空值表示恢复为跟随 Profile */
+function setMode(v: string) {
+  void update({ permissionMode: v ? (v as PermissionMode) : null })
+}
+
+async function update(patch: { model?: string; permissionMode?: PermissionMode | null; profileId?: string; systemPromptAppend?: string; title?: string }) {
   if (!detail.value) return
   try {
     await client.sessions.update(detail.value.id, patch)
@@ -208,8 +213,9 @@ onBeforeUnmount(() => {
                 </label>
                 <label class="pill" title="权限模式">
                   <span class="codicon text-[12px]" :class="detail.permissionMode === 'auto' ? 'codicon-unlock' : detail.permissionMode === 'readonly' ? 'codicon-eye' : 'codicon-shield'" />
-                  <select class="pill-select" :value="detail.permissionMode" :disabled="!idle" aria-label="权限模式" @change="update({ permissionMode: ($event.target as HTMLSelectElement).value as PermissionMode })">
-                    <option v-for="(label, k) in PERMISSION_LABELS" :key="k" :value="k">{{ label }}</option>
+                  <select class="pill-select" :value="detail.permissionModeOverridden ? detail.permissionMode : ''" :disabled="!idle" aria-label="权限模式" @change="setMode(($event.target as HTMLSelectElement).value)">
+                    <option value="">跟随 Profile{{ detail.permissionModeOverridden ? '' : `(${PERMISSION_LABELS[detail.permissionMode]})` }}</option>
+                    <option v-for="(label, k) in PERMISSION_LABELS" :key="k" :value="k">{{ label }}(仅本会话)</option>
                   </select>
                 </label>
               </template>

+ 3 - 3
apps/admin/src/views/ProfilesView.vue

@@ -29,7 +29,7 @@ const blank = (): ProfileInput => ({
 })
 
 function toInput(p: ProfileDto): ProfileInput {
-  const { id: _id, version: _v, updatedAt: _u, ...rest } = clone(p)
+  const { id: _id, version: _v, updatedAt: _u, builtin: _b, ...rest } = clone(p)
   return rest
 }
 
@@ -121,7 +121,7 @@ onMounted(() => load().catch(err => toast(errorMessage(err), 'error')))
         v-for="p in profiles" :key="p.id" class="flex flex-col justify-center h-[44px] pl-5 pr-3 cursor-pointer hover:bg-hover"
         :class="{ '!bg-active': p.id === id }" @click="router.push(`/profiles/${p.id}`)"
       >
-        <div class="truncate">{{ p.name }}<span v-if="p.isDefault" class="hint"> · 默认</span></div>
+        <div class="truncate">{{ p.name }}<span v-if="p.builtin" class="hint"> · 内置</span><span v-if="p.isDefault" class="hint"> · 默认</span></div>
         <div class="text-[11px] text-fg-muted truncate">{{ p.provider }} / {{ p.model }} · {{ PERMISSION_LABELS[p.permissionMode] }}</div>
       </div>
     </template>
@@ -129,7 +129,7 @@ onMounted(() => load().catch(err => toast(errorMessage(err), 'error')))
     <div v-if="!draft" class="flex-1 flex items-center justify-center hint">选择或新建一个 Profile</div>
     <template v-else>
       <PageHeader :title="isNew ? '新建 Profile' : draft.name" :subtitle="current ? `版本 ${current.version}` : undefined">
-        <button v-if="current && !current.isDefault" type="button" class="btn-danger" @click="remove">删除</button>
+        <button v-if="current && !current.isDefault && !current.builtin" type="button" class="btn-danger" @click="remove">删除</button>
         <button type="button" class="btn-primary" :disabled="saving || !draft.name.trim()" @click="save">{{ saving ? '保存中…' : '保存' }}</button>
       </PageHeader>
       <div class="flex-1 min-h-0 overflow-y-auto">

+ 34 - 0
apps/server/drizzle/0001_api_key_profiles.sql

@@ -0,0 +1,34 @@
+PRAGMA foreign_keys=OFF;--> statement-breakpoint
+CREATE TABLE `__new_sessions` (
+	`id` text PRIMARY KEY NOT NULL,
+	`source` text NOT NULL,
+	`profile_id` text,
+	`provider` text NOT NULL,
+	`model` text NOT NULL,
+	`permission_mode` text,
+	`system_prompt_append` text DEFAULT '' NOT NULL,
+	`provider_session_id` text,
+	`workspace_dir` text NOT NULL,
+	`title` text DEFAULT '' NOT NULL,
+	`last_seq` integer DEFAULT 0 NOT NULL,
+	`archived` integer DEFAULT false NOT NULL,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL
+);
+--> statement-breakpoint
+INSERT INTO `__new_sessions`("id", "source", "profile_id", "provider", "model", "permission_mode", "system_prompt_append", "provider_session_id", "workspace_dir", "title", "last_seq", "archived", "created_at", "updated_at") SELECT "id", "source", "profile_id", "provider", "model", "permission_mode", "system_prompt_append", "provider_session_id", "workspace_dir", "title", "last_seq", "archived", "created_at", "updated_at" FROM `sessions`;--> statement-breakpoint
+DROP TABLE `sessions`;--> statement-breakpoint
+ALTER TABLE `__new_sessions` RENAME TO `sessions`;--> statement-breakpoint
+PRAGMA foreign_keys=ON;--> statement-breakpoint
+CREATE INDEX `sessions_source_idx` ON `sessions` (`source`,`updated_at`);--> statement-breakpoint
+ALTER TABLE `api_keys` ADD `allowed_profile_ids` text DEFAULT '[]' NOT NULL;--> statement-breakpoint
+ALTER TABLE `api_keys` ADD `default_profile_id` text;--> statement-breakpoint
+ALTER TABLE `profiles` ADD `builtin` integer DEFAULT false NOT NULL;--> statement-breakpoint
+-- 数据迁移:自带的"通用助手"标记为内置(不可删除)
+UPDATE `profiles` SET `builtin` = 1 WHERE `id` = (SELECT `id` FROM `profiles` ORDER BY (`name` = '通用助手') DESC, `created_at` ASC LIMIT 1);--> statement-breakpoint
+-- 已有的 API Key 只开放内置 Profile,并设为默认
+UPDATE `api_keys` SET `allowed_profile_ids` = json_array((SELECT `id` FROM `profiles` WHERE `builtin` = 1)), `default_profile_id` = (SELECT `id` FROM `profiles` WHERE `builtin` = 1) WHERE EXISTS (SELECT 1 FROM `profiles` WHERE `builtin` = 1);--> statement-breakpoint
+-- 平台创建的会话:权限模式改为跟随 Profile
+UPDATE `sessions` SET `permission_mode` = NULL WHERE `source` <> 'admin';--> statement-breakpoint
+-- 后台会话:与所用 Profile 相同的权限模式也改为跟随 Profile
+UPDATE `sessions` SET `permission_mode` = NULL WHERE `permission_mode` = (SELECT `p`.`permission_mode` FROM `profiles` `p` WHERE `p`.`id` = `sessions`.`profile_id`);

+ 906 - 0
apps/server/drizzle/meta/0001_snapshot.json

@@ -0,0 +1,906 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "3e8e056e-165d-4d73-8242-7b5beb6fbac2",
+  "prevId": "141f9f20-7e65-46e9-a8b2-be1e32e8f27b",
+  "tables": {
+    "admin_sessions": {
+      "name": "admin_sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "api_keys": {
+      "name": "api_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "allowed_profile_ids": {
+          "name": "allowed_profile_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "default_profile_id": {
+          "name": "default_profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "api_keys_key_hash_unique": {
+          "name": "api_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "audit_logs": {
+      "name": "audit_logs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "integer",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": true
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "actor": {
+          "name": "actor",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "summary": {
+          "name": "summary",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "detail": {
+          "name": "detail",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "audit_ts_idx": {
+          "name": "audit_ts_idx",
+          "columns": [
+            "ts"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "events": {
+      "name": "events",
+      "columns": {
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "seq": {
+          "name": "seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "payload": {
+          "name": "payload",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "events_session_id_seq_pk": {
+          "columns": [
+            "session_id",
+            "seq"
+          ],
+          "name": "events_session_id_seq_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "files": {
+      "name": "files",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "rel_path": {
+          "name": "rel_path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mime": {
+          "name": "mime",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "size": {
+          "name": "size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mtime_ms": {
+          "name": "mtime_ms",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "files_session_path_idx": {
+          "name": "files_session_path_idx",
+          "columns": [
+            "session_id",
+            "rel_path"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "interactions": {
+      "name": "interactions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "request": {
+          "name": "request",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "response": {
+          "name": "response",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "resolved_at": {
+          "name": "resolved_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "interactions_session_idx": {
+          "name": "interactions_session_idx",
+          "columns": [
+            "session_id",
+            "status"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "knowledge_roots": {
+      "name": "knowledge_roots",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "access": {
+          "name": "access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'read'"
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "profiles": {
+      "name": "profiles",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "system_prompt": {
+          "name": "system_prompt",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'ask'"
+        },
+        "allowed_tools": {
+          "name": "allowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "disallowed_tools": {
+          "name": "disallowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "skills": {
+          "name": "skills",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'\"all\"'"
+        },
+        "file_access": {
+          "name": "file_access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "knowledge_root_ids": {
+          "name": "knowledge_root_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "is_default": {
+          "name": "is_default",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "builtin": {
+          "name": "builtin",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 1
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "runs": {
+      "name": "runs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "error": {
+          "name": "error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "runs_session_idx": {
+          "name": "runs_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "sessions": {
+      "name": "sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "system_prompt_append": {
+          "name": "system_prompt_append",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider_session_id": {
+          "name": "provider_session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "workspace_dir": {
+          "name": "workspace_dir",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "last_seq": {
+          "name": "last_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "archived": {
+          "name": "archived",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "sessions_source_idx": {
+          "name": "sessions_source_idx",
+          "columns": [
+            "source",
+            "updated_at"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "settings": {
+      "name": "settings",
+      "columns": {
+        "key": {
+          "name": "key",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "value": {
+          "name": "value",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    }
+  },
+  "views": {},
+  "enums": {},
+  "_meta": {
+    "schemas": {},
+    "tables": {},
+    "columns": {}
+  },
+  "internal": {
+    "indexes": {}
+  }
+}

+ 7 - 0
apps/server/drizzle/meta/_journal.json

@@ -8,6 +8,13 @@
       "when": 1792660103685,
       "tag": "0000_init",
       "breakpoints": true
+    },
+    {
+      "idx": 1,
+      "version": "6",
+      "when": 1792740352358,
+      "tag": "0001_api_key_profiles",
+      "breakpoints": true
     }
   ]
 }

+ 2 - 2
apps/server/src/app.ts

@@ -31,10 +31,10 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   const files = new FileService(db)
   const profiles = new ProfileService(db, settings)
   const knowledge = new KnowledgeService(db, settings)
-  const apiKeys = new ApiKeyService(db)
+  const apiKeys = new ApiKeyService(db, profiles)
   const audit = new AuditService(db)
   const interactions = new InteractionService(db, events, settings)
-  const sessions = new SessionService(db, settings, profiles, files, events)
+  const sessions = new SessionService(db, settings, profiles, apiKeys, files, events)
   const adminAuth = new AdminAuthService(db, log, config.adminPassword)
   const plugins = new PluginService(settings, audit)
   const claudeLogin = new ClaudeLoginManager(() => settings.get().providers.claude.cliPath, summary => audit.log({ actor: 'admin', kind: 'admin', summary }))

+ 8 - 1
apps/server/src/db/schema.ts

@@ -12,6 +12,10 @@ export const apiKeys = sqliteTable('api_keys', {
   keyHash: text('key_hash').notNull().unique(),
   keyPrefix: text('key_prefix').notNull(),
   enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
+  /** 这个平台可以使用的 Profile(至少一个) */
+  allowedProfileIds: text('allowed_profile_ids', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
+  /** 平台创建会话时不指定 Profile 则使用它,必须在 allowedProfileIds 中 */
+  defaultProfileId: text('default_profile_id'),
   createdAt: createdAt(),
   lastUsedAt: text('last_used_at'),
 })
@@ -30,6 +34,8 @@ export const profiles = sqliteTable('profiles', {
   fileAccess: text('file_access', { mode: 'json' }).$type<FileAccessPolicy>().notNull(),
   knowledgeRootIds: text('knowledge_root_ids', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
   isDefault: integer('is_default', { mode: 'boolean' }).notNull().default(false),
+  /** 内置 Profile(通用助手)不可删除 */
+  builtin: integer('builtin', { mode: 'boolean' }).notNull().default(false),
   version: integer('version').notNull().default(1),
   createdAt: createdAt(),
   updatedAt: updatedAt(),
@@ -53,7 +59,8 @@ export const sessions = sqliteTable('sessions', {
   profileId: text('profile_id'),
   provider: text('provider', { enum: ['claude', 'codex'] }).notNull(),
   model: text('model').notNull(),
-  permissionMode: text('permission_mode').$type<PermissionMode>().notNull(),
+  /** 为空表示跟随 Profile;只有后台管理员可以为单个会话覆盖 */
+  permissionMode: text('permission_mode').$type<PermissionMode>(),
   systemPromptAppend: text('system_prompt_append').notNull().default(''),
   providerSessionId: text('provider_session_id'),
   workspaceDir: text('workspace_dir').notNull(),

+ 11 - 2
apps/server/src/http/routes/admin.ts

@@ -121,6 +121,8 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       return p
     })
     g.delete<IdParams>('/profiles/:id', async (req, reply) => {
+      const refs = app.apiKeys.referencing(req.params.id)
+      if (refs.length) throw new AppError(400, 'profile_in_use', `这个 Profile 正被 API Key 使用:${refs.join('、')},请先在 API Keys 页面取消勾选`)
       app.profiles.delete(req.params.id)
       app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除 Profile:${req.params.id}` })
       reply.code(204)
@@ -160,9 +162,10 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
     // API Key
     g.get('/api-keys', async () => app.apiKeys.list())
     g.post('/api-keys', async (req, reply) => {
-      const { name } = parse(z.object({ name: z.string().trim().min(1).max(60) }), req.body)
+      const body = parse(z.object({ name: z.string().trim().min(1).max(60), allowedProfileIds: z.array(z.string()), defaultProfileId: z.string() }), req.body)
       reply.code(201)
-      const r = app.apiKeys.create(name)
+      const name = body.name
+      const r = app.apiKeys.create(name, body)
       app.audit.log({ actor: 'admin', kind: 'admin', summary: `签发 API Key:${name}` })
       return r
     })
@@ -172,6 +175,12 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       app.audit.log({ actor: 'admin', kind: 'admin', summary: `${enabled ? '启用' : '停用'} API Key:${req.params.id}` })
       return { ok: true }
     })
+    g.put<IdParams>('/api-keys/:id/profiles', async req => {
+      const body = parse(z.object({ allowedProfileIds: z.array(z.string()), defaultProfileId: z.string() }), req.body)
+      app.apiKeys.setProfiles(req.params.id, body)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改 API Key 可用的 Profile:${req.params.id}`, detail: body })
+      return { ok: true }
+    })
     g.delete<IdParams>('/api-keys/:id', async (req, reply) => {
       app.apiKeys.delete(req.params.id)
       app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除 API Key:${req.params.id}` })

+ 14 - 4
apps/server/src/http/routes/v1.ts

@@ -48,9 +48,17 @@ export default async function v1Routes(f: FastifyInstance, { app }: { app: App }
     return statusCache.data
   })
 
-  f.get('/profiles', async () => app.profiles.list().map(p => ({
-    id: p.id, name: p.name, description: p.description, provider: p.provider, model: p.model, permissionMode: p.permissionMode, isDefault: p.isDefault,
-  })))
+  // 平台只能看到 API Key 上配置的 Profile;isDefault 表示该平台的默认 Profile
+  f.get('/profiles', async req => {
+    const p0 = principal(req)
+    const acc = p0.kind === 'admin' ? null : app.apiKeys.access(p0.source)
+    return app.profiles.list()
+      .filter(p => !acc || acc.allowedProfileIds.includes(p.id))
+      .map(p => ({
+        id: p.id, name: p.name, description: p.description, provider: p.provider, model: p.model, permissionMode: p.permissionMode,
+        isDefault: acc ? p.id === acc.defaultProfileId : p.isDefault,
+      }))
+  })
 
   // ---- 会话 ----
   f.post('/sessions', async (req, reply) => {
@@ -71,11 +79,13 @@ export default async function v1Routes(f: FastifyInstance, { app }: { app: App }
 
   f.get<IdParams>('/sessions/:id', async (req): Promise<SessionDetailDto> => {
     const row = sessionFor(req)
+    const eff = app.sessions.effectiveProfile(row)
     return {
       ...app.sessions.toDto(row),
       activeRunId: app.runs.activeForSession(row.id)?.runId ?? null,
       pendingInteractions: app.interactions.pendingFor(row.id),
-      permissionMode: row.permissionMode,
+      permissionMode: eff.permissionMode,
+      permissionModeOverridden: eff.overridden,
       systemPromptAppend: row.systemPromptAppend,
     }
   })

+ 59 - 10
apps/server/src/services/catalog.ts

@@ -2,10 +2,10 @@
 import crypto from 'node:crypto'
 import { and, desc, eq, ne } from 'drizzle-orm'
 import { z } from 'zod'
-import type { ApiKeyDto, FileAccessPolicy, KnowledgeRootDto, ProfileDto, ProfileInput } from '@agent-server/shared'
+import type { ApiKeyDto, ApiKeyProfiles, FileAccessPolicy, KnowledgeRootDto, ProfileDto, ProfileInput } from '@agent-server/shared'
 import type { DB } from '../db'
 import { apiKeys, auditLogs, knowledgeRoots, profiles } from '../db/schema'
-import { badRequest, notFound } from '../lib/errors'
+import { AppError, badRequest, notFound } from '../lib/errors'
 import { newId, nowIso } from '../lib/ids'
 import { checkKnowledgePath } from './access-policy'
 import type { SettingsService } from './settings'
@@ -38,7 +38,7 @@ export const profileInputSchema: z.ZodType<ProfileInput> = z.object({
 const toProfileDto = (r: ProfileRow): ProfileDto => ({
   id: r.id, name: r.name, description: r.description, provider: r.provider, model: r.model, systemPrompt: r.systemPrompt,
   permissionMode: r.permissionMode, allowedTools: r.allowedTools, disallowedTools: r.disallowedTools, skills: r.skills,
-  fileAccess: r.fileAccess, knowledgeRootIds: r.knowledgeRootIds, isDefault: r.isDefault, version: r.version, updatedAt: r.updatedAt,
+  fileAccess: r.fileAccess, knowledgeRootIds: r.knowledgeRootIds, isDefault: r.isDefault, builtin: r.builtin, version: r.version, updatedAt: r.updatedAt,
 })
 
 export class ProfileService {
@@ -60,7 +60,7 @@ export class ProfileService {
       fileAccess: { mode: this.settings.get().security.defaultFileAccessMode, read: [], write: [], deny: [] },
       knowledgeRootIds: [],
       isDefault: true,
-    })
+    }, true)
   }
 
   list(): ProfileDto[] {
@@ -80,12 +80,12 @@ export class ProfileService {
     return toProfileDto(row)
   }
 
-  create(input: ProfileInput): ProfileDto {
+  create(input: ProfileInput, builtin = false): ProfileDto {
     const data = parse(profileInputSchema, input)
     const id = newId('prf')
     this.db.transaction(tx => {
       if (data.isDefault) tx.update(profiles).set({ isDefault: false }).run()
-      tx.insert(profiles).values({ id, ...data }).run()
+      tx.insert(profiles).values({ id, ...data, builtin }).run()
     })
     return this.get(id)
   }
@@ -101,8 +101,14 @@ export class ProfileService {
     return this.get(id)
   }
 
+  getBuiltin(): ProfileDto {
+    const row = this.db.select().from(profiles).where(eq(profiles.builtin, true)).get()
+    return row ? toProfileDto(row) : this.getDefault()
+  }
+
   delete(id: string) {
     const p = this.get(id)
+    if (p.builtin) throw badRequest('内置的 Profile 不能删除')
     if (p.isDefault) throw badRequest('不能删除默认 Profile')
     this.db.delete(profiles).where(eq(profiles.id, id)).run()
   }
@@ -163,22 +169,65 @@ const hashKey = (key: string) => crypto.createHash('sha256').update(key).digest(
 export class ApiKeyService {
   private lastTouched = new Map<string, number>()
 
-  constructor(private db: DB) {}
+  constructor(private db: DB, private profiles: ProfileService) {}
 
   list(): ApiKeyDto[] {
     return this.db.select().from(apiKeys).orderBy(desc(apiKeys.createdAt)).all()
-      .map(r => ({ id: r.id, name: r.name, prefix: r.keyPrefix, enabled: r.enabled, createdAt: r.createdAt, lastUsedAt: r.lastUsedAt }))
+      .map(r => ({
+        id: r.id, name: r.name, prefix: r.keyPrefix, enabled: r.enabled, allowedProfileIds: r.allowedProfileIds,
+        defaultProfileId: r.defaultProfileId, createdAt: r.createdAt, lastUsedAt: r.lastUsedAt,
+      }))
   }
 
   /** 返回明文 Key,只在创建时出现一次 */
-  create(name: string): { key: string; apiKey: ApiKeyDto } {
+  create(name: string, access: ApiKeyProfiles): { key: string; apiKey: ApiKeyDto } {
     if (!name.trim()) throw badRequest('名称不能为空')
+    const checked = this.checkAccess(access)
     const key = `ask_${crypto.randomBytes(24).toString('base64url')}`
     const id = newId('key')
-    this.db.insert(apiKeys).values({ id, name: name.trim(), keyHash: hashKey(key), keyPrefix: key.slice(0, 10) }).run()
+    this.db.insert(apiKeys).values({ id, name: name.trim(), keyHash: hashKey(key), keyPrefix: key.slice(0, 10), ...checked }).run()
     return { key, apiKey: this.list().find(k => k.id === id)! }
   }
 
+  setProfiles(id: string, access: ApiKeyProfiles) {
+    const checked = this.checkAccess(access)
+    const r = this.db.update(apiKeys).set(checked).where(eq(apiKeys.id, id)).run()
+    if (r.changes === 0) throw notFound('API Key')
+  }
+
+  /** 平台可用的 Profile 与默认 Profile */
+  access(id: string): ApiKeyProfiles {
+    const row = this.db.select().from(apiKeys).where(eq(apiKeys.id, id)).get()
+    if (!row) throw notFound('API Key')
+    const allowed = row.allowedProfileIds
+    return { allowedProfileIds: allowed, defaultProfileId: row.defaultProfileId ?? allowed[0] ?? '' }
+  }
+
+  /** 平台要使用某个 Profile(省略时用默认),无权使用时抛 403 */
+  resolveProfile(id: string, requested?: string | null): ProfileDto {
+    const acc = this.access(id)
+    const target = requested || acc.defaultProfileId
+    if (!target || !acc.allowedProfileIds.includes(target)) {
+      throw new AppError(403, 'profile_not_allowed', requested ? '该平台无权使用这个 Profile' : '该平台没有可用的 Profile,请在后台为 API Key 配置')
+    }
+    return this.profiles.get(target)
+  }
+
+  /** 引用了某个 Profile 的 Key 名称(删除 Profile 前检查) */
+  referencing(profileId: string): string[] {
+    return this.list().filter(k => k.allowedProfileIds.includes(profileId)).map(k => k.name)
+  }
+
+  private checkAccess(access: ApiKeyProfiles) {
+    const data = parse(z.object({ allowedProfileIds: z.array(z.string()), defaultProfileId: z.string() }), access)
+    const allowed = [...new Set(data.allowedProfileIds)]
+    if (!allowed.length) throw badRequest('至少选择一个 Profile')
+    if (!data.defaultProfileId) throw badRequest('请选择默认 Profile')
+    for (const pid of allowed) this.profiles.get(pid)
+    if (!allowed.includes(data.defaultProfileId)) throw badRequest('默认 Profile 必须在可用的 Profile 中')
+    return { allowedProfileIds: allowed, defaultProfileId: data.defaultProfileId }
+  }
+
   setEnabled(id: string, enabled: boolean) {
     const r = this.db.update(apiKeys).set({ enabled }).where(eq(apiKeys.id, id)).run()
     if (r.changes === 0) throw notFound('API Key')

+ 4 - 3
apps/server/src/services/runs.ts

@@ -94,6 +94,7 @@ export class RunManager {
     if (this.activeForSession(session.id)) throw conflict('session_busy', '会话正在运行,请等待完成或先中断')
     const provider = this.deps.providers[session.provider]
     if (!provider || !this.deps.settings.get().providers[session.provider].enabled) throw conflict('provider_unavailable', `引擎 ${session.provider} 当前不可用`)
+    this.deps.sessions.assertProfileAllowed(session)
     const attachments = this.deps.files.getMany(session.id, data.attachments ?? [])
 
     const runId = newId('run')
@@ -155,7 +156,7 @@ export class RunManager {
     try {
       session = deps.sessions.getRow(sessionId)
       const settings = deps.settings.get()
-      const profile = session.profileId ? safe(() => deps.profiles.get(session!.profileId!)) ?? deps.profiles.getDefault() : deps.profiles.getDefault()
+      const { profile, permissionMode } = deps.sessions.effectiveProfile(session)
       const knowledgeRoots = deps.knowledge.list()
       const access = resolveAccess({
         policy: profile.fileAccess,
@@ -170,7 +171,7 @@ export class RunManager {
         sessionId,
         runId,
         model: session.model,
-        permissionMode: session.permissionMode,
+        permissionMode,
         providerSessionId: session.providerSessionId,
         workspaceDir: session.workspaceDir,
         systemPromptAppend: buildSystemPrompt({
@@ -179,7 +180,7 @@ export class RunManager {
           knowledgeRoots: knowledgeRoots.filter(k => k.enabled && profile.knowledgeRootIds.includes(k.id)),
           profilePrompt: profile.systemPrompt,
           sessionPrompt: session.systemPromptAppend,
-          permissionMode: session.permissionMode,
+          permissionMode,
         }),
         allowedTools: profile.allowedTools,
         disallowedTools: profile.disallowedTools,

+ 56 - 13
apps/server/src/services/sessions.ts

@@ -1,12 +1,12 @@
 import path from 'node:path'
 import { and, desc, eq, lt, or } from 'drizzle-orm'
 import { z } from 'zod'
-import type { CreateSessionBody, Page, ProviderId, SessionDto } from '@agent-server/shared'
+import type { CreateSessionBody, Page, PermissionMode, ProfileDto, ProviderId, SessionDto } from '@agent-server/shared'
 import type { DB } from '../db'
 import { events, interactions, runs, sessions } from '../db/schema'
-import { badRequest, conflict, notFound } from '../lib/errors'
+import { AppError, badRequest, conflict, notFound } from '../lib/errors'
 import { newId, nowIso } from '../lib/ids'
-import { parse, type ProfileService } from './catalog'
+import { parse, type ApiKeyService, type ProfileService } from './catalog'
 import type { EventStore } from './event-store'
 import type { FileService } from './files'
 import type { SettingsService } from './settings'
@@ -27,7 +27,8 @@ const patchSchema = z.object({
   archived: z.boolean().optional(),
   // 以下只能在空闲时修改;引擎与 Profile 只能在还没有对话时修改
   model: z.string().min(1).optional(),
-  permissionMode: z.enum(['ask', 'auto', 'readonly']).optional(),
+  // null 表示恢复为跟随 Profile(仅后台)
+  permissionMode: z.enum(['ask', 'auto', 'readonly']).nullable().optional(),
   systemPromptAppend: z.string().max(20000).optional(),
   provider: z.enum(['claude', 'codex']).optional(),
   profileId: z.string().optional(),
@@ -41,32 +42,68 @@ export class SessionService {
     private db: DB,
     private settings: SettingsService,
     private profiles: ProfileService,
+    private apiKeys: ApiKeyService,
     private files: FileService,
     private events: EventStore,
   ) {}
 
   async create(source: string, body: CreateSessionBody): Promise<SessionRow> {
     const data = parse(createSchema, body)
-    const profile = data.profileId ? this.profiles.get(data.profileId) : this.profiles.getDefault()
+    const isAdmin = source === 'admin'
+    // 权限模式由 Profile 决定,平台不能指定(ADR-0015)
+    if (!isAdmin && data.permissionMode !== undefined) throw badRequest('权限模式由 Profile 决定,不能在创建会话时指定')
+    const profile = this.profileFor(source, data.profileId)
     const provider: ProviderId = data.provider ?? profile.provider
     const s = this.settings.get()
     const providerSettings = s.providers[provider]
     if (!providerSettings.enabled) throw badRequest(`引擎 ${provider} 未启用`)
     const model = data.model ?? (provider === profile.provider ? profile.model : providerSettings.defaultModel)
-    if (!model) throw badRequest('未指定模型')
+    this.checkModel(provider, model, profile)
 
     const id = newId('ses')
     const workspaceDir = path.join(s.paths.workspaceRoot, id)
     await this.files.createWorkspace(workspaceDir)
     this.db.insert(sessions).values({
       id, source, profileId: profile.id, provider, model, workspaceDir,
-      permissionMode: data.permissionMode ?? profile.permissionMode,
+      // 为空表示跟随 Profile;只有后台可以为单个会话覆盖
+      permissionMode: isAdmin ? data.permissionMode ?? null : null,
       systemPromptAppend: data.systemPromptAppend ?? '',
       title: data.title ?? '',
     }).run()
     return this.getRow(id)
   }
 
+  /** 会话来源可以使用的 Profile:后台不受限;平台只能用 API Key 上配置的 Profile */
+  profileFor(source: string, requested?: string | null): ProfileDto {
+    if (source === 'admin') return requested ? this.profiles.get(requested) : this.profiles.getDefault()
+    return this.apiKeys.resolveProfile(source, requested)
+  }
+
+  /** 会话当前的 Profile(已被删除时回落到默认)与生效的权限模式 */
+  effectiveProfile(row: SessionRow): { profile: ProfileDto; permissionMode: PermissionMode; overridden: boolean } {
+    let profile: ProfileDto
+    try {
+      profile = row.profileId ? this.profiles.get(row.profileId) : this.profiles.getDefault()
+    } catch {
+      profile = this.profiles.getDefault()
+    }
+    return { profile, permissionMode: row.permissionMode ?? profile.permissionMode, overridden: row.permissionMode !== null }
+  }
+
+  /** 平台的会话在每次运行前检查 Profile 是否仍对它开放 */
+  assertProfileAllowed(row: SessionRow) {
+    if (row.source === 'admin') return
+    const acc = this.apiKeys.access(row.source)
+    if (!row.profileId || !acc.allowedProfileIds.includes(row.profileId)) {
+      throw new AppError(403, 'profile_not_allowed', '该会话使用的 Profile 已不再对此平台开放,请新建会话')
+    }
+  }
+
+  private checkModel(provider: ProviderId, model: string, profile: ProfileDto) {
+    const allowed = new Set([...this.settings.get().providers[provider].models, ...(profile.provider === provider ? [profile.model] : [])])
+    if (!allowed.has(model)) throw badRequest(`不可用的模型:${model}(可选:${[...allowed].join('、')})`)
+  }
+
   getRow(id: string, source?: string): SessionRow {
     const row = this.db.select().from(sessions).where(eq(sessions.id, id)).get()
     // 非后台调用只能访问自己创建的会话
@@ -108,15 +145,21 @@ export class SessionService {
   patch(id: string, source: string, body: unknown): SessionRow {
     const row = this.getRow(id, source)
     const data = parse(patchSchema, body)
+    if (source !== 'admin' && data.permissionMode !== undefined) throw badRequest('权限模式由 Profile 决定,不能修改')
     const changesRunConfig = data.model !== undefined || data.permissionMode !== undefined || data.systemPromptAppend !== undefined
       || data.provider !== undefined || data.profileId !== undefined
     if (changesRunConfig && this.statusOf(id) !== 'idle') throw conflict('session_busy', '会话正在运行,请等待完成后再修改')
-    if ((data.provider && data.provider !== row.provider) || (data.profileId && data.profileId !== row.profileId)) {
-      if (row.providerSessionId) throw badRequest('会话已经开始对话,不能再更换引擎或 Profile')
-    }
-    if (data.profileId) this.profiles.get(data.profileId)
-    if (data.provider && !this.settings.get().providers[data.provider].enabled) throw badRequest(`引擎 ${data.provider} 未启用`)
-    this.db.update(sessions).set({ ...data, updatedAt: nowIso() }).where(eq(sessions.id, id)).run()
+    const switching = (data.provider && data.provider !== row.provider) || (data.profileId && data.profileId !== row.profileId)
+    if (switching && row.providerSessionId) throw badRequest('会话已经开始对话,不能再更换引擎或 Profile')
+
+    const profile = data.profileId ? this.profileFor(source, data.profileId) : this.effectiveProfile(row).profile
+    const provider = data.provider ?? (data.profileId ? profile.provider : row.provider)
+    if (!this.settings.get().providers[provider].enabled) throw badRequest(`引擎 ${provider} 未启用`)
+    // 更换 Profile 且没有指定模型时,改用新 Profile 的默认模型
+    const model = data.model ?? (data.profileId ? profile.model : row.model)
+    if (data.model !== undefined || data.profileId) this.checkModel(provider, model, profile)
+
+    this.db.update(sessions).set({ ...data, provider, model, updatedAt: nowIso() }).where(eq(sessions.id, id)).run()
     return this.getRow(id)
   }
 

+ 84 - 2
apps/server/test/api.test.ts

@@ -53,7 +53,8 @@ beforeAll(async () => {
   const log = pino({ level: 'silent' })
   app = createApp(log, { providers: { claude: fake } })
   server = await buildServer(app, { logger: log })
-  apiKey = app.apiKeys.create('test').key
+  const builtin = app.profiles.getBuiltin().id
+  apiKey = app.apiKeys.create('test', { allowedProfileIds: [builtin], defaultProfileId: builtin }).key
 })
 afterAll(() => server.close())
 
@@ -143,7 +144,8 @@ describe('会话与 Run', () => {
 
   it('其他 API Key 看不到这个会话', async () => {
     const sid = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: auth(), payload: {} })).json().id
-    const otherKey = app.apiKeys.create('other').key
+    const b = app.profiles.getBuiltin().id
+    const otherKey = app.apiKeys.create('other', { allowedProfileIds: [b], defaultProfileId: b }).key
     const res = await server.inject({ method: 'GET', url: `/v1/sessions/${sid}`, headers: { authorization: `Bearer ${otherKey}` } })
     expect(res.statusCode).toBe(404)
   })
@@ -162,3 +164,83 @@ describe('资料库', () => {
     expect(() => app.knowledge.create({ name: 'none', path: '/does/not/exist' })).toThrow(/不存在/)
   })
 })
+
+describe('API Key 可用的 Profile', () => {
+  const profileInput = (name: string, permissionMode: 'ask' | 'auto' | 'readonly' = 'ask') => ({
+    name, description: '', provider: 'claude' as const, model: 'haiku', systemPrompt: '', permissionMode,
+    allowedTools: [], disallowedTools: [], skills: 'all' as const, fileAccess: { mode: 'restricted' as const, read: [], write: [], deny: [] },
+    knowledgeRootIds: [], isDefault: false,
+  })
+
+  it('平台只能看到并使用配置给它的 Profile', async () => {
+    const builtin = app.profiles.getBuiltin()
+    const finance = app.profiles.create(profileInput('财务'))
+    const secret = app.profiles.create(profileInput('其他'))
+    const { key } = app.apiKeys.create('平台A', { allowedProfileIds: [builtin.id, finance.id], defaultProfileId: finance.id })
+    const h = { authorization: `Bearer ${key}` }
+
+    const list = (await server.inject({ method: 'GET', url: '/v1/profiles', headers: h })).json() as { id: string; isDefault: boolean }[]
+    expect(list.map(p => p.id).sort()).toEqual([builtin.id, finance.id].sort())
+    expect(list.find(p => p.isDefault)?.id).toBe(finance.id)
+
+    // 不指定时用平台的默认 Profile,并使用其默认模型
+    const s1 = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: {} })).json()
+    expect(s1.profileId).toBe(finance.id)
+    expect(s1.model).toBe('haiku')
+
+    const denied = await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: { profileId: secret.id } })
+    expect(denied.statusCode).toBe(403)
+    expect(denied.json().error.code).toBe('profile_not_allowed')
+
+    // 可以自选模型,但必须是可用模型
+    expect((await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: { model: 'opus' } })).statusCode).toBe(201)
+    expect((await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: { model: 'gpt-x' } })).statusCode).toBe(400)
+  })
+
+  it('权限模式由 Profile 决定:平台不能指定,修改 Profile 后立即生效', async () => {
+    const p = app.profiles.create(profileInput('只读助手', 'readonly'))
+    const { key } = app.apiKeys.create('平台B', { allowedProfileIds: [p.id], defaultProfileId: p.id })
+    const h = { authorization: `Bearer ${key}` }
+    expect((await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: { permissionMode: 'auto' } })).statusCode).toBe(400)
+
+    const sid = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: {} })).json().id
+    expect((await server.inject({ method: 'PATCH', url: `/v1/sessions/${sid}`, headers: h, payload: { permissionMode: 'auto' } })).statusCode).toBe(400)
+    expect((await server.inject({ method: 'GET', url: `/v1/sessions/${sid}`, headers: h })).json().permissionMode).toBe('readonly')
+
+    app.profiles.update(p.id, { ...profileInput('只读助手', 'ask') })
+    const detail = (await server.inject({ method: 'GET', url: `/v1/sessions/${sid}`, headers: h })).json()
+    expect(detail.permissionMode).toBe('ask')
+    expect(detail.permissionModeOverridden).toBe(false)
+
+    await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, headers: h, payload: { text: 'hi' } })
+    await waitFor(() => app.runs.activeForSession(sid) === undefined || undefined)
+    expect(fake.lastCtx?.permissionMode).toBe('ask')
+  })
+
+  it('撤销 Profile 后,已有会话不能继续运行', async () => {
+    const builtin = app.profiles.getBuiltin()
+    const p = app.profiles.create(profileInput('临时'))
+    const { key, apiKey } = app.apiKeys.create('平台C', { allowedProfileIds: [builtin.id, p.id], defaultProfileId: p.id })
+    const h = { authorization: `Bearer ${key}` }
+    const sid = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: {} })).json().id
+    app.apiKeys.setProfiles(apiKey.id, { allowedProfileIds: [builtin.id], defaultProfileId: builtin.id })
+    const res = await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, headers: h, payload: { text: 'hi' } })
+    expect(res.statusCode).toBe(403)
+  })
+
+  it('Key 配置校验;内置 Profile 和被引用的 Profile 不能删除', async () => {
+    const builtin = app.profiles.getBuiltin()
+    expect(builtin.builtin).toBe(true)
+    expect(() => app.apiKeys.create('x', { allowedProfileIds: [], defaultProfileId: builtin.id })).toThrow(/至少选择一个/)
+    const p = app.profiles.create(profileInput('另一个'))
+    expect(() => app.apiKeys.create('x', { allowedProfileIds: [builtin.id], defaultProfileId: p.id })).toThrow(/默认 Profile 必须/)
+    expect(() => app.profiles.delete(builtin.id)).toThrow(/内置/)
+
+    app.apiKeys.create('平台D', { allowedProfileIds: [p.id], defaultProfileId: p.id })
+    const login = await server.inject({ method: 'POST', url: '/admin/v1/auth/login', payload: { password: 'test-password' } })
+    const cookie = login.cookies[0]!
+    const del = await server.inject({ method: 'DELETE', url: `/admin/v1/profiles/${p.id}`, cookies: { [cookie.name]: cookie.value } })
+    expect(del.statusCode).toBe(400)
+    expect(del.json().error.message).toContain('平台D')
+  })
+})

+ 1 - 0
docs/README.md

@@ -43,6 +43,7 @@ docs/
 | [0012](adr/0012-dedicated-service-user.md) | 以专用用户 agentsvc 运行服务 | Accepted |
 | [0013](adr/0013-official-skills-via-plugins.md) | 官方技能通过 Claude Code 插件机制使用 | Accepted |
 | [0014](adr/0014-admin-claude-login.md) | 在后台完成 Claude 登录 | Accepted |
+| [0015](adr/0015-api-key-profiles.md) | API Key 只能使用指定的 Profile;权限模式由 Profile 决定 | Accepted |
 
 ## 调研
 

+ 17 - 0
docs/adr/0015-api-key-profiles.md

@@ -0,0 +1,17 @@
+# ADR-0015 API Key 只能使用指定的 Profile;权限模式由 Profile 决定
+
+- 状态:Accepted · 日期:2026-10-23
+
+## 决策
+- 每个 API Key 配置"可用的 Profile"(至少一个)和一个"默认 Profile"(必须在可用列表中),没有其他配置项
+- `GET /v1/profiles` 只返回该 Key 可用的 Profile,`isDefault` 表示该平台的默认 Profile
+- 创建会话:不指定 Profile 时用该 Key 的默认 Profile;指定了不可用的 Profile 返回 `403 profile_not_allowed`
+- **模型**:平台可以为会话自选模型(创建或修改会话时),范围是设置中该引擎的可选模型加上 Profile 的模型;Profile 只决定默认模型
+- **权限模式**:完全由 Profile 决定。平台创建或修改会话时传 `permissionMode` 返回 400;会话在每次运行时读取 Profile 的当前设置,修改 Profile 立即生效。只有后台管理员可以为单个会话临时覆盖(Playground 中可以选回"跟随 Profile")
+- 每次发送消息前检查会话的 Profile 是否仍对该 Key 开放,撤销后返回 403,需要新建会话
+- 内置 Profile"通用助手"(`builtin`)不可删除;被任一 Key 勾选的 Profile 不可删除(需先在 API Keys 页取消勾选)
+- 升级迁移:已有 Key 只开放内置 Profile 并设为默认;平台创建的会话改为跟随 Profile
+
+## 理由
+- 平台之间互相隔离,管理员按用途分配 Profile
+- 原先平台可以把权限模式改为 auto,从而跳过审批,这是安全缺口

+ 2 - 2
docs/design/04-api.md

@@ -12,7 +12,7 @@
 ### 会话
 | 方法 | 路径 | 说明 |
 |---|---|---|
-| POST | `/v1/sessions` | 创建:`{ profileId?, provider?, model?, title? }`,profile 中的值可被覆盖(受 profile 的 `overridable` 白名单约束) |
+| POST | `/v1/sessions` | 创建:`{ profileId?, model?, title?, systemPromptAppend? }`。Profile 必须是该 API Key 可用的(省略时用其默认 Profile,否则 403 `profile_not_allowed`);模型可自选(限可选模型);**不能指定权限模式**,由 Profile 决定(ADR-0015) |
 | GET | `/v1/sessions?cursor=&limit=` | 当前 API Key 的会话列表(`?scope=all` 可列出所有 Key 的会话,需要 Key 具备该权限) |
 | GET | `/v1/sessions/:id` | 详情:元数据 + 消息摘要 + 未决交互 |
 | PATCH | `/v1/sessions/:id` | 改标题、归档 |
@@ -44,7 +44,7 @@
 ### 其他
 | 方法 | 路径 | 说明 |
 |---|---|---|
-| GET | `/v1/profiles` | 当前平台可用的 Profile |
+| GET | `/v1/profiles` | 当前 API Key 可用的 Profile(`isDefault` 为该平台的默认 Profile) |
 | GET | `/v1/providers` | 引擎可用性与模型列表 |
 
 ## 3. 统一事件模型

+ 3 - 3
docs/design/05-data-model.md

@@ -18,11 +18,11 @@ settings   audit_logs   usage_daily   admin_sessions
 
 | 表 | 关键字段 | 说明 |
 |---|---|---|
-| `api_keys` | id, name, key_hash, key_prefix, enabled, created_at, last_used_at | 每个上层平台一个 Key,便于区分来源和单独吊销;只存哈希 |
-| `profiles` | id, name, provider, model, system_prompt, permission_mode, allowed_tools(json), disallowed_tools(json), skills(json), file_access(json), network(json), overridable(json), auto_allow_rules(json), version, updated_at | Agent 预设;`file_access` 见 06 §4 |
+| `api_keys` | id, name, key_hash, key_prefix, enabled, allowed_profile_ids(json), default_profile_id, created_at, last_used_at | 每个上层平台一个 Key;只存哈希;只能使用勾选的 Profile(ADR-0015) |
+| `profiles` | id, name, provider, model, system_prompt, permission_mode, allowed_tools(json), disallowed_tools(json), skills(json), file_access(json), knowledge_root_ids(json), is_default, builtin, version, updated_at | Agent 预设;`file_access` 见 06 §4;`builtin` 的通用助手不可删除 |
 | `knowledge_roots` | id, name, path, description, access('read'\|'write'), enabled, visible(检测结果), checked_at | 资料库,**在后台设定** |
 | `profile_knowledge` | profile_id, knowledge_root_id | Profile 启用的资料库 |
-| `sessions` | id, api_key_id, profile_id, provider, model, provider_session_id, workspace_dir, title, status, last_seq, created_at, updated_at, archived | |
+| `sessions` | id, source, profile_id, provider, model, permission_mode, provider_session_id, workspace_dir, title, last_seq, created_at, updated_at, archived | `permission_mode` 为空表示跟随 Profile(只有后台可以覆盖) |
 | `runs` | id, session_id, status, started_at, ended_at, input_tokens, output_tokens, cost_usd, error | |
 | `events` | session_id, seq, run_id, type, payload(json), ts — PK(session_id, seq) | 事件全量落库 |
 | `interactions` | id, run_id, session_id, kind, request(json), response(json), status, created_at, resolved_at, expires_at | |

+ 3 - 1
packages/client/src/index.ts

@@ -8,6 +8,7 @@ import type {
   InteractionRequest,
   InteractionResponse,
   Page,
+  PermissionMode,
   ProfileDto,
   ProviderStatusDto,
   RunDto,
@@ -67,7 +68,8 @@ export class AgentClient {
     list: (q: { cursor?: string; limit?: number; scope?: 'all'; archived?: boolean } = {}) =>
       this.request<Page<SessionDto>>('GET', `/v1/sessions${query(q)}`),
     get: (id: string) => this.request<SessionDetailDto>('GET', `/v1/sessions/${id}`),
-    update: (id: string, body: Partial<Pick<SessionDetailDto, 'title' | 'archived' | 'model' | 'permissionMode' | 'systemPromptAppend' | 'provider' | 'profileId'>>) => this.request<SessionDto>('PATCH', `/v1/sessions/${id}`, body),
+    /** permissionMode 只有后台可以修改(null 表示恢复为跟随 Profile) */
+    update: (id: string, body: Partial<Pick<SessionDetailDto, 'title' | 'archived' | 'model' | 'systemPromptAppend' | 'provider' | 'profileId'>> & { permissionMode?: PermissionMode | null }) => this.request<SessionDto>('PATCH', `/v1/sessions/${id}`, body),
     remove: (id: string) => this.request<void>('DELETE', `/v1/sessions/${id}`),
     events: (id: string, after = 0) => this.request<{ items: AgentEvent[]; lastSeq: number }>('GET', `/v1/sessions/${id}/events?after=${after}`),
     send: (id: string, body: SendMessageBody) => this.request<RunDto>('POST', `/v1/sessions/${id}/messages`, body),

+ 12 - 1
packages/shared/src/api.ts

@@ -23,7 +23,9 @@ export interface SessionDto {
 export interface SessionDetailDto extends SessionDto {
   activeRunId: string | null
   pendingInteractions: InteractionRequest[]
+  /** 实际生效的权限模式:会话覆盖值(仅后台可设)或 Profile 的设置 */
   permissionMode: PermissionMode
+  permissionModeOverridden: boolean
   systemPromptAppend: string
 }
 
@@ -85,11 +87,13 @@ export interface ProfileDto {
   fileAccess: FileAccessPolicy
   knowledgeRootIds: string[]
   isDefault: boolean
+  /** 内置 Profile(通用助手)不可删除 */
+  builtin: boolean
   version: number
   updatedAt: string
 }
 
-export type ProfileInput = Omit<ProfileDto, 'id' | 'version' | 'updatedAt'>
+export type ProfileInput = Omit<ProfileDto, 'id' | 'version' | 'updatedAt' | 'builtin'>
 
 export interface KnowledgeRootDto {
   id: string
@@ -137,6 +141,8 @@ export interface ApiKeyDto {
   name: string
   prefix: string
   enabled: boolean
+  allowedProfileIds: string[]
+  defaultProfileId: string | null
   createdAt: string
   lastUsedAt: string | null
 }
@@ -205,3 +211,8 @@ export type PluginAction =
   | { action: 'marketplace-add'; source: string }
   | { action: 'marketplace-remove' | 'marketplace-update'; name: string }
   | { action: 'install' | 'uninstall' | 'update' | 'enable' | 'disable'; plugin: string }
+
+export interface ApiKeyProfiles {
+  allowedProfileIds: string[]
+  defaultProfileId: string
+}