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:
mesalogo
2026-09-08 11:34:45 +08:00
parent 9aed926cda
commit c8db340cc7
48 changed files with 1858 additions and 200 deletions
+7 -15
View File
@@ -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]: {
+4 -4
View File
@@ -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', () => {
+8 -16
View File
@@ -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,
+66
View File
@@ -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()
+15 -2
View File
@@ -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)
})
}