Эх сурвалжийг харах

修复子账号 Bot 初始化权限并新增注册开通流程

Samien 1 сар өмнө
parent
commit
d9d25f352e

+ 2 - 1
desktop/boot.html

@@ -53,12 +53,13 @@
         <div class="field"><label for="password">密码</label><div class="password-input"><input id="password" name="password" type="password" autocomplete="current-password" placeholder="输入密码" required maxlength="4096"><button id="toggle-password" class="eye-button" type="button" aria-label="显示密码" aria-pressed="false"><svg><use href="boot-icons.svg#eye"/></svg></button></div></div>
         <div id="confirmation-field" class="field" hidden><label for="confirmation">确认密码</label><input id="confirmation" name="confirmation" type="password" autocomplete="new-password" placeholder="再次输入密码" maxlength="4096"></div>
       </div>
+      <p id="registration-notice" class="muted inset" role="status" hidden></p>
       <p id="login-error" class="error inset" role="alert" hidden></p>
       <button id="submit" class="primary" type="submit"><span>登录并进入夭夭</span><svg><use href="boot-icons.svg#arrow"/></svg></button>
     </form>
     <button id="waiting" class="primary" type="button" disabled hidden>正在准备服务,请稍候</button>
   </section>
-  <footer><label class="remember"><input id="remember" type="checkbox">记住本次选择</label><button id="help-button" class="text-button" type="button" aria-expanded="false" aria-controls="help">帮助</button></footer>
+  <footer><label class="remember"><input id="remember" type="checkbox">记住本次选择</label><button id="register-toggle" class="text-button" type="button" hidden>没有账号?注册子账号</button><button id="help-button" class="text-button" type="button" aria-expanded="false" aria-controls="help">帮助</button></footer>
   <div id="help" class="help" hidden><p><strong>本机运行:</strong>复用已有服务,或准备本机服务后创建管理员。机器人运行还需要本机的 Hermes 环境。</p><p><strong>远程连接:</strong>填写夭夭服务器地址,检测成功后使用该服务器的账号登录。管理员登录后会尝试将本机注册为可控制的电脑。</p><p>更换服务器不会迁移原服务器上的数据。遇到安装问题,可展开安装详情查看日志。</p></div>
 </main>
 <script src="boot.js"></script>

+ 26 - 13
desktop/boot.js

@@ -1,5 +1,6 @@
 const api = window.yaoyaoDesktop
 const $ = id => document.getElementById(id)
