fix: register native OpenCode skills

This commit is contained in:
lofyer
2026-08-10 11:31:02 +08:00
parent be82caebc4
commit a9ae00a845
7 changed files with 646 additions and 196 deletions
+3 -17
View File
@@ -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(
+1
View File
@@ -160,6 +160,7 @@ export function createAgentRuntime(
'',
modelProfile: settings?.opencodeModelProfile,
skillInstructions: capabilities.skillInstructions,
skillPackages: capabilities.skillPackages,
sandbox: resolveRuntimeSandbox(sandboxMode),
defaultWorkspace: workspace,
knowledgeGateway: capabilities.knowledgeGateway
+226 -14
View File
@@ -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<string, unknown>[]) {
}
function embeddedRuntime(
client: ReturnType<typeof createOpencodeClient>
client: ReturnType<typeof createOpencodeClient>,
overrides: Partial<
ConstructorParameters<typeof OpenCodeRuntime>[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<string, unknown>
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,
+354 -164
View File
@@ -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<typeof spawn>
@@ -90,6 +101,12 @@ type OpenCodeServer = {
close: () => Promise<void>
}
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<string, 'allow' | 'deny'> {
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<string, 'allow' | 'deny'>
}
} {
return {
skills: {
paths: [registration.skillsRoot],
urls: []
},
permission: {
skill: createSkillPermissionConfig(skillIds)
}
}
}
async function normalizeOpenCodeSkillManifest(
skillDirectory: string,
skillId: string
): Promise<void> {
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<string, unknown> = {
...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<OpenCodeSkillRegistration> {
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<OpenCodeServer> {
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<OpenCodeServer>((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<OpenCodeServer>((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<OpencodeClient> {
@@ -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<string, boolean> | 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 })
+26
View File
@@ -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<string> {
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 })
}
}
@@ -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')
+15 -1
View File
@@ -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