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:
@@ -6,6 +6,7 @@ import { dirname, join } from 'node:path'
|
||||
import { c as createTar } from 'tar'
|
||||
import { unzipSync } from 'fflate'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createOpenCodeConfigFixture } from './support/opencode-config-fixture'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const ci = require('../build/agent-ci-bundle.cjs') as {
|
||||
@@ -83,6 +84,7 @@ describe('Darwin arm64 Agent packages', () => {
|
||||
|
||||
it('assembles deterministic Darwin packages and rejects cross-platform bundle selection', () => {
|
||||
const root = scratch()
|
||||
createOpenCodeConfigFixture(root)
|
||||
const bundle = join(root, 'agent')
|
||||
const { lock, runtimeLock } = ci.readCiLocks('darwin', 'arm64')
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
|
||||
@@ -128,6 +130,7 @@ describe('Darwin arm64 Agent packages', () => {
|
||||
runtimeLock.runtimes.opencode.targets['darwin-arm64']!.integrity =
|
||||
`sha512-${createHash('sha512').update(readFileSync(archive)).digest('base64')}`
|
||||
const built = runtime.buildRuntimeBundle({
|
||||
projectRoot: root,
|
||||
platform: 'darwin', architecture: 'arm64', runtimeArchive: archive,
|
||||
outputRoot: join(root, 'runtime'), lock: runtimeLock, registry,
|
||||
testSigningIdentity: identity
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
AgentPackageManager,
|
||||
selectLatestCompatibleEntry
|
||||
} from '../src/main/remote-agent/agent-package-manager'
|
||||
import { createOpenCodeConfigFixture } from './support/opencode-config-fixture'
|
||||
|
||||
type AgentBundleModule = {
|
||||
canonicalManifestBytes(manifest: unknown): Buffer
|
||||
@@ -305,7 +306,7 @@ beforeAll(() => {
|
||||
architecture: 'x64' | 'arm64',
|
||||
archive: string
|
||||
) => runtimeBundle.buildRuntimeBundle({
|
||||
projectRoot: process.cwd(),
|
||||
projectRoot: temporaryRoot,
|
||||
architecture,
|
||||
runtimeArchive: archive,
|
||||
outputRoot: join(temporaryRoot, 'runtime'),
|
||||
@@ -317,6 +318,7 @@ beforeAll(() => {
|
||||
},
|
||||
enforceFilesystemMode: false
|
||||
})
|
||||
createOpenCodeConfigFixture(temporaryRoot)
|
||||
const builtRuntime = buildRuntime('x64', runtimeInput)
|
||||
const builtArm64Runtime = buildRuntime(
|
||||
'arm64',
|
||||
@@ -367,6 +369,58 @@ afterAll(() => {
|
||||
})
|
||||
|
||||
describe('compound Agent packages', () => {
|
||||
it('verifies and installs offline dependency inventories larger than one MiB', async () => {
|
||||
const projectRoot = join(temporaryRoot, 'large-offline-config')
|
||||
const config = createOpenCodeConfigFixture(projectRoot)
|
||||
const inventory = join(config, 'inventory')
|
||||
mkdirSync(inventory)
|
||||
for (let index = 0; index < 3000; index++) {
|
||||
const path = join(inventory, `${String(index).padStart(4, '0')}-${'x'.repeat(100)}.js`)
|
||||
writeFileSync(path, '// fixture\n')
|
||||
}
|
||||
const runtime = runtimeBundle.buildRuntimeBundle({
|
||||
projectRoot, architecture: 'x64',
|
||||
runtimeArchive: createRuntimeInput('x64'),
|
||||
outputRoot: join(projectRoot, 'runtime'),
|
||||
lock: remoteRuntimeLock, registry,
|
||||
testSigningIdentity: { keyId: 'agent-package-fixture', privateKey },
|
||||
enforceFilesystemMode: false
|
||||
})
|
||||
const archive = join(projectRoot, agentPackageArchiveName(agentLock.agentVersion, 'x64'))
|
||||
const result = agentPackage.assembleAgentPackage({
|
||||
projectRoot, architecture: 'x64', minimumDesktopVersion: '0.11.0',
|
||||
output: archive, agentBundle: join(temporaryRoot, 'agent'),
|
||||
runtimeBundle: runtime.bundleDirectory, agentLock, runtimeLock: remoteRuntimeLock,
|
||||
registry, testSigningIdentity: { keyId: 'agent-package-fixture', privateKey }
|
||||
})
|
||||
const descriptorBytes = agentPackage.descriptorBytes(result.descriptor)
|
||||
expect(descriptorBytes.length).toBeGreaterThan(1024 * 1024)
|
||||
expect(descriptorBytes.length).toBeLessThan(4 * 1024 * 1024)
|
||||
expect(agentCatalog.readPackageMetadata(archive, registry).sha256).toBe(result.sha256)
|
||||
await expect(extractAndVerifyAgentPackage({
|
||||
archivePath: archive, destinationDirectory: join(projectRoot, 'verified'),
|
||||
architecture: 'x64', desktopVersion: '0.11.0', trustedRegistry: registry
|
||||
})).resolves.toMatchObject({ descriptor: { contentDigest: result.descriptor.contentDigest } })
|
||||
const options = {
|
||||
operationRoot: join(projectRoot, 'operation'), archive,
|
||||
expectedSha256: result.sha256, homeDirectory: join(projectRoot, 'home')
|
||||
}
|
||||
mkdirSync(options.homeDirectory, { mode: 0o700 })
|
||||
expect(packageInstaller.preparePackage(options).status).toBe('prepared')
|
||||
expect(packageInstaller.commitPackage(options).status).toBe('committed')
|
||||
}, 60_000)
|
||||
|
||||
it('still rejects descriptors larger than four MiB before parsing', async () => {
|
||||
const archive = join(temporaryRoot, 'oversized-descriptor.zip')
|
||||
writeFileSync(archive, zipSync({
|
||||
'agent-package.json': new Uint8Array(4 * 1024 * 1024 + 1)
|
||||
}, { level: 0 }))
|
||||
await expect(extractAndVerifyAgentPackage({
|
||||
archivePath: archive, destinationDirectory: join(temporaryRoot, 'oversized-descriptor'),
|
||||
desktopVersion: '0.11.0', trustedRegistry: registry
|
||||
})).rejects.toThrow(/limit|large/iu)
|
||||
})
|
||||
|
||||
it('assembles deterministic signed archives with private extraction', async () => {
|
||||
const secondRoot = join(temporaryRoot, 'second')
|
||||
mkdirSync(secondRoot)
|
||||
@@ -456,9 +510,9 @@ describe('compound Agent packages', () => {
|
||||
expect(second.archive).toBe(
|
||||
agentPackageArchiveName(agentLock.agentVersion, 'x64')
|
||||
)
|
||||
expect(readFileSync(secondPath)).toEqual(
|
||||
expect(readFileSync(secondPath).equals(
|
||||
readFileSync(packagePath)
|
||||
)
|
||||
)).toBe(true)
|
||||
})
|
||||
|
||||
it('prepares and commits a format-v1 package without an embedded installer', () => {
|
||||
|
||||
@@ -888,6 +888,12 @@ describe('release build arguments', () => {
|
||||
const integrity = 'sha512-test-integrity'
|
||||
const executable = Buffer.from('desktop runtime')
|
||||
try {
|
||||
const configPlugin = join(
|
||||
projectRoot, '.runtime-resources', 'opencode-config',
|
||||
'node_modules', '@opencode-ai', 'plugin'
|
||||
)
|
||||
mkdirSync(configPlugin, { recursive: true })
|
||||
writeFileSync(join(configPlugin, 'package.json'), JSON.stringify({ version: '1.18.29' }))
|
||||
mkdirSync(join(projectRoot, 'out', 'main'), {
|
||||
recursive: true
|
||||
})
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const modulePath = require.resolve('../build/opencode-config.cjs')
|
||||
const spawnSync = vi.fn()
|
||||
let directory: string
|
||||
let target: string
|
||||
let config: {
|
||||
opencodeVersion: string
|
||||
prepareBundledOpenCodeConfig(projectDir: string): string
|
||||
}
|
||||
|
||||
function installPlugin(root: string, version: string) {
|
||||
const path = join(root, 'node_modules', '@opencode-ai', 'plugin', 'package.json')
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, JSON.stringify({ name: '@opencode-ai/plugin', version }))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(join(tmpdir(), 'goodbuddy-opencode-config-'))
|
||||
target = join(directory, '.runtime-resources', 'opencode-config')
|
||||
spawnSync.mockReset()
|
||||
vi.spyOn(require('node:child_process'), 'spawnSync').mockImplementation(spawnSync)
|
||||
vi.stubEnv('npm_execpath', 'npm-cli.js')
|
||||
delete require.cache[modulePath]
|
||||
config = require(modulePath)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
delete require.cache[modulePath]
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('bundled OpenCode config', () => {
|
||||
it('reuses the installed version without a root lock or ready marker', () => {
|
||||
installPlugin(target, config.opencodeVersion)
|
||||
expect(config.prepareBundledOpenCodeConfig(directory)).toBe(target)
|
||||
expect(spawnSync).not.toHaveBeenCalled()
|
||||
expect(existsSync(join(directory, 'package-lock.json'))).toBe(false)
|
||||
expect(existsSync(join(target, '.goodbuddy-ready.json'))).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['missing', 'old', 'invalid'])('installs a %s cache through staging', (state) => {
|
||||
if (state !== 'missing') {
|
||||
installPlugin(target, '0.0.1')
|
||||
if (state === 'invalid') {
|
||||
writeFileSync(join(target, 'node_modules', '@opencode-ai', 'plugin', 'package.json'), '{')
|
||||
}
|
||||
}
|
||||
spawnSync.mockImplementation((_command, args, options) => {
|
||||
expect(args).toContain('--ignore-scripts')
|
||||
expect(args).toContain('--bin-links=false')
|
||||
expect(options.cwd).toBe(`${target}.staging-${process.pid}`)
|
||||
expect(JSON.parse(readFileSync(join(options.cwd, 'package.json'), 'utf8')).dependencies)
|
||||
.toEqual({ '@opencode-ai/plugin': config.opencodeVersion })
|
||||
installPlugin(options.cwd, config.opencodeVersion)
|
||||
return { status: 0 }
|
||||
})
|
||||
expect(config.prepareBundledOpenCodeConfig(directory)).toBe(target)
|
||||
expect(readFileSync(join(target, 'node_modules', '@opencode-ai', 'plugin', 'package.json'), 'utf8'))
|
||||
.toContain(config.opencodeVersion)
|
||||
expect(existsSync(`${target}.staging-${process.pid}`)).toBe(false)
|
||||
expect(existsSync(join(target, '.goodbuddy-ready.json'))).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the old cache and removes staging after a failed install, then retries', () => {
|
||||
installPlugin(target, '0.0.1')
|
||||
spawnSync.mockImplementation((_command, _args, options) => {
|
||||
installPlugin(options.cwd, config.opencodeVersion)
|
||||
return { status: 1, stderr: 'install failed' }
|
||||
})
|
||||
expect(() => config.prepareBundledOpenCodeConfig(directory)).toThrow('install failed')
|
||||
expect(readFileSync(join(target, 'node_modules', '@opencode-ai', 'plugin', 'package.json'), 'utf8'))
|
||||
.toContain('0.0.1')
|
||||
expect(existsSync(`${target}.staging-${process.pid}`)).toBe(false)
|
||||
spawnSync.mockImplementation((_command, _args, options) => {
|
||||
installPlugin(options.cwd, config.opencodeVersion)
|
||||
return { status: 0 }
|
||||
})
|
||||
expect(config.prepareBundledOpenCodeConfig(directory)).toBe(target)
|
||||
expect(spawnSync).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from 'node:crypto'
|
||||
import {
|
||||
chmodSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
type RemoteRuntimeBundleManifest,
|
||||
type RemoteRuntimeLock
|
||||
} from '../src/shared/remote-runtime-launch-contracts'
|
||||
import { createOpenCodeConfigFixture } from './support/opencode-config-fixture'
|
||||
|
||||
type VerifiedBundle = {
|
||||
bundleDirectory: string
|
||||
@@ -110,6 +112,7 @@ beforeEach(() => {
|
||||
temporaryRoot = resolve(
|
||||
mkdtempSync(join(tmpdir(), 'goodbuddy-runtime-build-'))
|
||||
)
|
||||
createOpenCodeConfigFixture(temporaryRoot)
|
||||
privateKey = createPrivateKey({
|
||||
key: Buffer.from(
|
||||
'302e020100300506032b657004220420202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f',
|
||||
@@ -243,6 +246,14 @@ describe('Remote Runtime bundle tooling', () => {
|
||||
}
|
||||
})
|
||||
expect(first.manifest).not.toHaveProperty('quotas')
|
||||
expect(first.manifest.files.some((file) => file.path.includes('/.bin/'))).toBe(false)
|
||||
const pluginPath = 'config/opencode/node_modules/@opencode-ai/plugin/index.js'
|
||||
expect(first.manifest.files).toContainEqual(expect.objectContaining({
|
||||
path: pluginPath, mode: '0644'
|
||||
}))
|
||||
if (process.platform !== 'win32') {
|
||||
expect(lstatSync(join(first.bundleDirectory, pluginPath)).mode & 0o777).toBe(0o644)
|
||||
}
|
||||
})
|
||||
|
||||
it('creates deterministic archives and imports only verified content', () => {
|
||||
@@ -257,9 +268,9 @@ describe('Remote Runtime bundle tooling', () => {
|
||||
built.bundleDirectory,
|
||||
secondArchive
|
||||
)
|
||||
expect(readFileSync(secondArchive)).toEqual(
|
||||
expect(readFileSync(secondArchive).equals(
|
||||
readFileSync(firstArchive)
|
||||
)
|
||||
)).toBe(true)
|
||||
|
||||
const imported = runtimeBundle.importRuntimeArchive(
|
||||
firstArchive,
|
||||
@@ -335,7 +346,7 @@ describe('Remote Runtime bundle tooling', () => {
|
||||
}))
|
||||
}
|
||||
const built = runtimeBundle.buildRuntimeBundle({
|
||||
projectRoot: process.cwd(),
|
||||
projectRoot: temporaryRoot,
|
||||
architecture: 'x64',
|
||||
runtimeArchive,
|
||||
outputRoot: join(temporaryRoot, 'production'),
|
||||
@@ -354,7 +365,7 @@ describe('Remote Runtime bundle tooling', () => {
|
||||
|
||||
function build(outputRoot: string): VerifiedBundle {
|
||||
return runtimeBundle.buildRuntimeBundle({
|
||||
projectRoot: process.cwd(),
|
||||
projectRoot: temporaryRoot,
|
||||
architecture: 'x64',
|
||||
runtimeArchive,
|
||||
outputRoot,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
export function createOpenCodeConfigFixture(projectRoot: string): string {
|
||||
const config = join(projectRoot, '.runtime-resources', 'opencode-config')
|
||||
const files = {
|
||||
'node_modules/@opencode-ai/plugin/package.json': JSON.stringify({
|
||||
name: '@opencode-ai/plugin', version: '1.18.29'
|
||||
}),
|
||||
'node_modules/@opencode-ai/plugin/index.js': 'export default {}\n',
|
||||
'node_modules/.bin/unused': 'unused CLI shim\n'
|
||||
}
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
const destination = join(config, name)
|
||||
mkdirSync(dirname(destination), { recursive: true })
|
||||
writeFileSync(destination, content)
|
||||
chmodSync(destination, 0o755)
|
||||
}
|
||||
const license = join(projectRoot, 'node_modules', 'opencode-ai', 'LICENSE')
|
||||
mkdirSync(dirname(license), { recursive: true })
|
||||
writeFileSync(license, 'fixture license\n')
|
||||
return config
|
||||
}
|
||||
Reference in New Issue
Block a user