Merge master into fix/zoom-rerender

This commit is contained in:
yudshj committed 2026-09-23 19:31:59 +08:00
commit 312341970c
134 files changed
+3582 -297

No files matched your search

@@ -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 .agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md
2026-09-19-remote-duplex-stream.md: 0f1f6a575f6871d37822c5829fe4531261b8e95b
2026-09-19-remote-duplex-stream.zh.md: d3982aa433aa3ce84b945bb5831cef8064472853
2026-09-19-remote-duplex-stream.md: ea8e8cebcc8285c97ebdb70f4e37805d3ce8bfe9
2026-09-19-remote-duplex-stream.zh.md: 2e119bf054e20d076e22758178b5e0168092be18
@@ -276,6 +276,12 @@ Parsing rules: `item` has exactly the keys `type` and `streamId` plus an optiona
| `item` received after `end` | Any | The stream fails with a `gateway/protocol` error frame and aborts; the socket stays open |
| `item` / `end` with an unknown `streamId` | Any | Ignored, like `cancel`: after the Host ends a stream and deletes its id, uplink frames still in flight from the Client are normal and must not take down the other streams on the same socket |
### Cancellation wait lifetime
`cancellableStream`, `UplinkDecoder.next()`, and the Client uplink pump each create a cancellation or stop promise per read. `Promise.race` does not remove the losing input's reaction when a read completes: reusing a stream-wide pending promise retains completed races and their item results until that promise settles or becomes unreachable. Read-local promises keep retained cancellation state independent of the number of delivered items.
The Host downlink clears its current reject callback after each read and checks cancellation before reading again. The uplink decoder retains only pending reads: abort rejects them, while normal close resolves all of them with `done`, including overlapping reads. The Client pump drops its stop promise after each read and does not start another read after stopping. Source iterators retain their existing release ownership; closing the Host uplink still precedes returning the method iterator.
### Ordering guarantees
Frames on one socket arrive in total order: a logical stream's `open`, `item`…, `end` reach the Host in send order, and uplink items enter `uplink()` in the order of `send`. There is no cross-direction ordering guarantee between uplink and downlink; a protocol that needs request-response pairing carries its own sequence numbers.
@@ -538,6 +544,10 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
## Alternatives considered
**A shared cancellation controller for all three reading loops.** Each loop already has an owner with distinct close behavior. Read-local promises bound retained state without moving those responsibilities into a new abstraction.
**Requiring source iterators to cancel their own `next()`.** Gateway accepts arbitrary iterables, whose pending reads need not respond to cancellation or `return()`. Gateway must still wake its own waiters independently of source cleanup.
**A separate `mode: 'duplex'` plus a reserved parameter `uplink: AsyncIterable<In>` alongside `signal`.** Special logic everywhere: the analyzer must recognize a second reserved parameter and its position rules, the generated signature gains a parameter, load time must validate `duplex ⟺ uplink`, and because the mux does not know the method mode, the Gateway must reject uplink frames on behalf of `'stream'` methods. Duplex is an inherent property of the transport-layer stream, not a mode of the method: the carrier already has `streamId`, `open`, and `cancel`; the uplink adds just two more frames, which any stream can receive and which are simply not sent when unused.
**Declaring the uplink type in a decorator option, `@Remote({ mode: 'stream', uplink: 'JobInputFrame' })`.** A string reference to a type name is not checked by the compiler and breaks on rename. The return-type alias puts both types in one compiler-visible place.
@@ -572,6 +582,8 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
## Testing
Retention cases in `gateway-stream.host.spec.ts` and `gateway.client.spec.ts` count live iterator results after a full GC while the streams remain open. Lifecycle cases cover normal close waking multiple pending uplink reads, cancellation between reads, synchronous abort followed by a source exception, and termination during a Client send.
- `packages/api/gateway/tests/echo-stream.host.spec.ts`: the same echo method passes through the WebSocket carrier and through the in-process carrier.
- Gateway Host: exact-key validation in frame parsing; `UplinkInbox` buffering during opening, `item` after `end`, overflow, ignored unknown `streamId`, and cleanup on socket closure; `UplinkDecoder` delivery with and without a codec and abort on decode failure; `uplink()` taken only once; the `extend({ invocation })` view and the origin of `peer`; `cancellableStream` closing the uplink before `return()`.
- Gateway Client: the handle's `send` (JSON validation, queued before open, throws after termination), idempotent `end`, `dispose` driving the `cancel` frame and ending later reads; the pump's three exits; the pump stopping as soon as a terminal frame arrives.
@@ -276,6 +276,12 @@ export type RemoteStreamServerMessage =
| `end` 之后又收到 `item` | 任意 | 该流以 `gateway/protocol` 错误帧失败并中止;socket 不关闭 |
| 未知 `streamId` 的 `item` / `end` | 任意 | 忽略,与 `cancel` 相同:Host 结束流并删除 id 后,客户端在途的上行帧属正常现象,不能连累同一 socket 上的其他流 |
### 取消等待的生命周期
`cancellableStream`、`UplinkDecoder.next()` 和 Client 上行泵各自为单次读取创建取消或停止 Promise。读取完成后,`Promise.race` 不会移除另一输入上的 reaction:复用流级未完成 Promise 会保留已完成的竞争及其读取结果,直到该 Promise 完成或不可达。单次读取的 Promise 使取消状态的保留量不随已交付项数增长。
Host 下行在每次读取后清除当前拒绝回调,并在再次读取前检查取消状态。上行解码器只保留尚未完成的读取:取消时拒绝这些读取,正常关闭则以 `done` 唤醒全部读取,包括重叠的读取。Client 泵在每次读取后移除停止 Promise,停止后不再发起读取。源迭代器的释放仍由原有所有者负责;Host 仍先关闭上行,再返回方法迭代器。
### 顺序保证
同一条 socket 上的帧全序到达:一条逻辑流的 `open`、`item`…、`end` 按发送顺序到达 Host;上行项进入 `uplink()` 的顺序就是 `send` 的顺序。上行与下行之间没有跨方向顺序保证;需要请求应答配对的协议自带序号。
@@ -538,6 +544,10 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
## 考虑过的替代方案
**三处读取循环共用一个取消控制器。** 每处循环已有自己的所有者和不同的关闭行为。单次读取的 Promise 能限制保留状态,不需要把这些职责迁入新抽象。
**要求源迭代器自行取消 `next()`。** Gateway 接受任意 iterable,其待处理读取不一定响应取消或 `return()`。Gateway 仍需独立于源清理来唤醒自己的等待者。
**独立的 `mode: 'duplex'` 加一个与 `signal` 同级的保留参数 `uplink: AsyncIterable<In>`。** 每一处都是特殊逻辑:analyzer 要认第二个保留参数及其位置规则,生成签名多一个参数,加载期要校验 `duplex ⟺ uplink`,mux 不知道方法模式所以网关要替 `'stream'` 方法拒绝上行帧。双工是传输层流的固有性质,不是方法的一种模式:载体本来就有 `streamId`、`open`、`cancel`,上行只是再加两种帧,任何流都能收,不用就不发。
**用装饰器选项声明上行类型,`@Remote({ mode: 'stream', uplink: 'JobInputFrame' })`。** 字符串引用类型名不受编译器约束,重命名即断。返回类型别名把两个类型写在同一个编译器可见的位置。
@@ -572,6 +582,8 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
## 测试
`gateway-stream.host.spec.ts` 和 `gateway.client.spec.ts` 中的保留对象用例在流保持打开时进行全量 GC,再统计存活的迭代结果。生命周期用例覆盖正常关闭唤醒多个待处理上行读取、两次读取之间的取消、源同步取消后抛错,以及 Client 发送期间流结束。
- `packages/api/gateway/tests/echo-stream.host.spec.ts`:同一个 echo 方法分别穿过 WebSocket 载体与进程内载体。
- 网关 Host:帧解析的精确键校验;`UplinkInbox` 的 opening 缓冲、`end` 后 `item`、超限、未知 `streamId` 忽略、socket 关闭时的收尾;`UplinkDecoder` 有无 codec 两种交付与解码失败中止;`uplink()` 只能取一次;`extend({ invocation })` 视图与 `peer` 的来源;`cancellableStream` 先关 uplink 再 `return()`。
- 网关 Client:句柄的 `send`(JSON 校验、open 前入队、终止后抛错)、`end` 幂等、`dispose` 驱动 `cancel` 帧并结束后续读取;泵的三种退出;终止帧到达即停泵。
+2 -2
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 apps/cli/README.md
README.md: 4eb113a6155433cb69f949d9e461ccfbd0142e7c
README.zh.md: 6a0e8a38a70d2ad551b4e91619545b3def09f2ec
README.md: 765440438175083d2dbd765592731df5ec148bcf
README.zh.md: 08db105b160199cb9a64276f273b8b00f0d21c09
+2
View File
@@ -36,6 +36,8 @@ dsh --help # the launcher's own help
A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer). `dsh-hmr`, when enabled in YAML, watches the profile manifest and both profile and home patch files, then recomposes all layers through one serialized reload. Without HMR, changes apply on restart. Edits arriving during watcher registration use the same nonfatal reload reporting as later edits. [Plugin Manager](../../packages/boot/plugin-manager/README.md) shares package operations and the profile write lock with `dsh plugin`; package updates retain disabled bundle selections. CLI package commands inherit authentication variables and terminal descriptors, including interactive build approval; service calls retain their scrubbed environment and captured diagnostics.
Installation and profile startup enforce declared DSH peer ranges against the same runtime version shown by `dsh --version`. Incompatible plugins require an explicitly acknowledged exact-version exemption. The [plugin manager's compatibility reference](../../packages/boot/plugin-manager/README.md#version-compatibility-and-exemptions) documents `version-exemptions`, `allow-version`, `revoke-version`, persistence, and risks.
The tree composes over an empty root:
- each bundle's patch in `dsh.profile.bundles` order
- then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`
+2
View File
@@ -36,6 +36,8 @@ dsh --help # the launcher's own help
profile 目录包含一个 `package.json`,其中记录树外插件依赖,以及 profile manifest(元数据清单)`dsh.profile`、其中按顺序排列的 `bundles` 列表;还包含一个 `cordis.patch.yml`,其中保存用户自己的 patch 层。在 YAML 中启用的 `dsh-hmr` 监视 profile manifest、profile 与 home 级 patch 文件,再通过统一串行重载重新组合所有层。未启用 HMR 时,更改在重启后生效。监听器注册期间发生的编辑与后续编辑使用相同的非致命重载错误报告。[插件管理器](../../packages/boot/plugin-manager/README.zh.md) 与 `dsh plugin` 共享包操作和 profile 写锁;更新依赖会保留已停用的组合包选择。CLI 包操作继承认证环境和终端描述符,支持交互式构建批准;service 调用保留清理后的环境并捕获诊断。
安装和 profile 启动会按声明的 DSH peer 范围,检查与 `dsh --version` 显示值相同的运行时版本。不兼容插件需要用户明确确认精确版本豁免。[插件管理器的兼容性参考](../../packages/boot/plugin-manager/README.zh.md#version-compatibility-and-exemptions)说明 `version-exemptions`、`allow-version`、`revoke-version`、持久化规则与风险。
配置树以空根为起点,依次叠加以下配置层:
- `dsh.profile.bundles` 中各组合包的 patch
- profile 自身的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`
+2 -14
View File
@@ -6,29 +6,17 @@
/* v8 ignore file -- built-bin acceptance exercises this self-executing dispatch. */
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { loadLayeredEnv, StartupError } from '@deepseek-ai/dsh-app-boot'
import { getDshRuntimeVersion, loadLayeredEnv, StartupError } from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
import { parseDshArgs } from './args.ts'
import { reportStartupFailure } from './startup-diagnostics.ts'
// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
// one directory under apps/cli, so the checked-in manifest resolves with the
// same relative hop from either artifact.
function readVersion(): string {
const manifest = JSON.parse(
readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'),
) as { version?: unknown }
return typeof manifest.version === 'string' ? manifest.version : '0.0.0'
}
/**
* Run the public dsh command-line interface.
* @returns a promise that settles when the selected command mode finishes.
*/
export async function runCli(): Promise<void> {
const version = readVersion()
const version = getDshRuntimeVersion()
const invocation = parseDshArgs(process.argv.slice(2), version)
switch (invocation.mode) {
+62 -5
View File
@@ -1,19 +1,76 @@
/** dsh plugin forwards pnpm through the shared profile package operations. */
import { runPluginCommand } from '@deepseek-ai/dsh-plugin-manager/operations'
/** Profile package management and explicit, exact-version compatibility approvals. */
import { runPluginCommand, setProfileVersionExemption } from '@deepseek-ai/dsh-plugin-manager/operations'
import { INSTALL_ANCHOR } from './profile-boot.ts'
import { resolveProfileDir } from '@deepseek-ai/dsh-app-boot'
import { DEFAULT_PROFILE_BUNDLES, initProfile, PROFILE_TEMPLATES, readProfileCompatibility, resolveProfileDir } from '@deepseek-ai/dsh-app-boot'
import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
import { existsSync } from 'node:fs'
import { mkdir } from 'node:fs/promises'
import { join } from 'node:path'
/** Parse only DSH-owned commands; all other arguments remain pnpm's responsibility. */
async function versionCommand(profile: string, args: readonly string[]): Promise<number | undefined> {
const [command, ...rest] = args
if (command !== 'allow-version' && command !== 'revoke-version' && command !== 'version-exemptions') return undefined
try {
let packageVersion: string | undefined
let runtimeVersion: string | undefined
let acceptRisk = false
const argumentsIterator = rest.values()
for (const argument of argumentsIterator) {
if (argument === '--accept-risk' && command === 'allow-version' && !acceptRisk) acceptRisk = true
else if (argument === '--dsh-version' && runtimeVersion === undefined) runtimeVersion = argumentsIterator.next().value
else if (argument.startsWith('--dsh-version=') && runtimeVersion === undefined) runtimeVersion = argument.slice('--dsh-version='.length)
else if (!argument.startsWith('-') && packageVersion === undefined) packageVersion = argument
else throw new Error(`unexpected argument ${JSON.stringify(argument)}`)
}
if (command === 'version-exemptions' && rest.length > 0) throw new Error('usage: dsh plugin version-exemptions')
let request: { packageVersion: string; runtimeVersion: string } | undefined
if (command !== 'version-exemptions') {
if (packageVersion === undefined || runtimeVersion === undefined) {
throw new Error(`usage: dsh plugin ${command} <package@version> --dsh-version <exact>${command === 'allow-version' ? ' --accept-risk' : ''}`)
}
request = { packageVersion, runtimeVersion }
}
if (command === 'allow-version') {
process.stderr.write('dsh: warning: allowing incompatible plugin versions can break the application or corrupt data. Approval applies only to the exact package and DSH versions.\n')
}
const dir = resolveProfileDir(profile)
await mkdir(dir, { recursive: true })
await withFileLock(join(dir, 'package.json'), async () => {
if (!existsSync(join(dir, 'package.json'))) initProfile(dir, PROFILE_TEMPLATES[profile]?.bundles ?? DEFAULT_PROFILE_BUNDLES)
if (request === undefined) {
const { exemptions, warnings } = readProfileCompatibility(dir)
for (const warning of warnings) process.stderr.write(`dsh: warning: ${warning}\n`)
process.stdout.write(JSON.stringify(exemptions, undefined, 2) + '\n')
} else {
await setProfileVersionExemption(dir, request.packageVersion, request.runtimeVersion, command === 'allow-version', acceptRisk)
process.stdout.write(`dsh: ${command === 'allow-version' ? 'allowed' : 'revoked'} ${request.packageVersion} for DSH ${request.runtimeVersion}\n`)
}
}, { waitMs: 120000 })
return 0
} catch (error) {
process.stderr.write(`dsh: ${String(error)}\n`)
return 1
}
}
/** Run package management for a profile.
* @param profile Profile name.
* @param args Pnpm arguments relative to the invoking directory.
* @returns Pnpm exit code.
* @param args DSH exemption command or pnpm arguments relative to the invoking directory.
* @returns Zero on success; nonzero on invalid approval or package-manager failure.
*/
export async function runPlugin(profile: string, args: readonly string[]): Promise<number> {
const versionResult = await versionCommand(profile, args)
if (versionResult !== undefined) return versionResult
const dir = resolveProfileDir(profile)
if (existsSync(join(dir, 'package.json'))) {
for (const warning of readProfileCompatibility(dir).warnings) process.stderr.write(`dsh: warning: ${warning}\n`)
}
const result = await runPluginCommand({ profile, installAnchor: INSTALL_ANCHOR, cwd: process.cwd() }, args, {
execution: 'cli',
outputBytes: 16384,
lockWaitMs: 120000,
lookupTimeoutMs: 120000,
onOutput: (text, stream) => { process[stream].write(text) },
})
if (result.exitCode === 127) process.stderr.write('dsh: pnpm was not found; install pnpm and make it available on PATH.\n')
@@ -0,0 +1,69 @@
/** Published CLI acceptance for actual pnpm installation, restart checks, and explicit exemptions. */
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execa } from 'execa'
import { expect, it, onTestFinished } from 'vitest'
const bin = fileURLToPath(new URL('../lib/bin.js', import.meta.url))
const version = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version
it('refuses incompatible installation and startup until an exact risk exemption is granted', async () => {
const home = mkdtempSync(join(tmpdir(), 'dsh-compatibility-cli-'))
onTestFinished(() => { rmSync(home, { recursive: true, force: true }) })
const profile = join(home, 'profiles', 'compatibility')
const source = join(home, 'fixture-plugin')
mkdirSync(profile, { recursive: true })
mkdirSync(source)
const profileFile = join(profile, 'package.json')
const initial = { name: 'compatibility-profile', private: true, dependencies: {}, dsh: { profile: { bundles: [] } } }
writeFileSync(profileFile, JSON.stringify(initial))
writeFileSync(join(profile, 'pnpm-workspace.yaml'), 'packages:\n - .\nnodeLinker: hoisted\nautoInstallPeers: false\n')
writeFileSync(join(source, 'package.json'), JSON.stringify({
name: 'compatibility-fixture', version: '1.0.0', type: 'module',
peerDependencies: { '@deepseek-ai/dsh': '999.0.0' }, dsh: { bundle: { patch: './cordis.patch.yml' } },
}))
writeFileSync(join(source, 'cordis.patch.yml'), '- insert:\n - id: compatibility-fixture\n name: ./index.mjs\n')
writeFileSync(join(source, 'index.mjs'), 'process.stdout.write("PLUGIN_IMPORTED\\n"); export function apply() { process.stdout.write("PLUGIN_STARTED\\n") }\n')
const run = async (args: string[]) => {
const result = await execa(process.execPath, [bin, ...args], {
cwd: home, env: { ...process.env, DSH_HOME: home }, extendEnv: false, reject: false, timeout: 60_000,
})
expect(result.timedOut).toBe(false)
expect(result.signal).toBeUndefined()
return result
}
const plugin = (args: string[]) => run(['plugin', '--profile', 'compatibility', ...args])
const refused = await plugin(['add', source, '--offline', '--ignore-scripts'])
expect(refused.exitCode).toBe(1)
expect(refused.stderr).toContain('crashes or data loss')
expect(JSON.parse(readFileSync(profileFile, 'utf8'))).toEqual(initial)
expect(existsSync(join(profile, 'compatibility.json'))).toBe(false)
// The named package is checked before installation, so a refused one never lands on disk.
expect(refused.stderr).toContain('nothing was installed')
expect(existsSync(join(profile, 'node_modules', 'compatibility-fixture'))).toBe(false)
// A package installed outside DSH still goes through the independent startup check.
cpSync(source, join(profile, 'node_modules', 'compatibility-fixture'), { recursive: true })
writeFileSync(profileFile, JSON.stringify({ ...initial,
dependencies: { 'compatibility-fixture': `file:${source}` }, dsh: { profile: { bundles: ['compatibility-fixture'] } },
}))
const deniedStartup = await run(['--profile', 'compatibility'])
expect(deniedStartup.stdout).not.toContain('PLUGIN_IMPORTED')
expect(deniedStartup.stderr).toContain('incompatible')
const missingConsent = await plugin(['allow-version', '[email protected]', '--dsh-version', version])
expect(missingConsent.exitCode).toBe(1)
const granted = await plugin(['allow-version', '[email protected]', '--dsh-version', version, '--accept-risk'])
expect(granted.exitCode).toBe(0)
expect(granted.stderr).toContain('break the application or corrupt data')
// The exemption is profile metadata of its own; the dependency manifest and bundle list stay untouched.
expect(JSON.parse(readFileSync(join(profile, 'compatibility.json'), 'utf8'))).toEqual({ '[email protected]': [version] })
expect(JSON.parse(readFileSync(profileFile, 'utf8'))).not.toHaveProperty('dsh.profile.versionExemptions')
expect((await plugin(['add', source, '--offline', '--ignore-scripts'])).exitCode).toBe(0)
const admittedStartup = await run(['--profile', 'compatibility'])
expect(admittedStartup.exitCode).toBe(0)
expect(admittedStartup.stdout).toContain('PLUGIN_STARTED')
expect((await plugin(['revoke-version', '[email protected]', '--dsh-version', version])).exitCode).toBe(0)
expect((await run(['--profile', 'compatibility'])).stdout).not.toContain('PLUGIN_IMPORTED')
})
+91
View File
@@ -0,0 +1,91 @@
/** Explicit CLI exemptions use the profile lock and never become pnpm arguments. */
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it, onTestFinished, vi } from 'vitest'
import { getDshRuntimeVersion, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot'
import { runPluginCommand } from '@deepseek-ai/dsh-plugin-manager/operations'
import { runPlugin } from '../src/plugin.ts'
vi.mock('../src/profile-boot.ts', () => ({ INSTALL_ANCHOR: '/installation/package.json' }))
vi.mock('@deepseek-ai/dsh-plugin-manager/operations', async importOriginal => ({
...await importOriginal<typeof import('@deepseek-ai/dsh-plugin-manager/operations')>(),
runPluginCommand: vi.fn(),
}))
function fixture() {
const home = mkdtempSync(join(tmpdir(), 'cli-version-exemptions-'))
vi.stubEnv('DSH_HOME', home)
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
onTestFinished(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
vi.mocked(runPluginCommand).mockReset()
rmSync(home, { recursive: true, force: true })
})
return { dir: join(home, 'profiles', 'test'), stdout, stderr }
}
it('requires explicit acknowledgement, grants only the exact pair, lists and revokes it without pnpm', async () => {
const { dir, stdout, stderr } = fixture()
const runtime = getDshRuntimeVersion()
expect(await runPlugin('test', ['allow-version', '@example/[email protected]', '--dsh-version', runtime, '--accept-risk'])).toBe(0)
expect(stderr.mock.calls.map(call => call[0]).join('')).toMatchInlineSnapshot(`
"dsh: warning: allowing incompatible plugin versions can break the application or corrupt data. Approval applies only to the exact package and DSH versions.
"
`)
expect(stderr.mock.invocationCallOrder[0]).toBeLessThan(stdout.mock.invocationCallOrder[0]!)
expect(readProfileVersionExemptions(dir)).toEqual({ '@example/[email protected]': [runtime] })
expect(JSON.parse(readFileSync(join(dir, 'compatibility.json'), 'utf8'))).toEqual({ '@example/[email protected]': [runtime] })
expect(await runPlugin('test', ['version-exemptions'])).toBe(0)
expect(stdout).toHaveBeenLastCalledWith(JSON.stringify({ '@example/[email protected]': [runtime] }, undefined, 2) + '\n')
expect(await runPlugin('test', ['revoke-version', '@example/[email protected]', `--dsh-version=${runtime}`])).toBe(0)
expect(readProfileVersionExemptions(dir)).toEqual({})
expect(runPluginCommand).not.toHaveBeenCalled()
})
it.each([
['allow-version', '[email protected]', '--dsh-version', 'CURRENT'],
['allow-version', '[email protected]', '--accept-risk'],
['allow-version', 'plugin@^1.2.3', '--dsh-version', 'CURRENT', '--accept-risk'],
['allow-version', '[email protected]', '--dsh-version', '999.0.0', '--accept-risk'],
['allow-version', '[email protected]', '--dsh-version', 'CURRENT', '--accept-risk=false'],
['allow-version', '[email protected]', '--dsh-version', 'CURRENT', '--accept-risk', '--accept-risk'],
['revoke-version', '[email protected]'],
['version-exemptions', 'extra'],
])('rejects malformed or unacknowledged exemption command %j', async (...arguments_) => {
const { dir, stderr } = fixture()
const args = arguments_.map(value => value === 'CURRENT' ? getDshRuntimeVersion() : value)
expect(await runPlugin('test', args)).toBe(1)
expect(stderr).toHaveBeenCalled()
expect(existsSync(join(dir, 'compatibility.json'))).toBe(false)
expect(runPluginCommand).not.toHaveBeenCalled()
})
it('uses shipped profile defaults when listing a missing profile', async () => {
fixture()
expect(await runPlugin('web', ['version-exemptions'])).toBe(0)
expect(runPluginCommand).not.toHaveBeenCalled()
})
it.each([0, 1])('forwards ordinary pnpm output and exit status %s', async (exitCode) => {
const { stdout, stderr } = fixture()
vi.mocked(runPluginCommand).mockImplementation(async (_context, _args, options) => {
options.onOutput?.('pnpm output', 'stdout')
return { exitCode, output: '', truncated: false, logPath: '/profile/log' }
})
expect(await runPlugin('test', ['list'])).toBe(exitCode)
expect(stdout).toHaveBeenCalledWith('pnpm output')
if (exitCode === 0) expect(stderr).not.toHaveBeenCalled()
else expect(stderr).toHaveBeenCalledWith('dsh: pnpm failed; diagnostics: /profile/log\n')
})
it('forwards all other commands unchanged and retains package diagnostics', async () => {
const { stderr } = fixture()
vi.mocked(runPluginCommand).mockResolvedValue({ exitCode: 127, output: '', truncated: false, logPath: '/profile/log' })
const args = ['add', 'github:example/plugin']
expect(await runPlugin('test', args)).toBe(127)
expect(runPluginCommand).toHaveBeenCalledWith(expect.objectContaining({ profile: 'test' }), args, expect.objectContaining({ execution: 'cli' }))
expect(stderr.mock.calls.map(call => call[0]).join('')).toContain('pnpm was not found')
})
+2 -2
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 apps/desktop/README.md
README.md: 87b16ac8fd7ec269e8c02a34a32e6593b38aec0a
README.zh.md: 895fdd972148c90bad00916a35b21cebff8842e8
README.md: ec3f2f44d5e49db00bd595588a0594bcee0a1538
README.zh.md: 50fba340a0edc114a75d4b6363b8e6c42be0c45f
+2 -1
View File
@@ -399,7 +399,7 @@ The downloaded bytes are inert, and the installation call is recorded rather tha
## Low-level development overrides
An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_PNPM_ENTRY` and `DSH_DESKTOP_DSH_DIR` select explicit runtime resources. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile.
An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_PNPM_ENTRY` and `DSH_DESKTOP_DSH_DIR` are optional overrides with application-path defaults. `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` is required for every unpackaged launch: the development launchers (`dev:desktop`, `start:desktop`, and the workspace-update qualification runner) set it to the primary-runtime directory of the target they prepared, and a launch without it fails with the fatal startup dialog. The launcher must set it because the shell cannot derive that directory from `process.arch`: the build target fixes Windows to x64 while the host may be arm64. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile.
## Known limitations
@@ -408,6 +408,7 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i
- Release signing, notarization, update hosting, and previous-version installed-artifact qualification require the production release environment.
- Dependency lifecycle scripts follow pnpm’s build permissions; Desktop provides no separate approval dialog.
- The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, and lockfiles remain separate.
- Unpackaged startup on an Electron win32-arm64 host now succeeds, but the payload remains x64: the architecture check in `packages/skill/tool-workspace-dependencies/src/index.ts` compares the recorded payload architecture against the host `process.arch`, so the `load_workspace_dependencies` tool can still reject the primary runtime.
Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Opened and copied authorization links carry the effective Desktop theme as `theme=light` or `theme=dark`; `system` resolves at the time of the action. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available.
+2 -1
View File
@@ -399,7 +399,7 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd
## 底层开发覆盖项
未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_PNPM_ENTRY` 和 `DSH_DESKTOP_DSH_DIR` 用于选择明确的运行时资源。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。
未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_PNPM_ENTRY` 和 `DSH_DESKTOP_DSH_DIR` 是带应用路径默认值的可选覆盖项。每次未打包启动都必须设置 `DSH_DESKTOP_PRIMARY_RUNTIME_DIR`:开发启动器(`dev:desktop`、`start:desktop` 及工作区更新验证运行器)会把它设置为自己已准备目标的 primary-runtime 目录;缺少该变量的启动会以致命启动对话框失败。启动器必须设置它,因为壳无法从 `process.arch` 推导该目录:构建目标将 Windows 固定为 x64,而宿主可能是 arm64。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。
## 已知限制
@@ -408,6 +408,7 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd
- 发布签名、公证、更新托管和跨上一版本的已安装产物验证需要生产发布环境。
- 依赖的生命周期脚本遵循 pnpm 的构建权限;Desktop 不提供单独的审批对话框。
- 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活和锁文件彼此隔离。
- 在 Electron win32-arm64 宿主上,未打包启动现在可以成功,但载荷仍为 x64:`packages/skill/tool-workspace-dependencies/src/index.ts` 的架构校验会把载荷记录的架构与宿主 `process.arch` 比较,因此 `load_workspace_dependencies` 工具仍可能拒绝 primary runtime。
登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。打开和复制的授权链接通过 `theme=light` 或 `theme=dark` 携带当前生效的 Desktop 主题;`system` 在执行操作时解析。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。
@@ -36,6 +36,17 @@ export function resolveDesktopBuildTarget(
*/
export function desktopTargetBuildPaths(target: DesktopAutoUpdateTarget): DesktopTargetBuildPaths
/**
* Return the platform and architecture of the payload one release target prepares.
* Windows is prepared as x64 only, so this differs from the build host on an arm64 Windows machine.
* @param target - Supported Desktop target name.
* @returns Platform and architecture of the prepared payload.
*/
export function desktopTargetPlatform(target: DesktopAutoUpdateTarget): {
readonly platform: 'darwin' | 'win32'
readonly arch: 'arm64' | 'x64'
}
/**
* Resolve the paths owned by the target selected in a packaging environment.
* @param env - Packaging environment.
@@ -48,3 +59,16 @@ export function resolveDesktopTargetBuildPaths(
hostPlatform?: NodeJS.Platform,
hostArch?: string,
): DesktopTargetBuildPaths
/**
* Resolve the primary-runtime directory an unpackaged development launch uses.
* @param env - Packaging environment.
* @param hostPlatform - Build-host platform used when no target override exists.
* @param hostArch - Build-host architecture used when no target override exists.
* @returns Primary-runtime directory prepared for the selected target.
*/
export function developmentRuntimeDirectory(
env?: NodeJS.ProcessEnv,
hostPlatform?: NodeJS.Platform,
hostArch?: string,
): string
+38 -3
View File
@@ -29,15 +29,19 @@ export function resolveDesktopBuildTarget(
return /** @type {'mac-arm64' | 'mac-x64' | 'win-x64'} */ (target)
}
function assertSupportedTarget(target) {
if (!SUPPORTED_TARGETS.has(target)) {
throw new Error(`desktop build paths: unsupported target ${String(target)}`)
}
}
/**
* Return the mutable preparation and artifact directories owned by one release target.
* @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name.
* @returns {{ root: string, artifacts: string, unsignedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache.
*/
export function desktopTargetBuildPaths(target) {
if (!SUPPORTED_TARGETS.has(target)) {
throw new Error(`desktop build paths: unsupported target ${String(target)}`)
}
assertSupportedTarget(target)
const root = join(BUILD_ROOT, 'targets', target)
const packed = join(root, 'packed')
return {
@@ -56,6 +60,20 @@ export function desktopTargetBuildPaths(target) {
}
}
/**
* Return the platform and architecture of the payload one release target prepares.
* Windows is prepared as x64 only, so this differs from the build host on an arm64 Windows machine.
* @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name.
* @returns {{ platform: 'darwin' | 'win32', arch: 'arm64' | 'x64' }} Platform and architecture of the prepared payload.
*/
export function desktopTargetPlatform(target) {
assertSupportedTarget(target)
return {
platform: /** @type {'darwin' | 'win32'} */ (target === 'win-x64' ? 'win32' : 'darwin'),
arch: /** @type {'arm64' | 'x64'} */ (target === 'mac-arm64' ? 'arm64' : 'x64'),
}
}
/**
* Resolve the paths owned by the target selected in a packaging environment.
* @param {NodeJS.ProcessEnv} env - Packaging environment.
@@ -70,3 +88,20 @@ export function resolveDesktopTargetBuildPaths(
) {
return desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch))
}
/**
* Resolve the primary-runtime directory an unpackaged development launch uses.
* The build target fixes Windows to x64, so the shell cannot derive this directory from
* the architecture of the process that launched it.
* @param {NodeJS.ProcessEnv} env - Packaging environment.
* @param {NodeJS.Platform} hostPlatform - Build-host platform used when no target override exists.
* @param {string} hostArch - Build-host architecture used when no target override exists.
* @returns {string} Primary-runtime directory prepared for the selected target.
*/
export function developmentRuntimeDirectory(
env = process.env,
hostPlatform = process.platform,
hostArch = process.arch,
) {
return join(resolveDesktopTargetBuildPaths(env, hostPlatform, hostArch).runtime, 'primary-runtime')
}
+3
View File
@@ -7,6 +7,7 @@ import { join, resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
import type { DesktopRelease } from '../src/release.ts'
import { developmentRuntimeDirectory, resolveDesktopBuildTarget } from './desktop-build-paths.mjs'
import { prepareDevelopmentProject } from './development-project.ts'
import { prepareDevelopmentApp } from './development-app.ts'
import { preparePrimaryRuntime } from './prepare-primary-runtime.ts'
@@ -67,6 +68,7 @@ async function launchElectron(): Promise<void> {
const environment: NodeJS.ProcessEnv = {
...process.env,
DSH_HOME: home,
DSH_DESKTOP_PRIMARY_RUNTIME_DIR: process.env.DSH_DESKTOP_PRIMARY_RUNTIME_DIR ?? developmentRuntimeDirectory(),
DSH_DESKTOP_HOST_INSPECT_PORT: String(hostPort),
DSH_DESKTOP_OPEN_DEVTOOLS: process.env.DSH_DESKTOP_OPEN_DEVTOOLS ?? '1',
ELECTRON_ENABLE_LOGGING: process.env.ELECTRON_ENABLE_LOGGING ?? '1',
@@ -115,6 +117,7 @@ async function main(): Promise<void> {
hostDir: join(REPOSITORY_ROOT, 'apps', 'desktop-host'),
dependencyDir: join(REPOSITORY_ROOT, 'node_modules', '.pnpm', 'node_modules'),
release,
target: resolveDesktopBuildTarget(),
})
await preparePrimaryRuntime()
await launchElectron()
+6 -2
View File
@@ -16,6 +16,8 @@ import { dirname, join } from 'node:path'
import { createDevelopmentProjectMetadata } from '../src/project-manager.ts'
import type { DesktopRelease } from '../src/release.ts'
import { DESKTOP_RUNTIME_FILE, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
import type { DesktopAutoUpdateTarget } from './desktop-auto-update-environment.mjs'
import { desktopTargetPlatform } from './desktop-build-paths.mjs'
interface PackageManifest {
readonly name?: string
@@ -35,6 +37,8 @@ export interface DevelopmentProjectOptions {
readonly dependencyDir: string
/** Release identity written into the disposable project metadata. */
readonly release: DesktopRelease
/** Build target whose prepared payload the disposable project runs against. */
readonly target: DesktopAutoUpdateTarget
}
function readManifest(path: string): PackageManifest {
@@ -115,7 +119,7 @@ function mirrorWorkspaceDependencies(roots: readonly string[], destinationRoot:
/**
* Replace one disposable project with links to the current built workspace.
* @param options - Project destination, CLI package, and release identity.
* @param options - Project destination, CLI package, release identity, and build target.
* @returns the absolute project directory supplied by the caller.
*/
export function prepareDevelopmentProject(options: DevelopmentProjectOptions): string {
@@ -159,7 +163,7 @@ export function prepareDevelopmentProject(options: DevelopmentProjectOptions): s
return typeof manifest.version === 'string' ? [{ name, version: manifest.version, path: `node_modules/${name}` }] : []
})
const runtime: DesktopRuntimeDescriptor = { schemaVersion: 1, release: options.release,
platform: process.platform, arch: process.arch, sharedPackages, files: [] }
...desktopTargetPlatform(options.target), sharedPackages, files: [] }
writeFileSync(join(options.projectDir, DESKTOP_RUNTIME_FILE), `${JSON.stringify(runtime, undefined, 2)}\n`)
return options.projectDir
}
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'
import { createDevelopmentProjectMetadata, createPluginProfile } from '../src/project-manager.ts'
import { removeOwnedDirectory } from '../src/owned-directory.ts'
import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
import { desktopTargetPlatform, developmentRuntimeDirectory, resolveDesktopBuildTarget } from './desktop-build-paths.mjs'
const repo = resolve(import.meta.dirname, '../../..')
const interactive = process.argv.includes('--interactive')
@@ -27,10 +28,11 @@ try {
nodeVersion: execFileSync(electron, ['-p', 'process.versions.node'],
{ encoding: 'utf8', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, windowsHide: true }).trim(),
pnpmVersion: pnpm.version, hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION }
const target = resolveDesktopBuildTarget()
createDevelopmentProjectMetadata(project, release)
createPluginProfile(profile)
await writeFile(join(project, 'desktop-runtime.json'), JSON.stringify({
schemaVersion: 1, release, platform: process.platform, arch: process.arch, files: [],
schemaVersion: 1, release, ...desktopTargetPlatform(target), files: [],
sharedPackages: ['@deepseek-ai/dsh', '@deepseek-ai/dsh-desktop-host']
.map(name => ({ name, version: manifest.version, path: `node_modules/${name}` })),
}))
@@ -43,7 +45,6 @@ try {
for (const owner of [application, project]) {
await symlink(join(repo, 'node_modules/.pnpm/node_modules'), join(owner, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir')
}
const target = `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}`
const targetRoot = join(application, '.desktop-build/targets', target)
await mkdir(targetRoot, { recursive: true })
await symlink(join(repo, 'apps/desktop/.desktop-build/targets', target, 'runtime'), join(targetRoot, 'runtime'),
@@ -61,7 +62,7 @@ try {
...(interactive ? ['--interactive'] : [])], {
cwd: root, env: { ...environment, DSH_HOME: join(root, 'home'), USERPROFILE: root, HOME: root,
TEMP: root, TMP: root, TMPDIR: root, DSH_WORKSPACE_UPDATE_ROOT: root, DSH_WORKSPACE_UPDATE_TOKEN: randomUUID(),
DSH_DESKTOP_OPEN_DEVTOOLS: '0' },
DSH_DESKTOP_PRIMARY_RUNTIME_DIR: developmentRuntimeDirectory(), DSH_DESKTOP_OPEN_DEVTOOLS: '0' },
// Hiding the GUI process suppresses its first window and can suspend renderer frame callbacks.
stdio: 'inherit', windowsHide: false,
})
+12 -2
View File
@@ -139,6 +139,14 @@ function runtimeResources(): RuntimeResources {
return { node, nodeBin, pnpm, dsh }
}
function developmentPrimaryRuntime(): string {
const directory = process.env.DSH_DESKTOP_PRIMARY_RUNTIME_DIR
if (directory === undefined || directory === '') {
throw new Error('dsh desktop: DSH_DESKTOP_PRIMARY_RUNTIME_DIR is required for an unpackaged launch')
}
return directory
}
function developmentHostInspectPort(enabled: boolean): number | undefined {
const configured = process.env.DSH_DESKTOP_HOST_INSPECT_PORT
if (!enabled || configured === undefined || configured === '') return undefined
@@ -289,6 +297,9 @@ async function main(): Promise<void> {
const resources = runtimeResources()
const paths = resolveDesktopPaths()
const development = !app.isPackaged
const primaryRuntime = development
? developmentPrimaryRuntime()
: join(process.resourcesPath, 'runtime', 'primary-runtime')
const activeProject = paths.profile
const manager = new DesktopProjectManager(paths, resources)
let quitting = false
@@ -370,8 +381,7 @@ async function main(): Promise<void> {
const hostInspectPort = developmentHostInspectPort(development)
const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
hostInspectPort, process.env, onFailure,
development ? join(app.getAppPath(), '.desktop-build', 'targets', `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}`, 'runtime', 'primary-runtime')
: join(process.resourcesPath, 'runtime', 'primary-runtime'),
primaryRuntime,
resources, (next) => { platformView.setSession(next) })
return {
start: async () => {
@@ -2,6 +2,8 @@ import { join, sep } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
desktopTargetBuildPaths,
desktopTargetPlatform,
developmentRuntimeDirectory,
resolveDesktopBuildTarget,
} from '../scripts/desktop-build-paths.mjs'
@@ -39,6 +41,22 @@ describe('desktop build paths', () => {
expect(arm64.downloads).not.toContain(`${sep}targets${sep}`)
})
it('resolves the development primary runtime from the build target rather than the host architecture', () => {
expect(developmentRuntimeDirectory({}, 'darwin', 'arm64'))
.toContain(join('targets', 'mac-arm64', 'runtime', 'primary-runtime'))
expect(developmentRuntimeDirectory({}, 'darwin', 'x64'))
.toContain(join('targets', 'mac-x64', 'runtime', 'primary-runtime'))
expect(developmentRuntimeDirectory({}, 'win32', 'arm64'))
.toContain(join('targets', 'win-x64', 'runtime', 'primary-runtime'))
})
it('maps every target to the platform and architecture of the payload it prepares', () => {
expect(desktopTargetPlatform('mac-arm64')).toEqual({ platform: 'darwin', arch: 'arm64' })
expect(desktopTargetPlatform('mac-x64')).toEqual({ platform: 'darwin', arch: 'x64' })
expect(desktopTargetPlatform('win-x64')).toEqual({ platform: 'win32', arch: 'x64' })
expect(() => desktopTargetPlatform('linux-x64' as 'mac-x64')).toThrow(/unsupported target/u)
})
it('resolves environment overrides and rejects unsupported targets', () => {
expect(resolveDesktopBuildTarget({
DSH_DESKTOP_TARGET_PLATFORM: 'darwin',
@@ -46,9 +46,10 @@ describe('desktop development project', () => {
writeFileSync(join(host, 'lib/index.js'), '')
writeFileSync(join(dependency, 'package.json'), JSON.stringify({ name: 'unhoisted', version: '1.2.3' }))
symlinkSync(dependency, join(cli, 'node_modules/unhoisted'), process.platform === 'win32' ? 'junction' : 'dir')
const project = prepareDevelopmentProject({ projectDir: join(root, 'runtime'), cliDir: cli, hostDir: host, dependencyDir: hoisted, release: release() })
const project = prepareDevelopmentProject({ projectDir: join(root, 'runtime'), cliDir: cli, hostDir: host, dependencyDir: hoisted, release: release(), target: 'mac-arm64' })
expect(realpathSync(join(project, 'node_modules/unhoisted'))).toBe(realpathSync(dependency))
const descriptor = JSON.parse(readFileSync(join(project, 'desktop-runtime.json'), 'utf8')) as { sharedPackages: unknown[] }
const descriptor = JSON.parse(readFileSync(join(project, 'desktop-runtime.json'), 'utf8')) as { platform: string; arch: string; sharedPackages: unknown[] }
expect(descriptor).toMatchObject({ platform: 'darwin', arch: 'arm64' })
expect(descriptor.sharedPackages).toContainEqual({ name: 'unhoisted', version: '1.2.3', path: 'node_modules/unhoisted' })
})
@@ -76,6 +77,7 @@ describe('desktop development project', () => {
hostDir: host,
dependencyDir: dependencies,
release: release(),
target: 'win-x64',
})
expect(realpathSync(join(project, 'node_modules', '@deepseek-ai', 'dsh'))).toBe(realpathSync(cli))
expect(realpathSync(join(project, 'node_modules', '@deepseek-ai', 'dsh-desktop-host'))).toBe(realpathSync(host))
@@ -88,6 +90,8 @@ describe('desktop development project', () => {
}
expect(manifest.dependencies['@deepseek-ai/dsh']).toBe('1.2.3')
expect(manifest.dependencies['@deepseek-ai/dsh-desktop-host']).toBe('1.2.3')
const descriptor = JSON.parse(readFileSync(join(project, 'desktop-runtime.json'), 'utf8')) as { platform: string; arch: string }
expect(descriptor).toMatchObject({ platform: 'win32', arch: 'x64' })
const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, 'home')), {
dsh: project,
})
@@ -115,6 +119,7 @@ describe('desktop development project', () => {
hostDir: host,
dependencyDir: dependencies,
release: release(),
target: 'mac-x64',
})).toThrow(/must be @deepseek-ai\/dsh@1\.2\.3/u)
})
})
+13 -1
View File
@@ -333,6 +333,7 @@ beforeEach(() => {
vi.spyOn(console, 'info').mockImplementation(() => {})
vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm')
vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime')
vi.stubEnv('DSH_DESKTOP_PRIMARY_RUNTIME_DIR', 'test-primary-runtime')
vi.stubGlobal('process', { ...process, platform: 'win32', arch: 'x64', resourcesPath: 'desktop-test-resources' })
vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined)
vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', undefined)
@@ -1621,13 +1622,24 @@ describe('desktop main startup', () => {
harness.prepared.resolve()
await harness.hostStarted.promise
const project = join(harness.app.getAppPath(), '.desktop-build', 'development', 'project')
expect(harness.hosts[0]).toMatchObject({ node: process.execPath, runtime: project, profile: 'desktop-test-profile' })
expect(harness.hosts[0]).toMatchObject({ node: process.execPath, runtime: project,
primaryRuntime: 'test-primary-runtime', profile: 'desktop-test-profile' })
expect(harness.applyRelease).toHaveBeenCalledOnce()
harness.hosts[0]!.ready.resolve()
await harness.navigated.promise
expect(harness.dialog.showErrorBox).not.toHaveBeenCalled()
})
it('fails an unpackaged launch that receives no primary runtime directory', async () => {
harness.app.isPackaged = false
vi.stubEnv('DSH_DESKTOP_PRIMARY_RUNTIME_DIR', undefined)
await import('../src/main.ts')
await harness.dialogShown.promise
const options = harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions
expect(options.detail).toContain('DSH_DESKTOP_PRIMARY_RUNTIME_DIR is required for an unpackaged launch')
expect(harness.hosts).toHaveLength(0)
})
it('keeps startup errors in the existing window without a retry handler', async () => {
await import('../src/main.ts')
await harness.preparing.promise
+3
View File
@@ -98,6 +98,9 @@ describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => {
nodeVersion: process.versions.node,
hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
},
// This suite assembles a synthetic project on hosts that prepare no Desktop target, and
// nothing it exercises compares the descriptor's platform/arch outside packaging.
target: 'mac-x64',
})
cpSync(join(repository, 'packages/skill/skill-office/assets'), join(root, 'runtime/office-skills'), { recursive: true })
const nodeBin = join(root, 'runtime/primary-runtime/dependencies/node/bin')
@@ -148,6 +148,7 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho
vi.stubEnv('DSH_DESKTOP_NODE_BINARY', '/runtime/node')
vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', '/runtime/pnpm')
vi.stubEnv('DSH_DESKTOP_DSH_DIR', '/runtime/dsh')
vi.stubEnv('DSH_DESKTOP_PRIMARY_RUNTIME_DIR', '/runtime/primary-runtime')
vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined)
vi.stubEnv('DSH_DESKTOP_OPEN_DEVTOOLS', '0')
vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined)
+46
View File
@@ -250,6 +250,16 @@ it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pan
expect(await selection.innerText()).toBe('A1')
await expect.poll(() => excel.locator('.fortune-sheettab-scroll').count()).toBe(width === 360 ? 2 : 0)
}
const gridOffset = await excel.locator('.luckysheet-scrollbar-x').evaluate(node => node.scrollLeft)
await tabScroller.hover()
await page.mouse.wheel(180, 0)
await expect.poll(() => tabScroller.evaluate(node => node.scrollLeft)).toBeGreaterThan(0)
await page.mouse.wheel(-1000, 0)
await expect.poll(() => tabScroller.evaluate(node => node.scrollLeft)).toBe(0)
expect(await activeSheet.innerText()).toBe('会议信息')
expect(await selection.innerText()).toBe('A1')
expect(await zoom.innerText()).toBe('100%')
expect(await excel.locator('.luckysheet-scrollbar-x').evaluate(node => node.scrollLeft)).toBe(gridOffset)
for (const direction of ['right', 'left'] as const) {
const arrow = excel.locator(`#fortune-sheettab-${direction}scroll`)
for (let step = 0; step < 8; step += 1) {
@@ -289,6 +299,9 @@ it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pan
await openPreviewFile(column, filesTab, preview, name)
await excel.locator('.fortune-sheet-overlay').waitFor()
await expectExcelLayout(excel)
expect(await selection.evaluate(node => ({
text: getComputedStyle(node).color, background: getComputedStyle(node).backgroundColor,
}))).toEqual({ text: 'rgb(0, 0, 0)', background: 'rgb(255, 255, 255)' })
if (name === 'values.csv') {
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } })
await expect.poll(() => formula.textContent()).toBe(longCellText)
@@ -318,6 +331,38 @@ it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pan
}
})
/** Pan with native pixel wheel input in every direction and along the sheet edges. */
async function expectExcelPanning(page: Page, excel: Locator): Promise<void> {
// Chromium's CDP wheel distances scale with the emulated device pixel ratio.
const scale = await page.evaluate(() => window.devicePixelRatio)
const wheel = async (x: number, y: number) => { await page.mouse.wheel(x * scale, y * scale) }
const offset = async () => await excel.evaluate(node => ({
x: node.querySelector('.luckysheet-scrollbar-x')!.scrollLeft,
y: node.querySelector('.luckysheet-scrollbar-y')!.scrollTop,
}))
const selection = await excel.locator('.fortune-name-box').innerText()
await excel.evaluate((node) => {
node.querySelector('.luckysheet-scrollbar-x')!.scrollLeft = 240
node.querySelector('.luckysheet-scrollbar-y')!.scrollTop = 240
})
await expect.poll(offset).toEqual({ x: 240, y: 240 })
await excel.locator('.fortune-sheet-overlay').hover({ position: { x: 260, y: 160 } })
for (const [dx, dy] of [[37, 19], [-13, 27], [-19, -11], [23, -17], [11, 0], [0, 13], [-9, 0], [0, -7]] as const) {
const before = await offset()
await wheel(dx, dy)
await expect.poll(offset).toEqual({ x: before.x + dx, y: before.y + dy })
}
expect(await excel.locator('.fortune-name-box').innerText()).toBe(selection)
await wheel(-10000, -10000)
await expect.poll(offset).toEqual({ x: 0, y: 0 })
await wheel(-20, 20)
await expect.poll(offset).toEqual({ x: 0, y: 20 })
await wheel(20, -20)
await expect.poll(offset).toEqual({ x: 20, y: 0 })
await wheel(-20, 0)
await expect.poll(offset).toEqual({ x: 0, y: 0 })
}
/** Sample the pixel band before the divider center, excluding the adjacent cell grid line. */
async function freezeDividerInk(excel: Locator, axis: 'x' | 'y'): Promise<number> {
return await excel.evaluate((node, direction) => {
@@ -386,6 +431,7 @@ it.skipIf(MODE === 'record').each([1, 2])('keeps frozen headings without divider
await expect.poll(() => freezeDividerInk(excel, axis)).toBe(0)
}
}
await expectExcelPanning(page, excel)
await successShot(page, `excel-freeze-${sheet}-${ratio}-${deviceScaleFactor}`)
}
await excel.locator('.luckysheet-scrollbar-x').evaluate((node) => { node.scrollLeft = 240 })
@@ -137,7 +137,8 @@
- code: notes.md
- text: untouched.
- paragraph:
- img "Token preview"
- 'button "View full image: Token preview"':
- img "Token preview"
- text: css
- button "Wrap lines" [pressed]
- button "Copy"
@@ -11,15 +11,28 @@
- button "Took {{duration}}" [disabled]
- heading "Markdown images" [level=2]
- paragraph:
- img "Remote test image"
- paragraph: Local test image
- 'button "View full image: Remote test image"':
- img "Remote test image"
- paragraph:
- img "Workspace test image"
- paragraph: Oversized image
- 'button "View full image: Local test image"':
- img "Local test image"
- paragraph:
- img "Outside workspace image"
- paragraph: Missing image
- paragraph: {{cwd}}/corrupt.png
- 'button "View full image: Workspace test image"':
- img "Workspace test image"
- paragraph:
- button "View comparison"
- paragraph:
- 'button "View full image: Space path"':
- img "Space path"
- paragraph:
- 'button "View full image: Encoded path"':
- img "Encoded path"
- paragraph: Image preview unavailable · Oversized image
- paragraph:
- 'button "View full image: Outside workspace image"':
- img "Outside workspace image"
- paragraph: Image preview unavailable · Missing image
- paragraph: Image preview unavailable · {{cwd}}/corrupt.png
- paragraph: REMOTE_IMAGE_DONE
- button "Copy"
- button "Good response"
@@ -1 +1 @@
Prefer showing the primary results within your final response alongside a brief explanation. Markdown file links such as [Report](path/to/report.html) open the file in the sidebar preview. For images, you can add an inline preview such as ![Preview](/absolute/path/image.png); include a file link as well so the image remains accessible in clients that cannot display it inline. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, especially Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.
Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description](<path/to/image.png>) when an image supports an explanation or comparison. Use [Description](<path/to/image.png>) when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.
+53 -6
View File
@@ -1,5 +1,5 @@
// Real browser image loading and failure fallbacks through the shipped Web composition.
import { open, writeFile } from 'node:fs/promises'
import { mkdir, open, writeFile } from 'node:fs/promises'
import { createServer, type Server } from 'node:http'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -112,6 +112,12 @@ function markdownImageFixture(remoteUrl: string, outsidePath: string): string {
'',
`![${WORKSPACE_ALT}]({{cwd}}/valid.png)`,
'',
'[View comparison](./valid.png)',
'',
'![Space path]({{cwd}}/test workspace/测试图.png)',
'',
'![Encoded path]({{cwd}}/test%20workspace/测试图.png)',
'',
'![Oversized image]({{cwd}}/oversized.png)',
'',
`![Outside workspace image](${outsidePath})`,
@@ -164,6 +170,9 @@ describe('web e2e: Markdown image rendering', () => {
imageOrigin = await startImageOrigin()
scaffold = await launchWebScaffold({})
await writeFile(join(scaffold.workspaceCwd, 'valid.png'), PNG)
await writeFile(join(scaffold.workspaceCwd, 'local-image.png'), PNG)
await mkdir(join(scaffold.workspaceCwd, 'test workspace'))
await writeFile(join(scaffold.workspaceCwd, 'test workspace/测试图.png'), PNG)
await writeFile(join(scaffold.workspaceCwd, 'corrupt.png'), 'invalid image')
const oversized = await open(join(scaffold.workspaceCwd, 'oversized.png'), 'w')
try {
@@ -237,11 +246,13 @@ describe('web e2e: Markdown image rendering', () => {
borderRadius: '8px',
decoding: 'async',
loading: 'lazy',
maxWidth: '100%',
maxWidth: 'min(100%, 640px)',
referrerPolicy: 'no-referrer',
})
expect(await page.getByRole('img', { name: LOCAL_ALT }).count()).toBe(0)
expect(await page.getByText(LOCAL_ALT, { exact: true }).count()).toBe(1)
await expect.poll(() => page.getByRole('img', { name: LOCAL_ALT }).evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1)
for (const name of ['Space path', 'Encoded path']) {
await expect.poll(() => page.getByRole('img', { name, exact: true }).evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1)
}
expect(imageOrigin.requests).toEqual([{ path: '/image.png', referer: undefined }])
const workspaceImage = page.getByRole('img', { name: WORKSPACE_ALT })
@@ -253,23 +264,59 @@ describe('web e2e: Markdown image rendering', () => {
const outsideImage = page.getByRole('img', { name: 'Outside workspace image' })
await expect.poll(() => outsideImage.evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1)
for (const alt of ['Oversized image', 'Missing image']) {
await page.getByText(alt, { exact: true }).waitFor()
await page.getByText(`Image preview unavailable · ${alt}`, { exact: true }).waitFor()
expect(await page.getByRole('img', { name: alt }).count()).toBe(0)
}
await page.getByText(join(scaffold.workspaceCwd, 'corrupt.png'), { exact: true }).waitFor()
await page.getByText(`Image preview unavailable · ${join(scaffold.workspaceCwd, 'corrupt.png')}`, { exact: true }).waitFor()
expect(mediaResponses).toEqual(new Map([
[join(scaffold.workspaceCwd, 'valid.png'), 200],
[`${scaffold.workspaceCwd}/./local-image.png`, 200],
[join(scaffold.workspaceCwd, 'test workspace/测试图.png'), 200],
[join(scaffold.workspaceCwd, 'oversized.png'), 413],
[join(scaffold.persistenceRoot, 'outside.png'), 200],
[join(scaffold.workspaceCwd, 'missing.png'), 404],
[join(scaffold.workspaceCwd, 'corrupt.png'), 200],
]))
await page.getByRole('button', { name: `View full image: ${WORKSPACE_ALT}`, exact: true }).click()
const lightbox = page.getByRole('dialog', { name: 'Image preview', exact: true })
await lightbox.waitFor()
await lightbox.getByRole('button', { name: 'Close image preview' }).click()
await lightbox.waitFor({ state: 'detached' })
const link = page.getByRole('button', { name: 'View comparison', exact: true })
await link.hover()
const hoverImage = page.getByRole('img', { name: './valid.png', exact: true })
await hoverImage.waitFor()
await expect.poll(() => hoverImage.evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1)
await page.keyboard.press('Escape')
await hoverImage.waitFor({ state: 'detached' })
await link.press('Tab')
await page.keyboard.press('Shift+Tab')
await hoverImage.waitFor()
await page.keyboard.press('Escape')
await hoverImage.waitFor({ state: 'detached' })
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
await link.evaluate((element) => { element.scrollIntoView({ block: 'end' }) })
await page.mouse.move(0, 0)
await link.hover()
await hoverImage.waitFor()
const linkRect = await link.boundingBox()
if (linkRect === null) throw new Error('image link is not visible')
const previewRect = await hoverImage.evaluate((element) => {
const rect = element.parentElement!.parentElement!.getBoundingClientRect()
return { top: rect.top, bottom: rect.bottom }
})
expect(previewRect.bottom <= linkRect.y || previewRect.top >= linkRect.y + linkRect.height).toBe(true)
const thumbnailRect = await hoverImage.boundingBox()
if (thumbnailRect === null) throw new Error('image preview is not visible')
expect(thumbnailRect.y + thumbnailRect.height).toBeLessThanOrEqual(previewRect.bottom)
await page.mouse.click(linkRect.x + linkRect.width / 2, linkRect.y + linkRect.height / 2)
await expect.poll(() => page.locator('[data-image-preview] img').evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1)
}, 60_000)
})
@@ -107,8 +107,12 @@ it.each(['https://registry.npmjs.org/', MIRROR])('selects the fastest responding
await dialog.getByRole('button', { name: '立即启用', exact: true }).waitFor({ timeout: 20_000 })
await dialog.getByText('版本 2.0.0', { exact: true }).waitFor()
const lookups = (await readFile(join(profile, '.registry-lookups'), 'utf8')).trim().split('\n')
expect(lookups).toHaveLength(1)
expect(JSON.parse(lookups[0]!)).toContain('--registry=' + MIRROR)
.map(line => JSON.parse(line) as string[])
// The inspection asked the mirror first, and each install attempt checked the named package's
// DSH peers at the registry that attempt would use: the mirror, then pnpm's own.
expect(lookups.map(call => call.filter(argument => argument.startsWith('--registry=')))).toEqual([
['--registry=' + MIRROR], ['--registry=' + MIRROR], [],
])
await dialog.getByRole('button', { name: '查看安装详情', exact: true }).click()
await dialog.getByText('Installed from the registry pnpm names', { exact: true }).waitFor()
await dialog.getByText('第 1 次 · 中国大陆镜像源', { exact: true }).waitFor()
+2 -2
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 docs/config-catalog.md
config-catalog.md: eb66160994cb6d7792fb8225aa07839f988e53dc
config-catalog.zh.md: 2fbadf572626b0c1938f88ed2189a25f0a6c7268
config-catalog.md: eb63c85c48d91bc9a79ebd909f2e0acd3a406d26
config-catalog.zh.md: cdbfb07a2ecda96c8df282b8c49ec59ec34d8401
+1 -1
View File
@@ -2194,7 +2194,7 @@ export interface Config {
}
```
Source: [`packages/boot/plugin-manager/src/index.ts:38`](../packages/boot/plugin-manager/src/index.ts)
Source: [`packages/boot/plugin-manager/src/index.ts:40`](../packages/boot/plugin-manager/src/index.ts)
<a id="deepseek-aidsh-plugin-package-inventory-deepseek"></a>
+1 -1
View File
@@ -2196,7 +2196,7 @@ export interface Config {
}
```
来源: [`packages/boot/plugin-manager/src/index.ts:38`](../packages/boot/plugin-manager/src/index.ts)
来源: [`packages/boot/plugin-manager/src/index.ts:40`](../packages/boot/plugin-manager/src/index.ts)
<a id="deepseek-aidsh-plugin-package-inventory-deepseek"></a>
+2 -2
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 docs/event-producer-consumer.md
event-producer-consumer.md: 54a5e36bb6d2c39606c2e0263cad89293e6bb613
event-producer-consumer.zh.md: 248aacbafc115d2c7aea8480406d2a1842ed293d
event-producer-consumer.md: fb0816fe5f66b3981eac1f0fcb2b4d418a12ecfe
event-producer-consumer.zh.md: b2f7a1b2075bd8328a45ed7eedef2c5a3eecd1ce
+1 -1
View File
@@ -26,7 +26,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:616`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:595`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:602`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:45`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:52`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
+1 -1
View File
@@ -28,7 +28,7 @@
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:616`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:595`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:602`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:43`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:52`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
+2 -2
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 docs/subsystems/boot.md
boot.md: 5faf482b0532058e480adf13bf7fee8bb882498d
boot.zh.md: 1f50732fd36a996c6bc15b406f7e9a4ac83bcb26
boot.md: 07391705977bc7b3cba9377e3142feea7d62f69f
boot.zh.md: 64ff36d41295f529b81f2856f2fbc77f09efa808
+15
View File
@@ -89,6 +89,21 @@ Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts)
Manage profile files and apply their declared reload lifecycle.
```ts cordis-catalog
/** Read exact plugin-version exemptions saved in this profile.
* @returns Accepted package-name@version keys with the runtime versions they may run on, and any
* record or file problem the reader rejected, which the caller reports instead of failing.
*/
@Remote listVersionExemptions(): { exemptions: Record<string, string[]>; warnings: string[] }
/** Grant or revoke one exact plugin/runtime exemption and reevaluate live plugins.
* @param packageVersion Exact manifest package name followed by @ and its version; never an installation spec or alias.
* @param runtimeVersion Exact current DSH version for grants; revocation may name a previous runtime.
* @param enabled Whether to grant rather than revoke the exemption.
* @param acceptRisk Required true for grants after the user accepts possible crashes and data loss.
* @returns Saved and runtime outcomes. Startup-only profiles require restart.
*/
@Remote setVersionExemption(packageVersion: string, runtimeVersion: string, enabled: boolean, acceptRisk?: boolean): Promise<ChangeResult>
/** Read current plugins, including why a row cannot be changed through the profile patch.
* @returns Current runtime entries with persistent patch targets.
*/
+15
View File
@@ -89,6 +89,21 @@ Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts)
Manage profile files and apply their declared reload lifecycle.
```ts cordis-catalog
/** Read exact plugin-version exemptions saved in this profile.
* @returns Accepted package-name@version keys with the runtime versions they may run on, and any
* record or file problem the reader rejected, which the caller reports instead of failing.
*/
@Remote listVersionExemptions(): { exemptions: Record<string, string[]>; warnings: string[] }
/** Grant or revoke one exact plugin/runtime exemption and reevaluate live plugins.
* @param packageVersion Exact manifest package name followed by @ and its version; never an installation spec or alias.
* @param runtimeVersion Exact current DSH version for grants; revocation may name a previous runtime.
* @param enabled Whether to grant rather than revoke the exemption.
* @param acceptRisk Required true for grants after the user accepts possible crashes and data loss.
* @returns Saved and runtime outcomes. Startup-only profiles require restart.
*/
@Remote setVersionExemption(packageVersion: string, runtimeVersion: string, enabled: boolean, acceptRisk?: boolean): Promise<ChangeResult>
/** Read current plugins, including why a row cannot be changed through the profile patch.
* @returns Current runtime entries with persistent patch targets.
*/
+2 -2
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 docs/tool-catalog.md
tool-catalog.md: 8e1f4f7c8c8b36de366aa8a1b795106f4ae32283
tool-catalog.zh.md: 0995196921738ad4b5a5deda0cf48c83b1d4e664
tool-catalog.md: 3e4407192ee75b61537d9fd09f3d68381d1e803e
tool-catalog.zh.md: cf78ee4d4f0d8a8bdddbf4ad3987a18bd1f300d1
+13 -3
View File
@@ -52,7 +52,7 @@ This table connects model-visible tool names to the plugin package and service s
### `plugin_manager`
List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart.
List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart. Incompatible DSH peer dependencies block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.
```json
{
@@ -67,7 +67,9 @@ List plugins or bundles in the current profile, enable or disable them, install
"set_plugin",
"set_bundle",
"install_bundle",
"remove_bundle"
"remove_bundle",
"list_version_exemptions",
"set_version_exemption"
]
},
"target": {
@@ -76,7 +78,15 @@ List plugins or bundles in the current profile, enable or disable them, install
},
"enabled": {
"type": "boolean",
"description": "Required for set operations; defaults to true for installation."
"description": "Required for set operations; defaults to true for installation. For set_version_exemption, true grants and false revokes."
},
"runtimeVersion": {
"type": "string",
"description": "For set_version_exemption: exact DSH version from list_version_exemptions. Target must be the manifest package-name@version, not an alias or version range."
},
"acceptRisk": {
"type": "boolean",
"description": "For granting an exemption: true only after warning the user about possible crashes and data loss and receiving explicit permission for this exact plugin/runtime pair. General installation permission is not enough."
},
"approvedBuilds": {
"type": "array",
+13 -3
View File
@@ -56,7 +56,7 @@
### `plugin_manager`
列出当前 profile 中的插件或组合包,启用或禁用它们,安装组合包或移除已安装的组合包。每项操作都要求 danger-full-access 权限或本次调用的批准。批准不改变会话权限模式。变更影响该 profile 的所有会话。先列出条目以获取准确标识。包安装可能运行已获批准的构建脚本。支持热更新的 profile 立即应用变更;仅启动时加载的 profile 需要重启。
列出当前 profile 中的插件或组合包,启用或禁用它们,安装组合包或移除已安装的组合包。每项操作都要求 danger-full-access 权限或本次调用的批准。批准不改变会话权限模式。变更影响该 profile 的所有会话。先列出条目以获取准确标识。包安装可能运行已获批准的构建脚本。支持热更新的 profile 立即应用变更;仅启动时加载的 profile 需要重启。不兼容的 DSH peer 依赖会阻止安装和激活。版本豁免可能导致崩溃和数据丢失:授权前必须警告用户,并获得用户对精确插件版本与运行时版本组合的明确许可。
```json
{
@@ -71,7 +71,9 @@
"set_plugin",
"set_bundle",
"install_bundle",
"remove_bundle"
"remove_bundle",
"list_version_exemptions",
"set_version_exemption"
]
},
"target": {
@@ -80,7 +82,15 @@
},
"enabled": {
"type": "boolean",
"description": "Required for set operations; defaults to true for installation."
"description": "Required for set operations; defaults to true for installation. For set_version_exemption, true grants and false revokes."
},
"runtimeVersion": {
"type": "string",
"description": "For set_version_exemption: exact DSH version from list_version_exemptions. Target must be the manifest package-name@version, not an alias or version range."
},
"acceptRisk": {
"type": "boolean",
"description": "For granting an exemption: true only after warning the user about possible crashes and data loss and receiving explicit permission for this exact plugin/runtime pair. General installation permission is not enough."
},
"approvedBuilds": {
"type": "array",
+2 -2
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/api/gateway/README.md
README.md: 1a746a83eb966fba6ae40b61061d42bf478e1c84
README.zh.md: 7b37548f6ad894b761457df80278eeed7aa6ca7b
README.md: e0c986e290b1744017fca99e3a2553a68d754ef6
README.zh.md: 8be11a7c5aea1e299c4ca45cde4b1c9dfa08ec4b
+2
View File
@@ -38,6 +38,8 @@ When the launcher supplies `ctx.appReady`, Gateway registers the WebSocket upgra
Every stream also carries a Client-to-Host uplink on the same logical stream. A method declares the uplink item type as the second type argument of its return type, `RemoteStream<Out, In>` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `In` defaults to `never`, and a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream<Out>` declares that it reads none. The running method reads its call as `this.ctx.invocation`: the Gateway derives the receiver from a Context carrying that `RemoteInvocation` — `request`, `service`, `peer`, `signal`, and `uplink()` — so nothing enters the parameter list, and `ctx.invocation` is `undefined` on a Context no Remote call derived. `uplink()` is available once per call. With a descriptor codec it decodes every item before delivery; without one — an SRC method, or a method whose `In` is `never` — it delivers JSON-safe `unknown` values. The Client sends each item as an `item` frame on the open logical stream, a top-level `undefined` item as an `item` frame without `value`, and its half-close as an `end` frame; the method observes the half-close as the end of the `uplink()` iteration and cancellation through `signal`. A rejected item fails the whole stream with `gateway/input-invalid` (field `uplink`). Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through unchecked. When the method finishes its downlink the Gateway returns the uplink iterator and drops unread items; items sent to a method that never takes its uplink wait in the inbox until then. Each logical stream buffers at most `streamInboxBytes` (262144 by default) of uplink frame bytes; overflow fails the stream with `gateway/uplink-overflow`, and an item after `end` fails it with `gateway/protocol`, in both cases without closing the socket. `item`, `end`, and `cancel` frames for a stream id the Host has already finished are dropped; only a duplicate `open` closes the socket. Gateway-owned streams such as `$events` return their uplink as soon as they open, so their `item` frames are dropped instead of buffered. A unary method may also call `uplink()`; its items are readable only while the method runs. Every stream speaks for the operator Peer: a WebSocket's streams for the Peer Connection admitted at upgrade, whose scope disposal closes the socket, and an in-process carrier's for the operator directly.
Stream cancellation and stopping keep read-local state, not a history of delivered items, on the Host downlink, Host uplink, and Client uplink pump. Completing the downlink also wakes pending uplink reads.
Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready.
<a id="client-service-clientremote-ctx-key-remote"></a>
+2
View File
@@ -38,6 +38,8 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle
每条流还在同一条逻辑流上携带从 Client 到 Host 的上行。方法把上行项类型声明为返回类型的第二个类型参数——来自 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `RemoteStream<Out, In>`;`In` 缺省为 `never`,返回 `Iterable`、`AsyncIterable` 或 `RemoteStream<Out>` 的方法即声明自己不读上行。运行中的方法通过 `this.ctx.invocation` 读取本次调用:Gateway 从携带该 `RemoteInvocation`(`request`、`service`、`peer`、`signal` 与 `uplink()`)的 Context 派生接收者,因此没有任何东西进入参数列表,而非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。`uplink()` 每次调用只能取一次。描述符带 codec 时它在交付前逐项解码;不带时——SRC 方法,或 `In` 为 `never` 的方法——它交付经 JSON 安全校验的 `unknown` 值。Client 在已打开的逻辑流上把每一项作为 `item` 帧发送(顶层 `undefined` 项是不带 `value` 的 `item` 帧),以 `end` 帧表示半关闭;方法通过 `uplink()` 迭代结束感知半关闭,通过 `signal` 感知取消。被拒绝的项使整条流以 `gateway/input-invalid`(字段 `uplink`)失败。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,不经校验原样透传。方法结束下行时 Gateway 调用 uplink 迭代器的 return 并丢弃未读的项;发给从未取用 uplink 的方法的项在 inbox 里等到那时为止。每条逻辑流最多缓冲 `streamInboxBytes`(默认 262144)字节的上行帧;超限使流以 `gateway/uplink-overflow` 失败,`end` 之后的项使流以 `gateway/protocol` 失败,两种情况都不关闭 socket。发给 Host 已结束的流 id 的 `item`、`end` 与 `cancel` 帧被丢弃;只有重复的 `open` 会关闭 socket。`$events` 这类 Gateway 自有的流在打开时立即 return 其 uplink,因此它们的 `item` 帧被丢弃而不缓冲。一元方法也可以调用 `uplink()`;其项只在方法运行期间可读。每条流都代表操作者 Peer:WebSocket 上的流代表 Connection 在升级时接纳的 Peer,该 scope 释放时 socket 关闭;进程内载体的流直接代表操作者。
Host 下行、Host 上行和 Client 上行泵的取消与停止状态归属单次读取,不保留已交付项的历史。下行结束也会唤醒尚未完成的上行读取。
Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的流。事件名单、参数校验、每个 Client 的队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。
<a id="client-service-clientremote-ctx-key-remote"></a>
@@ -158,7 +158,7 @@ export class RemoteStreamMuxClient {
signal: AbortSignal,
inbox: StreamInbox,
): UplinkPump {
const stopped = Promise.withResolvers<IteratorReturnResult<undefined>>()
let stopped: PromiseWithResolvers<IteratorReturnResult<undefined>> | undefined
const interruption: IteratorReturnResult<undefined> = { value: undefined, done: true }
const uplinkIterator = uplink[Symbol.asyncIterator]()
const state = { stopping: false, exhausted: false, released: false }
@@ -171,7 +171,12 @@ export class RemoteStreamMuxClient {
const done = (async (): Promise<void> => {
try {
while (true) {
const stoppingBeforeRead = state.stopping
if (stoppingBeforeRead) return
// A stop promise belongs to one read, not the whole uplink history.
stopped = Promise.withResolvers<IteratorReturnResult<undefined>>()
const next = await Promise.race([uplinkIterator.next(), stopped.promise])
stopped = undefined
if (state.stopping || next === interruption || signal.aborted || this.socket !== socket) return
if (next.done === true) {
state.exhausted = true
@@ -183,6 +188,7 @@ export class RemoteStreamMuxClient {
} catch (error) {
inbox.fail(error)
} finally {
stopped = undefined
release()
}
})()
@@ -191,7 +197,7 @@ export class RemoteStreamMuxClient {
stop: () => {
if (state.stopping) return
state.stopping = true
stopped.resolve(interruption)
stopped?.resolve(interruption)
release()
},
}
+22 -9
View File
@@ -1164,19 +1164,24 @@ async function *cancellableStream(
? Reflect.apply(asyncFactory, source, []) as AsyncIterator<unknown>
: Reflect.apply(syncFactory as (...args: never[]) => Iterator<unknown>, source, [])
let rejectAbort: ((error: unknown) => void) | undefined
const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
const onAbort = (): void => {
rejectAbort?.(streamAbortFailure(endpoint, signal.reason))
}
signal.addEventListener('abort', onAbort, { once: true })
try {
if (signal.aborted) throw streamAbortFailure(endpoint, signal.reason)
while (true) {
if (signal.aborted) throw streamAbortFailure(endpoint, signal.reason)
// Reusing a pending cancellation promise retains every completed race.
const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
// next() can abort and throw synchronously before the race subscribes.
void aborted.catch(() => undefined)
const next = await Promise.race([Promise.resolve(iterator.next()), aborted])
rejectAbort = undefined
if (next.done === true) return
yield next.value
}
} finally {
rejectAbort = undefined
signal.removeEventListener('abort', onAbort)
// The uplink closes first so a method blocked on `uplink.next()` unwinds
// before its iterator is asked to return.
@@ -1209,9 +1214,10 @@ function remoteCancelled(endpoint: string, cause: unknown): RemoteError<'gateway
*/
class UplinkDecoder implements AsyncIterable<unknown>, AsyncIterator<unknown> {
private readonly source: AsyncIterator<unknown>
private readonly interrupted = Promise.withResolvers<IteratorResult<unknown>>()
private readonly interrupted = new Set<PromiseWithResolvers<IteratorResult<unknown>>>()
private readonly onAbort = (): void => {
this.interrupted.reject(streamAbortFailure(this.endpoint, this.signal.reason))
const failure = streamAbortFailure(this.endpoint, this.signal.reason)
for (const read of this.interrupted) read.reject(failure)
}
private closed = false
@@ -1223,9 +1229,6 @@ class UplinkDecoder implements AsyncIterable<unknown>, AsyncIterator<unknown> {
private readonly abort: (reason: unknown) => void,
) {
this.source = uplink[Symbol.asyncIterator]()
// The rejection settles the race inside next(); an abort with no read in
// flight must not surface as an unhandled rejection.
void this.interrupted.promise.catch(() => undefined)
signal.addEventListener('abort', this.onAbort, { once: true })
}
@@ -1237,7 +1240,17 @@ class UplinkDecoder implements AsyncIterable<unknown>, AsyncIterator<unknown> {
// A cancelled stream reports the cancellation on every read, closed or not.
if (this.signal.aborted) throw streamAbortFailure(this.endpoint, this.signal.reason)
if (this.closed) return UPLINK_DONE
const next = await Promise.race([this.source.next(), this.interrupted.promise])
// Only pending reads belong to the decoder; finish() must wake all of them.
const interrupted = Promise.withResolvers<IteratorResult<unknown>>()
this.interrupted.add(interrupted)
// The source can abort and throw synchronously before the race subscribes.
void interrupted.promise.catch(() => undefined)
let next: IteratorResult<unknown>
try {
next = await Promise.race([this.source.next(), interrupted.promise])
} finally {
this.interrupted.delete(interrupted)
}
if (next.done === true) {
this.finish()
return UPLINK_DONE
@@ -1270,7 +1283,7 @@ class UplinkDecoder implements AsyncIterable<unknown>, AsyncIterator<unknown> {
private finish(): void {
this.closed = true
this.signal.removeEventListener('abort', this.onAbort)
this.interrupted.resolve(UPLINK_DONE)
for (const read of this.interrupted) read.resolve(UPLINK_DONE)
}
}
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto'
import { once } from 'node:events'
import { queryObjects } from 'node:v8'
import { afterEach, describe, expect, it, vi } from 'vitest'
import WebSocket, { type RawData } from 'ws'
import { Context, symbols } from '@deepseek-ai/cordis'
@@ -56,6 +57,7 @@ class FeedService extends TypertRemoteService {
readonly peeked: unknown[] = []
readonly peers: PeerScope[] = []
leftover: AsyncIterator<string> | undefined
source: Iterable<string> = []
returns = 0
constructor(ctx: Context) {
@@ -82,6 +84,11 @@ class FeedService extends TypertRemoteService {
yield `${label}:two`
}
@Remote({ mode: 'stream' })
items(): Iterable<string> {
return this.source
}
@Remote({ mode: 'stream' })
*invalid(): Iterable<string> {
yield 42 as unknown as string
@@ -541,6 +548,110 @@ describe('Typert Remote streams', () => {
await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined })
})
it('finishes every pending uplink read when the downlink returns', async () => {
const { ctx, service } = await setup(false)
const reads = [
Promise.withResolvers<IteratorResult<string>>(),
Promise.withResolvers<IteratorResult<string>>(),
]
let index = 0
const returned = vi.fn(async (): Promise<IteratorResult<string>> => ({ done: true, value: undefined }))
const source = await ctx.typertGateway.stream({
namespace: 'feed', method: 'hold', args: {},
uplink: { [Symbol.asyncIterator]: () => ({ next: () => reads[index++]!.promise, return: returned }) },
})
const iterator = source[Symbol.asyncIterator]()
const completed: IteratorResult<string>[] = []
const pending: Promise<void>[] = []
try {
await expect(iterator.next()).resolves.toEqual({ done: false, value: 'held' })
const uplink = service.leftover!
pending.push(...reads.map(() => uplink.next().then((result) => { completed.push(result) })))
await iterator.return?.()
expect(completed).toEqual([
{ done: true, value: undefined },
{ done: true, value: undefined },
])
expect(returned).toHaveBeenCalledOnce()
} finally {
for (const read of reads) read.resolve({ done: true, value: undefined })
await Promise.all(pending)
await iterator.return?.()
}
})
it.each(['downlink', 'uplink'] as const)('releases consumed %s read results while the stream stays open', async (direction) => {
class ReadResult implements IteratorYieldResult<string> {
readonly done = false
readonly value = 'item'
}
const { ctx, service } = await setup(false)
service.source = { [Symbol.iterator]: () => ({ next: () => new ReadResult() }) }
const source = await ctx.typertGateway.stream(direction === 'downlink'
? { namespace: 'feed', method: 'items', args: {} }
: {
namespace: 'feed', method: 'echo', args: { prefix: '' },
uplink: { [Symbol.asyncIterator]: () => ({ next: async () => new ReadResult() }) },
})
const iterator = source[Symbol.asyncIterator]()
try {
for (const count of [64, 256]) {
for (let index = 0; index < count; index++) {
await expect(iterator.next()).resolves.toEqual({ done: false, value: 'item' })
}
// queryObjects collects garbage; the suspended read can retain its last result, not the history.
expect(queryObjects(ReadResult, { format: 'count' })).toBeLessThanOrEqual(2)
}
} finally {
await iterator.return?.()
}
})
it('does not read another downlink item after cancellation between reads', async () => {
const { ctx, service } = await setup(false)
const abort = new AbortController()
const next = vi.fn((): IteratorResult<string> => ({ done: false, value: 'item' }))
service.source = { [Symbol.iterator]: () => ({ next }) }
const source = await ctx.typertGateway.stream({
namespace: 'feed', method: 'items', args: {}, signal: abort.signal,
})
const iterator = source[Symbol.asyncIterator]()
try {
await expect(iterator.next()).resolves.toEqual({ done: false, value: 'item' })
abort.abort(new Error('caller cancelled between reads'))
await expect(iterator.next()).rejects.toMatchObject({ code: 'gateway/cancelled' })
expect(next).toHaveBeenCalledOnce()
} finally {
await iterator.return?.()
}
})
it.each(['downlink', 'uplink'] as const)('handles a synchronous %s read that aborts and throws', async (direction) => {
const { ctx, service } = await setup(false)
const abort = new AbortController()
const failure = new Error('source aborted and threw')
const next = (): never => {
abort.abort(failure)
throw failure
}
service.source = { [Symbol.iterator]: () => ({ next }) }
const source = await ctx.typertGateway.stream(direction === 'downlink'
? { namespace: 'feed', method: 'items', args: {}, signal: abort.signal }
: {
namespace: 'feed', method: 'hold', args: {}, signal: abort.signal,
uplink: { [Symbol.asyncIterator]: () => ({ next }) },
})
const iterator = source[Symbol.asyncIterator]()
try {
if (direction === 'uplink') {
await expect(iterator.next()).resolves.toEqual({ done: false, value: 'held' })
}
await expect((direction === 'downlink' ? iterator : service.leftover!).next()).rejects.toBe(failure)
} finally {
await iterator.return?.()
}
})
it('reports done on every read after the uplink ended', async () => {
const { ctx } = await setup(false)
await expect(collect(await ctx.typertGateway.stream({
@@ -1461,6 +1572,7 @@ function descriptors(): InvocationDescriptor[] {
stream('context', [label], z.string()),
{ ...stream('follow', [label], z.string()), cancellation: { parameter: 'signal' } },
stream('sync', [label], z.string()),
stream('items', [], z.string()),
stream('invalid', [], z.string()),
stream('nonJson', [], z.unknown()),
stream('missing', [], z.string()),
@@ -1,3 +1,4 @@
import { queryObjects } from 'node:v8'
import { RemoteError, typertOwnedValue } from '@deepseek-ai/dsh-typert-protocol'
import { Context, Service } from '@deepseek-ai/cordis'
import type { Fiber } from '@deepseek-ai/cordis'
@@ -2804,6 +2805,81 @@ describe('Client Typert API', () => {
})
describe('Remote stream client carrier lifecycle', () => {
it('does not pull another uplink item when sending ends the stream', async () => {
await withFakeWebSocket('https://harness.example', async () => {
const client = new RemoteStreamMuxClient()
client.start()
const socket = FakeWebSocket.sockets[0]!
const send = socket.send.bind(socket)
const sent = vi.spyOn(socket, 'send').mockImplementation((text) => {
send(text)
const frame = parseRemoteStreamClientMessage(text)
if (frame.type === 'item') socket.receive({ type: 'end', streamId: frame.streamId })
})
const next = vi.fn(async (): Promise<IteratorResult<string>> => ({ done: false, value: 'item' }))
const returned = vi.fn(async (): Promise<IteratorResult<string>> => ({ done: true, value: undefined }))
try {
const iterator = client.open('probe/attach', {}, new AbortController().signal, {
[Symbol.asyncIterator]: () => ({ next, return: returned }),
})[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
expect(next).toHaveBeenCalledOnce()
expect(returned).toHaveBeenCalledOnce()
} finally {
await client.close()
sent.mockRestore()
}
})
})
it('releases sent uplink read results while the stream stays open', async () => {
await withFakeWebSocket('https://harness.example', async () => {
class ReadResult implements IteratorYieldResult<string> {
readonly done = false
readonly value = 'item'
}
const blocked = Promise.withResolvers<undefined>()
const lastRead = Promise.withResolvers<IteratorResult<string>>()
let reads = 0
const uplink: AsyncIterable<string> = {
[Symbol.asyncIterator]: () => ({
next: () => {
if (reads++ < 256) return Promise.resolve(new ReadResult())
blocked.resolve(undefined)
return lastRead.promise
},
return: async () => {
lastRead.resolve({ done: true, value: undefined })
return { done: true, value: undefined }
},
}),
}
const client = new RemoteStreamMuxClient()
client.start()
const iterator = client.open('probe/attach', {}, new AbortController().signal, uplink)[Symbol.asyncIterator]()
const first = iterator.next()
// Closing the carrier during failed assertions can reject this pending read.
void first.catch(() => undefined)
try {
await blocked.promise
const socket = FakeWebSocket.sockets[0]!
expect(socket.sent).toHaveLength(257)
const opened = parseRemoteStreamClientMessage(socket.sent[0]!)
if (opened.type !== 'open') throw new Error('fixture expected an open frame')
socket.receive({ type: 'item', streamId: opened.streamId, value: 'ready' })
await expect(first).resolves.toEqual({ done: false, value: 'ready' })
// A full GC runs while the uplink pump is still waiting for its next item.
expect(queryObjects(ReadResult, { format: 'count' })).toBeLessThanOrEqual(2)
socket.receive({ type: 'end', streamId: opened.streamId })
await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
} finally {
await client.close()
await first.catch(() => undefined)
await iterator.return?.(undefined)
}
})
})
it('connects to the shell-owned Host while the document uses a local asset origin', async () => {
await withFakeWebSocket('dsh-app://app', async () => {
vi.stubGlobal('__DSH_TRANSPORT__', { streamBaseUrl: 'http://127.0.0.1:43210' })
+2 -2
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/boot/app-boot/README.md
README.md: 70a8e235ac5846061ea47e79e7dc575881110a50
README.zh.md: 9849b033e01827a4c50a22b72ea20deebb0fdd85
README.md: 729a4b1552cd28e89698580860f8c9e31043a76c
README.zh.md: c401978fdf05651479c8c6b74b01d48287fa2b63
+8 -1
View File
@@ -49,6 +49,12 @@ Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-mani
A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/<name>` and combines installable bundles with its own `cordis.patch.yml`. A bundle's `dsh.bundle.patch` names one patch file or an ordered list of files; `bundlePatchFiles` validates the declaration and `bundlePatchPaths` resolves it to absolute paths; the layer concatenates their patch lists in that order. The YAML composition enables or disables HMR. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile <name> --from-default-profile <template>` creates a custom profile at a new non-shipped name from one shipped template, while `dsh plugin` initializes a base-backed profile and manages its installed bundles. Bundle resolution, manifest, and patch-loading failures print a diagnostic and skip that bundle without changing its selection. Remaining bundles keep their order; profile and user-patch errors still fail startup. Skipping a bundle does not guarantee that the remaining composition can provide the required services. Application-owned npm projects, such as Electron's reserved Desktop profile, use `loadProfileDirectory` to load an already initialized directory without exposing it through CLI profile lookup.
Before a profile imports a plugin, DSH checks its `peerDependencies` on `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*` against the single runtime version returned by `getDshRuntimeVersion()`. Every declared range must match; prereleases participate in range matching. Source-workspace `workspace:^`, `workspace:~`, and `workspace:*` refer to that same runtime. Missing DSH peers impose no constraint; invalid ranges are incompatible. These checks use peer declarations, not `engines.dsh`, and are not a sandbox against malicious package code.
Admission happens only at composition boundaries DSH owns, and a denied row is denied in the launcher's own copy of the composition: no profile patch layer, dependency manifest, or bundle list changes. `prepareProfilePatches` composes over the launcher's empty profile root and runs while the root Include is mounted and on every profile recomposition, so a denied plugin never imports its module. `prepareProfileEntries` does the same for a preset's rows. A denied ordinary row becomes a detached `disabled: true` row; a native group stays mounted and its denied children do not load; a native Include that reaches a denied plugin is omitted whole, because its file is never rewritten. A row denied by policy keeps its configured `disabled` value in the profile, and every refusal is reported with the package, versions, and risk. These boundaries do not cover plugins another embedder mounts through its own `ctx.plugin` call. Two edits made directly to files during a session are judged only at the next recomposition or startup: a running plugin's own `package.json` peer declaration, and an entry-list file the Loader reads itself, such as the launcher's root config or a nested `cordis:include` file. `--dump-config` reports the configured composition, so a denied plugin still appears there, and `--dump-config-schema` imports each composed module to read its schema; run it only against a profile whose plugins you already trust.
Exact-version exemptions live in the profile's own `compatibility.json`, not in its `package.json`, so an exemption write never touches the dependency manifest, the bundle list, or Cordis patch files. It maps exact `package-name@version` keys to lists of exact DSH runtime versions; neither a plugin upgrade nor a DSH upgrade inherits permission. A missing file means no exemptions. A malformed one never stops the profile from starting: records the reader accepts still apply, each rejected record is reported on stderr beside the plugin denials, and the file is then treated as read-only, so a grant or revocation refuses and asks the user to repair it by hand instead of overwriting their content. [Plugin Manager](../plugin-manager/README.md#version-compatibility-and-exemptions) owns the grant, revocation, and risk confirmation each change requires.
Your machine-local preferences also live in the Harness home:
- **`.env`** — your ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. Variables that decide how the process starts (`PATH`, `DSH_*`, `XDG_*` and similar) are rejected from files: export them instead. The four proxy names (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`) are accepted from the Harness-home file only, never from the invoking directory's, which arrives with a clone. For a non-product bin that just wants one directory's `.env`, a missing file is fine and an unloadable one prints one labelled warning line.
@@ -122,6 +128,7 @@ This section explains how the outcomes above are realized and points at the code
### Design notes
- **Runtime version.** `getDshRuntimeVersion()` reads this package's manifest through a filesystem path, including the executable's virtual filesystem; missing or invalid versions fail rather than bypassing compatibility checks.
- **Profile launch data.** `ctx.profileContext` contains only profile locations, startup bundle names, parsed invocation overlays and the telemetry opt-out value. `readProfilePatches()` composes the supplied startup profile or reads current files at those locations; callers schedule and apply the result.
- **Process-local module resolution.** The launcher installs the runtime resolution on Node's internal ESM and CommonJS resolvers before profile rows mount. Node still owns exports, conditions, subpaths, module caches, and error codes; routed ESM failures report the original importer. Explicit CommonJS `paths` always retain native lookup, including paths inside profiles.
- **Linked directories.** A profile link to an external directory admits its importers to peer-aware ancestor lookup, even without its own `package.json`. At each `D/node_modules` position, current `D/package.json` peer names present in the runtime table use the runtime package; other names use the physical candidate. A nearer physical package precedes a later peer declaration, and a peer position needs no physical `node_modules`. Installation-scope package directories are excluded from linked interception; overlapping roots do not change the importer's lookup order ([rule](../../../.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.md)).
@@ -192,7 +199,7 @@ These limits describe when this boot library is a poor fit or needs special care
- **Runtime resolution depends on Node internals** — supported Node versions require the native builtin-access addon and executable compatibility coverage. Only built Harness-owned Workers receive the runtime resolution bootstrap; third-party Workers and custom `vm` linkers keep native resolution.
- **Relinking a profile package needs a restart** — Node caches real paths, so changing the target of a profile link or a dependency link requires a process restart.
- **Linked scope follows recorded real directories** — a hoisted dependency outside every linked root uses native Node. Current peer reads do not invalidate Node caches, watch files, or validate peer version ranges.
- **Linked scope follows recorded real directories** — a hoisted dependency outside every linked root uses native Node. A peer read never invalidates Node caches or changes which files are watched.
- **Source launches use an ESM-only hook** — CommonJS requests still need the JavaScript files selected by package exports; resolution does not supply missing build outputs.
- **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection.
- **Environment discovery is launch-scoped** — `loadLayeredEnv` reads only the invocation directory and Harness home once; it does not search parents or follow a workspace selected later. `loadEnv` remains the one-directory helper for non-product bins.
+8 -1
View File
@@ -49,6 +49,12 @@ Profile 与组合包的声明类型从 [`@deepseek-ai/dsh-package-manifest`](../
profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/<name>`,由可安装组合包和自身 `cordis.patch.yml` 组成。组合包的 `dsh.bundle.patch` 指定一个 patch 文件或一个有序的文件列表;`bundlePatchFiles` 校验该声明,`bundlePatchPaths` 把它解析为绝对路径;该层按此顺序拼接各文件的 patch 列表。YAML 组合决定是否启用 HMR。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立组合包,其他模板保留 base 加模式的组合包栈。`dsh --profile <name> --from-default-profile <template>` 从一个随附模板,在新的非内置名称处创建自定义 profile;`dsh plugin` 则初始化以 base 为基础的 profile,并管理其中安装的组合包。组合包解析、manifest 读取或 patch 加载失败时会输出诊断并跳过该组合包,不改变其选择状态。其余组合包保持原顺序;profile 和用户 patch 错误仍会导致启动失败。跳过组合包不保证剩余组合能够提供所需服务。由应用持有的 npm 项目(例如 Electron 保留的 Desktop profile)通过 `loadProfileDirectory` 加载已经初始化的目录,而不会将它暴露给 CLI profile 查找。
profile 导入插件前,DSH 会检查其 `peerDependencies` 中对 `@deepseek-ai/dsh` 和 `@deepseek-ai/dsh-*` 的依赖,与 `getDshRuntimeVersion()` 返回的唯一运行时版本比较。每个声明的版本范围都必须匹配;预发布版本参与范围匹配。源码工作区的 `workspace:^`、`workspace:~` 和 `workspace:*` 指向同一个运行时。未声明 DSH peer 时不施加版本约束;无效范围视为不兼容。这些检查使用 peer 声明,而不是 `engines.dsh`,也不是防范恶意包代码的沙箱。
检查只发生在 DSH 自己持有的组合入口,改写的是启动器自己的那份组合:profile 的 patch 层、依赖清单与组合包列表都不会改变。`prepareProfilePatches` 在启动器的空 profile 根之上组合,并在挂载根 Include 时以及每次 profile 重新组合时执行,因此被拒绝的插件永远不会导入其模块;`prepareProfileEntries` 对 preset 行做同样的事。被拒绝的普通行会变成游离的 `disabled: true` 行;原生 group 保持挂载,其被拒绝的子行不会加载;若某个原生 Include 会到达被拒绝的插件,则整体省略该 Include,因为它的文件不会被改写。被策略拒绝的行在 profile 中保留其配置的 `disabled` 值,每次拒绝都会报告包名、版本与风险。这些入口不覆盖其他嵌入方通过自己的 `ctx.plugin` 调用挂载的插件。会话中直接改文件的两类编辑只在下一次重新组合或启动时才被判定:正在运行的插件自己的 `package.json` peer 声明,以及 Loader 自己读取的入口清单文件(如启动器的根配置或嵌套的 `cordis:include` 文件)。`--dump-config` 报告的是配置出的组合,因此被拒绝的插件仍会出现在其中;`--dump-config-schema` 会导入每个组合模块以读取其 schema,请只对已经信任其插件的 profile 运行。
精确版本豁免保存在 profile 自己的 `compatibility.json` 中,而不是 `package.json`,因此写豁免不会触及依赖清单、组合包列表或 Cordis patch 文件。它把精确的 `package-name@version` 键映射到精确 DSH 运行时版本列表;插件升级和 DSH 升级都不继承授权。文件缺失表示没有豁免。文件损坏绝不会阻止 profile 启动:读取器接受的记录仍然生效,每条被拒绝的记录会与插件拒绝信息一起输出到 stderr,此后该文件被视为只读,因此授予或撤销会拒绝执行并要求用户手工修复,而不是覆盖用户的内容。[插件管理器](../plugin-manager/README.zh.md#version-compatibility-and-exemptions)负责每次变更所需的授权、撤销与风险确认。
你的机器本地偏好同样位于 harness home 中:
- **`.env`**——你的普通环境层:调用目录的文件优先于 harness home 的文件,两者都低于继承环境。在文件中设置的进程启动变量(如 `PATH`、`DSH_*`、`XDG_*`)会被拒绝:请改为导出这些变量。四个代理名(`HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY`)只从 harness home 的文件接受,绝不从调用目录的文件接受——后者随 clone 一起到来。对于只想加载某个目录 `.env` 的非产品 bin,文件缺失不影响启动,文件无法加载时输出一行带标签的警告。
@@ -122,6 +128,7 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如
### 设计说明
- **运行时版本。** `getDshRuntimeVersion()` 通过文件系统路径读取本包清单,也支持可执行文件内的虚拟文件系统;版本缺失或无效时会失败,而不会绕过兼容性检查。
- **Profile 启动数据。** `ctx.profileContext` 只包含 profile 位置、启动时组合包名称、已解析的调用级 overlay 与遥测退出值。`readProfilePatches()` 组合传入的启动 profile,或读取这些位置上的当前文件;调用方负责调度和应用结果。
- **进程内模块解析。** launcher 在挂载 profile 条目前,将 runtime resolution 安装到 Node 的 ESM 与 CommonJS 内部 resolver。exports、conditions、子路径、模块缓存和错误码仍由 Node 负责;路由后的 ESM 失败报告原始 importer。显式 CommonJS `paths` 始终保留原生查询,包括指向 profile 内的路径。
- **链接目录。** profile 链接到树外目录时,其下的 importer 参与逐层 peer 查询,即使目标没有自身的 `package.json`。在每个 `D/node_modules` 位置,当前 `D/package.json` 的 peer 包名若存在于运行时表,就使用运行时包;其他包名查询物理候选。更近的物理包先于后续 peer 声明,peer 位置无需物理 `node_modules`。installation 作用域包目录不参与 linked 拦截,重叠 root 不改变 importer 的查询顺序([规则](../../../.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.zh.md))。
@@ -192,7 +199,7 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如
- **运行时解析依赖 Node 内部机制**——受支持的 Node 版本需要 native builtin access addon 和可执行兼容验证。只有构建后的 Harness 自有 Worker 接收 runtime resolution bootstrap;第三方 Worker 与自定义 `vm` linker 保持原生解析。
- **重新链接 profile 包需要重启**——Node 缓存真实路径,因此改变 profile 链接或依赖链接的目标需要重启进程。
- **链接作用域以记录的真实目录为准**——提升后的依赖若在所有 linked root 之外,就使用原生 Node。实时读取 peer 不会使 Node 缓存失效、监视文件或校验 peer 版本范围。
- **链接作用域以记录的真实目录为准**——提升后的依赖若在所有 linked root 之外,就使用原生 Node。每次读取 peer 都不会使 Node 缓存失效,也不会改变被监视的文件。
- **源码启动只安装 ESM 钩子**——CommonJS 请求仍需要 package exports 选中的 JavaScript 文件,解析器不会补出缺失的构建产物。
- **快照回放替换仅识别特定 basename**——只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。
- **环境发现以启动为界**——`loadLayeredEnv` 只读取一次调用目录与 harness home 中的 `.env`;它不搜索父目录,也不跟随之后选择的 workspace。`loadEnv` 仍是非产品 bin 使用的单目录 helper。
+4 -1
View File
@@ -32,12 +32,14 @@
],
"license": "MIT",
"dependencies": {
"@deepseek-ai/dsh-atomic-write": "workspace:*",
"@deepseek-ai/dsh-package-manifest": "workspace:*",
"@eslint-community/regexpp": "^4.12.2",
"ajv": "^8.20.0",
"js-yaml": "^4.2.0",
"node-addon-require-builtin": "^0.1.6",
"resolve.exports": "^2.0.3"
"resolve.exports": "^2.0.3",
"semver": "^7.8.5"
},
"peerDependencies": {
"@deepseek-ai/cordis-plugin-group": "workspace:~",
@@ -58,6 +60,7 @@
"@deepseek-ai/dsh-home-paths": "workspace:*",
"@deepseek-ai/dsh-system-prompt": "workspace:*",
"@types/js-yaml": "^4.0.9",
"@types/semver": "^7.8.0",
"@deepseek-ai/cordis": "workspace:~"
}
}
@@ -0,0 +1,185 @@
/** Compatibility checks at the composition entry points DSH owns; no Loader instrumentation. */
import { existsSync, readFileSync, realpathSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, extname, isAbsolute, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import type { Context } from '@deepseek-ai/cordis'
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
import { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import { load } from 'js-yaml'
import { resolvePluginResource } from './package-meta.ts'
import { barePackageName } from './profile-resolution/resolver.ts'
import type {} from './profile-resolution/service.ts'
import type {} from './profile-context.ts'
import { evaluatePluginCompatibility, pluginCompatibilityWarning } from './plugin-compatibility.ts'
import { readProfileCompatibility } from './profile-compatibility.ts'
function readManifest(filename: string): object {
return JSON.parse(readFileSync(filename, 'utf8')) as object
}
/** Render the patch wording an Include would report, through the profile's logger. */
function patchWarning(ctx: Context): (message: string, ...args: unknown[]) => void {
return (message, ...args) => {
let index = 0
ctx.logger.warn(message.replace(/%C/g, () => JSON.stringify(args[index++])))
}
}
function manifestOf(ctx: Context, name: string, parentURL: string): object | undefined {
if (name.startsWith('cordis:')) return undefined
const specifier = name.startsWith('#') ? resolvePluginResource(name, parentURL) : name
const packages = ctx.get('pluginPackages')
const pkg = packages?.packageOf(specifier, parentURL)
if (pkg !== undefined) return readManifest(pkg.manifestPath)
const bare = barePackageName(specifier)
if (bare !== undefined && packages === undefined) {
// An embedder without the package service falls back to Node's own lookup, as `packageDirFromParent` does.
for (const path of createRequire(parentURL).resolve.paths(bare) as string[]) {
const filename = join(path, bare, 'package.json')
if (existsSync(filename)) return readManifest(filename)
}
}
if (!isAbsolute(specifier) && !specifier.startsWith('.') && !specifier.startsWith('file:')) return undefined
const url = isAbsolute(specifier) ? pathToFileURL(specifier) : new URL(specifier, parentURL)
let dir: string
try { dir = dirname(realpathSync(fileURLToPath(url))) }
catch (error) {
// A row this profile cannot resolve is the Loader's import failure to report, not a compatibility decision.
if (['ENOENT', 'ENOTDIR', 'EISDIR'].includes(String((error as NodeJS.ErrnoException).code))) return undefined
throw error
}
while (true) {
try { return readManifest(join(dir, 'package.json')) }
catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
const parent = dirname(dir)
if (parent === dir) return undefined
dir = parent
}
}
}
/** Prepare profile or preset rows before the owning DSH caller passes them to Loader.
* Only a compatibility conflict denies a row: an entry whose manifest cannot be resolved keeps
* the Loader's own import failure. A denied ordinary row gains `disabled`; a native Include that
* reaches a denied plugin is denied as a whole, because its file is never rewritten.
* @param ctx Context carrying launcher-owned profile facts; non-profile contexts retain their rows.
* @param entries Complete effective entry list, after patch composition.
* @param parentURL Resolution base of the tree that will import these rows.
* @param binName Diagnostic prefix for a denied row; defaults to `dsh`.
* @returns Detached rows with incompatible entries denied; reads the profile compatibility file once.
* @throws For malformed compatibility permissions or a profile composition without a resolution base.
*/
export function prepareProfileEntries(
ctx: Context, entries: readonly EntryOptions[], parentURL: string | undefined, binName = 'dsh',
): EntryOptions[] {
// Admission runs before the composed tree mounts, so no plugin-owned logger exporter exists yet;
// composition-stage diagnostics go to stderr like the profile launcher's skipped-bundle report.
return preflight(ctx, entries, parentURL, (row, reason) => {
process.stderr.write(`${binName}: disabling profile plugin ${row.name}: ${reason}\n`)
}).rows
}
/** Denial reporter invoked once per denied row. */
type DenialReporter = (row: EntryOptions, reason: string) => void
function preflight(
ctx: Context, entries: readonly EntryOptions[], parentURL: string | undefined, report: DenialReporter,
) {
const rows = structuredClone(entries) as EntryOptions[]
const profile = ctx.get('profileContext')
if (profile === undefined) return { rows, blocked: false }
if (parentURL === undefined) throw new Error('Profile compatibility preflight requires a resolution base')
// A damaged permission file authorizes nothing, but it must not stop the profile from starting.
const { exemptions, warnings } = readProfileCompatibility(profile.dir)
for (const warning of warnings) process.stderr.write(`${warning}\n`)
const includes = new Set<string>()
/** Only a compatibility conflict denies a row; every other failure keeps the Loader's own diagnosis. */
const denial = (row: EntryOptions, base: string): string | undefined => {
try {
const manifest = manifestOf(ctx, row.name, base)
if (manifest === undefined) return undefined
const issue = evaluatePluginCompatibility(manifest, exemptions)
return issue === undefined || issue.exempted ? undefined : pluginCompatibilityWarning(issue)
} catch (error) {
// Peer metadata that cannot be read or validated is refused rather than silently admitted.
/* v8 ignore next -- every reader and parser used here rejects with an Error. */
const detail = error instanceof Error ? error.message : String(error)
return `its declared peer dependencies cannot be validated: ${detail}`
}
}
const deny = (row: EntryOptions, reason: string): void => {
row.disabled = true
if (row.group) row.group = false
report(row, reason)
}
const check = (rows: EntryOptions[], base: string): boolean => {
let blocked = false
for (const row of rows) {
if (row.disabled === true && !row.group) continue
const conflict = denial(row, base)
if (conflict !== undefined) {
deny(row, conflict)
blocked = true
continue
}
// The `group` marker, not the module name, is what makes a row another tree carrier.
if ((row.group === true || row.name === 'cordis:group' || row.name === '@deepseek-ai/cordis-plugin-group')
&& Array.isArray(row.config) && check(row.config as EntryOptions[], base)) blocked = true
if (row.name !== 'cordis:include' && row.name !== '@deepseek-ai/cordis-plugin-include') continue
const reached = includedConflicts(row, base)
if (reached !== undefined) {
deny(row, reached)
blocked = true
}
}
return blocked
}
/** Whether a native Include reaches a denied plugin; an unreadable one has no compatibility opinion. */
const includedConflicts = (row: EntryOptions, base: string): string | undefined => {
const config = row.config as { path?: unknown; patches?: PatchOptions[]; initial?: EntryOptions[] } | undefined
if (typeof config?.path !== 'string' || !['.yml', '.yaml', '.json'].includes(extname(config.path))) {
// A dynamic Include is resolved by the Include plugin itself; this walk cannot judge its rows.
return undefined
}
const requested = isAbsolute(config.path) ? config.path : fileURLToPath(new URL(config.path, base))
const filename = existsSync(requested) ? realpathSync(requested) : requested
if (includes.has(filename)) return undefined
includes.add(filename)
try {
let data: unknown
try { data = load(readFileSync(filename, 'utf8'), { schema: entryListSchema }) }
catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT' || config.initial === undefined) return undefined
data = config.initial
}
if (!Array.isArray(data)) return undefined
const children = applyEntryPatches(data as EntryOptions[], config.patches, patchWarning(ctx))
return check(children, pathToFileURL(filename).href)
? `its included file ${filename} reaches an incompatible plugin, and that file is never rewritten`
: undefined
} finally {
includes.delete(filename)
}
}
return { rows, blocked: check(rows, parentURL) }
}
/** Apply compatibility policy to the complete patch composition over a profile's empty root.
* The caller passes every patch layer of the profile, because this returns one insertion patch for
* that empty root: a non-empty root config would lose the caller's override patches by id.
* @param ctx Profile context prepared by the launcher.
* @param patches Original ordered profile patches; these remain unchanged.
* @param parentURL Root Include's resolution base.
* @param binName Diagnostic prefix for a denied row; defaults to `dsh`.
* @returns One prepared insertion patch for profiles, or the original patches for non-profile callers.
*/
export function prepareProfilePatches(
ctx: Context, patches: PatchOptions[], parentURL: string, binName = 'dsh',
): PatchOptions[] {
if (ctx.get('profileContext') === undefined) return patches
const entries = applyEntryPatches([], patches, patchWarning(ctx))
const rows = prepareProfileEntries(ctx, entries, parentURL, binName)
return rows.length === 0 ? [] : [{ insert: rows }]
}
+27 -11
View File
@@ -19,6 +19,13 @@ import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
export { readProfilePatches, resolveTelemetryPatch, type ProfileContext, type ProfilePnpmInvocation } from './profile-context.ts'
export { sanitizeProfile } from './profile-sanitize.ts'
export { getDshRuntimeVersion, evaluatePluginCompatibility, pluginCompatibilityWarning, type PluginCompatibility } from './plugin-compatibility.ts'
export {
PROFILE_COMPATIBILITY_FILENAME, readProfileCompatibility, readProfileVersionExemptions,
setProfileVersionExemption, type ProfileCompatibility,
} from './profile-compatibility.ts'
import { prepareProfilePatches } from './compatibility-preflight.ts'
export { prepareProfileEntries, prepareProfilePatches } from './compatibility-preflight.ts'
export { readPluginMeta } from './package-meta.ts'
export { generateConfigSchema, type ConfigSchemaDump, type NativeConfigSchema } from './config-schema/index.ts'
export { createConfigProjector, LOADER_EXPRESSION_SCHEMA, type ConfigProjection } from './config-schema/projector.ts'
@@ -274,7 +281,10 @@ export async function reconcileProfilePatches(
fiber: row.fiber, failed: row.fiber.state === FIBER_FAILED || row.fiber.state === FIBER_DISPOSED,
}])
const { patches: _previous, ...includeConfig } = entry.options.config as Include.Config
await entry.update({ config: { ...includeConfig, patches } })
// The recomposition judges the rows the launch judged, resolved from the file this Include read.
const parentURL = new URL('.', new URL(includeConfig.path, entry.parent.tree.ctx.baseUrl)).href
const prepared = prepareProfilePatches(ctx, patches, parentURL, binName)
await entry.update({ config: { ...includeConfig, patches: prepared } })
const results = await Promise.allSettled(previousFibers.map(({ fiber }) => fiber.await()))
await ctx.loader.await()
const failures = await inactiveEntries(ctx)
@@ -384,14 +394,15 @@ export interface ConfigDumpLayer {
}
/**
* Compose the effective entry list exactly as `boot()` would mount it: parse
* the base config file with the include's entry-list dialect, apply every
* layer's patches as ONE flattened list through the include's own patch
* algorithm (`applyEntryPatches`) — the same single call `boot()` makes, so
* even patch-visibility corner cases (a later layer targeting a group child a
* plain config replacement introduced, which the single-pass id index never
* sees) compose identically — then render the result as YAML in the same
* dialect (`!!js` expressions print verbatim, unevaluated).
* Compose the configured entry list: parse the base config file with the
* include's entry-list dialect, apply every layer's patches as ONE flattened
* list through the include's own patch algorithm (`applyEntryPatches`) — the
* same call `boot()` makes, so even patch-visibility corner cases (a later
* layer targeting a group child a plain config replacement introduced, which
* the single-pass id index never sees) compose identically — then render the
* result as YAML in the same dialect (`!!js` expressions print verbatim,
* unevaluated). Admission is a later stage: this dump reports what the profile
* configures, and a plugin the compatibility policy denies still appears here.
*
* Every run of rows from the same file and patch layers is preceded by a `# ==` comment
* naming the file that contributed the rows and any layers that patched them,
@@ -517,6 +528,7 @@ function groupedDump(
* @param patches - initial app and user patches, applied in order.
* @param bareModuleBaseUrl - optional installed-host base for bare package
* names; relative names continue to resolve beside the configuration file.
* @param binName - diagnostic prefix for a profile plugin denied by compatibility policy; defaults to `dsh`.
* @returns the created root Include entry, or `undefined` when a surface
* disposed the whole tree (taking the Loader service with it) while the
* entry creation was in flight.
@@ -526,6 +538,7 @@ export async function mountRootInclude(
absoluteConfigPath: string,
patches: readonly PatchOptions[] = [],
bareModuleBaseUrl?: string,
binName = 'dsh',
): Promise<Entry | undefined> {
ctx.loader.builtins.include = bareModuleBaseUrl === undefined
? Include
@@ -549,9 +562,12 @@ export async function mountRootInclude(
// Pinned id: the bootstrap include is app glue, not a config row, and its
// id appears in Loader failure chains — a random id would make startup
// diagnostics unstable across runs (and snapshot fixtures).
// The launcher's own copy is prepared here: compatibility decisions must be made before the root
// Include imports anything, and they change no profile patch layer, manifest, or bundle list.
const prepared = prepareProfilePatches(ctx, [...patches], pathToFileURL(dirname(absoluteConfigPath)).href + '/', binName)
const includeConfig: Include.Config = {
path: pathToFileURL(absoluteConfigPath).href,
...patches.length > 0 ? { patches: [...patches] } : {},
...prepared.length > 0 ? { patches: prepared } : {},
}
const rootInclude: EntryOptions = {
id: 'include',
@@ -983,7 +999,7 @@ export async function boot(
await ctx.plugin(Loader)
await prepare?.(ctx)
stage = 'plugin tree failed to load'
await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl)
await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl, binName)
// A surface can finish and dispose the whole tree while startup is still
// in flight, before the last entry settles. The Loader service goes with
// it, and the activation audit describes a live tree — reading `ctx.loader`
@@ -0,0 +1,102 @@
/** Evaluate plugin dsh peer requirements without importing plugin code. */
import fs from 'node:fs'
import { fileURLToPath } from 'node:url'
import semver from 'semver'
/** Incompatible dsh peers and the exact plugin/runtime exemption decision. */
export interface PluginCompatibility {
name: string
version: string
runtimeVersion: string
/** Only peer requirements not satisfied by the running dsh version. */
peers: Record<string, string>
exempted: boolean
}
function objectOf(value: unknown, field: string): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`${field} must be an object`)
}
return value as Record<string, unknown>
}
function runtimeVersionOf(value: unknown): string {
if (typeof value !== 'string' || semver.valid(value) === null) {
throw new Error(`Invalid dsh runtime version: ${JSON.stringify(value)}; expected a semantic version`)
}
return value
}
function identityField(manifest: Record<string, unknown>, field: 'name' | 'version'): string {
const value = Object.hasOwn(manifest, field) ? manifest[field] : undefined
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`Plugin manifest ${field} must be a non-empty string when dsh peers are incompatible`)
}
return value
}
/**
* Read this app-boot package's version in both source and bundled installations.
* @returns the validated runtime semantic version, preserving its exact spelling.
* @throws if package.json cannot be read or its version is missing or invalid.
*/
export function getDshRuntimeVersion(): string {
// The executable's virtual filesystem intercepts string paths, not URL arguments.
const filename = fileURLToPath(new URL('../package.json', import.meta.url))
const manifest = objectOf(JSON.parse(fs.readFileSync(filename, 'utf8')), 'app-boot package.json')
return runtimeVersionOf(Object.hasOwn(manifest, 'version') ? manifest.version : undefined)
}
/**
* Check every @deepseek-ai/dsh or @deepseek-ai/dsh-* peer against the runtime.
* Prereleases participate in ranges. workspace:^, workspace:~, and workspace:*
* refer to the current runtime; other invalid ranges are incompatible.
* @param manifest - parsed plugin package.json; inherited fields are ignored.
* @param exemptions - exact plugin name@version keys mapped to exact runtime versions.
* @param runtimeVersion - running dsh version, defaulting to this app-boot package.
* @returns incompatible peers and exemption status, or undefined when none are incompatible.
* @throws for malformed manifest peer fields, invalid runtime versions, or missing identity on a mismatch.
*/
export function evaluatePluginCompatibility(
manifest: object,
exemptions: Readonly<Record<string, readonly string[]>> = {},
runtimeVersion = getDshRuntimeVersion(),
): PluginCompatibility | undefined {
runtimeVersionOf(runtimeVersion)
const fields = objectOf(manifest, 'Plugin manifest')
if (!Object.hasOwn(fields, 'peerDependencies')) return undefined
const dependencies = objectOf(fields.peerDependencies, 'Plugin manifest peerDependencies')
const peers: Record<string, string> = {}
for (const [name, range] of Object.entries(dependencies)) {
if (typeof range !== 'string') {
throw new Error(`Plugin manifest peerDependencies[${JSON.stringify(name)}] must be a string`)
}
if (name !== '@deepseek-ai/dsh' && !name.startsWith('@deepseek-ai/dsh-')) continue
const requirement = ['workspace:^', 'workspace:~', 'workspace:*'].includes(range) ? runtimeVersion : range
if (requirement.trim() === '' || !semver.satisfies(runtimeVersion, requirement, { includePrerelease: true })) {
peers[name] = range
}
}
if (Object.keys(peers).length === 0) return undefined
const name = identityField(fields, 'name')
const version = identityField(fields, 'version')
const key = `${name}@${version}`
const exemptedVersions = Object.hasOwn(exemptions, key) ? exemptions[key] : undefined
const exempted = exemptedVersions?.includes(runtimeVersion) === true
return { name, version, runtimeVersion, peers, exempted }
}
/**
* Describe incompatible peers, their risk, and the exact-version remedy.
* @param issue - incompatible plugin/runtime result, including exempted mismatches.
* @returns a warning suitable for CLI and plugin-management diagnostics.
*/
export function pluginCompatibilityWarning(issue: PluginCompatibility): string {
const key = `${issue.name}@${issue.version}`
return `Plugin ${key} is incompatible with dsh ${issue.runtimeVersion}: peerDependencies ${JSON.stringify(issue.peers)}. `
+ 'Running it may cause crashes or data loss. '
+ 'Update the plugin or install a plugin version compatible with this dsh runtime. '
+ `To accept this risk explicitly, add the exact-version exemption ${JSON.stringify({ [key]: [issue.runtimeVersion] })}. `
+ `Exact-version exemption: ${issue.exempted ? 'active' : 'not active'}.`
}
@@ -0,0 +1,141 @@
/** Profile-local compatibility permissions readable before any Cordis plugins load. */
import { readFileSync } from 'node:fs'
import { mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import { parse } from 'semver'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { getDshRuntimeVersion } from './plugin-compatibility.ts'
/** Independent profile metadata; neither package manifests nor Cordis patches carry grants. */
export const PROFILE_COMPATIBILITY_FILENAME = 'compatibility.json'
/** Published and scoped npm package names, as npm accepts them in a manifest dependency key. */
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/
/** Whether a decoded record accepts only exact DSH versions. */
function isVersionList(value: unknown): value is string[] {
return Array.isArray(value) && value.every(version => typeof version === 'string' && isExactPluginVersion(version))
}
/** Test whether an exemption names a canonical exact SemVer, including build metadata.
* @param value Version supplied by a manifest or user.
* @returns False for ranges, prefixes, whitespace, and malformed versions.
*/
export function isExactPluginVersion(value: string): boolean {
const parsed = parse(value)
return parsed !== null && value === `${parsed.version}${parsed.build.length === 0 ? '' : `+${parsed.build.join('.')}`}`
}
/** Validate one explicit exemption without granting it.
* @param packageVersion Exact npm package-name@version, never an installation spec or range.
* @param runtimeVersion Exact DSH version, including prerelease and build metadata.
* @throws When either identity is not canonical.
*/
export function validatePluginVersionExemption(packageVersion: string, runtimeVersion: string): void {
const separator = packageVersion.lastIndexOf('@')
if (separator <= 0 || !PACKAGE_NAME.test(packageVersion.slice(0, separator))
|| !isExactPluginVersion(packageVersion.slice(separator + 1)) || !isExactPluginVersion(runtimeVersion)) {
throw new Error('Version exemptions require an exact npm package-name@version and an exact DSH runtime version')
}
}
/** What one profile's compatibility file currently authorizes, and what is wrong with it. */
export interface ProfileCompatibility {
/** Accepted exact package-name@version keys mapped to their allowed DSH versions. */
readonly exemptions: Record<string, string[]>
/** Human-readable problems; empty when every record was accepted. */
readonly warnings: string[]
/**
* Whether the file holds nothing this reader rejected, so a grant or revocation may rewrite it.
* A false value means writing would discard content the user must repair by hand.
*/
readonly rewritable: boolean
}
/** Read the profile's independent compatibility file without loading plugins.
* A missing file authorizes nothing. An unreadable or unparsable file authorizes nothing and is
* reported instead of failing, so a bad file can never make the profile unusable; rejected records
* are skipped while the remaining valid ones still apply.
* @param profileDir Absolute profile directory.
* @returns Accepted exemptions plus every problem found; no manifest fallback is used.
*/
export function readProfileCompatibility(profileDir: string): ProfileCompatibility {
const filename = join(profileDir, PROFILE_COMPATIBILITY_FILENAME)
const unreadable = (reason: string): ProfileCompatibility =>
({ exemptions: {}, warnings: [`${filename} ${reason}; treating the profile as having no exemptions`], rewritable: false })
let text: string
try { text = readFileSync(filename, 'utf8') }
catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { exemptions: {}, warnings: [], rewritable: true }
return unreadable(`cannot be read (${String(error)})`)
}
let value: unknown
try { value = JSON.parse(text) }
catch (error) { return unreadable(`is not valid JSON (${String(error)})`) }
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return unreadable('must map exact package@version keys to DSH version lists')
}
const exemptions: Record<string, string[]> = {}
const warnings: string[] = []
for (const [key, versions] of Object.entries(value as Record<string, unknown>)) {
const separator = key.lastIndexOf('@')
if (separator <= 0 || !PACKAGE_NAME.test(key.slice(0, separator)) || !isExactPluginVersion(key.slice(separator + 1))) {
warnings.push(`${filename}: ${JSON.stringify(key)} is not an exact package-name@version key; the record is ignored`)
continue
}
if (!isVersionList(versions)) {
warnings.push(`${filename}: ${key} must contain a list of exact DSH versions; the record is ignored`)
continue
}
exemptions[key] = versions
}
return { exemptions, warnings, rewritable: warnings.length === 0 }
}
/** Read only the accepted exemptions of a profile.
* @param profileDir Absolute profile directory.
* @returns Exact package-name@version keys mapped to their allowed DSH versions.
*/
export function readProfileVersionExemptions(profileDir: string): Record<string, string[]> {
return readProfileCompatibility(profileDir).exemptions
}
/** Persist one informed grant or revocation under the compatibility file's own lock.
* @param profileDir Profile directory; no package manifest is created or modified.
* @param packageVersion Exact manifest package-name@version.
* @param runtimeVersion Exact DSH version; grants must name the current runtime, revocations may name historical ones.
* @param enabled Whether to grant rather than revoke.
* @param acceptRisk Required true for grants after explicit acknowledgement of possible crashes or data loss.
* @returns After the atomic write. Existing plugin instances are not reloaded by this operation.
* @throws For invalid identities, missing consent, a stale runtime, or a file the reader rejected,
* which the user must repair by hand because rewriting it would discard their content.
*/
export async function setProfileVersionExemption(
profileDir: string, packageVersion: string, runtimeVersion: string, enabled: boolean, acceptRisk: boolean,
): Promise<void> {
validatePluginVersionExemption(packageVersion, runtimeVersion)
if (enabled && !acceptRisk) {
throw new Error('Incompatible plugins may cause crashes or data loss. To grant this exact-version exemption, explicitly acknowledge the risk with --accept-risk (acceptRisk: true).')
}
const current = getDshRuntimeVersion()
if (enabled && runtimeVersion !== current) {
throw new Error(`Cannot approve DSH ${runtimeVersion}: this application runs DSH ${current}. Use --dsh-version ${current}.`)
}
await mkdir(profileDir, { recursive: true })
const filename = join(profileDir, PROFILE_COMPATIBILITY_FILENAME)
await withFileLock(filename, async () => {
const current = readProfileCompatibility(profileDir)
if (!current.rewritable) {
throw new Error(`${PROFILE_COMPATIBILITY_FILENAME} must be repaired before exemptions change:\n${current.warnings.join('\n')}`)
}
const exemptions = current.exemptions
const versions = exemptions[packageVersion] ?? []
if (enabled) exemptions[packageVersion] = [...new Set([...versions, runtimeVersion])]
else {
const retained = versions.filter(version => version !== runtimeVersion)
if (retained.length) exemptions[packageVersion] = retained
else Reflect.deleteProperty(exemptions, packageVersion)
}
await writeFileAtomic(filename, JSON.stringify(exemptions, undefined, 2) + '\n', { mode: 0o600 })
})
}
@@ -0,0 +1,351 @@
/** Admission of profile composition rows before the Loader imports any plugin code. */
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from '@deepseek-ai/cordis'
import { entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import { load } from 'js-yaml'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import {
PROFILE_COMPATIBILITY_FILENAME, PluginPackages, boot, getDshRuntimeVersion,
prepareProfileEntries, prepareProfilePatches, type ProfileContext,
} from '../src/index.ts'
const runtime = getDshRuntimeVersion()
function fixture() {
const temporary = mkdtempSync(join(tmpdir(), 'dsh-preflight-'))
let ctx: Context | undefined
onTestFinished(async () => {
try { await ctx?.fiber.dispose() } finally { rmSync(temporary, { recursive: true, force: true }) }
})
const dir = realpathSync(temporary)
const effects = join(dir, 'effects')
const warnings: string[] = []
// Admission diagnostics precede the composed tree, so stderr is the channel under test.
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
warnings.push(String(chunk))
return true
})
onTestFinished(() => { stderr.mockRestore() })
function plugin(name: string, options: { version?: string; peer?: string; alias?: string } = {}): void {
const packageDir = join(dir, 'node_modules', options.alias ?? name)
mkdirSync(packageDir, { recursive: true })
writeFileSync(join(packageDir, 'package.json'), JSON.stringify({
name, version: options.version ?? '1.0.0', type: 'module', exports: './index.mjs',
...options.peer === undefined ? {} : { peerDependencies: { '@deepseek-ai/dsh-test': options.peer } },
}))
writeFileSync(join(packageDir, 'index.mjs'), `import { appendFileSync } from 'node:fs'
const record = (value) => appendFileSync(new URL('${pathToFileURL(effects).href}'), value + '\\n')
record('import:${name}')
export function apply() { record('apply:${name}') }
`)
}
function exemptions(value: Record<string, string[]>): void {
writeFileSync(join(dir, PROFILE_COMPATIBILITY_FILENAME), JSON.stringify(value))
}
const profile: ProfileContext = {
name: 'preflight', dir, patchPath: join(dir, 'cordis.patch.yml'),
installAnchor: join(dir, 'package.json'), cwd: dir, home: dir,
startedBundles: [], overlays: [], telemetryDisabledEnv: undefined,
}
const configPath = join(dir, 'cordis.yml')
writeFileSync(configPath, '[]\n')
exemptions({})
/** Boot the profile with these patches and return the recorded plugin effects. */
async function run(patches: PatchOptions[], packages = false): Promise<string[]> {
ctx = await boot('preflight', configPath, patches, async (host) => {
host.provide('profileContext', profile)
if (packages) await host.plugin(PluginPackages)
host.logger.exporter({ levels: { default: 3 }, export(message) {
if (message.type === 'warn' || message.type === 'error') warnings.push(String(message.args[0]))
} })
})
return existsSync(effects) ? readFileSync(effects, 'utf8').trim().split('\n') : []
}
return { dir, profile, configPath, warnings, plugin, exemptions, run }
}
const insert = (...names: string[]) => [{ insert: names.map((name, index) => ({ id: `row-${String(index)}`, name })) }]
it('blocks an incompatible plugin before its module is imported while a compatible sibling loads', async () => {
const f = fixture()
f.plugin('denied-plugin', { peer: '^9.0.0' })
f.plugin('allowed-plugin')
const effects = await f.run(insert('allowed-plugin', 'denied-plugin'))
expect(effects).toEqual(['import:allowed-plugin', 'apply:allowed-plugin'])
expect(f.warnings.join('\n')).toContain('[email protected]')
expect(f.warnings.join('\n')).toContain('data loss')
})
it('admits the same plugin only for its exact version and the running runtime', async () => {
const granted = fixture()
granted.plugin('denied-plugin', { peer: '^9.0.0' })
granted.exemptions({ '[email protected]': [runtime] })
expect(await granted.run(insert('denied-plugin'))).toEqual(['import:denied-plugin', 'apply:denied-plugin'])
const otherVersion = fixture()
otherVersion.plugin('denied-plugin', { version: '2.0.0', peer: '^9.0.0' })
otherVersion.exemptions({ '[email protected]': [runtime] })
expect(await otherVersion.run(insert('denied-plugin'))).toEqual([])
const otherRuntime = fixture()
otherRuntime.plugin('denied-plugin', { peer: '^9.0.0' })
otherRuntime.exemptions({ '[email protected]': ['9.9.9'] })
expect(await otherRuntime.run(insert('denied-plugin'))).toEqual([])
})
const denialSpecifiers: Array<[string, (name: string, dir: string) => string]> = [
['a package name', () => 'denied-plugin'],
['a relative module', () => './node_modules/denied-plugin/index.mjs'],
['an absolute module', (name, dir) => join(dir, 'node_modules', name, 'index.mjs')],
['a file URL', (name, dir) => pathToFileURL(join(dir, 'node_modules', name, 'index.mjs')).href],
]
it.each(denialSpecifiers)('blocks an incompatible %s before import', async (_label, specifier) => {
const f = fixture()
f.plugin('denied-plugin', { peer: '^9.0.0' })
expect(await f.run(insert(specifier('denied-plugin', f.dir)))).toEqual([])
})
it('blocks a denied plugin reached through a package-imports alias', async () => {
const f = fixture()
const aliased = join(f.dir, 'aliased')
mkdirSync(aliased)
writeFileSync(join(aliased, 'package.json'), JSON.stringify({
name: 'denied-plugin', version: '1.0.0', type: 'module', exports: './index.mjs',
peerDependencies: { '@deepseek-ai/dsh-test': '^9.0.0' },
}))
writeFileSync(join(aliased, 'index.mjs'), `import { appendFileSync } from 'node:fs'
appendFileSync(new URL('${pathToFileURL(join(f.dir, 'effects')).href}'), 'import:denied-plugin\\n')
export function apply() {}
`)
writeFileSync(join(f.dir, 'package.json'), JSON.stringify({
name: 'preflight-profile', private: true, type: 'module',
imports: { '#denied': './aliased/index.mjs' },
}))
expect(await f.run(insert('#denied'))).toEqual([])
expect(f.warnings.join('\n')).toContain('[email protected]')
})
it('blocks an incompatible descendant of a native group while its compatible sibling loads', async () => {
const f = fixture()
f.plugin('denied-plugin', { peer: '^9.0.0' })
f.plugin('allowed-plugin')
const effects = await f.run([{ insert: [{ id: 'group', name: 'cordis:group', group: true, config: [
{ id: 'allowed', name: 'allowed-plugin' },
{ id: 'denied', name: 'denied-plugin' },
] }] }])
expect(effects).toEqual(['import:allowed-plugin', 'apply:allowed-plugin'])
})
it('omits a native Include that reaches an incompatible plugin and keeps an unrelated Include', async () => {
const f = fixture()
f.plugin('denied-plugin', { peer: '^9.0.0' })
f.plugin('allowed-plugin')
const deniedList = join(f.dir, 'denied.cordis.yml')
const allowedList = join(f.dir, 'allowed.cordis.yml')
writeFileSync(deniedList, JSON.stringify([{ id: 'denied', name: 'denied-plugin' }]))
writeFileSync(allowedList, JSON.stringify([{ id: 'allowed', name: 'allowed-plugin' }]))
const effects = await f.run([{ insert: [
{ id: 'denied-include', name: 'cordis:include', config: { path: pathToFileURL(deniedList).href } },
{ id: 'allowed-include', name: 'cordis:include', config: { path: pathToFileURL(allowedList).href } },
] }])
expect(effects).toEqual(['import:allowed-plugin', 'apply:allowed-plugin'])
expect(f.warnings.join('\n')).toContain('never rewritten')
})
it('leaves an unresolvable row to the Loader instead of denying it', async () => {
const f = fixture()
expect(await f.run(insert('./missing.mjs'))).toEqual([])
expect(f.warnings.join('\n')).toContain('missing.mjs')
expect(f.warnings.join('\n')).not.toContain('disabled:')
})
it('denies a row whose peer metadata cannot be validated', async () => {
const f = fixture()
f.plugin('malformed-plugin')
writeFileSync(join(f.dir, 'node_modules', 'malformed-plugin', 'package.json'),
JSON.stringify({ name: 'malformed-plugin', version: '1.0.0', peerDependencies: ['@deepseek-ai/dsh'] }))
expect(await f.run(insert('malformed-plugin'))).toEqual([])
expect(f.warnings.join('\n')).toContain('cannot be validated')
})
it('leaves an explicitly disabled row and a non-profile composition untouched', async () => {
const f = fixture()
f.plugin('denied-plugin', { peer: '^9.0.0' })
expect(await f.run([{ insert: [{ id: 'off', name: 'denied-plugin', disabled: true }] }])).toEqual([])
const unrelated = new Context()
onTestFinished(() => unrelated.fiber.dispose())
const rows = [{ id: 'row', name: 'denied-plugin' }]
expect(prepareProfileEntries(unrelated, rows, f.dir)).toEqual(rows)
})
it('reports a rejected record while the records it accepted still apply', async () => {
const f = fixture()
f.plugin('denied-plugin', { peer: '^9.0.0' })
f.plugin('allowed-plugin')
writeFileSync(join(f.dir, PROFILE_COMPATIBILITY_FILENAME),
JSON.stringify({ '[email protected]': [runtime], 'broken@*': [runtime] }))
// The accepted grant still admits its plugin; only the broken record is reported and dropped.
expect((await f.run(insert('allowed-plugin', 'denied-plugin'))).sort())
.toEqual(['apply:allowed-plugin', 'apply:denied-plugin', 'import:allowed-plugin', 'import:denied-plugin'])
expect(f.warnings.join('\n')).toContain('is not an exact package-name@version key')
})
it('starts with no exemptions and a warning when the permission file is unparsable', async () => {
const f = fixture()
f.plugin('denied-plugin', { peer: '^9.0.0' })
writeFileSync(join(f.dir, PROFILE_COMPATIBILITY_FILENAME), '{ not json')
expect(await f.run(insert('denied-plugin'))).toEqual([])
expect(f.warnings.join('\n')).toContain('is not valid JSON')
expect(f.warnings.join('\n')).toContain('is incompatible with dsh')
})
describe('manifest resolution', () => {
function context(f: ReturnType<typeof fixture>): Context {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
ctx.provide('profileContext', f.profile)
return ctx
}
it('leaves rows with no resolvable manifest to the Loader', () => {
const f = fixture()
writeFileSync(join(f.dir, 'standalone.mjs'), 'export function apply() {}\n')
const rows = [
{ id: 'outside', name: './standalone.mjs' },
{ id: 'builtin', name: 'node:fs' },
{ id: 'missing', name: 'missing-package' },
]
expect(prepareProfileEntries(context(f), rows, pathToFileURL(f.dir).href + '/')).toEqual(rows)
})
it('denies through the mounted package service when the launcher supplies one', async () => {
const f = fixture()
f.plugin('denied-plugin', { peer: '^9.0.0' })
expect(await f.run(insert('denied-plugin'), true)).toEqual([])
expect(f.warnings.join('\n')).toContain('[email protected]')
})
it('stops at a recursive Include instead of recursing forever', () => {
const f = fixture()
const inner = join(f.dir, 'inner.cordis.yml')
const outer = join(f.dir, 'outer.cordis.yml')
writeFileSync(inner, `- id: nested\n name: cordis:include\n config: { path: ${JSON.stringify(outer)} }\n`)
writeFileSync(outer, `- id: outer\n name: cordis:include\n config: { path: ${JSON.stringify(inner)} }\n`)
const rows = prepareProfileEntries(context(f), [{ id: 'cycle', name: 'cordis:include', config: { path: inner } }],
pathToFileURL(f.dir).href + '/')
expect(rows[0]?.disabled).toBeUndefined()
})
it('leaves an Include whose file is not an entry list undecided', () => {
const f = fixture()
const mapping = join(f.dir, 'mapping.cordis.yml')
writeFileSync(mapping, 'id: nested\nname: denied-plugin\n')
const rows = prepareProfileEntries(context(f), [{ id: 'row', name: 'cordis:include', config: { path: mapping } }],
pathToFileURL(f.dir).href + '/')
expect(rows[0]?.disabled).toBeUndefined()
})
it('warns about ignored patches while the included rows still decide', () => {
const f = fixture()
f.plugin('allowed-plugin')
const list = join(f.dir, 'patched.cordis.yml')
writeFileSync(list, '- id: nested\n name: allowed-plugin\n')
const ctx = context(f)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
onTestFinished(() => { warn.mockRestore() })
const rows = prepareProfileEntries(ctx, [{ id: 'outer', name: 'cordis:include',
config: { path: list, patches: [{ id: 'absent', disabled: true }] } }], pathToFileURL(f.dir).href + '/')
expect(rows[0]?.disabled).toBeUndefined()
expect(warn.mock.calls.flat().map(String).join(' ')).toContain('absent')
})
it.each([
['an unreadable manifest', (f: ReturnType<typeof fixture>) => {
f.plugin('denied-plugin')
rmSync(join(f.dir, 'node_modules', 'denied-plugin', 'package.json'))
mkdirSync(join(f.dir, 'node_modules', 'denied-plugin', 'package.json'))
}],
['a module path that loops', (f: ReturnType<typeof fixture>) => {
f.plugin('denied-plugin')
symlinkSync('loop.mjs', join(f.dir, 'node_modules', 'denied-plugin', 'loop.mjs'))
}],
])('refuses a row whose %s cannot be read', (_label, prepare) => {
const f = fixture()
prepare(f)
const name = _label === 'a module path that loops'
? './node_modules/denied-plugin/loop.mjs' : './node_modules/denied-plugin/index.mjs'
const rows = prepareProfileEntries(context(f), [{ id: 'row', name }], pathToFileURL(f.dir).href + '/')
expect(rows[0]?.disabled).toBe(true)
expect(f.warnings.join('\n')).toContain('cannot be validated')
})
it('denies a group-shaped row and clears its group flag', () => {
const f = fixture()
f.plugin('denied-plugin', { peer: '^9.0.0' })
const rows = prepareProfileEntries(context(f), [{ id: 'row', name: 'denied-plugin', group: true, config: [] }],
pathToFileURL(f.dir).href + '/')
expect(rows[0]).toMatchObject({ disabled: true, group: false })
})
it('ignores a native Include whose path is not a literal YAML or JSON file', () => {
const f = fixture()
const ctx = context(f)
for (const config of [undefined, { path: 7 }, { path: './plugin.mjs' }]) {
expect(prepareProfileEntries(ctx, [{ id: 'row', name: 'cordis:include', config }],
pathToFileURL(f.dir).href + '/')).toEqual([{ id: 'row', name: 'cordis:include', config }])
}
})
it('ignores an absent literal Include without initial entries and reads them when provided', () => {
const f = fixture()
f.plugin('denied-plugin', { peer: '^9.0.0' })
const ctx = context(f)
const base = pathToFileURL(f.dir).href + '/'
const missing = join(f.dir, 'missing.cordis.yml')
expect(prepareProfileEntries(ctx, [{ id: 'row', name: 'cordis:include', config: { path: missing } }], base))
.toEqual([{ id: 'row', name: 'cordis:include', config: { path: missing } }])
const rows = prepareProfileEntries(ctx, [{ id: 'row', name: 'cordis:include',
config: { path: missing, initial: [{ id: 'denied', name: 'denied-plugin' }] } }], base)
expect(rows[0]?.disabled).toBe(true)
expect(f.warnings.join('\n')).toContain('never rewritten')
})
it('returns no patches for a profile whose composition has no rows and warns about an unmatched patch', () => {
const f = fixture()
const ctx = context(f)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
onTestFinished(() => { warn.mockRestore() })
const base = pathToFileURL(f.dir).href + '/'
expect(prepareProfilePatches(ctx, [], base)).toEqual([])
expect(prepareProfilePatches(ctx, [{ id: 'absent', disabled: true }], base)).toEqual([])
expect(warn.mock.calls.flat().map(String).join(' ')).toContain('absent')
})
it('requires a resolution base for a profile composition', () => {
const f = fixture()
expect(() => { prepareProfileEntries(context(f), [{ id: 'row', name: 'allowed-plugin' }], undefined) })
.toThrow('requires a resolution base')
})
})
it('reuses the documented literal Include dialect when reading included entries', async () => {
const f = fixture()
f.plugin('allowed-plugin')
const list = join(f.dir, 'nested.cordis.yml')
writeFileSync(list, '- id: nested\n name: allowed-plugin\n')
expect(load(readFileSync(list, 'utf8'), { schema: entryListSchema })).toEqual([{ id: 'nested', name: 'allowed-plugin' }])
// The launcher mounts an Include with a file URL, which a Windows path cannot stand in for: `C:` reads as a scheme.
expect(await f.run([{ insert: [{ id: 'outer', name: 'cordis:include', config: { path: pathToFileURL(list).href } }] }]))
.toEqual(['import:allowed-plugin', 'apply:allowed-plugin'])
})
@@ -0,0 +1,177 @@
/** Plugin peer checks retain exact mismatches and never inherit exemption entries. */
import fs from 'node:fs'
import { fileURLToPath } from 'node:url'
import semver from 'semver'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { evaluatePluginCompatibility, getDshRuntimeVersion, pluginCompatibilityWarning } from '../src/plugin-compatibility.ts'
const runtime = '0.1.7-alpha.1'
const identity = { name: '@example/plugin', version: '2.0.0' }
const incompatible = { ...identity, peerDependencies: { '@deepseek-ai/dsh': '^0.2.0' } }
function check(peerDependencies: object) {
return evaluatePluginCompatibility({ ...identity, peerDependencies }, {}, runtime)
}
afterEach(() => vi.restoreAllMocks())
describe('dsh runtime version', () => {
it('reads the exact version with a string path accepted by executable virtual filesystems', () => {
const { version: expected } = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }
const read = vi.spyOn(fs, 'readFileSync')
expect(getDshRuntimeVersion()).toBe(expected)
expect(semver.valid(expected)).not.toBeNull()
expect(read).toHaveBeenCalledWith(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')
})
it.each([{}, { version: null }, { version: 1 }, { version: '' }, { version: 'not-semver' }])(
'rejects an invalid or absent package version: %j', (manifest) => {
vi.spyOn(fs, 'readFileSync').mockReturnValueOnce(JSON.stringify(manifest))
expect(() => getDshRuntimeVersion()).toThrow('Invalid dsh runtime version')
},
)
it.each(['null', '[]', '1', '"text"'])('rejects non-object package JSON: %s', (contents) => {
vi.spyOn(fs, 'readFileSync').mockReturnValueOnce(contents)
expect(() => getDshRuntimeVersion()).toThrow('app-boot package.json must be an object')
})
it('propagates malformed JSON and read failures', () => {
const read = vi.spyOn(fs, 'readFileSync').mockReturnValueOnce('{')
expect(() => getDshRuntimeVersion()).toThrow(SyntaxError)
read.mockImplementationOnce(() => { throw new Error('manifest unavailable') })
expect(() => getDshRuntimeVersion()).toThrow('manifest unavailable')
})
})
describe('plugin compatibility', () => {
it('defaults to the installed runtime and needs no identity without mismatches', () => {
expect(evaluatePluginCompatibility({})).toBeUndefined()
expect(evaluatePluginCompatibility({ peerDependencies: { '@deepseek-ai/dsh': getDshRuntimeVersion() } })).toBeUndefined()
expect(check({})).toBeUndefined()
})
it.each(['*', '^0.1.0', '~0.1.0', '>=0.1.0 <0.2.0', '0.1.x', '0.1.0 - 0.1.9', '^0.2.0 || ^0.1.0', runtime])(
'includes prereleases in supported semver range %s', (range) => {
expect(check({ '@deepseek-ai/dsh': range, '@deepseek-ai/dsh-session': range })).toBeUndefined()
},
)
it.each(['workspace:^', 'workspace:~', 'workspace:*'])('uses the source runtime for %s', (range) => {
expect(check({ '@deepseek-ai/dsh-session': range })).toBeUndefined()
})
it('checks the intersection of dsh requirements and returns only mismatches', () => {
expect(check({
'@deepseek-ai/dsh': '^0.1.0',
'@deepseek-ai/dsh-session': '>=0.2.0',
'@deepseek-ai/dsh-tools': '<0.1.0',
'@deepseek-ai/cordis': '^99.0.0',
})).toEqual({
...identity,
runtimeVersion: runtime,
peers: { '@deepseek-ai/dsh-session': '>=0.2.0', '@deepseek-ai/dsh-tools': '<0.1.0' },
exempted: false,
})
})
it.each(['^0.2.0', '>=0.1.7', 'broken', '', ' ', 'workspace:>=0.2.0', 'file:../dsh', 'npm:dsh@*'])(
'fails closed for an incompatible or malformed range %j', (range) => {
expect(check({ '@deepseek-ai/dsh': range })?.peers).toEqual({ '@deepseek-ai/dsh': range })
},
)
it('ignores unrelated names and dependency fields', () => {
expect(check({
'@deepseek-ai/cordis': 'broken', '@deepseek-ai/cordis-plugin-loader': '^99',
'@other/dsh': '^99', '@deepseek-ai/dshx': '^99', dsh: '^99',
constructor: '^99', hasOwnProperty: '^99', ['__proto__']: '^99',
})).toBeUndefined()
expect(evaluatePluginCompatibility({ dependencies: { '@deepseek-ai/dsh': '^99' } }, {}, runtime)).toBeUndefined()
})
it.each([null, [], 'bad', 1, false, undefined])('rejects malformed peerDependencies: %j', (peerDependencies) => {
expect(() => evaluatePluginCompatibility({ ...identity, peerDependencies }, {}, runtime)).toThrow('peerDependencies must be an object')
})
it.each([null, [], {}, 1, false, undefined])('rejects non-string peer ranges: %j', (range) => {
for (const name of ['@deepseek-ai/dsh', '@deepseek-ai/cordis']) {
expect(() => check({ [name]: range })).toThrow(`peerDependencies[${JSON.stringify(name)}] must be a string`)
}
})
it.each(['name', 'version'] as const)('requires own non-empty %s on a mismatch', (field) => {
for (const value of [undefined, null, '', ' ', 3]) {
expect(() => evaluatePluginCompatibility({ ...incompatible, [field]: value }, {}, runtime))
.toThrow(`Plugin manifest ${field} must be a non-empty string`)
}
const inherited = Object.assign(
Object.create({ [field]: identity[field] }) as object,
Object.fromEntries(Object.entries(incompatible).filter(([name]) => name !== field)),
)
expect(() => evaluatePluginCompatibility(inherited, {}, runtime)).toThrow(`Plugin manifest ${field}`)
})
it('ignores inherited peer declarations and inherited peer entries', () => {
expect(evaluatePluginCompatibility(Object.create(incompatible) as object, {}, runtime)).toBeUndefined()
expect(check(Object.create(incompatible.peerDependencies) as object)).toBeUndefined()
expect(check(Object.assign(Object.create({ '@deepseek-ai/dsh': '^99' }) as object, { '@deepseek-ai/dsh-session': '*' }))).toBeUndefined()
})
it('rejects an invalid explicit runtime even when no peers are declared', () => {
expect(() => evaluatePluginCompatibility({}, {}, 'invalid')).toThrow('Invalid dsh runtime version')
})
it('rejects array plugin manifests', () => {
expect(() => evaluatePluginCompatibility([], {}, runtime)).toThrow('Plugin manifest must be an object')
})
})
describe('exact-version exemptions', () => {
const exemptions = { '@example/[email protected]': [runtime] }
it('exempts only the exact scoped plugin and runtime versions', () => {
expect(evaluatePluginCompatibility(incompatible, exemptions, runtime)?.exempted).toBe(true)
expect(evaluatePluginCompatibility({ ...incompatible, version: '2.0.1' }, exemptions, runtime)?.exempted).toBe(false)
expect(evaluatePluginCompatibility({ ...incompatible, name: '@other/plugin' }, exemptions, runtime)?.exempted).toBe(false)
expect(evaluatePluginCompatibility(incompatible, exemptions, '0.1.7-alpha.2')?.exempted).toBe(false)
expect(evaluatePluginCompatibility(incompatible, exemptions, `${runtime}+build`)?.exempted).toBe(false)
})
it.each(['*', '^0.1.0', '0.1.7', '0.1.7-alpha.2'])('does not interpret exemption value %s as a range', (version) => {
expect(evaluatePluginCompatibility(incompatible, { '@example/[email protected]': [version] }, runtime)?.exempted).toBe(false)
})
it('does not accept inherited exemptions, but accepts own entries on null-prototype maps', () => {
expect(evaluatePluginCompatibility(incompatible, Object.create(exemptions) as typeof exemptions, runtime)?.exempted).toBe(false)
const ownExemptions = Object.assign(Object.create(null) as object, exemptions)
expect(evaluatePluginCompatibility(incompatible, ownExemptions, runtime)?.exempted).toBe(true)
})
it.each(['constructor', 'hasOwnProperty', '__proto__'])('handles plugin identity %s without prototype lookup', (name) => {
const manifest = { ...incompatible, name }
expect(evaluatePluginCompatibility(manifest, {}, runtime)?.exempted).toBe(false)
expect(evaluatePluginCompatibility(manifest, { [`${name}@2.0.0`]: [runtime] }, runtime)?.exempted).toBe(true)
})
it('does not produce issues for compatible exempted plugins', () => {
expect(evaluatePluginCompatibility({ ...identity, peerDependencies: { '@deepseek-ai/dsh': '*' } }, exemptions, runtime)).toBeUndefined()
})
})
describe('compatibility warning', () => {
it.each([false, true])('retains mismatch details and risk when exemption status is %s', (exempted) => {
expect(pluginCompatibilityWarning({
...identity, runtimeVersion: runtime,
peers: { '@deepseek-ai/dsh': '^0.2.0', '@deepseek-ai/dsh-session': 'broken' }, exempted,
})).toBe(
'Plugin @example/[email protected] is incompatible with dsh 0.1.7-alpha.1: '
+ 'peerDependencies {"@deepseek-ai/dsh":"^0.2.0","@deepseek-ai/dsh-session":"broken"}. '
+ 'Running it may cause crashes or data loss. '
+ 'Update the plugin or install a plugin version compatible with this dsh runtime. '
+ 'To accept this risk explicitly, add the exact-version exemption {"@example/[email protected]":["0.1.7-alpha.1"]}. '
+ `Exact-version exemption: ${exempted ? 'active' : 'not active'}.`,
)
})
})
@@ -0,0 +1,132 @@
/** Profile-local compatibility permissions: exact identities, informed grants, and malformed data. */
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it, onTestFinished } from 'vitest'
import { getDshRuntimeVersion } from '../src/index.ts'
import {
PROFILE_COMPATIBILITY_FILENAME, isExactPluginVersion,
readProfileCompatibility, readProfileVersionExemptions, setProfileVersionExemption, validatePluginVersionExemption,
} from '../src/profile-compatibility.ts'
const runtime = getDshRuntimeVersion()
function profileFixture(): string {
const dir = mkdtempSync(join(tmpdir(), 'dsh-compatibility-'))
onTestFinished(() => { rmSync(dir, { recursive: true, force: true }) })
return dir
}
function write(dir: string, value: unknown): void {
writeFileSync(join(dir, PROFILE_COMPATIBILITY_FILENAME), JSON.stringify(value))
}
it('reads no permissions from a profile without a compatibility file', () => {
expect(readProfileVersionExemptions(profileFixture())).toEqual({})
})
it('reports an unreadable permission file instead of failing the profile', () => {
const dir = profileFixture()
mkdirSync(join(dir, PROFILE_COMPATIBILITY_FILENAME))
const read = readProfileCompatibility(dir)
expect(read.exemptions).toEqual({})
expect(read.rewritable).toBe(false)
expect(read.warnings.join('\n')).toMatch(/cannot be read/)
})
it('reads exact plugin and runtime identities, including build metadata', () => {
const dir = profileFixture()
write(dir, { '@scope/[email protected]+build.7': [runtime, '2.0.0-alpha+runtime'] })
expect(readProfileVersionExemptions(dir)).toEqual({ '@scope/[email protected]+build.7': [runtime, '2.0.0-alpha+runtime'] })
})
it.each([
['an array', []], ['a string', 'all'], ['null', null],
['a non-list value', { '[email protected]': '1.2.3' }],
['a non-string version', { '[email protected]': [1] }],
['a range version', { '[email protected]': ['^1.2.3'] }],
['a range key', { 'plugin@^1.2.3': [runtime] }],
['an unversioned key', { plugin: [runtime] }],
])('authorizes nothing for malformed permissions and reports them: %s', (_label, value) => {
const dir = profileFixture()
write(dir, value)
const read = readProfileCompatibility(dir)
// A bad file must never make the profile unusable: it grants nothing, so incompatible plugins stay denied.
expect(read.exemptions).toEqual({})
expect(read.rewritable).toBe(false)
expect(read.warnings).toHaveLength(1)
expect(read.warnings[0]).toContain(PROFILE_COMPATIBILITY_FILENAME)
})
it('keeps the records it accepted and reports only the rejected ones', () => {
const dir = profileFixture()
write(dir, { '[email protected]': [runtime], 'bad@^1.0.0': [runtime], '[email protected]': [] })
const read = readProfileCompatibility(dir)
expect(read.exemptions).toEqual({ '[email protected]': [runtime], '[email protected]': [] })
expect(read.warnings).toHaveLength(1)
expect(read.warnings[0]).toContain('bad@^1.0.0')
expect(read.rewritable).toBe(false)
})
it('accepts an empty permission list as authorizing nothing', () => {
const dir = profileFixture()
write(dir, { '[email protected]': [] })
expect(readProfileVersionExemptions(dir)).toEqual({ '[email protected]': [] })
expect(readProfileCompatibility(dir)).toEqual({ exemptions: { '[email protected]': [] }, warnings: [], rewritable: true })
})
it.each(['1.2.3', '1.2.3-alpha.1', '1.2.3+build.7', '1.2.3-alpha+build'])('accepts exact version %s', (version) => {
expect(isExactPluginVersion(version)).toBe(true)
expect(() => { validatePluginVersionExemption(`@scope/plugin@${version}`, version) }).not.toThrow()
})
it.each([
['plugin', '1.2.3'], ['plugin@^1.2.3', '1.2.3'], ['[email protected]', '1.2.3'],
['[email protected]', '*'], ['[email protected]', ' 1.2.3'], ['../[email protected]', '1.2.3'],
])('rejects noncanonical identity %s / %s', (plugin, version) => {
expect(() => { validatePluginVersionExemption(plugin, version) }).toThrow('exact')
})
it('requires explicit risk acknowledgement and the running runtime for a grant', async () => {
const dir = profileFixture()
await expect(setProfileVersionExemption(dir, '[email protected]', runtime, true, false)).rejects.toThrow('crashes or data loss')
await expect(setProfileVersionExemption(dir, '[email protected]', '9.9.9', true, true)).rejects.toThrow(`runs DSH ${runtime}`)
expect(readProfileVersionExemptions(dir)).toEqual({})
})
it('persists grants with owner-only permissions and revokes one runtime without losing the others', async () => {
const dir = profileFixture()
await setProfileVersionExemption(dir, '[email protected]', runtime, true, true)
await setProfileVersionExemption(dir, '[email protected]', runtime, true, true)
const filename = join(dir, PROFILE_COMPATIBILITY_FILENAME)
expect(readProfileVersionExemptions(dir)).toEqual({ '[email protected]': [runtime], '[email protected]': [runtime] })
expect(JSON.parse(readFileSync(filename, 'utf8'))).toEqual({ '[email protected]': [runtime], '[email protected]': [runtime] })
if (process.platform !== 'win32') expect(statSync(filename).mode & 0o777).toBe(0o600)
// A repeated grant never duplicates, and a grant left by an older runtime can still be revoked.
await setProfileVersionExemption(dir, '[email protected]', runtime, true, true)
expect(readProfileVersionExemptions(dir)['[email protected]']).toEqual([runtime])
write(dir, { '[email protected]': [runtime, '0.0.1'], '[email protected]': [runtime] })
await setProfileVersionExemption(dir, '[email protected]', '0.0.1', false, false)
expect(readProfileVersionExemptions(dir)).toEqual({ '[email protected]': [runtime], '[email protected]': [runtime] })
await setProfileVersionExemption(dir, '[email protected]', runtime, false, false)
expect(readProfileVersionExemptions(dir)).toEqual({ '[email protected]': [runtime] })
})
it('creates the profile directory for a first grant and refuses to widen malformed permissions', async () => {
const dir = profileFixture()
const nested = join(dir, 'profiles', 'test')
await setProfileVersionExemption(nested, '[email protected]', runtime, true, true)
expect(readProfileVersionExemptions(nested)).toEqual({ '[email protected]': [runtime] })
write(nested, { 'plugin@*': [runtime] })
// Rewriting a file the reader rejected would discard the user's content, so a write refuses.
await expect(setProfileVersionExemption(nested, '[email protected]', runtime, true, true)).rejects.toThrow(/must be repaired/)
expect(readFileSync(join(nested, PROFILE_COMPATIBILITY_FILENAME), 'utf8')).toContain('plugin@*')
})
it('still revokes an accepted grant while another record is rejected', async () => {
const dir = profileFixture()
await setProfileVersionExemption(dir, '[email protected]', runtime, true, true)
write(dir, { '[email protected]': [runtime], '[email protected]': 'not-a-list' })
await expect(setProfileVersionExemption(dir, '[email protected]', runtime, false, false)).rejects.toThrow(/must be repaired/)
})
+3
View File
@@ -31,6 +31,9 @@
},
{
"path": "../../util/package-manifest"
},
{
"path": "../../util/atomic-write"
}
]
}
@@ -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/boot/plugin-manager/README.md
README.md: 24936df8b1be4e2997c2382e0d84fddd2dfc5345
README.zh.md: 122ccc9a2796caf891b19a38c3c90e9d32a22fdc
README.md: 4977a59a1a3a313580cb2ddad8c9054c36da7af9
README.zh.md: fcf4b6dc69689a6c71631bfd51ca3cdba5147f9e
+11
View File
@@ -57,6 +57,17 @@ The browser-safe `@deepseek-ai/dsh-plugin-manager/registry` entry exports `OFFIC
When pnpm 11 blocks dependency scripts, the failed installation reports every pending package name in the profile under `pendingBuilds`, including names left by earlier attempts; a failed run restores `package.json` and `pnpm-lock.yaml` but deliberately not `pnpm-workspace.yaml`, where pnpm records them. The Web plugin page offers **Allow these scripts and retry**; the tool can grant permission on the user's behalf through `approvedBuilds` on `install_bundle`, after the user approves those scripts in the conversation. The service validates pending names; it does not verify conversation approval. Approval persists by package name in this profile, permits commands with the host user's permissions, and survives another installation failure. Only currently undecided names can be approved; existing denials and wildcard rules cannot be overridden through this action. Approval rejects YAML anchors or aliases inside `allowBuilds`. Retry preserves the original activation choice.
<a id="version-compatibility-and-exemptions"></a>
### Version compatibility and exemptions
An install command that names packages (`add`, or `install` with specs) is checked before pnpm runs: a local path is read from its own `package.json`, and a registry spec is resolved through pnpm's registry lookup for the version its range selects and the peers that version declares. An incompatible DSH peer rejects the operation before pnpm runs, so nothing is downloaded and no build script runs; a build approval the caller supplied with the request is recorded before this check and remains. A git or tarball spec needs the fetch itself, so it is judged after installation: the operation then restores the profile manifest and lockfile, reinstalls the restored lockfile, and reports whether that recovery succeeded; already permitted build-script effects may remain. A dependency the run did not change never blocks an unrelated operation: it stays installed, the run reports a warning naming it, and profile startup denies it. Installation requests with `enabled: false` are checked the same way. Startup checks run independently; see [App boot](../app-boot/README.md#profiles) for range semantics. Version exemptions do not authorize dependency scripts.
An exemption is an exact `package-name@version` mapped to a list of exact DSH runtime versions in the profile's own `compatibility.json`, beside `package.json` and `cordis.patch.yml`. Writing it changes no dependency, no bundle selection, and no patch layer. Neither plugin upgrades nor DSH upgrades inherit permission. Use `plugin_manager` with `list_version_exemptions` to obtain the runtime version and saved grants, then `set_version_exemption` with `target`, `runtimeVersion`, and `enabled`. Granting also requires `acceptRisk: true`, only after warning the user that incompatible plugins may cause crashes or data loss and receiving explicit permission for that pair. The service checks the acknowledgement and versions, not conversation history. Revocation can remove historical runtime grants.
A grant takes effect on the next composition. A live profile recomposes, so the granted plugin mounts in the running session and the result reports `applied`; a startup-only profile keeps its current entries until restart and reports `restart-required`.
The CLI exposes `dsh plugin --profile <profile> version-exemptions`, `allow-version <package@version> --dsh-version <runtime> --accept-risk`, and `revoke-version <package@version> --dsh-version <runtime>`. A grant prints a risk warning before saving. The Web page reports compatibility failures; use the tool or CLI to grant an exemption and retry the original operation.
### Configuration
| Field | Default | Meaning |
+11
View File
@@ -57,6 +57,17 @@ kind: "package-reference"
pnpm 11 拦下依赖脚本时,失败的安装在 `pendingBuilds` 里报告 profile 中所有待决定的包名,包括先前尝试留下的;失败的运行会恢复 `package.json` 与 `pnpm-lock.yaml`,但有意不恢复 pnpm 记录这些名字的 `pnpm-workspace.yaml`。Web 插件页提供**允许这些脚本并重试**;工具可以在用户于对话中批准这些脚本后,通过 `install_bundle` 的 `approvedBuilds` 代为授权。服务只校验待决定的名字,不核实对话中的批准。授权按包名保存在当前 profile,允许以宿主用户的权限执行命令,并在再次安装失败后保留。只能批准当前未决定的名字;已有的拒绝与通配规则不能通过此操作覆盖。`allowBuilds` 里出现 YAML 锚点或别名时拒绝授权。重试保留原来的启用选择。
<a id="version-compatibility-and-exemptions"></a>
### 版本兼容性与豁免
点名软件包的安装命令(`add`,或带 spec 的 `install`)会在 pnpm 运行前完成检查:本地路径直接读取其 `package.json`,registry spec 通过 pnpm 的 registry 查询得到该范围选中的版本及其声明的 peer。不兼容的 DSH peer 会在 pnpm 运行前使操作失败,因此不会下载任何内容、不会运行构建脚本;调用方随请求提交的构建批准在此检查之前记录,会保留下来。git 或 tarball spec 必须先抓取,因此在安装后才判定:此时操作会恢复 profile 清单与锁文件,并按恢复后的锁文件重新安装,同时报告该恢复是否成功;已获准构建脚本的副作用可能保留。本次运行未改动的依赖不会阻塞无关操作:它保持已安装状态,运行会输出点名它的警告,由 profile 启动拒绝加载。请求 `enabled: false` 的安装同样受检。启动检查独立执行;版本范围语义见 [App boot](../app-boot/README.zh.md#profiles)。版本豁免不授权依赖脚本。
豁免保存在 profile 自己的 `compatibility.json` 中(与 `package.json`、`cordis.patch.yml` 并列),将精确的 `package-name@version` 映射到精确 DSH 运行时版本列表。写豁免不改变依赖、组合包选择或 patch 层。插件升级和 DSH 升级都不继承授权。使用 `plugin_manager` 的 `list_version_exemptions` 获取运行时版本与已有授权,再通过 `set_version_exemption` 提交 `target`、`runtimeVersion` 和 `enabled`。授权还要求 `acceptRisk: true`;只能在警告用户不兼容插件可能导致崩溃或数据丢失,并获得用户对此版本组合的明确许可后传入。服务校验确认参数和版本,不核实对话历史。撤销可以移除历史运行时版本的授权。
授权在下一次组合时生效。在线 profile 会重新组合,被授权的插件会在当前会话中挂载,结果报告 `applied`;仅启动型 profile 在重启前保留当前条目并报告 `restart-required`。
CLI 提供 `dsh plugin --profile <profile> version-exemptions`、`allow-version <package@version> --dsh-version <runtime> --accept-risk` 和 `revoke-version <package@version> --dsh-version <runtime>`。授权会在保存前打印风险警告。Web 页面报告兼容性失败;通过工具或 CLI 添加豁免后,重试原操作。
### 配置
| 字段 | 默认值 | 含义 |
+42 -4
View File
@@ -14,6 +14,8 @@ import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin
import {
readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries,
reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES, bundlePatchPaths,
evaluatePluginCompatibility, pluginCompatibilityWarning, readProfileCompatibility, readProfileVersionExemptions,
setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME,
} from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-hmr'
import type { ProfileContext, ProfileManifest } from '@deepseek-ai/dsh-app-boot'
@@ -221,6 +223,31 @@ export class PluginManager extends TypertRemoteService {
}, 'plugin-manager: package cancellation')
}
/** Read exact plugin-version exemptions saved in this profile.
* @returns Accepted package-name@version keys with the runtime versions they may run on, and any
* record or file problem the reader rejected, which the caller reports instead of failing.
*/
@Remote
listVersionExemptions(): { exemptions: Record<string, string[]>; warnings: string[] } {
const { exemptions, warnings } = readProfileCompatibility(this.profile.dir)
return { exemptions, warnings }
}
/** Grant or revoke one exact plugin/runtime exemption and reevaluate live plugins.
* @param packageVersion Exact manifest package name followed by @ and its version; never an installation spec or alias.
* @param runtimeVersion Exact current DSH version for grants; revocation may name a previous runtime.
* @param enabled Whether to grant rather than revoke the exemption.
* @param acceptRisk Required true for grants after the user accepts possible crashes and data loss.
* @returns Saved and runtime outcomes. Startup-only profiles require restart.
*/
@Remote
setVersionExemption(packageVersion: string, runtimeVersion: string, enabled: boolean, acceptRisk?: boolean): Promise<ChangeResult> {
return this.change(result => this.configure(async () => {
await setProfileVersionExemption(this.profile.dir, packageVersion, runtimeVersion, enabled, acceptRisk === true)
result.warnings = await this.reload()
}), { stage: 'enable', target: packageVersion, enabled }, 'bundle')
}
/** Read current plugins, including why a row cannot be changed through the profile patch.
* @returns Current runtime entries with persistent patch targets.
*/
@@ -251,6 +278,7 @@ export class PluginManager extends TypertRemoteService {
@Remote
listBundles(): Promise<BundleInfo[]> {
const manifest = readProfileManifest('dsh', this.profile.dir)
const exemptions = readProfileVersionExemptions(this.profile.dir)
const selected = manifest.dsh?.profile?.bundles ?? []
const dependencies = Object.keys(manifest.dependencies ?? {})
const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as InstallationManifest
@@ -269,6 +297,8 @@ export class PluginManager extends TypertRemoteService {
...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: { code: 'not-bundle' }, rows: [], overrides: [] })
continue
}
const compatibility = evaluatePluginCompatibility(info, exemptions)
if (compatibility !== undefined && !compatibility.exempted) throw new Error(pluginCompatibilityWarning(compatibility))
const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir)
const meta = readPluginMeta(info.name ?? name, pathToFileURL(join(dir, 'package.json')).href)
bundles.push({ name, ...(info.version === undefined ? {} : { version: info.version }),
@@ -510,6 +540,8 @@ export class PluginManager extends TypertRemoteService {
const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir)
const manifest = bundleManifest(name, this.profile.dir, this.profile.installAnchor)
if (manifest?.dsh?.bundle === undefined) throw new ManagementFailure('not-bundle')
const compatibility = evaluatePluginCompatibility(manifest, readProfileVersionExemptions(this.profile.dir))
if (compatibility !== undefined && !compatibility.exempted) throw new Error(pluginCompatibilityWarning(compatibility))
for (const file of bundlePatchPaths(dir, manifest.dsh.bundle)) loadOverlayPatches('dsh', file)
} catch (error) {
// pnpm has exited by now, so the files it rewrote go back as they were.
@@ -634,6 +666,7 @@ export class PluginManager extends TypertRemoteService {
execution: 'service', ...this.profile.packageManager ?? { command: this.pnpmCommand },
signal: signal === undefined ? this.abort.signal : AbortSignal.any([this.abort.signal, signal]),
outputBytes: this.outputBytes, activateNewBundles: false, idleTimeoutMs: this.idleTimeoutMs,
lookupTimeoutMs: this.inspectTimeoutMs,
onOutput: (text, stream) => {
this.ownerContext.emit('plugin-manager/install-log', { ...identity, jobId, argv, cwd, stream, text })
},
@@ -679,10 +712,15 @@ export class PluginManager extends TypertRemoteService {
private async selectBundle(name: string, enabled: boolean): Promise<void> {
const manifest = readProfileManifest('dsh', this.profile.dir)
const previous = manifest.dsh?.profile?.bundles ?? []
if ((enabled || !previous.includes(name)) && bundleManifest(name, this.profile.dir, this.profile.installAnchor) === undefined) {
throw new ManagementFailure('not-bundle')
if (enabled || !previous.includes(name)) {
const metadata = bundleManifest(name, this.profile.dir, this.profile.installAnchor)
if (metadata === undefined) throw new ManagementFailure('not-bundle')
if (enabled) {
const compatibility = evaluatePluginCompatibility(metadata, readProfileVersionExemptions(this.profile.dir))
if (compatibility !== undefined && !compatibility.exempted) throw new Error(pluginCompatibilityWarning(compatibility))
this.bundleRows(name)
}
}
if (enabled) this.bundleRows(name)
if (!enabled && previous.includes(name)) {
if (this.protectsManager(name)) throw new ManagementFailure('management-required')
}
@@ -750,7 +788,7 @@ export class PluginManager extends TypertRemoteService {
}
private diskState(): string {
return ['package.json', 'cordis.patch.yml', 'pnpm-workspace.yaml'].map((file) => {
return ['package.json', 'cordis.patch.yml', 'pnpm-workspace.yaml', PROFILE_COMPATIBILITY_FILENAME].map((file) => {
try { return readFileSync(join(this.profile.dir, file), 'utf8') }
catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''
+196 -19
View File
@@ -1,17 +1,21 @@
/** Shared profile package operations used by dsh plugin and the running manager. */
import { once } from 'node:events'
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, open } from 'node:fs/promises'
import { existsSync, readFileSync } from 'node:fs'
import { mkdir, mkdtemp, open, rm } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { execa } from 'execa'
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import {
DEFAULT_PROFILE_BUNDLES, bundlePatchPaths, initProfile, PROFILE_TEMPLATES, readProfileManifest,
resolveBundleDir, resolveProfileDir, loadOverlayPatches, type ProfileManifest,
resolveBundleDir, resolveProfileDir, loadOverlayPatches, composeEntries, readProfileVersionExemptions,
evaluatePluginCompatibility, pluginCompatibilityWarning, type ProfileManifest,
} from '@deepseek-ai/dsh-app-boot'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import { parseInstallSpec } from './install-spec.ts'
import { awaitTreeGone, leadsOwnGroup } from './run-tree.ts'
import type { PackageResult, Registry } from './types.ts'
export { setProfileVersionExemption, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot'
/** Profile and invocation locations supplied by the launcher. */
export interface PackageOperationContext {
@@ -45,6 +49,8 @@ export interface PackageOperationOptions {
* with inherited descriptors captures nothing and is never bound.
*/
idleTimeoutMs?: number
/** Bound on the pre-install registry lookup, in milliseconds; without one a fixed bound applies. */
lookupTimeoutMs?: number
}
/** Resolve relative package specs against the caller's directory.
@@ -132,18 +138,112 @@ async function drainWithin(collectors: readonly Promise<void>[], ms: number): Pr
}
}
/** Install commands that take the packages to install as positionals. */
const INSTALL_COMMANDS = new Set(['add', 'install', 'i'])
/** Bound on a pre-install registry lookup when the caller names none. */
const LOOKUP_TIMEOUT_MS = 20_000
/** Package specs an install command names explicitly, in order. */
function namedSpecs(args: readonly string[]): string[] {
const index = args.findIndex(argument => !argument.startsWith('-'))
const command = index < 0 ? undefined : args[index]
if (command === undefined || !INSTALL_COMMANDS.has(command)) return []
return args.slice(index + 1).filter(argument => !argument.startsWith('-'))
}
/** The manifest a named spec would install, read without installing it.
* A path spec is read from disk. A registry spec asks pnpm's own configuration for the version the
* range selects and its peer requirements. A git or tarball spec needs the fetch itself, so the
* check after installation is what judges it.
* @param dir Profile directory the lookup runs in.
* @param spec Anchored install spec.
* @param options Pnpm executable, prefix arguments, the caller's bound and signal.
* @param environment Environment of the caller's pnpm invocations.
* @param flags Flags of the run itself, so the lookup asks the registry that run will use.
* @returns The package manifest, or undefined when reading it would need the installation itself.
*/
async function namedSpecManifest(
dir: string, spec: string, options: PackageOperationOptions, environment: Readonly<Record<string, string | undefined>>,
flags: readonly string[],
): Promise<object | undefined> {
const parsed = parseInstallSpec(spec)
if (parsed.kind === 'path') {
const filename = join(parsed.path, 'package.json')
return existsSync(filename) ? JSON.parse(readFileSync(filename, 'utf8')) as object : undefined
}
if (parsed.kind !== 'registry') return undefined
const viewed = await execa(options.command ?? 'pnpm', [
...options.args ?? [], 'view', parsed.spec, 'name', 'version', 'peerDependencies', '--json',
...flags, '--config.fetch-retries=0',
], {
cwd: dir, env: environment, extendEnv: false, reject: false, stdin: 'ignore',
...options.signal === undefined ? {} : { cancelSignal: options.signal },
timeout: options.lookupTimeoutMs ?? LOOKUP_TIMEOUT_MS,
})
if (viewed.exitCode !== 0) return undefined
const value: unknown = JSON.parse(viewed.stdout)
return (Array.isArray(value) ? value.at(-1) : value) as object
}
/** Missing installed packages are repairable; their absence is part of the before/after comparison. */
function optionalFile(path: string): string | undefined {
try { return readFileSync(path, 'utf8') } catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
throw error
}
}
/** pnpm can install plugins through any direct-dependency field. */
function directDependencies(manifest: ProfileManifest): Record<string, string> {
const extra = manifest as ProfileManifest & { devDependencies?: Record<string, string>; optionalDependencies?: Record<string, string> }
return { ...extra.devDependencies, ...manifest.dependencies, ...extra.optionalDependencies }
}
/** Inspect only plugin rows contributed by the changed bundle, not its dependency closure. */
function bundleComponentManifests(manifest: ProfileManifest, dir: string, anchor: string): ProfileManifest[] {
const bundle = manifest.dsh?.bundle
if (bundle === undefined) return []
const patches = bundlePatchPaths(dir, bundle).flatMap(file => loadOverlayPatches('dsh', file))
const names = new Set<string>()
const visit = (rows: EntryOptions[]) => {
for (const row of rows) {
if (row.group && Array.isArray(row.config)) visit(row.config as EntryOptions[])
if (typeof row.name !== 'string' || row.name.startsWith('.') || row.name.startsWith('/') || row.name.includes(':')) continue
const parts = row.name.split('/')
names.add(parts.slice(0, row.name.startsWith('@') ? 2 : 1).join('/'))
}
}
visit(composeEntries([patches.filter(patch => patch.insert !== undefined)]))
return [...names].flatMap((name) => {
let packageDir: string
try { packageDir = resolveBundleDir('dsh', name, anchor, dir) } catch (error) {
// Resolution errors for uninstalled or dynamic rows remain subject to the startup loader's checks.
void error
return []
}
return [readProfileManifest('dsh', packageDir)]
})
}
/** Execute pnpm inside a profile whose caller already holds the profile write lock.
* Newly installed or updated direct dependencies are checked even when activation is disabled;
* an untouched dependency never blocks an unrelated operation and stays denied at startup.
* Compatibility denial restores the profile manifest and lockfile, but leaves downloaded modules on disk.
* @param context Launcher-owned profile and resolution locations.
* @param args Pnpm arguments, before relative path anchoring.
* @param options Output, activation and cancellation policy.
* @returns Exit status, whether the silence bound stopped the run, and the diagnostic path.
* Service output is bounded; CLI output uses inherited descriptors.
* A compatibility denial returns exit code 1. Service output is bounded; CLI output uses inherited descriptors.
*/
export async function runProfilePnpm(
context: PackageOperationContext, args: readonly string[], options: PackageOperationOptions,
): Promise<PackageResult> {
const dir = context.dir ?? resolveProfileDir(context.profile, context.home)
const before = readProfileManifest('dsh', dir)
const savedFiles = ['package.json', 'pnpm-lock.yaml'].map(name => ({ path: join(dir, name), text: optionalFile(join(dir, name)) }))
const beforeDependencies = directDependencies(before)
const installedBefore = new Map(Object.keys(beforeDependencies).map(name => [name, optionalFile(join(dir, 'node_modules', name, 'package.json'))]))
const logRoot = join(dir, '.plugin-manager', 'logs')
await mkdir(logRoot, { recursive: true, mode: 0o700 })
const logDir = await mkdtemp(join(logRoot, 'operation-'))
@@ -151,19 +251,6 @@ export async function runProfilePnpm(
const log = await open(logPath, 'wx', 0o600)
let output = Buffer.alloc(0)
let truncated = false
const cancellation = new AbortController()
// A service run captures output, so execa terminates the tree it leads when the
// run is killed: a lifecycle script outlives the pnpm process that started it.
// The CLI keeps the caller's process group, so an interrupt still reaches it.
const grouped = leadsOwnGroup(options.execution)
const child = execa(options.command ?? 'pnpm', [...options.args ?? [], ...args.map(arg => anchorPathSpec(arg, context.cwd))], {
cwd: dir, env: { ...(options.execution === 'cli' ? process.env : scrubbedParentEnv()), ...options.env }, extendEnv: false, reject: false,
stdout: options.execution === 'cli' ? 'inherit' : 'pipe',
stderr: options.execution === 'cli' ? 'inherit' : 'pipe',
killDescendants: options.execution === 'service',
buffer: false, stdin: options.execution === 'cli' ? 'inherit' : 'ignore', cancelSignal: options.signal === undefined
? cancellation.signal : AbortSignal.any([cancellation.signal, options.signal]),
})
const append = (bytes: Buffer): void => {
output = Buffer.concat([output, bytes])
if (output.length > options.outputBytes) {
@@ -171,6 +258,51 @@ export async function runProfilePnpm(
output = output.subarray(output.length - options.outputBytes)
}
}
const environment = { ...(options.execution === 'cli' ? process.env : scrubbedParentEnv()), ...options.env }
const restore = async (): Promise<void> => {
for (const file of savedFiles) {
if (file.text === undefined) await rm(file.path, { force: true })
else await writeFileAtomic(file.path, file.text, { mode: 0o600 })
}
}
const rejected = async (warnings: readonly string[], restoration: string): Promise<PackageResult> => {
const diagnostic = `\ndsh: installation rejected: ${warnings.join('\n')}\ndsh: ${restoration}.\n`
await log.write(diagnostic)
options.onOutput?.(diagnostic, 'stderr')
append(Buffer.from(diagnostic))
await log.close()
return { exitCode: 1, output: output.toString('utf8'), truncated, logPath }
}
// An install command names the packages it adds, so their manifests are read and checked before
// pnpm runs: an incompatible version is never installed, and the one already in use keeps working.
const preflight: string[] = []
const exemptions = readProfileVersionExemptions(dir)
// The run's own registry flags, so the lookup asks the registry the installation will use.
const registryFlags = args.filter(argument => argument.startsWith('--registry='))
for (const raw of namedSpecs(args)) {
// A spec whose manifest cannot be read or validated is left to the run itself and to the check
// after installation, which reports what it could not validate.
try {
const manifest = await namedSpecManifest(dir, anchorPathSpec(raw, context.cwd), options, environment, registryFlags)
if (manifest === undefined) continue
const issue = evaluatePluginCompatibility(manifest, exemptions)
if (issue !== undefined && !issue.exempted) preflight.push(pluginCompatibilityWarning(issue))
} catch (error) { void error; continue }
}
if (preflight.length > 0) return rejected(preflight, 'nothing was installed')
const cancellation = new AbortController()
// A service run captures output, so execa terminates the tree it leads when the
// run is killed: a lifecycle script outlives the pnpm process that started it.
// The CLI keeps the caller's process group, so an interrupt still reaches it.
const grouped = leadsOwnGroup(options.execution)
const child = execa(options.command ?? 'pnpm', [...options.args ?? [], ...args.map(arg => anchorPathSpec(arg, context.cwd))], {
cwd: dir, env: environment, extendEnv: false, reject: false,
stdout: options.execution === 'cli' ? 'inherit' : 'pipe',
stderr: options.execution === 'cli' ? 'inherit' : 'pipe',
killDescendants: options.execution === 'service',
buffer: false, stdin: options.execution === 'cli' ? 'inherit' : 'ignore', cancelSignal: options.signal === undefined
? cancellation.signal : AbortSignal.any([cancellation.signal, options.signal]),
})
let writes = Promise.resolve()
/** `settled` records that the process outcome is known; `stalled` that the silence bound stopped the run. */
const control = { settled: false, stalled: false }
@@ -268,8 +400,53 @@ export async function runProfilePnpm(
output = Buffer.from(diagnostic).subarray(0, options.outputBytes)
}
// A terminated run's exit status says nothing about what it wrote, so it never reconciles the selection.
if (exitCode === 0 && !control.stalled && options.activateNewBundles !== false) {
await reconcile(before, dir, context.installAnchor, options)
if (exitCode === 0 && !control.stalled) {
const after = readProfileManifest('dsh', dir)
const warnings: string[] = []
for (const [name, spec] of Object.entries(directDependencies(after))) {
const packageDir = join(dir, 'node_modules', name)
const installed = optionalFile(join(packageDir, 'package.json'))
if (installed === undefined) continue
// A dependency this run did not touch never blocks an unrelated operation; profile startup denies it.
const untouched = beforeDependencies[name] === spec && installedBefore.get(name) === installed
const found: string[] = []
try {
const manifest = readProfileManifest('dsh', packageDir)
for (const candidate of [manifest, ...bundleComponentManifests(manifest, packageDir, context.installAnchor)]) {
const issue = evaluatePluginCompatibility(candidate, readProfileVersionExemptions(dir))
if (issue !== undefined && !issue.exempted) found.push(pluginCompatibilityWarning(issue))
}
} catch (error) {
found.push(`Cannot validate installed package ${name}: ${String(error)}`)
}
if (found.length === 0) continue
if (!untouched) warnings.push(...found)
else {
const notice = `\ndsh: warning: ${found.join('\n')}\ndsh: it stays installed but profile startup denies it until you grant an exemption for those exact versions.\n`
await log.write(notice)
options.onOutput?.(notice, 'stderr')
}
}
if (warnings.length > 0) {
// A bundle component's peers need installed contents, so this rejection lands after pnpm
// replaced the tree: restore the files, then reinstall the restored lockfile so the version
// that worked before this run keeps loading.
await restore()
const repaired = await execa(options.command ?? 'pnpm', [...options.args ?? [], 'install', '--frozen-lockfile'], {
cwd: dir, env: environment, extendEnv: false, reject: false, stdin: 'ignore',
...options.idleTimeoutMs === undefined ? {} : { timeout: options.idleTimeoutMs },
})
exitCode = 1
const restoration = repaired.exitCode === 0
? 'restored package.json, pnpm-lock.yaml, and node_modules'
: "restored package.json and pnpm-lock.yaml, but node_modules could not be reinstalled; run 'dsh plugin install'"
const diagnostic = `\ndsh: installation rejected: ${warnings.join('\n')}\ndsh: ${restoration}.\n`
await log.write(diagnostic)
options.onOutput?.(diagnostic, 'stderr')
append(Buffer.from(diagnostic))
} else if (options.activateNewBundles !== false) {
await reconcile(before, dir, context.installAnchor, options)
}
}
} finally {
control.settled = true
+13 -3
View File
@@ -7,6 +7,7 @@ import type {} from '@deepseek-ai/dsh-user-approval'
import { approveEscalation } from '@deepseek-ai/dsh-sandbox'
import type { PluginEntryId } from './types.ts'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { getDshRuntimeVersion } from '@deepseek-ai/dsh-app-boot'
/** Required services for the management tool. */
export const inject = ['tools', 'pluginManager', 'sandboxPolicy']
@@ -17,11 +18,13 @@ export const inject = ['tools', 'pluginManager', 'sandboxPolicy']
export function apply(ctx: Context): void {
ctx.tools.register(defineTool({
name: 'plugin_manager',
description: 'List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart.',
description: 'List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart. Incompatible DSH peer dependencies block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.',
parameters: {
action: { type: 'string', required: true, enum: ['list_plugins', 'list_bundles', 'set_plugin', 'set_bundle', 'install_bundle', 'remove_bundle'], description: 'Management operation.' },
action: { type: 'string', required: true, enum: ['list_plugins', 'list_bundles', 'set_plugin', 'set_bundle', 'install_bundle', 'remove_bundle', 'list_version_exemptions', 'set_version_exemption'], description: 'Management operation.' },
target: { type: 'string', description: 'Plugin entry id, bundle package name, or installation spec, according to action.' },
enabled: { type: 'boolean', description: 'Required for set operations; defaults to true for installation.' },
enabled: { type: 'boolean', description: 'Required for set operations; defaults to true for installation. For set_version_exemption, true grants and false revokes.' },
runtimeVersion: { type: 'string', description: 'For set_version_exemption: exact DSH version from list_version_exemptions. Target must be the manifest package-name@version, not an alias or version range.' },
acceptRisk: { type: 'boolean', description: 'For granting an exemption: true only after warning the user about possible crashes and data loss and receiving explicit permission for this exact plugin/runtime pair. General installation permission is not enough.' },
approvedBuilds: { type: 'array', items: { type: 'string' }, description: 'For install_bundle: pass names from pendingBuilds only after the user explicitly approves running their install scripts in the conversation. This grants persistent permission for this profile.' },
registry: { type: 'string', description: 'For install_bundle: the npm registry URL asked first, when the user names one; otherwise the configured registry is asked, and its configured fallbacks while a registry is unreachable.' },
offset: { type: 'number', description: 'Zero-based list offset; defaults to 0.' },
@@ -41,6 +44,13 @@ export function apply(ctx: Context): void {
exec.signal.throwIfAborted()
const manager = ctx.pluginManager
switch (args.action) {
case 'list_version_exemptions':
return JSON.stringify({ runtimeVersion: getDshRuntimeVersion(), ...manager.listVersionExemptions() })
case 'set_version_exemption':
if (args.target === undefined || args.runtimeVersion === undefined || args.enabled === undefined) {
throw new Error('target package-name@version, runtimeVersion, and enabled are required')
}
return JSON.stringify(await manager.setVersionExemption(args.target, args.runtimeVersion, args.enabled, args.acceptRisk))
case 'list_plugins':
case 'list_bundles': {
const offset = args.offset ?? 0
@@ -12,7 +12,7 @@ import type { Context } from '@deepseek-ai/cordis'
import { expect, it, onTestFinished, vi } from 'vitest'
import {
boot, composeEntries, initProfile, loadProfileDirectory, readProfilePatches, readProfileManifest,
reconcileProfilePatches, OPTIONAL_BUNDLES, PluginPackages, readPluginMeta,
reconcileProfilePatches, OPTIONAL_BUNDLES, PluginPackages, readPluginMeta, getDshRuntimeVersion,
type ProfileContext, type RuntimeResolution,
} from '@deepseek-ai/dsh-app-boot'
import PluginManager, { type Config, type PluginChange, type PluginInstallLogChunk, type PluginInstallProgress, type PluginInstallRequestId } from '../src/index.ts'
@@ -430,6 +430,7 @@ it('retains installed dependencies when toggling a bundle and appends it when re
bundle('third', [])
await manager.setBundleEnabled('third', true)
expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: true, application: 'applied' })
expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: false, application: 'applied' })
expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' })
expect((await manager.listPlugins()).some(row => row.patchId === 'managed')).toBe(false)
await manager.setBundleEnabled('extra', true)
@@ -1322,3 +1323,59 @@ it('answers the configured registries in pnpm\'s comparison form with what pnpm
expect(() => PluginManager.Config({ registry: 'npm.corp.example' })).toThrow()
expect(() => PluginManager.Config({ fallbackRegistries: ['ftp://npm.corp.example/'] })).toThrow()
})
it.each(['live', 'startup'] as const)('requires exact risk acknowledgement and preserves exemptions in a %s profile', async (mode) => {
const { manager, dir, ctx } = await fixture(mode, false, undefined, {}, undefined, (dir) => {
const file = join(dir, 'node_modules', 'extra', 'package.json')
const metadata = JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
writeFileSync(file, JSON.stringify({ ...metadata, peerDependencies: { '@deepseek-ai/dsh': '999.0.0' } }))
})
const runtime = getDshRuntimeVersion()
const managed = () => [...ctx.loader.entries()].find(entry => entry.id === 'include:managed')
// Admission disables the incompatible row where it is composed: the Loader keeps its entry and gives it no fiber.
expect(managed()?.disabled).toBe(true)
expect((await manager.listBundles()).find(bundle => bundle.name === 'extra')?.error?.diagnostic).toContain('crashes or data loss')
expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ changed: false, application: 'failed' })
expect(await manager.setVersionExemption('[email protected]', runtime, true)).toMatchObject({ changed: false, application: 'failed' })
expect(manager.listVersionExemptions()).toEqual({ exemptions: {}, warnings: [] })
expect(existsSync(join(dir, 'compatibility.json'))).toBe(false)
expect(await manager.setVersionExemption('[email protected]', runtime, true, true)).toMatchObject({
changed: true, application: mode === 'live' ? 'applied' : 'restart-required',
})
expect(manager.listVersionExemptions()).toEqual({ exemptions: { '[email protected]': [runtime] }, warnings: [] })
// The grant is persisted only in the profile's compatibility file, never in its package manifest.
expect(JSON.parse(readFileSync(join(dir, 'compatibility.json'), 'utf8'))).toEqual({ '[email protected]': [runtime] })
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra'])
// `setVersionExemption` reconciles the profile itself, so a live tree re-admits the row here;
// no manifest watch or Loader instrumentation participates. A startup-only profile keeps it disabled until restart.
if (mode === 'live') expect(managed()?.fiber?.state).toBe(2)
else expect(managed()?.disabled).toBe(true)
expect(await manager.setVersionExemption('[email protected]', runtime, false)).toMatchObject({ changed: true })
expect(manager.listVersionExemptions()).toEqual({ exemptions: {}, warnings: [] })
expect(JSON.parse(readFileSync(join(dir, 'compatibility.json'), 'utf8'))).toEqual({})
if (mode === 'live') expect(managed()?.disabled).toBe(true)
})
it.each([false, true])('rechecks installed bundle peers before accepting a disabled installation (exempted=%s)', async (exempted) => {
const { manager, dir, bundle } = await fixture()
if (exempted) await manager.setVersionExemption('[email protected]', getDshRuntimeVersion(), true, true)
const before = readFileSync(join(dir, 'package.json'), 'utf8')
const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
bundle('incompatible', [])
const file = join(dir, 'node_modules', 'incompatible', 'package.json')
const metadata = JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
writeFileSync(file, JSON.stringify({ ...metadata, peerDependencies: { '@deepseek-ai/dsh': '<0.0.0' } }))
const profile = readProfileManifest('test', dir)
profile.dependencies = { ...profile.dependencies, incompatible: '1.0.0' }
writeFileSync(join(dir, 'package.json'), JSON.stringify(profile))
return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
})
onTestFinished(() => { install.mockRestore() })
const result = await manager.installBundle('incompatible', { enabled: false })
expect(result).toMatchObject({ application: exempted ? 'applied' : 'failed', changed: exempted })
if (!exempted) {
expect(result.error?.diagnostic).toContain('[email protected]')
expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before)
}
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra'])
})
@@ -111,7 +111,7 @@ function fixture(child: string, descendant: string) {
describe.skipIf(process.platform === 'win32')('a run with descendants', () => {
it('settles a run whose pipes a descendant keeps open', async () => {
const { context, pidFile, options } = fixture(DRAIN_CHILD, HOLDER_SCRIPT)
const outcome = await runProfilePnpm(context, ['add', 'held'], options)
const outcome = await runProfilePnpm(context, ['add', './held'], options)
const descendant = await readPid(pidFile)
expect(outcome).toMatchObject({ exitCode: 0 })
expect(outcome.output).toContain('dsh: pnpm output was cut short after its process exited')
@@ -121,7 +121,7 @@ describe.skipIf(process.platform === 'win32')('a run with descendants', () => {
it('surfaces an output consumer failure that the bounded drain cuts short', async () => {
const { context, options } = fixture(DRAIN_CHILD, HOLDER_SCRIPT)
const run = runProfilePnpm(context, ['add', 'held'], {
const run = runProfilePnpm(context, ['add', './held'], {
...options,
onOutput() { throw new Error('output destination closed') },
})
@@ -130,7 +130,7 @@ describe.skipIf(process.platform === 'win32')('a run with descendants', () => {
it('stops the whole tree of a stalled run before returning', async () => {
const { context, pidFile, lateFile, options } = fixture(STALLED_CHILD, LATE_SCRIPT)
const outcome = await runProfilePnpm(context, ['add', 'stalled'], { ...options, idleTimeoutMs: 300 })
const outcome = await runProfilePnpm(context, ['add', './stalled'], { ...options, idleTimeoutMs: 300 })
const descendant = await readPid(pidFile)
expect(outcome).toMatchObject({ timedOut: true })
// The descendant is gone, so the marker it would have written 1.5s in never appears.
@@ -1,17 +1,19 @@
/** The CLI and manager share package reconciliation, path anchoring and diagnostics. */
import { EventEmitter } from 'node:events'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { tmpdir } from 'node:os'
import { PassThrough } from 'node:stream'
import { expect, it, onTestFinished, vi } from 'vitest'
import { initProfile, readProfileManifest } from '@deepseek-ai/dsh-app-boot'
import { getDshRuntimeVersion, initProfile, readProfileManifest } from '@deepseek-ai/dsh-app-boot'
import { anchorPathSpec, readProfileRegistry, runPluginCommand, runProfilePnpm, viewProfilePackage } from '../src/operations.ts'
/** What execa resolves for a run that settled. */
/** What execa resolves for a run that settled, including the buffered output the pre-install lookup is read for. */
interface FakeOutcome {
exitCode: number | undefined
failed: boolean
stdout?: string
stderr?: string
code?: string
shortMessage?: string
}
@@ -24,17 +26,55 @@ interface FakeChild {
kill?: () => boolean
}
/** What each kind of pnpm invocation changes in the profile and what it answers. */
interface FakePnpm {
/** What the run changes in the profile. */
mutate: (dir: string) => void
/** The run's exit code and captured output. */
exitCode: number
output: string
/** Exit code of the `install --frozen-lockfile` run that repairs a post-install refusal. */
repairExit: number
/** What the pre-install `pnpm view` lookup answers for one spec; `{}` declares no peers. */
view: (spec: string) => { exitCode: number; stdout: string }
/** The specs the pre-install check asked `pnpm view` about, in order. */
views: string[]
}
const command = vi.hoisted(() => ({ run: vi.fn<(...args: unknown[]) => Promise<FakeOutcome> & FakeChild>() }))
vi.mock('execa', () => ({ execa: (...args: unknown[]) => command.run(...args) }))
function fixture() {
const home = mkdtempSync(join(tmpdir(), 'manager-pnpm-'))
onTestFinished(() => { command.run.mockReset(); rmSync(home, { recursive: true, force: true }) })
const dir = join(home, 'profiles', 'test')
const installAnchor = join(home, 'package.json')
writeFileSync(installAnchor, '{}\n')
initProfile(dir, [])
return { home, dir, context: { home, profile: 'test', installAnchor, cwd: home } }
// One pnpm command per operation: a lookup that only reads the registry, the frozen install that
// repairs a post-install refusal, and the run itself, which applies the test's mutation and exit code.
const pnpm: FakePnpm = {
mutate: () => {},
exitCode: 0,
output: '',
repairExit: 0,
view: () => ({ exitCode: 0, stdout: '{}' }),
views: [],
}
command.run.mockImplementation((...call: unknown[]) => {
const argv = call[1] as readonly string[]
const runDir = (call[2] as { cwd: string }).cwd
const lookup = argv.indexOf('view')
if (lookup !== -1) {
const spec = argv[lookup + 1] as string
pnpm.views.push(spec)
const answer = pnpm.view(spec)
return result(answer.exitCode, answer.stdout)
}
if (argv.includes('--frozen-lockfile')) return result(pnpm.repairExit, '')
return result(pnpm.exitCode, pnpm.output, () => { pnpm.mutate(runDir) })
})
onTestFinished(() => { command.run.mockReset(); rmSync(home, { recursive: true, force: true }) })
return { home, dir, context: { home, profile: 'test', installAnchor, cwd: home }, pnpm }
}
function result(
@@ -48,7 +88,7 @@ function result(
mutate()
stdout.end(output)
stderr.end()
return { exitCode, failed: exitCode !== 0, ...details }
return { exitCode, failed: exitCode !== 0, stdout: output, stderr: '', ...details }
})
// A launch failure reports on the raw child, which never emits an exit; anything else exits once execa's promise settles.
// Both arrive on a macrotask, so a fixture built before the run still reaches the listener the run attaches.
@@ -117,6 +157,350 @@ function install(dir: string, name: string) {
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
}
function installGuarded(dir: string, dependency: string, name = dependency, version = '1.0.0', peer = '>=999.0.0') {
install(dir, dependency)
writeFileSync(join(dir, 'node_modules', dependency, 'package.json'), JSON.stringify({
name, version, peerDependencies: { '@deepseek-ai/dsh-app-boot': peer }, dsh: { bundle: { patch: './cordis.patch.yml' } },
}))
}
/** A path spec whose manifest declares a peer requirement the running dsh does not satisfy. */
function writePathSpec(home: string, peer: string): void {
mkdirSync(join(home, 'plugin'), { recursive: true })
writeFileSync(join(home, 'plugin', 'package.json'), JSON.stringify({
name: 'plugin', version: '1.0.0', peerDependencies: { '@deepseek-ai/dsh': peer },
}))
}
it.each([
{ form: 'a relative path', spec: () => './plugin' },
{ form: 'a file: path', spec: () => 'file:./plugin' },
{ form: 'a link: path', spec: () => 'link:./plugin' },
{ form: 'an absolute path', spec: (home: string) => join(home, 'plugin') },
])('rejects an incompatible $form before pnpm runs and leaves both profile files alone', async ({ spec }) => {
const { home, dir, context } = fixture()
writePathSpec(home, '999.0.0')
const manifestBefore = readFileSync(join(dir, 'package.json'), 'utf8')
writeFileSync(join(dir, 'pnpm-lock.yaml'), 'original-lock\n')
const messages: string[] = []
const outcome = await runProfilePnpm(context, ['add', spec(home)], {
execution: 'service', outputBytes: 8192, onOutput: (text) => { messages.push(text) },
})
expect(outcome.exitCode).toBe(1)
expect(outcome.output).toContain('installation rejected')
expect(outcome.output).toContain('[email protected]')
expect(outcome.output).toContain('nothing was installed')
expect(messages.join('')).toContain('nothing was installed')
expect(readFileSync(outcome.logPath, 'utf8')).toContain('[email protected]')
// The spec was read from disk, so no pnpm invocation exists to install anything.
expect(command.run).not.toHaveBeenCalled()
expect(existsSync(join(dir, 'node_modules'))).toBe(false)
expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(manifestBefore)
expect(readFileSync(join(dir, 'pnpm-lock.yaml'), 'utf8')).toBe('original-lock\n')
})
it.each(['[email protected]', '@scope/[email protected]'])('rejects the registry spec %s the pre-install lookup denies, without installing it', async (spec) => {
const { dir, context, pnpm } = fixture()
const name = spec.slice(0, spec.lastIndexOf('@'))
pnpm.view = () => ({
exitCode: 0,
stdout: JSON.stringify({ name, version: '1.0.0', peerDependencies: { '@deepseek-ai/dsh': '999.0.0' } }),
})
const manifestBefore = readFileSync(join(dir, 'package.json'), 'utf8')
const outcome = await runProfilePnpm(context, ['add', spec], { execution: 'service', outputBytes: 8192 })
expect(outcome).toMatchObject({ exitCode: 1 })
expect(outcome.output).toContain('installation rejected')
expect(outcome.output).toContain(`${name}@1.0.0`)
expect(outcome.output).toContain('nothing was installed')
// The lookup is the only pnpm invocation: the refused spec never reached an install.
expect(pnpm.views).toEqual([spec])
expect(command.run).toHaveBeenCalledTimes(1)
expect(command.run.mock.calls[0]?.[1] as readonly string[]).toContain('view')
expect(existsSync(join(dir, 'node_modules'))).toBe(false)
expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(manifestBefore)
})
it('reads the last match when the registry lookup answers with several versions', async () => {
const { context, pnpm } = fixture()
pnpm.view = () => ({
exitCode: 0,
stdout: JSON.stringify([
{ name: 'plugin', version: '1.0.0' },
{ name: 'plugin', version: '2.0.0', peerDependencies: { '@deepseek-ai/dsh': '999.0.0' } },
]),
})
const outcome = await runProfilePnpm(context, ['add', 'plugin@^2', '--registry=https://registry.example'], {
execution: 'service', outputBytes: 8192, lookupTimeoutMs: 5_000, signal: new AbortController().signal,
})
expect(outcome).toMatchObject({ exitCode: 1 })
expect(outcome.output).toContain('[email protected]')
expect(pnpm.views).toEqual(['plugin@^2'])
// The lookup asks the registry the installation would use.
expect(command.run.mock.calls[0]?.[1] as readonly string[]).toContain('--registry=https://registry.example')
expect(command.run).toHaveBeenCalledTimes(1)
})
it('runs an invocation that names no command without a registry lookup', async () => {
const { context, pnpm } = fixture()
expect(await runProfilePnpm(context, ['--version'], { execution: 'service', outputBytes: 8192 }))
.toMatchObject({ exitCode: 0 })
expect(pnpm.views).toEqual([])
})
it('installs an incompatible path spec the profile exempts', async () => {
const { home, dir, context, pnpm } = fixture()
writePathSpec(home, '999.0.0')
writeFileSync(join(dir, 'compatibility.json'), JSON.stringify({ '[email protected]': [getDshRuntimeVersion()] }) + '\n')
pnpm.mutate = (target) => {
install(target, 'plugin')
writeFileSync(join(target, 'node_modules', 'plugin', 'package.json'), JSON.stringify({
name: 'plugin', version: '1.0.0', peerDependencies: { '@deepseek-ai/dsh': '999.0.0' },
dsh: { bundle: { patch: './cordis.patch.yml' } },
}))
}
expect(await runProfilePnpm(context, ['add', './plugin'], { execution: 'service', outputBytes: 8192 }))
.toMatchObject({ exitCode: 0 })
// A path spec never needs a registry lookup, and the exemption covers the installed manifest too.
expect(pnpm.views).toEqual([])
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['plugin'])
})
it('installs a compatible path spec', async () => {
const { home, dir, context, pnpm } = fixture()
writePathSpec(home, '*')
pnpm.mutate = (target) => { install(target, 'plugin') }
expect(await runProfilePnpm(context, ['add', './plugin'], { execution: 'service', outputBytes: 8192 }))
.toMatchObject({ exitCode: 0 })
expect(pnpm.views).toEqual([])
expect(command.run).toHaveBeenCalledTimes(1)
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['plugin'])
})
it('runs the install with the caller\'s flags and its path specs anchored', async () => {
const { context, pnpm } = fixture()
pnpm.mutate = (target) => { install(target, 'extra') }
expect(await runProfilePnpm(context, ['add', './extra', '--ignore-scripts'], {
execution: 'service', outputBytes: 8192, activateNewBundles: false,
})).toMatchObject({ exitCode: 0 })
expect(command.run.mock.calls[0]?.[1]).toEqual(['add', join(context.cwd, 'extra'), '--ignore-scripts'])
})
it.each([
{ answer: 'a failing lookup', view: { exitCode: 1, stdout: '' } },
{ answer: 'an unparsable answer', view: { exitCode: 0, stdout: 'ERR_PNPM_UNEXPECTED' } },
])('continues to the install after $answer', async ({ view }) => {
const { dir, context, pnpm } = fixture()
pnpm.view = () => view
pnpm.mutate = (target) => { install(target, 'plugin') }
expect(await runProfilePnpm(context, ['add', 'plugin'], { execution: 'service', outputBytes: 8192 }))
.toMatchObject({ exitCode: 0 })
// The lookup could not judge the spec, so the run itself is what installs and checks it.
expect(pnpm.views).toEqual(['plugin'])
expect(command.run).toHaveBeenCalledTimes(2)
expect(command.run.mock.lastCall?.[1]).toEqual(['add', 'plugin'])
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['plugin'])
})
it.each(['github:owner/repo', 'https://example.com/plugin.tgz'])(
'reaches the install for %s without asking the registry', async (spec) => {
const { context, pnpm } = fixture()
expect(await runProfilePnpm(context, ['add', spec], { execution: 'service', outputBytes: 8192 }))
.toMatchObject({ exitCode: 0 })
expect(pnpm.views).toEqual([])
expect(command.run).toHaveBeenCalledTimes(1)
expect(command.run.mock.calls[0]?.[1]).toEqual(['add', spec])
})
it('makes no registry lookup for commands that are not installs', async () => {
const { context, pnpm } = fixture()
for (const args of [['update'], ['remove', 'plugin'], ['list'], ['approve-builds']]) {
expect(await runProfilePnpm(context, args, { execution: 'service', outputBytes: 8192 }))
.toMatchObject({ exitCode: 0 })
}
expect(pnpm.views).toEqual([])
expect(command.run).toHaveBeenCalledTimes(4)
})
it.each([true, false])('rejects incompatible installed manifests before activation (%s) and restores exact profile files', async (activateNewBundles) => {
const { dir, context, pnpm } = fixture()
const manifestBefore = readFileSync(join(dir, 'package.json'), 'utf8')
writeFileSync(join(dir, 'pnpm-lock.yaml'), 'original-lock\n')
const messages: string[] = []
// Only the installed manifest carries the incompatibility, so it is the post-install check that refuses it.
pnpm.mutate = (target) => {
installGuarded(target, 'incompatible')
writeFileSync(join(target, 'pnpm-lock.yaml'), 'changed-lock\n')
}
const outcome = await runProfilePnpm(context, ['add', 'incompatible'], {
execution: 'service', outputBytes: 8192, activateNewBundles, onOutput: (text) => { messages.push(text) },
})
expect(outcome.exitCode).toBe(1)
expect(outcome.output).toContain('[email protected]')
expect(outcome.output).toContain('installation rejected')
expect(readFileSync(outcome.logPath, 'utf8')).toContain('[email protected]')
expect(messages.join('')).toContain('installation rejected')
expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(manifestBefore)
expect(readFileSync(join(dir, 'pnpm-lock.yaml'), 'utf8')).toBe('original-lock\n')
// The refused run replaced the installed tree, so the restored lockfile is reinstalled.
expect(command.run).toHaveBeenLastCalledWith(expect.anything(), ['install', '--frozen-lockfile'], expect.anything())
expect(outcome.output).toContain('and node_modules')
})
it('reports an unrepaired node_modules when the restoring install fails', async () => {
const { context, pnpm } = fixture()
pnpm.mutate = (target) => { installGuarded(target, 'incompatible') }
pnpm.repairExit = 1
const outcome = await runProfilePnpm(context, ['add', 'incompatible'], {
execution: 'service', outputBytes: 8192, idleTimeoutMs: 5_000,
})
expect(outcome).toMatchObject({ exitCode: 1 })
expect(outcome.output).toContain('node_modules could not be reinstalled')
expect(outcome.output).toContain("run 'dsh plugin install'")
})
it('detects a version update that keeps the dependency spec and removes a lockfile the run created', async () => {
const { dir, context, pnpm } = fixture()
// The installed tree holds an accepted generation of the dependency the profile already selects.
installGuarded(dir, 'updated', 'updated', '1.0.0', '*')
const before = readFileSync(join(dir, 'package.json'), 'utf8')
// `pnpm update` keeps the manifest spec and replaces only the installed contents.
pnpm.mutate = (target) => {
writeFileSync(join(target, 'node_modules', 'updated', 'package.json'), JSON.stringify({
name: 'updated', version: '2.0.0', peerDependencies: { '@deepseek-ai/dsh-app-boot': '>=999.0.0' },
dsh: { bundle: { patch: './cordis.patch.yml' } },
}))
writeFileSync(join(target, 'pnpm-lock.yaml'), 'changed-lock\n')
}
const outcome = await runProfilePnpm(context, ['update'], { execution: 'service', outputBytes: 8192 })
expect(outcome).toMatchObject({ exitCode: 1 })
expect(outcome.output).toContain('[email protected]')
expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before)
// The run created the lockfile, so the refusal removes it instead of restoring one.
expect(existsSync(join(dir, 'pnpm-lock.yaml'))).toBe(false)
})
it('does not fail an unrelated installation on an untouched incompatible dependency', async () => {
const { context, dir, pnpm } = fixture()
installGuarded(dir, 'incompatible')
const messages: string[] = []
pnpm.mutate = (target) => { install(target, 'unrelated') }
expect(await runProfilePnpm(context, ['add', 'unrelated'], {
execution: 'service', outputBytes: 8192, onOutput: (text) => { messages.push(text) },
})).toMatchObject({ exitCode: 0 })
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['unrelated'])
expect(messages.join('')).toContain('startup denies it')
})
it('installs a dependency whose installed manifest declares compatible peers', async () => {
const { dir, context, pnpm } = fixture()
pnpm.mutate = (target) => {
install(target, 'plugin')
writeFileSync(join(target, 'node_modules', 'plugin', 'package.json'), JSON.stringify({
name: 'plugin', version: '1.0.0', peerDependencies: { '@deepseek-ai/dsh': '*' },
dsh: { bundle: { patch: './cordis.patch.yml' } },
}))
}
expect(await runProfilePnpm(context, ['add', 'plugin'], { execution: 'service', outputBytes: 8192 }))
.toMatchObject({ exitCode: 0 })
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['plugin'])
})
it.each([
{ key: '[email protected]', runtime: 'current', exitCode: 0 },
{ key: '[email protected]', runtime: 'current', exitCode: 1 },
{ key: '[email protected]', runtime: 'current', exitCode: 1 },
{ key: '[email protected]', runtime: '999.0.0', exitCode: 1 },
])('matches exemptions to the actual alias manifest and exact versions: $key / $runtime', async ({ key, runtime, exitCode }) => {
const { dir, context, pnpm } = fixture()
writeFileSync(join(dir, 'compatibility.json'), JSON.stringify({ [key]: [runtime === 'current' ? getDshRuntimeVersion() : runtime] }) + '\n')
pnpm.mutate = (target) => { installGuarded(target, 'alias', 'actual') }
expect(await runProfilePnpm(context, ['add', 'alias@npm:[email protected]'], { execution: 'service', outputBytes: 8192 })).toMatchObject({ exitCode })
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(exitCode === 0 ? ['alias'] : [])
})
it.each(['list', 'remove'])('allows %s with preexisting incompatible packages', async (operation) => {
const { dir, context, pnpm } = fixture()
installGuarded(dir, 'incompatible')
// An inventory command changes nothing; `remove` deletes the row the run reports it deleted.
if (operation === 'remove') {
pnpm.mutate = (target) => {
const manifest = readProfileManifest('test', target)
delete manifest.dependencies?.incompatible
writeFileSync(join(target, 'package.json'), JSON.stringify(manifest))
}
}
expect(await runProfilePnpm(context, [operation, 'incompatible'], { execution: 'service', outputBytes: 8192 })).toMatchObject({ exitCode: 0 })
})
it.each(['devDependencies', 'optionalDependencies'])('checks installed direct plugins in %s', async (field) => {
const { context, pnpm } = fixture()
pnpm.mutate = (target) => {
installGuarded(target, 'incompatible')
const manifest = readProfileManifest('test', target)
writeFileSync(join(target, 'package.json'), JSON.stringify({ ...manifest, dependencies: {}, [field]: { incompatible: '1' } }))
}
expect(await runProfilePnpm(context, ['install'], { execution: 'service', outputBytes: 8192 })).toMatchObject({ exitCode: 1 })
})
it('rejects an incompatible component declared by a newly installed bundle', async () => {
const { dir, context, pnpm } = fixture()
pnpm.mutate = (target) => {
install(target, 'bundle')
const packageDir = join(target, 'node_modules', 'bundle')
const component = join(packageDir, 'node_modules', 'component')
mkdirSync(component, { recursive: true })
writeFileSync(join(component, 'package.json'), JSON.stringify({
name: 'component', version: '1.0.0', peerDependencies: { '@deepseek-ai/dsh': '>=999.0.0' },
}))
writeFileSync(join(packageDir, 'cordis.patch.yml'), '- insert:\n - id: component\n name: component/subpath\n')
}
const outcome = await runProfilePnpm(context, ['add', 'bundle'], { execution: 'service', outputBytes: 8192, activateNewBundles: false })
expect(outcome.exitCode).toBe(1)
expect(outcome.output).toContain('[email protected]')
expect(readProfileManifest('test', dir).dependencies).toEqual({})
})
it('restores the manifest on malformed installed peer metadata and bounds the warning', async () => {
const { dir, context, pnpm } = fixture()
const before = readFileSync(join(dir, 'package.json'), 'utf8')
pnpm.mutate = (target) => {
install(target, 'malformed')
writeFileSync(join(target, 'node_modules', 'malformed', 'package.json'), JSON.stringify({ name: 'malformed', version: '1.0.0', peerDependencies: [] }))
}
const outcome = await runProfilePnpm(context, ['add', 'malformed'], { execution: 'service', outputBytes: 4 })
expect(outcome).toMatchObject({ exitCode: 1, truncated: true })
expect(Buffer.byteLength(outcome.output)).toBe(4)
expect(readFileSync(outcome.logPath, 'utf8')).toContain('Cannot validate installed package malformed')
expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before)
})
it('checks scoped components in nested groups while leaving unresolved and local rows to startup', async () => {
const { context, pnpm } = fixture()
pnpm.mutate = (target) => {
install(target, 'bundle')
const packageDir = join(target, 'node_modules', 'bundle')
const component = join(packageDir, 'node_modules', '@example', 'component')
mkdirSync(component, { recursive: true })
writeFileSync(join(component, 'package.json'), JSON.stringify({ name: '@example/component', version: '1.0.0' }))
writeFileSync(join(packageDir, 'cordis.patch.yml'), JSON.stringify([{ insert: [
{ id: 'group', group: true, config: [{ id: 'component', name: '@example/component/subpath' }] },
{ id: 'empty-group', group: true, config: {} },
{ id: 'relative', name: './plugin.mjs' },
{ id: 'absolute', name: '/plugin.mjs' },
{ id: 'url', name: 'file:///plugin.mjs' },
{ id: 'unresolved', name: 'uninstalled-plugin' },
] }]))
}
expect(await runProfilePnpm(context, ['add', 'bundle'], { execution: 'service', outputBytes: 8192 })).toMatchObject({ exitCode: 0 })
})
it('reports unreadable snapshot files before invoking pnpm', async () => {
const { dir, context } = fixture()
mkdirSync(join(dir, 'pnpm-lock.yaml'))
await expect(runProfilePnpm(context, ['install'], { execution: 'service', outputBytes: 100 })).rejects.toThrow()
expect(command.run).not.toHaveBeenCalled()
})
it('anchors relative package specs without rewriting registry specs', () => {
expect(anchorPathSpec('.', '/workspace')).toBe(resolve('/workspace'))
expect(anchorPathSpec('file:../plugin', '/workspace/project')).toBe(`file:${resolve('/workspace/plugin')}`)
@@ -124,19 +508,20 @@ it('anchors relative package specs without rewriting registry specs', () => {
})
it('activates newly installed bundles and leaves retained disabled dependencies disabled', async () => {
const { dir, context } = fixture()
const { dir, context, pnpm } = fixture()
install(dir, 'disabled')
command.run.mockImplementationOnce(() => result(0, 'installed', () =>{ install(dir, 'new-bundle') }))
pnpm.mutate = (target) => { install(target, 'new-bundle') }
expect(await runPluginCommand(context, ['add', 'new-bundle'], { execution: 'service', outputBytes: 100 })).toMatchObject({ exitCode: 0 })
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['new-bundle'])
command.run.mockImplementationOnce(() => result(0, 'updated'))
pnpm.mutate = () => {}
await runPluginCommand(context, ['update'], { execution: 'service', outputBytes: 100 })
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['new-bundle'])
})
it('can install without activation and bounds output while retaining the complete log', async () => {
const { dir, context } = fixture()
command.run.mockImplementationOnce(() => result(0, '0123456789', () =>{ install(dir, 'extra') }))
const { dir, context, pnpm } = fixture()
pnpm.mutate = (target) => { install(target, 'extra') }
pnpm.output = '0123456789'
const outcome = await runProfilePnpm(context, ['add', './extra'], { execution: 'service', outputBytes: 4, activateNewBundles: false })
expect(outcome).toMatchObject({ exitCode: 0, output: '6789', truncated: true })
expect(readFileSync(outcome.logPath, 'utf8')).toBe('0123456789')
@@ -145,21 +530,22 @@ it('can install without activation and bounds output while retaining the complet
})
it.each([runPluginCommand, runProfilePnpm])('installs into the supplied application profile directory with %s', async (run) => {
const { home, dir: namedDir, context } = fixture()
const { home, dir: namedDir, context, pnpm } = fixture()
const dir = join(home, 'application', 'profile')
initProfile(dir, [])
command.run.mockImplementationOnce(() => result(0, 'installed', () => { install(dir, 'extra') }))
pnpm.mutate = (target) => { install(target, 'extra') }
const outcome = await run({ ...context, dir }, ['add', 'extra'], { execution: 'service', outputBytes: 100 })
expect(outcome.exitCode).toBe(0)
expect(command.run.mock.calls[0]?.[2]).toMatchObject({ cwd: dir })
expect(command.run).toHaveBeenCalledWith(expect.anything(), ['add', 'extra'], expect.objectContaining({ cwd: dir }))
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['extra'])
expect(readProfileManifest('test', namedDir).dependencies).not.toHaveProperty('extra')
expect(readProfileManifest('test', namedDir).dsh?.profile?.bundles).toEqual([])
})
it('retains partial package-manager changes after failure without activating them', async () => {
const { dir, context } = fixture()
command.run.mockImplementationOnce(() => result(1, 'installation failed', () =>{ install(dir, 'partial') }))
it('retains partial package-manager changes after a failed install without activating them', async () => {
const { dir, context, pnpm } = fixture()
pnpm.mutate = (target) => { install(target, 'partial') }
pnpm.exitCode = 1
expect(await runProfilePnpm(context, ['add', 'partial'], { execution: 'service', outputBytes: 100 })).toMatchObject({ exitCode: 1 })
expect(readProfileManifest('test', dir).dependencies).toEqual({ partial: '1' })
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual([])
@@ -169,7 +555,6 @@ it('retains partial package-manager changes after failure without activating the
it('initializes missing profiles under the same lock and reports initialization', async () => {
const { home, context } = fixture()
const messages: string[] = []
command.run.mockImplementation(() => result(0, ''))
for (const profile of ['custom', 'web']) {
await runPluginCommand({ ...context, profile }, ['root'], {
execution: 'service', outputBytes: 100, lockWaitMs: 1000, onOutput: (text) => { messages.push(text) },
@@ -182,7 +567,7 @@ it('initializes missing profiles under the same lock and reports initialization'
it('terminates a run that stopped printing and reports the silence bound', async () => {
const { context } = fixture()
command.run.mockImplementationOnce(() => silentChild('installing\n'))
const outcome = await runProfilePnpm(context, ['add', 'silent'], { execution: 'service', outputBytes: 100, idleTimeoutMs: 20 })
const outcome = await runProfilePnpm(context, ['add', './extra'], { execution: 'service', outputBytes: 100, idleTimeoutMs: 20 })
expect(outcome).toMatchObject({ exitCode: 1, timedOut: true })
expect(outcome.output).toBe('installing\ndsh: pnpm printed nothing for 20ms and was terminated\n')
expect(readFileSync(outcome.logPath, 'utf8')).toBe(outcome.output)
@@ -192,7 +577,7 @@ it('settles a run whose pipes stay open past the process exit', async () => {
const { context } = fixture()
const child = heldPipeChild('installed\n')
command.run.mockImplementationOnce(() => child)
const outcome = await runProfilePnpm(context, ['add', 'held'], {
const outcome = await runProfilePnpm(context, ['add', './extra'], {
execution: 'service', outputBytes: 100, activateNewBundles: false,
})
expect(outcome).toMatchObject({ exitCode: 0 })
@@ -206,7 +591,7 @@ it('settles a run whose pipes stay open past the process exit', async () => {
it('reports a terminated run that trapped the signal and exited zero', async () => {
const { context } = fixture()
command.run.mockImplementationOnce(() => silentChild('installing\n', 0))
const outcome = await runProfilePnpm(context, ['add', 'trapped'], { execution: 'service', outputBytes: 100, idleTimeoutMs: 20 })
const outcome = await runProfilePnpm(context, ['add', './extra'], { execution: 'service', outputBytes: 100, idleTimeoutMs: 20 })
// The independent facts stay separate: the signal was trapped, so the exit status is still zero.
expect(outcome).toMatchObject({ exitCode: 0, timedOut: true })
expect(outcome.output).toContain('printed nothing for 20ms and was terminated')
@@ -215,7 +600,7 @@ it('reports a terminated run that trapped the signal and exited zero', async ()
it('surfaces an output consumer failure the bounded drain cuts short', async () => {
const { context } = fixture()
command.run.mockImplementationOnce(() => heldPipeChild('installed\n'))
await expect(runProfilePnpm(context, ['add', 'held'], {
await expect(runProfilePnpm(context, ['add', './extra'], {
execution: 'service', outputBytes: 100, activateNewBundles: false,
onOutput() { throw new Error('output destination closed') },
})).rejects.toThrow('output destination closed')
@@ -225,7 +610,7 @@ it('reports a reading that fails with something other than an Error by its text'
const { context } = fixture()
const child = heldPipeChild('installed\n')
command.run.mockImplementationOnce(() => child)
await expect(runProfilePnpm(context, ['add', 'held'], {
await expect(runProfilePnpm(context, ['add', './extra'], {
execution: 'service', outputBytes: 100, activateNewBundles: false,
// A reading can reject with any value; its text is what the failure reports.
onOutput() { throw 'pipe broke' },
@@ -234,7 +619,6 @@ it('reports a reading that fails with something other than an Error by its text'
it('terminates a service run as a tree and leaves the CLI in the caller group', async () => {
const { context } = fixture()
command.run.mockImplementation(() => result(0, ''))
await runProfilePnpm(context, ['add', 'tree'], { execution: 'service', outputBytes: 100, activateNewBundles: false })
await runProfilePnpm(context, ['add', 'tree'], { execution: 'cli', outputBytes: 100, activateNewBundles: false })
expect(command.run).toHaveBeenCalledWith(
@@ -246,33 +630,33 @@ it('terminates a service run as a tree and leaves the CLI in the caller group',
})
it('retains built-in layers, removes deleted dependencies and warns about plain packages', async () => {
const { context, dir } = fixture()
const { context, dir, pnpm } = fixture()
install(dir, 'removed')
const manifest = readProfileManifest('test', dir)
manifest.dsh = { profile: { bundles: ['builtin', 'removed'] } }
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
const messages: string[] = []
command.run.mockImplementationOnce(() => result(0, '', () => {
install(dir, 'plain')
writeFileSync(join(dir, 'node_modules', 'plain', 'package.json'), '{"name":"plain"}')
const after = readProfileManifest('test', dir)
pnpm.mutate = (target) => {
install(target, 'plain')
writeFileSync(join(target, 'node_modules', 'plain', 'package.json'), '{"name":"plain"}')
const after = readProfileManifest('test', target)
delete after.dependencies?.removed
writeFileSync(join(dir, 'package.json'), JSON.stringify(after))
}))
writeFileSync(join(target, 'package.json'), JSON.stringify(after))
}
await runPluginCommand(context, ['remove', 'removed'], { execution: 'service', outputBytes: 100, onOutput: (text) => { messages.push(text) } })
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['builtin'])
expect(messages.join('')).toContain('plain dependency')
})
it('preserves a package-manager selected new bundle without adding it twice', async () => {
const { context, dir } = fixture()
const { context, dir, pnpm } = fixture()
writeFileSync(join(dir, 'package.json'), '{}')
command.run.mockImplementationOnce(() => result(0, '', () => {
install(dir, 'new')
const manifest = readProfileManifest('test', dir)
pnpm.mutate = (target) => {
install(target, 'new')
const manifest = readProfileManifest('test', target)
manifest.dsh = { profile: { bundles: ['new'] } }
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
}))
writeFileSync(join(target, 'package.json'), JSON.stringify(manifest))
}
await runProfilePnpm(context, ['add', 'new'], { execution: 'service', outputBytes: 100 })
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['new'])
})
@@ -298,7 +682,7 @@ it('cancels and settles package output when the output consumer fails', async ()
child.stdout.setEncoding('utf8')
return child
})
await expect(runProfilePnpm(context, ['root'], {
await expect(runProfilePnpm(context, ['add', './extra'], {
execution: 'service', outputBytes: 100, onOutput() { throw new Error('output destination closed') },
})).rejects.toThrow('output destination closed')
expect(cancellation?.aborted).toBe(true)
@@ -317,6 +701,15 @@ it('preserves an unexpected subprocess rejection after both streams settle', asy
})
it('allows inventory commands when a declared dependency is missing from disk', async () => {
const { context, dir } = fixture()
install(dir, 'missing')
rmSync(join(dir, 'node_modules', 'missing'), { recursive: true })
command.run.mockImplementationOnce(() => result(0, 'missing dependency'))
expect(await runProfilePnpm(context, ['list'], { execution: 'service', outputBytes: 100, activateNewBundles: false }))
.toMatchObject({ exitCode: 0 })
})
it('handles manifests without dependency or bundle selections', async () => {
const { context, dir } = fixture()
command.run.mockImplementationOnce(() => result(0, '', () => { writeFileSync(join(dir, 'package.json'), '{}') }))
@@ -335,7 +728,6 @@ it.each(['cli', 'service'] as const)('uses the %s environment and interaction po
})
})
for (const name of names) process.env[name] = 'fixture-credential'
command.run.mockImplementationOnce(() => result(0, ''))
await runPluginCommand(context, ['approve-builds'], { execution, outputBytes: 100 })
const options = command.run.mock.calls[0]?.[2] as { env: NodeJS.ProcessEnv; stdin: string; stdout: string; stderr: string }
for (const name of names) expect(options.env[name]).toBe(execution === 'cli' ? 'fixture-credential' : undefined)
@@ -401,10 +793,9 @@ it('asks the registry through pnpm view in the profile directory, without pnpm\'
it('uses application-owned executable arguments and environment for package operations and inspection', async () => {
const { dir, context } = fixture()
const runtime = { command: '/app/electron', args: ['--expose-internals', '/app/pnpm.mjs'], env: { ELECTRON_RUN_AS_NODE: '1', PATH: '/app/bin' } }
command.run.mockImplementationOnce(() => result(0, ''))
await runProfilePnpm(context, ['add', './extra'], { ...runtime, execution: 'service', outputBytes: 100, activateNewBundles: false })
expect(command.run).toHaveBeenLastCalledWith(runtime.command, [...runtime.args, 'add', resolve(context.cwd, 'extra')],
expect.objectContaining({ env: expect.objectContaining(runtime.env) as unknown }))
expect(command.run.mock.calls[0]).toEqual([runtime.command, [...runtime.args, 'add', resolve(context.cwd, 'extra')],
expect.objectContaining({ env: expect.objectContaining(runtime.env) as unknown })])
command.run.mockResolvedValueOnce(Object.assign({ exitCode: 0, failed: false }, { stdout: '{}', stderr: '', timedOut: false }))
await viewProfilePackage(dir, 'example', { ...runtime, timeoutMs: 1000 })
expect(command.run).toHaveBeenLastCalledWith(runtime.command,
@@ -27,6 +27,8 @@ async function fixture(mode: 'read-only' | 'workspace-write' | 'danger-full-acce
setBundleEnabled: vi.fn(async () => ({ changed: true, application: 'applied' })),
installBundle: vi.fn(async () => ({ changed: true, application: 'restart-required' })),
removeBundle: vi.fn(async () => ({ changed: false, application: 'failed' })),
listVersionExemptions: vi.fn(() => ({ exemptions: { '[email protected]': ['0.1.7-alpha.1'] }, warnings: [] })),
setVersionExemption: vi.fn(async () => ({ changed: true, application: 'applied' })),
}
ctx.provide('pluginManager', manager as unknown as PluginManager)
await ctx.plugin(SystemPrompt)
@@ -43,7 +45,7 @@ async function fixture(mode: 'read-only' | 'workspace-write' | 'danger-full-acce
it.each(['read-only', 'workspace-write'] as const)('denies every management action in %s before accessing the manager', async (mode) => {
const { call, manager } = await fixture(mode)
for (const action of ['list_plugins', 'list_bundles', 'set_plugin', 'set_bundle', 'install_bundle', 'remove_bundle']) {
for (const action of ['list_plugins', 'list_bundles', 'set_plugin', 'set_bundle', 'install_bundle', 'remove_bundle', 'list_version_exemptions', 'set_version_exemption']) {
const result = await call({ action, target: 'bundle', enabled: true })
expect(result.isError).toBe(true)
expect(JSON.stringify(result.content)).toContain('requires approval, but no approval service is composed')
@@ -105,14 +107,14 @@ it.each(['read-only', 'workspace-write'] as const)('approves each action once in
const agent = activeAgent()
const prompted = vi.fn(async () => 'allowed-once' as const)
const dispose = ctx.on('approval/request', prompted)
for (const action of ['list_plugins', 'list_bundles', 'set_plugin', 'set_bundle', 'install_bundle', 'remove_bundle']) {
expect((await call({ action, target: 'bundle', enabled: true }, agent)).isError).toBe(false)
for (const action of ['list_plugins', 'list_bundles', 'set_plugin', 'set_bundle', 'install_bundle', 'remove_bundle', 'list_version_exemptions', 'set_version_exemption']) {
expect((await call({ action, target: 'bundle@1.0.0', enabled: true, runtimeVersion: '0.1.7-alpha.1', acceptRisk: true }, agent)).isError).toBe(false)
}
expect(prompted).toHaveBeenCalledTimes(6)
expect(prompted).toHaveBeenCalledTimes(8)
for (const method of Object.values(manager)) expect(method).toHaveBeenCalledTimes(1)
expect(ctx.sandboxPolicy.resolve({ session: agent.session }).mode).toBe(mode)
const audit = agent.session.snapshotEvents().filter(event => event.type.startsWith('approval/'))
expect(audit).toHaveLength(12)
expect(audit).toHaveLength(16)
expect(audit[0]).toMatchObject({ type: 'approval/asked', data: {
toolName: 'plugin_manager', callId: 'manager-call',
} })
@@ -228,6 +230,9 @@ it.each([
{ action: 'set_plugin', enabled: true },
{ action: 'set_bundle', target: 'bundle' },
{ action: 'install_bundle' }, { action: 'remove_bundle' },
{ action: 'set_version_exemption', enabled: true, runtimeVersion: '1.0.0' },
{ action: 'set_version_exemption', target: '[email protected]', enabled: true },
{ action: 'set_version_exemption', target: '[email protected]', runtimeVersion: '1.0.0' },
])('rejects incomplete or unbounded tool inputs: %j', async (args) => {
const { call } = await fixture()
expect((await call(args)).isError).toBe(true)
@@ -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/client/ui-attachment/README.md
README.md: 5a3e697189936a79df19ff9b976f06e0dd6191fb
README.zh.md: 4168650770f42e7b94146041f37ffc440a5ec52e
README.md: af2ed79cc2ede546db06187df4750b9d0fa698dd
README.zh.md: 64d12869c5038ceb19675d554f9055411cfac433
+1 -1
View File
@@ -58,7 +58,7 @@ The plugin waits for `conversation.input.attachments`, `conversation.message.ima
| [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | Horizontal attachment overflow, wheel translation, edge arrows |
| [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | Per-message gallery + lightbox assembly |
| [`src/MessageImage.tsx`](src/MessageImage.tsx) | Single image sizing, load/retry, click-to-open; local submission-echo previews render their object URL directly |
| [`src/ImageLightbox.tsx`](src/ImageLightbox.tsx) | Document-level modal preview over the shared mask |
| [`ImageLightbox`](../ui-primitives/src/ImageLightbox.tsx) | Document-level modal preview over the shared mask |
| [`src/DropOverlay.tsx`](src/DropOverlay.tsx) | Pointer-inert drag invitation portal |
</details>
+1 -1
View File
@@ -58,7 +58,7 @@ Trajectory 附件行使用 48px 方形缩略图,完整缩放图片而不裁剪
| [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | 附件横向溢出、滚轮转换、边缘箭头 |
| [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | 每消息画廊+灯箱的组装 |
| [`src/MessageImage.tsx`](src/MessageImage.tsx) | 单图尺寸、加载/重试、点击打开;本地提交回显预览直接显示其 object URL |
| [`src/ImageLightbox.tsx`](src/ImageLightbox.tsx) | 铺在共享遮罩上的文档级模态预览 |
| [`ImageLightbox`](../ui-primitives/src/ImageLightbox.tsx) | 铺在共享遮罩上的文档级模态预览 |
| [`src/DropOverlay.tsx`](src/DropOverlay.tsx) | 不接收指针事件的拖放提示 portal |
</details>
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { IconLoadingOutlineRegular, IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { ImageLightbox } from './ImageLightbox.tsx'
import type { ImageLightboxLabels } from './ImageLightbox.tsx'
import { ImageLightbox } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ImageLightboxLabels } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './MessageImage.module.css'
/** Loads a session-authorized durable image URL and may expose a cached URL synchronously. */
@@ -7,7 +7,7 @@ import { AttachmentRail } from '../AttachmentRail.tsx'
import type { AttachmentRailItem } from '../AttachmentRail.tsx'
import { DropOverlay } from '../DropOverlay.tsx'
import { FileCard } from '../FileCard.tsx'
import { ImageLightbox } from '../ImageLightbox.tsx'
import { ImageLightbox } from '@deepseek-ai/dsh-client-ui-primitives'
import { attachmentRailLabels, dropOverlayLabels, fileCardLabels, lightboxLabels } from './labels.ts'
import { installDocumentDropEvents } from './drop-events.ts'
import css from './ComposerAttachments.module.css'
@@ -2,7 +2,7 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import type { AttachmentRailLabels } from '../AttachmentRail.tsx'
import type { DropOverlayLabels } from '../DropOverlay.tsx'
import type { FileCardLabels } from '../FileCard.tsx'
import type { ImageLightboxLabels } from '../ImageLightbox.tsx'
import type { ImageLightboxLabels } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MessageImageLabels } from '../MessageImage.tsx'
/**
+2 -2
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/client/ui-chat/README.md
README.md: b3d649c319c58ff807f11cc6b6d0e634eb931711
README.zh.md: 6feb9d459299755968a3d99d5789d0cfae8b6e8e
README.md: cca209016220560b7f39831e11ebb677da515ea9
README.zh.md: 0b97e490f5a535c3b871ec7c6344d4299976b683
+2
View File
@@ -33,6 +33,8 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
Chat supplies file and HTTP(S) navigation through one `MarkdownDelegateProvider` around its node list. Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state.
Standalone Markdown images show contained previews and open the shared image lightbox; local paths resolve against the viewed workspace after settlement. Image file links keep their sidebar activation and show a thumbnail after hover dwell or keyboard focus. Escape dismisses the thumbnail. Failed images retain a localized status and their description; no duplicate-image filtering is applied.
Settings → General → Open chat links in selects the destination for ordinary clicks on Chat HTTP(S) links: In-App Sidebar (default) opens a new right-Sidebar Browser tab, while Default Browser opens an external tab. The setting is shown only while the Sidebar Browser is available. If the Sidebar Browser is not registered, both choices use the external browser; modified clicks retain native behavior. The `ui-chat.linkOpening` preference persists on loopback browsers and stays process-local when settings cannot persist writes. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references.
<a id="system-prompt-row"></a>
+2
View File
@@ -33,6 +33,8 @@ kind: "package-reference"
Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。
独立的 Markdown 图片以内嵌预览显示,点击打开共享图片浮层;本地路径在消息落定后基于当前查看的工作区解析。图片文件链接保持点击打开侧栏,鼠标停留或键盘聚焦时显示缩略图,Esc 关闭缩略图。图片加载失败时保留本地化状态与图片说明;不执行重复图片过滤。
设置 → 通用设置 → 网页链接默认打开方式控制普通点击 Chat HTTP(S) 链接时的目标:「应用内侧边栏」(默认)打开新的右侧 Sidebar Browser tab,「默认浏览器」打开外部标签页。该设置项仅在 Sidebar Browser 可用时显示。若 Sidebar Browser 未注册,两种选择均使用外部浏览器;带修饰键的点击保留原生行为。`ui-chat.linkOpening` 偏好在回环地址浏览器中持久化,设置无法持久化写入时仅在当前进程内生效。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。
<a id="system-prompt-row"></a>
@@ -1,4 +1,5 @@
import { Fragment, memo, useMemo } from 'react'
import { fileMediaUrl } from '@deepseek-ai/dsh-util-workspace-path'
import type { ReactNode } from 'react'
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MarkdownFileMentions, MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -10,16 +11,20 @@ import { useSearchableHidden } from './searchable-hidden.ts'
import css from './AssistantMarkdown.module.css'
/**
* Resolve an authored POSIX image path against the document's file API.
* Standalone fallback for image destinations (query/fragment suffixes are ignored).
* Chat fileImages resolves decoded file references against cwd; pathImages also
* serves this component outside that provider and accepts legacy image URL suffixes.
* Resolve an authored absolute image path against the document's file API.
* @param base - canonical `document.baseURI` at render time.
* @param value - authored markdown destination.
* @param value - authored Markdown destination; URL escapes are decoded once.
* @returns an absolute HTTP(S) file-API URL, or undefined for unsupported
* protocols and non-local paths.
*/
export function localPathMediaUrl(base: string, value: string): string | undefined {
if (!value.startsWith('/') || value.startsWith('//')) return undefined
if (!base.startsWith('http:') && !base.startsWith('https:')) return undefined
return new URL(`api/file?path=${encodeURIComponent(value)}`, base).href
let path: string
try { path = decodeURIComponent(value.split(/[?#]/u)[0] ?? '') }
catch { return undefined } // Malformed URL escapes cannot identify a file.
return fileMediaUrl(base, path)
}
export interface AssistantMarkdownProps {
@@ -20,6 +20,7 @@ import { assertNever } from '@deepseek-ai/dsh-util-values'
import { TurnNavigator } from './TurnNavigator.tsx'
import { mergeTurnRailItems } from './turn-rail-items.ts'
import { useChatScroll } from './use-chat-scroll.ts'
import { fileMediaUrl, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
import css from './ChatView.module.css'
/** Host/OS refusal text for the file-open dialog; empty throws keep a locale fallback. */
@@ -120,6 +121,13 @@ export function ChatView({
const inbox = useProjection('inbox') as unknown as InboxState | undefined
// Workspace root off the session list row: path summaries display relative to it.
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
const fileImages = useMemo(() => ({
resolve: (path: string) => fileMediaUrl(document.baseURI, resolveWorkspacePath(cwd, path)),
labels: {
open: t('image.open'), loading: t('image.loading'), failed: t('image.failed'),
dialog: t('image.dialog'), close: t('image.close'),
},
}), [cwd, t])
const running = useSession(s => s.running)
const openState = useSession(s => s.openState)
const openError = useSession(s => s.openError)
@@ -237,7 +245,7 @@ export function ChatView({
</button>
</div>
)}
<MarkdownDelegateProvider openExternalLink={openExternalLink} openFile={requestOpenFile}>
<MarkdownDelegateProvider openExternalLink={openExternalLink} openFile={requestOpenFile} fileImages={fileImages}>
<ChatNodeList
entries={entries}
pendingInputs={pendingInputs}
@@ -66,6 +66,11 @@ export const zh = {
'message.turnProcess.took': '用时 {duration}',
'message.turnProcess.failed': '处理失败',
'view.chat': '对话',
'image.open': '查看大图',
'image.loading': '加载图片…',
'image.failed': '图片无法预览',
'image.dialog': '图片预览',
'image.close': '关闭图片预览',
'number.groupSeparator': ',',
'duration.compactSeconds': '{seconds}秒',
'duration.compactMinutes': '{minutes}分{seconds}秒',
@@ -241,6 +246,11 @@ export const en = {
'message.turnProcess.took': 'Took {duration}',
'message.turnProcess.failed': 'Failed',
'view.chat': 'Chat',
'image.open': 'View full image',
'image.loading': 'Loading image…',
'image.failed': 'Image preview unavailable',
'image.dialog': 'Image preview',
'image.close': 'Close image preview',
'number.groupSeparator': ',',
'duration.compactSeconds': '{seconds}s',
'duration.compactMinutes': '{minutes}m{seconds}s',
@@ -43,7 +43,7 @@ describe('localPathMediaUrl', () => {
expect(localPathMediaUrl(BASE, '')).toBeUndefined()
expect(localPathMediaUrl(BASE, '//cdn.example.com/x.png')).toBeUndefined()
expect(localPathMediaUrl(BASE, 'relative.png')).toBeUndefined()
expect(localPathMediaUrl(BASE, 'C:\\tmp\\x.png')).toBeUndefined()
expect(new URL(localPathMediaUrl(BASE, 'C:\\tmp\\x.png')!).searchParams.get('path')).toBe('C:\\tmp\\x.png')
})
it('encodes the full path including spaces', () => {
@@ -84,3 +84,12 @@ describe('AssistantMarkdown local-path images', () => {
expect(container.textContent).toContain('diagram')
})
})
it('decodes Markdown URL escapes once before encoding the file query', () => {
for (const [authored, path] of [
['/work/test%20workspace/图.png', '/work/test workspace/图.png'],
['/work/100%25%23.png', '/work/100%#.png'],
['/work/literal%2520.png', '/work/literal%20.png'],
]) expect(new URL(localPathMediaUrl(BASE, authored!)!).searchParams.get('path')).toBe(path)
expect(localPathMediaUrl(BASE, '/work/bad%escape.png')).toBeUndefined()
})
@@ -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/client/ui-deliverables/README.md
README.md: 8a0c51a8c88418862438342fc2a0faf41055dd00
README.zh.md: 3c01c405d390e6fcd06665d9a53face55f5c39dc
README.md: 786495d76098ce134ac378858c177cc498bd4c9f
README.zh.md: 9a1f000845ffab755ce23bd570cbe1a79b133676
+2 -2
View File
@@ -91,7 +91,7 @@ Read these pages when the deliverables surface is not enough. They move from the
#### What the model sees
The guidance prefers primary results in the final reply: file links open Sidebar previews, and image previews include a file link for clients without inline-image support. It recommends separate `present` cards for complete file deliverables, especially Office files, usually selecting the most important one or two, with at most four files per call when more are needed. It discourages cards solely for code edits and extra commands to inspect diff visibility. This guidance applies even with developer tools disabled: it accepts fewer file surfaces to avoid duplicate presentation and unnecessary tool calls. It asks the model to link every existing-file mention outside commands, configuration expressions, and code blocks, including repeats and tables. Labels default to filenames or clear aliases, with only enough parent directories to distinguish files. Precise references display `filename:24` or `filename:24–30`; their destinations retain full relative or absolute paths with `#L24` or `#L24-L30` anchors. The display suffix contains neither `#` nor `L`.
The guidance prefers primary results in the final reply: inline images support explanations and comparisons, image links serve references and file lists, and image file cards serve separate deliverables. File destinations use angle brackets, including paths with spaces. It recommends separate `present` cards for complete file deliverables, especially Office files, usually selecting the most important one or two, with at most four files per call when more are needed. It discourages cards solely for code edits and extra commands to inspect diff visibility. This guidance applies even with developer tools disabled: it accepts fewer file surfaces to avoid duplicate presentation and unnecessary tool calls. It asks the model to link every existing-file mention outside commands, configuration expressions, and code blocks, including repeats and tables. Labels default to filenames or clear aliases, with only enough parent directories to distinguish files. Precise references display `filename:24` or `filename:24–30`; their destinations retain full relative or absolute paths with `#L24` or `#L24-L30` anchors. The display suffix contains neither `#` nor `L`.
#### Token effect
@@ -108,7 +108,7 @@ The section is static at first-party order 9000 for the lifetime of the package
These limits define the current deliverables vocabulary. They are current package constraints, not a general file-linking comparison or a task backlog.
- **Inline local images require HTTP(S) and POSIX absolute paths**: Desktop’s `dsh-app:` pages and Windows drive-letter paths do not support inline local images. The guidance retains a Markdown file link so users can open the Sidebar preview.
- **Inline local images require an HTTP(S) page**: Desktop’s `dsh-app:` pages do not support this file route. POSIX absolute paths and Windows drive-letter paths are supported; relative Chat image paths require the viewed Session’s workspace root. Separate image file links are optional.
- **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it.
- **Terminal-created files require explicit delivery** — the card lists them once git records the change, but delivery cards and inline-code references require `present`; explicit Markdown links can reference existing files directly.
- **Declarations do not preserve file contents** — reopening or transferring a Session requires source files accessible through the viewed Session’s filesystem. Missing files, directories, and final symbolic links return 404.
+2 -2
View File
@@ -91,7 +91,7 @@ Node 半部注册[模型体验](#model-experience)所述的静态 `ui:deliverabl
#### 模型看到的内容
提示词建议在最终回复中展示主要结果:文件链接打开 Sidebar 预览,图片预览同时保留文件链接,供不支持行内图片的客户端使用。对于需要完整文件的交付,尤其是 Office 文件,建议使用独立的 `present` 卡片,通常选最重要的一两项,需要时可以更多,但每次调用最多四项。提示词建议避免仅为列出代码改动生成卡片,也不为确认 diff 是否显示额外运行命令。开发者工具关闭时仍采用这条建议,接受较少的文件展示入口,以减少重复展示和不必要的工具调用。文件引用要求链接命令、配置表达式和代码块以外的每次现有文件提及,包括重复提及和表格。标签默认使用文件名或清楚的别名,仅添加足以区分文件的父目录。精确引用显示为 `filename:24` 或 `filename:24–30`;目标保留完整相对路径或绝对路径,以及 `#L24` 或 `#L24-L30` 锚点。显示后缀不含 `#` 或 `L`。
提示词建议在最终回复中展示主要结果:内嵌图片用于讲解与对比,图片链接用于引用和文件列表,图片文件卡片用于独立交付。文件目标路径使用尖括号包裹,包括含空格的路径。对于需要完整文件的交付,尤其是 Office 文件,建议使用独立的 `present` 卡片,通常选最重要的一两项,需要时可以更多,但每次调用最多四项。提示词建议避免仅为列出代码改动生成卡片,也不为确认 diff 是否显示额外运行命令。开发者工具关闭时仍采用这条建议,接受较少的文件展示入口,以减少重复展示和不必要的工具调用。文件引用要求链接命令、配置表达式和代码块以外的每次现有文件提及,包括重复提及和表格。标签默认使用文件名或清楚的别名,仅添加足以区分文件的父目录。精确引用显示为 `filename:24` 或 `filename:24–30`;目标保留完整相对路径或绝对路径,以及 `#L24` 或 `#L24-L30` 锚点。显示后缀不含 `#` 或 `L`。
#### Token 影响
@@ -108,7 +108,7 @@ Node 半部注册[模型体验](#model-experience)所述的静态 `ui:deliverabl
这些限制界定了当前产出物词表。它们是当前包约束,不是通用文件链接对比或任务积压。
- **本地行内图片要求 HTTP(S) 页面和 POSIX 绝对路径**:Desktop 的 `dsh-app:` 页面和 Windows 盘符路径不支持本地行内图片。提示词要求保留 Markdown 文件链接,让用户仍可打开 Sidebar 预览。
- **本地行内图片要求 HTTP(S) 页面**:Desktop 的 `dsh-app:` 页面不支持此文件路由。支持 POSIX 绝对路径与 Windows 盘符路径;Chat 中的相对图片路径需要当前查看的 Session 工作区根目录。图片的独立文件链接是可选的。
- **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。
- **终端创建的文件需要显式交付**——git 记录到改动后卡片会列出它们,但交付卡片和行内代码引用需要调用 `present`;显式 Markdown 链接可以直接引用现有文件。
- **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404。
+2 -2
View File
@@ -15,9 +15,9 @@ export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionCon
/** Static Web guidance for primary outputs and existing-file references. */
const FILE_REFERENCE_PROMPT = 'Prefer showing the primary results within your final response alongside a brief explanation. '
+ 'Markdown file links such as [Report](path/to/report.html) open the file in the sidebar preview. For images, you can add an inline preview such as ![Preview](/absolute/path/image.png); include a file link as well so the image remains accessible in clients that cannot display it inline. '
+ 'Use ![Description](<path/to/image.png>) when an image supports an explanation or comparison. Use [Description](<path/to/image.png>) when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. '
+ 'Do not call present just to list edited source files, or run commands to check whether a diff view will appear. '
+ 'Use present when a separate file card helps the user open the complete deliverable, especially Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. '
+ 'Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. '
+ 'Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. '
+ 'Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. '
+ 'Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.'
@@ -29,7 +29,7 @@ describe('ui-deliverables node plugin', () => {
const section = (await ctx.systemPrompt.assemble()).sections
.find(entry => entry.name === 'ui:deliverable-file-references')
expect(section?.text).toMatchInlineSnapshot('"Prefer showing the primary results within your final response alongside a brief explanation. Markdown file links such as [Report](path/to/report.html) open the file in the sidebar preview. For images, you can add an inline preview such as ![Preview](/absolute/path/image.png); include a file link as well so the image remains accessible in clients that cannot display it inline. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, especially Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix."')
expect(section?.text).toMatchInlineSnapshot('"Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description](<path/to/image.png>) when an image supports an explanation or comparison. Use [Description](<path/to/image.png>) when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix."')
// Only live request pins track current guidance; seeded sessions retain their recorded prompt.
for (const scenario of ['fresh-round-trip', 'ptc-round']) {
@@ -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/client/ui-primitives/README.md
README.md: 5b8a16739504d8da169e04f64b0feb684f47f8c3
README.zh.md: 291beb0cef60584ec86d349e8be8a11a1ae1694b
README.md: fbb8ef676e55d1ef2c3c5c8a6c91a12a3c8fe556
README.zh.md: 822ff17c3c4c6b02339cdacd03d9c52c67a738c7
+4 -1
View File
@@ -54,6 +54,7 @@ Check this table before writing a control in a feature package. A plugin cannot
| `OnboardingSurface` | First-run stage that holds the application root inert. |
| `Tooltip` | Hover text anchored to a cloned child; optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. |
| `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. |
| `ImageLightbox` | Shared image modal with focus restoration and Escape dismissal. |
| `Toast` | Transient top-center banner held for the owner's `holdMs`. |
| `SettingsForm`, `SettingsValueField`, `SettingsSecretField` | The frame and the controls of a plugin's settings page: the frame takes its copy as `labels`, saves only on its button, and discards on unmount; a value field shows staged text with the overridden badge and reset; a secret field starts blank and reports only whether a value is configured. |
| `SettingsFormModel`, `settingsNumberField`, `settingsTextField` | The staged-edit model behind such a page over a settings scope: drafts are staged and written on save, a field is overridden by its presence in the user layer, and a save that did not land keeps its drafts. |
@@ -85,7 +86,7 @@ The catalog above lists what each export is for; this section covers the behavio
The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener.
`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. Reference labels inherit the consumer’s wrapping policy: long names wrap within a bubble, while queue previews retain their single-line layout. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected.
`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. Reference labels inherit the consumer’s wrapping policy: long names wrap within a bubble, while queue previews retain their single-line layout. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected.
`MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache.
@@ -95,6 +96,8 @@ The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and
`JsonTree` clamps collapsed strings to `collapsedStringLines` (three by default). Expanded strings show raw text, retain sibling commas, and fit within the window and outer scrolling containers. Resize and ancestor-scroll events update that limit. Row copy feedback updates independently of JSON value rendering; pending clipboard writes cannot update a different row or an unmounted tree.
`ImageLightbox` is the shared original-image modal with focus restoration and Escape dismissal. The internal thumbnail renderer receives loading/failure labels from its owner. `HoverCard.inline` keeps a file link in the text flow and uses the shared menu material, keyboard-visible focus, and placement above or below the anchor without covering it. Escape closes an open thumbnail even when focus is elsewhere; subsequent Escape presses reach the owner. `MarkdownDelegateProvider.fileImages` supplies a decoded-path resolver and complete image labels: settled image links gain hover previews, and standalone images gain lightbox activation. Image-only anchors retain their single navigation target. The parser recovers only complete, unescaped, standalone local image references with bare spaces and an unambiguous supported image suffix; code and ambiguous destinations remain literal.
### Localizing copy
The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface.
+4 -1
View File
@@ -54,6 +54,7 @@ kind: "package-library"
| `OnboardingSurface` | 首次运行的引导舞台,期间保持应用根节点 inert。 |
| `Tooltip` | 锚定在克隆子元素上的悬停文本;可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 |
| `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 |
| `ImageLightbox` | 共享图片浮层,支持焦点恢复与 Esc 关闭。 |
| `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 |
| `SettingsForm`、`SettingsValueField`、`SettingsSecretField` | 插件设置页的框架与控件:框架以 `labels` 接收文案,只在按钮点击时保存,卸载即丢弃;值字段显示暂存文本以及已覆盖标签和重置;密文字段每次为空,只报告是否已配置。 |
| `SettingsFormModel`、`settingsNumberField`、`settingsTextField` | 这类页面背后基于设置 scope 的暂存编辑模型:草稿先暂存、保存时写入,字段是否被覆盖看用户层是否含有它,未落地的保存保留草稿。 |
@@ -85,7 +86,7 @@ kind: "package-library"
最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。
`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。引用名称继承调用方的换行规则:长名称在气泡内换行,排队预览保持单行布局。传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。
`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。引用名称继承调用方的换行规则:长名称在气泡内换行,排队预览保持单行布局。传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。
`MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。
@@ -95,6 +96,8 @@ kind: "package-library"
`JsonTree` 把折叠字符串限制为 `collapsedStringLines` 行(默认三行)。展开后显示原始文本、保留同级逗号,并限制在窗口与外层滚动容器内;尺寸变化和祖先滚动事件会更新此限制。行复制反馈独立于 JSON 值渲染更新;尚未完成的剪贴板写入不会更新另一行或已卸载的树。
`ImageLightbox` 是共享原图浮层,支持焦点恢复与 Esc 关闭。包内缩略图渲染器由调用方提供加载及失败文案。`HoverCard.inline` 使文件链接保持在文本流内,并使用共享菜单材质、键盘可见焦点,并在锚点上方或下方定位而不遮挡锚点。即使焦点位于其他位置,Esc 也会关闭已打开的缩略图;后续 Esc 按键继续传给 owner。`MarkdownDelegateProvider.fileImages` 提供已解码路径解析器与完整图片文案:消息落定后的图片链接支持悬停预览,独立图片支持点击放大。仅包含图片的链接保留单一导航目标。解析器仅恢复完整、未转义、独立成段且带明确图片扩展名的含裸空格本地图片引用;代码与有歧义的目标保持原文。
### 本地化文案
这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。
@@ -79,3 +79,16 @@
@media (prefers-reduced-motion: reduce) {
.preview { transition: none; }
}
.inline { display: inline; }
.media {
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
overflow: auto;
padding: 8px;
border: 0;
color: var(--dsw-alias-label-primary);
background: var(--dsw-specific-menu);
backdrop-filter: var(--dsw-menu-backdrop-filter);
box-shadow: var(--dsw-elevation-panel);
}
+41 -13
View File
@@ -11,6 +11,7 @@ import css from './HoverCard.module.css'
const PREVIEW_FADE_MS = 100
const PREVIEW_MAX_HEIGHT = 420
const PREVIEW_INSET = 24
const INLINE_PREVIEW_WIDTH = 300
const ANCHOR_GAP = 8
const VIEWPORT_MARGIN = 8
@@ -23,6 +24,7 @@ const VIEWPORT_MARGIN = 8
* @param props.variant - compact card beside the anchor, or a preview above/below it
* with 24px side insets, a 420px height cap, frame-top clearance, and 100ms opacity transitions.
* @param props.widthAnchorRef - optional element whose width and horizontal position size the preview.
* @param props.inline - keep the anchor in prose; show a contained preview on hover or keyboard focus.
* @param props.disabled - suppress opening; turning true dismisses an open card.
* @param props.copyText - optional primary value copied by activation and
* included in the card's accessible name.
@@ -32,9 +34,11 @@ const VIEWPORT_MARGIN = 8
*/
export function HoverCard({
anchor, content, openDelayMs = 500, disabled = false,
copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef,
copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false,
}: {
anchor: ReactNode
/** Inline media preview using the shared menu material and keyboard focus. */
inline?: boolean
content: ReactNode
openDelayMs?: number
disabled?: boolean
@@ -112,15 +116,17 @@ export function HoverCard({
}, [])
useEffect(() => {
if (!open || variant !== 'preview') return
if (!open || (variant !== 'preview' && !inline)) return
const dismiss = (event: KeyboardEvent): void => {
if (event.key !== 'Escape') return
if (inline) event.stopPropagation()
clearTimer()
cancelClose()
close()
}
window.addEventListener('keydown', dismiss)
return () => { window.removeEventListener('keydown', dismiss) }
}, [open, variant, cancelClose, close])
window.addEventListener('keydown', dismiss, inline)
return () => { window.removeEventListener('keydown', dismiss, inline) }
}, [open, variant, inline, cancelClose, close])
// Fixed-position from the anchor rect before paint; track the anchor while
// open (capture-phase scroll catches nested panes), as in Menu portal mode.
@@ -148,11 +154,27 @@ export function HoverCard({
})
return
}
if (inline) {
const height = Math.max(h, cardRef.current?.scrollHeight ?? 0)
const width = Math.max(0, Math.min(INLINE_PREVIEW_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2))
const topMargin = overlayTopMargin(VIEWPORT_MARGIN)
const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP)
const above = Math.max(0, r.top - ANCHOR_GAP - topMargin)
const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN)
const onTop = height > below && above > below
const maxHeight = onTop ? above : below
setPos({
left: Math.max(VIEWPORT_MARGIN, Math.min(r.left, window.innerWidth - width - VIEWPORT_MARGIN)),
top: onTop ? r.top - ANCHOR_GAP - Math.min(height, maxHeight) : belowTop,
width, maxHeight,
})
return
}
const top = r.top + h > window.innerHeight - VIEWPORT_MARGIN ? window.innerHeight - h - VIEWPORT_MARGIN : r.top
setPos({ left: r.right + ANCHOR_GAP, top })
}
place()
const observer = variant === 'preview' && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null
const observer = (variant === 'preview' || inline) && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null
for (const element of [cardRef.current, rootRef.current, widthAnchorRef?.current]) {
if (element !== null && element !== undefined) observer?.observe(element)
}
@@ -163,19 +185,19 @@ export function HoverCard({
window.removeEventListener('scroll', place, true)
window.removeEventListener('resize', place)
}
}, [open, variant, widthAnchorRef, positioned])
}, [open, variant, widthAnchorRef, positioned, inline])
// The first placement ran before the card mounted (height read 0): once the
// card's real height is measurable, correct the bottom-edge clamp. The
// correction converges — a clamped top satisfies the guard, so it runs once.
useLayoutEffect(() => {
if (!open || pos === null || variant === 'preview') return
if (!open || pos === null || variant === 'preview' || inline) return
/* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */
const h = cardRef.current?.offsetHeight ?? 0
if (pos.top + h > window.innerHeight - VIEWPORT_MARGIN) {
setPos({ left: pos.left, top: window.innerHeight - h - VIEWPORT_MARGIN })
}
}, [open, pos, variant])
}, [open, pos, variant, inline])
const copy = async (text: string): Promise<void> => {
if (copied || copyingRef.current) return
@@ -202,7 +224,7 @@ export function HoverCard({
const card = open && pos !== null && (
<div
ref={cardRef}
className={clsx(css.card, variant === 'preview' && css.preview, copyable && css.copyable, copied && css.feedback)}
className={clsx(css.card, variant === 'preview' && css.preview, inline && css.media, copyable && css.copyable, copied && css.feedback)}
data-closing={closing || undefined}
style={{
...pos, minHeight: copied && copyHeightRef.current !== null ? copyHeightRef.current : undefined,
@@ -237,9 +259,15 @@ export function HoverCard({
return (
<span
ref={rootRef}
className={css.root}
onPointerEnter={() => {
if (disabled) return
className={clsx(css.root, inline && css.inline)}
onFocus={inline ? (event) => {
if (!disabled && event.target.matches(':focus-visible')) { cancelClose(); setPhase('open') }
} : undefined}
onBlur={inline ? (event) => {
if (!event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() }
} : undefined}
onPointerEnter={(event) => {
if (disabled || (inline && event.pointerType === 'touch')) return
// Coming back inside during the grace (the gap, or the card itself)
// keeps the current card rather than restarting the dwell.
cancelClose()
@@ -24,7 +24,8 @@
object-fit: contain;
border-radius: 12px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv3);
border: 0;
box-shadow: var(--dsw-elevation-prominent);
}
.close {
@@ -43,3 +44,5 @@
color: var(--dsw-alias-label-primary);
cursor: pointer;
}
.close:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 3px; }
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { IconCloseOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCloseOutlineRegular } from './icons/index.tsx'
import css from './ImageLightbox.module.css'
/** Lightbox strings the owner resolves from its own locale namespace. */
@@ -37,11 +37,12 @@ export function ImageLightbox({ src, alt, labels, onClose }: {
restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
closeRef.current?.focus()
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
if (event.key === 'Escape') onClose()
if (event.key === 'Escape') { event.stopPropagation(); onClose() }
if (event.key === 'Tab') { event.preventDefault(); closeRef.current?.focus() }
}
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keydown', onKeyDown, true)
return () => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keydown', onKeyDown, true)
restoreRef.current?.focus()
}
}, [onClose])
@@ -0,0 +1,24 @@
.frame {
position: relative;
display: grid;
place-items: center;
width: 100%;
height: 180px;
overflow: hidden;
border-radius: 8px;
background: var(--dsw-alias-bg-base);
}
.image { width: 100%; height: 100%; min-height: 0; object-fit: contain; opacity: 0; }
.image[data-ready] { opacity: 1; }
.status {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
}
@@ -0,0 +1,36 @@
/** Passive, contained image preview for Markdown image links. */
import { useState } from 'react'
import { IconLoadingOutlineRegular } from './icons/index.tsx'
import css from './ImagePreview.module.css'
/**
* Render an image without introducing a second activation target.
* @param props - Source, accessible description, localized status.
* @returns A contained preview with loading or failure status.
*/
export function ImagePreview({ src, alt, loadingLabel, failedLabel }: {
src: string
alt: string
loadingLabel: string
failedLabel: string
}) {
return <Preview key={src} src={src} alt={alt} loadingLabel={loadingLabel} failedLabel={failedLabel} />
}
function Preview({ src, alt, loadingLabel, failedLabel }: {
src: string
alt: string
loadingLabel: string
failedLabel: string
}) {
const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading')
return <span className={css.frame}>
{state !== 'failed' && <img src={src} alt={alt} loading="lazy" decoding="async" referrerPolicy="no-referrer"
className={css.image} data-ready={state === 'ready' || undefined}
onLoad={() => { setState('ready') }} onError={() => { setState('failed') }} />}
{state !== 'ready' && <span className={css.status} role="status">
{state === 'loading' && <IconLoadingOutlineRegular size={16} />}
<span>{state === 'loading' ? loadingLabel : failedLabel}</span>
</span>}
</span>
}
@@ -102,3 +102,5 @@ export * from './icons/index.tsx'
export {
PluginArtworkTerminal, PluginArtworkLoop, PluginArtworkSubagent, PluginArtworkSearch, PluginArtworkDefault,
} from './plugin-artwork.tsx'
export { ImageLightbox } from './ImageLightbox.tsx'
export type { ImageLightboxLabels } from './ImageLightbox.tsx'
@@ -1,6 +1,7 @@
/** Consumer-owned navigation for Markdown links. */
import { createContext, useContext, useMemo } from 'react'
import type { ReactNode } from 'react'
import type { ImageLightboxLabels } from '../ImageLightbox.tsx'
/**
* Handle one sanitized absolute HTTP(S) URL selected from Markdown.
@@ -10,6 +11,11 @@ export type MarkdownExternalLinkHandler = (href: string) => void
/** Navigation capabilities supplied by the nearest Markdown owner. */
export interface MarkdownDelegate {
/** Image previews for decoded local paths in this owner's workspace. */
readonly fileImages?: {
resolve: (path: string) => string | undefined
labels: ImageLightboxLabels & { open: string; loading: string; failed: string }
} | undefined
/** Ordinary HTTP(S) activation; absent handlers retain native anchor behavior. */
readonly openExternalLink?: MarkdownExternalLinkHandler | undefined
/**
@@ -37,8 +43,9 @@ export function MarkdownDelegateProvider({
children,
openExternalLink,
openFile,
fileImages,
}: MarkdownDelegateProviderProps): ReactNode {
const delegate = useMemo(() => ({ openExternalLink, openFile }), [openExternalLink, openFile])
const delegate = useMemo(() => ({ openExternalLink, openFile, fileImages }), [openExternalLink, openFile, fileImages])
return (
<MarkdownDelegateContext.Provider value={delegate}>
{children}
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff. Show more