Merge remote-tracking branch 'origin/master' into worktree/persist-web-theme-settings
# Conflicts: # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-theme/README.i18n.yaml # packages/host/apiproxy/README.i18n.yaml
This commit is contained in:
@@ -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: bd7fdc0c4256111b88946780efcf0997c0ec1a62
|
||||
README.zh.md: 76b3549d4444f4ce6a03e77c604edf41dbbca0f8
|
||||
README.md: f6ba1f93f910fb3f8733a3d5c806fcf97af6d958
|
||||
README.zh.md: 54f6e26a2d2145268cc995d4a0d4ff1d72543c3f
|
||||
|
||||
@@ -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, 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`.
|
||||
`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). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
|
||||
|
||||
@@ -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`)。WSL 会通过 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
|
||||
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSe
|
||||
export type { CredentialsApi, CredentialView } from './credentials.ts'
|
||||
export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts'
|
||||
export type { ApprovalResponsePayload } from './approvals.ts'
|
||||
|
||||
export type { QuestionResponsePayload } from './questions.ts'
|
||||
|
||||
// ---- Message layer: narrow forms (domain-signature view) ----
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
/** Cross-platform native path and text-document openers used by the local GUI carrier. */
|
||||
/**
|
||||
* Cross-platform native path and text-document openers used by the local GUI
|
||||
* carrier.
|
||||
*
|
||||
* The default intent prefers the default browser for documents it renders when
|
||||
* the platform can name one, then falls back to the default application. WSL
|
||||
* translates every path for the Windows desktop instead of assuming a Linux
|
||||
* GUI. The text-editor intent never consults the browser.
|
||||
*/
|
||||
|
||||
import { release as osRelease } from 'node:os'
|
||||
import { extname } from 'node:path'
|
||||
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
|
||||
|
||||
/** Testable command boundary; native implementations never invoke a shell. */
|
||||
@@ -11,11 +20,63 @@ 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>>>
|
||||
/** Environment used for WSL markers and the desktop Linux browser convention. */
|
||||
env?: NodeJS.ProcessEnv
|
||||
run?: PathOpenerRunner
|
||||
}
|
||||
|
||||
/** Documents a browser renders, as opposed to ones an editor merely edits. */
|
||||
const BROWSER_DOCUMENTS = new Set(['.html', '.htm', '.xhtml', '.svg'])
|
||||
|
||||
/**
|
||||
* The macOS bundle registered for `https` — the default browser, as
|
||||
* LaunchServices records it. The nested version dict is stripped first
|
||||
* because it carries its own `LSHandlerRoleAll`.
|
||||
*/
|
||||
function macBundleForHttps(plist: string): string | undefined {
|
||||
const stripped = plist.replace(/LSHandlerPreferredVersions\s*=\s*\{[^}]*\};/g, '')
|
||||
const block = /\{[^{}]*LSHandlerURLScheme\s*=\s*"?https"?;[^{}]*\}/.exec(stripped)?.[0]
|
||||
if (block === undefined) return undefined
|
||||
return /LSHandlerRoleAll\s*=\s*"?([\w.-]+)"?;/.exec(block)?.[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one browser-renderable document with the default browser.
|
||||
* @returns true when a browser took it; false when this platform cannot name
|
||||
* one, or naming it failed — the caller then uses the default application.
|
||||
*/
|
||||
async function openInBrowser(
|
||||
path: string, signal: AbortSignal, platform: NodeJS.Platform,
|
||||
run: PathOpenerRunner, env: NodeJS.ProcessEnv,
|
||||
): Promise<boolean> {
|
||||
if (platform === 'darwin') {
|
||||
let bundle: string | undefined
|
||||
try {
|
||||
const { stdout } = await run(
|
||||
'defaults', ['read', 'com.apple.LaunchServices/com.apple.launchservices.secure'], signal)
|
||||
bundle = macBundleForHttps(stdout)
|
||||
} catch {
|
||||
// No LaunchServices record (a fresh account never changed a default):
|
||||
// the content-type handler is then the system's own choice anyway.
|
||||
return false
|
||||
}
|
||||
if (bundle === undefined) return false
|
||||
await run('open', ['-b', bundle, path], signal)
|
||||
return true
|
||||
}
|
||||
if (platform === 'linux') {
|
||||
// $BROWSER is the portable convention; desktop-entry resolution through
|
||||
// xdg-settings needs a launcher this package has no business shipping.
|
||||
const browser = env.BROWSER
|
||||
if (browser === undefined || browser === '') return false
|
||||
await run(browser, [path], signal)
|
||||
return true
|
||||
}
|
||||
// Windows names no browser without reading the UserChoice registry, and its
|
||||
// .html association is the browser in the ordinary case.
|
||||
return false
|
||||
}
|
||||
|
||||
/** Native path-open intent; macOS distinguishes text editing from file association. */
|
||||
type PathOpenIntent = 'default' | 'text-editor'
|
||||
|
||||
@@ -63,6 +124,11 @@ async function openNativePathWithIntent(
|
||||
): Promise<void> {
|
||||
const platform = internals.platform ?? process.platform
|
||||
const run = internals.run ?? runNativeCommand
|
||||
const env = internals.env ?? process.env
|
||||
const wsl = platform === 'linux' && isWsl(internals)
|
||||
|
||||
if (!wsl && intent === 'default' && BROWSER_DOCUMENTS.has(extname(path).toLowerCase())
|
||||
&& await openInBrowser(path, signal, platform, run, env)) return
|
||||
|
||||
if (platform === 'darwin') {
|
||||
await run('open', intent === 'text-editor' ? ['-t', path] : [path], signal)
|
||||
@@ -75,7 +141,7 @@ async function openNativePathWithIntent(
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
if (isWsl(internals)) {
|
||||
if (wsl) {
|
||||
await openWslPath(path, signal, run)
|
||||
return
|
||||
}
|
||||
@@ -87,10 +153,11 @@ async function openNativePathWithIntent(
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a filesystem path with the operating system's default application.
|
||||
* Open a filesystem path with the operating system's default application, or
|
||||
* with the default browser when the path names a document a browser renders.
|
||||
* @param path - absolute or host-resolvable path (caller owns resolution).
|
||||
* @param signal - caller/connection lifetime; abort terminates the native command.
|
||||
* @param internals - platform and runner seam for deterministic tests.
|
||||
* @param internals - platform, environment, and runner seam for deterministic tests.
|
||||
*/
|
||||
export function openNativePath(
|
||||
path: string,
|
||||
|
||||
@@ -167,3 +167,123 @@ describe('native path opener', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('browser-renderable documents', () => {
|
||||
const LS_PLIST = `{
|
||||
LSHandlers = (
|
||||
{
|
||||
LSHandlerPreferredVersions = {
|
||||
LSHandlerRoleAll = "-";
|
||||
};
|
||||
LSHandlerRoleAll = "com.google.chrome";
|
||||
LSHandlerURLScheme = https;
|
||||
}
|
||||
);
|
||||
}`
|
||||
|
||||
it('opens a page with the default browser rather than the .html handler on darwin', async () => {
|
||||
const calls: { command: string; args: readonly string[] }[] = []
|
||||
const run = async (command: string, args: readonly string[]) => {
|
||||
calls.push({ command, args })
|
||||
return { stdout: command === 'defaults' ? LS_PLIST : '', stderr: '' }
|
||||
}
|
||||
await openNativePath('/w/page.html', new AbortController().signal, { platform: 'darwin', run })
|
||||
// A developer who bound .html to an editor still gets a rendered page.
|
||||
expect(calls.map(c => [c.command, ...c.args])).toEqual([
|
||||
['defaults', 'read', 'com.apple.LaunchServices/com.apple.launchservices.secure'],
|
||||
['open', '-b', 'com.google.chrome', '/w/page.html'],
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves every other document to the default application', async () => {
|
||||
const calls: string[][] = []
|
||||
const run = async (command: string, args: readonly string[]) => {
|
||||
calls.push([command, ...args])
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
await openNativePath('/w/report.md', new AbortController().signal, { platform: 'darwin', run })
|
||||
// No LaunchServices read at all: markdown is not a browser document.
|
||||
expect(calls).toEqual([['open', '/w/report.md']])
|
||||
})
|
||||
|
||||
it('falls back to the default application when no browser can be named', async () => {
|
||||
// LaunchServices has no https record (a fresh account), so the system's
|
||||
// own content-type choice is the best answer available.
|
||||
const calls: string[][] = []
|
||||
const run = async (command: string, args: readonly string[]) => {
|
||||
calls.push([command, ...args])
|
||||
if (command === 'defaults') throw new Error('domain not found')
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
await openNativePath('/w/page.html', new AbortController().signal, { platform: 'darwin', run })
|
||||
expect(calls).toEqual([
|
||||
['defaults', 'read', 'com.apple.LaunchServices/com.apple.launchservices.secure'],
|
||||
['open', '/w/page.html'],
|
||||
])
|
||||
|
||||
// A record without an https handler is the same answer.
|
||||
const bare: string[][] = []
|
||||
await openNativePath('/w/page.html', new AbortController().signal, {
|
||||
platform: 'darwin',
|
||||
run: async (command, args) => {
|
||||
bare.push([command, ...args])
|
||||
return { stdout: '{ LSHandlers = ( ); }', stderr: '' }
|
||||
},
|
||||
})
|
||||
expect(bare[1]).toEqual(['open', '/w/page.html'])
|
||||
})
|
||||
|
||||
it('honors $BROWSER on linux and leaves windows to its association', async () => {
|
||||
const linux: string[][] = []
|
||||
await openNativePath('/w/page.html', new AbortController().signal, {
|
||||
platform: 'linux',
|
||||
osRelease: '6.8.0-generic',
|
||||
env: { BROWSER: 'firefox' },
|
||||
run: async (command, args) => { linux.push([command, ...args]); return { stdout: '', stderr: '' } },
|
||||
})
|
||||
expect(linux).toEqual([['firefox', '/w/page.html']])
|
||||
|
||||
// Unset $BROWSER: xdg-open's association is the fallback.
|
||||
const bare: string[][] = []
|
||||
await openNativePath('/w/page.html', new AbortController().signal, {
|
||||
platform: 'linux',
|
||||
osRelease: '6.8.0-generic',
|
||||
env: {},
|
||||
run: async (command, args) => { bare.push([command, ...args]); return { stdout: '', stderr: '' } },
|
||||
})
|
||||
expect(bare).toEqual([['xdg-open', '/w/page.html']])
|
||||
|
||||
// Windows names no browser without the UserChoice registry.
|
||||
const win: string[][] = []
|
||||
await openNativePath('C:\\w\\page.html', new AbortController().signal, {
|
||||
platform: 'win32',
|
||||
run: async (command, args) => { win.push([command, ...args]); return { stdout: '', stderr: '' } },
|
||||
})
|
||||
expect(win[0]?.[0]).toBe('powershell.exe')
|
||||
})
|
||||
|
||||
it('hands browser-renderable WSL paths to the Windows desktop', async () => {
|
||||
const calls: string[][] = []
|
||||
await openNativePath('/home/test/page.html', new AbortController().signal, {
|
||||
platform: 'linux',
|
||||
osRelease: '5.15.153.1-microsoft-standard-WSL2',
|
||||
env: { BROWSER: 'firefox' },
|
||||
run: async (command, args) => {
|
||||
calls.push([command, ...args])
|
||||
return {
|
||||
stdout: command === 'wslpath' ? 'C:\\workspace\\page.html\n' : '',
|
||||
stderr: '',
|
||||
}
|
||||
},
|
||||
})
|
||||
expect(calls).toEqual([
|
||||
['wslpath', '-w', '/home/test/page.html'],
|
||||
[
|
||||
'powershell.exe',
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
"Invoke-Item -LiteralPath 'C:\\workspace\\page.html'",
|
||||
],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user