Przeglądaj źródła

feat: 支持在管理后台登录 Claude 账号

- 新增 ClaudeLoginManager:以服务账号 agentsvc 启动官方的 `claude auth login`
  (可选 --sso、--email),把授权网址交给后台,再把管理员粘贴的一次性授权码
  写入 CLI 的标准输入;授权码无效时可以重新输入
- 同一时间只允许一个登录流程,10 分钟超时,关闭对话框即取消
- 授权码只在内存中转交,不写入日志、数据库或审计详情;审计只记录
  发起、成功、失败、取消
- 新增管理接口:查询、发起、提交授权码、取消登录,以及退出登录
  (/admin/v1/providers/claude/login、/logout)
- 后台"概览"页的 Claude 卡片新增登录、重新登录、退出登录,
  并新增登录对话框 ClaudeLoginDialog
- 未登录时的引擎状态提示改为引导使用后台登录;终端方式
  `agent-claude auth login` 仍然保留
- 文档:新增 ADR-0014(含合规与安全考虑),更新 06-security、
  10-deployment、README

测试:36 个通过(新增登录流程测试:网址解析、无效授权码重试、取消、
输入校验、审计中不含授权码)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 1 tydzień temu
rodzic
commit
55c2062eb1

+ 1 - 1
README.md

@@ -33,7 +33,7 @@ docs/              设计文档、ADR、调研
 sudo deploy/install.sh          # 首次:创建 agentsvc、目录权限、systemd 服务、agent-claude 命令
 sudo deploy/setup-runtime.sh    # 首次 / 依赖变化:Python、Node、系统工具
 deploy/deploy.sh                # 每次发布:构建、发布到 /srv/agent-server/app、重启
