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

统一 Web 和移动端聊天使用 Agent 默认工作目录

LSamien 1 сар өмнө
parent
commit
23c87542d8

+ 12 - 0
docs/chat-working-directory.md

@@ -0,0 +1,12 @@
+# 聊天工作目录
+
+夭夭 Web 与移动端通过同一个服务端执行规则,使用所选基础 Hermes Profile 的 `terminal.cwd`。客户端不决定服务器路径,也不把手机沙盒、Web 启动目录或历史会话目录作为覆盖值。
+
+- 新建普通聊天和 Bot 模式聊天时,服务端读取该 Profile 的 `/api/config`,将 `terminal.cwd` 传给 Hermes。
+- 旧会话恢复只恢复状态;在空闲会话下一次发送消息或上传附件前,使用 `session.cwd.set` 对齐工作目录。每次新一轮执行读取当前配置,目录已经一致时不重建终端。
+- 正在执行、等待交互或接收排队消息的会话不在运行中切换目录。后续空闲发送再应用最新配置。
+- 配置读取失败、路径无效或 Hermes 无法确认切换时,停止提交,不静默使用旧目录。旧版 Hermes 没有提供 `terminal.cwd` 时保留其默认行为。
+- `.`、`~` 和相对路径交给实际执行的 Hermes 主机解析。需要固定目录时应在 Hermes 配置中填写该主机上的绝对路径。
+- 扫码节点和远端 Agent 在目标服务读取自己的 Profile 配置,保留授权身份;原生 Hermes Bot 入口继续由其自身管理工作区。
+
+这是夭夭 Web 服务端的规则,两端客户端无需额外获取或缓存工作路径。部署更新后的 Web 服务后,移动端通过该服务发送的下一轮消息即可使用此规则;不迁移历史文件,不修改 Hermes 全局配置。

+ 7 - 0
src/server/realtimeApi.ts

@@ -10,6 +10,7 @@ import { NodePairingStore } from './pairing.js'
 import { CookieJar, UpstreamClient } from './upstream.js'
 import { CsrfProtection, isAllowedHostHeader, isExactOrigin } from './security.js'
 import { HttpError } from './errors.js'
+import { configuredWorkingDirectory } from './sessionWorkingDirectory.js'
 import { canonicalEpoch, groupCursor } from './realtimeProtocol.js'
 import { CHAT_MAX_PAYLOAD } from './realtimeProtocol.js'
 import { ChatPushRelayObserver, type ChatNotificationResolver, type PushEventCoordinator, type ChatPushTransportFactory } from './pushEvents.js'
