Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/buddy/extensions/sdk/authoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
8 changes: 6 additions & 2 deletions apps/buddy/extensions/sdk/plugin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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')
Expand All @@ -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 }
}
5 changes: 3 additions & 2 deletions apps/buddy/extensions/tools.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,16 @@ async function fixture(overrides: Partial<ExtensionServicePorts> = {}, 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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
Expand Down
4 changes: 2 additions & 2 deletions apps/buddy/platform/extensions/compileExtensionSource.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -88,6 +88,6 @@ export async function compileExtensionSource(files: Map<string, Uint8Array>, 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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;用户在安装确认中查看权限。没有一般性消息订阅、系统剪贴板或屏幕截图接口。

## 窗口效果

Expand Down
12 changes: 9 additions & 3 deletions apps/buddy/shared/extensions/extensionManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = extensionPermissionsSchema.parse({})
export const extensionMetadataSchema = z.object({
schemaVersion: z.number().int().positive(),
id: extensionIdSchema,
Expand Down Expand Up @@ -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<string>()
const settings = manifest.contributes.settings
const agent = manifest.contributes.agent
Expand Down Expand Up @@ -229,6 +231,10 @@ export const extensionManifestSchema = extensionMetadataSchema.extend({
export type ExtensionManifest = z.infer<typeof extensionManifestSchema>
export type ExtensionPermissions = z.infer<typeof extensionPermissionsSchema>

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 })
}
Expand Down