feat(desktop): create safe web profiles

This commit is contained in:
t4we fan
2026-08-21 15:13:16 +08:00
parent d17c7d2f81
commit 73e5fa9910
5 changed files with 125 additions and 2 deletions
+55 -2
View File
@@ -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
+13
View File
@@ -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()
@@ -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', () => {
@@ -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()
@@ -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 },