From 6407ed8a441b71568bee25acd4a2b86aeabc000a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 4 Sep 2026 04:48:58 +0800 Subject: [PATCH] fix(plugins): recognize the harness cordis copy by package directory The probe compared the resolved cordis entry URLs of the child process and the host. Under the source launcher tsx resolves the host's copy to vendor/cordis/src/index.ts while the plain-Node child resolves lib/index.js, so every third-party package was refused as carrying its own copy. Compare the real package directories instead. The install dialog now shows the Host's refusal reason after pnpm's output when a run installs but cannot enable, and no longer forces a width the modal cannot give it. --- packages/boot/app-boot/src/probe.ts | 41 +++++++++++++++++-- packages/boot/app-boot/tests/probe.spec.ts | 28 ++++++++++++- .../PluginManagerSettingsTab.module.css | 2 +- .../src/client/manager-store.ts | 8 ++-- .../tests/manager-store.client.spec.ts | 12 +++++- 5 files changed, 81 insertions(+), 10 deletions(-) diff --git a/packages/boot/app-boot/src/probe.ts b/packages/boot/app-boot/src/probe.ts index e4adeed8d6..6e7fce7320 100644 --- a/packages/boot/app-boot/src/probe.ts +++ b/packages/boot/app-boot/src/probe.ts @@ -8,8 +8,9 @@ */ import { spawn } from 'node:child_process' -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { loadOverlayPatches } from './index.ts' import { readProfileManifest, resolveBundleDir, type ProfileManifest } from './profile.ts' @@ -220,7 +221,12 @@ export async function probePackage(options: ProbeOptions): Promise const hasMain = manifest.main !== undefined || manifest.exports !== undefined const report = await runChild(options, packageDir, hasMain ? options.packageName : '', declaredAddable.map(entry => entry.name)) const harnessCordis = resolveHarnessCordis() - const cordisSameCopy = report.cordis === null || harnessCordis === undefined ? null : report.cordis === harnessCordis + // Compared by package directory, not by entry file: a source launch + // resolves the harness's copy to its TypeScript entry while the child, a + // plain Node process, resolves the same package's built entry. + const cordisSameCopy = report.cordis === null || harnessCordis === undefined + ? null + : cordisPackageDir(report.cordis) === cordisPackageDir(harnessCordis) const kind: PluginProbe['kind'] = declaredBundle !== undefined ? 'bundle' : report.main.isPlugin ? 'plugin' : 'library' const addable: PluginProbeAddable[] = declaredAddable.map((entry) => { // The child reports every declared name; a missing report is a child @@ -275,6 +281,35 @@ function resolveHarnessCordis(): string | undefined { } } +/** + * The real directory of the `@deepseek-ai/cordis` package a resolved entry + * URL belongs to: the nearest ancestor whose manifest carries that name. + * @param url - a `file:` URL of one cordis entry module. + * @returns the package directory with symlinks resolved, or the entry path + * itself when no such manifest is found above it. + */ +function cordisPackageDir(url: string): string { + let dir = dirname(fileURLToPath(url)) + for (;;) { + const manifest = join(dir, 'package.json') + if (existsSync(manifest)) { + let name: unknown + try { + name = (JSON.parse(readFileSync(manifest, 'utf8')) as { name?: unknown }).name + } catch { + // A manifest Node's resolver did not read — an unrelated ancestor's, + // malformed — is not cordis's; keep climbing. + /* v8 ignore next */ + name = undefined + } + if (name === '@deepseek-ai/cordis') return realpathSync(dir) + } + const parent = dirname(dir) + if (parent === dir) return fileURLToPath(url) + dir = parent + } +} + /** The cache file for one package under a profile. */ function probeCachePath(profileDir: string, packageName: string): string { return join(profileDir, PLUGIN_PROBE_DIR, `${packageName.replaceAll('/', '__')}.json`) diff --git a/packages/boot/app-boot/tests/probe.spec.ts b/packages/boot/app-boot/tests/probe.spec.ts index c8918d73d6..36c6ce32b3 100644 --- a/packages/boot/app-boot/tests/probe.spec.ts +++ b/packages/boot/app-boot/tests/probe.spec.ts @@ -3,9 +3,10 @@ * a child process, and the per-profile cache. */ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, writeFileSync, symlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { PLUGIN_PROBE_DIR, probePackage, readProbeCache, writeProbeCache, type PluginProbe } from '../src/index.ts' @@ -120,6 +121,16 @@ describe('probePackage', () => { 'node_modules/@deepseek-ai/cordis/index.js': 'export const Context = class {}\n', }, }, + // A copy whose manifest carries another name still resolves as cordis + // for Node; with no cordis manifest above its entry, the entry itself + // stands for the copy, which is not the harness's. + 'renamed-cordis': { + main: 'export function apply() {}\n', + files: { + 'node_modules/@deepseek-ai/cordis/package.json': JSON.stringify({ name: 'not-cordis', version: '0.0.0', type: 'module', main: './index.js' }), + 'node_modules/@deepseek-ai/cordis/index.js': 'export const Context = class {}\n', + }, + }, }) const broken = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'broken' }) expect(broken.ok).toBe(false) @@ -130,6 +141,21 @@ describe('probePackage', () => { expect(own.cordisSameCopy).toBe(false) expect(own.ok).toBe(false) expect(own.reason).toContain('own copy of @deepseek-ai/cordis') + const renamed = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'renamed-cordis' }) + expect(renamed.cordisSameCopy).toBe(false) + }) + + it('recognizes the harness copy of cordis by its package directory, whichever entry each side resolved', async () => { + // The staged package reaches cordis through a symlink to the harness's + // own package; the child resolves its built entry, this process (under + // the test runner's source aliases) its TypeScript entry. + const { profileDir, installAnchor } = stage({ 'shared-cordis': { main: 'export function apply() {}\n' } }) + const harnessCordis = fileURLToPath(new URL('../../../../vendor/cordis', import.meta.url)) + mkdirSync(join(profileDir, 'node_modules', 'shared-cordis', 'node_modules', '@deepseek-ai'), { recursive: true }) + symlinkSync(harnessCordis, join(profileDir, 'node_modules', 'shared-cordis', 'node_modules', '@deepseek-ai', 'cordis'), 'dir') + const shared = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'shared-cordis' }) + expect(shared.cordisSameCopy).toBe(true) + expect(shared.ok).toBe(true) }) it('probes a package with the minimal manifest and no main export', async () => { diff --git a/packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.module.css b/packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.module.css index b2266945f8..cb500529f5 100644 --- a/packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.module.css +++ b/packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.module.css @@ -430,7 +430,7 @@ display: flex; flex-direction: column; gap: 10px; - min-width: min(520px, 80vw); + min-width: 0; } .installField { diff --git a/packages/client/ui-settings-plugin-manager/src/client/manager-store.ts b/packages/client/ui-settings-plugin-manager/src/client/manager-store.ts index 1fe537b9fd..c0c2ad3157 100644 --- a/packages/client/ui-settings-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-settings-plugin-manager/src/client/manager-store.ts @@ -341,10 +341,12 @@ export class PluginManagerController { if (result.ok) { this.patchInstall({ phase: 'done', installed: result.value.installed }) } else { - // Streamed chunks already show the run; otherwise the Host's captured - // log, or its message when pnpm never started. + // The Host's reason follows whatever streamed: pnpm's captured log when + // no chunk arrived, else the refusal that followed a successful pnpm + // run — a bundle the tree rejected, a probe that refused it. const current = this.getSnapshot().install.log - const log = current === '' ? detailOf(result.error, 'log') ?? result.error.message : current + const reason = detailOf(result.error, 'reason') ?? detailOf(result.error, 'log') ?? result.error.message + const log = current === '' || current.endsWith(reason) ? (current === '' ? reason : current) : `${current}\n${reason}` this.patchInstall({ phase: 'failed', log }) } void this.load() diff --git a/packages/client/ui-settings-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-settings-plugin-manager/tests/manager-store.client.spec.ts index c07248d914..8b6df128b6 100644 --- a/packages/client/ui-settings-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-settings-plugin-manager/tests/manager-store.client.spec.ts @@ -288,7 +288,8 @@ describe('PluginManagerController', () => { add: vi.fn() .mockResolvedValueOnce(refused('plugins/install-failed', 'exit 1', { spec: 'x', exitCode: 1, log: 'ERR_PNPM' })) .mockResolvedValueOnce(refused('gateway/internal', 'offline')) - .mockResolvedValueOnce(refused('plugins/install-failed', 'exit 1', { spec: 'x', exitCode: 1, log: 'ignored' })), + .mockResolvedValueOnce(refused('plugins/install-failed', 'exit 1', { spec: 'x', exitCode: 1, log: 'tail' })) + .mockResolvedValueOnce(refused('plugins/enable-failed', 'plugin-manager: x rejected', { packageName: 'x', reason: 'the tree rejected it' })), }) await controller.load() face.openInstall() @@ -303,7 +304,14 @@ describe('PluginManagerController', () => { controller.appendLog({ jobId: 'j', spec: 'x', stream: 'stderr', text: 'streamed' }) await vi.waitFor(() => { expect(plugins.add).toHaveBeenCalledTimes(3) }) await vi.waitFor(() => { expect(state().install.phase).toBe('failed') }) - expect(state().install.log).toBe('streamed') + // The Host's captured log follows what streamed; a refusal after a clean + // pnpm run follows it with its reason. + expect(state().install.log).toBe('streamed\ntail') + face.runInstall() + controller.appendLog({ jobId: 'j', spec: 'x', stream: 'stdout', text: 'Done' }) + await vi.waitFor(() => { expect(plugins.add).toHaveBeenCalledTimes(4) }) + await vi.waitFor(() => { expect(state().install.phase).toBe('failed') }) + expect(state().install.log).toBe('Done\nthe tree rejected it') }) it('drops every late settlement after disposal', async () => {