diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 7e395f72d5..430fd91f46 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -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 diff --git a/apps/cli/README.md b/apps/cli/README.md index 4eb113a615..7654404381 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -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` diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 6a0e8a38a7..08db105b16 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -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` diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index e52c7c8202..20f5867e23 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -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 { - const version = readVersion() + const version = getDshRuntimeVersion() const invocation = parseDshArgs(process.argv.slice(2), version) switch (invocation.mode) { diff --git a/apps/cli/src/plugin.ts b/apps/cli/src/plugin.ts index 12a0dbc443..a246af7573 100644 --- a/apps/cli/src/plugin.ts +++ b/apps/cli/src/plugin.ts @@ -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 { + 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} --dsh-version ${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 { + 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') diff --git a/apps/cli/tests/plugin-compatibility.expected.e2e.ts b/apps/cli/tests/plugin-compatibility.expected.e2e.ts new file mode 100644 index 0000000000..a002bb2514 --- /dev/null +++ b/apps/cli/tests/plugin-compatibility.expected.e2e.ts @@ -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', 'compatibility-fixture@1.0.0', '--dsh-version', version]) + expect(missingConsent.exitCode).toBe(1) + const granted = await plugin(['allow-version', 'compatibility-fixture@1.0.0', '--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({ 'compatibility-fixture@1.0.0': [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', 'compatibility-fixture@1.0.0', '--dsh-version', version])).exitCode).toBe(0) + expect((await run(['--profile', 'compatibility'])).stdout).not.toContain('PLUGIN_IMPORTED') +}) diff --git a/apps/cli/tests/plugin.spec.ts b/apps/cli/tests/plugin.spec.ts new file mode 100644 index 0000000000..f3163d5d3d --- /dev/null +++ b/apps/cli/tests/plugin.spec.ts @@ -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(), + 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/plugin@1.2.3', '--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/plugin@1.2.3': [runtime] }) + expect(JSON.parse(readFileSync(join(dir, 'compatibility.json'), 'utf8'))).toEqual({ '@example/plugin@1.2.3': [runtime] }) + expect(await runPlugin('test', ['version-exemptions'])).toBe(0) + expect(stdout).toHaveBeenLastCalledWith(JSON.stringify({ '@example/plugin@1.2.3': [runtime] }, undefined, 2) + '\n') + expect(await runPlugin('test', ['revoke-version', '@example/plugin@1.2.3', `--dsh-version=${runtime}`])).toBe(0) + expect(readProfileVersionExemptions(dir)).toEqual({}) + expect(runPluginCommand).not.toHaveBeenCalled() +}) + +it.each([ + ['allow-version', 'plugin@1.2.3', '--dsh-version', 'CURRENT'], + ['allow-version', 'plugin@1.2.3', '--accept-risk'], + ['allow-version', 'plugin@^1.2.3', '--dsh-version', 'CURRENT', '--accept-risk'], + ['allow-version', 'plugin@1.2.3', '--dsh-version', '999.0.0', '--accept-risk'], + ['allow-version', 'plugin@1.2.3', '--dsh-version', 'CURRENT', '--accept-risk=false'], + ['allow-version', 'plugin@1.2.3', '--dsh-version', 'CURRENT', '--accept-risk', '--accept-risk'], + ['revoke-version', 'plugin@1.2.3'], + ['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') +}) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 14c2728288..151120c05a 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -238,6 +238,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) { @@ -277,6 +287,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) @@ -306,6 +319,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 { + // 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 { return await excel.evaluate((node, direction) => { @@ -374,6 +419,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 }) diff --git a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md index 06c0c5258b..25c6502dad 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -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" diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index 199f2afb89..31dee5c21a 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -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" diff --git a/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md b/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md index f4bdf601f8..62c89bd029 100644 --- a/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md +++ b/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md @@ -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]() when an image supports an explanation or comparison. Use [Description]() 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. diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts index b8f18c6c67..bc24459a1e 100644 --- a/apps/web/tests/markdown-images.e2e.ts +++ b/apps/web/tests/markdown-images.e2e.ts @@ -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) }) diff --git a/apps/web/tests/plugin-install-registry.e2e.ts b/apps/web/tests/plugin-install-registry.e2e.ts index 16a9d7d0a9..ca1ef80e6d 100644 --- a/apps/web/tests/plugin-install-registry.e2e.ts +++ b/apps/web/tests/plugin-install-registry.e2e.ts @@ -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() diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 2edb529bbc..e14a54aeb8 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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: 46ecae67e69b937b2d8137f3f8d1e5d0bf834b91 -config-catalog.zh.md: 07c6288a46e6019742913df2a08e74cd392a089b +config-catalog.md: cb647ea0a7bd8014e7f199ef0ec1a8cae362dc0e +config-catalog.zh.md: 6e80a049d2041821af37ee1df851505d6b3bfbe5 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 46ecae67e6..cb647ea0a7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 07c6288a46..6e80a049d2 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -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) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index e3971707b4..a86f94ab5f 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -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 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 54a5e36bb6..fb0816fe5f 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 248aacbafc..b2f7a1b207 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -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` | diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index 598649b415..54a45c2704 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.i18n.yaml @@ -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 diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 5faf482b05..0739170597 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -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; 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 + /** Read current plugins, including why a row cannot be changed through the profile patch. * @returns Current runtime entries with persistent patch targets. */ diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index 1f50732fd3..64ff36d412 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -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; 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 + /** Read current plugins, including why a row cannot be changed through the profile patch. * @returns Current runtime entries with persistent patch targets. */ diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index d020d136a3..2b04d1eacd 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -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 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 8e1f4f7c8c..3e4407192e 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -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", diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 0995196921..cf78ee4d4f 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -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", diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index ff61bc8512..8a77721112 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -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 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 70a8e235ac..729a4b1552 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -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/` 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 --from-default-profile