import { execFile } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { cp, mkdir, readFile, rename, rm, stat } from 'node:fs/promises'; import { createRequire } from 'node:module'; import path from 'node:path'; import { atomicWriteJson } from '../coding-projects/atomic-json'; import { devicePackageKind, inspectDevicePackage, parseDevicePackageSource, relativeContainedPath, safeStorageSegment, type DevicePackageIndexV1, type DevicePackageRecordV1, type InstallPreviewV1, } from './device-package-format'; export * from './device-package-format'; const PLAN_TTL_MS = 10 * 60 * 1000; const INSTALL_OUTPUT_BYTES = 64 * 1024; const INSTALL_TIMEOUT_MS = 2 * 60 * 1000; const INDEX_SCHEMA_VERSION = 1; const EXECUTABLE_WARNING = '此包包含可执行代码,将获得完整桌面权限,包括当前进程可用的文件、网络和进程权限。'; const SKILL_SCRIPT_WARNING = '此包包含可执行 Skill 脚本;Skill 被调用时,脚本可能使用当前桌面用户可用的文件、网络和进程权限。'; function executableWarning(kind: InstallPreviewV1['kind'], includesExecutableCode: boolean): string | null { if (!includesExecutableCode) return null; return kind === 'skill-only' ? SKILL_SCRIPT_WARNING : EXECUTABLE_WARNING; } export class DevicePackageError extends Error { constructor( readonly code: | 'local_package_source_unsupported' | 'local_package_not_found' | 'local_package_manifest_invalid' | 'local_package_dependency_failed' | 'local_package_confirmation_required' | 'local_package_plan_expired' | 'local_package_install_failed' | 'local_package_not_installed' | 'local_package_in_use', message: string, ) { super(message); this.name = 'DevicePackageError'; } } export interface DevicePackageInstallInput { source: string; executablePath: string; cliPath: string; npmCliPath?: string; agentDir: string; cwd: string; env: NodeJS.ProcessEnv; } export type DevicePackageInstallRunner = (input: DevicePackageInstallInput) => Promise; interface PiPackageManagerModule { DefaultPackageManager: new (input: { cwd: string; agentDir: string; settingsManager: unknown; }) => { getInstalledPath(source: string, scope: 'user'): string | undefined }; SettingsManager: { inMemory(user: Record, project: { projectTrusted: boolean }): unknown; }; } export interface DevicePackageManagerOptions { rootDir: string; executablePath?: string; cliPath?: string; npmCliPath?: string; runInstall?: DevicePackageInstallRunner; now?: () => number; createId?: () => string; writeIndex?: (filePath: string, value: DevicePackageIndexV1) => Promise; onGenerationChanged?(index: DevicePackageIndexV1): Promise | void; } interface StagedPlanV1 { schemaVersion: 1; requestedByTurnId: string; preview: InstallPreviewV1; record: DevicePackageRecordV1; } export interface EnabledDevicePackageResources { generation: number; packageIds: string[]; packageRefs: Array<{ packageId: string; resolvedVersion: string }>; skillEntries: Array<{ id: string; entryPath: string; packageRoot: string }>; extensionPaths: string[]; } function installEnvironment(agentDir: string): NodeJS.ProcessEnv { return { ...process.env, ELECTRON_RUN_AS_NODE: '1', PI_CODING_AGENT_DIR: agentDir, PI_TELEMETRY: '0', GIT_TERMINAL_PROMPT: '0', GIT_SSH_COMMAND: 'ssh -o BatchMode=yes', CI: '1', npm_config_ignore_scripts: 'true', npm_config_update_notifier: 'false', npm_config_audit: 'false', npm_config_fund: 'false', }; } export async function runPiPackageInstall(input: DevicePackageInstallInput): Promise { await mkdir(input.agentDir, { recursive: true }); await mkdir(input.cwd, { recursive: true }); if (input.npmCliPath) { await atomicWriteJson(path.join(input.agentDir, 'settings.json'), { npmCommand: [input.executablePath, input.npmCliPath], }); } await new Promise((resolve, reject) => { execFile( input.executablePath, [input.cliPath, 'install', input.source, '--no-approve'], { cwd: input.cwd, env: input.env, windowsHide: true, timeout: INSTALL_TIMEOUT_MS, maxBuffer: INSTALL_OUTPUT_BYTES, }, (error) => error ? reject(error) : resolve(), ); }); } function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } function string(value: unknown, field: string, maximum = 2048): string { if (typeof value !== 'string' || !value.trim() || value.length > maximum) { throw new DevicePackageError('local_package_install_failed', `${field} is invalid`); } return value; } function parseRecord(value: unknown): DevicePackageRecordV1 { if (!isRecord(value) || value.schemaVersion !== 1 || !isRecord(value.source) || !Array.isArray(value.skillEntries) || !Array.isArray(value.extensionEntries) || typeof value.enabled !== 'boolean' || typeof value.confirmedExecutableCode !== 'boolean') { throw new DevicePackageError('local_package_install_failed', 'Device package index is invalid'); } const sourceKind = value.source.kind; if (sourceKind !== 'npm' && sourceKind !== 'git' && sourceKind !== 'file') { throw new DevicePackageError('local_package_install_failed', 'Device package source is invalid'); } const kind = value.kind; if (kind !== 'skill-only' && kind !== 'pi-extension' && kind !== 'mixed') { throw new DevicePackageError('local_package_install_failed', 'Device package kind is invalid'); } const skillEntries = value.skillEntries.map((entry) => { if (!isRecord(entry)) throw new DevicePackageError('local_package_install_failed', 'Skill entry is invalid'); return { id: string(entry.id, 'Skill id', 128), entryPath: string(entry.entryPath, 'Skill path') }; }); return { schemaVersion: 1, packageId: string(value.packageId, 'Package id', 128), displayName: string(value.displayName, 'Display name', 256), resolvedVersion: string(value.resolvedVersion, 'Resolved version', 128), source: { kind: sourceKind, requested: string(value.source.requested, 'Requested source'), resolved: string(value.source.resolved, 'Resolved source'), }, kind, skillEntries, extensionEntries: value.extensionEntries.map((entry) => string(entry, 'Extension path')), enabled: value.enabled, confirmedExecutableCode: value.confirmedExecutableCode, installedAt: string(value.installedAt, 'Installed at', 64), }; } function parseIndex(value: unknown): DevicePackageIndexV1 { if (!isRecord(value) || value.schemaVersion !== INDEX_SCHEMA_VERSION || !Number.isSafeInteger(value.generation) || (value.generation as number) < 0 || !Array.isArray(value.packages)) { throw new DevicePackageError('local_package_install_failed', 'Device package index is invalid'); } const packages = value.packages.map(parseRecord); const ids = new Set(); for (const record of packages) { if (ids.has(record.packageId)) { throw new DevicePackageError('local_package_install_failed', 'Device package index contains duplicate ids'); } ids.add(record.packageId); } return { schemaVersion: 1, generation: value.generation as number, packages }; } function cloneIndex(index: DevicePackageIndexV1): DevicePackageIndexV1 { return structuredClone(index); } async function pathMetadata(target: string): Promise<'file' | 'directory' | null> { return await stat(target).then((value) => value.isDirectory() ? 'directory' : value.isFile() ? 'file' : null) .catch((error: NodeJS.ErrnoException) => { if (error.code === 'ENOENT') return null; throw error; }); } async function gitHead(directory: string): Promise { return await new Promise((resolve, reject) => { execFile('git', ['rev-parse', 'HEAD'], { cwd: directory, windowsHide: true, timeout: 10_000, maxBuffer: 4096, }, (error, stdout) => error ? reject(error) : resolve(stdout.trim())); }); } async function loadBundledPiPackageManager(cliPath: string): Promise { const entryPath = path.join(path.dirname(path.resolve(cliPath)), 'index.js'); try { const loaded = createRequire(entryPath)(entryPath) as Partial; if (typeof loaded.DefaultPackageManager !== 'function' || typeof loaded.SettingsManager?.inMemory !== 'function') { throw new Error('Pi package manager exports are unavailable'); } return loaded as PiPackageManagerModule; } catch { throw new DevicePackageError( 'local_package_dependency_failed', 'Bundled Pi package manager is unavailable', ); } } export class DevicePackageManager { private readonly rootDir: string; private readonly packagesDir: string; private readonly stagingDir: string; private readonly trashDir: string; private readonly indexPath: string; private readonly now: () => number; private readonly createId: () => string; private readonly runInstall: DevicePackageInstallRunner; private readonly writeIndex: (filePath: string, value: DevicePackageIndexV1) => Promise; private readonly activePackages = new Map(); private readonly pendingCleanup = new Set(); private operation: Promise = Promise.resolve(); constructor(private readonly options: DevicePackageManagerOptions) { this.rootDir = path.resolve(options.rootDir); this.packagesDir = path.join(this.rootDir, 'packages'); this.stagingDir = path.join(this.rootDir, 'staging'); this.trashDir = path.join(this.rootDir, 'trash'); this.indexPath = path.join(this.rootDir, 'index.json'); this.now = options.now ?? Date.now; this.createId = options.createId ?? randomUUID; this.runInstall = options.runInstall ?? runPiPackageInstall; this.writeIndex = options.writeIndex ?? atomicWriteJson; } async prepare(sourceValue: string, requestedByTurnId: string): Promise { if (!requestedByTurnId.trim()) { throw new DevicePackageError('local_package_confirmation_required', 'Request turn id is required'); } let source; try { source = parseDevicePackageSource(sourceValue); } catch (error) { throw new DevicePackageError('local_package_source_unsupported', (error as Error).message); } const planId = this.createId(); if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u.test(planId)) { throw new DevicePackageError('local_package_install_failed', 'Install plan id is invalid'); } const planRoot = path.join(this.stagingDir, planId); const packageRoot = path.join(planRoot, 'package'); await mkdir(planRoot, { recursive: true }); try { let resolvedSource = sourceValue.trim(); if (source.kind === 'file') { const metadata = await pathMetadata(source.absolutePath); if (!metadata) throw new DevicePackageError('local_package_not_found', 'Local package source does not exist'); if (metadata === 'directory') await cp(source.absolutePath, packageRoot, { recursive: true, force: false }); else { await mkdir(packageRoot, { recursive: true }); await cp(source.absolutePath, path.join(packageRoot, path.basename(source.absolutePath)), { force: false }); } resolvedSource = source.absolutePath; } else { const executablePath = this.options.executablePath; const cliPath = this.options.cliPath; if (!executablePath || !cliPath) { throw new DevicePackageError('local_package_dependency_failed', 'Bundled Pi installer is unavailable'); } const agentDir = path.join(planRoot, 'pi-agent'); const cwd = path.join(planRoot, 'project'); try { await this.runInstall({ source: source.spec, executablePath, cliPath, ...(this.options.npmCliPath ? { npmCliPath: this.options.npmCliPath } : {}), agentDir, cwd, env: installEnvironment(agentDir), }); } catch { throw new DevicePackageError( 'local_package_dependency_failed', 'Pi could not install this package source', ); } const { DefaultPackageManager, SettingsManager } = await loadBundledPiPackageManager(cliPath); const manager = new DefaultPackageManager({ cwd, agentDir, settingsManager: SettingsManager.inMemory({}, { projectTrusted: false }), }); const installedPath = manager.getInstalledPath(source.spec, 'user'); if (!installedPath) throw new DevicePackageError('local_package_dependency_failed', 'Pi did not install the package'); if (source.kind === 'npm') { const installRoot = path.join(agentDir, 'npm'); await cp(installRoot, packageRoot, { recursive: true, force: false }); const relativePackage = relativeContainedPath(installRoot, installedPath); const nested = path.join(packageRoot, ...relativePackage.split('/')); const inspected = await inspectDevicePackage(nested); const prefix = relativeContainedPath(packageRoot, nested); const remapped = { ...inspected, skillEntries: inspected.skillEntries.map((entry) => ({ ...entry, entryPath: `${prefix}/${entry.entryPath}`, })), extensionEntries: inspected.extensionEntries.map((entry) => `${prefix}/${entry}`), }; return await this.finishPrepare({ planId, planRoot, packageRoot, sourceValue, source, requestedByTurnId, resolvedSource: `npm:${inspected.displayName}@${inspected.resolvedVersion ?? 'unknown'}`, inspected: remapped, }); } await cp(installedPath, packageRoot, { recursive: true, force: false }); const commit = await gitHead(installedPath).catch(() => { throw new DevicePackageError('local_package_dependency_failed', 'Git package commit cannot be resolved'); }); resolvedSource = `${source.spec.replace(/@[^/@]+$/u, '')}@${commit}`; } return await this.finishPrepare({ planId, planRoot, packageRoot, sourceValue, source, requestedByTurnId, resolvedSource, inspected: await inspectDevicePackage( packageRoot, source.kind === 'file' ? path.basename(source.absolutePath, path.extname(source.absolutePath)) : path.basename(packageRoot), ), }); } catch (error) { await rm(planRoot, { recursive: true, force: true }).catch(() => undefined); if (error instanceof DevicePackageError) throw error; throw new DevicePackageError('local_package_manifest_invalid', (error as Error).message); } } private async finishPrepare(input: { planId: string; planRoot: string; packageRoot: string; sourceValue: string; source: ReturnType; requestedByTurnId: string; resolvedSource: string; inspected: Awaited>; }): Promise { const kind = devicePackageKind(input.inspected); const includesExecutableCode = input.inspected.extensionEntries.length > 0 || input.inspected.hasSkillScripts; const warning = executableWarning(kind, includesExecutableCode); const resolvedVersion = input.inspected.resolvedVersion ?? `local-${new Date(this.now()).toISOString().replace(/[-:.]/gu, '')}`; safeStorageSegment(resolvedVersion); const preview: InstallPreviewV1 = { schemaVersion: 1, planId: input.planId, expiresAt: new Date(this.now() + PLAN_TTL_MS).toISOString(), requestedSource: input.sourceValue.trim(), resolvedSource: input.resolvedSource, packageId: input.inspected.packageId, displayName: input.inspected.displayName, resolvedVersion, kind, skillEntries: input.inspected.skillEntries, extensionEntries: input.inspected.extensionEntries, includesExecutableCode, ignoredLifecycleScripts: input.inspected.ignoredLifecycleScripts, warnings: warning ? [warning] : [], scope: 'device-parent-workers', }; const record: DevicePackageRecordV1 = { schemaVersion: 1, packageId: preview.packageId, displayName: preview.displayName, resolvedVersion, source: { kind: input.source.kind, requested: preview.requestedSource, resolved: preview.resolvedSource, }, kind: preview.kind, skillEntries: preview.skillEntries, extensionEntries: preview.extensionEntries, enabled: true, confirmedExecutableCode: includesExecutableCode, installedAt: new Date(this.now()).toISOString(), }; const plan: StagedPlanV1 = { schemaVersion: 1, requestedByTurnId: input.requestedByTurnId, preview, record, }; await atomicWriteJson(path.join(input.planRoot, 'plan.json'), plan); return structuredClone(preview); } async commit(planId: string, confirmed: true, confirmationTurnId: string): Promise { if (confirmed !== true || !confirmationTurnId.trim()) { throw new DevicePackageError('local_package_confirmation_required', 'Literal confirmation is required'); } return await this.withOperation(async () => { const plan = await this.readPlan(planId); if (plan.requestedByTurnId === confirmationTurnId) { throw new DevicePackageError('local_package_confirmation_required', 'Confirm installation in a new user turn'); } if (Date.parse(plan.preview.expiresAt) <= this.now()) { throw new DevicePackageError('local_package_plan_expired', 'Install preview has expired'); } const warning = executableWarning(plan.preview.kind, plan.preview.includesExecutableCode); if (warning && !plan.preview.warnings.includes(warning)) { throw new DevicePackageError('local_package_confirmation_required', 'Executable permission warning is missing'); } const stagedPackage = path.join(this.stagingDir, planId, 'package'); await this.assertRecordEntries(stagedPackage, plan.record); const index = await this.readIndex(); const existing = index.packages.find(({ packageId }) => packageId === plan.record.packageId); if (existing && existing.source.requested !== plan.record.source.requested) { throw new DevicePackageError('local_package_install_failed', 'Package id belongs to another source'); } const destination = this.packageDirectory(plan.record); const activeKey = this.packageKey(plan.record); if (await pathMetadata(destination) && (this.activePackages.get(activeKey) ?? 0) > 0) { throw new DevicePackageError('local_package_in_use', 'This package version is active in a worker'); } await mkdir(path.dirname(destination), { recursive: true }); await mkdir(this.trashDir, { recursive: true }); const displaced = await pathMetadata(destination) ? path.join(this.trashDir, `${plan.record.packageId}-${this.createId()}`) : null; if (displaced) await rename(destination, displaced); let moved = false; try { await rename(stagedPackage, destination); moved = true; const next: DevicePackageIndexV1 = { schemaVersion: 1, generation: index.generation + 1, packages: [...index.packages.filter(({ packageId }) => packageId !== plan.record.packageId), plan.record] .sort((left, right) => left.packageId.localeCompare(right.packageId)), }; await this.writeIndex(this.indexPath, next); await rm(path.join(this.stagingDir, planId), { recursive: true, force: true }); if (existing && existing.resolvedVersion !== plan.record.resolvedVersion) { await this.cleanupPackage(existing); } if (displaced) await rm(displaced, { recursive: true, force: true }).catch(() => undefined); await this.options.onGenerationChanged?.(cloneIndex(next)); return cloneIndex(next); } catch (error) { if (moved) await rm(destination, { recursive: true, force: true }).catch(() => undefined); if (displaced) await rename(displaced, destination).catch(() => undefined); if (error instanceof DevicePackageError) throw error; throw new DevicePackageError('local_package_install_failed', 'Device package commit failed'); } }); } async list(): Promise { return cloneIndex(await this.readIndex()); } async setEnabled(packageId: string, enabled: boolean): Promise { return await this.withOperation(async () => { const index = await this.readIndex(); const existing = index.packages.find((record) => record.packageId === packageId); if (!existing) throw new DevicePackageError('local_package_not_installed', 'Device package is not installed'); if (existing.enabled === enabled) return cloneIndex(index); const next: DevicePackageIndexV1 = { schemaVersion: 1, generation: index.generation + 1, packages: index.packages.map((record) => record.packageId === packageId ? { ...record, enabled } : record), }; await this.writeIndex(this.indexPath, next); await this.options.onGenerationChanged?.(cloneIndex(next)); return cloneIndex(next); }); } async uninstall(packageId: string): Promise { return await this.withOperation(async () => { const index = await this.readIndex(); const existing = index.packages.find((record) => record.packageId === packageId); if (!existing) return cloneIndex(index); const next: DevicePackageIndexV1 = { schemaVersion: 1, generation: index.generation + 1, packages: index.packages.filter((record) => record.packageId !== packageId), }; await this.writeIndex(this.indexPath, next); await this.cleanupPackage(existing); await this.options.onGenerationChanged?.(cloneIndex(next)); return cloneIndex(next); }); } async resolveEnabledResources(): Promise { const index = await this.readIndex(); const enabled = index.packages.filter(({ enabled }) => enabled); const skillEntries: EnabledDevicePackageResources['skillEntries'] = []; const extensionPaths: string[] = []; for (const record of enabled) { const packageRoot = this.packageDirectory(record); await this.assertRecordEntries(packageRoot, record); for (const entry of record.skillEntries) skillEntries.push({ ...entry, packageRoot }); for (const entry of record.extensionEntries) { extensionPaths.push(path.join(packageRoot, ...entry.split('/'))); } } return { generation: index.generation, packageIds: enabled.map(({ packageId }) => packageId), packageRefs: enabled.map(({ packageId, resolvedVersion }) => ({ packageId, resolvedVersion })), skillEntries, extensionPaths, }; } registerActiveWorker(refs: readonly { packageId: string; resolvedVersion: string }[]): () => Promise { const keys = [...new Set(refs.map((record) => this.packageKey(record)))]; for (const key of keys) this.activePackages.set(key, (this.activePackages.get(key) ?? 0) + 1); let released = false; return async () => { if (released) return; released = true; for (const key of keys) { const count = this.activePackages.get(key) ?? 0; if (count <= 1) this.activePackages.delete(key); else this.activePackages.set(key, count - 1); } for (const target of [...this.pendingCleanup]) { const [key, packagePath] = target.split('\u0000', 2); if (!key || !packagePath || (this.activePackages.get(key) ?? 0) > 0) continue; this.pendingCleanup.delete(target); await rm(packagePath, { recursive: true, force: true }).catch(() => undefined); } }; } private async readPlan(planId: string): Promise { if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u.test(planId)) { throw new DevicePackageError('local_package_plan_expired', 'Install preview is unavailable'); } try { const value = JSON.parse(await readFile(path.join(this.stagingDir, planId, 'plan.json'), 'utf8')) as unknown; if (!isRecord(value) || value.schemaVersion !== 1 || !isRecord(value.preview)) throw new Error(); const record = parseRecord(value.record); const preview = value.preview as unknown as InstallPreviewV1; if (preview.schemaVersion !== 1 || preview.planId !== planId || !Array.isArray(preview.warnings) || !Array.isArray(preview.skillEntries) || !Array.isArray(preview.extensionEntries)) throw new Error(); return { schemaVersion: 1, requestedByTurnId: string(value.requestedByTurnId, 'Request turn id', 256), preview, record, }; } catch (error) { if (error instanceof DevicePackageError) throw error; throw new DevicePackageError('local_package_plan_expired', 'Install preview is unavailable'); } } private async readIndex(): Promise { try { return parseIndex(JSON.parse(await readFile(this.indexPath, 'utf8')) as unknown); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return { schemaVersion: 1, generation: 0, packages: [] }; } if (error instanceof DevicePackageError) throw error; throw new DevicePackageError('local_package_install_failed', 'Device package index is invalid'); } } private packageDirectory(record: Pick): string { return path.join(this.packagesDir, record.packageId, safeStorageSegment(record.resolvedVersion)); } private packageKey(record: Pick): string { return `${record.packageId}@${record.resolvedVersion}`; } private async assertRecordEntries(packageRoot: string, record: DevicePackageRecordV1): Promise { if (await pathMetadata(packageRoot) !== 'directory') { throw new DevicePackageError('local_package_install_failed', 'Device package bytes are unavailable'); } for (const entry of [...record.skillEntries.map(({ entryPath }) => entryPath), ...record.extensionEntries]) { const candidate = path.resolve(packageRoot, ...entry.split('/')); try { relativeContainedPath(packageRoot, candidate); } catch { throw new DevicePackageError('local_package_manifest_invalid', 'Device package entry escapes its root'); } if (await pathMetadata(candidate) !== 'file') { throw new DevicePackageError('local_package_manifest_invalid', 'Device package entry is unavailable'); } } } private async cleanupPackage(record: DevicePackageRecordV1): Promise { const packagePath = this.packageDirectory(record); const key = this.packageKey(record); if ((this.activePackages.get(key) ?? 0) > 0) { this.pendingCleanup.add(`${key}\u0000${packagePath}`); return; } await rm(packagePath, { recursive: true, force: true }).catch(() => undefined); } private withOperation(operation: () => Promise): Promise { const result = this.operation.then(operation, operation); this.operation = result.then(() => undefined, () => undefined); return result; } }