Merge branch 'stack/agent-profiles-5-web-ui' into stack/agent-profiles-8-authoring

# Conflicts:
#	packages/host/apiproxy/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-08-07 15:41:18 +08:00
103 changed files with 2198 additions and 170 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 2715a3115307017a06ac13f35efd50c239036923
README.zh.md: 597c1fe2df6ce97385e16c6120e62fdfe0ab090d
README.md: 395e0d5085878e230fdf7de49a0ca47745bdc270
README.zh.md: 2ef34f7d6e7ae031dd5f847dfa13827fe4550839

View File

@@ -32,7 +32,7 @@ A stale continuation discards every partial result, deduplication entry, and cur
Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method does not use the default 30-second unary timeout, while caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request.
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). WSL translates the Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item` instead of assuming a Linux desktop association. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
The `agentPreset.list` domain exposes the deployment's preset roster so a browser can offer a choice when starting a session; each row carries its `trust` (a `user` preset is exactly as privileged as the plugins it names) and whether it is the current default. A deployment composing no presets answers with an empty roster rather than an error, because sharing the host composition is a valid deployment. `agentPreset.select` recomposes one session's agent from a different preset, and is allowed only while the session is blank: once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so the attempt answers `agent-preset-locked`. The agent and the session survive — only the composition is swapped, and a failed swap restores the previous one.

View File

@@ -32,7 +32,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,不使用默认的 30 秒一元调用超时,而调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable``directory-exists``directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏dsh-client-connection像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径macOS 为 `open`Windows 为 `Invoke-Item`Linux 为 `xdg-open`)。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径macOS 为 `open`Windows 为 `Invoke-Item`桌面 Linux 为 `xdg-open`)。WSL 会通过 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
`agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust``user` preset 的权限恰好等于它所引用的插件)以及它是否为当前默认值。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。`agentPreset.select` 用另一个 preset 重组某个会话的 agent且仅在会话空白时允许一旦跑过任何轮次那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call此时返回 `agent-preset-locked`。agent 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。

View File

@@ -1,5 +1,6 @@
/** Cross-platform native path and text-document openers used by the local GUI carrier. */
import { release as osRelease } from 'node:os'
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
/** Testable command boundary; native implementations never invoke a shell. */
@@ -8,6 +9,10 @@ export type PathOpenerRunner = NativeCommandRunner
/** Injectable platform facts for deterministic adapter tests. */
export interface PathOpenerInternals {
platform?: NodeJS.Platform
/** Kernel release override used to distinguish WSL from desktop Linux. */
osRelease?: string
/** WSL environment marker override used with the kernel release. */
env?: Readonly<Partial<Record<'WSL_DISTRO_NAME' | 'WSL_INTEROP', string>>>
run?: PathOpenerRunner
}
@@ -19,6 +24,36 @@ function powershellLiteral(path: string): string {
return `'${path.replace(/'/g, "''")}'`
}
/** Whether one environment marker is set to a non-empty value. */
function present(value: string | undefined): boolean {
return value !== undefined && value !== ''
}
/** Distinguish WSL from desktop Linux using its process and kernel markers. */
function isWsl(internals: PathOpenerInternals): boolean {
const env = internals.env ?? process.env
if (present(env.WSL_DISTRO_NAME) || present(env.WSL_INTEROP)) return true
return (internals.osRelease ?? osRelease()).toLowerCase().includes('microsoft')
}
/** Open one Windows-resolvable path through its registered desktop application. */
async function openWindowsPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise<void> {
await run('powershell.exe', [
'-NoProfile',
'-Command',
`Invoke-Item -LiteralPath ${powershellLiteral(path)}`,
], signal)
}
/** Translate a WSL path before handing it to the Windows desktop. */
async function openWslPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise<void> {
const translated = await run('wslpath', ['-w', path], signal)
signal.throwIfAborted()
const windowsPath = translated.stdout.replace(/[\r\n]+$/, '')
if (windowsPath === '') throw new Error('wslpath returned no Windows path')
await openWindowsPath(windowsPath, signal, run)
}
/** Dispatch one shell-free platform command for the requested open intent. */
async function openNativePathWithIntent(
path: string,
@@ -35,15 +70,15 @@ async function openNativePathWithIntent(
}
if (platform === 'win32') {
await run('powershell.exe', [
'-NoProfile',
'-Command',
`Invoke-Item -LiteralPath ${powershellLiteral(path)}`,
], signal)
await openWindowsPath(path, signal, run)
return
}
if (platform === 'linux') {
if (isWsl(internals)) {
await openWslPath(path, signal, run)
return
}
await run('xdg-open', [path], signal)
return
}

View File

@@ -14,6 +14,7 @@ const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn<ExecFileMock>()
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
import { release as osRelease } from 'node:os'
import { describe, expect, it, vi } from 'vitest'
import { openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts'
@@ -34,10 +35,58 @@ describe('native path opener', () => {
it('uses the Linux desktop association for text documents', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativeTextFile('/tmp/settings.yaml', signal(), { platform: 'linux', run })
await openNativeTextFile('/tmp/settings.yaml', signal(), {
platform: 'linux', osRelease: '6.8.0-generic', env: {}, run,
})
expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/settings.yaml'], expect.any(AbortSignal))
})
it.each([
['distribution marker', { WSL_DISTRO_NAME: 'Ubuntu' }, '6.8.0-generic'],
['interop marker', { WSL_INTEROP: '/run/WSL/123_interop' }, '6.8.0-generic'],
['kernel release', {}, '5.15.153.1-microsoft-standard-WSL2'],
])('hands WSL text documents to the Windows desktop from the %s', async (_label, env, osRelease) => {
const requestSignal = signal()
const run = vi.fn<PathOpenerRunner>(async command => command === 'wslpath'
? { stdout: '\\\\wsl.localhost\\Ubuntu\\home\\test user\\settings.yaml\r\n', stderr: '' }
: { stdout: '', stderr: '' })
await openNativeTextFile('/home/test user/settings.yaml', requestSignal, {
platform: 'linux', osRelease, env, run,
})
expect(run.mock.calls).toEqual([
['wslpath', ['-w', '/home/test user/settings.yaml'], requestSignal],
[
'powershell.exe',
[
'-NoProfile',
'-Command',
"Invoke-Item -LiteralPath '\\\\wsl.localhost\\Ubuntu\\home\\test user\\settings.yaml'",
],
requestSignal,
],
])
})
it('rejects an empty WSL path translation before invoking Windows', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '\r\n', stderr: '' }))
await expect(openNativeTextFile('/home/test/settings.yaml', signal(), {
platform: 'linux', osRelease: '6.8.0-generic', env: { WSL_DISTRO_NAME: 'Ubuntu' }, run,
})).rejects.toThrow('wslpath returned no Windows path')
expect(run).toHaveBeenCalledOnce()
})
it('does not invoke Windows when the request aborts during WSL path translation', async () => {
const abort = new AbortController()
const run = vi.fn<PathOpenerRunner>(async () => {
abort.abort(new Error('closed'))
return { stdout: '\\\\wsl.localhost\\Ubuntu\\home\\test\\settings.yaml\n', stderr: '' }
})
await expect(openNativeTextFile('/home/test/settings.yaml', abort.signal, {
platform: 'linux', osRelease: '6.8.0-generic', env: { WSL_DISTRO_NAME: 'Ubuntu' }, run,
})).rejects.toThrow('closed')
expect(run).toHaveBeenCalledOnce()
})
it('opens with Windows Invoke-Item and escapes single quotes', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativePath("C:\\work\\o'reilly.txt", signal(), { platform: 'win32', run })
@@ -60,7 +109,10 @@ describe('native path opener', () => {
it('opens with Linux xdg-open', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativePath('/tmp/a.txt', signal(), { platform: 'linux', run })
await openNativePath('/tmp/a.txt', signal(), {
platform: 'linux', osRelease: '6.8.0-generic',
env: { WSL_DISTRO_NAME: '', WSL_INTEROP: '' }, run,
})
expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/a.txt'], expect.any(AbortSignal))
})
@@ -71,7 +123,9 @@ describe('native path opener', () => {
it('uses the current process platform when no platform override is supplied', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativePath('/tmp/platform-default.txt', signal(), { run })
await openNativePath('/tmp/platform-default.txt', signal(), {
osRelease: '6.8.0-generic', env: {}, run,
})
const expected = process.platform === 'win32'
? 'powershell.exe'
: process.platform === 'linux'
@@ -80,6 +134,17 @@ describe('native path opener', () => {
expect(run.mock.calls[0]?.[0]).toBe(expected)
})
it('samples ambient WSL markers and kernel release when no fact overrides are supplied', async () => {
const ambientWsl = [process.env.WSL_DISTRO_NAME, process.env.WSL_INTEROP]
.some(value => value !== undefined && value !== '')
|| osRelease().toLowerCase().includes('microsoft')
const run = vi.fn<PathOpenerRunner>(async command => command === 'wslpath'
? { stdout: 'C:\\settings.yaml\n', stderr: '' }
: { stdout: '', stderr: '' })
await openNativePath('/tmp/ambient-facts.yaml', signal(), { platform: 'linux', run })
expect(run.mock.calls[0]?.[0]).toBe(ambientWsl ? 'wslpath' : 'xdg-open')
})
it('runs the default command adapter without a shell and preserves command failures', async () => {
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
callback(null, '', '')