Files
makelore/electron/opencode/superpowers.ts
brother7 e97a7ce9df feat: 增加共享 Agent Browser 调试能力
需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。

实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
2026-07-31 14:53:36 +08:00

292 lines
9.0 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import {
cpSync,
existsSync,
lstatSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
import { retryTransientFilesystemOperation } from './filesystem-retry';
export interface BundledSuperpowersPathInput {
isPackaged: boolean;
resourcesPath: string;
appPath: string;
}
export interface EnsureBundledSuperpowersOptions {
managedConfigDir: string;
sourceDir?: string;
}
export interface EnsureBundledCourseSkillsOptions {
managedConfigDir: string;
sourceDir?: string;
}
const WRAPPER_PLUGIN_NAME = 'superpowers-niancode.js';
const SUPERPOWERS_REPO_DIR = 'superpowers';
const SUPERPOWERS_BUNDLES_DIR = 'superpowers-bundles';
const SUPERPOWERS_ACTIVE_MANIFEST = 'superpowers-active.json';
export const BUNDLED_COURSE_SKILL_IDS = [
'agent-browser',
'deploy-publish-check',
'designer-design-spec',
'dev-build-test',
'game-assets',
'marketing-launch-story',
'nianxxgame-skill',
'partner-agent-showcase',
'pm-project-plan',
'product-demo-prototype',
'ui-ux-course-quality',
'youth-plain-language',
'youth-ai-product-course',
] as const;
const RETIRED_COURSE_SKILL_IDS = ['course-stage-review', 'student-growth-logger'] as const;
export function resolveBundledSuperpowersDir(input: BundledSuperpowersPathInput): string {
const resourcesDir = input.isPackaged
? join(input.resourcesPath, 'resources')
: join(input.appPath, 'resources');
return join(resourcesDir, 'skills', SUPERPOWERS_REPO_DIR);
}
export function resolveBundledCourseSkillsDir(input: BundledSuperpowersPathInput): string {
return input.isPackaged
? join(input.resourcesPath, 'course-skills')
: join(input.appPath, '.opencode', 'skills');
}
export function resolveBundledAgentBrowserPluginPath(input: BundledSuperpowersPathInput): string {
return join(
resolveBundledCourseSkillsDir(input),
'agent-browser',
'.opencode',
'plugins',
'niancode-agent-browser.js',
);
}
export function getManagedOpencodeConfigDir(userDataDir: string): string {
return join(userDataDir, 'opencode', 'niancode-config');
}
function readBundledSuperpowersVersion(sourceDir: string): string {
const packagePath = join(sourceDir, 'package.json');
const packageJson = JSON.parse(retryTransientFilesystemOperation(
() => readFileSync(packagePath, 'utf8'),
)) as { version?: unknown };
if (typeof packageJson.version !== 'string' || !packageJson.version.trim()) {
throw new Error(`Bundled Superpowers package has no valid version: ${packagePath}`);
}
return packageJson.version.trim();
}
function toSafeBundleName(version: string): string {
const safeVersion = version.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
if (!safeVersion) {
throw new Error(`Bundled Superpowers version cannot form a safe directory name: ${version}`);
}
return safeVersion;
}
function isCompleteBundle(bundleDir: string, expectedVersion: string): boolean {
try {
if (!existsSync(join(bundleDir, '.opencode', 'plugins', 'superpowers.js'))
|| !existsSync(join(bundleDir, 'skills', 'using-superpowers', 'SKILL.md'))) {
return false;
}
return readBundledSuperpowersVersion(bundleDir) === expectedVersion;
} catch {
return false;
}
}
function resolveManifestBundle(
managedConfigDir: string,
bundlesDir: string,
expectedVersion: string,
): string | null {
try {
const manifest = JSON.parse(retryTransientFilesystemOperation(
() => readFileSync(join(managedConfigDir, SUPERPOWERS_ACTIVE_MANIFEST), 'utf8'),
)) as { version?: unknown; directory?: unknown };
if (manifest.version !== expectedVersion || typeof manifest.directory !== 'string') {
return null;
}
const bundlesRoot = resolve(bundlesDir);
const bundleDir = resolve(bundlesDir, manifest.directory);
const relativeBundlePath = relative(bundlesRoot, bundleDir);
if (!relativeBundlePath
|| relativeBundlePath === '..'
|| relativeBundlePath.startsWith(`..${sep}`)
|| isAbsolute(relativeBundlePath)) {
return null;
}
return isCompleteBundle(bundleDir, expectedVersion) ? bundleDir : null;
} catch {
return null;
}
}
function copyDirectorySync(sourceDir: string, targetDir: string): void {
cpSync(sourceDir, targetDir, {
recursive: true,
force: true,
filter: (sourcePath, targetPath) => {
// Node 24.14's Windows override path can corrupt non-ASCII destinations
// when replacing a file. Public unlink keeps force-overwrite semantics.
if (lstatSync(sourcePath).isFile() && existsSync(targetPath)) {
unlinkSync(targetPath);
}
return true;
},
});
}
function installImmutableBundle(
sourceDir: string,
bundlesDir: string,
safeBundleName: string,
expectedVersion: string,
): string {
retryTransientFilesystemOperation(() => mkdirSync(bundlesDir, { recursive: true }));
const stagingDir = join(
bundlesDir,
`.staging-${safeBundleName}-${process.pid}-${randomUUID()}`,
);
try {
retryTransientFilesystemOperation(() => copyDirectorySync(sourceDir, stagingDir));
if (!isCompleteBundle(stagingDir, expectedVersion)) {
throw new Error(`Bundled Superpowers staging copy is incomplete: ${stagingDir}`);
}
const preferredDir = join(bundlesDir, safeBundleName);
if (isCompleteBundle(preferredDir, expectedVersion)) {
return preferredDir;
}
const selectedDir = existsSync(preferredDir)
? join(bundlesDir, `${safeBundleName}-${randomUUID()}`)
: preferredDir;
try {
retryTransientFilesystemOperation(() => renameSync(stagingDir, selectedDir));
} catch (error) {
if (!isCompleteBundle(selectedDir, expectedVersion)) {
throw error;
}
}
return selectedDir;
} finally {
if (existsSync(stagingDir)) {
try {
retryTransientFilesystemOperation(() => rmSync(stagingDir, {
recursive: true,
force: true,
}));
} catch {
// Startup cleanup is best-effort; preserve the installation result or error.
}
}
}
}
function writeSelectedBundleFiles(
managedConfigDir: string,
bundlesDir: string,
bundleDir: string,
version: string,
): void {
const targetPluginsDir = join(managedConfigDir, 'plugins');
retryTransientFilesystemOperation(() => mkdirSync(targetPluginsDir, { recursive: true }));
const pluginPath = join(bundleDir, '.opencode', 'plugins', 'superpowers.js');
let pluginImport = relative(targetPluginsDir, pluginPath).split(sep).join('/');
if (!pluginImport.startsWith('.')) pluginImport = `./${pluginImport}`;
retryTransientFilesystemOperation(() => writeFileSync(
join(targetPluginsDir, WRAPPER_PLUGIN_NAME),
`export { SuperpowersPlugin } from '${pluginImport}';\n`,
'utf8',
));
retryTransientFilesystemOperation(() => writeFileSync(
join(managedConfigDir, SUPERPOWERS_ACTIVE_MANIFEST),
`${JSON.stringify({
version,
directory: relative(bundlesDir, bundleDir),
}, null, 2)}\n`,
'utf8',
));
}
export function ensureBundledSuperpowersPlugin(options: EnsureBundledSuperpowersOptions): boolean {
const sourceDir = options.sourceDir?.trim();
if (!sourceDir) {
return false;
}
const sourcePluginPath = join(sourceDir, '.opencode', 'plugins', 'superpowers.js');
const sourceSkillsDir = join(sourceDir, 'skills');
if (!existsSync(sourcePluginPath) || !existsSync(sourceSkillsDir)) {
return false;
}
const version = readBundledSuperpowersVersion(sourceDir);
const safeBundleName = toSafeBundleName(version);
const bundlesDir = join(options.managedConfigDir, SUPERPOWERS_BUNDLES_DIR);
const preferredDir = join(bundlesDir, safeBundleName);
const bundleDir = resolveManifestBundle(
options.managedConfigDir,
bundlesDir,
version,
) ?? (isCompleteBundle(preferredDir, version)
? preferredDir
: installImmutableBundle(sourceDir, bundlesDir, safeBundleName, version));
writeSelectedBundleFiles(
options.managedConfigDir,
bundlesDir,
bundleDir,
version,
);
return true;
}
export function ensureBundledCourseSkills(options: EnsureBundledCourseSkillsOptions): boolean {
const sourceDir = options.sourceDir?.trim();
if (!sourceDir || !existsSync(sourceDir)) {
return false;
}
const targetSkillsDir = join(options.managedConfigDir, 'skills');
mkdirSync(targetSkillsDir, { recursive: true });
for (const skillId of RETIRED_COURSE_SKILL_IDS) {
rmSync(join(targetSkillsDir, skillId), { recursive: true, force: true });
}
copyDirectorySync(sourceDir, targetSkillsDir);
return true;
}
export function ensureBundledAgentBrowserPlugin(options: {
managedConfigDir: string;
sourcePath?: string;
}): boolean {
const sourcePath = options.sourcePath?.trim();
if (!sourcePath || !existsSync(sourcePath)) return false;
const targetPluginsDir = join(options.managedConfigDir, 'plugins');
mkdirSync(targetPluginsDir, { recursive: true });
cpSync(sourcePath, join(targetPluginsDir, 'niancode-agent-browser.js'), { force: true });
return true;
}