From a9ae00a8453612c2637568d702364c8552bca9cd Mon Sep 17 00:00:00 2001 From: lofyer Date: Mon, 10 Aug 2026 11:31:02 +0800 Subject: [PATCH] fix: register native OpenCode skills --- src/main/agent/continue-host-adapter.ts | 20 +- src/main/agent/create-runtime.ts | 1 + src/main/agent/opencode-runtime.test.ts | 240 +++++++- src/main/agent/opencode-runtime.ts | 518 ++++++++++++------ src/main/agent/runtime-skill-packages.ts | 26 + .../capabilities/capability-service.test.ts | 21 + src/main/capabilities/capability-service.ts | 16 +- 7 files changed, 646 insertions(+), 196 deletions(-) create mode 100644 src/main/agent/runtime-skill-packages.ts diff --git a/src/main/agent/continue-host-adapter.ts b/src/main/agent/continue-host-adapter.ts index 4a5f137..58b106d 100644 --- a/src/main/agent/continue-host-adapter.ts +++ b/src/main/agent/continue-host-adapter.ts @@ -1,7 +1,6 @@ import spawn from 'cross-spawn' import { createHash, randomBytes } from 'node:crypto' import { - cp, copyFile, mkdir, readFile, @@ -38,6 +37,7 @@ import { boundedToolDetail, safeToolErrorDetail } from './approval-summary' +import { stageRuntimeSkillPackages } from './runtime-skill-packages' const supportedVersion = '1.5.47' const supportedBundleHashes = new Set([ @@ -997,22 +997,8 @@ export class ContinueHostAdapter { if (skillPackages.length === 0) { return root } - const skillsRoot = join(root, 'skills') - await mkdir(skillsRoot, { mode: 0o700 }) - try { - for (const skill of skillPackages) { - await cp(skill.directory, join(skillsRoot, skill.id), { - recursive: true, - errorOnExist: true, - force: false, - verbatimSymlinks: true - }) - } - return root - } catch (error) { - await rm(root, { recursive: true, force: true }) - throw new Error('Continue Skill 注册失败', { cause: error }) - } + await stageRuntimeSkillPackages(root, skillPackages, 'Continue') + return root } async run( diff --git a/src/main/agent/create-runtime.ts b/src/main/agent/create-runtime.ts index a3ddd5d..e33204e 100644 --- a/src/main/agent/create-runtime.ts +++ b/src/main/agent/create-runtime.ts @@ -160,6 +160,7 @@ export function createAgentRuntime( '', modelProfile: settings?.opencodeModelProfile, skillInstructions: capabilities.skillInstructions, + skillPackages: capabilities.skillPackages, sandbox: resolveRuntimeSandbox(sandboxMode), defaultWorkspace: workspace, knowledgeGateway: capabilities.knowledgeGateway diff --git a/src/main/agent/opencode-runtime.test.ts b/src/main/agent/opencode-runtime.test.ts index a62496a..c5f6899 100644 --- a/src/main/agent/opencode-runtime.test.ts +++ b/src/main/agent/opencode-runtime.test.ts @@ -1,6 +1,13 @@ import { EventEmitter } from 'node:events' import { createServer } from 'node:http' -import { mkdtemp, rm } from 'node:fs/promises' +import { + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile +} from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { PassThrough } from 'node:stream' @@ -246,7 +253,10 @@ function runClient(events: Record[]) { } function embeddedRuntime( - client: ReturnType + client: ReturnType, + overrides: Partial< + ConstructorParameters[0] + > = {} ): OpenCodeRuntime { const child = fakeChild() const { deps } = dependencies(child, { @@ -259,7 +269,7 @@ function embeddedRuntime( 'opencode server listening on http://127.0.0.1:4010\n' ) }, 0) - return new OpenCodeRuntime(options(), deps) + return new OpenCodeRuntime(options(overrides), deps) } async function collectRun( @@ -405,6 +415,109 @@ describe('OpenCodeRuntime embedded launcher', () => { expect(killerChild.unref).toHaveBeenCalledOnce() }) + it('registers only assigned Skill packages in an isolated config directory', async () => { + const sourceRoot = await mkdtemp( + join(tmpdir(), 'goodbuddy-opencode-skill-source-') + ) + const skillDirectory = join(sourceRoot, 'longdoc-docx') + await mkdir(join(skillDirectory, 'templates'), { + recursive: true + }) + await writeFile( + join(skillDirectory, 'SKILL.md'), + [ + '---', + 'id: longdoc-docx', + 'name: 长文档', + 'description: Build a DOCX', + '---', + '', + '# Long document' + ].join('\n'), + 'utf8' + ) + await writeFile( + join(skillDirectory, 'templates', 'document.txt'), + 'template', + 'utf8' + ) + const child = fakeChild() + const { deps, spawnMock } = dependencies(child) + setTimeout(() => { + stdoutOf(child).write( + 'opencode server listening on http://127.0.0.1:3012\n' + ) + }, 0) + const runtime = new OpenCodeRuntime( + options({ + skillPackages: [ + { + id: 'longdoc-docx', + directory: skillDirectory + } + ] + }), + deps + ) + + await expect(runtime.getStatus()).resolves.toMatchObject({ + available: true + }) + const spawnOptions = spawnMock.mock.calls[0]?.[2] as + | { env?: NodeJS.ProcessEnv } + | undefined + const configDirectory = spawnOptions?.env?.OPENCODE_CONFIG_DIR + expect(configDirectory).toBeTruthy() + const registrationRoot = resolve(configDirectory!, '..') + const registeredSkill = join( + configDirectory!, + 'skills', + 'longdoc-docx' + ) + try { + await expect( + readFile( + join(registeredSkill, 'templates', 'document.txt'), + 'utf8' + ) + ).resolves.toBe('template') + const registeredManifest = await readFile( + join(registeredSkill, 'SKILL.md'), + 'utf8' + ) + expect(registeredManifest).toContain('name: longdoc-docx') + expect(registeredManifest).not.toContain('id: longdoc-docx') + const config = JSON.parse( + spawnOptions?.env?.OPENCODE_CONFIG_CONTENT ?? '{}' + ) as Record + expect(config).toEqual({ + skills: { + paths: [join(configDirectory!, 'skills')], + urls: [] + }, + permission: { + skill: { + '*': 'deny', + 'longdoc-docx': 'allow' + } + } + }) + expect(spawnOptions?.env).toMatchObject({ + OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: '1', + OPENCODE_DISABLE_EXTERNAL_SKILLS: '1', + OPENCODE_DISABLE_PROJECT_CONFIG: '1', + XDG_CACHE_HOME: join(registrationRoot, 'xdg-cache'), + XDG_CONFIG_HOME: join(registrationRoot, 'xdg-config'), + XDG_DATA_HOME: join(registrationRoot, 'xdg-data'), + XDG_STATE_HOME: join(registrationRoot, 'xdg-state') + }) + } finally { + await runtime.dispose() + await rm(sourceRoot, { recursive: true, force: true }) + } + await expect(stat(registrationRoot)).rejects.toThrow() + }) + it('injects an independent model profile without persisting its key', async () => { const child = fakeChild() const { deps, spawnMock } = dependencies(child) @@ -764,6 +877,7 @@ describe('OpenCodeRuntime embedded launcher', () => { const isolatedNames = [ 'OPENCODE_CONFIG', 'OPENCODE_CONFIG_CONTENT', + 'OPENCODE_CONFIG_DIR', 'OPENCODE_SERVER_PASSWORD', 'OPENCODE_SERVER_USERNAME' ] as const @@ -791,7 +905,12 @@ describe('OpenCodeRuntime embedded launcher', () => { | { env?: NodeJS.ProcessEnv } | undefined expect(spawnOptions?.env?.OPENCODE_CONFIG).toBeUndefined() - expect(spawnOptions?.env?.OPENCODE_CONFIG_CONTENT).toBeUndefined() + expect( + spawnOptions?.env?.OPENCODE_CONFIG_CONTENT + ).not.toBe('must-not-be-inherited') + expect(spawnOptions?.env?.OPENCODE_CONFIG_DIR).not.toBe( + 'must-not-be-inherited' + ) expect(spawnOptions?.env?.OPENCODE_SERVER_USERNAME).toBe( 'goodbuddy' ) @@ -801,9 +920,12 @@ describe('OpenCodeRuntime embedded launcher', () => { expect(spawnOptions?.env).toMatchObject({ DO_NOT_TRACK: '1', OPENCODE_DISABLE_AUTOUPDATE: '1', + OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: '1', OPENCODE_DISABLE_EMBEDDED_WEB_UI: '1', + OPENCODE_DISABLE_EXTERNAL_SKILLS: '1', OPENCODE_DISABLE_LSP_DOWNLOAD: '1', OPENCODE_DISABLE_MODELS_FETCH: '1', + OPENCODE_DISABLE_PROJECT_CONFIG: '1', OPENCODE_DISABLE_SHARE: '1', OTEL_EXPORTER_OTLP_ENDPOINT: '', OTEL_EXPORTER_OTLP_HEADERS: '', @@ -838,11 +960,15 @@ describe('OpenCodeRuntime embedded launcher', () => { 'http://127.0.0.1:4321/admin' ])('rejects an unsafe listening URL: %s', async (url) => { const child = fakeChild() - const { deps, createClient } = dependencies(child) - setTimeout(() => { - stdoutOf(child).write(`opencode server listening on ${url}\n`) - closeChild(child, 7) - }, 0) + const { deps, createClient } = dependencies(child, { + spawn: vi.fn(() => { + queueMicrotask(() => { + stdoutOf(child).write(`opencode server listening on ${url}\n`) + closeChild(child, 7) + }) + return child + }) as unknown as typeof spawn + }) const runtime = new OpenCodeRuntime(options(), deps) await expect(runtime.getStatus()).resolves.toMatchObject({ @@ -872,17 +998,34 @@ describe('OpenCodeRuntime embedded launcher', () => { it('reports early exit without leaking captured stderr', async () => { const child = fakeChild() const secret = 'OPENCODE_CONFIG=/secret/config.json' - const { deps } = dependencies(child) - setTimeout(() => { - stderrOf(child).write(secret) - closeChild(child, 9) - }, 0) + let registrationRoot = '' + const { deps } = dependencies(child, { + spawn: vi.fn( + ( + _command: string, + _args: string[], + spawnOptions: { env?: NodeJS.ProcessEnv } + ) => { + registrationRoot = resolve( + spawnOptions.env?.OPENCODE_CONFIG_DIR ?? '', + '..' + ) + queueMicrotask(() => { + stderrOf(child).write(secret) + closeChild(child, 9) + }) + return child + } + ) as unknown as typeof spawn + }) const runtime = new OpenCodeRuntime(options(), deps) const status = await runtime.getStatus() expect(status.detail).toBe('OpenCode Server 启动前退出(code 9)') expect(status.detail).not.toContain(secret) + expect(registrationRoot).toBeTruthy() + await expect(stat(registrationRoot)).rejects.toThrow() }) it('terminates startup when the request is aborted', async () => { @@ -1425,6 +1568,75 @@ describe('OpenCodeRuntime embedded permission mediation', () => { await runtime.dispose() }) + it('allows only registered native Skills in read-only modes', async () => { + const sourceRoot = await mkdtemp( + join(tmpdir(), 'goodbuddy-opencode-permission-skill-') + ) + const skillDirectory = join(sourceRoot, 'longdoc-docx') + await mkdir(skillDirectory) + await writeFile( + join(skillDirectory, 'SKILL.md'), + [ + '---', + 'name: longdoc-docx', + 'description: Build a DOCX', + '---', + '', + '# Long document' + ].join('\n'), + 'utf8' + ) + const setup = runClient([ + { + id: 'idle', + type: 'session.idle', + properties: { sessionID: 'session-1' } + } + ]) + const runtime = embeddedRuntime(setup.client, { + skillInstructions: '# Original path: C:\\private\\skills', + skillPackages: [ + { + id: 'longdoc-docx', + directory: skillDirectory + } + ] + }) + try { + await collectRun(runtime, 'ask') + + expect(setup.session.create).toHaveBeenCalledWith({ + title: 'GoodBuddy 对话', + directory: process.cwd(), + permission: [ + { permission: '*', pattern: '*', action: 'deny' }, + { permission: 'skill', pattern: '*', action: 'deny' }, + { + permission: 'skill', + pattern: 'longdoc-docx', + action: 'allow' + } + ] + }) + expect(setup.session.promptAsync).toHaveBeenCalledWith( + expect.objectContaining({ + system: undefined, + tools: { + read: false, + write: false, + bash: false, + task: false, + skill: true + } + }), + expect.anything() + ) + } finally { + await runtime.dispose() + await rm(sourceRoot, { recursive: true, force: true }) + } + }) + it('subscribes before prompting and auto-allows a tool request', async () => { const { client, diff --git a/src/main/agent/opencode-runtime.ts b/src/main/agent/opencode-runtime.ts index 0b2b8b6..6bf7207 100644 --- a/src/main/agent/opencode-runtime.ts +++ b/src/main/agent/opencode-runtime.ts @@ -8,7 +8,15 @@ import { } from '@opencode-ai/sdk/v2' import spawn from 'cross-spawn' import { createHash, randomBytes } from 'node:crypto' -import { resolve } from 'node:path' +import { + mkdtemp, + readFile, + rm, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml' import type { AgentQuestionAnswer, AgentRuntimeStatus @@ -38,6 +46,8 @@ import { boundedToolDetail, safeToolErrorDetail } from './approval-summary' +import type { RuntimeSkillPackage } from '../capabilities/capability-service' +import { stageRuntimeSkillPackages } from './runtime-skill-packages' const MAX_STARTUP_OUTPUT_BYTES = 64 * 1024 const STARTUP_TIMEOUT_MS = 10_000 @@ -51,6 +61,7 @@ const MAX_QUESTION_REQUEST_BYTES = 32 * 1_024 const MAX_QUESTIONS_PER_REQUEST = 4 const MAX_QUESTION_OPTIONS = 20 const EMBEDDED_SERVER_USERNAME = 'goodbuddy' +const OPENCODE_SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u type SpawnedProcess = ReturnType @@ -90,6 +101,12 @@ type OpenCodeServer = { close: () => Promise } +type OpenCodeSkillRegistration = { + root: string + configDirectory: string + skillsRoot: string +} + const executePermissionRules: PermissionRuleset = [ { permission: '*', pattern: '*', action: 'ask' }, { permission: 'task', pattern: '*', action: 'deny' } @@ -359,10 +376,91 @@ export type OpenCodeRuntimeOptions = { defaultWorkspace: string modelProfile?: ResolvedModelProfile skillInstructions?: string + skillPackages?: RuntimeSkillPackage[] sandbox?: RuntimeSandboxResolution knowledgeGateway?: KnowledgeMcpGateway } +function createSkillPermissionRules( + skillIds: readonly string[] +): PermissionRuleset { + if (skillIds.length === 0) { + return [] + } + return Object.entries(createSkillPermissionConfig(skillIds)).map( + ([pattern, action]) => ({ + permission: 'skill', + pattern, + action + }) + ) +} + +function createSkillPermissionConfig( + skillIds: readonly string[] +): Record { + return Object.fromEntries([ + ['*', 'deny' as const], + ...skillIds.map((skillId) => [skillId, 'allow' as const]) + ]) +} + +function createOpenCodeSkillConfig( + registration: OpenCodeSkillRegistration, + skillIds: readonly string[] +): { + skills: { paths: string[]; urls: never[] } + permission: { + skill: Record + } +} { + return { + skills: { + paths: [registration.skillsRoot], + urls: [] + }, + permission: { + skill: createSkillPermissionConfig(skillIds) + } + } +} + +async function normalizeOpenCodeSkillManifest( + skillDirectory: string, + skillId: string +): Promise { + const manifestPath = join(skillDirectory, 'SKILL.md') + const content = await readFile(manifestPath, 'utf8') + const match = + /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]+)$/u.exec(content) + if (!match?.[1] || !match[2]?.trim()) { + throw new Error('OpenCode Skill 清单格式无效') + } + const metadata = parseYaml(match[1]) + if ( + typeof metadata !== 'object' || + metadata === null || + Array.isArray(metadata) + ) { + throw new Error('OpenCode Skill 清单元数据无效') + } + const normalizedMetadata: Record = { + ...metadata, + name: skillId + } + delete normalizedMetadata.id + await writeFile( + manifestPath, + [ + '---', + stringifyYaml(normalizedMetadata).trimEnd(), + '---', + match[2] + ].join('\n'), + 'utf8' + ) +} + async function defaultDetectBinary( runtime: 'opencode', configuredPath: string, @@ -518,6 +616,47 @@ export class OpenCodeRuntime implements AgentRuntime { }) } + private getNativeSkillIds(): string[] { + if (!this.usesEmbeddedPermissionMediation()) { + return [] + } + const ids = (this.options.skillPackages ?? []).map( + (skill) => skill.id + ) + if ( + new Set(ids).size !== ids.length || + ids.some( + (id) => + id.length > 64 || !OPENCODE_SKILL_NAME_PATTERN.test(id) + ) + ) { + throw new Error('OpenCode Skill 注册信息无效') + } + return ids + } + + private async createSkillRegistration(): Promise { + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-')) + const configDirectory = join(root, 'config') + try { + const skillsRoot = await stageRuntimeSkillPackages( + configDirectory, + this.options.skillPackages ?? [], + 'OpenCode' + ) + for (const skill of this.options.skillPackages ?? []) { + await normalizeOpenCodeSkillManifest( + join(skillsRoot, skill.id), + skill.id + ) + } + return { root, configDirectory, skillsRoot } + } catch (error) { + await rm(root, { recursive: true, force: true }) + throw error + } + } + private async launchEmbedded(signal?: AbortSignal): Promise { if (signal?.aborted) { throw new Error('OpenCode Server 启动已取消') @@ -545,48 +684,6 @@ export class OpenCodeRuntime implements AgentRuntime { ) { throw new Error('OpenCode 独立模型连接尚未配置 API Key') } - const profile = this.options.modelProfile - const env = profile - ? buildExplicitProfileRuntimeEnvironment( - runtimePrivacyEnvironment, - profile.authentication === 'api-key' && profile.apiKey - ? { - name: - profile.protocol === 'anthropic-messages' - ? 'ANTHROPIC_API_KEY' - : 'OPENAI_API_KEY', - value: profile.apiKey - } - : undefined - ) - : buildRuntimeEnvironment(runtimePrivacyEnvironment) - delete env.OPENCODE_CONFIG - delete env.OPENCODE_CONFIG_CONTENT - delete env.OPENCODE_SERVER_PASSWORD - delete env.OPENCODE_SERVER_USERNAME - const serverPassword = randomBytes(32).toString('base64url') - const authorization = `Basic ${Buffer.from( - `${EMBEDDED_SERVER_USERNAME}:${serverPassword}` - ).toString('base64')}` - env.OPENCODE_SERVER_USERNAME = EMBEDDED_SERVER_USERNAME - env.OPENCODE_SERVER_PASSWORD = serverPassword - env.OPENCODE_DISABLE_AUTOUPDATE = '1' - env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = '1' - env.OPENCODE_DISABLE_LSP_DOWNLOAD = '1' - env.OPENCODE_DISABLE_MODELS_FETCH = '1' - env.OPENCODE_DISABLE_SHARE = '1' - if (profile) { - env.OPENCODE_CONFIG_CONTENT = JSON.stringify( - createOpenCodeProviderConfig(profile) - ) - } else if (this.options.configPath.trim()) { - env.OPENCODE_CONFIG = resolve(this.options.configPath) - } - const serverArgs = [ - 'serve', - '--hostname=127.0.0.1', - `--port=${port}` - ] const sandbox = this.options.sandbox if ( sandbox?.status.mode === 'strict' && @@ -594,134 +691,215 @@ export class OpenCodeRuntime implements AgentRuntime { ) { throw new Error(sandbox.status.detail) } - const launch = - sandbox?.status.available && sandbox.binaryPath - ? buildBubblewrapLaunch({ - binaryPath: sandbox.binaryPath, - command: binaryPath, - args: serverArgs, - workspace: this.options.defaultWorkspace, - readOnlyPaths: this.options.configPath.trim() - ? [resolve(this.options.configPath)] - : [], - platform: this.dependencies.platform - }) - : { command: binaryPath, args: serverArgs } - - return new Promise((resolveServer, reject) => { - const child = this.dependencies.spawn( - launch.command, - launch.args, - { - cwd: this.options.defaultWorkspace, - env, - shell: false, - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true - } - ) - this.startingChild = child - const { stdout, stderr } = child - let stdoutText = '' - let stdoutBytes = 0 - let stderrBytes = 0 - let settled = false - - const cleanupStartupListeners = (): void => { - clearTimeout(timeout) - signal?.removeEventListener('abort', abort) - stdout?.removeListener('data', onStdout) - stderr?.removeListener('data', onStderr) - child.removeListener('error', onError) - child.removeListener('close', onClose) + const skillIds = this.getNativeSkillIds() + const registration = await this.createSkillRegistration() + try { + if (signal?.aborted) { + throw new Error('OpenCode Server 启动已取消') } - const fail = (message: string): void => { - if (settled) { - return + const profile = this.options.modelProfile + const env = profile + ? buildExplicitProfileRuntimeEnvironment( + runtimePrivacyEnvironment, + profile.authentication === 'api-key' && profile.apiKey + ? { + name: + profile.protocol === 'anthropic-messages' + ? 'ANTHROPIC_API_KEY' + : 'OPENAI_API_KEY', + value: profile.apiKey + } + : undefined + ) + : buildRuntimeEnvironment(runtimePrivacyEnvironment) + delete env.OPENCODE_CONFIG + delete env.OPENCODE_CONFIG_CONTENT + delete env.OPENCODE_CONFIG_DIR + delete env.OPENCODE_SERVER_PASSWORD + delete env.OPENCODE_SERVER_USERNAME + const serverPassword = randomBytes(32).toString('base64url') + const authorization = `Basic ${Buffer.from( + `${EMBEDDED_SERVER_USERNAME}:${serverPassword}` + ).toString('base64')}` + env.OPENCODE_SERVER_USERNAME = EMBEDDED_SERVER_USERNAME + env.OPENCODE_SERVER_PASSWORD = serverPassword + env.OPENCODE_CONFIG_DIR = registration.configDirectory + env.OPENCODE_DISABLE_AUTOUPDATE = '1' + env.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS = '1' + env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = '1' + env.OPENCODE_DISABLE_EXTERNAL_SKILLS = '1' + env.OPENCODE_DISABLE_LSP_DOWNLOAD = '1' + env.OPENCODE_DISABLE_MODELS_FETCH = '1' + env.OPENCODE_DISABLE_PROJECT_CONFIG = '1' + env.OPENCODE_DISABLE_SHARE = '1' + env.XDG_CACHE_HOME = join(registration.root, 'xdg-cache') + env.XDG_CONFIG_HOME = join(registration.root, 'xdg-config') + env.XDG_DATA_HOME = join(registration.root, 'xdg-data') + env.XDG_STATE_HOME = join(registration.root, 'xdg-state') + const skillConfig = createOpenCodeSkillConfig( + registration, + skillIds + ) + env.OPENCODE_CONFIG_CONTENT = JSON.stringify( + profile + ? { + ...createOpenCodeProviderConfig(profile), + ...skillConfig + } + : skillConfig + ) + if (!profile && this.options.configPath.trim()) { + env.OPENCODE_CONFIG = resolve(this.options.configPath) + } + const serverArgs = [ + 'serve', + '--hostname=127.0.0.1', + `--port=${port}` + ] + const launch = + sandbox?.status.available && sandbox.binaryPath + ? buildBubblewrapLaunch({ + binaryPath: sandbox.binaryPath, + command: binaryPath, + args: serverArgs, + workspace: this.options.defaultWorkspace, + readOnlyPaths: this.options.configPath.trim() + ? [resolve(this.options.configPath)] + : [], + writablePaths: [registration.root], + platform: this.dependencies.platform + }) + : { command: binaryPath, args: serverArgs } + + return await new Promise((resolveServer, reject) => { + const child = this.dependencies.spawn( + launch.command, + launch.args, + { + cwd: this.options.defaultWorkspace, + env, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + } + ) + this.startingChild = child + const { stdout, stderr } = child + let stdoutText = '' + let stdoutBytes = 0 + let stderrBytes = 0 + let settled = false + + const cleanupStartupListeners = (): void => { + clearTimeout(timeout) + signal?.removeEventListener('abort', abort) + stdout?.removeListener('data', onStdout) + stderr?.removeListener('data', onStderr) + child.removeListener('error', onError) + child.removeListener('close', onClose) } - settled = true - cleanupStartupListeners() - const clearStartingChild = (): void => { + const fail = (message: string): void => { + if (settled) { + return + } + settled = true + cleanupStartupListeners() + const clearStartingChild = (): void => { + if (this.startingChild === child) { + this.startingChild = undefined + } + } + const exited = this.waitForExit(child) + this.terminate(child) + void exited.finally(() => { + if (child.exitCode !== null) { + clearStartingChild() + } + reject(new Error(message.slice(0, 1_000))) + }) + } + const succeed = (url: string): void => { + if (settled) { + return + } + settled = true + cleanupStartupListeners() if (this.startingChild === child) { this.startingChild = undefined } + stdout?.resume() + stderr?.resume() + resolveServer({ + url, + authorization, + close: async () => { + try { + const exited = this.waitForExit(child) + this.terminate(child) + await exited + } finally { + await rm(registration.root, { + recursive: true, + force: true + }) + } + } + }) } - child.once('close', clearStartingChild) - this.terminate(child) - if (child.exitCode !== null) { - child.removeListener('close', clearStartingChild) - clearStartingChild() - } - reject(new Error(message.slice(0, 1_000))) - } - const succeed = (url: string): void => { - if (settled) { - return - } - settled = true - cleanupStartupListeners() - if (this.startingChild === child) { - this.startingChild = undefined - } - stdout?.resume() - stderr?.resume() - resolveServer({ - url, - authorization, - close: async () => { - const exited = this.waitForExit(child) - this.terminate(child) - await exited + const onStdout = (chunk: string | Buffer): void => { + const text = chunk.toString() + stdoutBytes += Buffer.isBuffer(chunk) + ? chunk.byteLength + : Buffer.byteLength(chunk) + if (stdoutBytes > MAX_STARTUP_OUTPUT_BYTES) { + fail('OpenCode Server stdout 超过 64KB 安全限制') + return } - }) - } - const onStdout = (chunk: string | Buffer): void => { - const text = chunk.toString() - stdoutBytes += Buffer.isBuffer(chunk) - ? chunk.byteLength - : Buffer.byteLength(chunk) - if (stdoutBytes > MAX_STARTUP_OUTPUT_BYTES) { - fail('OpenCode Server stdout 超过 64KB 安全限制') + stdoutText += text + const url = parseListeningUrl(stdoutText) + if (url) { + succeed(url) + } + } + const onStderr = (chunk: string | Buffer): void => { + stderrBytes += Buffer.byteLength(chunk) + if (stderrBytes > MAX_STARTUP_OUTPUT_BYTES) { + fail('OpenCode Server stderr 超过 64KB 安全限制') + } + } + const onError = (): void => { + fail('OpenCode Server 启动失败') + } + const onClose = (code: number | null): void => { + fail(`OpenCode Server 启动前退出(code ${code ?? 'unknown'})`) + } + const abort = (): void => { + fail('OpenCode Server 启动已取消') + } + const timeout = setTimeout(() => { + fail('OpenCode Server 启动超时(10 秒)') + }, this.dependencies.startupTimeoutMs) + + if (!stdout || !stderr) { + fail('OpenCode Server 管道初始化失败') return } - stdoutText += text - const url = parseListeningUrl(stdoutText) - if (url) { - succeed(url) + stdout.on('data', onStdout) + stderr.on('data', onStderr) + child.once('error', onError) + child.once('close', onClose) + signal?.addEventListener('abort', abort, { once: true }) + if (signal?.aborted) { + abort() } - } - const onStderr = (chunk: string | Buffer): void => { - stderrBytes += Buffer.byteLength(chunk) - if (stderrBytes > MAX_STARTUP_OUTPUT_BYTES) { - fail('OpenCode Server stderr 超过 64KB 安全限制') - } - } - const onError = (): void => { - fail('OpenCode Server 启动失败') - } - const onClose = (code: number | null): void => { - fail(`OpenCode Server 启动前退出(code ${code ?? 'unknown'})`) - } - const abort = (): void => { - fail('OpenCode Server 启动已取消') - } - const timeout = setTimeout(() => { - fail('OpenCode Server 启动超时(10 秒)') - }, this.dependencies.startupTimeoutMs) - - if (!stdout || !stderr) { - fail('OpenCode Server 管道初始化失败') - return - } - stdout.on('data', onStdout) - stderr.on('data', onStderr) - child.once('error', onError) - child.once('close', onClose) - signal?.addEventListener('abort', abort, { once: true }) - if (signal?.aborted) { - abort() - } - }) + }) + } catch (error) { + await rm(registration.root, { + recursive: true, + force: true + }).catch(() => undefined) + throw error + } } private async getClient(signal?: AbortSignal): Promise { @@ -861,6 +1039,9 @@ export class OpenCodeRuntime implements AgentRuntime { } const client = await this.getClient(signal) const directory = this.options.defaultWorkspace + const nativeSkillIds = this.getNativeSkillIds() + const nativeSkillPermissionRules = + createSkillPermissionRules(nativeSkillIds) let knowledgeMcpName: string | undefined let knowledgeToolIds: string[] = [] try { @@ -907,6 +1088,7 @@ export class OpenCodeRuntime implements AgentRuntime { ? request.workMode === 'execute' ? [ ...executePermissionRules, + ...nativeSkillPermissionRules, ...knowledgeToolIds.map((toolId) => ({ permission: toolId, pattern: '*', @@ -916,13 +1098,17 @@ export class OpenCodeRuntime implements AgentRuntime { : knowledgeToolIds.length > 0 ? [ ...readOnlyPermissionRules, + ...nativeSkillPermissionRules, ...knowledgeToolIds.map((toolId) => ({ permission: toolId, pattern: '*', action: 'allow' as const })) ] - : readOnlyPermissionRules + : [ + ...readOnlyPermissionRules, + ...nativeSkillPermissionRules + ] : undefined let disabledTools: Record | undefined if (request.workMode !== 'execute') { @@ -938,7 +1124,8 @@ export class OpenCodeRuntime implements AgentRuntime { ), ...Object.fromEntries( knowledgeToolIds.map((toolId) => [toolId, true]) - ) + ), + ...(nativeSkillIds.length > 0 ? { skill: true } : {}) } } const session = await this.getSessionId( @@ -1010,7 +1197,10 @@ export class OpenCodeRuntime implements AgentRuntime { modelID: this.options.modelProfile.modelName } : undefined, - system: this.options.skillInstructions || undefined, + system: + nativeSkillIds.length > 0 + ? undefined + : this.options.skillInstructions || undefined, ...(disabledTools ? { tools: disabledTools } : {}), parts: [{ type: 'text', text: promptText }] }, { signal }) diff --git a/src/main/agent/runtime-skill-packages.ts b/src/main/agent/runtime-skill-packages.ts new file mode 100644 index 0000000..848cc48 --- /dev/null +++ b/src/main/agent/runtime-skill-packages.ts @@ -0,0 +1,26 @@ +import { cp, mkdir, rm } from 'node:fs/promises' +import { join } from 'node:path' +import type { RuntimeSkillPackage } from '../capabilities/capability-service' + +export async function stageRuntimeSkillPackages( + root: string, + skillPackages: readonly RuntimeSkillPackage[], + runtimeLabel: 'Continue' | 'OpenCode' +): Promise { + const skillsRoot = join(root, 'skills') + try { + await mkdir(skillsRoot, { recursive: true, mode: 0o700 }) + for (const skill of skillPackages) { + await cp(skill.directory, join(skillsRoot, skill.id), { + recursive: true, + errorOnExist: true, + force: false, + verbatimSymlinks: true + }) + } + return skillsRoot + } catch (error) { + await rm(root, { recursive: true, force: true }) + throw new Error(`${runtimeLabel} Skill 注册失败`, { cause: error }) + } +} diff --git a/src/main/capabilities/capability-service.test.ts b/src/main/capabilities/capability-service.test.ts index a14341b..1a0895e 100644 --- a/src/main/capabilities/capability-service.test.ts +++ b/src/main/capabilities/capability-service.test.ts @@ -360,6 +360,27 @@ describe('CapabilityService', () => { expect(fullyTruncated).toContain('超长技能') }) + it('omits Skill names that exceed the OpenCode native limit', async () => { + const { builtinRoot, service } = await createService() + const longId = `a${'-a'.repeat(32)}` + await writeSkill(builtinRoot, longId, '超长名称技能') + + const openCodeContext = + await service.getRuntimeSkillContext('opencode') + expect(openCodeContext.instructions).toContain( + '超过 OpenCode 的 64 字符上限' + ) + expect(openCodeContext.instructions).toContain('超长名称技能') + expect(openCodeContext.packages).not.toContainEqual( + expect.objectContaining({ id: longId }) + ) + + const modelContext = await service.getRuntimeSkillContext('model') + expect(modelContext.packages).toContainEqual( + expect.objectContaining({ id: longId }) + ) + }) + it('imports a managed Skill from a ZIP package', async () => { const { directory, importedRoot, service } = await createService() const packageRoot = join(directory, 'zip-source') diff --git a/src/main/capabilities/capability-service.ts b/src/main/capabilities/capability-service.ts index f884bff..0031b85 100644 --- a/src/main/capabilities/capability-service.ts +++ b/src/main/capabilities/capability-service.ts @@ -1343,6 +1343,7 @@ export class CapabilityService { const snapshot = await this.getSnapshot() const sections: string[] = [] const skipped: string[] = [] + const incompatible: string[] = [] const packages: RuntimeSkillPackage[] = [] let length = 0 for (const skill of snapshot.skills) { @@ -1355,6 +1356,10 @@ export class CapabilityService { : this.importedSkillsRoot const directory = join(root, skill.id) const content = await readFile(join(directory, 'SKILL.md'), 'utf8') + if (target === 'opencode' && skill.id.length > 64) { + incompatible.push(skill.name) + continue + } const body = /^---\r?\n[\s\S]*?\r?\n---\r?\n([\s\S]+)$/u.exec(content)?.[1]?.trim() ?? '' @@ -1373,7 +1378,11 @@ export class CapabilityService { sections.push(section) length += section.length } - if (sections.length === 0 && skipped.length === 0) { + if ( + sections.length === 0 && + skipped.length === 0 && + incompatible.length === 0 + ) { return { instructions: '', packages } } return { @@ -1385,6 +1394,11 @@ export class CapabilityService { `注意:以下 Skill 因超出注入上限未加载,本次对话不可用:${skipped.join('、')}。` ] : []), + ...(incompatible.length > 0 + ? [ + `注意:以下 Skill 名称超过 OpenCode 的 64 字符上限,本次对话不可用:${incompatible.join('、')}。` + ] + : []), ...sections ].join('\n\n'), packages