fix: register native OpenCode skills
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -160,6 +160,7 @@ export function createAgentRuntime(
|
||||
'',
|
||||
modelProfile: settings?.opencodeModelProfile,
|
||||
skillInstructions: capabilities.skillInstructions,
|
||||
skillPackages: capabilities.skillPackages,
|
||||
sandbox: resolveRuntimeSandbox(sandboxMode),
|
||||
defaultWorkspace: workspace,
|
||||
knowledgeGateway: capabilities.knowledgeGateway
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user