diff --git a/apps/buddy/extensions/sdk/authoring.ts b/apps/buddy/extensions/sdk/authoring.ts index 731bcf779..b620b0cbb 100644 --- a/apps/buddy/extensions/sdk/authoring.ts +++ b/apps/buddy/extensions/sdk/authoring.ts @@ -4,7 +4,7 @@ export { validateExtensionFileCounts } from '../../platform/extensions/extension export { validateFileIconThemeFiles } from '../../platform/icons/fileIconThemeAssets' export { validateThemeFiles } from '../../platform/themes/themeAssets' export { createExtensionId, extensionAuthorSchema } from '../../shared/extensions/extensionIdentity' -export { extensionManifestSchema, extensionPathSchema } from '../../shared/extensions/extensionManifest' +export { extensionManifestSchema, extensionPackageManifest, extensionPathSchema } from '../../shared/extensions/extensionManifest' export { fileIconThemeDocumentSchema } from '../../shared/icons/fileIconTheme' export { themeDocumentSchema } from '../../shared/theme/themeDocument' diff --git a/apps/buddy/extensions/sdk/plugin.mjs b/apps/buddy/extensions/sdk/plugin.mjs index 742487ee4..b739697c9 100644 --- a/apps/buddy/extensions/sdk/plugin.mjs +++ b/apps/buddy/extensions/sdk/plugin.mjs @@ -3,7 +3,7 @@ import { createHash } from 'node:crypto' import fs from 'node:fs/promises' import path from 'node:path' import { zipSync } from 'fflate' -import { compileExtensionSource, extensionIconUrl, extensionManifestSchema, extensionPathSchema, validateExtensionFileCounts, validateFileIconThemeFiles, validateThemeFiles } from './authoring.mjs' +import { compileExtensionSource, extensionIconUrl, extensionManifestSchema, extensionPackageManifest, extensionPathSchema, validateExtensionFileCounts, validateFileIconThemeFiles, validateThemeFiles } from './authoring.mjs' export async function readPlugin(source) { const files = new Map() @@ -46,6 +46,8 @@ export async function buildPlugin(source, destination, report = () => {}) { assert(source !== destination && !source.startsWith(`${destination}${path.sep}`), 'Output must not contain source') const { files, manifest } = await readPlugin(source) const result = manifest.format === 'source' ? await compileExtensionSource(files, manifest, report) : files + if (manifest.format !== 'source') + result.set('extension.json', new TextEncoder().encode(JSON.stringify(extensionPackageManifest(manifest)))) const old = await fs.readdir(destination).catch(error => error.code === 'ENOENT' ? [] : Promise.reject(error)) if (old.length) assert(JSON.parse(await fs.readFile(path.join(destination, 'extension.json'), 'utf8')).id === manifest.id, 'Output belongs to another plugin') @@ -61,8 +63,10 @@ export async function packPlugin(source, destination, { license } = {}) { const { files, manifest, iconUrl } = await readPlugin(source) if (license && !files.has('LICENSE')) files.set('LICENSE', license) + const packageManifest = extensionPackageManifest(manifest) + files.set('extension.json', new TextEncoder().encode(JSON.stringify(packageManifest))) const bytes = zipSync(Object.fromEntries(files), { level: 6, mtime: new Date('2020-01-01T00:00:00Z') }) await fs.mkdir(path.dirname(path.resolve(destination)), { recursive: true }) await fs.writeFile(destination, bytes) - return { manifest, iconUrl, sha256: createHash('sha256').update(bytes).digest('hex'), size: bytes.length } + return { manifest: packageManifest, iconUrl, sha256: createHash('sha256').update(bytes).digest('hex'), size: bytes.length } } diff --git a/apps/buddy/extensions/tools.mjs b/apps/buddy/extensions/tools.mjs index b8b1ad934..adc3463c1 100644 --- a/apps/buddy/extensions/tools.mjs +++ b/apps/buddy/extensions/tools.mjs @@ -12,7 +12,7 @@ import { compileExtensionSource } from '../platform/extensions/compileExtensionS import { validateExtensionFileCounts } from '../platform/extensions/extensionLimits.ts' import { fileIconAssetPaths, readFileIconTheme, validateFileIconThemeFiles } from '../platform/icons/fileIconThemeAssets.ts' import { validateThemeFiles } from '../platform/themes/themeAssets.ts' -import { extensionCompatible, extensionManifestSchema, extensionPathSchema } from '../shared/extensions/extensionManifest.ts' +import { extensionCompatible, extensionManifestSchema, extensionPackageManifest, extensionPathSchema } from '../shared/extensions/extensionManifest.ts' import { themeAssetPaths, themeDocumentSchema } from '../shared/theme/themeDocument.ts' async function run() { @@ -78,6 +78,7 @@ async function run() { } } await walk(source) + files['extension.json'] = new TextEncoder().encode(JSON.stringify(extensionPackageManifest(manifest))) const bytes = zipSync(files, { level: 6 }) await fs.mkdir(path.dirname(path.resolve(output)), { recursive: true }) await fs.writeFile(output, bytes) @@ -122,7 +123,7 @@ async function run() { await fs.rm(destination, { recursive: true, force: true }) await fs.mkdir(destination, { recursive: true }) const compiledManifest = { ...manifest, entry: manifest.entry?.replace(/\.[^.]+$/, '.js'), contributes: { ...manifest.contributes, views: manifest.contributes.views.map(view => ({ ...view, entry: view.entry.replace(/\.[^.]+$/, '.js') })) } } - await fs.writeFile(path.join(destination, 'extension.json'), JSON.stringify(compiledManifest, null, 2)) + await fs.writeFile(path.join(destination, 'extension.json'), JSON.stringify(extensionPackageManifest(compiledManifest), null, 2)) } for (const [name, bytes] of themeFiles) { await fs.mkdir(path.dirname(path.join(destination, name)), { recursive: true }) diff --git a/apps/buddy/platform/extensions/__tests__/ExtensionPackageStore.spec.ts b/apps/buddy/platform/extensions/__tests__/ExtensionPackageStore.spec.ts index 1940c1f42..effc6a7c1 100644 --- a/apps/buddy/platform/extensions/__tests__/ExtensionPackageStore.spec.ts +++ b/apps/buddy/platform/extensions/__tests__/ExtensionPackageStore.spec.ts @@ -21,6 +21,32 @@ afterEach(async () => { }) describe('extension package contract', () => { + it('installs and reloads known contributions while ignoring unsupported declarations without granting them', async () => { + const { root, store } = await fixture() + const value = manifest() + const input = { ...value, futureMetadata: { enabled: true }, permissions: { ...value.permissions, inference: true, inferenceGpu: false, inferenceNetwork: ['https://example.com'], futureAccess: 'read', futureMetadata: { enabled: true } }, contributes: { ...value.contributes, futureProvider: { entry: 'future.js' } } } + const review = await reviewPackage(root, store, input) + expect(review.manifest.permissions).toEqual(value.permissions) + expect(review.manifest).toEqual(value) + expect(review.addedPermissions).toEqual(['selectedResource:read']) + await store.install(review.token) + const pkg = store.installed[value.id]!.current + expect(JSON.parse(await readFile(join(store.packageRoot(pkg), 'extension.json'), 'utf8')).permissions).toEqual(input.permissions) + const reloaded = new ExtensionPackageStore(store.root, '0.7.3') + await reloaded.load() + expect(reloaded.installed[value.id]!.current.manifest.permissions).toEqual(value.permissions) + expect(new TextDecoder().decode(await reloaded.asset(reloaded.installed[value.id]!.current, 'extension.js'))).toBe('export function activate() {}') + }) + it.each([true, false, 'read', 'none', ['https://example.com'], [], 0, null, '', {}, [false]])('ignores unknown permissions regardless of their value: %j', (permission) => { + const value = manifest() + expect(extensionManifestSchema.parse({ ...value, permissions: { ...value.permissions, futureAccess: permission } })).toEqual(value) + }) + it('still rejects malformed known permissions and contributions', () => { + const value = manifest() + expect(extensionManifestSchema.safeParse({ ...value, permissions: { ...value.permissions, network: false } }).success).toBe(false) + expect(extensionManifestSchema.safeParse({ ...value, permissions: { ...value.permissions, agent: [] } }).success).toBe(false) + expect(extensionManifestSchema.safeParse({ ...value, contributes: { ...value.contributes, commands: false } }).success).toBe(false) + }) it('bounds state by UTF-8 bytes and requires permission for declared file views', () => { expect(extensionJsonSchema.safeParse('文'.repeat(90000)).success).toBe(false) expect(extensionManifestSchema.safeParse({ ...manifest(), permissions: { selectedResource: 'none', network: [] } }).success).toBe(false) diff --git a/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts b/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts index 3deab84ee..21179bc8d 100644 --- a/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts +++ b/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts @@ -54,6 +54,16 @@ async function fixture(overrides: Partial = {}, initialMa } const target = { spaceId: 'space', directoryId: 'directory', revision: 4, path: 'README.md' } +it('ignores unsupported declarations without exposing new methods or expanding existing permissions', async () => { + const value = manifest() + const input = { ...value, permissions: { ...value.permissions, futureExecution: true, futureNetwork: ['https://example.com'] }, contributes: { ...value.contributes, futureExecution: { entry: 'future.js' } } } + const { service, hosts } = await fixture({}, input) + await service.execute(value.id, `${value.id}.open`, null) + await expect(hosts[0]!.broker('shell.execute', {})).rejects.toThrow('EXTENSION_METHOD_DENIED') + await expect(hosts[0]!.broker('network.get', { url: 'https://example.com' })).rejects.toThrow('EXTENSION_NETWORK_DENIED') + await expect(hosts[0]!.broker('notifications.show', { title: 'Unknown permission' })).rejects.toThrow('EXTENSION_NOTIFICATIONS_DENIED') +}) + it.each([false, true])('uninstalls with clearData=%s while preserving other plugins and external files', async (clearData) => { const { root, store, service, hosts } = await fixture() const id = 'tests.reader' diff --git a/apps/buddy/platform/extensions/__tests__/ExtensionSource.spec.ts b/apps/buddy/platform/extensions/__tests__/ExtensionSource.spec.ts index b4d0feb61..de7a86afd 100644 --- a/apps/buddy/platform/extensions/__tests__/ExtensionSource.spec.ts +++ b/apps/buddy/platform/extensions/__tests__/ExtensionSource.spec.ts @@ -14,6 +14,14 @@ it('compiles package-local modules without executing source or build scripts', a expect(JSON.parse(decode(output.get('extension.json')))).toMatchObject({ id: manifest.id, format: 'compiled', entry: 'extension.js' }) }) +it('emits only requested permissions while retaining runtime defaults and active grants after parsing', async () => { + const source = extensionManifestSchema.parse({ ...manifest, permissions: { selectedResource: 'read', notifications: true, network: ['https://example.com'], futureAccess: true, futureNetwork: ['https://unsupported.example.com'] } }) + const output = await compileExtensionSource(encode({ 'extension.ts': 'export function activate() {}' }), source, () => {}) + const packaged = JSON.parse(decode(output.get('extension.json'))) + expect(packaged.permissions).toEqual({ selectedResource: 'read', notifications: true, network: ['https://example.com'] }) + expect(extensionManifestSchema.parse(packaged)).toEqual({ ...source, format: 'compiled', entry: 'extension.js' }) +}) + it.each(['import "node:fs"', 'import "npm-library"', 'import "../../outside.js"', 'import(globalThis.path)'])('rejects imports outside the package: %s', async (text) => { await expect(compileExtensionSource(encode({ 'extension.ts': text }), manifest, () => {})).rejects.toThrow('EXTENSION_SOURCE_IMPORT_DENIED') }) diff --git a/apps/buddy/platform/extensions/compileExtensionSource.ts b/apps/buddy/platform/extensions/compileExtensionSource.ts index 853f0d56e..9335127bf 100644 --- a/apps/buddy/platform/extensions/compileExtensionSource.ts +++ b/apps/buddy/platform/extensions/compileExtensionSource.ts @@ -1,7 +1,7 @@ import type { ExtensionManifest } from '../../shared/extensions/extensionManifest' import { posix } from 'node:path' import ts from 'typescript' -import { extensionPathSchema } from '../../shared/extensions/extensionManifest.ts' +import { extensionPackageManifest, extensionPathSchema } from '../../shared/extensions/extensionManifest.ts' import { EXTENSION_UNO_STYLESHEET } from '../../shared/extensions/extensionStyles.ts' import { compileExtensionStyles } from './compileExtensionStyles.ts' @@ -88,6 +88,6 @@ export async function compileExtensionSource(files: Map, man output.set(EXTENSION_UNO_STYLESHEET, styles) } const compiledManifest: ExtensionManifest = { ...manifest, format: 'compiled', ...(manifest.entry ? { entry: outputPath(manifest.entry) } : {}), contributes: { ...manifest.contributes, views: manifest.contributes.views.map(view => ({ ...view, entry: outputPath(view.entry) })) } } - output.set('extension.json', encode.encode(JSON.stringify(compiledManifest))) + output.set('extension.json', encode.encode(JSON.stringify(extensionPackageManifest(compiledManifest)))) return output } diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md b/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md index 512f5259a..cbd4e6644 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md +++ b/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md @@ -75,7 +75,7 @@ | `resourceExport: true` | 用户通过系统另存为窗口确认目标后,保存插件提供的字节 | | `network: ["https://example.com"]` | 通过宿主向列出的 HTTPS origin 发 GET 请求、下载文件或打开外链 | -未声明权限默认为关闭。视图无法访问 Node、宿主 DOM、全局键盘、网络 fetch 或任意本地文件。资源和网络调用通过 API;用户在安装确认中查看权限。没有一般性消息订阅、系统剪贴板或屏幕截图接口。 +未声明权限默认为关闭,清单只需列出实际申请的权限,SDK 打包时会省略默认值。宿主忽略清单顶层、`permissions` 和 `contributes` 中不认识的声明,不因此提供对应能力;已知字段仍按类型和权限校验。需要新版能力时,通过 `engines.lexora` 和 API 版本声明兼容范围。视图无法访问 Node、宿主 DOM、全局键盘、网络 fetch 或任意本地文件。资源和网络调用通过 API;用户在安装确认中查看权限。没有一般性消息订阅、系统剪贴板或屏幕截图接口。 ## 窗口效果 diff --git a/apps/buddy/shared/extensions/extensionManifest.ts b/apps/buddy/shared/extensions/extensionManifest.ts index e1ec5844c..57618516c 100644 --- a/apps/buddy/shared/extensions/extensionManifest.ts +++ b/apps/buddy/shared/extensions/extensionManifest.ts @@ -76,7 +76,9 @@ export const extensionPermissionsSchema = z.object({ const url = new URL(value) return url.protocol === 'https:' && url.origin === value && !url.username && !url.password }, 'Expected an HTTPS origin')).max(16).default([]), -}).strict() +}) + +const extensionPermissionDefaults: Record = extensionPermissionsSchema.parse({}) export const extensionMetadataSchema = z.object({ schemaVersion: z.number().int().positive(), id: extensionIdSchema, @@ -116,8 +118,8 @@ export const extensionManifestSchema = extensionMetadataSchema.extend({ views: z.array(z.object({ id: contributionId, title: z.string().min(1).max(100), entry: extensionPathSchema, stateVersion: z.number().int().min(1).max(10000).default(1), resource: z.enum(['selected-file', 'none']).default('selected-file'), location: z.enum(['context', 'page', 'window-overlay']).default('context'), when: workbenchConditionSchema.optional() }).strict()).max(16).default([]), placements: z.array(extensionPlacementSchema).max(16).default([]), navigation: z.object({ title: z.string().min(1).max(40), view: contributionId, when: workbenchConditionSchema.optional() }).strict().optional(), - }).strict(), -}).strict().superRefine((manifest, context) => { + }), +}).superRefine((manifest, context) => { const ids = new Set() const settings = manifest.contributes.settings const agent = manifest.contributes.agent @@ -229,6 +231,10 @@ export const extensionManifestSchema = extensionMetadataSchema.extend({ export type ExtensionManifest = z.infer export type ExtensionPermissions = z.infer +export function extensionPackageManifest(manifest: ExtensionManifest) { + return { ...manifest, permissions: Object.fromEntries(Object.entries(manifest.permissions).filter(([key, value]) => JSON.stringify(value) !== JSON.stringify(extensionPermissionDefaults[key]))) } +} + export function extensionCompatible(manifest: ExtensionManifest, version: string): boolean { return manifest.apiVersion <= EXTENSION_API_VERSION && satisfies(version, manifest.engines.lexora, { includePrerelease: true }) }