-agent-claude                    # 以 agentsvc 身份打开 Claude Code,输入 /login 登录(只需一次)
+# 然后在后台"概览"页点"登录"完成 Claude 登录(也可以在终端执行 agent-claude auth login)
 ```
 
 后台:http://127.0.0.1:8787/admin/ ;日志:`journalctl -u agent-server -f`

+ 119 - 0
apps/admin/src/components/ClaudeLoginDialog.vue

@@ -0,0 +1,119 @@
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+import { admin, errorMessage } from '../lib/api'
+
+const emit = defineEmits<{ close: [loggedIn: boolean] }>()
+
+const step = ref<'options' | 'code' | 'done'>('options')
+const sso = ref(false)
+const email = ref('')
+const login = ref<{ id: string; url: string; expiresAt: string } | null>(null)
+const code = ref('')
+const busy = ref(false)
+const error = ref('')
+const result = ref('')
+
+onMounted(async () => {
+  // 恢复进行中的登录流程(例如刷新了页面)
+  const cur = await admin.claudeLogin.current().catch(() => null)
+  if (cur) {
+    login.value = cur
+    step.value = 'code'
+  }
+})
+
+async function start() {
+  busy.value = true
+  error.value = ''
+  try {
+    login.value = await admin.claudeLogin.start({ sso: sso.value, email: email.value.trim() || undefined })
+    step.value = 'code'
+  } catch (err) {
+    error.value = errorMessage(err)
+  } finally {
+    busy.value = false
+  }
+}
+
+async function submit() {
+  if (!login.value || !code.value.trim()) return
+  busy.value = true
+  error.value = ''
+  try {
+    const r = await admin.claudeLogin.submit(login.value.id, code.value.trim())
+    if (r.ok) {
+      result.value = r.message
+      step.value = 'done'
+    } else {
+      error.value = r.message
+      code.value = ''
+      if (!r.retry) {
+        login.value = null
+        step.value = 'options'
+      }
+    }
+  } catch (err) {
+    error.value = errorMessage(err)
+  } finally {
+    busy.value = false
+  }
+}
+
+async function close() {
+  if (login.value && step.value === 'code') await admin.claudeLogin.cancel(login.value.id).catch(() => {})
+  emit('close', step.value === 'done')
+}
+</script>
+
+<template>
+  <div class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="close">
+    <div class="w-[560px] card bg-input-alt p-5 flex flex-col gap-4 shadow-xl text-[12.5px]">
+      <div class="flex items-center gap-2">
+        <span class="codicon codicon-account text-accent text-[16px]" />
+        <span class="font-600 text-[14px] flex-1">登录 Claude 账号</span>
+        <button type="button" class="icon-btn" aria-label="关闭" @click="close"><span class="codicon codicon-close" /></button>
+      </div>
+
+      <template v-if="step === 'options'">
+        <div class="text-fg-muted">服务会以专用账号 agentsvc 启动官方 Claude Code 的登录流程。你在 Claude 官方页面登录,AgentServer 不会接触你的密码,登录凭证由 Claude Code 自己保存。</div>
+        <label class="flex items-center gap-2 cursor-pointer"><input v-model="sso" type="checkbox">使用单点登录(SSO,组织账号要求时勾选)</label>
+        <div>
+          <label class="label" for="login-email">邮箱(可选,预填到登录页)</label>
+          <input id="login-email" v-model="email" class="field" type="email" placeholder="name@example.com" autocomplete="email">
+        </div>
+        <div v-if="error" class="text-error">{{ error }}</div>
+        <div class="flex justify-end gap-2">
+          <button type="button" class="btn-secondary" @click="close">取消</button>
+          <button type="button" class="btn-primary" :disabled="busy" @click="start">
+            <span v-if="busy" class="codicon codicon-loading codicon-modifier-spin" />开始登录
+          </button>
+        </div>
+      </template>
+
+      <template v-else-if="step === 'code' && login">
+        <ol class="m-0 pl-5 flex flex-col gap-2">
+          <li>
+            点击打开 Claude 登录页,登录并授权:
+            <a :href="login.url" target="_blank" rel="noopener noreferrer" class="btn-primary ml-2 no-underline"><span class="codicon codicon-link-external" />打开 Claude 登录页</a>
+          </li>
+          <li>授权完成后,页面会显示一串授权码,复制<b>完整</b>的授权码。</li>
+          <li>粘贴到下面并提交:</li>
+        </ol>
+        <form class="flex gap-2" @submit.prevent="submit">
+          <input v-model="code" class="field font-mono" placeholder="粘贴授权码" aria-label="授权码" autocomplete="off" spellcheck="false" autofocus>
+          <button type="submit" class="btn-primary flex-none" :disabled="busy || !code.trim()">
+            <span v-if="busy" class="codicon codicon-loading codicon-modifier-spin" />提交
+          </button>
+        </form>
+        <div v-if="error" class="text-error whitespace-pre-wrap">{{ error }}</div>
+        <div class="hint">授权码只用于这一次登录,服务端不会保存或记录它。本次登录流程在 {{ new Date(login.expiresAt).toLocaleTimeString('zh-CN', { hour12: false }) }} 前有效。</div>
+      </template>
+
+      <template v-else-if="step === 'done'">
+        <div class="flex items-center gap-2 text-success"><span class="codicon codicon-pass-filled text-[16px]" />登录成功</div>
+        <div v-if="result" class="hint whitespace-pre-wrap">{{ result }}</div>
+        <div class="flex justify-end"><button type="button" class="btn-primary" @click="close">完成</button></div>
+      </template>
+    </div>
+  </div>
+</template>

+ 7 - 0
apps/admin/src/lib/api.ts

@@ -16,6 +16,13 @@ export const admin = {
   login: (password: string) => req<{ ok: true }>('POST', '/auth/login', { password }),
   logout: () => req<{ ok: true }>('POST', '/auth/logout'),
   changePassword: (current: string, next: string) => req<{ ok: true }>('POST', '/auth/password', { current, next }),
+  claudeLogin: {
+    current: () => req<{ id: string; url: string; expiresAt: string } | null>('GET', '/providers/claude/login'),
+    start: (body: { sso?: boolean; email?: string }) => req<{ id: string; url: string; expiresAt: string }>('POST', '/providers/claude/login', body),
+    submit: (id: string, code: string) => req<{ ok: boolean; message: string; retry?: boolean }>('POST', `/providers/claude/login/${id}/code`, { code }),
+    cancel: (id: string) => req<void>('DELETE', `/providers/claude/login/${id}`),
+    logout: () => req<{ ok: true }>('POST', '/providers/claude/logout'),
+  },
   overview: () => req<{ providers: ProviderStatusDto[]; runs: { running: number; queued: number; active: number }; version: string }>('GET', '/overview'),
   settings: () => req<{ settings: AppSettings; defaults: AppSettings; builtinGlobalDeny: string[] }>('GET', '/settings'),
   updateSettings: (patch: DeepPartial<AppSettings>) => req<{ settings: AppSettings }>('PATCH', '/settings', patch),

+ 33 - 1
apps/admin/src/views/OverviewView.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 import { onMounted, ref } from 'vue'
 import type { ProviderStatusDto } from '@agent-server/shared'
+import ClaudeLoginDialog from '../components/ClaudeLoginDialog.vue'
 import PageHeader from '../components/PageHeader.vue'
 import { admin, errorMessage } from '../lib/api'
 import { toast } from '../lib/ui'
@@ -8,6 +9,28 @@ import { toast } from '../lib/ui'
 const providers = ref<ProviderStatusDto[]>([])
 const runs = ref({ running: 0, queued: 0, active: 0 })
 const loading = ref(false)
+const showLogin = ref(false)
+const loggingOut = ref(false)
+
+async function logout() {
+  if (!confirm('退出 Claude 登录?退出后 AgentServer 将无法运行 Claude,直到重新登录。')) return
+  loggingOut.value = true
+  try {
+    await admin.claudeLogin.logout()
+    toast('已退出登录', 'success')
+    await load()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    loggingOut.value = false
+  }
+}
+
+function onLoginClosed(loggedIn: boolean) {
+  showLogin.value = false
+  if (loggedIn) toast('Claude 登录成功', 'success')
+  void load()
+}
 
 async function load() {
   loading.value = true
@@ -46,6 +69,14 @@ onMounted(load)
               <span class="badge" :class="p.available ? 'bg-success text-white' : p.enabled ? 'bg-danger text-white' : 'bg-[var(--app-input-border)] text-fg-muted'">
                 {{ p.available ? '可用' : p.enabled ? '不可用' : '未启用' }}
               </span>
+              <span class="flex-1" />
+              <template v-if="p.id === 'claude' && p.loggedIn !== null">
+                <button v-if="!p.loggedIn" type="button" class="btn-primary" @click="showLogin = true"><span class="codicon codicon-account" />登录</button>
+                <template v-else>
+                  <button type="button" class="btn-ghost" @click="showLogin = true">重新登录</button>
+                  <button type="button" class="btn-ghost" :disabled="loggingOut" @click="logout">退出登录</button>
+                </template>
+              </template>
             </div>
             <span class="text-fg-muted">账户</span><span>{{ p.account ?? '—' }}</span>
             <span class="text-fg-muted">CLI</span><span class="font-mono break-all">{{ p.cliPath }} <span class="text-fg-muted">{{ p.version }}</span></span>
@@ -54,8 +85,9 @@ onMounted(load)
             <template v-if="p.message"><span class="text-fg-muted">说明</span><span class="text-warning">{{ p.message }}</span></template>
           </div>
         </div>
-        <div class="hint">服务以专用账号 agentsvc 运行。未登录时在服务器终端执行 <code class="font-mono">agent-claude</code> 并输入 /login,完成后点刷新即可,无需重启服务(开发环境直接用 <code class="font-mono">claude</code>)。</div>
+        <div class="hint">服务以专用账号 agentsvc 运行,登录的是你自己的 Claude 账号。可以点上方的"登录"按钮在后台完成,也可以在服务器终端执行 <code class="font-mono">agent-claude auth login</code>。无需重启服务。</div>
       </section>
     </div>
+    <ClaudeLoginDialog v-if="showLogin" @close="onLoginClosed" />
   </div>
 </template>

+ 3 - 1
apps/server/src/app.ts

@@ -10,6 +10,7 @@ import { EventStore } from './services/event-store'
 import { FileService } from './services/files'
 import { InteractionService } from './services/interactions'
 import { PluginService } from './services/plugins'
+import { ClaudeLoginManager } from './services/claude-login'
 import { RunManager } from './services/runs'
 import { SessionService } from './services/sessions'
 import { SettingsService } from './services/settings'
@@ -36,10 +37,11 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   const sessions = new SessionService(db, settings, profiles, 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 }))
   const providers: Partial<Record<ProviderId, AgentProvider>> = opts.providers ?? { claude: new ClaudeProvider() }
   const runs = new RunManager({ db, log, settings, sessions, profiles, knowledge, files, events, interactions, audit, plugins, providers })
 
-  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, adminAuth, plugins, providers, runs }
+  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, adminAuth, plugins, claudeLogin, providers, runs }
 }
 
 export type App = ReturnType<typeof createApp>

+ 22 - 0
apps/server/src/http/routes/admin.ts

@@ -4,6 +4,7 @@ import { z } from 'zod'
 import { BUILTIN_GLOBAL_DENY, type AppSettings, type DeepPartial, type FileAccessPolicy, type PluginAction } from '@agent-server/shared'
 import type { App } from '../../app'
 import { AppError } from '../../lib/errors'
+import { runClaudeCli } from '../../providers/claude/cli'
 import { checkKnowledgePath, checkPathBasic, resolveAccess, toResolvedDto } from '../../services/access-policy'
 import { parse, type AuditKind, type KnowledgeInput } from '../../services/catalog'
 import { defaultSettings } from '../../services/settings'
@@ -66,6 +67,27 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
 
     g.get('/providers', async () => providerStatuses(app))
 
+    // 在后台登录 Claude(以服务账号运行官方 CLI 的登录流程)
+    g.get('/providers/claude/login', async () => app.claudeLogin.current())
+    g.post('/providers/claude/login', async req => {
+      const body = parse(z.object({ sso: z.boolean().optional(), email: z.string().trim().max(200).optional() }), req.body ?? {})
+      return app.claudeLogin.start({ sso: body.sso, email: body.email || undefined })
+    })
+    g.post<IdParams>('/providers/claude/login/:id/code', async req => {
+      const { code } = parse(z.object({ code: z.string().min(1).max(2000) }), req.body)
+      return app.claudeLogin.submitCode(req.params.id, code)
+    })
+    g.delete<IdParams>('/providers/claude/login/:id', async (req, reply) => {
+      app.claudeLogin.cancel(req.params.id)
+      reply.code(204)
+    })
+    g.post('/providers/claude/logout', async () => {
+      const r = await runClaudeCli(app.settings.get().providers.claude.cliPath, ['auth', 'logout'], { timeoutMs: 30_000 })
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `Claude 退出登录${r.code === 0 ? '' : '失败'}` })
+      if (r.code !== 0) throw new AppError(502, 'logout_failed', (r.stderr || r.stdout).trim().slice(-300) || '退出登录失败')
+      return { ok: true }
+    })
+
     // 设置
     g.get('/settings', async () => ({ settings: app.settings.get(), defaults: defaultSettings(), builtinGlobalDeny: BUILTIN_GLOBAL_DENY }))
     g.patch('/settings', async req => {

+ 1 - 1
apps/server/src/providers/claude/adapter.ts

@@ -180,7 +180,7 @@ export class ClaudeProvider implements AgentProvider {
         loggedIn: !!info.loggedIn,
         available: s.enabled && !!info.loggedIn,
         account: info.loggedIn ? [info.email, info.subscriptionType && `(${info.subscriptionType})`].filter(Boolean).join(' ') : null,
-        message: info.loggedIn ? null : '未登录:请在服务器终端执行 agent-claude,然后输入 /login(开发环境直接执行 claude)',
+        message: info.loggedIn ? null : '未登录:点击右上角"登录"按钮,或在服务器终端执行 agent-claude auth login',
       }
     } catch (err) {
       return { ...base, message: `无法执行 ${s.cliPath}:${(err as Error).message}` }

+ 152 - 0
apps/server/src/services/claude-login.ts

@@ -0,0 +1,152 @@
+// 后台登录 Claude:以服务账号启动官方的 `claude auth login`,把登录网址交给后台,
+// 再把管理员粘贴的一次性授权码写入 CLI 的标准输入。登录在 Anthropic 官方页面完成,
+// 凭证由官方 CLI 自己交换和保存;授权码只在内存中转交,不写入日志、数据库或审计详情。
+import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
+import { AppError, badRequest, conflict, notFound } from '../lib/errors'
+import { newId } from '../lib/ids'
+
+const URL_TIMEOUT_MS = 30_000
+const SESSION_TTL_MS = 10 * 60_000
+const CODE_RESULT_TIMEOUT_MS = 60_000
+const AUTHORIZE_URL = /https:\/\/\S+\/oauth\/authorize\?\S+/
+
+export interface LoginStart { id: string; url: string; expiresAt: string }
+export type LoginResult = { ok: true; message: string } | { ok: false; message: string; retry: boolean }
+
+interface Session {
+  id: string
+  child: ChildProcessWithoutNullStreams
+  url: string
+  expiresAt: Date
+  /** 最近一次提交授权码后的输出 */
+  output: string
+  exited: { code: number | null } | null
+  timer: NodeJS.Timeout
+  listeners: Set<() => void>
+}
+
+export class ClaudeLoginManager {
+  private session: Session | null = null
+
+  constructor(
+    private bin: () => string,
+    private audit: (summary: string) => void,
+  ) {}
+
+  current(): { id: string; url: string; expiresAt: string } | null {
+    const s = this.session
+    return s && !s.exited ? { id: s.id, url: s.url, expiresAt: s.expiresAt.toISOString() } : null
+  }
+
+  async start(opts: { sso?: boolean; email?: string }): Promise<LoginStart> {
+    if (this.current()) throw conflict('login_in_progress', '已有一个登录流程在进行,请先完成或取消')
+    const args = ['auth', 'login']
+    if (opts.sso) args.push('--sso')
+    if (opts.email) {
+      if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(opts.email)) throw badRequest('邮箱格式不正确')
+      args.push('--email', opts.email)
+    }
+    const env: NodeJS.ProcessEnv = { ...process.env, BROWSER: 'false' }
+    delete env.ANTHROPIC_API_KEY
+    delete env.ANTHROPIC_AUTH_TOKEN
+    const child = spawn(this.bin(), args, { env, stdio: ['pipe', 'pipe', 'pipe'] })
+
+    const session: Session = {
+      id: newId('adm'), child, url: '', expiresAt: new Date(Date.now() + SESSION_TTL_MS), output: '', exited: null,
+      timer: setTimeout(() => this.cancel(session.id, '登录超时,已取消'), SESSION_TTL_MS), listeners: new Set(),
+    }
+    const onData = (d: Buffer) => {
+      session.output += d.toString()
+      for (const l of session.listeners) l()
+    }
+    child.stdout.on('data', onData)
+    child.stderr.on('data', onData)
+    child.on('exit', code => {
+      session.exited = { code }
+      clearTimeout(session.timer)
+      for (const l of session.listeners) l()
+    })
+    child.on('error', err => {
+      session.output += `\n${err.message}`
+      session.exited = { code: -1 }
+      for (const l of session.listeners) l()
+    })
+
+    // 等待 CLI 输出登录网址
+    const url = await waitFor(session, URL_TIMEOUT_MS, () => AUTHORIZE_URL.exec(session.output)?.[0] ?? (session.exited ? '' : null))
+    if (!url) {
+      child.kill('SIGKILL')
+      clearTimeout(session.timer)
+      throw new AppError(502, 'login_start_failed', `无法启动登录流程:${tail(session.output) || '没有输出登录网址'}`)
+    }
+    session.url = url
+    session.output = ''
+    this.session = session
+    this.audit('后台发起 Claude 登录')
+    return { id: session.id, url, expiresAt: session.expiresAt.toISOString() }
+  }
+
+  async submitCode(id: string, code: string): Promise<LoginResult> {
+    const s = this.require(id)
+    const clean = code.trim()
+    if (!clean || clean.length > 2000 || /\s/.test(clean)) throw badRequest('授权码格式不正确')
+    s.output = ''
+    s.child.stdin.write(`${clean}\n`)
+
+    // 结果:进程退出(成功或失败),或者输出"无效授权码"后继续等待重新输入
+    const outcome = await waitFor(s, CODE_RESULT_TIMEOUT_MS, () => {
+      if (s.exited) return 'exit'
+      return /invalid|expired|error|failed/i.test(s.output) ? 'invalid' : null
+    })
+    if (outcome === 'exit' && s.exited?.code === 0) {
+      this.session = null
+      this.audit('Claude 登录成功')
+      return { ok: true, message: tail(s.output) || '登录成功' }
+    }
+    if (outcome === 'invalid' && !s.exited) {
+      return { ok: false, message: tail(s.output) || '授权码无效,请重新复制完整的授权码', retry: true }
+    }
+    if (!outcome) return { ok: false, message: '等待登录结果超时,请稍后刷新状态确认', retry: true }
+    this.session = null
+    this.audit('Claude 登录失败')
+    return { ok: false, message: tail(s.output) || `登录失败(退出码 ${s.exited?.code ?? '?'})`, retry: false }
+  }
+
+  cancel(id: string, reason = '已取消登录'): void {
+    const s = this.session
+    if (!s || s.id !== id) return
+    this.session = null
+    clearTimeout(s.timer)
+    if (!s.exited) s.child.kill('SIGTERM')
+    this.audit(reason)
+  }
+
+  private require(id: string): Session {
+    const s = this.session
+    if (!s || s.id !== id || s.exited) throw notFound('登录流程(可能已超时或已结束)')
+    return s
+  }
+}
+
+function waitFor<T>(s: Session, timeoutMs: number, check: () => T | null): Promise<T | null> {
+  return new Promise(resolve => {
+    const done = (v: T | null) => {
+      clearTimeout(timer)
+      s.listeners.delete(listener)
+      resolve(v)
+    }
+    const listener = () => {
+      const v = check()
+      if (v !== null) done(v)
+    }
+    const timer = setTimeout(() => done(null), timeoutMs)
+    s.listeners.add(listener)
+    listener()
+  })
+}
+
+/** 输出的最后几行(去掉终端控制字符),用于向管理员展示 */
+function tail(out: string): string {
+  // eslint-disable-next-line no-control-regex
+  return out.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').trim().split('\n').slice(-3).join('\n').slice(0, 500)
+}

+ 55 - 0
apps/server/test/claude-login.test.ts

@@ -0,0 +1,55 @@
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { ClaudeLoginManager } from '../src/services/claude-login'
+
+// 模拟 `claude auth login` 的非交互行为:输出网址 → 读授权码 → 无效则提示并继续等待,有效则成功退出
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-claude-'))
+const fake = path.join(dir, 'claude')
+fs.writeFileSync(fake, `#!/usr/bin/env node
+process.stdout.write('Opening browser to sign in…\\nIf the browser didn\\'t open, visit: https://claude.com/cai/oauth/authorize?code=true&state=abc\\nPaste code here if prompted > ')
+let buf = ''
+process.stdin.on('data', d => {
+  buf += d
+  const lines = buf.split('\\n'); buf = lines.pop()
+  for (const l of lines) {
+    if (l === 'good-code') { console.log('Login successful.'); process.exit(0) }
+    else console.error('Invalid code. Please make sure the full code was copied.')
+  }
+})
+`, { mode: 0o755 })
+
+describe('ClaudeLoginManager', () => {
+  const audits: string[] = []
+  const mgr = new ClaudeLoginManager(() => fake, s => audits.push(s))
+
+  it('启动后返回登录网址;无效授权码可以重试;有效授权码完成登录', async () => {
+    const start = await mgr.start({})
+    expect(start.url).toBe('https://claude.com/cai/oauth/authorize?code=true&state=abc')
+    await expect(mgr.start({})).rejects.toThrow(/已有一个登录流程/)
+
+    const bad = await mgr.submitCode(start.id, 'wrong')
+    expect(bad).toEqual({ ok: false, retry: true, message: expect.stringContaining('Invalid code') })
+
+    const good = await mgr.submitCode(start.id, 'good-code')
+    expect(good.ok).toBe(true)
+    expect(mgr.current()).toBeNull()
+    // 审计中不包含授权码
+    expect(audits.join('\n')).not.toMatch(/good-code|wrong/)
+  })
+
+  it('取消后流程结束,旧 ID 不能再提交', async () => {
+    const start = await mgr.start({ sso: true, email: 'a@b.com' })
+    mgr.cancel(start.id)
+    expect(mgr.current()).toBeNull()
+    await expect(mgr.submitCode(start.id, 'good-code')).rejects.toThrow(/不存在/)
+  })
+
+  it('拒绝格式不正确的输入', async () => {
+    await expect(mgr.start({ email: 'not-an-email' })).rejects.toThrow(/邮箱/)
+    const start = await mgr.start({})
+    await expect(mgr.submitCode(start.id, 'has space')).rejects.toThrow(/格式/)
+    mgr.cancel(start.id)
+  })
+})

+ 1 - 0
docs/README.md

@@ -42,6 +42,7 @@ docs/
 | [0011](adr/0011-admin-managed-settings.md) | 路径等运行配置由后台管理,不写死在配置文件中 | Accepted |
 | [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 |
 
 ## 调研
 

+ 18 - 0
docs/adr/0014-admin-claude-login.md

@@ -0,0 +1,18 @@
+# ADR-0014 在后台完成 Claude 登录
+
+- 状态:Accepted · 日期:2026-10-23
+
+## 背景
+服务以 agentsvc 运行(ADR-0012),每次登录都要在服务器终端执行 `agent-claude auth login`,不够方便。实测确认官方 CLI 在非 TTY 下也支持登录:先输出授权网址,再从标准输入读取授权码。
+
+## 决策
+- 后台"概览"页提供登录、重新登录、退出登录。服务端以 agentsvc 身份启动官方的 `claude auth login`(可选 `--sso`、`--email`),把授权网址交给后台;管理员在 Claude 官方页面登录后,把页面显示的一次性授权码粘贴回后台,服务端写入 CLI 的标准输入
+- 同一时间只允许一个登录流程,10 分钟超时,关闭对话框即取消;授权码无效时可以重新输入
+- 授权码只在内存中转交:不写入日志、数据库和审计详情(审计只记录"发起 / 成功 / 失败 / 取消"),有测试覆盖
+- 终端方式 `agent-claude auth login` 继续保留
+
+## 合规考虑
+Anthropic 条款要求登录在其官方流程中完成,开发者不得收集、保存或中转 Claude.ai 的凭证或会话令牌。本方案中:登录在官方页面完成,凭证的交换和保存由官方 CLI 负责,AgentServer 接触不到密码和令牌;但它会转交一次性授权码,这一步条款没有明确规定。考虑到是账号所有者登录自己服务器上的官方 CLI,效果等同于通过 SSH 把授权码粘贴进终端,判断为低风险;希望完全避开时使用终端方式。不构成法律意见。
+
+## 安全
+- 接口只对后台管理员开放;后台开放到本机以外时必须使用 HTTPS(授权码会经过网络)

+ 1 - 0
docs/design/06-security.md

@@ -173,3 +173,4 @@ TasksMax=512
 - 服务以 `agentsvc` 运行:不在 docker/lxd/sudo 组;程序、运行环境和技能由 root 所有、服务只读;数据目录 0700
 - 内置禁止项中的 `~` 现在指 `/srv/agent-server/home`:`~/.claude/.credentials.json`、`~/.claude.json` 保护的是服务账号自己的登录态;home 位于数据目录内,同时受"服务数据目录"的软禁止保护(其他会话的 Claude 会话记录也在这里)
 - 官方技能的许可约束与使用方式见 ADR-0013
+- 后台登录 Claude(ADR-0014):服务端以 agentsvc 运行官方 `claude auth login`,一次性授权码只在内存中写入 CLI 标准输入,不进入日志、数据库和审计详情;后台开放到本机以外时必须启用 HTTPS

+ 2 - 2
docs/design/10-deployment.md

@@ -28,7 +28,7 @@
 | `deploy/deploy.sh` | 构建 → `pnpm deploy` → rsync 到 app/、同步 skills/ → 重启服务 → 健康检查 | 每次发布 |
 | `agent-claude ...` | 以 agentsvc 身份运行自带的 Claude Code | 登录(`/login`)、查看状态(`auth status`)、手动管理插件 |
 
-首次安装顺序:`sudo deploy/install.sh` → `sudo deploy/setup-runtime.sh` → `deploy/deploy.sh` → `agent-claude`(输入 /login)。
+首次安装顺序:`sudo deploy/install.sh` → `sudo deploy/setup-runtime.sh` → `deploy/deploy.sh` → 在后台"概览"页登录 Claude(或执行 `agent-claude auth login`)。
 
 ## 4. systemd 加固(deploy/agent-server.service)
 - `User=agentsvc`;`ProtectSystem=strict` + 只有 home/data/workspaces/logs/knowledge 可写;`ProtectHome=yes`;`PrivateTmp=yes`;`UMask=0077`
@@ -45,6 +45,6 @@
 ## 6. 运维
 - 日志:`journalctl -u agent-server -f`
 - 状态:`systemctl status agent-server`;健康检查 `GET /healthz`、`GET /readyz`
-- 登录过期:后台"概览"和状态栏会提示,执行 `agent-claude` 重新登录,无需重启
+- 登录 / 登录过期:在后台"概览"页点"登录"完成(ADR-0014),或在终端执行 `agent-claude auth login`,无需重启
 - 备份:每天对 `data/agent.db` 做 SQLite 在线备份;`home/` 中的登录态可以不备份(重新登录即可)
 - 升级 Claude Code:升级 `@anthropic-ai/claude-agent-sdk` 依赖后执行 deploy.sh(自带 CLI 版本随之更新),再在 Playground 回归验证