@@ -68,6 +69,12 @@ export class RealtimeAPI {
     return {
       key, nativeBot, instanceKey: endpoint.href, upstreamKey: `${endpoint.href}:${device ? key : 'service'}`, paired: Boolean(device), valid, authorize,
       canResume: (profile, sessionId) => this.canResume(profile, sessionId, jar, owner),
+      configuredWorkingDirectory: profile => configuredWorkingDirectory((path, options) => {
+        if (!valid()) throw new HttpError(401, 'Authentication expired', 'authentication_required')
+        if (exported && endpoint.href !== this.config.upstream.href) return exported.target.session.request(path, options)
+        if (jar) return this.upstream.request(path, jar, options)
+        return this.session.request(path, options)
+      }, profile),
       agent: upstream.directAgent,
       observeCommand: f => observer?.observeClientFrame(f),
       observeEvent: f => observer?.observeUpstreamFrame(Buffer.from(f), false),

+ 30 - 1
src/server/realtimeBroker.ts

@@ -3,6 +3,7 @@ import WebSocket from 'ws'
 import { HttpError } from './errors.js'
 import { checkedChatFrame, CHAT_MAX_PAYLOAD, GroupFrameValidator } from './realtimeProtocol.js'
 import { RealtimeReceipts, type CommandReceipt } from './realtimeReceipts.js'
+import { applyWorkingDirectory, type AppliedWorkingDirectory } from './sessionWorkingDirectory.js'
 
 type Frame = Record<string, any>
 export interface RealtimeActivity { kind: 'command' | 'event' | 'group' | 'reset'; name: string; sessionId?: string; roomId?: string }
@@ -16,6 +17,7 @@ export interface RealtimePrincipal {
   valid(): boolean
   authorize?(kind: 'chat' | 'groups', method?: string): void
   canResume?(profile: string, sessionId: string): Promise<boolean>
+  configuredWorkingDirectory?(profile: string): Promise<string | undefined>
   url(channel: 'chat' | 'groups', anchor?: { epoch: string; cursor: number }): Promise<URL>
   observeCommand?(frame: string): void
   observeEvent?(frame: string): void
@@ -27,7 +29,7 @@ export interface RealtimeChannel {
   listeners: Set<(entry: StreamEntry) => void>; touched: number; bytes: number
   group?: { epoch: string; cursor: number }; socket?: WebSocket
 }
-interface Route { profile: string; stored: string; runtime: string; active: boolean; seq: number; observers: Map<string, RealtimePrincipal> }
+interface Route { profile: string; stored: string; runtime: string; active: boolean; seq: number; observers: Map<string, RealtimePrincipal>; cwd?: string; workingDirectory?: AppliedWorkingDirectory }
 interface Pending { resolve(frame: Frame): void; reject(error: Error): void; timer: ReturnType<typeof setTimeout> }
 interface Upstream {
   principal: RealtimePrincipal; socket?: WebSocket; connecting?: Promise<void>; ready?: Frame
@@ -218,6 +220,24 @@ export class RealtimeBroker {
       await this.connect(u)
       await u.recovery
       if (!c.principal.valid()) throw new HttpError(401, 'Authentication expired', 'authentication_required')
+      if (!c.principal.nativeBot && c.principal.configuredWorkingDirectory) {
+        if (method === 'session.create') {
+          const cwd = await c.principal.configuredWorkingDirectory(String(p.profile))
+          // Client cwd is never authoritative, including paired mobile clients.
+          delete p.cwd
+          if (cwd) p.cwd = cwd
+        } else if (route && !route.active && ['prompt.submit', 'file.attach', 'image.attach_bytes', 'pdf.attach'].includes(method)) {
+          const cwd = await c.principal.configuredWorkingDirectory(route.profile)
+          if (!c.principal.valid()) throw new HttpError(401, 'Authentication expired', 'authentication_required')
+          route.workingDirectory = await applyWorkingDirectory(async (method, params) => {
+            const response = await this.rpc(u, method, params)
+            if (response.error) throw new HttpError(502, response.error.message || '无法切换到 Agent 工作目录', 'working_directory_rejected')
+            return response.result
+          }, route.runtime, cwd, route.workingDirectory, route.cwd)
+          if (route.workingDirectory) route.cwd = route.workingDirectory.resolved
+        }
+        if (!c.principal.valid()) throw new HttpError(401, 'Authentication expired', 'authentication_required')
+      }
       const previouslyActive = route?.active ?? false
       if (route && (method === 'prompt.submit' || method === 'session.steer')) route.active = true
       const nativeOwner = this.nativeOwner(c.principal)
@@ -243,6 +263,9 @@ export class RealtimeBroker {
         u.byRuntime.delete(r.runtime)
         r.runtime = runtime
         r.active = result.running === true || info.running === true
+        // Resume/reconnect may have restored a historical cwd or another runtime.
+        r.workingDirectory = undefined
+        r.cwd = typeof info.cwd === 'string' ? info.cwd : undefined
         u.routes.set(key, r); u.byRuntime.set(runtime, r); c.routes.add(key)
         r.observers.set(c.principal.key, c.principal)
         if (nativeOwner) this.onNativeRoute(nativeOwner, profile, stored, runtime)
@@ -370,6 +393,10 @@ export class RealtimeBroker {
         if (r.seq > 0 && p.seq > r.seq + 1) this.reset(u, 'upstream_sequence_gap')
         r.seq = p.seq
       }
+      if (p.type === 'session.info' && typeof p.payload?.cwd === 'string') {
+        r.cwd = p.payload.cwd
+        if (r.cwd !== r.workingDirectory?.resolved) r.workingDirectory = undefined
+      }
       if (['message.start', 'approval.request', 'clarify.request'].includes(p.type)) r.active = true
       if (p.type === 'message.complete') r.active = false
       if (p.payload?.request_id) this.interactions.set(`${u.principal.upstreamKey}:${p.payload.request_id}`, sid)
@@ -430,6 +457,8 @@ export class RealtimeBroker {
       for (const r of u.routes.values()) {
         const lastSeen = r.seq
         const resumed = await this.rpc(u, 'session.resume', { session_id: r.stored, profile: r.profile, close_on_disconnect: false, omit_messages: true })
+        r.workingDirectory = undefined
+        r.cwd = typeof resumed.result?.info?.cwd === 'string' ? resumed.result.info.cwd : undefined
         if (typeof resumed.result?.running === 'boolean') r.active = resumed.result.running
         if (!resumed.result || resumed.result.session_id !== r.runtime || changed) {
           if (resumed.result?.session_id) { u.byRuntime.delete(r.runtime); r.runtime = resumed.result.session_id; u.byRuntime.set(r.runtime, r) }

+ 44 - 0
src/server/sessionWorkingDirectory.ts

@@ -0,0 +1,44 @@
+import { HttpError } from './errors.js'
+import type { UpstreamRequestOptions, UpstreamResponse } from './upstream.js'
+
+type Request = (path: string, options: UpstreamRequestOptions) => Promise<UpstreamResponse>
+export interface AppliedWorkingDirectory { configured: string; resolved: string }
+
+/** Read the selected Hermes profile, never resolve a remote path on the Web host. */
+export async function configuredWorkingDirectory(request: Request, profile: string): Promise<string | undefined> {
+  const response = await request('/api/config', {
+    search: new URLSearchParams({ profile }), cache: 'reload',
+  })
+  if (response.status !== 200) {
+    throw new HttpError(502, '无法读取 Agent 的 terminal.cwd,未开始执行', 'working_directory_unavailable')
+  }
+  let config: any
+  try { config = JSON.parse(response.body.toString()) } catch {
+    throw new HttpError(502, 'Agent 工作目录配置响应无效', 'invalid_working_directory')
+  }
+  const raw = config?.terminal?.cwd
+  // Older Hermes installations may not expose this setting.
+  if (raw === undefined || raw === null || raw === '') return undefined
+  if (typeof raw !== 'string' || !raw.trim() || raw.length > 4096 || /[\u0000-\u001f\u007f]/.test(raw)) {
+    throw new HttpError(502, 'Agent 的 terminal.cwd 配置无效', 'invalid_working_directory')
+  }
+  return raw.trim()
+}
+
+/** Session-only RPC: updates tool execution and persisted cwd without changing global config. */
+export async function applyWorkingDirectory(
+  rpc: (method: string, params: Record<string, unknown>) => Promise<any>,
+  sessionID: string,
+  configured: string | undefined,
+  previous?: AppliedWorkingDirectory,
+  observedCwd?: string,
+): Promise<AppliedWorkingDirectory | undefined> {
+  if (!configured) return undefined
+  if (previous?.configured === configured) return previous
+  if (observedCwd === configured) return { configured, resolved: observedCwd }
+  const info = await rpc('session.cwd.set', { session_id: sessionID, cwd: configured })
+  if (typeof info?.cwd !== 'string' || !info.cwd.trim()) {
+    throw new HttpError(502, 'Hermes 未确认会话工作目录,未开始执行', 'working_directory_unconfirmed')
+  }
+  return { configured, resolved: info.cwd }
+}

+ 8 - 0
src/server/workspaceRuntime.ts

@@ -4,6 +4,7 @@ import { z } from 'zod'
 import { WorkspaceStore, parse } from './workspaceStore.js'
 import { WorkspaceNodes, WorkspaceGateway, type GatewayFrame } from './workspaceGateway.js'
 import { HttpError } from './errors.js'
+import { applyWorkingDirectory, configuredWorkingDirectory } from './sessionWorkingDirectory.js'
 import { UploadStore } from './uploads.js'
 import type {
   WorkspaceAgent as Agent,
@@ -372,6 +373,10 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     try {
       await gateway.connect()
       this.nodes.requireSource(owner, agent)
+      // Paired Web nodes enforce their own profile config at the destination.
+      const cwd = recovering || target.pairedToken ? undefined
+        : await configuredWorkingDirectory((path, options) => target.session.request(path, options), agent.profile)
+      this.nodes.requireSource(owner, agent)
       const opened = binding?.storedId
         ? await gateway.rpc('session.resume', {
             profile: agent.profile,
@@ -383,6 +388,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
             profile: agent.profile,
             title: `Yaoyao ${c.id}${run.conversationTaskId ? ` / ${run.conversationTaskId}` : ''}`,
             source: 'yaoyao_workspace',
+            ...(cwd ? { cwd } : {}),
             hidden: true,
             room_plumbing: true,
             close_on_disconnect: false,
@@ -459,6 +465,8 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         }
       } else {
         if (opened.running) throw new HttpError(409, '上游会话仍在运行', 'session_busy')
+        this.nodes.requireSource(owner, agent)
+        await applyWorkingDirectory((method, params) => gateway.rpc(method, params), runtimeId, cwd, undefined, opened.info?.cwd)
         const trigger = this.store.require<Message>(owner, 'message', run.messageId)
         const attachmentRefs: string[] = []
         for (const file of trigger.attachments) {

+ 82 - 1
tests/server/realtimeBroker.test.ts

@@ -17,6 +17,7 @@ async function fixture() {
   const commands: any[] = []
   let count = 0
   let seq = 0, truncate = false, dropPrompt = false
+  let cwd = '/old-session', busy = false, rejectCwd = false
   const history: any[] = []
   let peer: WebSocket
   ws.on('connection', socket => {
@@ -33,7 +34,11 @@ async function fixture() {
       } else if (f.method === 'session.branch') {
         socket.send(JSON.stringify({ id: f.id, result: { session_id: 'runtime-branch', stored_session_id: 'stored-branch' } }))
       } else if (f.method === 'session.create' || f.method === 'session.resume') {
-        socket.send(JSON.stringify({ id: f.id, result: { session_id: 'runtime-1', stored_session_id: 'stored-1', running: false } }))
+        if (f.method === 'session.create' && f.params.cwd) cwd = f.params.cwd
+        socket.send(JSON.stringify({ id: f.id, result: { session_id: 'runtime-1', stored_session_id: 'stored-1', running: busy, info: { cwd } } }))
+      } else if (f.method === 'session.cwd.set') {
+        if (rejectCwd) socket.send(JSON.stringify({ id: f.id, error: { message: 'working directory does not exist' } }))
+        else { cwd = f.params.cwd; socket.send(JSON.stringify({ id: f.id, result: { cwd } })) }
       } else {
         setTimeout(() => socket.send(JSON.stringify({ id: f.id, result: { status: 'submitted' } })), 20)
       }
@@ -46,6 +51,7 @@ async function fixture() {
   const principal = (key: string): RealtimePrincipal => ({ key, upstreamKey: 'one-service', paired: false,
     valid: () => true, url: async () => new URL(`ws://127.0.0.1:${(ws.address() as any).port}`) })
   return { home, broker, principal, commands, activities, count: () => count, advance: (n: number) => { now += n },
+    cwd: () => cwd, busy: () => { busy = true }, rejectCwd: () => { rejectCwd = true },
     disconnect: () => peer!.terminate(), truncate: () => { truncate = true }, dropPrompt: () => { dropPrompt = true },
     emit: (type: string, payload: unknown) => {
       const params = { type, session_id: 'runtime-1', seq: ++seq, payload }; history.push(params)
@@ -54,6 +60,81 @@ async function fixture() {
 }
 const resume = { method: 'session.resume', params: { session_id: 'stored-1', profile: 'default' } }
 describe('realtime broker', () => {
+  it.each([
+    { source: 'web', paired: false }, { source: 'ios', paired: false }, { source: 'ios', paired: true },
+  ])('uses the profile terminal.cwd for $source (paired=$paired), ignoring client cwd', async ({ source, paired }) => {
+    const f = await fixture()
+    const read = vi.fn(async (_profile: string) => '/Hermes/配置目录')
+    const principal = { ...f.principal('alice'), paired, configuredWorkingDirectory: read }
+    const channel = await f.broker.create(principal, 'chat')
+    const created = await f.broker.command(channel, 'create', {
+      method: 'session.create', params: { profile: 'work', source, cwd: '/client/override' },
+    })
+    expect(created.state).toBe('confirmed')
+    expect(f.commands[0].params.cwd).toBe('/Hermes/配置目录')
+    const sent = await f.broker.command(channel, 'send', {
+      method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'run' },
+    })
+    expect(sent.state).toBe('confirmed')
+    expect(read.mock.calls.every(([profile]) => profile === 'work')).toBe(true)
+    expect(f.commands.map(x => x.method)).toEqual(['session.create', 'prompt.submit'])
+    expect(f.cwd()).toBe('/Hermes/配置目录')
+  })
+
+  it('corrects an old cwd before attachments and follows config changes on the next turn', async () => {
+    const f = await fixture()
+    let configured = '/configured/first'
+    const channel = await f.broker.create({ ...f.principal('alice'),
+      configuredWorkingDirectory: async () => configured }, 'chat')
+    await f.broker.command(channel, 'resume', resume)
+    expect(f.cwd()).toBe('/old-session')
+    await f.broker.command(channel, 'attach', {
+      method: 'file.attach', params: { session_id: 'runtime-1', data_url: 'data:text/plain;base64,eA==' },
+    })
+    expect(f.cwd()).toBe('/configured/first')
+    await f.broker.command(channel, 'send', { method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'one' } })
+    expect(f.commands.filter(x => x.method === 'session.cwd.set')).toHaveLength(1)
+    configured = '/configured/second'
+    const events: StreamEntry[] = []
+    f.broker.subscribe(channel, undefined, entry => events.push(entry))
+    f.emit('message.complete', {})
+    await vi.waitFor(() => expect(events.some(e => e.data.includes('message.complete'))).toBe(true))
+    await f.broker.command(channel, 'send-next', { method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'two' } })
+    expect(f.cwd()).toBe('/configured/second')
+    expect(f.commands.filter(x => x.method === 'session.cwd.set')).toHaveLength(2)
+    expect(f.commands.some(x => x.method === 'config.set')).toBe(false)
+  })
+
+  it('does not switch a running session when another client resumes or queues a message', async () => {
+    const f = await fixture(); f.busy()
+    const read = vi.fn(async () => '/configured')
+    const channel = await f.broker.create({ ...f.principal('alice'), configuredWorkingDirectory: read }, 'chat')
+    await f.broker.command(channel, 'resume', resume)
+    await f.broker.command(channel, 'queued', { method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'next', queued: true } })
+    expect(read).not.toHaveBeenCalled()
+    expect(f.commands.map(x => x.method)).toEqual(['session.resume', 'prompt.submit'])
+  })
+
+  it('does not submit a prompt when Hermes rejects the configured directory', async () => {
+    const f = await fixture(); f.rejectCwd()
+    const channel = await f.broker.create({ ...f.principal('alice'), configuredWorkingDirectory: async () => '/missing' }, 'chat')
+    await f.broker.command(channel, 'resume', resume)
+    const sent = await f.broker.command(channel, 'send', { method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'run' } })
+    expect(sent).toMatchObject({ state: 'rejected', response: { error: { code: 'working_directory_rejected' } } })
+    expect(f.commands.some(x => x.method === 'prompt.submit')).toBe(false)
+  })
+
+  it('checks revocation again after loading the configured directory', async () => {
+    const f = await fixture()
+    let valid = true
+    const channel = await f.broker.create({ ...f.principal('alice'), valid: () => valid,
+      configuredWorkingDirectory: async () => { valid = false; return '/configured' } }, 'chat')
+    await f.broker.command(channel, 'resume', resume)
+    const sent = await f.broker.command(channel, 'send', { method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'run' } })
+    expect(sent).toMatchObject({ state: 'rejected', response: { error: { code: 'authentication_required' } } })
+    expect(f.commands.map(x => x.method)).toEqual(['session.resume'])
+  })
+
   it('rejects resuming a history-only session before opening an upstream route', async () => {
     const f = await fixture()
     const routes: string[] = []

+ 18 - 1
tests/server/realtimeMigration.test.ts

@@ -40,6 +40,7 @@ function closeRuntime(runtime: NodeServerRuntime): () => Promise<void> {
 describe('HTTP+SSE migration', () => {
   it('lets a QR-paired iOS node use authenticated HTTP+SSE and rejects old tickets', async () => {
     let pairedCookie = ''
+    const cwdReads: Array<{ profile: string | null; cookie: string }> = []
     const pairedFrames: Array<Record<string, unknown>> = []
     const upstream = createServer((request, response) => {
       response.setHeader('Content-Type', 'application/json')
@@ -55,6 +56,10 @@ describe('HTTP+SSE migration', () => {
         response.end(JSON.stringify({ profiles: [{ name: 'default', is_default: true }] }))
       } else if (request.url === '/api/plugins/yaoyao/profiles') {
         response.end(JSON.stringify({ profiles: [{ name: 'default', botName: '竹儿', agentName: '旧插件名称' }] }))
+      } else if (request.url?.startsWith('/api/config?')) {
+        const profile = new URL(request.url, 'http://hermes.test').searchParams.get('profile')
+        cwdReads.push({ profile, cookie: request.headers.cookie ?? '' })
+        response.end(JSON.stringify({ terminal: { cwd: '/Hermes/agent-work' } }))
       } else if (request.url === '/api/auth/providers') {
         response.end(JSON.stringify({ providers: [{ name: 'basic', supports_password: true }] }))
       } else if (request.url === '/auth/password-login' && request.method === 'POST') {
@@ -80,7 +85,8 @@ describe('HTTP+SSE migration', () => {
         client.on('message', data => {
           const frame = JSON.parse(data.toString()) as Record<string, unknown>
           pairedFrames.push(frame)
-          client.send(JSON.stringify({ id: frame.id, result: frame.method === 'session.create' ? { session_id: 'runtime-test', stored_session_id: 'stored-test' } : {} }))
+          client.send(JSON.stringify({ id: frame.id, result: frame.method === 'session.create'
+            ? { session_id: 'runtime-test', stored_session_id: 'stored-test', info: { cwd: '/Hermes/agent-work' } } : {} }))
         })
       })
     })
@@ -154,6 +160,17 @@ describe('HTTP+SSE migration', () => {
     })
     expect(opened.status).toBe(201)
     expect(pairedFrames).toEqual([])
+    const channel = (await opened.json() as { id: string }).id
+    const command = (id: string, method: string, params: object) => fetch(`${claim.serverUrl}/api/realtime/channels/${channel}/commands`, {
+      method: 'POST', headers: { Authorization: authorization, 'Content-Type': 'application/json', 'Idempotency-Key': id },
+      body: JSON.stringify({ method, params }),
+    })
+    const created = await command('ios-create', 'session.create', { profile: 'default', source: 'ios', cwd: '/mobile/override' })
+    expect(await created.json()).toMatchObject({ state: 'confirmed' })
+    expect(pairedFrames.at(-1)).toMatchObject({ method: 'session.create', params: { cwd: '/Hermes/agent-work' } })
+    expect(await (await command('ios-send', 'prompt.submit', { session_id: 'runtime-test', text: 'hello' })).json()).toMatchObject({ state: 'confirmed' })
+    expect(cwdReads).toHaveLength(2)
+    expect(cwdReads.every(r => r.profile === 'default' && r.cookie.includes('paired'))).toBe(true)
     const revoke = await fetch(`${origin}/api/pair/v1/devices/${claim.deviceId}`, {
       method: 'DELETE', headers: { Authorization: authorization },
     })

+ 20 - 0
tests/server/sessionWorkingDirectory.test.ts

@@ -0,0 +1,20 @@
+import { describe, expect, it, vi } from 'vitest'
+import { configuredWorkingDirectory, applyWorkingDirectory } from '../../src/server/sessionWorkingDirectory.js'
+
+describe('profile working directory', () => {
+  it.each(['/remote/agent work', '~/Agents', '.'])('leaves %s to Hermes to resolve on its own host', async cwd => {
+    const request = vi.fn(async () => ({ status: 200, headers: new Headers(), body: Buffer.from(JSON.stringify({ terminal: { cwd } })) }))
+    expect(await configuredWorkingDirectory(request, 'work')).toBe(cwd)
+    expect(request).toHaveBeenCalledWith('/api/config', { search: new URLSearchParams({ profile: 'work' }), cache: 'reload' })
+    const rpc = vi.fn(async () => ({ cwd: '/resolved/on/hermes' }))
+    expect(await applyWorkingDirectory(rpc, 'session', cwd)).toEqual({ configured: cwd, resolved: '/resolved/on/hermes' })
+    expect(rpc).toHaveBeenCalledWith('session.cwd.set', { session_id: 'session', cwd })
+  })
+
+  it('fails closed on unreadable configuration or unconfirmed cwd', async () => {
+    await expect(configuredWorkingDirectory(async () => ({ status: 403, headers: new Headers(), body: Buffer.from('{}') }), 'work'))
+      .rejects.toMatchObject({ code: 'working_directory_unavailable' })
+    await expect(applyWorkingDirectory(async () => ({}), 'session', '/configured'))
+      .rejects.toMatchObject({ code: 'working_directory_unconfirmed' })
+  })
+})

+ 25 - 1
tests/server/workspace.test.ts

@@ -25,6 +25,7 @@ let home: string,
 let requests: Array<{ method: string; params: Record<string, any> }>,
   reply: (socket: WebSocket, params: Record<string, any>) => void
 let nodes: WorkspaceNodes
+let configuredCwds: Map<string, string>
 let rejectPrompt: string | undefined
 let rejectedInterrupts: number
 let recoveryHistory: Array<{ role: string; content: string; tool_calls?: unknown }>
@@ -36,6 +37,7 @@ beforeEach(async () => {
   store = new WorkspaceStore(home)
   uploads = new UploadStore(home)
   requests = []
+  configuredCwds = new Map()
   recoveryHistory = []
   storedByRuntime = new Map(); recoveryByStored = new Map()
   rejectPrompt = undefined
@@ -50,7 +52,9 @@ beforeEach(async () => {
       webSocketCredential: async () => ({ name: 'ticket', value: 'test' }),
       request: async (path: string, options?: {search?: URLSearchParams}) => ({
         status: 200,
-        body: Buffer.from(JSON.stringify({ messages: (() => { const all = recoveryByStored.get(decodeURIComponent(path.split('/')[3]!)) ?? recoveryHistory; const offset = Number(options?.search?.get('offset') ?? 0); return all.slice(Math.max(0,all.length-offset-500), all.length-offset) })() })),
+        body: Buffer.from(JSON.stringify(path === '/api/config'
+          ? { terminal: { cwd: configuredCwds.get(options?.search?.get('profile') ?? '') } }
+          : { messages: (() => { const all = recoveryByStored.get(decodeURIComponent(path.split('/')[3]!)) ?? recoveryHistory; const offset = Number(options?.search?.get('offset') ?? 0); return all.slice(Math.max(0,all.length-offset-500), all.length-offset) })() })),
       }),
     },
   }
@@ -82,6 +86,7 @@ beforeEach(async () => {
         storedByRuntime.set(runtimeId, storedId)
         respond({ session_id: runtimeId, stored_session_id: storedId, running: false, info: { profile_name: frame.params.profile } })
       }
+      else if (frame.method === 'session.cwd.set') respond({ cwd: frame.params.cwd })
       else if (frame.method === 'prompt.submit') {
         if (rejectPrompt) { socket.send(JSON.stringify({id:frame.id,error:{code:4006,message:rejectPrompt}})); return }
         respond({ status: 'streaming' })
@@ -119,6 +124,25 @@ async function finished(id: string) {
   return store.require<WorkspaceRun>(owner, 'run', id)
 }
 describe('Web-owned workspace', () => {
+  it('uses each base profile cwd and replaces historical cwd before the next Bot turn', async () => {
+    configuredCwds.set('default', '/profile/first')
+    configuredCwds.set('other', '/profile/other')
+    const a = agent('主助手'), c = direct(a.id)
+    await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: 'first' }).id)
+    expect(requests.find(r => r.method === 'session.create')?.params.cwd).toBe('/profile/first')
+    expect(requests.find(r => r.method === 'session.cwd.set')?.params.cwd).toBe('/profile/first')
+    configuredCwds.set('default', '/profile/changed')
+    const before = requests.length
+    await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: 'second' }).id)
+    const next = requests.slice(before)
+    expect(next[0].method).toBe('session.resume')
+    expect(next.find(r => r.method === 'session.cwd.set')?.params.cwd).toBe('/profile/changed')
+    expect(next.findIndex(r => r.method === 'session.cwd.set')).toBeLessThan(next.findIndex(r => r.method === 'prompt.submit'))
+    const b = store.createAgent(owner, { name: '其他 Profile', profile: 'other' })
+    await finished(runtime.send(owner, direct(b.id).id, { requestId: randomUUID(), content: 'third' }).id)
+    expect(requests.filter(r => r.method === 'session.create').at(-1)?.params.cwd).toBe('/profile/other')
+  })
+
   it('flushes a paused delta before completion and cancels pending flush on finish', async () => {
     let upstream: { socket: WebSocket; params: Record<string, any> } | undefined
     reply = (socket, params) => {

+ 9 - 2
tests/server/workspaceRemoteAgents.test.ts

@@ -18,7 +18,8 @@ beforeEach(async()=>{
     socket.send(JSON.stringify({method:'event',params:{type:'gateway.ready'}}))
     socket.on('message',raw=>{
       const frame=JSON.parse(String(raw));frames.push(frame)
-      const result=['session.create','session.resume'].includes(frame.method) ? {session_id:randomUUID(),stored_session_id:frame.params.session_id ?? randomUUID(),info:{profile_name:frame.params.profile}} : {ok:true}
+      const result=['session.create','session.resume'].includes(frame.method) ? {session_id:randomUUID(),stored_session_id:frame.params.session_id ?? randomUUID(),info:{profile_name:frame.params.profile}}
+        : frame.method==='session.cwd.set' ? {cwd:frame.params.cwd} : {ok:true}
       socket.send(JSON.stringify({id:frame.id,result}))
     })
   })
@@ -30,6 +31,10 @@ beforeEach(async()=>{
       return Response.json(url.pathname.endsWith('/channels') ? {id:'55555555-5555-4555-8555-555555555555'} : {state:'confirmed',response:{result:{session_id:'runtime',stored_session_id:'stored'}}},{status:url.pathname.endsWith('/channels')?201:200})
     }
     if(url.pathname==='/api/auth/ws-ticket')return Response.json({ticket:'test-ticket'})
+    if(url.pathname==='/api/config') {
+      forwarded.push({path:url.pathname,profile:url.searchParams.get('profile'),headers:new Headers(init.headers)})
+      return Response.json({terminal:{cwd:'/remote/'+url.searchParams.get('profile')}})
+    }
     return Response.json({messages:[],profiles:[{name:'remote-profile'}]})
   }) as typeof fetch})
 })
@@ -74,9 +79,11 @@ describe('referenced remote Bot Agents',()=>{
     const opened=await rpc('session.create',{profile:'wrong-profile',model:'overwrite',cwd:'/different',fast:true,reasoning_effort:'high',messages:[{role:'system',content:'override'}]})
     expect(opened.status).toBe(200)
     const result=opened.body.response.result
-    expect(frames.at(-1).params.profile).toBe('remote-profile');expect(frames.at(-1).params.model).toBeUndefined();expect(frames.at(-1).params.cwd).toBe('');expect(frames.at(-1).params.fast).toBeUndefined();expect(frames.at(-1).params.messages).toBeUndefined()
+    expect(frames.at(-1).params.profile).toBe('remote-profile');expect(frames.at(-1).params.model).toBeUndefined();expect(frames.at(-1).params.cwd).toBe('/remote/remote-profile');expect(frames.at(-1).params.fast).toBeUndefined();expect(frames.at(-1).params.messages).toBeUndefined()
     expect((await rpc('prompt.submit',{session_id:result.session_id,text:'question'})).status).toBe(200)
     expect(frames.at(-1).params.text).toContain('REMOTE RULE V1')
+    expect(frames.find(f=>f.method==='session.cwd.set')?.params.cwd).toBe('/remote/remote-profile')
+    expect(forwarded.filter(f=>f.path==='/api/config').every(f=>f.profile==='remote-profile' && f.headers.get('cookie')?.includes('delegated-native-cookie=test'))).toBe(true)
     runtime.workspace.updateAgent('owner',agent.id,{instructions:'REMOTE RULE V2'})
     expect((await rpc('prompt.submit',{session_id:result.session_id,text:'next question'})).status).toBe(200)
     expect(frames.at(-1).params.text).toContain('REMOTE RULE V2');expect(frames.at(-1).params.text).not.toContain('REMOTE RULE V1')