fix: prevent runtime permission stalls and Agent reconnect failures
Execute could stall on workspace-external access or native child-session permissions, while structured questions could be lost or answered incorrectly. Route owned OpenCode descendant interactions, correct Continue question and custom-answer handling, and resolve DSH Execute ACP permissions without a second approval. Ask remains read-only and unrelated sessions stay isolated. Restore the required DSH session projection service. Reuse offline OpenCode configuration by plugin version, remove unused npm shims from remote payloads, normalize dependency modes, and accept real compound descriptors up to 4 MiB. Fix intermittent Agent attach rejection and closed-handshake waits. Add focused and real-Runtime regressions, document interaction boundaries and validation evidence, and include the existing application/tool navigation design drafts. Those navigation changes are design-only, not implemented UI. Validation: full suite 3597 passed, 0 failed, 61 skipped across 338 files; typecheck, lint, release-note verification, whitespace and relative-link checks passed. Current-source Linux x64 installation, Ask/Execute, native subagent external writes, reconnect and stop/bootstrap passed with 10 completed real model calls, 13 native endpoint tests and 20 consecutive handshakes. System sleep/wake and release CI packaging remain unverified. Release note: 修复 Execute 访问工作区外目录或运行原生子代理时卡在权限等待的问题,并恢复 OpenCode 与 Continue 结构化问答的正确转交。修复远程 Agent 安装与重连失败;Ask 仍保持只读,任意终端密码或业务确认不会自动代答。
This commit is contained in:
@@ -48,13 +48,13 @@ export const MODEL_BRIDGE_SDK_AUTH_SENTINEL =
|
||||
|
||||
export type OpenCodeModelBridgeProviderConfig = {
|
||||
model: string
|
||||
permission?: 'ask'
|
||||
permission: 'ask' | 'allow'
|
||||
agent: {
|
||||
title: {
|
||||
disable: true
|
||||
}
|
||||
build?: {
|
||||
permission: 'ask'
|
||||
build: {
|
||||
permission: 'ask' | 'allow'
|
||||
}
|
||||
}
|
||||
provider: Record<
|
||||
@@ -521,11 +521,7 @@ export function createOpenCodeModelBridgeProviderConfig(input: {
|
||||
const baseURL = `${origin}/v1`
|
||||
return {
|
||||
model: openCodeModelBridgeModelId(input.protocol, model),
|
||||
...(input.workMode === 'ask'
|
||||
? {
|
||||
permission: 'ask' as const
|
||||
}
|
||||
: {}),
|
||||
permission: input.workMode === 'ask' ? 'ask' : 'allow',
|
||||
// GoodBuddy owns conversation titles. A first-prompt title request can
|
||||
// race the real ACP prompt, abandon its Provider response, and correctly
|
||||
// poison the no-replay ledger before the user request can run.
|
||||
@@ -533,13 +529,9 @@ export function createOpenCodeModelBridgeProviderConfig(input: {
|
||||
title: {
|
||||
disable: true
|
||||
},
|
||||
...(input.workMode === 'ask'
|
||||
? {
|
||||
build: {
|
||||
permission: 'ask' as const
|
||||
}
|
||||
}
|
||||
: {})
|
||||
build: {
|
||||
permission: input.workMode === 'ask' ? 'ask' : 'allow'
|
||||
}
|
||||
},
|
||||
provider: {
|
||||
[providerId]: {
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('model bridge loopback helper', () => {
|
||||
expect(options.env.NODE_OPTIONS).toBeUndefined()
|
||||
const config = JSON.parse(options.env.OPENCODE_CONFIG_CONTENT!)
|
||||
expect(config.model).toBe('goodbuddy-anthropic/private-model')
|
||||
expect(config.permission).toBe(workMode === 'ask' ? 'ask' : undefined)
|
||||
expect(config.permission).toBe(workMode === 'ask' ? 'ask' : 'allow')
|
||||
expect(config.plugin).toHaveLength(1)
|
||||
}
|
||||
)
|
||||
@@ -594,7 +594,7 @@ describe('model bridge loopback helper', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves Execute provider permissions unchanged', () => {
|
||||
it('explicitly allows Execute permissions instead of inheriting directory prompts', () => {
|
||||
const config = createOpenCodeModelBridgeProviderConfig({
|
||||
protocol: 'openai-responses',
|
||||
model: 'private-model',
|
||||
@@ -603,8 +603,8 @@ describe('model bridge loopback helper', () => {
|
||||
workMode: 'execute'
|
||||
})
|
||||
|
||||
expect(config).not.toHaveProperty('permission')
|
||||
expect(config.agent).not.toHaveProperty('build')
|
||||
expect(config.permission).toBe('allow')
|
||||
expect(config.agent.build.permission).toBe('allow')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -102,6 +102,10 @@ describe('OpenCode direct launch profile', () => {
|
||||
)
|
||||
})
|
||||
expect(execute.args).not.toContain('--unshare-all')
|
||||
expect(JSON.parse(execute.env.OPENCODE_CONFIG_CONTENT!)).toMatchObject({
|
||||
permission: 'allow',
|
||||
agent: { build: { permission: 'allow' } }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps Runtime arguments credential-free and rejects a changed manifest', () => {
|
||||
|
||||
@@ -10,15 +10,6 @@ import {
|
||||
} from "../shared/model-bridge-contracts";
|
||||
import { assertAbsoluteManagedPath } from "./managed-paths";
|
||||
|
||||
const OPEN_CODE_ASK_CONFIG = JSON.stringify({
|
||||
permission: "ask",
|
||||
agent: {
|
||||
build: {
|
||||
permission: "ask",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const OPEN_CODE_RUNTIME_ENVIRONMENT = Object.freeze({
|
||||
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: "1",
|
||||
@@ -151,13 +142,14 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
...OPEN_CODE_RUNTIME_ENVIRONMENT,
|
||||
OPENCODE_CONFIG_DIR: join(bundleDirectory, "config", "opencode"),
|
||||
};
|
||||
const environment =
|
||||
input.workMode === "ask"
|
||||
? {
|
||||
...environmentBase,
|
||||
OPENCODE_CONFIG_CONTENT: OPEN_CODE_ASK_CONFIG,
|
||||
}
|
||||
: environmentBase;
|
||||
const permission = input.workMode === "ask" ? "ask" : "allow";
|
||||
const environment = {
|
||||
...environmentBase,
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify({
|
||||
permission,
|
||||
agent: { build: { permission } },
|
||||
}),
|
||||
};
|
||||
return {
|
||||
...runtimeCommand,
|
||||
processExecutable: runtimeCommand.executable,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { InstallationChallengeVerifier } from './installation-challenge'
|
||||
import {
|
||||
attachRelay,
|
||||
PrivateEndpoint,
|
||||
probeAuthenticatedEndpoint,
|
||||
type PeerIdentity
|
||||
} from './private-endpoint'
|
||||
import { AgentProtocolServer } from './protocol-server'
|
||||
@@ -51,6 +52,57 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('private endpoint lifecycle', () => {
|
||||
runOnUnix.each(['-', '_'])('accepts a valid HMAC beginning with %s', async (prefix) => {
|
||||
const root = privateTemporaryDirectory()
|
||||
const verifier = new InstallationChallengeVerifier(Buffer.alloc(32, 7))
|
||||
const attached = vi.fn()
|
||||
const endpoint = new PrivateEndpoint({
|
||||
socketPath: join(root, 'agent.sock'),
|
||||
peerIdentity: currentPeerIdentity(), challenge: verifier,
|
||||
controllers: new ControllerRegistry(), ...endpointIdentity, onAttach: attached
|
||||
})
|
||||
await endpoint.listen()
|
||||
const client = createConnection(join(root, 'agent.sock'))
|
||||
try {
|
||||
await onceConnected(client)
|
||||
const challenge = await readPacket(client)
|
||||
let clientNonce = ''
|
||||
let response = ''
|
||||
for (let index = 0; index < 4096 && !response.startsWith(prefix); index++) {
|
||||
clientNonce = `client-${index}`
|
||||
response = verifier.createResponse({
|
||||
serverNonce: String(challenge.serverNonce), clientNonce, controllerId: 'controller-a'
|
||||
})
|
||||
}
|
||||
expect(response.startsWith(prefix)).toBe(true)
|
||||
await writePacket(client, {
|
||||
type: 'response', version: 1, controllerId: 'controller-a', clientNonce,
|
||||
protocol: AGENT_PROTOCOL_VERSION, response
|
||||
})
|
||||
expect(attachWelcomeSchema.parse(await readPacket(client)).installationId).toBe('installation-a')
|
||||
await vi.waitFor(() => expect(attached).toHaveBeenCalledOnce())
|
||||
} finally {
|
||||
client.destroy()
|
||||
await endpoint.close()
|
||||
}
|
||||
})
|
||||
|
||||
runOnUnix('rejects a failed health handshake instead of leaving an unsettled read', async () => {
|
||||
const root = privateTemporaryDirectory()
|
||||
const socketPath = join(root, 'agent.sock')
|
||||
const endpoint = new PrivateEndpoint({
|
||||
socketPath, peerIdentity: currentPeerIdentity(),
|
||||
challenge: new InstallationChallengeVerifier(Buffer.alloc(32, 7)),
|
||||
controllers: new ControllerRegistry(), ...endpointIdentity, onAttach: vi.fn()
|
||||
})
|
||||
await endpoint.listen()
|
||||
try {
|
||||
await expect(probeAuthenticatedEndpoint({
|
||||
socketPath, secret: Buffer.alloc(32, 8), ...endpointIdentity
|
||||
})).rejects.toThrow('closed during handshake')
|
||||
} finally { await endpoint.close() }
|
||||
})
|
||||
|
||||
runOnUnix(
|
||||
'never pre-unlinks an endpoint that may still be live',
|
||||
async () => {
|
||||
@@ -346,6 +398,20 @@ describe('private endpoint lifecycle', () => {
|
||||
})
|
||||
|
||||
describe('attach relay preface validation', () => {
|
||||
it.each(['before', 'during'])('rejects a stream destroyed %s its preface arrives', async (when) => {
|
||||
const input = new PassThrough()
|
||||
if (when === 'before') input.destroy()
|
||||
const relay = attachRelay({
|
||||
socketPath: resolve('missing-agent.sock'), secret: Buffer.alloc(32, 7),
|
||||
input, output: new PassThrough()
|
||||
})
|
||||
const rejection = expect(relay).rejects.toThrow('closed during preface')
|
||||
if (when === 'during') input.destroy()
|
||||
await rejection
|
||||
expect(input.listenerCount('readable')).toBe(0)
|
||||
expect(input.listenerCount('close')).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects unknown Main preface fields before opening an endpoint', async () => {
|
||||
const input = new PassThrough()
|
||||
const output = new PassThrough()
|
||||
|
||||
@@ -51,7 +51,8 @@ const challengeResponsePacketSchema = z
|
||||
controllerId: agentIdentifierSchema,
|
||||
clientNonce: agentIdentifierSchema,
|
||||
protocol: protocolVersionSchema,
|
||||
response: agentIdentifierSchema
|
||||
// A SHA-256 HMAC is canonical Base64URL, not an application identifier.
|
||||
response: z.string().regex(/^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/u)
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -400,7 +401,7 @@ export async function probeAuthenticatedEndpoint(options: {
|
||||
const challenge = challengePacketSchema.parse(await readPacket(socket))
|
||||
const verifier = new InstallationChallengeVerifier(options.secret)
|
||||
const controllerId = 'lifecycle-health'
|
||||
const clientNonce = randomBytes(24).toString('base64url')
|
||||
const clientNonce = `health-${randomBytes(24).toString('base64url')}`
|
||||
await writePacket(socket, {
|
||||
type: 'response',
|
||||
version: 1,
|
||||
@@ -559,6 +560,9 @@ async function readExactly(socket: Socket, byteLength: number): Promise<Buffer>
|
||||
remaining -= chunk.byteLength
|
||||
continue
|
||||
}
|
||||
if (socket.destroyed || socket.readableEnded) {
|
||||
throw new Error('Attach socket closed during handshake')
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onReadable = (): void => {
|
||||
cleanup()
|
||||
@@ -575,10 +579,12 @@ async function readExactly(socket: Socket, byteLength: number): Promise<Buffer>
|
||||
const cleanup = (): void => {
|
||||
socket.off('readable', onReadable)
|
||||
socket.off('end', onEnd)
|
||||
socket.off('close', onEnd)
|
||||
socket.off('error', onError)
|
||||
}
|
||||
socket.once('readable', onReadable)
|
||||
socket.once('end', onEnd)
|
||||
socket.once('close', onEnd)
|
||||
socket.once('error', onError)
|
||||
})
|
||||
}
|
||||
@@ -588,6 +594,8 @@ async function readExactly(socket: Socket, byteLength: number): Promise<Buffer>
|
||||
async function readExactlyFromStream(
|
||||
stream: NodeJS.ReadableStream & {
|
||||
read(size?: number): Buffer | null
|
||||
destroyed?: boolean
|
||||
readableEnded?: boolean
|
||||
},
|
||||
byteLength: number
|
||||
): Promise<Buffer> {
|
||||
@@ -601,6 +609,9 @@ async function readExactlyFromStream(
|
||||
remaining -= bytes.byteLength
|
||||
continue
|
||||
}
|
||||
if (stream.destroyed || stream.readableEnded) {
|
||||
throw new Error('Attach relay input closed during preface')
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onReadable = (): void => {
|
||||
cleanup()
|
||||
@@ -617,10 +628,12 @@ async function readExactlyFromStream(
|
||||
const cleanup = (): void => {
|
||||
stream.removeListener('readable', onReadable)
|
||||
stream.removeListener('end', onEnd)
|
||||
stream.removeListener('close', onEnd)
|
||||
stream.removeListener('error', onError)
|
||||
}
|
||||
stream.once('readable', onReadable)
|
||||
stream.once('end', onEnd)
|
||||
stream.once('close', onEnd)
|
||||
stream.once('error', onError)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -55,6 +55,12 @@ describe('bundled runtime paths', () => {
|
||||
'opencode',
|
||||
'opencode.exe'
|
||||
),
|
||||
opencodeConfig: join(
|
||||
'installed',
|
||||
'resources',
|
||||
'runtimes',
|
||||
'opencode-config'
|
||||
),
|
||||
continue: join(
|
||||
'installed',
|
||||
'resources',
|
||||
|
||||
@@ -1971,7 +1971,12 @@ describe('ContinueHostAdapter', () => {
|
||||
expect(inventory.detail).toContain('不提供 Resources')
|
||||
})
|
||||
|
||||
it('bridges authenticated QuizService questions and cleans answered mappings', async () => {
|
||||
it.each([
|
||||
{ options: ['Safe', 'Fast'], answer: [['Safe']], submitted: 'Safe', custom: false },
|
||||
{ options: ['yes', 'no'], answer: [['no']], submitted: 'no', custom: false },
|
||||
{ options: [], answer: [['Free text']], submitted: 'Free text', custom: true },
|
||||
{ options: ['yes', 'no'], answer: undefined, submitted: 'User declined to answer this question.', custom: true }
|
||||
])('bridges QuizService answers $submitted and cleans mappings', async (scenario) => {
|
||||
const distribution = await createDistribution()
|
||||
const configPath = join(
|
||||
distribution.cacheRoot,
|
||||
@@ -2017,11 +2022,9 @@ describe('ContinueHostAdapter', () => {
|
||||
goodbuddyQuestion: {
|
||||
requestId: 'quiz-123',
|
||||
timestamp: Date.now(),
|
||||
question: {
|
||||
question: 'Choose safely',
|
||||
options: ['Safe', 'Fast'],
|
||||
defaultAnswer: 'Safe'
|
||||
}
|
||||
question: 'Choose safely',
|
||||
options: scenario.options,
|
||||
defaultAnswer: 'Safe'
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2072,7 +2075,7 @@ describe('ContinueHostAdapter', () => {
|
||||
if (event.type === 'question') {
|
||||
await adapter.respondToQuestion(
|
||||
event.questionId,
|
||||
[['Safe']]
|
||||
scenario.answer
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2087,10 +2090,7 @@ describe('ContinueHostAdapter', () => {
|
||||
questions: [
|
||||
expect.objectContaining({
|
||||
question: 'Choose safely',
|
||||
options: [
|
||||
{ label: 'Safe', description: '' },
|
||||
{ label: 'Fast', description: '' }
|
||||
]
|
||||
options: scenario.options.map(label => ({ label, description: '' }))
|
||||
})
|
||||
]
|
||||
})
|
||||
@@ -2098,8 +2098,8 @@ describe('ContinueHostAdapter', () => {
|
||||
expect(answerBodies).toEqual([
|
||||
{
|
||||
requestId: 'quiz-123',
|
||||
answer: 'Safe',
|
||||
isCustomAnswer: false
|
||||
answer: scenario.submitted,
|
||||
isCustomAnswer: scenario.custom
|
||||
}
|
||||
])
|
||||
await expect(
|
||||
|
||||
@@ -122,16 +122,12 @@ const continueHostQuestionSchema = z
|
||||
.object({
|
||||
requestId: z.string().min(1).max(128),
|
||||
timestamp: z.number().finite().optional(),
|
||||
question: z
|
||||
.object({
|
||||
question: z.string().trim().min(1).max(2_000),
|
||||
options: z
|
||||
.array(z.string().trim().min(1).max(200))
|
||||
.max(20)
|
||||
.optional(),
|
||||
defaultAnswer: z.string().trim().max(2_000).optional()
|
||||
})
|
||||
.passthrough()
|
||||
question: z.string().trim().min(1).max(2_000),
|
||||
options: z
|
||||
.array(z.string().trim().min(1).max(200))
|
||||
.max(20)
|
||||
.optional(),
|
||||
defaultAnswer: z.string().trim().max(2_000).optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -812,6 +808,7 @@ export class ContinueHostAdapter {
|
||||
origin: string
|
||||
token: string
|
||||
signal: AbortSignal
|
||||
options: readonly string[]
|
||||
}
|
||||
>()
|
||||
private preparation?: Promise<PreparedHost>
|
||||
@@ -1111,7 +1108,7 @@ export class ContinueHostAdapter {
|
||||
throw new Error('Continue 提问回答数量不匹配')
|
||||
}
|
||||
answer = answers[0][0].trim()
|
||||
isCustomAnswer = false
|
||||
isCustomAnswer = !pending.options.includes(answer)
|
||||
}
|
||||
await this.request(
|
||||
pending.origin,
|
||||
@@ -1711,7 +1708,8 @@ export class ContinueHostAdapter {
|
||||
this.pendingQuestions.set(pendingQuestion.requestId, {
|
||||
origin,
|
||||
token,
|
||||
signal: executionSignal
|
||||
signal: executionSignal,
|
||||
options: pendingQuestion.options ?? []
|
||||
})
|
||||
await runOptions.onEvent?.({
|
||||
type: 'question',
|
||||
@@ -1719,9 +1717,9 @@ export class ContinueHostAdapter {
|
||||
questions: [
|
||||
{
|
||||
header: 'Continue',
|
||||
question: pendingQuestion.question.question,
|
||||
question: pendingQuestion.question,
|
||||
options: (
|
||||
pendingQuestion.question.options ?? []
|
||||
pendingQuestion.options ?? []
|
||||
).map((option) => ({
|
||||
label: option,
|
||||
description: ''
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment node
|
||||
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import { ContinueAgentRuntime } from './continue-runtime'
|
||||
|
||||
it('completes Execute external writes and native free-text questions without a second approval', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-continue-permissions-'))
|
||||
const workspace = join(root, 'workspace')
|
||||
const externalFile = join(root, 'external.txt')
|
||||
await mkdir(workspace)
|
||||
const command = process.platform === 'win32'
|
||||
? `Set-Content -LiteralPath '${externalFile.replaceAll("'", "''")}' -Value 'CONTINUE_EXTERNAL_OK' -NoNewline`
|
||||
: `printf CONTINUE_EXTERNAL_OK > '${externalFile.replaceAll("'", "'\\''")}'`
|
||||
let requests = 0
|
||||
let questions = 0
|
||||
const server = createServer((request, response) => {
|
||||
void (async () => {
|
||||
for await (const chunk of request) { void chunk }
|
||||
if (++requests > 3) {
|
||||
response.writeHead(500).end('Unexpected extra model request')
|
||||
return
|
||||
}
|
||||
const tool = requests <= 2
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
for (const [delta, finishReason] of [
|
||||
[tool ? {
|
||||
role: 'assistant', tool_calls: [{
|
||||
index: 0, id: requests === 1 ? 'external-write' : 'native-question', type: 'function',
|
||||
function: requests === 1
|
||||
? { name: 'Bash', arguments: JSON.stringify({ command }) }
|
||||
: { name: 'AskQuestion', arguments: JSON.stringify({ question: 'Add details', options: [] }) }
|
||||
}]
|
||||
} : { role: 'assistant', content: 'CONTINUE_DONE' }, null],
|
||||
[{}, tool ? 'tool_calls' : 'stop']
|
||||
]) {
|
||||
response.write(`data: ${JSON.stringify({
|
||||
id: `continue-${requests}`, object: 'chat.completion.chunk', created: 1,
|
||||
model: 'permissions', choices: [{ index: 0, delta, finish_reason: finishReason }]
|
||||
})}\n\n`)
|
||||
}
|
||||
response.end('data: [DONE]\n\n')
|
||||
})().catch(() => response.destroy())
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') throw new Error('No model port')
|
||||
const runtime = new ContinueAgentRuntime({
|
||||
binaryPath: '', bundledBinaryPath: join(process.cwd(), 'node_modules', '@continuedev', 'cli', 'dist', 'cn.js'),
|
||||
configPath: '', defaultWorkspace: workspace, hostCacheRoot: join(root, 'cache'),
|
||||
modelProfile: {
|
||||
id: crypto.randomUUID(), name: 'Permissions fixture', modelName: 'permissions',
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
protocol: 'openai-chat-completions', authentication: 'none'
|
||||
}
|
||||
})
|
||||
try {
|
||||
let text = ''
|
||||
for await (const event of runtime.run({
|
||||
requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(),
|
||||
workMode: 'execute', prompt: 'Write the external test sentinel, then reply CONTINUE_DONE.'
|
||||
}, AbortSignal.timeout(30_000))) {
|
||||
if (event.type === 'text') text += event.delta
|
||||
if (event.type === 'question') {
|
||||
questions++
|
||||
expect(event.questions[0]).toMatchObject({ custom: true, options: [] })
|
||||
await runtime.respondToQuestion(event.questionId, [['Custom answer']])
|
||||
}
|
||||
}
|
||||
expect(text).toContain('CONTINUE_DONE')
|
||||
expect(await readFile(externalFile, 'utf8')).toBe('CONTINUE_EXTERNAL_OK')
|
||||
expect(requests).toBe(3)
|
||||
expect(questions).toBe(1)
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
server.closeAllConnections()
|
||||
await new Promise<void>(resolve => server.close(() => resolve()))
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 60_000)
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
realpath,
|
||||
rm,
|
||||
writeFile
|
||||
@@ -383,6 +384,49 @@ function createInProcessLaunch(
|
||||
}
|
||||
|
||||
describe('DeepSeek Harness real ACP control-plane E2E', () => {
|
||||
it('writes outside the Workspace in Execute without approval and rejects the same tool in Ask', async () => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-directory-')))
|
||||
const workspace = join(root, 'workspace')
|
||||
const dshHome = join(root, 'home')
|
||||
await Promise.all([mkdir(workspace), mkdir(dshHome)])
|
||||
const externalFile = join(root, 'external.txt')
|
||||
const results: string[] = []
|
||||
const inProcess = createInProcessLaunch(dshHome, {
|
||||
stream(options) {
|
||||
const result = toolResultText(options, 'external-write')
|
||||
if (result !== undefined) {
|
||||
results.push(result)
|
||||
return textResponse('DIRECTORY_TEST_DONE')
|
||||
}
|
||||
return toolCall('external-write', 'write', {
|
||||
file_path: externalFile, content: latestUserText(options)
|
||||
})
|
||||
}
|
||||
})
|
||||
const runtime = new DeepSeekHarnessRuntime({
|
||||
defaultWorkspace: workspace, baseUrl: 'https://api.deepseek.com',
|
||||
model: 'directory-test', launch: options => inProcess.launch(options),
|
||||
credentialRefs: { [CREDENTIAL_REF]: 'unused-in-memory-model-credential' },
|
||||
initializationTimeoutMs: 20_000, promptTimeoutMs: 20_000, shutdownTimeoutMs: 5_000
|
||||
})
|
||||
const authorize = vi.fn(async () => 'deny' as const)
|
||||
try {
|
||||
for (const mode of ['execute', 'ask'] as const) {
|
||||
const events = await collect(runtime.run({
|
||||
requestId: `directory-${mode}`, conversationId: `directory-${mode}`,
|
||||
workMode: mode, prompt: mode === 'execute' ? 'EXECUTE_WRITTEN' : 'ASK_MUST_NOT_WRITE'
|
||||
}, AbortSignal.timeout(20_000), authorize))
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
expect(await readFile(externalFile, 'utf8')).toBe('EXECUTE_WRITTEN')
|
||||
}
|
||||
expect(results).toHaveLength(2)
|
||||
expect(authorize).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('delivers bounded inline images to an image-capable Harness model', async () => {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-acp-image-'))
|
||||
|
||||
@@ -858,9 +858,9 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('authorizes Execute but can select only allow-once', async () => {
|
||||
it('answers Execute permissions without waiting for a second authorizer', async () => {
|
||||
const harness = setup()
|
||||
const authorize = vi.fn().mockResolvedValue('always')
|
||||
const authorize = vi.fn(() => new Promise<never>(() => {}))
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('execute'),
|
||||
@@ -880,13 +880,11 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
optionId: 'allow-once'
|
||||
}
|
||||
})
|
||||
expect(authorize).toHaveBeenCalledWith({
|
||||
scopeKey: 'deepseek-harness:shell',
|
||||
title: 'Run tests',
|
||||
description: 'DeepSeek Harness 请求一次性执行此工具',
|
||||
toolName: 'shell',
|
||||
argumentSummary: '{\n "command": "npm test"\n}',
|
||||
allowPermanent: false
|
||||
expect(authorize).not.toHaveBeenCalled()
|
||||
const persistentOnly = permission('session-1')
|
||||
persistentOnly.options = persistentOnly.options.filter(option => option.kind !== 'allow_once')
|
||||
await expect(harness.permission(persistentOnly)).resolves.toEqual({
|
||||
outcome: { outcome: 'selected', optionId: 'allow-always' }
|
||||
})
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
await running
|
||||
@@ -1194,7 +1192,8 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
assignments: ['deepseek-harness'],
|
||||
secretConfigured: false,
|
||||
transport: 'stdio',
|
||||
command: process.execPath,
|
||||
// Electron's execPath needs an env flag that the MCP SDK does not inherit.
|
||||
command: process.versions.electron ? 'node' : process.execPath,
|
||||
args: [
|
||||
resolve('tests', 'fixtures', 'web-3d-game-mcp.mjs')
|
||||
]
|
||||
|
||||
@@ -754,12 +754,11 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
)
|
||||
const allowOnce = permission.options.find(
|
||||
(option) => option.kind === 'allow_once'
|
||||
)
|
||||
) ?? permission.options.find((option) => option.kind === 'allow_always')
|
||||
if (
|
||||
!run ||
|
||||
run.closed ||
|
||||
run.request.workMode !== 'execute' ||
|
||||
!run.authorize
|
||||
run.request.workMode !== 'execute'
|
||||
) {
|
||||
return reject
|
||||
? {
|
||||
@@ -771,26 +770,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
: { outcome: { outcome: 'cancelled' } }
|
||||
}
|
||||
|
||||
const argumentSummary = safeStringify(
|
||||
permission.toolCall.rawInput
|
||||
)
|
||||
const decision: Awaited<ReturnType<RuntimeAuthorizer>> =
|
||||
await run
|
||||
.authorize({
|
||||
scopeKey: `deepseek-harness:${permission.toolCall.name ?? permission.toolCall.kind ?? 'tool'}`,
|
||||
title:
|
||||
(permission.toolCall.title ??
|
||||
permission.toolCall.name ??
|
||||
'DeepSeek Harness 工具请求').slice(0, 200),
|
||||
description: 'DeepSeek Harness 请求一次性执行此工具',
|
||||
...(permission.toolCall.name
|
||||
? { toolName: permission.toolCall.name.slice(0, 200) }
|
||||
: {}),
|
||||
...(argumentSummary ? { argumentSummary } : {}),
|
||||
allowPermanent: false
|
||||
})
|
||||
.catch(() => 'deny')
|
||||
if (decision !== 'deny' && allowOnce) {
|
||||
if (allowOnce) {
|
||||
return {
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// @vitest-environment node
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import { OpenCodeRuntime } from './opencode-runtime'
|
||||
|
||||
const cachedBinary = join(
|
||||
process.cwd(), '.runtime-resources', process.arch,
|
||||
process.platform === 'win32' ? 'opencode.exe' : 'opencode'
|
||||
)
|
||||
const binaryPath = process.env.GOODBUDDY_TEST_OPENCODE_BINARY || cachedBinary
|
||||
|
||||
it.skipIf(!existsSync(binaryPath))(
|
||||
'completes native Task external reads and structured yes/no and text questions while Ask disables tools',
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-permissions-'))
|
||||
const workspace = join(root, 'workspace')
|
||||
const externalFile = join(root, 'external.txt')
|
||||
await mkdir(workspace)
|
||||
await writeFile(externalFile, 'EXTERNAL_DIRECTORY_READ_OK\n')
|
||||
let requests = 0
|
||||
let readVerified = false
|
||||
let answerVerified = false
|
||||
let askTools: string[] | undefined
|
||||
const model = createServer((request, response) => {
|
||||
void (async () => {
|
||||
let body = ''
|
||||
for await (const chunk of request) body += chunk.toString()
|
||||
const input = JSON.parse(body)
|
||||
if (++requests > 7) {
|
||||
response.writeHead(500).end('Unexpected extra model request')
|
||||
return
|
||||
}
|
||||
const messages = input.messages as Array<{ role: string; content: unknown; tool_call_id?: string }>
|
||||
const user = JSON.stringify(messages.filter(message => message.role === 'user').at(-1)?.content)
|
||||
const results = messages.filter(message => message.role === 'tool')
|
||||
let content: string | undefined
|
||||
let tool: { name: string; arguments: object; id: string } | undefined
|
||||
if (user.includes('ASK_WITHOUT_TOOLS')) {
|
||||
askTools = (input.tools ?? []).map((entry: { function: { name: string } }) => entry.function.name)
|
||||
content = 'ASK_OK'
|
||||
} else if (user.includes('READ_EXTERNAL_SENTINEL')) {
|
||||
if (results.some(result => result.tool_call_id === 'external-read')) {
|
||||
readVerified = JSON.stringify(results).includes('EXTERNAL_DIRECTORY_READ_OK')
|
||||
content = 'CHILD_OK'
|
||||
} else {
|
||||
tool = { id: 'external-read', name: 'read', arguments: { filePath: externalFile } }
|
||||
}
|
||||
} else if (results.some(result => result.tool_call_id === 'native-question')) {
|
||||
answerVerified = JSON.stringify(results).includes('Yes') && JSON.stringify(results).includes('Custom answer')
|
||||
content = 'PARENT_OK'
|
||||
} else if (results.some(result => result.tool_call_id === 'native-task')) {
|
||||
tool = { id: 'native-question', name: 'question', arguments: { questions: [
|
||||
{ header: 'Confirm', question: 'Continue?', options: [
|
||||
{ label: 'Yes', description: 'Continue' }, { label: 'No', description: 'Stop' }
|
||||
], custom: false },
|
||||
{ header: 'Details', question: 'Add details', options: [], custom: true }
|
||||
] } }
|
||||
} else {
|
||||
tool = {
|
||||
id: 'native-task', name: 'task',
|
||||
arguments: {
|
||||
subagent_type: 'general', description: 'Read external test file',
|
||||
prompt: `READ_EXTERNAL_SENTINEL: read ${externalFile}, then reply CHILD_OK.`
|
||||
}
|
||||
}
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
for (const [delta, finishReason] of [
|
||||
[tool ? {
|
||||
role: 'assistant',
|
||||
tool_calls: [{ index: 0, id: tool.id, type: 'function', function: {
|
||||
name: tool.name, arguments: JSON.stringify(tool.arguments)
|
||||
} }]
|
||||
} : { role: 'assistant', content }, null],
|
||||
[{}, tool ? 'tool_calls' : 'stop']
|
||||
]) {
|
||||
response.write(`data: ${JSON.stringify({
|
||||
id: `permissions-${requests}`, object: 'chat.completion.chunk', created: 1,
|
||||
model: 'permissions', 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 runtime = new OpenCodeRuntime({
|
||||
embedded: true, binaryPath: '', bundledBinaryPath: binaryPath, configPath: '',
|
||||
bundledConfigPath: join(process.cwd(), '.runtime-resources', 'opencode-config'),
|
||||
defaultWorkspace: workspace,
|
||||
modelProfile: {
|
||||
id: crypto.randomUUID(), name: 'Permissions fixture', modelName: 'permissions',
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
protocol: 'openai-chat-completions', authentication: 'none'
|
||||
}
|
||||
})
|
||||
try {
|
||||
for (const mode of ['execute', 'ask'] as const) {
|
||||
let text = ''
|
||||
for await (const event of runtime.run({
|
||||
requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(),
|
||||
workMode: mode, prompt: mode === 'execute' ? 'USE_NATIVE_TASK' : 'ASK_WITHOUT_TOOLS'
|
||||
}, AbortSignal.timeout(30_000))) {
|
||||
if (event.type === 'text') text += event.delta
|
||||
if (event.type === 'question') {
|
||||
expect(event.questions).toHaveLength(2)
|
||||
await runtime.respondToQuestion(event.questionId, [['Yes'], ['Custom answer']])
|
||||
}
|
||||
}
|
||||
expect(text).toBe(mode === 'execute' ? 'PARENT_OK' : 'ASK_OK')
|
||||
}
|
||||
expect(readVerified).toBe(true)
|
||||
expect(answerVerified).toBe(true)
|
||||
expect(askTools).toBeDefined()
|
||||
expect(askTools).not.toEqual(expect.arrayContaining(['task']))
|
||||
expect(askTools?.filter(name => ['bash', 'write', 'edit', 'read'].includes(name))).toEqual([])
|
||||
expect(requests).toBe(6)
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
model.closeAllConnections()
|
||||
await new Promise<void>(resolve => model.close(() => resolve()))
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
90_000
|
||||
)
|
||||
@@ -185,6 +185,7 @@ function runClient(events: Record<string, unknown>[]) {
|
||||
});
|
||||
const client = {
|
||||
session: {
|
||||
get: vi.fn().mockResolvedValue({ data: { id: "unrelated-session" } }),
|
||||
list: vi.fn().mockResolvedValue({ data: [], error: undefined }),
|
||||
create: vi.fn().mockResolvedValue({
|
||||
data: { id: "session-1" },
|
||||
@@ -598,6 +599,7 @@ describe("OpenCodeRuntime embedded launcher", () => {
|
||||
urls: [],
|
||||
},
|
||||
permission: {
|
||||
"*": "allow",
|
||||
skill: {
|
||||
"*": "deny",
|
||||
"longdoc-docx": "allow",
|
||||
@@ -620,6 +622,33 @@ describe("OpenCodeRuntime embedded launcher", () => {
|
||||
await expect(stat(registrationRoot)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.each(["1.18.29", "0.0.1"])("handles cached plugin %s without ready markers", async (cachedVersion) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "goodbuddy-opencode-version-"));
|
||||
const bundledConfigPath = join(root, "bundle");
|
||||
const sharedCacheRoot = join(root, "cache");
|
||||
const target = join(sharedCacheRoot, "config", "opencode");
|
||||
const packagePath = join("node_modules", "@opencode-ai", "plugin", "package.json");
|
||||
for (const [directory, version] of [[bundledConfigPath, "1.18.29"], [target, cachedVersion]]) {
|
||||
await mkdir(join(directory!, "node_modules", "@opencode-ai", "plugin"), { recursive: true });
|
||||
await writeFile(join(directory!, packagePath), JSON.stringify({ version }));
|
||||
}
|
||||
await writeFile(join(target, "cached-only"), "old cache");
|
||||
const { deps } = dependencies(fakeChild());
|
||||
const runtime = new OpenCodeRuntime(options({ bundledConfigPath, sharedCacheRoot }), deps);
|
||||
try {
|
||||
await expect(runtime.testConnection()).resolves.toMatchObject({ available: true });
|
||||
expect(JSON.parse(await readFile(join(target, packagePath), "utf8")).version).toBe("1.18.29");
|
||||
if (cachedVersion === "1.18.29") {
|
||||
await expect(readFile(join(target, "cached-only"), "utf8")).resolves.toBe("old cache");
|
||||
} else {
|
||||
await expect(stat(join(target, "cached-only"))).rejects.toThrow();
|
||||
}
|
||||
} finally {
|
||||
await runtime.dispose();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses shared OpenCode config and content-addressed Skills across projects", async () => {
|
||||
const sourceRoot = await mkdtemp(
|
||||
join(tmpdir(), "goodbuddy-opencode-shared-skill-source-"),
|
||||
@@ -634,11 +663,6 @@ describe("OpenCodeRuntime embedded launcher", () => {
|
||||
join(bundledConfigPath, "node_modules", "@opencode-ai", "plugin"),
|
||||
{ recursive: true },
|
||||
);
|
||||
await writeFile(
|
||||
join(bundledConfigPath, ".goodbuddy-ready.json"),
|
||||
'{"version":"1.18.29"}\n',
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(
|
||||
bundledConfigPath,
|
||||
@@ -1877,14 +1901,14 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("parses OpenCode questions and sends the selected answers back", async () => {
|
||||
it.each(["parent", "child"] as const)("parses %s OpenCode questions and sends selected answers back", async (owner) => {
|
||||
const setup = runClient([
|
||||
{
|
||||
id: "question-event",
|
||||
type: "question.asked",
|
||||
properties: {
|
||||
id: "question-1",
|
||||
sessionID: "session-1",
|
||||
sessionID: owner === "parent" ? "session-1" : "child-session",
|
||||
questions: [
|
||||
{
|
||||
header: "实现方式",
|
||||
@@ -1915,6 +1939,9 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
|
||||
properties: { sessionID: "session-1" },
|
||||
},
|
||||
]);
|
||||
vi.mocked(setup.client.session.get).mockResolvedValue({
|
||||
data: { id: "child-session", parentID: "session-1" },
|
||||
} as never);
|
||||
const runtime = embeddedRuntime(setup.client);
|
||||
const stream = runtime.run(
|
||||
{
|
||||
@@ -3263,6 +3290,45 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
it.each(["ask", "execute"] as const)("settles owned descendant directory permissions in %s without touching peer sessions", async (mode) => {
|
||||
const { client, permissionReply } = runClient([
|
||||
permissionEvent({ id: "peer-permission", sessionID: "peer-session" }),
|
||||
permissionEvent({
|
||||
id: "child-permission", sessionID: "grandchild-session",
|
||||
permission: "external_directory", patterns: ["/tmp/logs/*"],
|
||||
}),
|
||||
permissionEvent({
|
||||
id: "child-permission", sessionID: "grandchild-session",
|
||||
permission: "external_directory", patterns: ["/tmp/logs/*"],
|
||||
}),
|
||||
{
|
||||
id: "event-idle", type: "session.idle",
|
||||
properties: { sessionID: "session-1" },
|
||||
},
|
||||
]);
|
||||
const sessionGet = vi.fn(async ({ sessionID }: { sessionID: string }) => ({
|
||||
data: {
|
||||
id: sessionID,
|
||||
parentID: sessionID === "grandchild-session" ? "child-session"
|
||||
: sessionID === "child-session" ? "session-1" : undefined,
|
||||
},
|
||||
}));
|
||||
client.session.get = sessionGet as unknown as typeof client.session.get;
|
||||
const runtime = embeddedRuntime(client);
|
||||
try {
|
||||
const events = await collectRun(runtime, mode);
|
||||
expect(events.at(-1)).toMatchObject({ type: "done" });
|
||||
expect(events.filter((event) => event.type === "tool")).toEqual([]);
|
||||
expect(permissionReply).toHaveBeenCalledExactlyOnceWith({
|
||||
requestID: "child-permission", directory: process.cwd(),
|
||||
reply: mode === "execute" ? "once" : "reject",
|
||||
}, { signal: expect.any(AbortSignal) });
|
||||
expect(sessionGet).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
await runtime.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("fails the run when a tool reports an error before session idle", async () => {
|
||||
const { client, session } = runClient([
|
||||
{
|
||||
|
||||
@@ -625,6 +625,7 @@ function createOpenCodeSkillConfig(
|
||||
): {
|
||||
skills: { paths: string[]; urls: never[] };
|
||||
permission: {
|
||||
"*": "allow";
|
||||
skill: Record<string, "allow" | "deny">;
|
||||
};
|
||||
} {
|
||||
@@ -634,6 +635,7 @@ function createOpenCodeSkillConfig(
|
||||
urls: [],
|
||||
},
|
||||
permission: {
|
||||
"*": "allow",
|
||||
skill: createSkillPermissionConfig(skillIds),
|
||||
},
|
||||
};
|
||||
@@ -961,12 +963,20 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
const preparation = (async () => {
|
||||
const source = resolve(bundledConfigPath);
|
||||
const markerName = ".goodbuddy-ready.json";
|
||||
const sourceMarker = await readFile(join(source, markerName), "utf8");
|
||||
const packagePath = join(
|
||||
"node_modules",
|
||||
"@opencode-ai",
|
||||
"plugin",
|
||||
"package.json",
|
||||
);
|
||||
const sourcePackage = JSON.parse(
|
||||
await readFile(join(source, packagePath), "utf8"),
|
||||
) as { version: string };
|
||||
try {
|
||||
if (
|
||||
(await readFile(join(target, markerName), "utf8")) === sourceMarker
|
||||
) {
|
||||
const installed = JSON.parse(
|
||||
await readFile(join(target, packagePath), "utf8"),
|
||||
) as { version?: string };
|
||||
if (installed.version === sourcePackage.version) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
@@ -2399,6 +2409,29 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
prompt.catch(() => undefined);
|
||||
|
||||
const repliedPermissionIds = new Set<string>();
|
||||
const ownedSessions = new Set([sessionId]);
|
||||
const ownsSession = async (candidate: string): Promise<boolean> => {
|
||||
const visited = new Set<string>();
|
||||
let current: string | undefined = candidate;
|
||||
while (current && !visited.has(current)) {
|
||||
if (ownedSessions.has(current)) {
|
||||
for (const id of visited) ownedSessions.add(id);
|
||||
return true;
|
||||
}
|
||||
visited.add(current);
|
||||
const result = await this.controlRequest(
|
||||
"确认子会话权限归属",
|
||||
(controlSignal) => client.session.get(
|
||||
{ sessionID: current!, directory },
|
||||
{ signal: controlSignal },
|
||||
),
|
||||
signal,
|
||||
);
|
||||
if (result.error || !result.data) return false;
|
||||
current = result.data.parentID;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const reportedMessageIds = new Set<string>();
|
||||
for await (const event of subscription.stream) {
|
||||
const childProgress = subagentProgress.update(event);
|
||||
@@ -2563,11 +2596,11 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
|
||||
if (
|
||||
event.type === "question.asked" &&
|
||||
event.properties.sessionID === sessionId
|
||||
await ownsSession(event.properties.sessionID)
|
||||
) {
|
||||
const questionRequest = parseQuestionRequest(
|
||||
event.properties,
|
||||
sessionId,
|
||||
event.properties.sessionID,
|
||||
);
|
||||
if (
|
||||
questionRequest &&
|
||||
@@ -2575,7 +2608,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
) {
|
||||
const publicQuestionId = createPublicQuestionId(
|
||||
request.requestId,
|
||||
sessionId,
|
||||
event.properties.sessionID,
|
||||
questionRequest.id,
|
||||
);
|
||||
if (this.pendingQuestions.has(publicQuestionId)) {
|
||||
@@ -2609,7 +2642,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
if (
|
||||
(event.type === "question.replied" ||
|
||||
event.type === "question.rejected") &&
|
||||
event.properties.sessionID === sessionId
|
||||
reportedQuestionIds.has(event.properties.requestID)
|
||||
) {
|
||||
const publicQuestionId = reportedQuestionIds.get(
|
||||
event.properties.requestID,
|
||||
@@ -2624,17 +2657,19 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
event.type === "permission.asked"
|
||||
) {
|
||||
const properties = event.properties as unknown;
|
||||
const permissionSessionId = isRecord(properties) &&
|
||||
typeof properties.sessionID === "string"
|
||||
? properties.sessionID
|
||||
: sessionId;
|
||||
if (
|
||||
isRecord(properties) &&
|
||||
typeof properties.sessionID === "string" &&
|
||||
properties.sessionID !== sessionId
|
||||
!await ownsSession(permissionSessionId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let permissionRequest: PermissionRequest;
|
||||
try {
|
||||
const parsed = parsePermissionRequest(properties, sessionId);
|
||||
const parsed = parsePermissionRequest(properties, permissionSessionId);
|
||||
if (!parsed) {
|
||||
throw new Error("OpenCode 权限请求格式无效");
|
||||
}
|
||||
@@ -2680,15 +2715,17 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
const callId =
|
||||
permissionRequest.tool?.callID ?? permissionRequest.id;
|
||||
const toolName = permissionRequest.permission.slice(0, 200);
|
||||
toolStates.set(callId, { name: toolName, state: "pending" });
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: "tool",
|
||||
callId,
|
||||
name: toolName,
|
||||
state: "pending",
|
||||
summary: `OpenCode 工具:${toolName}`,
|
||||
};
|
||||
if (permissionSessionId === sessionId) {
|
||||
toolStates.set(callId, { name: toolName, state: "pending" });
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: "tool",
|
||||
callId,
|
||||
name: toolName,
|
||||
state: "pending",
|
||||
summary: `OpenCode 工具:${toolName}`,
|
||||
};
|
||||
}
|
||||
const allowKnowledge =
|
||||
request.workMode === "ask" &&
|
||||
knowledgeToolIds.includes(permissionRequest.permission);
|
||||
|
||||
@@ -114,14 +114,16 @@ describe('bundled skills', () => {
|
||||
service.getRuntimeSkillContext('deepseek-harness')
|
||||
).resolves.toMatchObject({
|
||||
packages: expect.arrayContaining([
|
||||
{
|
||||
expect.objectContaining({
|
||||
id: 'deai-writing',
|
||||
directory: join(builtinSkillsRoot, 'deai-writing')
|
||||
},
|
||||
{
|
||||
directory: join(builtinSkillsRoot, 'deai-writing'),
|
||||
digest: expect.stringMatching(/^[a-f0-9]{64}$/u)
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'longdoc-docx',
|
||||
directory: join(builtinSkillsRoot, 'longdoc-docx')
|
||||
}
|
||||
directory: join(builtinSkillsRoot, 'longdoc-docx'),
|
||||
digest: expect.stringMatching(/^[a-f0-9]{64}$/u)
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -92,6 +92,8 @@ describe('controlled DeepSeek Harness host', () => {
|
||||
})
|
||||
|
||||
expect(host.context.fs.sandboxMode).toBeUndefined()
|
||||
expect(host.context.get('sessionProjections')).toBeDefined()
|
||||
expect(host.context.get('agentLoop')).toBeDefined()
|
||||
expect(host.context.shell.sandboxMode).toBeUndefined()
|
||||
expect(host.context.get('attachments')).toBeInstanceOf(
|
||||
GoodBuddyHarnessAttachmentStore
|
||||
|
||||
@@ -10,6 +10,7 @@ import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import LlmRuntime from '@deepseek-ai/dsh-llm'
|
||||
import * as PiAiLlm from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SkillRegistry from '@deepseek-ai/dsh-skill'
|
||||
import LocalSubprocess from '@deepseek-ai/dsh-subprocess-local'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -225,6 +226,7 @@ export async function startControlledDeepSeekHarnessHost(
|
||||
const specs: PluginSpec[] = [
|
||||
{ plugin: LlmRuntime },
|
||||
{ plugin: SessionStore },
|
||||
{ plugin: SessionProjectionRegistry },
|
||||
...(config.supportsImageInput
|
||||
? [{ plugin: GoodBuddyHarnessAttachmentStore }]
|
||||
: []),
|
||||
|
||||
@@ -60,6 +60,7 @@ const MAXIMUM_ARCHIVE_BYTES = 512 * 1024 * 1024
|
||||
const MAXIMUM_EXPANDED_BYTES = 1024 * 1024 * 1024
|
||||
const MAXIMUM_FILE_BYTES = 384 * 1024 * 1024
|
||||
const MAXIMUM_METADATA_BYTES = 1024 * 1024
|
||||
const MAXIMUM_DESCRIPTOR_BYTES = 4 * 1024 * 1024
|
||||
const MAXIMUM_ENTRIES = 50_002
|
||||
const PRIVATE_DIRECTORY_MODE = 0o700
|
||||
const SIGNATURE_DOMAIN = Buffer.from(
|
||||
@@ -115,7 +116,7 @@ export async function verifyExtractedAgentPackage(options: {
|
||||
await Promise.all([
|
||||
readBoundedFile(
|
||||
join(rootDirectory, DESCRIPTOR_NAME),
|
||||
MAXIMUM_METADATA_BYTES
|
||||
MAXIMUM_DESCRIPTOR_BYTES
|
||||
),
|
||||
readBoundedFile(
|
||||
join(rootDirectory, SIGNATURE_NAME),
|
||||
@@ -321,9 +322,11 @@ async function extractArchive(
|
||||
}
|
||||
seen.add(name)
|
||||
const maximum =
|
||||
name === DESCRIPTOR_NAME || name === SIGNATURE_NAME
|
||||
? MAXIMUM_METADATA_BYTES
|
||||
: MAXIMUM_FILE_BYTES
|
||||
name === DESCRIPTOR_NAME
|
||||
? MAXIMUM_DESCRIPTOR_BYTES
|
||||
: name === SIGNATURE_NAME
|
||||
? MAXIMUM_METADATA_BYTES
|
||||
: MAXIMUM_FILE_BYTES
|
||||
if (
|
||||
file.originalSize !== undefined &&
|
||||
(
|
||||
|
||||
@@ -37,6 +37,7 @@ const MAXIMUM_ARCHIVE_BYTES = 512 * 1024 * 1024
|
||||
const MAXIMUM_EXPANDED_BYTES = 1024 * 1024 * 1024
|
||||
const MAXIMUM_ENTRY_BYTES = 384 * 1024 * 1024
|
||||
const MAXIMUM_METADATA_BYTES = 1024 * 1024
|
||||
const MAXIMUM_DESCRIPTOR_BYTES = 4 * 1024 * 1024
|
||||
const MAXIMUM_ENTRIES = 50_002
|
||||
const MAXIMUM_CENTRAL_BYTES = 32 * 1024 * 1024
|
||||
const ZIP_LOCAL = 0x04034b50
|
||||
@@ -480,7 +481,9 @@ function streamEntry(
|
||||
destination?: string
|
||||
): { sha256: string; crc32: number; bytes?: Buffer } {
|
||||
const wantedBytes =
|
||||
destination === undefined && entry.size <= MAXIMUM_METADATA_BYTES
|
||||
destination === undefined && entry.size <= (
|
||||
entry.name === PACKAGE_DESCRIPTOR ? MAXIMUM_DESCRIPTOR_BYTES : MAXIMUM_METADATA_BYTES
|
||||
)
|
||||
? Buffer.allocUnsafe(entry.size)
|
||||
: undefined
|
||||
let output: number | undefined
|
||||
@@ -549,7 +552,10 @@ function writeFileChunk(handle: number, bytes: Buffer, offset: number): number {
|
||||
|
||||
function readEntry(handle: number, entries: Map<string, ZipEntry>, name: string): Buffer {
|
||||
const entry = entries.get(name)
|
||||
if (entry === undefined || entry.size > MAXIMUM_METADATA_BYTES) {
|
||||
const maximumBytes = name === PACKAGE_DESCRIPTOR
|
||||
? MAXIMUM_DESCRIPTOR_BYTES
|
||||
: MAXIMUM_METADATA_BYTES
|
||||
if (entry === undefined || entry.size > maximumBytes) {
|
||||
throw new Error(`Agent package metadata is missing or too large: ${name}`)
|
||||
}
|
||||
const actual = streamEntry(handle, entry)
|
||||
@@ -1656,7 +1662,9 @@ function writePreparedFile(
|
||||
): void {
|
||||
if (
|
||||
bytes.byteLength <= 0 ||
|
||||
bytes.byteLength > MAXIMUM_METADATA_BYTES
|
||||
bytes.byteLength > (
|
||||
name === PACKAGE_DESCRIPTOR ? MAXIMUM_DESCRIPTOR_BYTES : MAXIMUM_METADATA_BYTES
|
||||
)
|
||||
) {
|
||||
throw new Error(`Prepared package metadata is invalid: ${name}`)
|
||||
}
|
||||
@@ -2392,7 +2400,7 @@ export function commitPackage(options: InstallerOptions): PackageInstallerResult
|
||||
)
|
||||
const descriptorBytes = readBoundedRegularFile(
|
||||
join(preparedRoot, PACKAGE_DESCRIPTOR),
|
||||
MAXIMUM_METADATA_BYTES,
|
||||
MAXIMUM_DESCRIPTOR_BYTES,
|
||||
'Prepared Agent package descriptor',
|
||||
'0600'
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user