fix: enforce remote Ask at tool boundary

Managed SSH Ask previously required bubblewrap and applied a process-level filesystem sandbox, while Execute launched directly. Ask and Execute now share the same direct signed Runtime startup; Ask remains read-only through the Agent's existing tool permission mediation, which only permits native read requests and rejects other tool kinds.

Release note: 远程 Ask 与 Execute 现在都可直接启动签名 Runtime,无需 Host 安装 bubblewrap;Ask 仍在工具权限层仅允许读取操作。
This commit is contained in:
mesalogo
2026-09-03 16:23:14 +08:00
parent 987a08ad78
commit 6586192222
7 changed files with 71 additions and 367 deletions
@@ -12,7 +12,6 @@ import {
type RemoteRuntimeBundleManifest
} from '../shared/remote-runtime-launch-contracts'
import {
OPENCODE_BWRAP_EXECUTABLE,
createOpenCodeLaunchProfile
} from './opencode-runtime-profile'
@@ -24,28 +23,25 @@ afterEach(() => {
}
})
describe('OpenCode bubblewrap launch profile', () => {
it('uses a read-only Workspace bind for Ask', () => {
describe('OpenCode direct launch profile', () => {
it('runs Ask directly in the Workspace', () => {
const fixture = createFixture()
const profile = createOpenCodeLaunchProfile({
...fixture,
workMode: 'ask'
})
expect(profile.executable).toBe(OPENCODE_BWRAP_EXECUTABLE)
expect(profile.cwd).toBe(fixture.scratchDirectory)
expect(profile.args).toContain('--unshare-all')
expect(profile.args).toContain('--clearenv')
expectArgumentSequence(profile.args, [
'--ro-bind',
fixture.workspaceDirectory,
fixture.workspaceDirectory
])
expectArgumentSequence(profile.args, [
'--',
resolve(fixture.bundleDirectory, 'bin', 'opencode'),
'acp'
])
expect(profile.executable).toBe(
resolve(fixture.bundleDirectory, 'bin', 'opencode')
)
expect(profile.processExecutable).toBe(profile.executable)
expect(profile.args).toEqual(['acp'])
expect(profile.cwd).toBe(fixture.workspaceDirectory)
expect(profile.env).toMatchObject({
HOME:
process.env.HOME ?? fixture.workspaceDirectory,
PATH: process.env.PATH || '/usr/bin:/bin'
})
})
it('runs Execute directly with the SSH account environment', () => {
@@ -98,29 +94,12 @@ describe('OpenCode bubblewrap launch profile', () => {
expect(execute.args).not.toContain('--unshare-all')
})
it('sets only the fixed environment and rejects a changed profile', () => {
it('keeps Runtime arguments credential-free and rejects a changed manifest', () => {
const fixture = createFixture()
const profile = createOpenCodeLaunchProfile({
...fixture,
workMode: 'ask'
})
const setEnvironmentNames = profile.args.flatMap(
(argument, index) =>
argument === '--setenv'
? [profile.args[index + 1]]
: []
)
expect(setEnvironmentNames).toEqual([
'HOME',
'LANG',
'LC_ALL',
'PATH',
'TMPDIR',
'XDG_CACHE_HOME',
'XDG_CONFIG_HOME',
'XDG_DATA_HOME',
'XDG_STATE_HOME'
])
expect(profile.args.join('\0')).not.toContain('ANTHROPIC_API_KEY')
expect(() =>
@@ -142,18 +121,9 @@ describe('OpenCode bubblewrap launch profile', () => {
).toThrow(/normalized absolute/iu)
})
it('rejects overlapping bundle, Workspace, and scratch mounts', () => {
it('does not impose mount-layout constraints on direct launches', () => {
const fixture = createFixture()
for (const conflict of [
{
scratchDirectory: fixture.workspaceDirectory
},
{
scratchDirectory: resolve(
fixture.workspaceDirectory,
'scratch'
)
},
{
workspaceDirectory: resolve(
fixture.bundleDirectory,
@@ -167,13 +137,13 @@ describe('OpenCode bubblewrap launch profile', () => {
)
}
]) {
expect(() =>
expect(
createOpenCodeLaunchProfile({
...fixture,
...conflict,
workMode: 'ask'
})
).toThrow(/must not overlap/iu)
).toMatchObject({ workMode: 'ask' })
}
})
@@ -215,19 +185,10 @@ describe('OpenCode bubblewrap launch profile', () => {
}
})
expectArgumentSequence(profile.args, [
'--ro-bind',
agentInstallationDirectory,
agentInstallationDirectory
])
expectArgumentSequence(profile.args, [
'--ro-bind',
bridgeDirectory,
bridgeDirectory
])
expectArgumentSequence(profile.args, [
'--',
resolve(agentInstallationDirectory, 'node'),
expect(profile.executable).toBe(
resolve(agentInstallationDirectory, 'node')
)
expect(profile.args).toEqual([
resolve(agentInstallationDirectory, 'lib', 'agent.cjs'),
'model-bridge-helper',
'--socket-path',
@@ -286,11 +247,6 @@ describe('OpenCode bubblewrap launch profile', () => {
'runtimes',
'opencode'
)
const scratchDirectory = resolve(
managedRoot,
'scratch',
'prompt'
)
const agentInstallationDirectory = resolve(
managedRoot,
'agent',
@@ -324,7 +280,6 @@ describe('OpenCode bubblewrap launch profile', () => {
manifest: fixture.manifest,
bundleDirectory,
workspaceDirectory,
scratchDirectory,
workMode: 'execute',
modelBridge
})
@@ -332,16 +287,18 @@ describe('OpenCode bubblewrap launch profile', () => {
cwd: workspaceDirectory,
workMode: 'execute'
})
expect(() =>
expect(
createOpenCodeLaunchProfile({
manifest: fixture.manifest,
bundleDirectory,
workspaceDirectory,
scratchDirectory,
workMode: 'ask',
modelBridge
})
).toThrow(/paths must not overlap/iu)
).toMatchObject({
cwd: workspaceDirectory,
workMode: 'ask'
})
})
})
@@ -349,7 +306,6 @@ function createFixture(): {
manifest: RemoteRuntimeBundleManifest
bundleDirectory: string
workspaceDirectory: string
scratchDirectory: string
} {
const root = resolve(
mkdtempSync(join(tmpdir(), 'goodbuddy-opencode-profile-'))
@@ -357,7 +313,6 @@ function createFixture(): {
temporaryPaths.push(root)
const bundleDirectory = resolve(root, 'bundle')
const workspaceDirectory = resolve(root, 'workspace')
const scratchDirectory = resolve(root, 'scratch')
for (const directory of [root, bundleDirectory, workspaceDirectory]) {
mkdirSync(directory, { recursive: true, mode: 0o700 })
if (process.platform !== 'win32') {
@@ -367,7 +322,6 @@ function createFixture(): {
return {
bundleDirectory,
workspaceDirectory,
scratchDirectory,
manifest: remoteRuntimeBundleManifestSchema.parse({
formatVersion: 2,
product: 'GoodBuddy',
@@ -433,17 +387,3 @@ function createFixture(): {
})
}
}
function expectArgumentSequence(
args: readonly string[],
sequence: readonly string[]
): void {
const start = args.findIndex(
(value, index) =>
value === sequence[0] &&
sequence.every(
(candidate, offset) => args[index + offset] === candidate
)
)
expect(start).toBeGreaterThanOrEqual(0)
}