fix: clean up OpenCode streams and allow external working directories
Completed OpenCode requests left their event streams open across repeated and parallel conversations. Each request now closes its own subscription on exit without cancelling peer sessions, and cancellation preserves its original cause. Direct-model Execute commands no longer restrict cwd to the workspace. Absolute paths, parent-relative paths, and symlinks to external directories are accepted. The workspace remains the default and relative-path base; Ask stays read-only, and local execution binding, directory validation, and process cleanup remain. Add real OpenCode lifecycle and real Shell tool-loop regressions, plus matching feature documentation. Focused checks: 182 passed, 1 skipped; typecheck and lint passed. Full shared-worktree suite: 3542 passed, 57 skipped, 1 unrelated sidebar heading failure, reproduced separately and documented. Release note: 修复 OpenCode 连续及并行对话结束后事件连接未释放的问题;直连模型 Execute 命令现在可直接指定工作区外的绝对路径、相对路径或符号链接目录,Ask 只读边界保持不变。
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdir, mkdtemp, realpath, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { join, relative } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { LocalWorkspaceAccess } from '../workspace'
|
||||
import {
|
||||
@@ -110,10 +110,10 @@ describe('LocalDirectModelProcessService', () => {
|
||||
).rejects.toThrow()
|
||||
await expect(
|
||||
service.execute(
|
||||
{ command: 'echo ok', cwd: join(process.cwd(), 'absolute') },
|
||||
{ command: 'echo ok', cwd: 'invalid\0directory' },
|
||||
context
|
||||
)
|
||||
).rejects.toThrow('相对于工作区')
|
||||
).rejects.toThrow('无效字符')
|
||||
expect(spawnProcess).not.toHaveBeenCalled()
|
||||
await service.dispose()
|
||||
})
|
||||
@@ -198,68 +198,82 @@ describe('LocalDirectModelProcessService', () => {
|
||||
await unavailable.dispose()
|
||||
})
|
||||
|
||||
it('resolves only existing workspace directories and rejects symlink escapes', async () => {
|
||||
const { root, access } = await workspace()
|
||||
const child = new FakeChild()
|
||||
const { service, spawnProcess } = fakeService(child)
|
||||
await mkdir(join(root, 'nested'))
|
||||
|
||||
const execution = service.execute(
|
||||
{ command: 'pwd', cwd: 'nested' },
|
||||
{
|
||||
conversationId: 'cwd',
|
||||
workspace: access,
|
||||
signal: new AbortController().signal
|
||||
it.each(['relative', 'absolute', 'outside', 'parent', 'symlink'])(
|
||||
'resolves an existing %s working directory without workspace containment',
|
||||
async (kind) => {
|
||||
const { root, access } = await workspace()
|
||||
const outside = await mkdtemp(join(tmpdir(), 'goodbuddy-outside-'))
|
||||
temporaryDirectories.push(outside)
|
||||
const nested = join(root, 'nested')
|
||||
await mkdir(nested)
|
||||
const link = join(root, 'outside-link')
|
||||
if (kind === 'symlink') {
|
||||
await symlink(
|
||||
outside,
|
||||
link,
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
)
|
||||
}
|
||||
)
|
||||
await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledOnce())
|
||||
expect(spawnProcess.mock.calls[0]?.[2].cwd).toBe(
|
||||
await realpath(join(root, 'nested'))
|
||||
)
|
||||
child.close(0)
|
||||
await expect(execution).resolves.toMatchObject({ cwd: 'nested' })
|
||||
const cwd =
|
||||
kind === 'relative'
|
||||
? 'nested'
|
||||
: kind === 'absolute'
|
||||
? nested
|
||||
: kind === 'outside'
|
||||
? outside
|
||||
: kind === 'parent'
|
||||
? relative(root, outside)
|
||||
: 'outside-link'
|
||||
const expectedPath = await realpath(
|
||||
kind === 'relative' || kind === 'absolute' ? nested : outside
|
||||
)
|
||||
const child = new FakeChild()
|
||||
const { service, spawnProcess } = fakeService(child)
|
||||
|
||||
await expect(
|
||||
service.execute(
|
||||
{ command: 'pwd', cwd: '..' },
|
||||
const execution = service.execute(
|
||||
{ command: 'pwd', cwd },
|
||||
{
|
||||
conversationId: 'escape',
|
||||
conversationId: 'cwd',
|
||||
workspace: access,
|
||||
signal: new AbortController().signal
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('不能超出')
|
||||
|
||||
const outside = await mkdtemp(join(tmpdir(), 'goodbuddy-outside-'))
|
||||
temporaryDirectories.push(outside)
|
||||
const link = join(root, 'outside-link')
|
||||
try {
|
||||
await symlink(
|
||||
outside,
|
||||
link,
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
)
|
||||
await expect(
|
||||
service.execute(
|
||||
{ command: 'pwd', cwd: 'outside-link' },
|
||||
{
|
||||
conversationId: 'symlink',
|
||||
workspace: access,
|
||||
signal: new AbortController().signal
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('符号链接')
|
||||
} catch (error) {
|
||||
if (
|
||||
!(
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
error.code === 'EPERM'
|
||||
)
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledOnce())
|
||||
expect(spawnProcess.mock.calls[0]?.[2].cwd).toBe(expectedPath)
|
||||
child.close(0)
|
||||
await expect(execution).resolves.toMatchObject({
|
||||
cwd:
|
||||
kind === 'relative' || kind === 'absolute'
|
||||
? 'nested'
|
||||
: expectedPath
|
||||
})
|
||||
await service.dispose()
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects missing working directories and files before starting a process', async () => {
|
||||
const { root, access } = await workspace()
|
||||
const file = join(root, 'file.txt')
|
||||
await writeFile(file, 'not a directory')
|
||||
const { service, spawnProcess } = fakeService(new FakeChild())
|
||||
const context = {
|
||||
conversationId: 'invalid-cwd',
|
||||
workspace: access,
|
||||
signal: new AbortController().signal
|
||||
}
|
||||
await expect(
|
||||
service.execute(
|
||||
{ command: 'pwd', cwd: join(root, 'missing') },
|
||||
context
|
||||
)
|
||||
).rejects.toThrow()
|
||||
await expect(
|
||||
service.execute(
|
||||
{ command: 'pwd', cwd: file },
|
||||
context
|
||||
)
|
||||
).rejects.toThrow('不是目录')
|
||||
expect(spawnProcess).not.toHaveBeenCalled()
|
||||
await service.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -28,7 +28,15 @@ export const DIRECT_MODEL_PROCESS_TRUNCATION_MARKER =
|
||||
export const processExecuteInputSchema = z
|
||||
.object({
|
||||
command: z.string().trim().min(1).max(100_000),
|
||||
cwd: z.string().trim().min(1).max(4_096).optional(),
|
||||
cwd: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(4_096)
|
||||
.describe(
|
||||
'工作目录,可使用绝对路径或相对于当前工作区的路径;省略时使用工作区根目录,也可选择工作区外目录。'
|
||||
)
|
||||
.optional(),
|
||||
timeoutMs: z
|
||||
.number()
|
||||
.int()
|
||||
@@ -350,10 +358,6 @@ async function resolveWorkingDirectory(
|
||||
if (input.cwd?.includes('\0')) {
|
||||
throw new Error('工作目录包含无效字符')
|
||||
}
|
||||
if (input.cwd && isAbsolute(input.cwd)) {
|
||||
throw new Error('工作目录必须相对于工作区')
|
||||
}
|
||||
|
||||
const initialIdentity = await workspace.getIdentity()
|
||||
if (initialIdentity.kind !== 'local') {
|
||||
throw new Error('本机进程不能在远程工作区中运行')
|
||||
@@ -365,23 +369,8 @@ async function resolveWorkingDirectory(
|
||||
throw new Error('项目工作区不是目录')
|
||||
}
|
||||
const candidatePath = resolve(canonicalRoot, input.cwd ?? '.')
|
||||
const lexicalDifference = relative(canonicalRoot, candidatePath)
|
||||
if (
|
||||
lexicalDifference === '..' ||
|
||||
lexicalDifference.startsWith(`..${sep}`) ||
|
||||
isAbsolute(lexicalDifference)
|
||||
) {
|
||||
throw new Error('工作目录不能超出项目工作区')
|
||||
}
|
||||
const canonicalPath = await realpath(candidatePath)
|
||||
const canonicalDifference = relative(canonicalRoot, canonicalPath)
|
||||
if (
|
||||
canonicalDifference === '..' ||
|
||||
canonicalDifference.startsWith(`..${sep}`) ||
|
||||
isAbsolute(canonicalDifference)
|
||||
) {
|
||||
throw new Error('工作目录不能通过符号链接超出项目工作区')
|
||||
}
|
||||
if (!(await stat(canonicalPath)).isDirectory()) {
|
||||
throw new Error('工作目录不是目录')
|
||||
}
|
||||
@@ -397,7 +386,12 @@ async function resolveWorkingDirectory(
|
||||
}
|
||||
return {
|
||||
canonicalPath,
|
||||
displayPath: canonicalDifference || '.'
|
||||
displayPath:
|
||||
canonicalDifference === '..' ||
|
||||
canonicalDifference.startsWith(`..${sep}`) ||
|
||||
isAbsolute(canonicalDifference)
|
||||
? canonicalPath
|
||||
: canonicalDifference || '.'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, relative } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
RecoverableModelToolError,
|
||||
@@ -2496,6 +2499,69 @@ describe('ModelAgentRuntime', () => {
|
||||
expect(toolProvider.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['absolute', 'relative'])(
|
||||
'executes a real command outside the workspace using a %s cwd through the model tool loop',
|
||||
async (pathKind) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-model-cwd-'))
|
||||
const workspace = join(root, 'workspace')
|
||||
const target = join(root, 'target')
|
||||
await mkdir(workspace)
|
||||
await mkdir(target)
|
||||
await writeFile(join(target, 'input.txt'), 'outside-workspace')
|
||||
const command = process.platform === 'win32'
|
||||
? "Copy-Item 'input.txt' 'output.txt'"
|
||||
: 'cp input.txt output.txt'
|
||||
const responses = [
|
||||
{
|
||||
choices: [{
|
||||
message: {
|
||||
role: 'assistant', content: null,
|
||||
tool_calls: [{
|
||||
id: 'call-process', type: 'function',
|
||||
function: {
|
||||
name: 'process_execute',
|
||||
arguments: JSON.stringify({
|
||||
command,
|
||||
cwd: pathKind === 'absolute' ? target : relative(workspace, target)
|
||||
})
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
},
|
||||
{ choices: [{ message: { role: 'assistant', content: 'done' } }] }
|
||||
]
|
||||
const fetcher = vi.fn<typeof fetch>(async () => Response.json(responses.shift()))
|
||||
const runtime = new ModelAgentRuntime({
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
model: 'test-model',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'none',
|
||||
fetcher,
|
||||
defaultWorkspace: workspace
|
||||
})
|
||||
try {
|
||||
const events: RuntimeEvent[] = []
|
||||
for await (const event of runtime.run({
|
||||
requestId: crypto.randomUUID(),
|
||||
conversationId: crypto.randomUUID(),
|
||||
prompt: 'Copy the file in the requested directory.',
|
||||
workMode: 'execute'
|
||||
}, new AbortController().signal, async () => 'once')) {
|
||||
events.push(event)
|
||||
}
|
||||
expect(await readFile(join(target, 'output.txt'), 'utf8')).toBe('outside-workspace')
|
||||
expect(events).toContainEqual(expect.objectContaining({
|
||||
type: 'tool', callId: 'call-process', state: 'completed'
|
||||
}))
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('delegates one programming Subagent that can run a real project command without recursion', async () => {
|
||||
const command =
|
||||
process.platform === 'win32'
|
||||
|
||||
@@ -1217,7 +1217,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
scopeKey: 'model:builtin:process_execute',
|
||||
title: '允许运行项目命令?',
|
||||
description:
|
||||
'该命令会在当前工作区中使用当前用户权限运行,并可访问该账号有权访问的主机资源。',
|
||||
'该命令会在指定目录(默认当前工作区)中使用当前用户权限运行,并可访问该账号有权访问的主机资源。',
|
||||
toolName: tool.displayName,
|
||||
argumentSummary,
|
||||
allowPermanent: false
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// @vitest-environment node
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createOpencodeClient } from '@opencode-ai/sdk/v2'
|
||||
import { expect, it } from 'vitest'
|
||||
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||
import { OpenCodeRuntime } from './opencode-runtime'
|
||||
|
||||
const binaryPath = join(
|
||||
process.cwd(), '.runtime-resources', process.arch,
|
||||
process.platform === 'win32' ? 'opencode.exe' : 'opencode'
|
||||
)
|
||||
|
||||
it.skipIf(!existsSync(binaryPath))(
|
||||
'closes real event streams after parallel runs without cancelling a peer',
|
||||
async () => {
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-lifecycle-'))
|
||||
let cancelledController: AbortController | undefined
|
||||
const model = createServer((request, response) => {
|
||||
void (async () => {
|
||||
let body = ''
|
||||
for await (const chunk of request) body += chunk.toString()
|
||||
if (body.includes('CANCEL_LIFECYCLE')) {
|
||||
setTimeout(() => cancelledController?.abort(
|
||||
new DOMException('Cancelled', 'AbortError')
|
||||
), 30)
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
for (const [delta, finishReason] of [
|
||||
[{ role: 'assistant', content: 'LIFECYCLE_OK' }, null],
|
||||
[{}, 'stop']
|
||||
]) {
|
||||
response.write(`data: ${JSON.stringify({
|
||||
id: 'lifecycle', object: 'chat.completion.chunk', created: 1,
|
||||
model: 'lifecycle',
|
||||
choices: [{ index: 0, delta, finish_reason: finishReason }]
|
||||
})}\n\n`)
|
||||
}
|
||||
response.end('data: [DONE]\n\n')
|
||||
})().catch(() => response.destroy())
|
||||
})
|
||||
await new Promise<void>((resolve) => model.listen(0, '127.0.0.1', resolve))
|
||||
const address = model.address()
|
||||
if (!address || typeof address === 'string') throw new Error('No model port')
|
||||
const gateway = new KnowledgeMcpGateway({} as never)
|
||||
await gateway.start()
|
||||
let openEventBodies = 0
|
||||
const runtime = new OpenCodeRuntime({
|
||||
embedded: true, binaryPath: '', bundledBinaryPath: binaryPath,
|
||||
configPath: '', defaultWorkspace: workspace, knowledgeGateway: gateway,
|
||||
modelProfile: {
|
||||
id: crypto.randomUUID(), name: 'Lifecycle fixture', modelName: 'lifecycle',
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
protocol: 'openai-chat-completions', authentication: 'none'
|
||||
}
|
||||
}, {
|
||||
createClient: (options) => createOpencodeClient({
|
||||
...options,
|
||||
fetch: async (request, init) => {
|
||||
const response = await fetch(request, init)
|
||||
const url = request instanceof Request ? request.url : request
|
||||
if (new URL(url).pathname !== '/event' || !response.body) return response
|
||||
openEventBodies++
|
||||
const reader = response.body.getReader()
|
||||
let closed = false
|
||||
const close = (): void => {
|
||||
if (!closed) openEventBodies--
|
||||
closed = true
|
||||
}
|
||||
return new Response(new ReadableStream({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const item = await reader.read()
|
||||
if (item.done) {
|
||||
close()
|
||||
controller.close()
|
||||
} else controller.enqueue(item.value)
|
||||
} catch (error) {
|
||||
close()
|
||||
controller.error(error)
|
||||
}
|
||||
},
|
||||
async cancel(reason) {
|
||||
close()
|
||||
await reader.cancel(reason)
|
||||
}
|
||||
}), { status: response.status, headers: response.headers })
|
||||
}
|
||||
})
|
||||
})
|
||||
const run = async (controller = new AbortController(), cancel = false): Promise<string> => {
|
||||
const requestId = crypto.randomUUID()
|
||||
const token = gateway.grant(requestId, [crypto.randomUUID()], controller.signal)!
|
||||
const deadline = setTimeout(() => controller.abort(), 30_000)
|
||||
let text = ''
|
||||
try {
|
||||
for await (const event of runtime.run({
|
||||
requestId, conversationId: crypto.randomUUID(), workMode: 'ask',
|
||||
prompt: cancel ? 'CANCEL_LIFECYCLE' : 'Reply LIFECYCLE_OK without tools.',
|
||||
knowledgeCapabilityToken: token
|
||||
}, controller.signal)) {
|
||||
if (event.type === 'text') text += event.delta
|
||||
}
|
||||
return text
|
||||
} finally {
|
||||
clearTimeout(deadline)
|
||||
gateway.revoke(token)
|
||||
}
|
||||
}
|
||||
try {
|
||||
for (const concurrency of [1, 4, 8]) {
|
||||
const results = await Promise.all(Array.from({ length: concurrency }, () => run()))
|
||||
expect(results).toEqual(Array(concurrency).fill('LIFECYCLE_OK'))
|
||||
await expect.poll(() => openEventBodies).toBe(0)
|
||||
}
|
||||
cancelledController = new AbortController()
|
||||
const cancelled = run(cancelledController, true)
|
||||
await Promise.all([
|
||||
expect(cancelled).rejects.toMatchObject({ name: 'AbortError' }),
|
||||
expect(run()).resolves.toBe('LIFECYCLE_OK')
|
||||
])
|
||||
await expect.poll(() => openEventBodies).toBe(0)
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await gateway.dispose()
|
||||
model.closeAllConnections()
|
||||
await new Promise<void>((resolve) => model.close(() => resolve()))
|
||||
await rm(workspace, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
120_000
|
||||
)
|
||||
@@ -1290,6 +1290,59 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
expect(runtime.requiresToolApproval).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['completed', 'failed', 'cancelled', 'consumer-return'])(
|
||||
'closes the event subscription when a run is %s',
|
||||
async (outcome) => {
|
||||
const setup = runClient([])
|
||||
const controller = new AbortController()
|
||||
let subscriptionSignal: AbortSignal | undefined
|
||||
const subscribe = setup.event.subscribe as unknown as ReturnType<typeof vi.fn>
|
||||
subscribe.mockImplementation(async (_input, options: { signal: AbortSignal }) => {
|
||||
subscriptionSignal = options.signal
|
||||
return {
|
||||
stream: (async function* () {
|
||||
if (outcome === 'cancelled') {
|
||||
controller.abort(new DOMException('Cancelled', 'AbortError'))
|
||||
return
|
||||
}
|
||||
if (outcome === 'failed') return
|
||||
yield {
|
||||
type: 'session.idle',
|
||||
properties: { sessionID: 'session-1' }
|
||||
}
|
||||
})()
|
||||
}
|
||||
})
|
||||
const runtime = new OpenCodeRuntime(
|
||||
options({ embedded: false, baseUrl: 'http://127.0.0.1:4096' }),
|
||||
{ createClient: (() => setup.client) as typeof createOpencodeClient }
|
||||
)
|
||||
const collect = async (): Promise<void> => {
|
||||
for await (const event of runtime.run({
|
||||
requestId: crypto.randomUUID(),
|
||||
conversationId: crypto.randomUUID(),
|
||||
prompt: 'test',
|
||||
workMode: 'execute'
|
||||
}, controller.signal)) {
|
||||
if (outcome === 'consumer-return' && event.type === 'done') break
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (outcome === 'failed') {
|
||||
await expect(collect()).rejects.toThrow('事件流意外结束')
|
||||
} else if (outcome === 'cancelled') {
|
||||
await expect(collect()).rejects.toMatchObject({ name: 'AbortError' })
|
||||
} else {
|
||||
await collect()
|
||||
}
|
||||
expect(subscriptionSignal?.aborted).toBe(true)
|
||||
expect(controller.signal.aborted).toBe(outcome === 'cancelled')
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('serializes external runs that share one conversation session', async () => {
|
||||
const child = fakeChild()
|
||||
let releaseFirst!: () => void
|
||||
|
||||
@@ -1980,6 +1980,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
let knowledgeToolIds: string[] = []
|
||||
let customMcpName: string | undefined
|
||||
let customMcpToken: string | undefined
|
||||
const subscriptionController = new AbortController()
|
||||
try {
|
||||
if (
|
||||
request.knowledgeCapabilityToken &&
|
||||
@@ -2193,7 +2194,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
{ directory },
|
||||
{ signal: controlSignal }
|
||||
),
|
||||
signal
|
||||
AbortSignal.any([signal, subscriptionController.signal])
|
||||
)
|
||||
|
||||
const abortSession = (): void => {
|
||||
@@ -2699,6 +2700,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
)
|
||||
)
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
throw new Error('OpenCode 事件流意外结束')
|
||||
} catch (error) {
|
||||
abortSession()
|
||||
@@ -2739,6 +2741,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
subscriptionController.abort()
|
||||
const cleanupSignal = AbortSignal.timeout(1_000)
|
||||
if (knowledgeMcpName) {
|
||||
await awaitWithAbort(
|
||||
|
||||
@@ -98,7 +98,7 @@ export const builtinModelTools = [
|
||||
name: 'process_execute',
|
||||
displayName: '进程执行',
|
||||
description:
|
||||
'使用当前工作区和当前用户权限运行 PowerShell、Bash 或 Sh 命令。',
|
||||
'使用当前用户权限运行 PowerShell、Bash 或 Sh 命令,默认在工作区执行,也可指定其他工作目录。',
|
||||
access: 'write',
|
||||
group: 'programming'
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user