diff --git a/dsh-plugin-desktop/src/profile-manager.ts b/dsh-plugin-desktop/src/profile-manager.ts index faaee00365..1704343f95 100644 --- a/dsh-plugin-desktop/src/profile-manager.ts +++ b/dsh-plugin-desktop/src/profile-manager.ts @@ -11,6 +11,7 @@ import { openSync, readFileSync, readdirSync, + rmSync, renameSync, unlinkSync, writeFileSync, @@ -18,8 +19,10 @@ import { import { basename, dirname, join } from 'node:path' import { PROFILE_TEMPLATES, + initProfile, readProfileManifest, resolveProfileDir, + writeProfileManifest, } from '@deepseek-ai/dsh-app-boot' const BIN_NAME = 'dsh-plugin-desktop' @@ -33,6 +36,7 @@ const STATE_VERSION = 1 const MAX_STATE_BYTES = 4 * 1024 const STATE_DIRECTORY_MODE = 0o700 const STATE_FILE_MODE = 0o600 +const MAX_PROFILE_NAME_BYTES = 255 /** One discovered or lazily available DSH profile. */ export interface DesktopProfileSummary { @@ -93,10 +97,15 @@ function defaultState(): DesktopProfileStateV1 { /** Reject profile names that cannot safely cross the persisted state boundary. */ export function assertDesktopProfileName(name: string): void { - resolveProfileDir(name, '/') - if (name.length > 255 || /[\0-\x1f\x7f]/.test(name)) { + if (typeof name !== 'string' || name.length === 0 + || name.includes('/') || name.includes('\\') || name === '.' || name === '..' + || name === 'node_modules' || Buffer.byteLength(name, 'utf8') > MAX_PROFILE_NAME_BYTES + || /[\0-\x1f\x7f-\x9f]/.test(name) + || /[<>:"|?*]/.test(name) || /[. ]$/.test(name) + || /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$/i.test(name)) { throw new Error(`${BIN_NAME}: invalid desktop profile name ${JSON.stringify(name)}`) } + resolveProfileDir(name, '/') } /** Validate a manifest bundle list without trusting arbitrary JSON values. */ @@ -156,6 +165,50 @@ function virtualProfile(name: typeof DEFAULT_PROFILE_NAME | typeof WEB_PROFILE_N } } +/** + * Create a safe Web profile using only the shipped template. + * + * The profile is initialized in a sibling staging directory and published with + * one rename, so a failed initialization never leaves a partially initialized + * target visible to discovery. + */ +export function createDesktopWebProfile(home: string, name: string): DesktopProfileSummary { + assertDesktopProfileName(name) + const template = PROFILE_TEMPLATES.web + if (template === undefined) { + throw new Error(`${BIN_NAME}: installed dsh-app-boot has no web profile template`) + } + const target = resolveProfileDir(name, home) + const profilesDir = dirname(target) + mkdirSync(profilesDir, { recursive: true }) + try { + lstatSync(target) + throw new Error(`${BIN_NAME}: profile ${JSON.stringify(name)} already exists`) + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code !== 'ENOENT') throw cause + } + + const staging = join(profilesDir, `.${basename(target)}.creating-${process.pid}-${randomUUID()}`) + try { + initProfile(staging, [...template]) + const manifest = readProfileManifest(BIN_NAME, staging) + writeProfileManifest(staging, { ...manifest, name: `dsh-profile-${name}` }) + // The target is checked again immediately before publication. `renameSync` + // is atomic on the same filesystem; an existing target is never replaced. + try { + lstatSync(target) + throw new Error(`${BIN_NAME}: profile ${JSON.stringify(name)} already exists`) + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code !== 'ENOENT') throw cause + } + renameSync(staging, target) + } catch (cause) { + rmSync(staging, { recursive: true, force: true }) + throw cause + } + return existingProfile(name, home) +} + /** Deterministic profile order with the product defaults first. */ function compareProfiles(left: DesktopProfileSummary, right: DesktopProfileSummary): number { const priority = (name: string): number => name === DEFAULT_PROFILE_NAME ? 0 : name === WEB_PROFILE_NAME ? 1 : 2 diff --git a/dsh-plugin-desktop/src/profile-service.ts b/dsh-plugin-desktop/src/profile-service.ts index 5419241d79..c33556a0f8 100644 --- a/dsh-plugin-desktop/src/profile-service.ts +++ b/dsh-plugin-desktop/src/profile-service.ts @@ -15,6 +15,8 @@ export interface DesktopCurrentProfile { export interface DesktopProfiles { /** Immutable identity of the profile backing this Cordis generation. */ readonly current: DesktopCurrentProfile + /** Create a safe Web profile without selecting or restarting it. */ + create(name: string): DesktopProfileSummary /** Re-read the available profile manifests without changing them. */ list(): readonly DesktopProfileSummary[] /** Persist a compatible profile and request an orderly application restart. */ @@ -32,6 +34,8 @@ declare module '@deepseek-ai/cordis' { export interface DesktopProfileServiceBootstrap { /** Profile that produced the current Cordis generation. */ readonly current: DesktopCurrentProfile + /** Create a safe Web profile without selecting or restarting it. */ + create?: (name: string) => DesktopProfileSummary /** Re-read the available profile manifests without changing them. */ list(): readonly DesktopProfileSummary[] /** Persist one validated profile as pending for the next startup. */ @@ -81,6 +85,15 @@ export class DesktopProfileService extends Service implements DesktopProfiles { return this.fixedCurrent } + /** Create a profile through the launcher-owned implementation. */ + create(name: string): DesktopProfileSummary { + this.assertActive() + if (this.bootstrap.create === undefined) { + throw new Error('dsh-plugin-desktop: profile creation is unavailable') + } + return this.bootstrap.create(name) + } + /** Re-read profile discovery through the launcher-owned implementation. */ list(): readonly DesktopProfileSummary[] { this.assertActive() diff --git a/dsh-plugin-desktop/tests/profile-manager.spec.ts b/dsh-plugin-desktop/tests/profile-manager.spec.ts index 0d0aab4a3b..118ed1508c 100644 --- a/dsh-plugin-desktop/tests/profile-manager.spec.ts +++ b/dsh-plugin-desktop/tests/profile-manager.spec.ts @@ -15,6 +15,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { assertDesktopProfileName, beginDesktopProfileStartup, + createDesktopWebProfile, listDesktopProfiles, markDesktopProfileFailed, markDesktopProfileHealthy, @@ -50,6 +51,43 @@ describe('desktop profile discovery', () => { expect(() => assertDesktopProfileName('团队 profile')).not.toThrow() expect(() => assertDesktopProfileName('profile\nname')).toThrow('invalid desktop profile name') expect(() => assertDesktopProfileName('../outside')).toThrow() + expect(() => assertDesktopProfileName('CON.txt')).toThrow('invalid desktop profile name') + expect(() => assertDesktopProfileName('name.')).toThrow('invalid desktop profile name') + expect(() => assertDesktopProfileName('name ')).toThrow('invalid desktop profile name') + expect(() => assertDesktopProfileName('é'.repeat(128))).toThrow('invalid desktop profile name') + }) + + it('creates a Web profile from the shipped template and publishes all files together', () => { + const home = temporaryRoot() + + expect(createDesktopWebProfile(home, 'work')).toEqual(expect.objectContaining({ + name: 'work', + dir: join(home, 'profiles', 'work'), + exists: true, + bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'], + webCapable: true, + })) + expect(readFileSync(join(home, 'profiles', 'work', 'package.json'), 'utf8')) + .toContain('"name": "dsh-profile-work"') + expect(existsSync(join(home, 'profiles', 'work', 'cordis.patch.yml'))).toBe(true) + expect(existsSync(join(home, 'profiles', 'work', 'pnpm-workspace.yaml'))).toBe(true) + expect(readdirSync(join(home, 'profiles')).filter(name => name.includes('.work.creating-'))).toEqual([]) + }) + + it('does not overwrite an existing profile or leave a target after failure', () => { + const home = temporaryRoot() + const existing = writeProfile(home, 'work', ['@deepseek-ai/dsh-base']) + const before = readFileSync(join(existing, 'package.json'), 'utf8') + + expect(() => createDesktopWebProfile(home, 'work')).toThrow('already exists') + expect(readFileSync(join(existing, 'package.json'), 'utf8')).toBe(before) + expect(readdirSync(join(home, 'profiles')).filter(name => name.includes('.work.creating-'))).toEqual([]) + + const blockedHome = join(home, 'blocked-home') + mkdirSync(blockedHome, { recursive: true }) + writeFileSync(join(blockedHome, 'profiles'), 'not a directory') + expect(() => createDesktopWebProfile(blockedHome, 'work')).toThrow() + expect(existsSync(join(blockedHome, 'profiles', 'work'))).toBe(false) }) it('lists lazy defaults and existing profiles without creating or changing manifests', () => { diff --git a/dsh-plugin-desktop/tests/profile-service.spec.ts b/dsh-plugin-desktop/tests/profile-service.spec.ts index 6feac8c8f2..3f7ea2cb65 100644 --- a/dsh-plugin-desktop/tests/profile-service.spec.ts +++ b/dsh-plugin-desktop/tests/profile-service.spec.ts @@ -77,6 +77,24 @@ describe('desktop profile service', () => { expect(list).toHaveBeenCalledTimes(2) }) + it('creates a profile through the launcher without selecting it', async () => { + const create = vi.fn(() => WORK) + const mounted = await mount(createBootstrap({ create })) + + expect(mounted.service.create('work')).toEqual(WORK) + expect(create).toHaveBeenCalledWith('work') + expect(mounted.service.current.name).toBe('desktop') + }) + + it('rejects profile creation through a retained service after disposal', async () => { + const create = vi.fn(() => WORK) + const mounted = await mount(createBootstrap({ create })) + await mounted.dispose() + + expect(() => mounted.service.create('work')).toThrow('desktopProfiles service disposed') + expect(create).not.toHaveBeenCalled() + }) + it('does nothing when the current profile is selected', async () => { const persistSelection = vi.fn() const requestRestart = vi.fn() diff --git a/dsh-plugin-desktop/tests/profiles.spec.ts b/dsh-plugin-desktop/tests/profiles.spec.ts index 64dc3e5c47..279537f2e3 100644 --- a/dsh-plugin-desktop/tests/profiles.spec.ts +++ b/dsh-plugin-desktop/tests/profiles.spec.ts @@ -21,6 +21,7 @@ describe('desktop profiles Host plugin', () => { } as unknown as DesktopRuntime const profiles: DesktopProfiles = { current: { name: 'desktop', dir: '/profiles/desktop' }, + create: vi.fn(), list: () => [ { name: 'desktop', dir: '/profiles/desktop', exists: true, bundles: [], webCapable: true }, { name: '工作 profile', dir: '/profiles/work', exists: true, bundles: [], webCapable: true },