+let registering = false
 let state, initialized = false, pending = false, serverDirty = false, generation = 0, actionError = ''
 
 function display(next) {
@@ -13,7 +14,13 @@ function display(next) {
   const local = state.mode === 'local'
   const ready = ['ready', 'authenticating', 'entering'].includes(state.phase) && !serverDirty
   const setup = ready && state.setupRequired
-  const accountTitle = setup ? '创建管理员' : '登录账号'
+  const confirm = setup || registering
+  const accountTitle = setup ? '创建管理员' : registering ? '注册子账号' : '登录账号'
+  $('register-toggle').hidden = !ready || setup || state.authenticated
+  $('register-toggle').disabled = busy
+  $('register-toggle').textContent = registering ? '已有账号?返回登录' : '没有账号?注册子账号'
+  $('registration-notice').textContent = state.registrationNotice || ''
+  $('registration-notice').hidden = !ready || !state.registrationNotice
   $('mode-options').disabled = busy
   for (const input of document.querySelectorAll('[name=mode]')) input.checked = input.value === state.mode
   $('remember').disabled = busy
@@ -43,20 +50,20 @@ function display(next) {
   $('account-section').setAttribute('aria-busy', String(busy && ready))
   $('account-hint').textContent = !ready ? '服务准备完成后,在此创建账号或登录。'
     : state.authenticated ? `已登录${state.username ? `:${state.username}` : ''},可以继续进入夭夭。`
-    : setup ? '首次使用,请设置管理员账号和密码。密码至少 8 个字符。' : '登录所选服务器的夭夭账号。'
+    : setup ? '首次使用,请设置管理员账号和密码。密码至少 8 个字符。' : registering ? '子账号仅可使用 Bot 模式,注册后需管理员分配机器人并开通。' : '登录所选服务器的夭夭账号。'
   $('login-form').hidden = !ready
   $('credentials').hidden = state.authenticated
-  $('confirmation-field').hidden = !setup
+  $('confirmation-field').hidden = !confirm
   $('username').disabled = busy || state.authenticated || !ready
   $('password').disabled = busy || state.authenticated || !ready
-  $('confirmation').disabled = busy || !setup || state.authenticated
-  $('confirmation').required = setup && !state.authenticated
-  $('password').minLength = setup ? 8 : 1
-  $('password').autocomplete = setup ? 'new-password' : 'current-password'
+  $('confirmation').disabled = busy || !confirm || state.authenticated
+  $('confirmation').required = confirm && !state.authenticated
+  $('password').minLength = confirm ? 8 : 1
+  $('password').autocomplete = confirm ? 'new-password' : 'current-password'
   $('toggle-password').disabled = busy
   $('submit').disabled = busy
-  $('submit').querySelector('span').textContent = state.phase === 'authenticating' ? (setup ? '正在创建…' : '正在登录…')
-    : state.phase === 'entering' ? '正在进入…' : state.authenticated ? '进入夭夭' : setup ? '创建并进入夭夭' : '登录并进入夭夭'
+  $('submit').querySelector('span').textContent = state.phase === 'authenticating' ? (confirm ? '正在提交…' : '正在登录…')
+    : state.phase === 'entering' ? '正在进入…' : state.authenticated ? '进入夭夭' : setup ? '创建并进入夭夭' : registering ? '注册子账号' : '登录并进入夭夭'
   $('login-error').textContent = ready ? actionError || state.error : ''
   $('login-error').hidden = !$('login-error').textContent
   $('waiting').hidden = !busy || ready
@@ -106,11 +113,11 @@ for (const input of document.querySelectorAll('[name=mode]')) input.addEventList
   $('login-form').reset(); $('password').type = 'password'
   $('toggle-password').setAttribute('aria-pressed', 'false'); $('toggle-password').setAttribute('aria-label', '显示密码')
   $('password').removeAttribute('aria-invalid'); $('confirmation').removeAttribute('aria-invalid')
-  serverDirty = false
+  serverDirty = false; registering = false
   void action(() => api.selectServer(input.value))
 })
 $('server').addEventListener('input', () => {
-  serverDirty = true; actionError = ''
+  serverDirty = true; registering = false; actionError = ''
   // Credentials belong to the checked server, never carry them to a new address.
   $('login-form').reset(); $('password').type = 'password'
   $('toggle-password').setAttribute('aria-pressed', 'false'); $('toggle-password').setAttribute('aria-label', '显示密码')
@@ -139,16 +146,22 @@ $('confirmation').addEventListener('blur', () => {
 $('login-form').addEventListener('submit', event => {
   event.preventDefault()
   if (serverDirty || pending) return
-  if (state.setupRequired && !state.authenticated && $('confirmation').value !== $('password').value) {
+  if ((state.setupRequired || registering) && !state.authenticated && $('confirmation').value !== $('password').value) {
     actionError = '两次输入的密码不一致'; $('confirmation').setAttribute('aria-invalid', 'true'); display(state); $('confirmation').focus(); return
   }
   void action(async () => {
     const next = await api.login({ username: $('username').value, password: $('password').value,
-      confirmation: $('confirmation').value, remember: $('remember').checked })
+      confirmation: $('confirmation').value, register: registering, remember: $('remember').checked })
     $('password').value = ''; $('confirmation').value = ''
+    if (registering && !next.error) registering = false
     return next
   })
 })
+$('register-toggle').onclick = () => {
+  if (!state.registrationAvailable) { actionError = '当前服务器尚不支持子账号注册,请联系管理员升级。'; display(state); return }
+  registering = !registering; actionError = ''; state.error = ''; state.registrationNotice = ''
+  $('password').value = ''; $('confirmation').value = ''; display(state); $('username').focus()
+}
 $('help-button').onclick = () => {
   $('help').hidden = !$('help').hidden
   $('help-button').setAttribute('aria-expanded', String(!$('help').hidden))

+ 9 - 7
desktop/main.mjs

@@ -11,7 +11,7 @@ import { synchronizeLocalService, stopLocalService, migrateLocalData } from './s
 import { resolveDataHome } from './data-home.mjs'
 import { DesktopEnvironmentHost, DesktopHostCore } from './environment-host.mjs'
 import { DesktopHostManager } from './host-manager.mjs'
-import { remoteSession, enrollDesktopHost, inspectServer } from './remote-login.mjs'
+import { remoteRegistration, remoteSession, enrollDesktopHost, inspectServer } from './remote-login.mjs'
 import { DesktopOnboarding } from './onboarding.mjs'
 import { DesktopUpdateManager } from './update-manager.mjs'
 import { installComputerViewer } from './computer-viewer.mjs'
@@ -160,10 +160,10 @@ else {
     })
     window.on('close', event => { if (!quitting) { event.preventDefault(); window.hide() } })
     window.once('ready-to-show', () => { if (!startHidden) window.show() })
-    if (onboarding?.state.active) void window.loadURL(bootURL)
-    else if (remoteMode && remoteURL) void window.loadURL(remoteURL)
-    else if (manager?.state.phase === 'ready') void window.loadURL(manager.state.url)
-    else void window.loadFile(join(desktopRoot, 'boot.html'))
+    if (onboarding?.state.active) return window.loadURL(bootURL)
+    if (remoteMode && remoteURL) return window.loadURL(remoteURL)
+    if (manager?.state.phase === 'ready') return window.loadURL(manager.state.url)
+    return window.loadFile(join(desktopRoot, 'boot.html'))
   }
   function stateChanged(state) {
     log(`${state.phase}: ${state.message}`)
@@ -371,6 +371,7 @@ else {
         await runnerManager.start().catch(error => runnerManager.publish(error.message))
         return manager.state.url
       },
+      register: ({ serverURL, username, password }) => remoteRegistration(serverURL, { username, password }),
       authenticate: async ({ mode, serverURL, setup, username, password }) => {
         const auth = await remoteSession(serverURL, { setup, username, password })
         for (const cookie of auth.cookieDetails) {
@@ -413,7 +414,7 @@ else {
     async function openOnboarding(mode, { autoPrepare = false, remember = preferences.value.startupChoice !== 'ask', forceLogin = false } = {}) {
       if (closing || quitting) throw new Error('App 正在退出')
       onboarding.open({ mode, serverURL: preferences.value.remoteServer, remember, forceLogin })
-      if (!window || window.isDestroyed()) createWindow()
+      if (!window || window.isDestroyed()) await createWindow()
       else if (window.webContents.getURL() !== bootURL) await window.loadURL(bootURL)
       window.show(); window.focus()
       if (autoPrepare && (mode === 'local' || preferences.value.remoteServer))
@@ -505,7 +506,8 @@ else {
       : preferences.value.startupChoice
     onboarding.open({ mode: startupMode, serverURL: preferences.value.remoteServer,
       remember: preferences.value.startupChoice !== 'ask' })
-    createWindow()
+    // Finish the initial navigation before a remembered session enters its server.
+    await createWindow()
     powerMonitor.on('resume', () => { if (!closing && !quitting && serverModeActive) void manager.check() })
     timer = setInterval(() => { if (serverModeActive) void manager.check() }, 10_000); timer.unref()
     syncModeMenu()

+ 27 - 3
desktop/onboarding-flow.test.mjs

@@ -10,11 +10,22 @@ const root = resolve(import.meta.dirname, '..')
 test('first launch, failed connection and remote login all stay in the only main window', { timeout: 60000 }, async () => {
   const home = await realpath(await mkdtemp(join(tmpdir(), 'yaoyao-onboarding-')))
   let loginCount = 0
+  let registrationCount = 0
   const server = createServer((req, res) => {
     const json = (value, status = 200) => { res.statusCode = status; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(value)) }
     if (req.url === '/api/app/bootstrap') {
       res.setHeader('set-cookie', 'csrf=fixture; Path=/; HttpOnly')
-      return json({ authenticated: String(req.headers.cookie).includes('session=fixture'), setupRequired: false, csrfToken: 'fixture', serverKind: 'yaoyao-web' })
+      return json({ authenticated: String(req.headers.cookie).includes('session=fixture'), setupRequired: false, registrationAvailable: true, csrfToken: 'fixture', serverKind: 'yaoyao-web' })
+    }
+    if (req.url === '/api/app/register') {
+      let raw = ''; req.on('data', chunk => { raw += chunk }); req.on('end', () => {
+        registrationCount++
+        assert.deepEqual(JSON.parse(raw), { username: 'new-child', password: 'child-password' })
+        assert.equal(req.headers['x-csrf-token'], 'fixture')
+        assert.ok(String(req.headers.cookie).includes('csrf=fixture'))
+        assert.ok(!String(req.headers.cookie).includes('session=fixture'))
+        json({ registrationStatus: 'pending', message: '注册成功,请等待管理员开通' }, 201)
+      }); return
     }
     if (req.url === '/api/app/login') {
       let raw = ''; req.on('data', chunk => { raw += chunk }); req.on('end', () => {
@@ -49,6 +60,16 @@ test('first launch, failed connection and remote login all stay in the only main
     await expect(page.locator('#status')).toHaveText('连接成功,服务器已就绪')
     await expect(page.locator('#login-form')).toBeVisible()
     assert.match(page.url(), /boot.html$/)
+    await page.locator('#register-toggle').click()
+    await page.locator('#username').fill('new-child')
+    await page.locator('#password').fill('child-password')
+    await page.locator('#confirmation').fill('child-password')
+    await page.locator('#submit').click()
+    await expect(page.locator('#registration-notice')).toContainText('注册成功,请等待管理员开通')
+    await expect(page.locator('#confirmation-field')).toBeHidden()
+    assert.equal(registrationCount, 1)
+    assert.equal(loginCount, 0, 'registration never signs in or authorizes this computer')
+    assert.match(page.url(), /boot.html$/)
     await page.locator('#username').fill('user')
     await page.locator('#password').fill('wrong-password')
     await page.locator('#submit').click()
@@ -88,13 +109,16 @@ test('first launch, failed connection and remote login all stay in the only main
       window.yaoyaoDesktop.status(), window.yaoyaoDesktop.selectServer('local'),
       window.yaoyaoDesktop.prepareServer({}), window.yaoyaoDesktop.login({ username: 'x', password: 'y' }),
     ].map(promise => promise.then(() => false, () => true)))), [true, true, true, true])
-    await app.close(); app = undefined
+    const firstProcess = app.process()
+    const firstExit = firstProcess.exitCode === null ? new Promise(done => firstProcess.once('exit', done)) : Promise.resolve()
+    await app.close(); await firstExit; app = undefined
     app = await electron.launch({ args: [root], cwd: root, env: { ...process.env,
       HERMES_YAOYAO_DESKTOP_TEST_HOME: home, HERMES_YAOYAO_DESKTOP_TEST_MODE: 'ask', HERMES_YAOYAO_DESKTOP_TEST_SYNC: '0' } })
     const reopened = await app.firstWindow()
     try { await reopened.waitForURL(url + '/**', { timeout: 8000 }) }
     catch (error) {
-      console.log('Reopened fixture state:', await reopened.evaluate(() => window.yaoyaoDesktop.status()))
+      console.log('Reopened fixture URL:', reopened.url())
+      console.log('Reopened fixture state:', await reopened.evaluate(() => window.yaoyaoDesktop.status().catch(error => ({ error: error.message }))))
       console.log('Fixture cookie metadata:', await app.evaluate(async ({ session }, url) => (await session.defaultSession.cookies.get({ url })).map(({ name, expirationDate, sameSite, session }) => ({ name, expirationDate, sameSite, session })), url))
       throw error
     }

+ 17 - 4
desktop/onboarding.mjs

@@ -12,7 +12,7 @@ export class DesktopOnboarding {
   open({ mode = null, serverURL = '', remember = false, forceLogin = false } = {}) {
     if (this.state && this.busy) throw new Error('当前步骤尚未完成,请稍候')
     this.state = { active: true, mode, serverURL, remember, forceLogin, phase: 'idle', stage: 'checking',
-      setupRequired: false, authenticated: false, username: '', message: '', error: '',
+      setupRequired: false, registrationAvailable: false, registrationNotice: '', authenticated: false, username: '', message: '', error: '',
       canForceSync: false, history: [] }
     return this.snapshot()
   }
@@ -44,14 +44,14 @@ export class DesktopOnboarding {
     const mode = this.state.mode
     if (!mode) throw new Error('请先选择服务器')
     Object.assign(this.state, { phase: 'preparing', error: '', authenticated: false,
-      setupRequired: false, remember: remember === true, stage: 'checking', canForceSync: false,
+      setupRequired: false, registrationAvailable: false, registrationNotice: '', remember: remember === true, stage: 'checking', canForceSync: false,
       message: mode === 'local' ? '正在检查本机环境…' : '正在检测服务器连接…' })
     try {
       const target = mode === 'local' ? await this.options.prepareLocal({ force }) : normalizeServerURL(serverURL)
       this.state.serverURL = target
       const info = await this.options.inspect(target)
       if (mode === 'remote' && info.setupRequired) throw new Error('远程服务器尚未初始化,请先在服务器本机创建管理员,再重新检测。')
-      Object.assign(this.state, { phase: 'ready', stage: 'ready', setupRequired: info.setupRequired === true,
+      Object.assign(this.state, { phase: 'ready', stage: 'ready', setupRequired: info.setupRequired === true, registrationAvailable: info.registrationAvailable === true,
         authenticated: info.authenticated === true && !this.state.forceLogin, username: info.user?.username || '',
         message: mode === 'local' ? '本机服务已就绪' : '连接成功,服务器已就绪' })
       if (autoEnter && this.state.authenticated) await this.enter()
@@ -61,10 +61,11 @@ export class DesktopOnboarding {
     }
     return this.snapshot()
   }
-  async submit({ username, password, confirmation, remember = this.state.remember } = {}) {
+  async submit({ username, password, confirmation, register = false, remember = this.state.remember } = {}) {
     if (this.state.phase !== 'ready') throw new Error('请先完成服务器检测')
     this.state.remember = remember === true
     this.state.error = ''
+    if (register) return this.register({ username, password, confirmation })
     if (this.state.authenticated) return this.enter()
     if (!String(username || '').trim() || !password) throw new Error('请填写用户名和密码')
     if (this.state.setupRequired && (password.length < 8 || password !== confirmation))
@@ -86,6 +87,18 @@ export class DesktopOnboarding {
     }
     return this.snapshot()
   }
+  async register({ username, password, confirmation }) {
+    if (!this.state.registrationAvailable || this.state.setupRequired || this.state.authenticated) throw new Error('当前服务器尚不支持子账号注册,请联系管理员初始化或升级。')
+    if (!String(username || '').trim() || !password || password.length < 8 || password !== confirmation) throw new Error('请填写用户名及至少 8 位密码,并确认两次密码一致。')
+    this.state.phase = 'authenticating'
+    this.state.registrationNotice = ''
+    try {
+      await this.options.register({ serverURL: this.state.serverURL, username, password })
+      this.state.registrationNotice = '注册成功,请等待管理员开通。开通后使用此账号登录。'
+    } catch (error) { this.state.error = error.message || '注册失败,请重试。' }
+    finally { this.state.phase = 'ready' }
+    return this.snapshot()
+  }
   async enter() {
     this.state.phase = 'entering'
     try {

+ 13 - 0
desktop/onboarding.test.mjs

@@ -70,3 +70,16 @@ test('failed computer enrollment preserves login and offers inline continuation'
   assert.match(flow.snapshot().error, /授权未恢复/); assert.deepEqual(calls, [])
   await flow.submit(); assert.equal(flow.snapshot().active, false)
 })
+
+test('registration stays anonymous and does not enter or enroll a computer', async () => {
+  const calls = []
+  const flow = new DesktopOnboarding({ remoteServer: () => 'http://127.0.0.1:15300', inspect: async () => ({ registrationAvailable: true }), register: async input => { calls.push(input) }, authenticate: async () => { throw new Error('must not log in') }, activate: async () => { throw new Error('must not activate') } })
+  flow.select('remote'); await flow.prepare()
+  const next = await flow.submit({ register: true, username: 'child', password: 'password', confirmation: 'password' })
+  assert.equal(next.authenticated, false)
+  assert.match(next.registrationNotice, /等待管理员开通/)
+  assert.equal(calls.length, 1)
+  assert.equal(JSON.stringify(next).includes('password'), false)
+  await flow.prepare({ serverURL: 'http://another-server.test' })
+  assert.equal(flow.snapshot().registrationNotice, '', 'registration belongs to the checked server')
+})

+ 22 - 1
desktop/remote-login.mjs

@@ -73,7 +73,11 @@ export async function remoteSession(serverURL,{username,password,setup=false},fe
     body:JSON.stringify({username:user,password:pass}),
   })
   jar.absorb(response)
-  if(!setup&&(response.status===401||response.status===403))throw new Error('用户名或密码不正确')
+  if(!setup&&(response.status===401||response.status===403)) {
+    const body=await response.json().catch(()=>({}))
+    if(body.code==='account_pending_approval')throw new Error(body.error || '账号尚未开通,请等待管理员开通。')
+    throw new Error('用户名或密码不正确')
+  }
   if(setup&&!response.ok){const body=await response.json().catch(()=>({}));throw new Error(body.error?.message||body.error||`创建管理员失败(HTTP ${response.status})`)}
   if(!response.ok)throw new Error(`登录失败(HTTP ${response.status})`)
   const result=await response.json().catch(()=>{throw new Error('登录响应无效')})
@@ -94,3 +98,20 @@ export async function enrollDesktopHost(session,{name,installId,previousHostId},
   if(typeof result?.token!=='string'||!result?.host?.id)throw new Error('注册电脑响应无效')
   return result
 }
+
+/** Registration owns a temporary cookie jar and never activates a desktop session. */
+export async function remoteRegistration(serverURL,{username,password},fetchImpl=globalThis.fetch){
+  const base=normalizeServerURL(serverURL),jar=new CookieJar()
+  const bootstrap=await requestServer(fetchImpl,new URL('/api/app/bootstrap',base),{redirect:'error',signal:AbortSignal.timeout(10000)})
+  jar.absorb(bootstrap)
+  const info=await bootstrap.json().catch(()=>({}))
+  if(!bootstrap.ok||info.registrationAvailable!==true||info.setupRequired||typeof info.csrfToken!=='string')throw new Error('当前服务器尚不支持子账号注册,请联系管理员初始化或升级。')
+  const response=await requestServer(fetchImpl,new URL('/api/app/register',base),{
+    method:'POST',redirect:'error',signal:AbortSignal.timeout(15000),
+    headers:{'content-type':'application/json',cookie:jar.header(),'x-csrf-token':info.csrfToken,origin:base},
+    body:JSON.stringify({username:String(username).trim(),password}),
+  })
+  const result=await response.json().catch(()=>({}))
+  if(!response.ok)throw new Error(result.error || `注册失败(HTTP ${response.status})`)
+  if(result.registrationStatus!=='pending')throw new Error('注册响应无效,请联系管理员确认账号状态。')
+}

+ 23 - 0
desktop/remote-login.test.mjs

@@ -89,3 +89,26 @@ test('native login preserves cookie expiry, scope and same-site policy', async (
   assert.ok(session.expirationDate >= before + 3600 && session.expirationDate <= Date.now() / 1000 + 3600)
   assert.equal(auth.cookieDetails.find(cookie => cookie.name === 'csrf').expirationDate, undefined)
 })
+
+test('pending approval is not mislabeled as a bad password', async () => {
+  let calls = 0
+  await assert.rejects(() => remoteSession('https://server.test', { username: 'child', password: 'password' }, async () => {
+    if (++calls === 1) return Response.json({ csrfToken: 'csrf' })
+    return Response.json({ code: 'account_pending_approval', error: '账号尚未开通,请等待管理员开通。' }, { status: 403 })
+  }), /等待管理员开通/)
+})
+
+test('registration uses only anonymous bootstrap and does not return a session', async () => {
+  const { remoteRegistration } = await import('./remote-login.mjs')
+  const calls = []
+  await remoteRegistration('https://server.test', { username: ' child ', password: 'password' }, async (url, options) => {
+    calls.push(url.pathname)
+    if (url.pathname.endsWith('bootstrap')) return Response.json({ registrationAvailable: true, csrfToken: 'csrf' }, { headers: { 'set-cookie': 'csrf-cookie=value; Path=/; HttpOnly' } })
+    assert.equal(options.headers.origin, 'https://server.test')
+    assert.equal(options.headers['x-csrf-token'], 'csrf')
+    assert.match(options.headers.cookie, /csrf-cookie=value/)
+    assert.deepEqual(JSON.parse(options.body), { username: 'child', password: 'password' })
+    return Response.json({ registrationStatus: 'pending' }, { status: 201 })
+  })
+  assert.deepEqual(calls, ['/api/app/bootstrap', '/api/app/register'])
+})

BIN
docs/design/desktop-onboarding/remote-dark.png


BIN
docs/design/desktop-onboarding/remote-login.png


BIN
docs/design/desktop-onboarding/remote-narrow.png


+ 15 - 2
docs/subaccount-permissions.md

@@ -9,8 +9,19 @@
 - 账号 ID 从登录会话取得。Agent、聊天、任务、运行、交互、文件和事件继续按 owner 隔离,管理员也不会通过自己的 Bot 列表读取子账号会话。
 - 创建群、加入成员、发送消息、启动/恢复执行、建立上游会话、提交 prompt 和审批答复都会校验来源权限。异步读取基础目录之后再次校验,避免撤权竞态。
 - 修改分配、重置密码、禁用或删除账号会让旧会话失效,并请求停止该账号已存在的运行。撤销后的 Agent 配置和本人历史保留,但不能继续调用、编辑或加入新群。上游已经执行完成的工具副作用无法撤销。
-- 子账号采用允许列表访问应用接口;原生 REST、HTTP/SSE 实时接口、原生 Bot 中继、全局语音密钥、原生媒体路径(`/Users/...`、`/attachments/...`)均拒绝。自己的附件使用 owner 隔离的 `/api/app/files/:id/...`。
-- Web 在渲染路由前强制进入 `/conversations`。iOS 按当前服务身份强制 Bot 模式;服务器仍是最终权限边界,旧客户端也无法绕过。
+- 子账号采用允许列表访问应用接口;Bot 初始化快照,以及项目、记忆、协作接口按方法和路径放行,内部继续检查 owner 和来源分配。原生 REST、HTTP/SSE 实时接口、原生 Bot 中继、全局语音密钥、原生媒体路径(`/Users/...`、`/attachments/...`)均拒绝。自己的附件使用 owner 隔离的 `/api/app/files/:id/...`。
+- Web 等待路由初始化后检查账号模式,保留合法的 Bot 会话深链接。四端子账号均使用 Bot 模式;服务器仍是最终权限边界,旧客户端也无法绕过。能力声明不向子账号提供全局语音、原生上下文和节点入口。
+
+## 注册与开通
+
+- Web、macOS 独立启动页、iOS、Android 的登录界面提供“没有账号?注册子账号”。注册仅作用于当前连接的服务器;成功后不登录、不创建 Bot。
+- 匿名 `GET /api/app/bootstrap` 提供 CSRF 和 `registrationAvailable`。管理员尚未初始化时为 false;旧服务器未提供标记时,新客户端提示不支持注册。
+- `POST /api/app/register` 仅接收 `username`、`password`,沿用用户名规则和至少 8 位密码要求。创建 `role=user`、`enabled=false`、`assignedProfiles=[]`、`mustChangePassword=false`、`registrationStatus=pending`,返回 201,不签发业务会话。
+- 注册复用 Origin/Cookie/CSRF 校验,额外按直连 IP 限制每 15 分钟 10 次请求,超限返回 429 和 `Retry-After`。不信任转发 IP 请求头;多用户共享反向代理出口时共享该限额。
+- 正确密码登录待开通账号返回 403 / `account_pending_approval`,错误密码仍为原有 401。兼容登录入口同样生效。
+- 管理员在现有“用户与权限”中选择基础机器人并点击“分配并开通”。`POST /api/app/admin/users/:userID/approve` 接收 `assignedProfiles`,核对实时本机 Profile 目录,要求至少一个可用来源,在同次保存中写入分配、已开通、启用状态。
+- 普通启用接口不能开通 pending 账号。管理员原有创建、分配、启停、重置密码、删除操作继续保留。旧账号缺失 `registrationStatus` 时按 approved 处理,不改变其启用状态、密码、分配或首次改密要求。
+- 先更新服务端,再更新客户端。旧客户端保持现有登录协议,管理员开通后即可登录;原有账号不会获得默认机器人分配。
 
 本实现是 Web/iOS 账号与服务 API 的授权隔离,不创建 Hermes 工具执行的操作系统沙箱。分配的 Profile 仍按 Hermes 自身的工具权限运行,工具可访问的磁盘和外部服务范围需要在 Hermes 执行环境中约束。
 
@@ -19,3 +30,5 @@
 - `tests/server/subaccountPermissions.test.ts` 使用真实账号、Cookie 和 CSRF,覆盖默认拒绝、Profile/节点伪造、REST/实时/媒体/密钥入口、跨 owner 资源和撤权。
 - `tests/e2e/workspace-chat.spec.ts` 的 subaccount 用例验证管理员分配、首次改密、限制后的创建列表、隐藏模式菜单和手输旧路由重定向。
 - iOS `WorkspaceChatTests` 和 `AuthAndRESTTests` 覆盖服务权限解析、能力撤回和保留的原生模式设置被纠正。
+- `tests/server/subaccountRegistration.test.ts` 通过完整应用中间件覆盖注册、审核、知识与附件隔离、SSE 鉴权和禁用后的旧连接关闭;不替换认证。
+- 四端具体结果和未完成的真实环境验收见 [2026-09-21 验证记录](verification/2026-09-21-subaccount-bot.md)。

+ 62 - 0
docs/verification/2026-09-21-subaccount-bot.md

@@ -0,0 +1,62 @@
+# 子账号 Bot 与注册开通验证记录
+
+日期:2026-09-21。范围:yaoyao(服务端、Web、macOS)、yaoyao-mobile、yaoyao-android。官网和云端目录未修改。
+
+## 实现
+
+修复四端共用的 Bot 快照被子账号允许列表拦截的问题,并补齐按方法限定的项目、记忆、协作入口。来源授权、owner 隔离、撤权停止和管理员权限边界继续由服务端控制。子账号能力声明去除管理员专属入口。
+
+四端增加应用内子账号注册,macOS 包含独立启动页。注册不创建会话;管理员在现有用户页面分配至少一个可用本机基础机器人后开通。旧账号按已开通兼容,保留原状态和权限;普通启用不能绕过审核。
+
+补充登录失效、权限撤回和来源不可用提示,停止权限错误后的重连;Web 明确处理离线与恢复、清理换账号时的草稿和事件连接。修复 Web 初始化丢失会话深链接和 Electron 记住登录时初始页面导航竞态。管理员登录、原生模式和电脑授权协议未变。
+
+## 已执行验证
+
+| 范围 | 结果 | 说明 |
+| --- | --- | --- |
+| 服务端 / Web Vitest 全量 | 1543 通过,27 跳过 | 192 个测试文件通过;16 个带环境条件的文件跳过。Node 26 使用 `NODE_OPTIONS=--no-experimental-webstorage`,避免其全局 localStorage 与 jsdom 冲突 |
+| 新增完整中间件注册与权限测试 | 4 通过(包含于上项) | 真实账号、Cookie、Origin、CSRF;初始化限制、严格字段、用户名重复、密码校验、限流、pending 登录拒绝、禁止直接启用、可用来源审核、快照、项目、记忆读写/导出/删除、附件与跨账号访问、SSE 及禁用后旧流关闭 |
+| Web 子账号端到端 | 2 通过 | 分配、首次改密、注册、等待开通、管理员审核、创建 Bot、实际应用运行器收到回复、正文呈现、离线恢复、刷新保留会话、管理员列表隔离、没有误请求管理员接口 |
+| Electron 全量 | 54 通过 | 独立窗口注册、登录失败、记住登录、原有管理员电脑授权、安装/同步/重试、升级与退出流程;后续注册提示清理改动另复测 13 项登录相关测试通过 |
+| iOS | 212 通过,1 跳过 | AuthAndRESTTests、WorkspaceChatTests、LaunchSmokeTests;包含匿名注册 CSRF、旧服务器提示、登录页切换、来源诊断、403 停止重连、Bot 与账号切换回归 |
+| Android JVM | 65 通过 | core:network 52 项、app 13 项;包含注册 Cookie/CSRF/Origin、旧服务器拒绝、SSE 401/403、来源诊断 |
+| Android 相关仪器测试 | 4 通过 | LoginScreenTest、WorkspacePermissionsTest;注册确认密码与原登录入口、Bot 权限显示和记忆配置 |
+| 构建 | 通过 | Web/服务端、Electron `desktop:build`、iOS Simulator 构建及测试、Android `assembleDebug`。未生成新的签名发行包 |
+
+Web 回复测试使用真实夭夭认证、中间件和 WorkspaceRuntime,Hermes 上游为仓库确定性协议测试服务;没有关闭 Bot 执行或替换认证来证明聊天成功。移动端协议测试使用本地 HTTP/URLProtocol 测试服务,不能视作四端真实 Hermes 联调。
+
+## 全量界面回归的已有失败
+
+为区分本次变化与已有问题,分别使用独立 Git worktree 复测了修改前版本,未改动用户工作区:
+
+- Web 基线 `8470ebd`:原有 12 项 workspace 浏览器测试中 5 通过、7 失败。本次全量运行重现同样 7 项失败;涉及旧思考计时断言、身份造型入口、4 项旧流式 Markdown DOM 选择器、旧附件卡片选择器。新增子账号用例及原有子账号用例单独均通过。这里没有把全量浏览器回归标为通过。
+- Android 基线 `780407c`:WorkspaceParityFlowsTest 的 7 项中 3 通过、4 失败,与修改后相同。失败项为群聊任务切换、创建空白任务、电脑说明可见性、归档恢复;测试服务响应与界面断言需要后续对齐。未改动管理员业务行为来迁就这些断言。
+
+## 未完成的真实环境验收
+
+本机测试 Hermes `127.0.0.1:9119` 状态接口可达,但工具桥能力接口 `/api/plugins/yaoyao-bot-bridge/capabilities?profile=default` 返回 401 Unauthorized;未取得该测试服务的有效登录会话,状态还报告 gateway stopped。
+
+因此四端分别连接真实 Hermes 的完整消息往返、真实上游断线恢复,以及真实工具审批/停止、协作、记忆提炼、定时任务执行,仍未完成现场验收。原生三端从注册到管理员开通再到真实模型回复的完整闭环也未完成;不能用上述模拟上游或协议测试结果代替。需要可登录且工具桥已启用的测试 Hermes 环境后补验。
+
+服务端新增兼容接口应先发布,再交付客户端更新;当前仅完成本地实现、构建与上述验证,未修改正在运行的 Hermes 配置或部署服务。
+
+## 复现命令
+
+```sh
+# yaoyao
+NODE_OPTIONS=--no-experimental-webstorage npm test
+npx playwright test -c playwright.workspace.config.ts -g subaccount
+npm run desktop:test
+npm run desktop:build
+
+# yaoyao-mobile:XcodeBuildMCP / Xcode
+# scheme YaoYaoAI,模拟器 Yaoyao Workspace QA
+# only-testing: YaoYaoAITests/AuthAndRESTTests、YaoYaoAITests/WorkspaceChatTests、YaoYaoAIUITests/LaunchSmokeTests
+
+# yaoyao-android:配置 JDK 17 与 Android SDK 后
+./gradlew :core:network:test :app:testDebugUnitTest :app:assembleDebug
+python3 scripts/parity-fixture-server.py --port 18777
+./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=cn.samien.yaoyao.hermes.LoginScreenTest,cn.samien.yaoyao.hermes.WorkspacePermissionsTest
+```
+
+保留工作区原有的 `.zcode/`、iOS 工程/版本配置与 IPA、Android `.DS_Store`,不纳入本次提交。

+ 1 - 1
src/client/App.vue

@@ -45,7 +45,7 @@ watch(() => theme.resolvedTheme, value => {
 watch(pageTitle, value => { document.title = value }, { immediate: true })
 
 onMounted(() => {
-  if (!agentIdentityFixture) void auth.bootstrap()
+  if (!agentIdentityFixture) void router.isReady().then(() => auth.bootstrap())
 })
 </script>
 

+ 5 - 0
src/client/api/admin.ts

@@ -5,6 +5,7 @@ export interface ManagedUser {
   id: string
   username: string
   role: 'admin' | 'user'
+  registrationStatus?: 'pending' | 'approved'
   assignedProfiles?: string[]
   enabled: boolean
   mustChangePassword: boolean
@@ -88,3 +89,7 @@ export function getOpenVikingSettings(): Promise<OpenVikingSettings> {
 export function saveOpenVikingSettings(input: Omit<OpenVikingSettings, 'keyConfigured' | 'source' | 'status' | 'error'> & { adminKey?: string }): Promise<OpenVikingSettings> {
   return apiRequest('/api/app/admin/openviking', { method: 'PUT', body: input as unknown as JsonValue })
 }
+
+export function approveUser(id: string, assignedProfiles: string[]): Promise<ManagedUser> {
+  return apiRequest(`/api/app/admin/users/${encodeURIComponent(id)}/approve`, { method: 'POST', body: { assignedProfiles } })
+}

+ 6 - 0
src/client/api/auth.ts

@@ -14,6 +14,7 @@ function normalizeBootstrap(payload: unknown): BootstrapResponse {
   const response: BootstrapResponse = {
     status: string(status.state ?? root.state) || undefined,
     authRequired,
+    registrationAvailable: bool(root.registrationAvailable),
     setupRequired: bool(root.setupRequired ?? root.setup_required),
     profiles,
     csrfToken,
@@ -94,3 +95,8 @@ export async function updateAccountAvatar(avatar: string | null): Promise<Curren
   })))
   return normalizeUser(payload.user ?? payload)
 }
+
+export async function register(input: LoginInput): Promise<void> {
+  await bootstrap()
+  await apiRequest('/api/app/register', { method: 'POST', body: { username: input.username, password: input.password }, notifyUnauthorized: false })
+}

+ 9 - 3
src/client/components/app/SystemManagementPanel.vue

@@ -7,6 +7,7 @@ import AppIcon from '@/components/common/AppIcon.vue'
 import DesktopHostSettingsPanel from './DesktopHostSettingsPanel.vue'
 import RunnerSettingsPanel from './RunnerSettingsPanel.vue'
 import {
+  approveUser,
   createUser,
   deleteUser,
   getAllowedHostsSettings,
@@ -310,6 +311,10 @@ function saveAssignments(user: ManagedUser) {
   void run(async () => { await updateUser(user.id, { assignedProfiles: userAssignments.value[user.id] ?? [] }) })
 }
 
+function approve(user: ManagedUser) {
+  void run(async () => { await approveUser(user.id, userAssignments.value[user.id] ?? []) })
+}
+
 function toggle(user: ManagedUser) {
   void run(async () => { await updateUser(user.id, { enabled: !user.enabled }) })
 }
@@ -471,7 +476,7 @@ watch(() => [props.active, props.section] as const, ([active, section]) => {
       <div v-for="user in users" :key="user.id" class="user">
         <span>
           <b>{{ user.username }}</b>
-          <small>{{ user.role === 'admin' ? '管理员' : user.enabled ? (user.mustChangePassword ? '等待修改临时密码' : '普通用户') : '已禁用' }}</small>
+          <small>{{ user.role === 'admin' ? '管理员' : user.registrationStatus === 'pending' ? '待管理员开通' : user.enabled ? (user.mustChangePassword ? '等待修改临时密码' : '普通用户') : '已禁用' }}</small>
         </span>
         <template v-if="user.role !== 'admin'">
           <label class="user-assignment">基础机器人
@@ -479,9 +484,10 @@ watch(() => [props.active, props.section] as const, ([active, section]) => {
               <option v-for="profile in profiles" :key="profile.name" :value="profile.name">{{ profile.agentName || profile.displayName || profile.name }}</option>
             </select>
           </label>
-          <button type="button" :disabled="busy" @click="saveAssignments(user)">保存分配</button>
+          <button v-if="user.registrationStatus !== 'pending'" type="button" :disabled="busy" @click="saveAssignments(user)">保存分配</button>
           <button type="button" :aria-label="`为用户 ${user.username} 重置密码`" :disabled="busy" @click="reset(user)">重置密码</button>
-          <button type="button" :aria-label="`${user.enabled ? '禁用' : '启用'}用户 ${user.username}`" :disabled="busy" @click="toggle(user)">{{ user.enabled ? '禁用' : '启用' }}</button>
+          <button v-if="user.registrationStatus === 'pending'" type="button" :disabled="busy || !userAssignments[user.id]?.length" @click="approve(user)">分配并开通</button>
+          <button v-else type="button" :aria-label="`${user.enabled ? '禁用' : '启用'}用户 ${user.username}`" :disabled="busy" @click="toggle(user)">{{ user.enabled ? '禁用' : '启用' }}</button>
           <button class="danger" type="button" :aria-label="`删除用户 ${user.username}`" :disabled="busy" @click="remove(user)">删除</button>
         </template>
       </div>

+ 5 - 1
src/client/stores/auth.ts

@@ -21,6 +21,7 @@ export const useAuthStore = defineStore('auth', () => {
   const error = ref<string>()
   const authRequired = ref(true)
   const setupRequired = ref(false)
+  const registrationAvailable = ref(false)
   const insecureLan = ref(false)
   const groupUploadsEnabled = ref(false)
   const upstreamReady = ref(false)
@@ -54,6 +55,7 @@ export const useAuthStore = defineStore('auth', () => {
     if (response.serverIdentity) acceptServerIdentity(response.serverIdentity)
     authRequired.value = response.authRequired
     setupRequired.value = Boolean(response.setupRequired)
+    registrationAvailable.value = response.registrationAvailable === true
     csrfToken.value = response.csrfToken
     insecureLan.value = Boolean(response.insecureLan)
     groupUploadsEnabled.value = Boolean(response.groupUploadsEnabled)
@@ -150,6 +152,7 @@ export const useAuthStore = defineStore('auth', () => {
   }
 
   async function refreshProfiles(): Promise<void> {
+    if (isBotOnly.value) return
     const nextProfiles = await authApi.fetchProfiles()
     profiles.value = nextProfiles
     if (!nextProfiles.some(profile => profile.name === activeProfileName.value)) {
@@ -158,6 +161,7 @@ export const useAuthStore = defineStore('auth', () => {
   }
 
   async function refreshProfileAvatars(): Promise<void> {
+    if (isBotOnly.value) return
     const identities = await authApi.fetchProfileIdentities(profiles.value)
     profiles.value = profiles.value.map(profile => ({
       ...profile,
@@ -178,7 +182,7 @@ export const useAuthStore = defineStore('auth', () => {
 
   return {
     serverIdentity, acceptServerIdentity, refreshServerIdentity,
-    status, user, profiles, activeProfileName, activeProfile, csrfToken, error, authRequired, setupRequired, insecureLan, groupUploadsEnabled,
+    registrationAvailable, status, user, profiles, activeProfileName, activeProfile, csrfToken, error, authRequired, setupRequired, insecureLan, groupUploadsEnabled,
     upstreamReady, upstreamError, isAuthenticated, isBotOnly, bootstrap, login, setup, logout, selectProfile, refreshProfiles, refreshProfileAvatars,
     changeCredentials, updateAccountAvatar, expire,
   }

+ 25 - 2
src/client/views/ConversationsView.vue

@@ -521,6 +521,7 @@ async function hydrateWorkspace() {
 function connectEvents() {
   eventSource?.close()
   if (disposed) return
+  if (!navigator.onLine) { suspendEvents(); return }
   const source = new EventSource(`/api/app/events/stream?after=${cursor}${patchEvents ? '&format=patch-v1' : ''}`)
   eventSource = source
   source.addEventListener('workspace', event => {
@@ -540,11 +541,23 @@ function connectEvents() {
     if (disposed || eventSource !== source) return
     error.value = '连接中断,正在重连'
     void apiRequest('/api/app/capabilities').catch(cause => {
-      if (cause instanceof ApiError && cause.status === 401) source.close()
+      if (cause instanceof ApiError && [401, 403].includes(cause.status)) {
+        source.close()
+        error.value = cause.message
+      }
     })
   }
 }
+function suspendEvents() {
+  eventSource?.close(); eventSource = undefined
+  if (timer) clearTimeout(timer)
+  error.value = '网络已断开,恢复连接后将自动同步'
+}
+function resumeEvents() {
+  if (!disposed && auth.user?.id) void recoverEvents()
+}
 async function recoverEvents() {
+  if (!navigator.onLine) { suspendEvents(); return }
   eventSource?.close(); eventSource = undefined; eventQueue = []
   if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
   eventFrame = undefined
@@ -559,7 +572,9 @@ async function recoverEvents() {
   }
   catch (cause) {
     if (disposed) return
+    if (!navigator.onLine) { suspendEvents(); return }
     error.value = cause instanceof Error ? cause.message : '连接中断'
+    if (cause instanceof ApiError && [401, 403].includes(cause.status)) return
     timer = setTimeout(() => void recoverEvents(), 1500)
   }
 }
@@ -603,7 +618,8 @@ async function openDialog(kind: NonNullable<typeof dialog.value>, conversation =
   })
   try {
     if (kind === 'agent' || kind === 'editAgent') {
-      const s = await apiRequest<{ sources: Source[] }>('/api/app/agents/sources')
+      const s = await apiRequest<{ sources: Source[]; errors?: string[] }>('/api/app/agents/sources')
+      if (!s.sources.length && s.errors?.length) throw new Error('基础机器人服务暂不可用,请联系管理员检查 Hermes 连接后重试。')
       sources.value = s.sources
       form.source = s.sources[0] ? JSON.stringify([s.sources[0].nodeId, s.sources[0].profile]) : ''
     }
@@ -913,6 +929,9 @@ watch(() => auth.user?.id, (owner, previous) => {
   knowledgePanel.value?.close(); knowledgeEnabled.value = false; projects.value = []; knowledgeRevision.value++
   accountEpoch++; busy.value = false
   generation++
+  if (timer) clearTimeout(timer)
+  text.value = ''; files.value = []; mentions.value = []; quoted.value = null
+  pendingRequestId = undefined; pendingFingerprint = ''; uploadedSources = []; uploadedReferences = []
   eventSource?.close(); eventSource = undefined; eventQueue = []
   if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
   eventFrame = undefined; cursor = 0
@@ -924,6 +943,8 @@ watch(() => auth.user?.id, (owner, previous) => {
   if (owner) void recoverEvents()
 })
 onMounted(async () => {
+  window.addEventListener('offline', suspendEvents)
+  window.addEventListener('online', resumeEvents)
   void auth.refreshProfileAvatars().catch(() => undefined)
   try {
     await hydrateWorkspace()
@@ -934,6 +955,8 @@ onMounted(async () => {
   }
 })
 onBeforeUnmount(() => {
+  window.removeEventListener('offline', suspendEvents)
+  window.removeEventListener('online', resumeEvents)
   for (const timer of sendRefreshes.values()) clearTimeout(timer)
   sendRefreshes.clear()
   disposed = true

+ 29 - 9
src/client/views/LoginView.vue

@@ -2,6 +2,7 @@
 import { computed, nextTick, onMounted, ref } from 'vue'
 import BrandMark from '@/components/common/BrandMark.vue'
 import AppIcon from '@/components/common/AppIcon.vue'
+import { register } from '@/api/auth'
 import { useAuthStore } from '@/stores/auth'
 import { useThemeStore } from '@/stores/theme'
 
@@ -11,7 +12,16 @@ const username = ref('')
 const password = ref('')
 const passwordConfirmation = ref('')
 const localError = ref('')
-const submitting = computed(() => auth.status === 'authenticating')
+const registering = ref(false)
+const registrationBusy = ref(false)
+const notice = ref('')
+const submitting = computed(() => auth.status === 'authenticating' || registrationBusy.value)
+const needsConfirmation = computed(() => auth.setupRequired || registering.value)
+function toggleRegistration() {
+  registering.value = !registering.value
+  password.value = ''; passwordConfirmation.value = ''; localError.value = ''; notice.value = ''; auth.error = undefined
+}
+
 
 onMounted(() => {
   if (window.yaoyaoDesktop?.openLogin) {
@@ -21,21 +31,27 @@ onMounted(() => {
 
 async function login() {
   if (!username.value.trim() || !password.value) return
-  if (auth.setupRequired && password.value !== passwordConfirmation.value) {
+  if (needsConfirmation.value && password.value !== passwordConfirmation.value) {
     localError.value = '两次输入的密码不一致'
     return
   }
   localError.value = ''
   try {
     const input = { username: username.value.trim(), password: password.value }
-    if (auth.setupRequired) await auth.setup(input)
+    if (registering.value) {
+      registrationBusy.value = true
+      await register(input)
+      registering.value = false
+      password.value = ''; passwordConfirmation.value = ''
+      notice.value = '注册成功,请等待管理员开通。开通后使用此账号登录。'
+    } else if (auth.setupRequired) await auth.setup(input)
     else await auth.login(input)
   } catch (error) {
     localError.value = error instanceof Error ? error.message : '登录失败,请检查账号和密码'
     password.value = ''
     await nextTick()
     document.querySelector<HTMLInputElement>('#password')?.focus()
-  }
+  } finally { registrationBusy.value = false }
 }
 </script>
 
@@ -48,23 +64,27 @@ async function login() {
     <section class="login-panel" aria-labelledby="login-title">
       <BrandMark :size="72" />
       <div class="login-copy">
-        <h1 id="login-title">{{ auth.setupRequired ? '创建管理员' : '登录夭夭' }}</h1>
+        <h1 id="login-title">{{ auth.setupRequired ? '创建管理员' : registering ? '注册子账号' : '登录夭夭' }}</h1>
         <p v-if="auth.setupRequired">首次启动请设置 15300 管理账号,不会创建固定默认密码。</p>
       </div>
+      <p v-if="notice" role="status" class="registration-note">{{ notice }}</p>
+      <p v-if="registering" class="registration-note">子账号仅可使用 Bot 模式,注册后需要本服务器管理员分配机器人并开通。</p>
       <form @submit.prevent="login">
         <label><span>账号</span><input v-model="username" name="username" autocomplete="username" autofocus required placeholder="用户名" /></label>
-        <label><span>密码</span><input id="password" v-model="password" name="password" type="password" autocomplete="current-password" required placeholder="密码" /></label>
-        <label v-if="auth.setupRequired"><span>确认密码</span><input v-model="passwordConfirmation" name="password-confirmation" type="password" autocomplete="new-password" minlength="8" required placeholder="再次输入密码" /></label>
+        <label><span>密码</span><input id="password" v-model="password" name="password" type="password" :autocomplete="needsConfirmation ? 'new-password' : 'current-password'" required placeholder="密码" /></label>
+        <label v-if="needsConfirmation"><span>确认密码</span><input v-model="passwordConfirmation" name="password-confirmation" type="password" autocomplete="new-password" minlength="8" required placeholder="再次输入密码" /></label>
         <p v-if="localError || auth.error" class="login-error" role="alert"><AppIcon name="alert" :size="14" />{{ localError || auth.error }}</p>
-        <button class="login-submit solid-button" type="submit" :disabled="submitting || !username.trim() || password.length < (auth.setupRequired ? 8 : 1) || (auth.setupRequired && password !== passwordConfirmation)">
-          <span>{{ submitting ? '正在提交…' : auth.setupRequired ? '创建并登录' : '登录' }}</span><AppIcon v-if="!submitting" name="arrow-up" :size="15" />
+        <button class="login-submit solid-button" type="submit" :disabled="submitting || !username.trim() || password.length < (needsConfirmation ? 8 : 1) || (needsConfirmation && password !== passwordConfirmation)">
+          <span>{{ submitting ? '正在提交…' : auth.setupRequired ? '创建并登录' : registering ? '注册子账号' : '登录' }}</span><AppIcon v-if="!submitting" name="arrow-up" :size="15" />
         </button>
       </form>
+      <button v-if="!auth.setupRequired" class="registration-link" type="button" :disabled="submitting" @click="auth.registrationAvailable ? toggleRegistration() : localError = '当前服务器尚不支持子账号注册,请联系管理员升级。'">{{ registering ? '已有账号?返回登录' : '没有账号?注册子账号' }}</button>
     </section>
   </main>
 </template>
 
 <style scoped>
+.registration-link{width:100%;min-height:44px;margin-top:12px;border:0;background:transparent;color:var(--text-secondary);cursor:pointer}.registration-link:focus-visible{outline:2px solid var(--accent)}.registration-note{font-size:13px;line-height:1.6;color:var(--text-secondary);margin:12px 0}
 .login-view { position: relative; display: grid; width: 100%; height: 100%; place-items: center; overflow: hidden; padding: 24px; background: var(--canvas); }
 .theme-button { position: absolute; z-index: 3; top: max(15px, env(safe-area-inset-top)); right: 16px; }
 .login-atmosphere { position: absolute; inset: 0; pointer-events: none; opacity: .75; }.login-atmosphere i { position: absolute; display: block; border: 1px solid var(--line); border-radius: 50%; }.login-atmosphere i:nth-child(1) { width: 520px; height: 520px; top: -360px; left: -180px; }.login-atmosphere i:nth-child(2) { width: 310px; height: 310px; right: -190px; bottom: -170px; }.login-atmosphere i:nth-child(3) { width: 3px; height: 3px; top: 22%; right: 18%; border: 0; background: var(--text-muted); box-shadow: -80px 160px 0 var(--line-strong), 90px 270px 0 var(--line-strong), -900px 340px 0 var(--line-strong); }

+ 3 - 1
src/server/app.ts

@@ -1,3 +1,4 @@
+import { isWorkspaceAccountPath } from './subaccountAccess.js'
 import { chatTranscriptRouter } from './chatTranscriptApi.js'
 import {DesktopEnvironments} from './desktopEnvironments.js'
 import {DesktopHostHub} from './desktopHosts.js'
@@ -403,7 +404,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
         && path !== '/api/app/agents/remote'
       const pushPath = /^\/api\/(?:app\/)?push\/v1\//.test(path)
       const identityRead = path === '/api/app/server-identity' && ['GET', 'HEAD'].includes(ctx.method)
-      if (!publicPath && !accountPath && !workspacePath && !pushPath && !identityRead)
+      if (!publicPath && !accountPath && !workspacePath && !isWorkspaceAccountPath(ctx.method, path) && !pushPath && !identityRead)
         throw new HttpError(403, '子账号只能使用 Bot 模式', 'bot_mode_required')
     }
     await next()
@@ -428,6 +429,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     const anonymous = ctx.path === '/api/app/bootstrap'
       || ctx.path === '/api/app/setup'
       || ctx.path === '/api/app/login'
+      || ctx.path === '/api/app/register'
       || ctx.path === '/api/status'
       || ctx.path === '/api/auth/providers'
       || ctx.path === '/auth/password-login'

+ 37 - 4
src/server/localAuth.ts

@@ -28,8 +28,10 @@ const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30
 const USERNAME_PATTERN = /^[^\u0000-\u001f\u007f\s/@\\]{1,100}$/u
 
 export type LocalRole = 'admin' | 'user'
+export type RegistrationStatus = 'pending' | 'approved'
 
 interface StoredUser {
+  registrationStatus?: RegistrationStatus
   id: string
   username: string
   avatar?: string
@@ -55,6 +57,7 @@ interface SessionRecord {
 interface StoredSessions { version: 1; sessions: Array<SessionRecord & { tokenHash: string }> }
 
 export interface LocalUser {
+  registrationStatus?: RegistrationStatus
   id: string
   username: string
   avatar?: string
@@ -95,6 +98,7 @@ function publicUser(user: StoredUser): LocalUser {
     username: user.username,
     ...(validAvatarImage(user.avatar) ? { avatar: user.avatar } : {}),
     role: user.role,
+    registrationStatus: user.registrationStatus ?? 'approved',
     assignedProfiles: [...(user.assignedProfiles ?? [])],
     enabled: user.enabled,
     mustChangePassword: user.mustChangePassword,
@@ -151,15 +155,17 @@ export class LocalAuthStore {
   login(ctx: Koa.Context, usernameValue: string, password: string): LocalUser {
     const normalized = usernameValue.trim().toLocaleLowerCase('en-US')
     const user = this.#users.find(candidate => candidate.normalizedUsername === normalized)
-    if (!user || !user.enabled || !this.#passwordMatches(user, password)) {
+    if (!user || !this.#passwordMatches(user, password)) {
       throw new HttpError(401, '用户名或密码错误', 'login_failed')
     }
+    if (user.registrationStatus === 'pending') throw new HttpError(403, '账号尚未开通,请等待管理员分配机器人并开通。', 'account_pending_approval')
+    if (!user.enabled) throw new HttpError(401, '用户名或密码错误', 'login_failed')
     return this.#issueSession(ctx, user)
   }
 
   issueSession(ctx: Koa.Context, userID: string): LocalUser {
     const user = this.#users.find(candidate => candidate.id === userID)
-    if (!user || !user.enabled) {
+    if (!user || !user.enabled || user.registrationStatus === 'pending') {
       throw new HttpError(401, '用户已失效', 'account_pairing_user_unavailable')
     }
     return this.#issueSession(ctx, user)
@@ -212,7 +218,7 @@ export class LocalAuthStore {
       return undefined
     }
     const user = this.#users.find(candidate => candidate.id === session.userID)
-    if (!user || !user.enabled || user.authVersion !== session.authVersion) {
+    if (!user || !user.enabled || user.registrationStatus === 'pending' || user.authVersion !== session.authVersion) {
       this.#sessions.delete(key)
       this.#saveSessions()
       return undefined
@@ -243,7 +249,7 @@ export class LocalAuthStore {
   }
 
   canUseSource(userID: string, nodeId: string, profile: string): boolean {
-    const user = this.#users.find(candidate => candidate.id === userID && candidate.enabled)
+    const user = this.#users.find(candidate => candidate.id === userID && candidate.enabled && candidate.registrationStatus !== 'pending')
     return Boolean(user && (user.role === 'admin' || (nodeId === 'local' && user.assignedProfiles?.includes(profile))))
   }
 
@@ -279,11 +285,38 @@ export class LocalAuthStore {
     return publicUser(user)
   }
 
+  register(usernameValue: string, passwordValue: string): void {
+    if (this.setupRequired) throw new HttpError(409, '服务器尚未初始化,请联系管理员。', 'registration_unavailable')
+    const username = canonicalUsername(usernameValue)
+    if (this.#users.some(user => user.normalizedUsername === username.toLocaleLowerCase('en-US'))) {
+      throw new HttpError(409, '用户名已存在', 'username_exists')
+    }
+    const user = this.#newUser(username, validatePassword(passwordValue), 'user', false, Date.now())
+    user.registrationStatus = 'pending'
+    user.enabled = false
+    user.assignedProfiles = []
+    this.#users.push(user)
+    this.#saveUsers()
+  }
+
+  approve(admin: LocalUser, userID: string, profiles: string[]): LocalUser {
+    if (admin.role !== 'admin') throw new HttpError(403, '需要管理员权限', 'admin_required')
+    const user = this.#users.find(candidate => candidate.id === userID)
+    if (!user) throw new HttpError(404, '用户不存在', 'user_not_found')
+    if (user.role !== 'user' || user.registrationStatus !== 'pending') throw new HttpError(409, '该账号不在待开通状态,请刷新列表。', 'approval_not_pending')
+    const assigned = this.validateAssignedProfiles(profiles)
+    if (!assigned.length) throw new HttpError(400, '开通时必须分配至少一个基础机器人', 'assignment_required')
+    Object.assign(user, { assignedProfiles: assigned, registrationStatus: 'approved', enabled: true, updatedAt: Date.now(), authVersion: user.authVersion + 1 })
+    this.#saveUsers()
+    return publicUser(user)
+  }
+
   updateUser(admin: LocalUser, userID: string, input: { enabled?: boolean; password?: string; assignedProfiles?: unknown }): LocalUser {
     if (admin.role !== 'admin') throw new HttpError(403, '需要管理员权限', 'admin_required')
     const user = this.#users.find(candidate => candidate.id === userID)
     if (!user) throw new HttpError(404, '用户不存在', 'user_not_found')
     if (user.role === 'admin') throw new HttpError(409, '管理员账号请在账号设置中修改', 'admin_account_protected')
+    if (user.registrationStatus === 'pending' && input.enabled === true) throw new HttpError(409, '请使用“分配并开通”审核此账号', 'approval_required')
     const assigned = input.assignedProfiles === undefined ? undefined : this.validateAssignedProfiles(input.assignedProfiles)
     if (input.password !== undefined) validatePassword(input.password)
     if (assigned !== undefined) user.assignedProfiles = assigned

+ 30 - 0
src/server/routes.ts

@@ -1,3 +1,4 @@
+import { RegistrationLimiter } from './subaccountAccess.js'
 import { readServerIdentity, updateServerIdentity } from './serverIdentity.js'
 import { authorizeFileRead, fileAccessWorkingDirectory, readFileAccess, saveFileAccess } from './fileAccess.js'
 import { readHostTools, saveHostTools } from './hostToolSettings.js'
@@ -809,6 +810,7 @@ async function bootstrap(
       authRequired: true,
       authenticated: false,
       setupRequired: dependencies.auth.setupRequired,
+      registrationAvailable: !dependencies.auth.setupRequired,
       profiles: [],
       csrfToken,
       insecureLan: dependencies.config.insecureLan,
@@ -856,6 +858,7 @@ async function bootstrap(
     status,
     authRequired: true,
     authenticated: true,
+    registrationAvailable: true,
     serverIdentity: readServerIdentity(dependencies.workspace),
     user,
     profiles,
@@ -1152,6 +1155,7 @@ function pairedLocalMediaPath(config: ServerConfig, rawPath: string): string {
 
 export function createApiRouter(dependencies: RouteDependencies): Router {
   const router = new Router()
+  const registrations = new RegistrationLimiter()
   const fileReadRequest = (ctx: Koa.Context, path: string) => ['GET', 'HEAD'].includes(ctx.method)
     && /^\/api\/(?:files(?:\/[^/]+)?|fs\/[^/]+|media|hermes\/download)$/.test(path)
   const authorizeFileQuery = async (ctx: Koa.Context, route: string, host: Pick<UpstreamServiceSession, 'request'> = dependencies.upstreamSession) => {
@@ -1709,6 +1713,18 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
   router.post('/api/app/login', async (ctx) => {
     await login(ctx, dependencies)
   })
+  router.post('/api/app/register', ctx => {
+    const retry = registrations.take(ctx.req.socket.remoteAddress ?? 'unknown')
+    if (retry) {
+      ctx.set('Retry-After', String(retry))
+      throw new HttpError(429, '注册请求过于频繁,请稍后重试。', 'registration_rate_limited')
+    }
+    const request = body(ctx)
+    if (Object.keys(request).some(key => !['username', 'password'].includes(key)) || typeof request.username !== 'string' || typeof request.password !== 'string')
+      throw new HttpError(400, '请填写用户名和密码', 'invalid_registration')
+    dependencies.auth.register(request.username, request.password)
+    json(ctx, 201, { registrationStatus: 'pending', message: '注册成功,请等待管理员开通。' })
+  })
   router.post('/api/app/logout', async (ctx) => {
     dependencies.auth.logout(ctx)
     json(ctx, 200, { ok: true, csrfToken: dependencies.csrf.issue(ctx, true) })
@@ -1737,6 +1753,20 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     const password = typeof request.password === 'string' ? request.password : ''
     json(ctx, 201, dependencies.auth.create(admin, username, password, request.assignedProfiles))
   })
+  router.post('/api/app/admin/users/:userID/approve', async ctx => {
+    dependencies.auth.requireAdmin(ctx)
+    const userID = canonicalUUID(ctx.params.userID, 'user ID'), request = body(ctx)
+    if (Object.keys(request).some(key => key !== 'assignedProfiles')) throw new HttpError(400, '开通参数无效', 'invalid_approval')
+    const profiles = dependencies.auth.validateAssignedProfiles(request.assignedProfiles)
+    if (!profiles.length) throw new HttpError(400, '开通时必须分配至少一个基础机器人', 'assignment_required')
+    const response = await dependencies.upstreamSession.request('/api/profiles', { cache: 'reload' })
+    const available = new Set(normalizedProfiles(requireSuccess(response)).map(value => typeof value === 'string' ? value : (value as JsonObject).name))
+    if (profiles.some(profile => !available.has(profile))) throw new HttpError(409, '所选基础机器人当前不可用,请刷新后重新选择。', 'source_unavailable')
+    // Revalidate the administrator after the asynchronous catalogue lookup.
+    delete ctx.state.localUser
+    const admin = dependencies.auth.requireAdmin(ctx)
+    json(ctx, 200, dependencies.auth.approve(admin, userID, profiles))
+  })
   router.patch('/api/app/admin/users/:userID', async (ctx) => {
     const admin = dependencies.auth.requireAdmin(ctx)
     const request = body(ctx)

+ 23 - 0
src/server/subaccountAccess.ts

@@ -0,0 +1,23 @@
+/** Owner-scoped Bot endpoints. Keep native and administrative surfaces denied. */
+export function isWorkspaceAccountPath(method: string, path: string): boolean {
+  const read = method === 'GET' || method === 'HEAD'
+  if (read && /^\/api\/app\/workspace\/(snapshot|projects|memories|memory-export|memory-jobs|collaboration)$/.test(path)) return true
+  if (read && /^\/api\/app\/workspace\/memories\/[^/]+\/revisions$/.test(path)) return true
+  return method === 'POST' && /^\/api\/app\/workspace\/(projects|memories|memories\/forget|memory-jobs\/[^/]+\/retry|collaboration\/[^/]+\/stop)$/.test(path)
+}
+
+/** Bound memory as well as request frequency; untrusted forwarded IPs are not used. */
+export class RegistrationLimiter {
+  private readonly attempts = new Map<string, { count: number; expires: number }>()
+  take(address: string, now = Date.now()): number {
+    for (const [key, value] of this.attempts) if (value.expires <= now) this.attempts.delete(key)
+    let entry = this.attempts.get(address)
+    if (!entry) {
+      if (this.attempts.size >= 10_000) return 60
+      entry = { count: 0, expires: now + 15 * 60_000 }
+      this.attempts.set(address, entry)
+    }
+    if (++entry.count > 10) return Math.ceil((entry.expires - now) / 1000)
+    return 0
+  }
+}

+ 1 - 2
src/server/workspaceRoutes.ts

@@ -83,8 +83,7 @@ export function workspaceRouter(
         'agentTeamManagement',
         'computerControl','sharedComputers','computerManagement',
         'files',
-        'voice',
-        'context',
+        ...(auth.require(ctx).role === 'admin' ? ['voice', 'context'] : []),
         ...(auth.require(ctx).role === 'admin' ? ['nodes', 'pairedNodes', 'editableNodeAddress'] : []),
         'events', 'workspace-stream-v1',
         ...(store.messagePatchesEnabled ? [WORKSPACE_PATCH_CAPABILITY] : []),

+ 1 - 0
src/shared/types.ts

@@ -47,6 +47,7 @@ export interface BootstrapResponse {
   serverIdentity?: import('./serverIdentity.js').ServerIdentity
   status?: string
   authRequired: boolean
+  registrationAvailable?: boolean
   setupRequired?: boolean
   user?: CurrentUser
   profiles: Profile[]

+ 61 - 0
tests/e2e/workspace-chat.spec.ts

@@ -777,3 +777,64 @@ test('account update checks show loading, available versions and retry on deskto
     await expect(account).toBeFocused()
   }
 })
+
+test('self-registered subaccount approval supports Bot replies, reload and isolation', async ({ page, browser }, testInfo) => {
+  const childContext = await browser.newContext({ viewport: { width: 1280, height: 800 } })
+  const child = await childContext.newPage()
+  const username = `registered-${Date.now()}`
+  const denied: string[] = []
+  child.on('response', response => { if (response.status() === 403 && response.url().includes('/api/app/')) denied.push(new URL(response.url()).pathname) })
+  try {
+    await child.goto('/conversations')
+    await child.getByRole('button', { name: '没有账号?注册子账号' }).click()
+    await child.getByRole('textbox', { name: '账号', exact: true }).fill(username)
+    await child.getByLabel('密码', { exact: true }).fill('child-password')
+    await child.getByLabel('确认密码', { exact: true }).fill('child-password')
+    await child.getByRole('button', { name: '注册子账号', exact: true }).click()
+    await expect(child.getByRole('status')).toContainText('注册成功')
+    await child.getByLabel('密码', { exact: true }).fill('child-password')
+    await child.getByRole('button', { name: '登录', exact: true }).click()
+    await expect(child.getByRole('alert')).toContainText('尚未开通')
+    await login(page)
+    await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click()
+    await page.getByRole('menuitem', { name: '我的设置', exact: true }).click()
+    const settings = page.getByRole('dialog', { name: '我的设置', exact: true })
+    await settings.getByRole('button', { name: '用户与权限', exact: true }).click()
+    const row = settings.locator('.user').filter({ hasText: username })
+    await expect(row).toContainText('待管理员开通')
+    await expect(row.getByRole('button', { name: '分配并开通' })).toBeDisabled()
+    await row.getByRole('listbox').selectOption('default')
+    await row.getByRole('button', { name: '分配并开通' }).click()
+    await expect(row).toContainText('普通用户')
+    await child.getByLabel('密码', { exact: true }).fill('child-password')
+    await child.getByRole('button', { name: '登录', exact: true }).click()
+    await expect(child.getByRole('heading', { name: '还没有聊天' })).toBeVisible()
+    denied.length = 0
+    await openCreate(child, '新建 Bot')
+    const dialog = child.getByRole('dialog')
+    await expect(dialog.getByLabel('基础机器人').locator('option')).toHaveCount(1)
+    await dialog.getByRole('textbox', { name: '名称', exact: true }).fill('注册验收助手')
+    await dialog.getByRole('button', { name: '保存', exact: true }).click()
+    await expect(child.getByRole('heading', { name: '注册验收助手', exact: true })).toBeVisible()
+    const composer = child.getByRole('textbox', { name: '输入消息,Enter 发送,Shift + Enter 换行' })
+    await composer.fill('注册后验证 Bot 回复')
+    await composer.press('Enter')
+    await expect.poll(async () => {
+      const id = new URL(child.url()).pathname.split('/').at(-1)
+      const response = await child.request.get(`/api/app/conversations/${id}/messages`)
+      const value = await response.json()
+      return value.messages?.some((m: any) => m.role === 'assistant' && m.status === 'complete' && m.content)
+    }).toBe(true)
+    await expect(child.getByText('会话独立保存', { exact: true })).toBeVisible()
+    await childContext.setOffline(true)
+    await expect(child.getByText('网络已断开,恢复连接后将自动同步', { exact: true })).toBeVisible()
+    await childContext.setOffline(false)
+    await expect(child.getByText('网络已断开,恢复连接后将自动同步', { exact: true })).toBeHidden({ timeout: 15_000 })
+    await child.reload()
+    await expect(child.getByRole('heading', { name: '注册验收助手', exact: true })).toBeVisible()
+    await expect(child.getByText('注册后验证 Bot 回复', { exact: true }).first()).toBeVisible()
+    expect((await (await page.request.get('/api/app/agents')).json()).agents.some((a: any) => a.name === '注册验收助手')).toBe(false)
+    expect(denied).toEqual([])
+    await child.screenshot({ path: testInfo.outputPath('registered-subaccount-bot.png'), fullPage: true })
+  } finally { await childContext.close() }
+})

+ 145 - 0
tests/server/subaccountRegistration.test.ts

@@ -0,0 +1,145 @@
+// @vitest-environment node
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { randomUUID } from 'node:crypto'
+import { createServer } from 'node:http'
+import { beforeEach, afterEach, describe, expect, it } from 'vitest'
+import request from 'supertest'
+import { createApplication, type ApplicationRuntime } from '../../src/server/app'
+import { loadServerConfig } from '../../src/server/config'
+import { LocalAuthStore } from '../../src/server/localAuth'
+import { RegistrationLimiter } from '../../src/server/subaccountAccess'
+let runtime: ApplicationRuntime, home: string, csrf: string
+let admin: ReturnType<typeof request.agent>, child: ReturnType<typeof request.agent>
+const host = '127.0.0.1:15300'
+const call = (agent: typeof admin, method: 'get'|'post'|'patch'|'head', path: string, token = '') => agent[method](path).set('Host', host).set('Origin', `http://${host}`).set('X-CSRF-Token', token)
+beforeEach(async () => {
+  home = mkdtempSync(join(tmpdir(), 'yaoyao-registration-'))
+  runtime = createApplication({ config: loadServerConfig({ HERMES_YAOYAO_HOME: home, HERMES_YAOYAO_UPSTREAM: 'http://127.0.0.1:1', HERMES_YAOYAO_ALLOWED_HOSTS: '127.0.0.1' }), fetchImpl: (async input => {
+    const path = new URL(String(input)).pathname
+    return Response.json(path === '/api/profiles' ? { profiles: [{ name: 'allowed' }] } : path === '/api/status' ? { auth_required: true } : { user_id: 'service' })
+  }) as typeof fetch })
+  admin = request.agent(runtime.app.callback()); child = request.agent(runtime.app.callback())
+})
+afterEach(() => { runtime.close(); rmSync(home, { recursive: true, force: true }) })
+async function setup() {
+  const boot = await call(admin, 'get', '/api/app/bootstrap')
+  csrf = (await call(admin, 'post', '/api/app/setup', boot.body.csrfToken).send({ username: 'admin', password: 'admin-password' }).expect(200)).body.csrfToken
+}
+async function register(username = 'new-child') {
+  const boot = await call(child, 'get', '/api/app/bootstrap')
+  const result = await call(child, 'post', '/api/app/register', boot.body.csrfToken).send({ username, password: 'child-password' }).expect(201)
+  expect(result.body.registrationStatus).toBe('pending')
+  expect(result.headers['set-cookie']).toBeUndefined()
+  return boot.body.csrfToken as string
+}
+describe('subaccount registration and approval through real middleware', () => {
+  it('keeps initial setup exclusive and validates CSRF, role injection and credentials', async () => {
+    const boot = await call(child, 'get', '/api/app/bootstrap')
+    expect(boot.body.registrationAvailable).toBe(false)
+    await call(child, 'post', '/api/app/register', boot.body.csrfToken).send({ username: 'early', password: 'child-password' }).expect(409)
+    await setup()
+    expect((await call(child, 'get', '/api/app/bootstrap')).body.registrationAvailable).toBe(true)
+    await call(child, 'post', '/api/app/register').send({ username: 'bad', password: 'child-password' }).expect(403)
+    await call(child, 'post', '/api/app/register', boot.body.csrfToken).send({ username: 'bad', password: 'child-password', role: 'admin', enabled: true }).expect(400)
+    await call(child, 'post', '/api/app/register', boot.body.csrfToken).send({ username: 'bad', password: 'short' }).expect(400)
+    await register()
+    await call(child, 'post', '/api/app/register', boot.body.csrfToken).send({ username: 'NEW-CHILD', password: 'child-password' }).expect(409)
+  })
+  it('requires valid assigned profiles to approve; preserves pending status across restart and blocks enable bypass', async () => {
+    await setup(); const token = await register()
+    await call(child, 'post', '/api/app/login', token).send({ username: 'new-child', password: 'wrong' }).expect(401)
+    expect((await call(child, 'post', '/api/app/login', token).send({ username: 'new-child', password: 'child-password' }).expect(403)).body.code).toBe('account_pending_approval')
+    expect((await call(child, 'post', '/auth/password-login').send({ username: 'new-child', password: 'child-password' }).expect(403)).body.code).toBe('account_pending_approval')
+    await call(child, 'get', '/api/app/workspace/snapshot').expect(401)
+    const user = (await call(admin, 'get', '/api/app/admin/users')).body.items.find((u: any) => u.username === 'new-child')
+    expect(user).toMatchObject({ registrationStatus: 'pending', role: 'user', enabled: false, assignedProfiles: [], mustChangePassword: false })
+    expect(new LocalAuthStore(home).isUserActive(user.id)).toBe(false)
+    await call(admin, 'patch', `/api/app/admin/users/${user.id}`, csrf).send({ enabled: true, assignedProfiles: ['allowed'] }).expect(409)
+    await call(admin, 'post', `/api/app/admin/users/${user.id}/approve`, csrf).send({ assignedProfiles: [] }).expect(400)
+    await call(admin, 'post', `/api/app/admin/users/${user.id}/approve`, csrf).send({ assignedProfiles: ['missing'] }).expect(409)
+    await call(child, 'post', `/api/app/admin/users/${user.id}/approve`, token).send({ assignedProfiles: ['allowed'] }).expect(401)
+    const approved = await call(admin, 'post', `/api/app/admin/users/${user.id}/approve`, csrf).send({ assignedProfiles: ['allowed'] }).expect(200)
+    expect(approved.body).toMatchObject({ enabled: true, registrationStatus: 'approved', assignedProfiles: ['allowed'], mustChangePassword: false })
+    await call(admin, 'post', `/api/app/admin/users/${user.id}/approve`, csrf).send({ assignedProfiles: ['allowed'] }).expect(409)
+    const login = await call(child, 'post', '/api/app/login', token).send({ username: 'new-child', password: 'child-password' }).expect(200)
+    const create = await call(child, 'post', '/api/app/agents', login.body.csrfToken).send({ name: '我的 Bot', profile: 'allowed' }).expect(201)
+    const snapshot = (await call(child, 'get', '/api/app/workspace/snapshot').expect(200)).body
+    expect(snapshot.agents.map((a: any) => a.id)).toEqual([create.body.agent.id])
+    expect(snapshot.conversations).toHaveLength(1)
+    expect((await call(admin, 'get', '/api/app/workspace/snapshot')).body.agents).toEqual([])
+    await call(child, 'get', '/api/app/workspace/projects').expect(200)
+    await call(child, 'get', '/api/app/workspace/collaboration').expect(200)
+    await call(child, 'get', '/api/app/workspace/memory-jobs').expect(200)
+    await call(child, 'post', '/api/app/workspace/snapshot', login.body.csrfToken).send({}).expect(403)
+    await call(child, 'get', '/api/app/workspace/admin').expect(403)
+    await call(child, 'get', '/api/app/admin/users').expect(403)
+    await call(admin, 'patch', `/api/app/admin/users/${user.id}`, csrf).send({ enabled: false }).expect(200)
+    await call(child, 'get', '/api/app/workspace/snapshot').expect(401)
+  })
+  it('bounds registration attempts without changing login or administrator creation', async () => {
+    await setup()
+    const token = (await call(child, 'get', '/api/app/bootstrap')).body.csrfToken
+    for (let i = 0; i < 10; i++) await call(child, 'post', '/api/app/register', token).send({}).expect(400)
+    const limited = await call(child, 'post', '/api/app/register', token).send({}).expect(429)
+    expect(Number(limited.headers['retry-after'])).toBeGreaterThan(0)
+    await call(admin, 'post', '/api/app/admin/users', csrf).send({ username: 'managed', password: 'temporary-password' }).expect(201)
+    const login = await call(child, 'post', '/api/app/login', token).send({ username: 'managed', password: 'temporary-password' }).expect(200)
+    expect(login.body.user.mustChangePassword).toBe(true)
+    const limiter = new RegistrationLimiter()
+    for (let i = 0; i < 10; i++) expect(limiter.take('ip', 0)).toBe(0)
+    expect(limiter.take('ip', 1)).toBe(900)
+    expect(limiter.take('ip', 900_000)).toBe(0)
+  })
+  it('authorizes owner-scoped projects, memory, attachments and event replay through the complete middleware', async () => {
+    await setup(); const token = await register()
+    const user = (await call(admin, 'get', '/api/app/admin/users')).body.items.find((u: any) => u.username === 'new-child')
+    await call(admin, 'post', `/api/app/admin/users/${user.id}/approve`, csrf).send({ assignedProfiles: ['allowed'] }).expect(200)
+    const login = await call(child, 'post', '/api/app/login', token).send({ username: 'new-child', password: 'child-password' }).expect(200)
+    const childCSRF = login.body.csrfToken
+    const agent = (await call(child, 'post', '/api/app/agents', childCSRF).send({ name: '知识助手', profile: 'allowed' }).expect(201)).body.agent
+    await call(child, 'head', '/api/app/workspace/snapshot').expect(200)
+    const project = (await call(child, 'post', '/api/app/workspace/projects', childCSRF).send({ requestId: randomUUID(), name: '私人项目', memberIds: [agent.id] }).expect(200)).body.project
+    expect((await call(admin, 'get', '/api/app/workspace/projects')).body.projects).toEqual([])
+    const scope = { scope: 'project', agentId: agent.id, projectId: project.id }
+    const memory = (await call(child, 'post', '/api/app/workspace/memories', childCSRF).send({ ...scope, requestId: randomUUID(), content: '只属于此账号的记忆' }).expect(200)).body.memory
+    const query = new URLSearchParams(scope).toString()
+    expect((await call(child, 'get', `/api/app/workspace/memories?${query}`).expect(200)).body.memories.map((m: any) => m.id)).toContain(memory.id)
+    await call(child, 'get', `/api/app/workspace/memories/${memory.id}/revisions?${query}`).expect(200)
+    expect((await call(child, 'get', `/api/app/workspace/memory-export?${query}`).expect(200)).text).toContain('只属于此账号的记忆')
+    expect((await call(admin, 'get', `/api/app/workspace/memories?${query}`).expect(200)).body.memories).toEqual([])
+    await call(admin, 'get', `/api/app/workspace/memories/${memory.id}/revisions?${query}`).expect(404)
+    await call(child, 'post', '/api/app/workspace/memories/forget', childCSRF).send({ ...scope, id: memory.id, expectedRevision: memory.revision, requestId: randomUUID() }).expect(200)
+    const file = (await call(child, 'post', '/api/app/uploads', childCSRF).attach('files', Buffer.from('子账号附件'), { filename: 'child.txt', contentType: 'text/plain' }).expect(201)).body.files[0]
+    await call(child, 'get', `/api/app/files/${file.id}/download`).expect(200)
+    await call(admin, 'get', `/api/app/files/${file.id}/download`).expect(404)
+    // Unknown resources reach owner-scoped handlers, rather than the Bot-only gate.
+    await call(child, 'post', `/api/app/workspace/memory-jobs/${randomUUID()}/retry`, childCSRF).send({}).expect(404)
+    await call(child, 'post', `/api/app/workspace/collaboration/${randomUUID()}/stop`, childCSRF).send({}).expect(404)
+    const events = await call(child, 'get', '/api/app/events?after=0').expect(200)
+    expect(events.body.events.some((e: any) => e.type === 'project.changed')).toBe(true)
+    const caps = (await call(child, 'get', '/api/app/capabilities')).body.features
+    for (const feature of ['voice', 'context', 'nodes']) expect(caps).not.toContain(feature)
+    expect((await call(admin, 'get', '/api/app/capabilities')).body.features).toEqual(expect.arrayContaining(['voice', 'context', 'nodes']))
+    const server = createServer(runtime.app.callback())
+    await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
+    const address = server.address() as { port: number }
+    const cookie = (login.headers['set-cookie'] as unknown as string[]).map(value => value.split(';')[0]).join('; ')
+    const abort = new AbortController()
+    try {
+      const response = await fetch(`http://127.0.0.1:${address.port}/api/app/events/stream?after=${events.body.cursor}`, { headers: { host, cookie }, signal: AbortSignal.any([abort.signal, AbortSignal.timeout(20_000)]) })
+      expect(response.status).toBe(200)
+      expect(response.headers.get('content-type')).toContain('text/event-stream')
+      const reader = response.body!.getReader(), decoder = new TextDecoder()
+      let text = ''
+      while (!text.includes('event: ready')) text += decoder.decode((await reader.read()).value)
+      await call(admin, 'patch', `/api/app/admin/users/${user.id}`, csrf).send({ enabled: false }).expect(200)
+      while (!(await reader.read()).done) { /* Revocation closes the old stream no later than its heartbeat. */ }
+      await call(child, 'get', '/api/app/workspace/snapshot').expect(401)
+    } finally {
+      abort.abort(); server.closeAllConnections()
+      await new Promise<void>(resolve => server.close(() => resolve()))
+    }
+  }, 25_000)
+})