feat(plugins): download sources without checkout and pin catalog releases in a v2 manifest/catalog (#30636)

* plugins: download Git sources without checkout, within a protocol allowlist and size cap

Plugin and marketplace sources were a real clone/checkout, so user hooks,
smudge filters and core.hooksPath ran, insteadOf rules could reach any
transport, and nothing bounded the download. Now a --no-checkout depth-1
clone (or exact-commit fetch) runs with an empty hooks path, only the https
and ssh transports, and Orca writes the commit's exact blobs from ls-tree +
cat-file --batch. Symlinks, submodules, .git aliases, colliding portable
paths and SHA-256 repositories are refused. A size watcher stops the whole
Git process tree once the pack outgrows an approximate cap.

The exact argv lives in shared code and joins the real-Git compatibility
matrix, so the Git 2.25 lane runs it. Tests reach fixtures over an SSH
shim, since file transports are now refused.

* plugins: manifest v2 declares an execution profile

data < instructional < sandboxed-ui < trusted-worker. A v2 manifest is
strict and fails when it carries contributions beyond its profile; v1
manifests keep loading with the least profile inferred from what they
declare.

* plugins: pin exact catalog releases in orca-marketplace.v2.json

A v2 catalog entry carries the release it vouches for: commit, content
hash, version, profile, minimum Orca and contribution types. Preview and
install fetch that commit directly and refuse bytes that differ. The v1
file stays strict and frozen for shipped clients; v2 entries strip unknown
keys, and entries this client cannot install are dropped at parse time.

Catalog fetches read only the index blobs under their own download cap,
so a catalog repository is not held to one plugin's limits. Release-bearing
snapshots are cached under a new key, so an older build never reads them
and simply refetches.

readMarketplaceJson and parseMarketplaceFile from #30612 are replaced by
parsePluginMarketplaceIndex: catalogs are now read as Git blobs, so the
on-disk JSON read has no caller left.

* test(plugins): serve marketplace e2e fixtures over SSH

Plugin downloads now refuse the file transport, even through an insteadOf
rewrite, so the fresh-profile marketplace journey reaches its fixture
repositories through the GIT_SSH_COMMAND shim the unit tests use.

* test(plugins): give the Git compatibility plugin source its own identity

* test(plugins): record the plugin fixture's executable bit in the Git index on every platform
This commit is contained in:
Neil
2026-10-28 13:36:49 -07:00
committed by GitHub
parent 51c1fca90b
commit 751e974f88
27 changed files with 1491 additions and 129 deletions
@@ -0,0 +1,20 @@
import { readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { vi } from 'vitest'
/** Serves local repositories at `ssh://git@<any host><path>` through a GIT_SSH_COMMAND shim,
* because plugin downloads refuse the file transport even behind an insteadOf rewrite. */
export async function serveRepositoriesOverTestSsh(
shimDirectory: string,
repositories: Record<string, string>
): Promise<void> {
const shim = join(shimDirectory, 'git-ssh-shim.cjs')
await writeFile(
shim,
await readFile(join(import.meta.dirname, '..', 'plugin-private-marketplace-ssh-shim.cjs'))
)
const node = process.execPath.replaceAll('\\', '/')
vi.stubEnv('GIT_SSH_COMMAND', `"${node}" "${shim.replaceAll('\\', '/')}"`)
vi.stubEnv('GIT_SSH_VARIANT', 'ssh')
vi.stubEnv('ORCA_TEST_SSH_REPOSITORIES', JSON.stringify(repositories))
}
@@ -0,0 +1,127 @@
import { randomBytes } from 'node:crypto'
import { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, expect, it, vi } from 'vitest'
import { serveRepositoriesOverTestSsh } from './__mocks__/plugin-git-ssh-remote-fixture'
import { checkoutPluginGitSource, runPluginGit } from './plugin-git-repository'
const PLUGIN_URL = 'ssh://git@plugins.example.invalid/sample.git'
// Why not on Windows: the planted hooks are POSIX shell scripts; the no-checkout path is shared.
const PLANT_HOOKS = process.platform !== 'win32'
const roots: string[] = []
afterEach(async () => {
vi.unstubAllEnvs()
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
function nodeCommand(code: string): string {
const encoded = Buffer.from(code).toString('base64')
return `"${process.execPath.replaceAll('\\', '/')}" -e "eval(Buffer.from('${encoded}','base64').toString())"`
}
async function fixture(initArgs: string[] = []) {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-git-'))
roots.push(root)
const repository = join(root, 'source')
const destination = join(root, 'download')
const config = join(root, 'config')
const hooks = join(root, 'hooks')
const filtered = join(root, 'filter-executed')
const hooked = join(root, 'hook-executed')
await mkdir(hooks)
await writeFile(config, '')
vi.stubEnv('GIT_CONFIG_GLOBAL', config)
vi.stubEnv('GIT_CONFIG_NOSYSTEM', '1')
const configure = (key: string, value: string) =>
runPluginGit(['config', '--file', config, key, value], root)
// Why a user-level allow: proves the plugin download's own protocol policy outranks it.
await configure('protocol.file.allow', 'always')
await configure('core.hooksPath', hooks)
await serveRepositoriesOverTestSsh(root, { '/sample.git': repository })
await runPluginGit(['init', '--quiet', '--template=', ...initArgs, repository], root)
const git = (args: string[]) => runPluginGit(args, repository)
await git(['config', 'user.name', 'Plugin fixture'])
await git(['config', 'user.email', 'plugins@example.invalid'])
const bytes = Buffer.from([0, 255, 13, 10, 65, 0, 128])
await writeFile(join(repository, 'data.bin'), bytes)
await writeFile(join(repository, '.gitattributes'), '*.bin filter=plugin-test\n')
await git(['add', '--all'])
await git(['commit', '--quiet', '-m', 'Exact source bytes'])
await git(['tag', 'v1'])
// Planted after the fixture commit so only the download under test can trigger them.
for (const hook of PLANT_HOOKS ? ['post-checkout', 'reference-transaction'] : []) {
await writeFile(
join(hooks, hook),
`#!/bin/sh\n${nodeCommand(`require('node:fs').writeFileSync(${JSON.stringify(hooked)},'1')`)}\n`,
{ mode: 0o755 }
)
}
const checkout = (ref: string, url = PLUGIN_URL, maxDownloadBytes?: number) =>
checkoutPluginGitSource({
url,
ref,
destination,
workingDirectory: root,
...(maxDownloadBytes ? { maxDownloadBytes } : {})
})
return { root, destination, bytes, filtered, hooked, git, checkout, configure, repository }
}
it.each(['tag', 'exact commit'])(
'materializes exact bytes through a %s without hooks or smudge filters',
async (mode) => {
const f = await fixture()
const commit = await f.git(['rev-parse', 'HEAD'])
expect(await f.checkout(mode === 'tag' ? 'v1' : commit)).toBe(commit)
expect(await readFile(join(f.destination, 'data.bin'))).toEqual(f.bytes)
await expect(access(f.filtered)).rejects.toThrow()
if (PLANT_HOOKS) {
await expect(access(f.hooked)).rejects.toThrow()
}
expect(await readdir(f.destination)).not.toContain('.git')
}
)
it.each([
['file', (repository: string) => pathToFileURL(repository).href],
['http', () => 'http://127.0.0.1:9/sample.git']
])('refuses a %s transport reached through an insteadOf rewrite', async (name, target) => {
const f = await fixture()
const url = 'https://plugins.example.invalid/rewritten.git'
await f.configure(`url.${target(f.repository)}.insteadOf`, url)
// Why this also covers redirects: Git hands the same allowlist to curl's redirect protocols.
await expect(f.checkout('v1', url)).rejects.toThrow(`transport '${name}' not allowed`)
})
it('rejects symlink objects before writing any file', async () => {
const f = await fixture()
await writeFile(join(f.root, 'link-target'), '../outside.txt')
const object = await f.git(['hash-object', '-w', join(f.root, 'link-target')])
await f.git(['update-index', '--add', '--cacheinfo', `120000,${object},link`])
await f.git(['commit', '--quiet', '-m', 'Symbolic link'])
await expect(f.checkout(await f.git(['rev-parse', 'HEAD']))).rejects.toThrow('symlinks')
expect(await readdir(f.destination)).toEqual([])
})
it('aborts a download that grows past its size limit', async () => {
const f = await fixture()
await writeFile(join(f.root, 'source', 'large.bin'), randomBytes(4 * 1024 * 1024))
await f.git(['add', 'large.bin'])
await f.git(['commit', '--quiet', '-m', 'Large'])
await expect(f.checkout('', PLUGIN_URL, 1024 * 1024)).rejects.toThrow(
'exceeds the download size limit'
)
expect(await readdir(f.destination)).toEqual([])
})
it('rejects SHA-256 repositories', async () => {
const f = await fixture(['--object-format=sha256']).catch(() => null)
if (!f) {
return // Git before 2.29 cannot create the fixture.
}
await expect(f.checkout('v1')).rejects.toThrow('SHA-256 plugin repositories are not supported')
await expect(f.checkout('a'.repeat(64))).rejects.toThrow('SHA-256')
})
+144 -24
View File
@@ -1,46 +1,166 @@
import { lstat, mkdir, mkdtemp, readdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
isAllowedPluginGitUrl,
PLUGIN_COMMIT_PATTERN
} from '../../shared/plugins/plugin-install-lockfile'
import {
pluginGitCloneArgs,
pluginGitConfigArgs,
pluginGitFetchCommitArgs
} from '../../shared/plugins/plugin-git-arguments'
import { gitExecFileAsync } from '../git/runner'
import { MAX_PLUGIN_TOTAL_BYTES } from './plugin-content-hash'
import { materializePluginGitTree } from './plugin-git-tree'
const PLUGIN_GIT_TIMEOUT_MS = 120_000
// Why headroom over the tree cap: a depth-1 pack of incompressible files carries per-object overhead.
export const PLUGIN_GIT_DOWNLOAD_MAX_BYTES = MAX_PLUGIN_TOTAL_BYTES + 16 * 1024 * 1024
const DOWNLOAD_POLL_MS = 200
const SHA1_COMMIT_PATTERN = /^[0-9a-f]{40}$/
/** Use system Git credentials while retaining argv-only invocation and restricted remote protocols. */
export async function runPluginGit(args: string[], cwd: string): Promise<string> {
/** Runs system Git with argv-only invocation so credential helpers and SSH
* remotes work without exposing an executable remote-helper surface. */
export async function runPluginGit(
args: string[],
cwd: string,
signal?: AbortSignal
): Promise<string> {
const { stdout } = await gitExecFileAsync(args, {
cwd,
timeout: PLUGIN_GIT_TIMEOUT_MS
timeout: PLUGIN_GIT_TIMEOUT_MS,
// Why: abort must stop remote-https and index-pack too, and settle only once they exit.
...(signal ? { signal, terminationBarrier: true } : {})
})
return stdout.trim()
}
async function directoryBytes(path: string): Promise<number> {
let total = 0
for (const entry of await readdir(path, { withFileTypes: true }).catch(() => [])) {
const child = join(path, entry.name)
total += entry.isDirectory()
? await directoryBytes(child)
: await lstat(child).then(
(stat) => stat.size,
() => 0
)
}
return total
}
/** Git has no client-side fetch size limit, so poll the pack landing on disk and stop it.
* The cap is approximate: a fast link can overshoot by one poll interval of transfer. */
async function runBoundedDownload(
args: string[],
cwd: string,
repository: string,
maxBytes: number
): Promise<void> {
const controller = new AbortController()
let exceeded = false
let measuring = false
const timer = setInterval(() => {
if (measuring) {
return
}
measuring = true
void directoryBytes(repository)
.then((bytes) => {
if (bytes > maxBytes && !controller.signal.aborted) {
exceeded = true
controller.abort()
}
})
.finally(() => {
measuring = false
})
}, DOWNLOAD_POLL_MS)
try {
await runPluginGit(args, cwd, controller.signal)
} catch (error) {
if (exceeded) {
throw new Error('plugin repository exceeds the download size limit')
}
throw error
} finally {
clearInterval(timer)
}
if ((await directoryBytes(repository)) > maxBytes) {
throw new Error('plugin repository exceeds the download size limit')
}
}
/** Downloads one ref without a checkout, hands the exact commit to `read`, then deletes it. */
export async function readPluginGitCommit<T>(
input: { url: string; ref: string; workingDirectory: string; maxDownloadBytes: number },
read: (repository: string, commit: string) => Promise<T>
): Promise<{ commit: string; value: T }> {
if (!isAllowedPluginGitUrl(input.url)) {
throw new Error('plugin Git URL must use HTTPS or SSH')
}
const ref = input.ref.trim()
const exactCommit = PLUGIN_COMMIT_PATTERN.test(ref)
if (exactCommit && !SHA1_COMMIT_PATTERN.test(ref)) {
throw new Error('SHA-256 plugin repositories are not supported')
}
const temporaryRoot = await mkdtemp(join(tmpdir(), 'orca-plugin-git-'))
const repository = join(temporaryRoot, 'repository')
const hooks = join(temporaryRoot, 'no-hooks')
const config = pluginGitConfigArgs(hooks)
try {
await mkdir(hooks)
await mkdir(repository)
if (exactCommit) {
await runPluginGit([...config, 'init', '--quiet', '--template=', repository], temporaryRoot)
await runPluginGit([...config, 'remote', 'add', 'origin', input.url], repository)
await runBoundedDownload(
[...config, ...pluginGitFetchCommitArgs(ref)],
repository,
repository,
input.maxDownloadBytes
)
} else {
const args = [...config, ...pluginGitCloneArgs(input.url, ref, repository)]
await runBoundedDownload(args, input.workingDirectory, repository, input.maxDownloadBytes)
}
const commit = await runPluginGit(
['rev-parse', '--verify', `${exactCommit ? 'FETCH_HEAD' : 'HEAD'}^{commit}`],
repository
)
if (!SHA1_COMMIT_PATTERN.test(commit)) {
throw new Error(
PLUGIN_COMMIT_PATTERN.test(commit)
? 'SHA-256 plugin repositories are not supported'
: 'Git resolved an invalid commit identity'
)
}
if (exactCommit && commit !== ref) {
throw new Error('Git fetched a different commit than requested')
}
return { commit, value: await read(repository, commit) }
} finally {
await rm(temporaryRoot, { recursive: true, force: true })
}
}
/** Writes one ref's exact files into an empty destination and returns its commit. */
export async function checkoutPluginGitSource(input: {
url: string
ref: string
destination: string
workingDirectory: string
maxDownloadBytes?: number
}): Promise<string> {
if (!isAllowedPluginGitUrl(input.url)) {
throw new Error('plugin Git URL must use HTTPS or SSH')
await mkdir(input.destination, { recursive: true })
if ((await readdir(input.destination)).length > 0) {
throw new Error('plugin Git destination must be empty')
}
const ref = input.ref.trim()
if (PLUGIN_COMMIT_PATTERN.test(ref)) {
await runPluginGit(['init', '--quiet', input.destination], input.workingDirectory)
await runPluginGit(['remote', 'add', 'origin', input.url], input.destination)
await runPluginGit(['fetch', '--quiet', '--depth', '1', 'origin', ref], input.destination)
await runPluginGit(['checkout', '--quiet', 'FETCH_HEAD'], input.destination)
} else {
const args = ['clone', '--quiet', '--depth', '1']
if (ref.length > 0) {
args.push('--branch', ref)
}
args.push('--', input.url, input.destination)
await runPluginGit(args, input.workingDirectory)
}
const resolvedCommit = await runPluginGit(['rev-parse', 'HEAD'], input.destination)
if (!PLUGIN_COMMIT_PATTERN.test(resolvedCommit)) {
throw new Error('Git resolved an invalid commit identity')
}
return resolvedCommit
const { commit } = await readPluginGitCommit(
{ ...input, maxDownloadBytes: input.maxDownloadBytes ?? PLUGIN_GIT_DOWNLOAD_MAX_BYTES },
(repository, resolved) =>
materializePluginGitTree({ repository, commit: resolved, destination: input.destination })
)
return commit
}
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import { parsePluginGitTree, readPluginGitBatchBlobs } from './plugin-git-tree'
const OBJECT = 'a'.repeat(40)
function tree(...records: string[]): Buffer {
return Buffer.from(records.map((record) => `${record}\0`).join(''))
}
describe('parsePluginGitTree', () => {
it('parses directories and files with their executable bit', () => {
expect(
parsePluginGitTree(
tree(`040000 tree ${OBJECT} -\tbin`, `100755 blob ${OBJECT} 12\tbin/run`)
)
).toEqual([
{ kind: 'tree', path: 'bin' },
{ kind: 'blob', path: 'bin/run', object: OBJECT, size: 12, executable: true }
])
})
it.each([
['a symlink', `120000 blob ${OBJECT} 5\tlink`, 'symlinks'],
['a submodule', `160000 commit ${OBJECT} -\tmodule`, 'submodules'],
['a SHA-256 object', `100644 blob ${'a'.repeat(64)} 5\tfile`, 'unknown entries'],
['a .git directory', `040000 tree ${OBJECT} -\t.GIT`, 'unsafe plugin path'],
['a .git name hidden by a zero-width joiner', `100644 blob ${OBJECT} 5\t.g\u200dit`, 'unsafe'],
['an 8.3 .git alias', `100644 blob ${OBJECT} 5\tGIT~1`, 'unsafe plugin path'],
['a Windows device name', `100644 blob ${OBJECT} 5\tcon.txt`, 'unsafe plugin path'],
['a file without its directory', `100644 blob ${OBJECT} 5\tdir/file`, 'missing directory']
])('rejects %s', (_label, record, message) => {
expect(() => parsePluginGitTree(tree(record))).toThrow(message)
})
it('rejects a .git directory below the root', () => {
expect(() =>
parsePluginGitTree(tree(`040000 tree ${OBJECT} -\tsub`, `040000 tree ${OBJECT} -\tsub/.git`))
).toThrow('unsafe plugin path')
})
it('rejects paths that collide on case-insensitive filesystems', () => {
expect(() =>
parsePluginGitTree(tree(`100644 blob ${OBJECT} 1\tREADME`, `100644 blob ${OBJECT} 1\treadme`))
).toThrow('colliding')
})
it('rejects trees over the byte budget before any blob is read', () => {
expect(() => parsePluginGitTree(tree(`100644 blob ${OBJECT} 99999999999\tbig`))).toThrow(
'byte limit'
)
})
})
describe('readPluginGitBatchBlobs', () => {
const entries = parsePluginGitTree(tree(`100644 blob ${OBJECT} 3\tfile`))
it('returns exact blob bytes', () => {
const blobs = readPluginGitBatchBlobs(Buffer.from(`${OBJECT} blob 3\nabc\n`), entries)
expect(blobs.get(OBJECT)?.toString()).toBe('abc')
})
it.each([
['a size mismatch', `${OBJECT} blob 4\nabcd\n`],
['a different object', `${'b'.repeat(40)} blob 3\nabc\n`],
['truncated data', `${OBJECT} blob 3\nab`],
['trailing data', `${OBJECT} blob 3\nabc\nextra`]
])('rejects %s', (_label, output) => {
expect(() => readPluginGitBatchBlobs(Buffer.from(output), entries)).toThrow()
})
})
+161
View File
@@ -0,0 +1,161 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { gitExecFileAsyncBuffer } from '../git/runner'
import { pluginGitTreeArgs } from '../../shared/plugins/plugin-git-arguments'
import { pluginRelativePathError } from '../../shared/plugins/plugin-path-safety'
import { MAX_PLUGIN_FILES, MAX_PLUGIN_TOTAL_BYTES } from './plugin-content-hash'
const MAX_PATH_BYTES = 1024
const MAX_PATH_DEPTH = 64
const TREE_METADATA_MAX_BYTES = MAX_PLUGIN_FILES * (MAX_PATH_BYTES + 128)
const PLUGIN_GIT_TREE_TIMEOUT_MS = 120_000
export type PluginGitTreeEntry =
| { kind: 'tree'; path: string }
| { kind: 'blob'; path: string; object: string; size: number; executable: boolean }
// Why: case-insensitive and NFC-normalizing filesystems (macOS, Windows) merge these; the
// upper-then-lower round trip approximates full case folding (e.g. ß/ẞ, Kelvin sign).
function portablePathIdentity(path: string): string {
return path.normalize('NFC').toUpperCase().toLowerCase()
}
function validatePath(path: string): void {
const segments = path.split('/')
if (
pluginRelativePathError(path) ||
path.includes('\\') ||
Buffer.byteLength(path, 'utf8') > MAX_PATH_BYTES ||
segments.length > MAX_PATH_DEPTH ||
segments.some((segment) => {
// Why: HFS+ ignores these code points and NTFS 8.3 names alias `.git`.
const name = portablePathIdentity(segment).replace(/[‌-‏‪-‮-]/g, '')
return name === '.git' || /^\.?git~[0-9]+$/.test(name)
})
) {
throw new Error(`unsafe plugin path: ${path}`)
}
}
/** Parses `git ls-tree -r -t -l -z` output; anything but plain files and directories is refused. */
export function parsePluginGitTree(bytes: Buffer): PluginGitTreeEntry[] {
if (bytes.length > TREE_METADATA_MAX_BYTES) {
throw new Error('plugin Git tree metadata exceeds its size limit')
}
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
if (text && !text.endsWith('\0')) {
throw new Error('truncated plugin Git tree metadata')
}
const records = text ? text.slice(0, -1).split('\0') : []
if (records.length > MAX_PLUGIN_FILES) {
throw new Error(`plugin exceeds the ${MAX_PLUGIN_FILES}-entry limit`)
}
const entries: PluginGitTreeEntry[] = []
const kinds = new Map<string, PluginGitTreeEntry['kind']>()
const portablePaths = new Set<string>()
let totalBytes = 0
for (const record of records) {
const match = /^(040000|100644|100755) (tree|blob) ([0-9a-f]{40}) +(-|[0-9]+)\t(.+)$/s.exec(
record
)
if (!match) {
throw new Error('plugin Git trees cannot contain symlinks, submodules or unknown entries')
}
const [, mode, type, object, sizeText, path] = match
validatePath(path)
const identity = portablePathIdentity(path)
if (portablePaths.has(identity)) {
throw new Error(`colliding plugin paths: ${path}`)
}
portablePaths.add(identity)
const parent = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : null
if (parent !== null && kinds.get(parent) !== 'tree') {
throw new Error(`missing directory in plugin Git tree: ${path}`)
}
let entry: PluginGitTreeEntry
if (mode === '040000' && type === 'tree' && sizeText === '-') {
entry = { kind: 'tree', path }
} else if (mode !== '040000' && type === 'blob' && sizeText !== '-') {
const size = Number(sizeText)
totalBytes += size
if (!Number.isSafeInteger(size) || totalBytes > MAX_PLUGIN_TOTAL_BYTES) {
throw new Error(`plugin exceeds the ${MAX_PLUGIN_TOTAL_BYTES}-byte limit`)
}
entry = { kind: 'blob', path, object, size, executable: mode === '100755' }
} else {
throw new Error('invalid plugin Git entry mode, type or size')
}
kinds.set(path, entry.kind)
entries.push(entry)
}
return entries
}
/** Splits `git cat-file --batch` output, requesting each distinct blob once in entry order. */
export function readPluginGitBatchBlobs(
bytes: Buffer,
entries: readonly PluginGitTreeEntry[]
): Map<string, Buffer> {
const blobs = new Map<string, Buffer>()
let offset = 0
for (const entry of entries) {
if (entry.kind !== 'blob' || blobs.has(entry.object)) {
continue
}
const end = bytes.indexOf(10, offset)
if (end < offset || end - offset > 128) {
throw new Error('missing plugin Git blob header')
}
if (bytes.subarray(offset, end).toString('ascii') !== `${entry.object} blob ${entry.size}`) {
throw new Error('plugin Git blob identity or size changed')
}
const start = end + 1
const next = start + entry.size
if (next >= bytes.length || bytes[next] !== 10) {
throw new Error('truncated plugin Git blob')
}
blobs.set(entry.object, bytes.subarray(start, next))
offset = next + 1
}
if (offset !== bytes.length) {
throw new Error('unexpected trailing plugin Git blob data')
}
return blobs
}
/** Writes a commit's exact blobs without `checkout`, so no filter, hook or attribute runs. */
export async function materializePluginGitTree(input: {
repository: string
commit: string
destination: string
}): Promise<void> {
const options = { cwd: input.repository, timeout: PLUGIN_GIT_TREE_TIMEOUT_MS }
const metadata = await gitExecFileAsyncBuffer(pluginGitTreeArgs(input.commit), {
...options,
maxBuffer: TREE_METADATA_MAX_BYTES
})
const entries = parsePluginGitTree(metadata.stdout)
const objects = [
...new Set(entries.flatMap((entry) => (entry.kind === 'blob' ? [entry.object] : [])))
]
const data = objects.length
? await gitExecFileAsyncBuffer(['cat-file', '--batch'], {
...options,
maxBuffer: MAX_PLUGIN_TOTAL_BYTES + MAX_PLUGIN_FILES * 128,
stdin: `${objects.join('\n')}\n`
})
: { stdout: Buffer.alloc(0) }
const blobs = readPluginGitBatchBlobs(data.stdout, entries)
for (const entry of entries) {
const target = join(input.destination, ...entry.path.split('/'))
if (entry.kind === 'tree') {
await mkdir(target)
continue
}
const bytes = blobs.get(entry.object)
if (!bytes || bytes.length !== entry.size) {
throw new Error('missing plugin Git blob')
}
await writeFile(target, bytes, { flag: 'wx', mode: entry.executable ? 0o755 : 0o644 })
}
}
+22 -1
View File
@@ -9,6 +9,10 @@ import {
type PluginManifest
} from '../../shared/plugins/plugin-manifest'
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
import {
pluginCatalogReleaseError,
type PluginCatalogRelease
} from '../../shared/plugins/plugin-catalog-release'
import type {
PluginInstallSource,
PluginLockEntry
@@ -84,6 +88,8 @@ export async function inspectPluginInstallTree(input: {
rootDir: string
hostVersion: string
expectedPluginKey?: string
/** Catalog-pinned release these bytes must match exactly. */
release?: { record: PluginCatalogRelease; resolvedCommit: string | null }
}): Promise<PluginInstallInspection> {
const sourceManifest = await readInstallManifest(input.rootDir, input.hostVersion)
if (!sourceManifest.ok) {
@@ -104,6 +110,17 @@ export async function inspectPluginInstallTree(input: {
if (!treeHash.ok) {
return { ok: false, error: treeHash.error }
}
const releaseError =
input.release &&
pluginCatalogReleaseError({
release: input.release.record,
manifest: sourceManifest.manifest,
resolvedCommit: input.release.resolvedCommit,
contentHash: treeHash.hash
})
if (releaseError) {
return { ok: false, error: releaseError }
}
return {
ok: true,
manifest: sourceManifest.manifest,
@@ -120,6 +137,7 @@ export async function installStagedPluginTree(input: {
source: PluginInstallSource
resolvedCommit: string | null
expectedPluginKey?: string
release?: PluginCatalogRelease
/** Trusted bundled bytes may restore an immutable directory damaged on disk. */
repairCorruptedVersion?: boolean
blockedPluginReason?: (pluginKey: string) => string | null
@@ -127,7 +145,10 @@ export async function installStagedPluginTree(input: {
const sourceInspection = await inspectPluginInstallTree({
rootDir: input.stagingDir,
hostVersion: input.hostVersion,
...(input.expectedPluginKey ? { expectedPluginKey: input.expectedPluginKey } : {})
...(input.expectedPluginKey ? { expectedPluginKey: input.expectedPluginKey } : {}),
...(input.release
? { release: { record: input.release, resolvedCommit: input.resolvedCommit } }
: {})
})
if (!sourceInspection.ok) {
return sourceInspection
+16 -28
View File
@@ -11,7 +11,6 @@ import {
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
@@ -21,6 +20,7 @@ import {
readPluginLockfile,
removeInstalledPlugin
} from './plugin-install'
import { serveRepositoriesOverTestSsh } from './__mocks__/plugin-git-ssh-remote-fixture'
import { PLUGIN_MANIFEST_MAX_BYTES } from './plugin-manifest-file'
import { readPluginCurrentPointer } from './plugin-current-pointer'
import { writePluginLockfile } from './plugin-install-lockfile-store'
@@ -65,6 +65,7 @@ async function writePluginSource(
}
afterEach(async () => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
@@ -446,34 +447,21 @@ describe('installPluginFromGit', () => {
cwd: sourcePath
})
const configKeys = ['GIT_CONFIG_COUNT', 'GIT_CONFIG_KEY_0', 'GIT_CONFIG_VALUE_0'] as const
const previous = Object.fromEntries(configKeys.map((key) => [key, process.env[key]]))
process.env.GIT_CONFIG_COUNT = '1'
process.env.GIT_CONFIG_KEY_0 = `url.${pathToFileURL(sourcePath).href}.insteadOf`
process.env.GIT_CONFIG_VALUE_0 = 'https://plugin.test/demo.git'
try {
const result = await installPluginFromGit({
pluginsDir,
url: 'https://plugin.test/demo.git',
ref: 'v1.0.0',
hostVersion: '1.4.0'
})
await serveRepositoriesOverTestSsh(await tempRoot('orca-plugin-ssh-shim-'), {
'/demo.git': sourcePath
})
const result = await installPluginFromGit({
pluginsDir,
url: 'ssh://git@plugin.test/demo.git',
ref: 'v1.0.0',
hostVersion: '1.4.0'
})
expect(result).toMatchObject({ ok: true, resolvedCommit: commitStdout.trim() })
if (result.ok) {
await expect(
readFile(join(pluginsDir, result.pluginKey, result.contentHash, 'panel.html'), 'utf8')
).resolves.toBe('<h1>Panel</h1>')
}
} finally {
for (const key of configKeys) {
const value = previous[key]
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
expect(result).toMatchObject({ ok: true, resolvedCommit: commitStdout.trim() })
if (result.ok) {
await expect(
readFile(join(pluginsDir, result.pluginKey, result.contentHash, 'panel.html'), 'utf8')
).resolves.toBe('<h1>Panel</h1>')
}
})
})
+4 -1
View File
@@ -13,6 +13,7 @@ import {
pluginInstallSourceSchema,
removePluginLock
} from '../../shared/plugins/plugin-install-lockfile'
import type { PluginCatalogRelease } from '../../shared/plugins/plugin-catalog-release'
import { readPluginLockfile, writePluginLockfile } from './plugin-install-lockfile-store'
import {
inspectPluginInstallTree,
@@ -142,6 +143,7 @@ export async function installPluginFromMarketplace(input: {
expectedResolvedCommit: string
marketplace: { url: string; ref: string; resolvedCommit: string }
plugin: { url: string; ref: string }
release?: PluginCatalogRelease
blockedPluginReason?: (pluginKey: string) => string | null
}): Promise<PluginInstallResult> {
const source = pluginInstallSourceSchema.parse({
@@ -160,7 +162,7 @@ export async function installPluginFromMarketplace(input: {
try {
const resolvedCommit = await checkoutPluginGitSource({
url: input.plugin.url,
ref: input.plugin.ref,
ref: input.release?.commit ?? input.plugin.ref,
destination: stagingDir,
workingDirectory: tmpdir()
})
@@ -174,6 +176,7 @@ export async function installPluginFromMarketplace(input: {
source,
resolvedCommit,
expectedPluginKey: input.expectedPluginKey,
...(input.release ? { release: input.release } : {}),
blockedPluginReason: input.blockedPluginReason
})
} catch (error) {
@@ -0,0 +1,48 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { gitExecFileAsync } from '../git/runner'
import { serveRepositoriesOverTestSsh } from './__mocks__/plugin-git-ssh-remote-fixture'
import { fetchPluginMarketplace } from './plugin-marketplace-fetch'
const roots: string[] = []
afterEach(async () => {
vi.unstubAllEnvs()
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
it('reads only the catalog index, prefers the v2 file, and ignores plugin size limits', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-marketplace-fetch-'))
roots.push(root)
const repository = join(root, 'catalog')
const git = async (args: string[]) => (await gitExecFileAsync(args, { cwd: repository })).stdout
const entry = {
id: 'samples.demo',
source: { kind: 'git', url: 'https://example.com/demo.git', ref: 'v1' },
categories: []
}
const catalog = (name: string, plugins: unknown[]) =>
JSON.stringify({ name, owner: 'samples', plugins })
// More files than one plugin may hold: the catalog download has its own limits.
await mkdir(join(repository, 'packs'), { recursive: true })
for (let index = 0; index <= 2_000; index += 1) {
await writeFile(join(repository, 'packs', `${index}.txt`), String(index))
}
await writeFile(join(repository, 'orca-marketplace.json'), catalog('Old', [entry]))
await writeFile(join(repository, 'orca-marketplace.v2.json'), catalog('New', []))
await git(['init', '--quiet'])
await git(['add', '--all'])
await git(['-c', 'user.name=t', '-c', 'user.email=t@example.invalid', 'commit', '-qm', 'c'])
await git(['tag', 'catalog'])
await serveRepositoriesOverTestSsh(root, { '/catalog.git': repository })
const fetched = await fetchPluginMarketplace({
id: 'a'.repeat(32),
source: { kind: 'git', url: 'ssh://git@catalog.example.invalid/catalog.git', ref: 'catalog' },
addedAt: 0
})
expect(fetched.marketplace.name).toBe('New')
expect(fetched.marketplaceCommit).toBe((await git(['rev-parse', 'HEAD'])).trim())
})
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import {
PLUGIN_MARKETPLACE_FILENAME,
PLUGIN_MARKETPLACE_V2_FILENAME
} from '../../shared/plugins/plugin-marketplace'
import { parsePluginMarketplaceIndex } from './plugin-marketplace-fetch'
const entry = {
id: 'samples.demo',
source: { kind: 'git', url: 'https://example.com/demo.git', ref: 'v1.0.0' },
categories: []
}
const release = {
version: '1.0.0',
commit: 'c'.repeat(40),
contentHash: 'f'.repeat(64),
execution: 'data',
minimumOrca: '1.4.0',
types: ['interface-language']
}
function parse(filename: string, catalog: unknown) {
return parsePluginMarketplaceIndex(filename, Buffer.from(JSON.stringify(catalog)))
}
describe('parsePluginMarketplaceIndex', () => {
it('keeps valid v2 entries next to ones this client cannot install', () => {
const marketplace = parse(PLUGIN_MARKETPLACE_V2_FILENAME, {
name: 'New',
owner: 'samples',
futureHeader: true,
plugins: [
{ ...entry, id: 'samples.theme', release: { ...release, types: ['app-theme'] } },
{ ...entry, id: 'samples.broken', release: { ...release, commit: 'nope' } },
{ ...entry, release: { ...release, futureField: 1 }, futureField: 1 }
]
})
expect(marketplace).toEqual({ name: 'New', owner: 'samples', plugins: [{ ...entry, release }] })
})
it('rejects duplicate ids even when one copy is unreadable', () => {
expect(() =>
parse(PLUGIN_MARKETPLACE_V2_FILENAME, {
name: 'New',
owner: 'samples',
plugins: [
{ ...entry, release },
{ ...entry, release: { ...release, commit: 'nope' } }
]
})
).toThrow('duplicate plugin id')
})
it('keeps release entries out of the frozen v1 file', () => {
expect(() =>
parse(PLUGIN_MARKETPLACE_FILENAME, {
name: 'Old',
owner: 'samples',
plugins: [{ ...entry, release }]
})
).toThrow(`invalid ${PLUGIN_MARKETPLACE_FILENAME}`)
})
})
+62 -39
View File
@@ -1,16 +1,19 @@
import { createReadStream } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
PLUGIN_MARKETPLACE_FILENAME,
PLUGIN_MARKETPLACE_V2_FILENAME,
pluginMarketplaceSchema,
pluginMarketplaceV2Schema,
type PluginMarketplace
} from '../../shared/plugins/plugin-marketplace'
import { checkoutPluginGitSource } from './plugin-git-repository'
import { gitExecFileAsyncBuffer } from '../git/runner'
import { readPluginGitCommit } from './plugin-git-repository'
import type { PluginMarketplaceRegisteredSource } from './plugin-marketplace-store'
const MARKETPLACE_INDEX_MAX_BYTES = 16 * 1024 * 1024
// Why its own cap: a catalog repository may hold far more than one plugin, and only its index is read.
const MARKETPLACE_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024
const MARKETPLACE_GIT_TIMEOUT_MS = 120_000
export type PluginMarketplaceFetchResult = {
marketplaceCommit: string
@@ -21,47 +24,67 @@ export type PluginMarketplaceFetchResult = {
export async function fetchPluginMarketplace(
source: PluginMarketplaceRegisteredSource
): Promise<PluginMarketplaceFetchResult> {
const stagingDirectory = await mkdtemp(join(tmpdir(), 'orca-plugin-marketplace-'))
try {
const marketplaceCommit = await checkoutPluginGitSource({
const { commit, value } = await readPluginGitCommit(
{
url: source.source.url,
ref: source.source.ref,
destination: stagingDirectory,
workingDirectory: tmpdir()
})
const marketplace = await readPluginMarketplaceIndex(stagingDirectory)
return { marketplaceCommit, marketplace }
} finally {
await rm(stagingDirectory, { recursive: true, force: true })
}
}
export async function readPluginMarketplaceIndex(
rootDirectory: string
): Promise<PluginMarketplace> {
const raw = await readMarketplaceJson(rootDirectory, PLUGIN_MARKETPLACE_FILENAME)
return parseMarketplaceFile(PLUGIN_MARKETPLACE_FILENAME, () => pluginMarketplaceSchema.parse(raw))
}
async function readMarketplaceJson(rootDirectory: string, filename: string): Promise<unknown> {
const chunks: Buffer[] = []
let totalBytes = 0
for await (const chunk of createReadStream(join(rootDirectory, filename))) {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
totalBytes += bytes.byteLength
if (totalBytes > MARKETPLACE_INDEX_MAX_BYTES) {
throw new Error(`${filename} exceeds its size limit`)
}
chunks.push(bytes)
}
return parseMarketplaceFile(filename, () =>
JSON.parse(Buffer.concat(chunks, totalBytes).toString('utf8'))
workingDirectory: tmpdir(),
maxDownloadBytes: MARKETPLACE_DOWNLOAD_MAX_BYTES
},
readMarketplaceAtCommit
)
return { marketplaceCommit: commit, marketplace: value }
}
function parseMarketplaceFile<T>(filename: string, parse: () => T): T {
/** Reads only the catalog index blobs, so the rest of the repository is never written out.
* Prefers the release-pinned v2 catalog; repositories that only publish v1 still load. */
async function readMarketplaceAtCommit(
repository: string,
commit: string
): Promise<PluginMarketplace> {
const options = { cwd: repository, timeout: MARKETPLACE_GIT_TIMEOUT_MS }
const listing = await gitExecFileAsyncBuffer(
[
'ls-tree',
'-l',
'-z',
commit,
'--',
PLUGIN_MARKETPLACE_V2_FILENAME,
PLUGIN_MARKETPLACE_FILENAME
],
{ ...options, maxBuffer: 64 * 1024 }
)
const blobs = new Map<string, { object: string; size: number }>()
for (const record of listing.stdout.toString('utf8').split('\0')) {
const match = /^100(?:644|755) blob ([0-9a-f]{40}) +([0-9]+)\t(.+)$/s.exec(record)
if (match) {
blobs.set(match[3]!, { object: match[1]!, size: Number(match[2]) })
}
}
const filename = blobs.has(PLUGIN_MARKETPLACE_V2_FILENAME)
? PLUGIN_MARKETPLACE_V2_FILENAME
: PLUGIN_MARKETPLACE_FILENAME
const blob = blobs.get(filename)
if (!blob) {
throw new Error(`${PLUGIN_MARKETPLACE_FILENAME} not found`)
}
if (blob.size > MARKETPLACE_INDEX_MAX_BYTES) {
throw new Error(`${filename} exceeds its size limit`)
}
const bytes = await gitExecFileAsyncBuffer(['cat-file', 'blob', blob.object], {
...options,
maxBuffer: MARKETPLACE_INDEX_MAX_BYTES
})
return parsePluginMarketplaceIndex(filename, bytes.stdout)
}
export function parsePluginMarketplaceIndex(filename: string, bytes: Buffer): PluginMarketplace {
try {
return parse()
const raw: unknown = JSON.parse(bytes.toString('utf8'))
return filename === PLUGIN_MARKETPLACE_V2_FILENAME
? pluginMarketplaceV2Schema.parse(raw)
: pluginMarketplaceSchema.parse(raw)
} catch (error) {
throw new Error(
`invalid ${filename}: ${error instanceof Error ? error.message : String(error)}`
@@ -2,19 +2,33 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PluginCatalogRelease } from '../../shared/plugins/plugin-catalog-release'
import type { PluginMarketplace } from '../../shared/plugins/plugin-marketplace'
import { readPluginLockfile } from './plugin-install'
import { PluginMarketplaceInstaller } from './plugin-marketplace-installer'
import { PluginMarketplaceService } from './plugin-marketplace-service'
const git = vi.hoisted(() => ({
checkout: vi.fn(),
version: '1.0.0',
publisher: 'community',
id: 'notes',
commit: 'a'.repeat(40),
payload: 'first'
}))
const git = vi.hoisted(
(): {
checkout: ReturnType<typeof vi.fn>
version: string
publisher: string
id: string
commit: string
payload: string
v2: boolean
release: PluginCatalogRelease | undefined
} => ({
checkout: vi.fn(),
version: '1.0.0',
publisher: 'community',
id: 'notes',
commit: 'a'.repeat(40),
payload: 'first',
v2: false,
release: undefined
})
)
vi.mock('./plugin-git-repository', () => ({
checkoutPluginGitSource: git.checkout
@@ -40,7 +54,8 @@ function marketplace(): PluginMarketplace {
url: 'https://github.com/community/notes.git',
ref: 'stable'
},
categories: ['productivity']
categories: ['productivity'],
...(git.release ? { release: git.release } : {})
}
]
}
@@ -51,7 +66,13 @@ async function writeCurrentPlugin(destination: string): Promise<void> {
await writeFile(
join(destination, 'orca-plugin.json'),
JSON.stringify({
manifestVersion: 1,
...(git.v2
? {
manifestVersion: 2,
execution: 'instructional',
contributes: { commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }] }
}
: { manifestVersion: 1 }),
id: git.id,
publisher: git.publisher,
name: 'Notes',
@@ -98,6 +119,8 @@ beforeEach(() => {
git.id = 'notes'
git.commit = 'a'.repeat(40)
git.payload = 'first'
git.v2 = false
git.release = undefined
git.checkout.mockReset()
git.checkout.mockImplementation(async ({ destination }: { destination: string }) => {
await writeCurrentPlugin(destination)
@@ -156,6 +179,38 @@ describe('PluginMarketplaceInstaller', () => {
})
})
it('installs only the exact bytes a catalog release pins', async () => {
git.v2 = true
const unpinnedSetup = await setup()
const unpinned = await unpinnedSetup.installer.preview(
unpinnedSetup.sourceId,
'community.notes'
)
git.release = {
version: '1.0.0',
commit: git.commit,
contentHash: unpinned.contentHash,
execution: 'instructional',
minimumOrca: '1.0.0',
types: ['instructions']
}
const { installer, sourceId } = await setup()
const preview = await installer.preview(sourceId, 'community.notes')
// The pinned commit is fetched directly, so the listed branch is display text only.
expect(git.checkout).toHaveBeenLastCalledWith(expect.objectContaining({ ref: git.commit }))
await expect(installer.install(preview)).resolves.toMatchObject({ ok: true })
expect(git.checkout).toHaveBeenLastCalledWith(expect.objectContaining({ ref: git.commit }))
git.payload = 'tampered at the same commit'
await expect(installer.install(preview)).resolves.toEqual({
ok: false,
error: 'plugin bytes do not match the catalog release'
})
await expect(installer.preview(sourceId, 'community.notes')).rejects.toThrow(
'plugin bytes do not match the catalog release'
)
})
it('rejects a source whose manifest identity differs from its listing', async () => {
const { installer, sourceId } = await setup()
git.publisher = 'attacker'
@@ -65,14 +65,16 @@ export class PluginMarketplaceInstaller {
try {
const resolvedCommit = await checkoutPluginGitSource({
url: listing.source.url,
ref: listing.source.ref,
// Why: a pinned release names its commit; the listed ref is only what the author calls it.
ref: listing.release?.commit ?? listing.source.ref,
destination: stagingDirectory,
workingDirectory: tmpdir()
})
const inspection = await inspectPluginInstallTree({
rootDir: stagingDirectory,
hostVersion: this.hostVersion,
expectedPluginKey: pluginKey
expectedPluginKey: pluginKey,
...(listing.release ? { release: { record: listing.release, resolvedCommit } } : {})
})
if (!inspection.ok) {
throw new Error(inspection.error)
@@ -124,6 +126,7 @@ export class PluginMarketplaceInstaller {
resolvedCommit: preview.marketplaceCommit
},
plugin: { url: listing.source.url, ref: listing.source.ref },
...(listing.release ? { release: listing.release } : {}),
blockedPluginReason: this.blockedPluginReason
})
}
@@ -2,6 +2,7 @@ import type {
PluginMarketplaceEntry,
PluginMarketplaceGitSource
} from '../../shared/plugins/plugin-marketplace'
import type { PluginCatalogRelease } from '../../shared/plugins/plugin-catalog-release'
export type PluginMarketplaceSourceState = {
id: string
@@ -30,4 +31,6 @@ export type PluginMarketplaceListing = {
official: boolean
bundled: boolean
blockedByKillList?: { reason: string; advisoryUrl?: string }
/** Exact release the catalog vouches for; absent for v1 catalogs. */
release?: PluginCatalogRelease
}
@@ -277,6 +277,7 @@ export class PluginMarketplaceService {
categories: entry.categories,
official,
bundled: false,
...(entry.release ? { release: entry.release } : {}),
...(blocked
? {
blockedByKillList: {
@@ -1,4 +1,4 @@
import { mkdtemp, readdir, rm } from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
@@ -82,4 +82,56 @@ describe('PluginMarketplaceStore', () => {
await expect(store.readSnapshot('../outside')).rejects.toThrow()
await expect(store.removeSource('../outside')).rejects.toThrow()
})
it('keeps release-bearing snapshots out of the key older builds read', async () => {
const root = await tempRoot()
const store = new PluginMarketplaceStore(root)
const registered = await store.addSource(source(), 123)
const snapshots = join(root, 'marketplaces', 'snapshots')
const legacy = {
schemaVersion: 1,
sourceId: registered.id,
source: source(),
marketplaceCommit: 'a'.repeat(40),
fetchedAt: 1,
marketplace: { name: 'Old', owner: 'community', plugins: [] }
}
await mkdir(snapshots, { recursive: true })
await writeFile(join(snapshots, `${registered.id}.json`), JSON.stringify(legacy))
await expect(store.readSnapshot(registered.id)).resolves.toEqual(legacy)
await store.writeSnapshot({
source: registered,
marketplaceCommit: 'b'.repeat(40),
marketplace: {
name: 'New',
owner: 'community',
plugins: [
{
id: 'community.notes',
source: { kind: 'git', url: 'https://github.com/community/notes.git', ref: 'v1' },
categories: [],
release: {
version: '1.0.0',
commit: 'c'.repeat(40),
contentHash: 'f'.repeat(64),
execution: 'data',
minimumOrca: '1.4.0',
types: ['interface-language']
}
}
]
}
})
await expect(store.readSnapshot(registered.id)).resolves.toMatchObject({
marketplace: { name: 'New' }
})
// An older build reads only `<id>.json`, which still holds v1-shaped data it can parse.
expect(JSON.parse(await readFile(join(snapshots, `${registered.id}.json`), 'utf8'))).toEqual(
legacy
)
await store.removeSource(registered.id)
expect(await readdir(snapshots)).toEqual([])
})
})
+41 -11
View File
@@ -6,6 +6,7 @@ import { z } from 'zod'
import { PLUGIN_COMMIT_PATTERN } from '../../shared/plugins/plugin-install-lockfile'
import {
pluginMarketplaceGitSourceSchema,
pluginMarketplaceListingSchema,
pluginMarketplaceSchema,
type PluginMarketplace,
type PluginMarketplaceGitSource
@@ -25,17 +26,29 @@ const sourceFileSchema = z.strictObject({
schemaVersion: z.literal(1),
sources: z.array(registeredSourceSchema).max(PLUGIN_MARKETPLACE_SOURCE_LIMIT)
})
const cachedSnapshotSchema = z.strictObject({
schemaVersion: z.literal(1),
const snapshotFields = {
sourceId: sourceIdSchema,
source: pluginMarketplaceGitSourceSchema,
marketplaceCommit: z.string().regex(PLUGIN_COMMIT_PATTERN),
fetchedAt: z.number().finite().nonnegative(),
fetchedAt: z.number().finite().nonnegative()
}
// Why two cache keys: builds that predate release records parse `<id>.json` strictly, so a
// downgrade must find only v1-shaped data there and refetch rather than fail.
const legacySnapshotSchema = z.strictObject({
schemaVersion: z.literal(1),
...snapshotFields,
marketplace: pluginMarketplaceSchema
})
const cachedSnapshotSchema = z.strictObject({
schemaVersion: z.literal(2),
...snapshotFields,
marketplace: pluginMarketplaceListingSchema
})
export type PluginMarketplaceRegisteredSource = z.infer<typeof registeredSourceSchema>
export type PluginMarketplaceCachedSnapshot = z.infer<typeof cachedSnapshotSchema>
export type PluginMarketplaceCachedSnapshot =
| z.infer<typeof cachedSnapshotSchema>
| z.infer<typeof legacySnapshotSchema>
const SOURCE_FILE_MAX_BYTES = 2 * 1024 * 1024
const SNAPSHOT_FILE_MAX_BYTES = 16 * 1024 * 1024
@@ -97,6 +110,7 @@ export class PluginMarketplaceStore {
})
if (removed) {
await rm(this.snapshotPath(parsedId), { force: true })
await rm(this.legacySnapshotPath(parsedId), { force: true })
}
return removed
}
@@ -108,7 +122,7 @@ export class PluginMarketplaceStore {
marketplace: PluginMarketplace
}): Promise<PluginMarketplaceCachedSnapshot> {
const snapshot = cachedSnapshotSchema.parse({
schemaVersion: 1,
schemaVersion: 2,
sourceId: input.source.id,
source: input.source.source,
marketplaceCommit: input.marketplaceCommit,
@@ -121,17 +135,29 @@ export class PluginMarketplaceStore {
async readSnapshot(sourceId: string): Promise<PluginMarketplaceCachedSnapshot | null> {
const parsedId = sourceIdSchema.parse(sourceId)
return (
(await this.readSnapshotFile(this.snapshotPath(parsedId), parsedId, cachedSnapshotSchema)) ??
(await this.readSnapshotFile(
this.legacySnapshotPath(parsedId),
parsedId,
legacySnapshotSchema
))
)
}
private async readSnapshotFile<T extends { sourceId: string }>(
path: string,
sourceId: string,
schema: z.ZodType<T>
): Promise<T | null> {
try {
const raw = JSON.parse(
await readBoundedText(this.snapshotPath(parsedId), SNAPSHOT_FILE_MAX_BYTES)
)
const parsed = cachedSnapshotSchema.parse(raw)
if (parsed.sourceId !== parsedId) {
const parsed = schema.parse(JSON.parse(await readBoundedText(path, SNAPSHOT_FILE_MAX_BYTES)))
if (parsed.sourceId !== sourceId) {
throw new Error('marketplace snapshot source identity does not match its cache path')
}
return parsed
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
return null
}
throw new Error(
@@ -188,6 +214,10 @@ export class PluginMarketplaceStore {
}
private snapshotPath(sourceId: string): string {
return join(this.snapshotDirectory, `${sourceId}.v2.json`)
}
private legacySnapshotPath(sourceId: string): string {
return join(this.snapshotDirectory, `${sourceId}.json`)
}
}
@@ -45,6 +45,7 @@ import { registerGitRepositoryRegistrationBinaryCompatibilityCase } from './git-
import { registerGitLocalBaseBranchBinaryCompatibilityCase } from './git-local-base-branch-binary-compatibility.test-cases'
import { registerGitResolutionBinaryCompatibilityCases } from './git-resolution-binary-compatibility.test-cases'
import { registerGitWorktreeBinaryCompatibilityCases } from './git-worktree-binary-compatibility.test-cases'
import { registerPluginGitDownloadBinaryCompatibilityCase } from './plugin-git-download-binary-compatibility.test-cases'
const image = process.env.ORCA_GIT_COMPAT_IMAGE
const binary = process.env.ORCA_GIT_COMPAT_BINARY
@@ -816,6 +817,7 @@ describeBinaryCompatibility('real Git binary compatibility', () => {
})
registerGitLocalBaseBranchBinaryCompatibilityCase(runGit, () => repoPath, Boolean(image))
registerPluginGitDownloadBinaryCompatibilityCase(runGit, () => repoPath)
registerGitResolutionBinaryCompatibilityCases(runGit, (name) =>
image ? `/repo/${name}.git` : join(repoPath, `${name}.git`)
)
@@ -0,0 +1,66 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import {
pluginGitCloneArgs,
pluginGitConfigArgs,
pluginGitFetchCommitArgs,
pluginGitTreeArgs
} from './plugins/plugin-git-arguments'
export function registerPluginGitDownloadBinaryCompatibilityCase(
runGit: (
args: string[],
env?: NodeJS.ProcessEnv,
stdin?: string
) => Promise<{ stdout: string; stderr: string }>,
getRepoPath: () => string
): void {
// Why pin this: plugin downloads pass every `-c` key and flag below to the user's Git, and a
// local path counts as the file transport, so the protocol policy is observable offline.
it('downloads a plugin commit with the exact no-checkout arguments', async () => {
const source = 'compat-plugin-source'
await mkdir(join(getRepoPath(), source, 'bin'), { recursive: true })
await writeFile(join(getRepoPath(), source, 'bin', 'run'), 'plugin\n', { mode: 0o755 })
await runGit(['init', '-q', source])
await runGit(['-C', source, 'add', '--all'])
// Why: Windows ignores the 0o755 file mode, so record the executable bit in the index directly.
await runGit(['-C', source, 'update-index', '--chmod=+x', 'bin/run'])
// Why inline identity: a fresh repository does not inherit the fixture repository's config.
const identity = [
'-c',
'user.name=Compatibility Test',
'-c',
'user.email=compatibility@example.invalid'
]
await runGit(['-C', source, ...identity, 'commit', '-qm', 'plugin'])
await runGit(['-C', source, 'tag', 'v1'])
const commit = (await runGit(['-C', source, 'rev-parse', 'HEAD'])).stdout.trim()
const config = pluginGitConfigArgs('compat-no-hooks')
const allowLocal = ['-c', 'protocol.file.allow=always']
await expect(
runGit([...config, ...pluginGitCloneArgs(source, 'v1', 'refused')])
).rejects.toThrow("transport 'file' not allowed")
await runGit([...config, ...allowLocal, ...pluginGitCloneArgs(source, 'v1', 'compat-clone')])
await runGit([...config, 'init', '--quiet', '--template=', 'compat-fetch'])
await runGit(['-C', 'compat-fetch', ...config, 'remote', 'add', 'origin', `../${source}`])
await runGit([
'-C',
'compat-fetch',
...config,
...allowLocal,
...pluginGitFetchCommitArgs(commit)
])
for (const repository of ['compat-clone', 'compat-fetch']) {
const tree = await runGit(['-C', repository, ...pluginGitTreeArgs(commit)])
const [directory, file] = tree.stdout.split('\0')
expect(directory).toMatch(/^040000 tree [0-9a-f]{40} +-\tbin$/)
expect(file).toMatch(/^100755 blob [0-9a-f]{40} +7\tbin\/run$/)
const object = file?.slice(12, 52) ?? ''
const blob = await runGit(['-C', repository, 'cat-file', '--batch'], {}, `${object}\n`)
expect(blob.stdout).toBe(`${object} blob 7\nplugin\n\n`)
}
})
}
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest'
import {
pluginCatalogReleaseError,
pluginCatalogReleaseSchema,
type PluginCatalogRelease
} from './plugin-catalog-release'
import { parsePluginManifest, type PluginManifest } from './plugin-manifest'
const COMMIT = 'c'.repeat(40)
const HASH = 'f'.repeat(64)
function manifest(overrides: Record<string, unknown> = {}): PluginManifest {
const parsed = parsePluginManifest({
manifestVersion: 2,
execution: 'data',
id: 'demo',
publisher: 'samples',
name: 'Demo',
version: '1.2.0',
engines: { orca: '>=1.4.0' },
pluginApi: 1,
contributes: { languagePacks: [{ locale: 'pt-BR', path: 'pt.json' }] },
...overrides
})
if (!parsed.ok) {
throw new Error(parsed.error)
}
return parsed.manifest
}
function release(overrides: Partial<PluginCatalogRelease> = {}): PluginCatalogRelease {
return pluginCatalogReleaseSchema.parse({
version: '1.2.0',
commit: COMMIT,
contentHash: HASH,
execution: 'data',
minimumOrca: '1.4.0',
types: ['interface-language'],
...overrides
})
}
function check(record: PluginCatalogRelease, subject = manifest()): string | null {
return pluginCatalogReleaseError({
release: record,
manifest: subject,
resolvedCommit: COMMIT,
contentHash: HASH
})
}
function manifestInput(): Record<string, unknown> {
return {
manifestVersion: 2,
execution: 'data',
id: 'demo',
publisher: 'samples',
name: 'Demo',
version: '1.2.0',
engines: { orca: '>=1.4.0' },
pluginApi: 1
}
}
describe('catalog releases', () => {
it('accepts a release that matches the exact bytes and manifest', () => {
expect(check(release())).toBeNull()
})
it('strips unknown optional fields instead of rejecting the release', () => {
expect(pluginCatalogReleaseSchema.parse({ ...release(), futureField: 1 })).toEqual(release())
})
it('rejects a release type its profile cannot run or this client cannot install', () => {
expect(() => release({ types: ['worker'] })).toThrow('data cannot declare worker')
expect(
pluginCatalogReleaseSchema.safeParse({ ...release(), types: ['app-theme'] }).success
).toBe(false)
})
it.each([
['commit', { resolvedCommit: 'd'.repeat(40) }, 'commit'],
['bytes', { contentHash: 'e'.repeat(64) }, 'bytes']
])('rejects a changed %s', (_label, override, message) => {
expect(
pluginCatalogReleaseError({
release: release(),
manifest: manifest(),
resolvedCommit: COMMIT,
contentHash: HASH,
...override
})
).toContain(message)
})
it.each([
['version', release({ version: '1.3.0' }), 'version'],
['profile', release({ execution: 'instructional' }), 'execution profile'],
['compatibility', release({ minimumOrca: '1.5.0' }), 'compatibility']
])('rejects a manifest whose %s differs from the release', (_label, record, message) => {
expect(check(record)).toContain(message)
})
it('rejects declared contributions the manifest does not carry', () => {
const record = release({
execution: 'instructional',
types: ['interface-language', 'instructions']
})
expect(check(record, manifest({ execution: 'instructional' }))).toContain('contributions')
})
it('requires a v2 manifest', () => {
const v1 = parsePluginManifest({ ...manifestInput(), manifestVersion: 1, execution: undefined })
if (!v1.ok) {
throw new Error(v1.error)
}
expect(check(release(), v1.manifest)).toContain('version 2 manifest')
})
})
@@ -0,0 +1,106 @@
import { z } from 'zod'
import {
pluginExecutionAllows,
pluginExecutionProfileSchema,
type PluginExecutionProfile
} from './plugin-execution-profile'
import type { PluginManifest } from './plugin-manifest'
// Why only installable types: a catalog entry naming any other type fails to parse, so the
// v2 catalog drops it instead of listing a plugin this client cannot install.
export const PLUGIN_CATALOG_TYPES = [
'interface-language',
'instructions',
'panel',
'worker'
] as const
export type PluginCatalogType = (typeof PLUGIN_CATALOG_TYPES)[number]
const CATALOG_TYPE_PROFILE: Partial<Record<PluginCatalogType, PluginExecutionProfile>> = {
instructions: 'instructional',
panel: 'sandboxed-ui',
worker: 'trusted-worker'
}
/** The exact release a catalog vouches for. Unknown keys are stripped so a newer catalog
* can add optional fields without breaking clients that predate them. */
export const pluginCatalogReleaseSchema = z
.object({
version: z.string().min(1).max(128),
// Why SHA-1 only: plugin materialization rejects SHA-256 repositories.
commit: z.string().regex(/^[0-9a-f]{40}$/),
contentHash: z.string().regex(/^[0-9a-f]{64}$/),
execution: pluginExecutionProfileSchema,
minimumOrca: z.string().regex(/^\d+\.\d+\.\d+$/),
types: z
.array(z.enum(PLUGIN_CATALOG_TYPES))
.min(1)
.max(PLUGIN_CATALOG_TYPES.length)
.refine((types) => new Set(types).size === types.length, 'duplicate contribution type')
})
.superRefine((release, ctx) => {
for (const [index, type] of release.types.entries()) {
const required = CATALOG_TYPE_PROFILE[type]
if (required && !pluginExecutionAllows(release.execution, required)) {
ctx.addIssue({
code: 'custom',
path: ['types', index],
message: `${release.execution} cannot declare ${type}`
})
}
}
})
export type PluginCatalogRelease = z.infer<typeof pluginCatalogReleaseSchema>
export function pluginCatalogTypes(manifest: PluginManifest): PluginCatalogType[] {
const c = manifest.contributes
const types: PluginCatalogType[] = []
if (c.languagePacks.length > 0) {
types.push('interface-language')
}
if (c.commands.length || c.keybindings.length || c.vmRecipes.length || c.agents.length) {
types.push('instructions')
}
if (c.panels.length > 0) {
types.push('panel')
}
if (manifest.main !== undefined) {
types.push('worker')
}
return types
}
/** Why null-or-message: install and preview surface the first mismatch verbatim. */
export function pluginCatalogReleaseError(input: {
release: PluginCatalogRelease
manifest: PluginManifest
resolvedCommit: string | null
contentHash: string
}): string | null {
const { release, manifest } = input
if (input.resolvedCommit !== release.commit) {
return 'plugin source no longer matches the catalog release commit'
}
if (input.contentHash !== release.contentHash) {
return 'plugin bytes do not match the catalog release'
}
if (manifest.manifestVersion !== 2) {
return 'catalog releases require a version 2 manifest'
}
if (manifest.version !== release.version) {
return 'manifest version does not match the catalog release'
}
if (manifest.execution !== release.execution) {
return 'execution profile does not match the catalog release'
}
if (manifest.engines.orca !== `>=${release.minimumOrca}`) {
return 'Orca compatibility does not match the catalog release'
}
const declared = [...release.types].sort().join()
if (pluginCatalogTypes(manifest).sort().join() !== declared) {
return 'contributions do not match the catalog release'
}
return null
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { derivePluginExecutionProfile } from './plugin-execution-profile'
import { parsePluginManifest } from './plugin-manifest'
function manifestInput(): Record<string, unknown> {
return {
manifestVersion: 2,
execution: 'data',
id: 'demo',
publisher: 'samples',
name: 'Demo',
version: '1.2.0',
engines: { orca: '>=1.4.0' },
pluginApi: 1
}
}
describe('execution profiles', () => {
const empty = { panels: [], commands: [], events: [], keybindings: [], vmRecipes: [], agents: [] }
it.each([
[{ main: 'main.mjs' }, {}, 'trusted-worker'],
[{}, { panels: [{}] }, 'sandboxed-ui'],
[{}, { commands: [{}] }, 'instructional'],
[{}, { vmRecipes: [{}] }, 'instructional'],
[{}, {}, 'data']
])('infers the least profile for a v1 manifest', (top, contributes, expected) => {
expect(
derivePluginExecutionProfile({
capabilities: [],
...top,
contributes: { ...empty, ...contributes }
})
).toBe(expected)
})
it('rejects v2 contributions beyond the declared profile', () => {
const parsed = parsePluginManifest({
...manifestInput(),
execution: 'data',
contributes: { commands: [{ id: 'open', title: 'Open', action: 'view.tasks' }] }
})
expect(parsed).toMatchObject({ ok: false, error: expect.stringContaining('execution') })
})
it('requires a Node entry for trusted-worker and rejects unknown v2 keys', () => {
expect(parsePluginManifest({ ...manifestInput(), execution: 'trusted-worker' })).toMatchObject({
ok: false,
error: expect.stringContaining('main')
})
expect(parsePluginManifest({ ...manifestInput(), surprise: true })).toMatchObject({ ok: false })
})
it('keeps v1 manifests valid without a declared profile', () => {
expect(parsePluginManifest({ ...manifestInput(), manifestVersion: 1 })).toMatchObject({
ok: true
})
})
})
@@ -0,0 +1,86 @@
import { z } from 'zod'
import type { RefinementCtx } from 'zod'
/** Ordered from least to most authority; each profile may carry everything below it. */
export const PLUGIN_EXECUTION_PROFILES = [
'data',
'instructional',
'sandboxed-ui',
'trusted-worker'
] as const
export const pluginExecutionProfileSchema = z.enum(PLUGIN_EXECUTION_PROFILES)
export type PluginExecutionProfile = z.infer<typeof pluginExecutionProfileSchema>
type ExecutionSubject = {
main?: string
capabilities: readonly unknown[]
contributes: {
panels: readonly unknown[]
commands: readonly unknown[]
events: readonly unknown[]
keybindings: readonly unknown[]
vmRecipes: readonly unknown[]
agents: readonly unknown[]
}
}
function rank(profile: PluginExecutionProfile): number {
return PLUGIN_EXECUTION_PROFILES.indexOf(profile)
}
export function pluginExecutionAllows(
profile: PluginExecutionProfile,
required: PluginExecutionProfile
): boolean {
return rank(profile) >= rank(required)
}
/** Least profile that can run what a manifest declares; v1 manifests use this as their profile. */
export function derivePluginExecutionProfile(manifest: ExecutionSubject): PluginExecutionProfile {
const c = manifest.contributes
if (manifest.main !== undefined || c.events.length > 0) {
return 'trusted-worker'
}
if (c.panels.length > 0) {
return 'sandboxed-ui'
}
if (
c.commands.length > 0 ||
c.keybindings.length > 0 ||
c.vmRecipes.length > 0 ||
c.agents.length > 0 ||
manifest.capabilities.length > 0
) {
return 'instructional'
}
return 'data'
}
export function pluginExecutionProfile(
manifest: ExecutionSubject & { execution?: PluginExecutionProfile }
): PluginExecutionProfile {
return manifest.execution ?? derivePluginExecutionProfile(manifest)
}
/** A v2 manifest declares its profile, so contributions beyond it fail validation. */
export function validatePluginExecutionProfile(
manifest: ExecutionSubject & { execution: PluginExecutionProfile },
ctx: RefinementCtx
): void {
const required = derivePluginExecutionProfile(manifest)
if (!pluginExecutionAllows(manifest.execution, required)) {
ctx.addIssue({
code: 'custom',
path: ['execution'],
message: `${manifest.execution} cannot carry contributions that need ${required}`
})
}
if (manifest.execution === 'trusted-worker' && manifest.main === undefined) {
ctx.addIssue({
code: 'custom',
path: ['main'],
message: 'trusted-worker requires a Node entry'
})
}
}
@@ -0,0 +1,50 @@
/** Exact Git argv for plugin downloads, shared with the real-binary compatibility matrix. */
const PLUGIN_GIT_SETTINGS = [
// Why per-protocol keys too: a user's `protocol.<name>.allow` outranks `protocol.allow`, and
// an insteadOf rewrite or a redirect must not reach file, ext or plain-http transports.
'protocol.allow=never',
'protocol.https.allow=always',
'protocol.ssh.allow=always',
...['file', 'ext', 'git', 'http', 'ftp', 'ftps'].map((name) => `protocol.${name}.allow=never`),
'submodule.recurse=false',
'fetch.recurseSubmodules=false',
'gc.auto=0',
'maintenance.auto=false',
'init.defaultObjectFormat=sha1',
// Why: keeps every fetched object in one pack so the size watcher sees it grow.
'fetch.unpackLimit=0'
]
/** An empty hooksPath and template stop user or global hooks (e.g. reference-transaction);
* unknown keys are ignored by older Git, so these stay 2.25-safe. */
export function pluginGitConfigArgs(emptyHooksPath: string): string[] {
return [`core.hooksPath=${emptyHooksPath}`, ...PLUGIN_GIT_SETTINGS].flatMap((setting) => [
'-c',
setting
])
}
export function pluginGitCloneArgs(url: string, ref: string, repository: string): string[] {
return [
'clone',
'--quiet',
'--no-checkout',
'--no-local',
'--template=',
'--depth',
'1',
...(ref.length > 0 ? ['--branch', ref] : []),
'--',
url,
repository
]
}
export function pluginGitFetchCommitArgs(commit: string): string[] {
return ['fetch', '--quiet', '--no-tags', '--depth', '1', 'origin', commit]
}
export function pluginGitTreeArgs(commit: string): string[] {
return ['ls-tree', '-r', '-t', '-l', '-z', '--full-tree', commit]
}
+27 -5
View File
@@ -16,9 +16,22 @@ import {
pluginRelativePathSchema
} from './plugin-manifest-fields'
import { validatePluginManifestContributions } from './plugin-manifest-contribution-validation'
import {
pluginExecutionProfileSchema,
validatePluginExecutionProfile
} from './plugin-execution-profile'
/** Shared orca-plugin.json validation keeps desktop, serve, relay and CLI behavior aligned. Contribution names mirror
* Electron conventions for future adapters. Experimental until pluginApi v1 freezes. */
/**
* Plugin manifest v1 and v2 (`orca-plugin.json` at the plugin root). The
* `contributes` key names deliberately mirror common Electron-ecosystem
* manifest conventions so future adapters stay cheap.
*
* Lives in `shared` so the desktop app, the headless `orca serve` runtime,
* the relay, and the CLI validate manifests identically (SSH/remote parity).
*
* Everything here is EXPERIMENTAL: no compatibility promises until pluginApi
* v1 freezes (see the plugin roadmap).
*/
const SEMVER_RE =
/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
@@ -106,9 +119,18 @@ const pluginManifestV1Object = z.object({
capabilities: z.array(pluginCapabilitySchema).max(32).default([])
})
export const pluginManifestSchema = pluginManifestV1Object.superRefine(
validatePluginManifestContributions
)
// Why strict: v2 declares its execution profile, so unknown keys must not smuggle in behavior.
const pluginManifestV2Object = pluginManifestV1Object
.extend({ manifestVersion: z.literal(2), execution: pluginExecutionProfileSchema })
.strict()
export const pluginManifestSchema = z.discriminatedUnion('manifestVersion', [
pluginManifestV1Object.superRefine(validatePluginManifestContributions),
pluginManifestV2Object.superRefine((manifest, ctx) => {
validatePluginManifestContributions(manifest, ctx)
validatePluginExecutionProfile(manifest, ctx)
})
])
export type PluginManifest = z.infer<typeof pluginManifestSchema>
export type PluginPanelContribution = z.infer<typeof panelContributionSchema>
+48 -2
View File
@@ -1,8 +1,12 @@
import { z } from 'zod'
import { isAllowedPluginGitUrl } from './plugin-install-lockfile'
import { isQualifiedPluginKey } from './plugin-manifest'
import { pluginCatalogReleaseSchema } from './plugin-catalog-release'
export const PLUGIN_MARKETPLACE_FILENAME = 'orca-marketplace.json'
// Why a second file: v1 entries are strict, so shipped clients reject any new field.
// Release-bearing entries live here and the v1 file stays frozen for those clients.
export const PLUGIN_MARKETPLACE_V2_FILENAME = 'orca-marketplace.v2.json'
export const PLUGIN_MARKETPLACE_ENTRY_LIMIT = 2_048
export const PLUGIN_MARKETPLACE_CATEGORY_LIMIT = 16
@@ -95,6 +99,16 @@ export const pluginMarketplaceEntrySchema = z
.strictObject(marketplaceEntryFields)
.superRefine(rejectDuplicateCategories)
/** v2 entries strip unknown keys so later optional fields reach older clients safely. */
const pluginMarketplaceV2EntrySchema = z
.object({ ...marketplaceEntryFields, release: pluginCatalogReleaseSchema })
.superRefine(rejectDuplicateCategories)
/** Normalized entry held in memory and in the snapshot cache, from either catalog file. */
const pluginMarketplaceListingEntrySchema = z
.strictObject({ ...marketplaceEntryFields, release: pluginCatalogReleaseSchema.optional() })
.superRefine(rejectDuplicateCategories)
const marketplaceHeaderFields = {
name: z.string().min(1).max(256),
owner: marketplaceOwnerSchema
@@ -107,6 +121,38 @@ export const pluginMarketplaceSchema = z
})
.superRefine(rejectDuplicatePluginIds)
export const pluginMarketplaceListingSchema = z
.strictObject({
...marketplaceHeaderFields,
plugins: z.array(pluginMarketplaceListingEntrySchema).max(PLUGIN_MARKETPLACE_ENTRY_LIMIT)
})
.superRefine(rejectDuplicatePluginIds)
/** Why entries parse one by one: an entry this client cannot install (a newer contribution
* type) is skipped instead of hiding every other plugin in the catalog. Duplicate ids are
* still checked across every raw entry, matching the v1 rule. */
export const pluginMarketplaceV2Schema = z
.object({
...marketplaceHeaderFields,
plugins: z.array(z.unknown()).max(PLUGIN_MARKETPLACE_ENTRY_LIMIT)
})
.superRefine((catalog, context) =>
rejectDuplicatePluginIds({ plugins: catalog.plugins.map(rawPluginIdentity) }, context)
)
.transform((catalog) => ({
...catalog,
plugins: catalog.plugins.flatMap((entry) => {
const parsed = pluginMarketplaceV2EntrySchema.safeParse(entry)
return parsed.success ? [parsed.data] : []
})
}))
function rawPluginIdentity(entry: unknown, index: number): { id: string } {
const id = typeof entry === 'object' && entry !== null && 'id' in entry ? entry.id : undefined
// Why the NUL placeholder: entries without a string id can never collide with each other.
return { id: typeof id === 'string' ? id : `\0${index}` }
}
/** Host-derived trust metadata. Marketplace JSON cannot self-award either bit. */
export const pluginMarketplaceTrustMetadataSchema = z
.strictObject({
@@ -118,8 +164,8 @@ export const pluginMarketplaceTrustMetadataSchema = z
path: ['bundled']
})
export type PluginMarketplace = z.infer<typeof pluginMarketplaceSchema>
export type PluginMarketplaceEntry = z.infer<typeof pluginMarketplaceEntrySchema>
export type PluginMarketplace = z.infer<typeof pluginMarketplaceListingSchema>
export type PluginMarketplaceEntry = z.infer<typeof pluginMarketplaceListingEntrySchema>
export type PluginMarketplaceGitSource = z.infer<typeof pluginMarketplaceGitSourceSchema>
export type PluginMarketplaceTrustMetadata = z.infer<typeof pluginMarketplaceTrustMetadataSchema>
+24 -5
View File
@@ -3,14 +3,26 @@
import { execFile } from 'node:child_process'
import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
import { join } from 'node:path'
import { promisify } from 'node:util'
import type { Page, TestInfo } from '@stablyai/playwright-test'
import { expect, test } from '@stablyai/playwright-test'
import { createRestartSession } from './helpers/orca-restart'
const execFileAsync = promisify(execFile)
const SSH_SHIM = join(
process.cwd(),
'src',
'main',
'plugins',
'plugin-private-marketplace-ssh-shim.cjs'
)
const FIXTURE_REPOSITORIES = [
'orca-plugins',
'orca-portuguese',
'orca-multipass-recipes',
'orca-navigation-shortcuts'
]
type MarketplaceFixture = {
root: string
@@ -82,10 +94,17 @@ async function configureFixtureGit(
GIT_CONFIG_NOSYSTEM: '1',
GIT_TERMINAL_PROMPT: '0'
}
const repositoryBaseUrl = pathToFileURL(`${repositories}${sep}`).href
// Why SSH: plugin downloads refuse the file transport even behind an insteadOf rewrite, so the
// fixture repositories are served through the same GIT_SSH_COMMAND shim the unit tests use.
gitEnvironment.GIT_SSH_COMMAND = `"${process.execPath.replaceAll('\\', '/')}" "${SSH_SHIM.replaceAll('\\', '/')}"`
gitEnvironment.GIT_SSH_VARIANT = 'ssh'
gitEnvironment.ORCA_TEST_SSH_REPOSITORIES = JSON.stringify(
Object.fromEntries(
FIXTURE_REPOSITORIES.map((name) => [`/${name}.git`, join(repositories, `${name}.git`)])
)
)
const entries = [
[`url.${repositoryBaseUrl}.insteadOf`, 'https://github.com/stablyai/'],
['protocol.file.allow', 'always'],
['url.ssh://git@fixtures.invalid/.insteadOf', 'https://github.com/stablyai/'],
['commit.gpgSign', 'false'],
['tag.gpgSign', 'false'],
['core.hooksPath', hooksDirectory]