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
@@ -24,24 +24,25 @@ afterEach(() => {
})
describe('direct Linux stdio Runtime ownership', () => {
it('spawns exact bubblewrap argv without systemd using detached fixed stdio', async () => {
it('spawns Ask directly without systemd using detached fixed stdio', async () => {
const registry = createRegistry()
const child = fakeChild()
let launchedIdentity!: LinuxRuntimeProcessIdentity
const spawn = vi.fn<DirectLinuxStdioSpawn>((executable, args, options) => {
expect(executable).toBe('/usr/bin/bwrap')
expect(args).not.toContain('--die-with-parent')
expect(args).toContain('--unshare-all')
expect(args).toContain('--new-session')
expect(args).toContain('GOODBUDDY_RUNTIME_OWNER_TOKEN')
expect(executable).toBe('/bundle/bin/opencode')
expect(args).toEqual(['acp'])
expect(args.join('\0')).not.toMatch(/systemd-run|systemctl|DBUS/iu)
expect(options).toEqual({
shell: false,
detached: true,
windowsHide: true,
cwd: '/state/scratch',
cwd: '/workspace',
stdio: ['pipe', 'pipe', 'pipe'],
env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' }
env: {
PATH: '/usr/bin:/bin',
LANG: 'C.UTF-8',
GOODBUDDY_RUNTIME_OWNER_TOKEN: 'a'.repeat(32)
}
})
launchedIdentity = identity()
queueMicrotask(() => child.emit('spawn'))
@@ -122,47 +123,6 @@ describe('direct Linux stdio Runtime ownership', () => {
registry.close()
})
it('waits for bubblewrap to exec the supervised bridge helper', async () => {
const registry = createRegistry()
const child = fakeChild()
const bwrapIdentity = identity()
const helperIdentity = {
...bwrapIdentity,
executablePath: '/agent/node'
}
const spawn: DirectLinuxStdioSpawn = () => {
queueMicrotask(() => child.emit('spawn'))
return child
}
const readProcessIdentity = vi
.fn()
.mockResolvedValueOnce(bwrapIdentity)
.mockResolvedValue(helperIdentity)
const owner = await launchDirectLinuxStdioProcessOwner({
manifest: manifest(),
profile: {
...profile(),
processExecutable: '/agent/node'
},
identity: { launchId: 'launch-1', processId: 'process-1' },
installationId: 'installation-1',
registry,
deadlineAt: '2030-01-01T00:00:00.000Z',
maximumInputBytes: 1024,
platform: 'linux',
spawn,
randomOwnerToken: () => 'a'.repeat(32),
readProcessIdentity
})
expect(readProcessIdentity).toHaveBeenCalledTimes(2)
expect(owner.processIdentity).toEqual(helperIdentity)
child.exitCode = 0
child.emit('close', 0, null)
registry.close()
})
it('rejects PID reuse before sending any signal', async () => {
const registry = createRegistry()
const child = fakeChild()
@@ -350,24 +310,12 @@ function fakeChild(): DirectLinuxStdioChild & EventEmitter {
function profile() {
return {
executable: '/usr/bin/bwrap' as const,
processExecutable: '/usr/bin/bwrap' as const,
cwd: '/state/scratch',
executable: '/bundle/bin/opencode' as const,
processExecutable: '/bundle/bin/opencode' as const,
cwd: '/workspace',
env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' },
workMode: 'ask' as const,
args: [
'--new-session',
'--unshare-all',
'--clearenv',
'--proc',
'/proc',
'--ro-bind',
'/workspace',
'/workspace',
'--',
'/bundle/bin/opencode',
'acp'
]
args: ['acp']
}
}
@@ -377,7 +325,7 @@ function identity(): LinuxRuntimeProcessIdentity {
pid: 42,
startTimeTicks: 100n,
processGroupId: 42,
executablePath: '/usr/bin/bwrap'
executablePath: '/bundle/bin/opencode'
}
}
@@ -18,7 +18,6 @@ import {
import type { ModelBridgePolicy } from '../shared/model-bridge-contracts'
import {
createOpenCodeLaunchProfile,
OPENCODE_BWRAP_EXECUTABLE,
type OpenCodeLaunchProfile
} from './opencode-runtime-profile'
import {
@@ -102,7 +101,6 @@ export type DirectLinuxStdioProcessOwnerOptions = {
profileInput?: {
bundleDirectory: string
workspaceDirectory: string
scratchDirectory: string
workMode: 'ask' | 'execute'
modelBridge?: {
agentExecutablePath: string
@@ -771,26 +769,12 @@ export function bindDirectOwnerToken(
) {
throw identityError('Runtime owner token marker is invalid or duplicated')
}
if (profile.workMode === 'execute') {
return {
...profile,
env: {
...profile.env,
[OWNER_TOKEN_ENVIRONMENT_NAME]: ownerToken
}
}
}
const separator = profile.args.indexOf('--')
return {
...profile,
args: [
...profile.args.slice(0, separator),
'--setenv',
OWNER_TOKEN_ENVIRONMENT_NAME,
ownerToken,
...profile.args.slice(separator)
],
env: { ...profile.env }
env: {
...profile.env,
[OWNER_TOKEN_ENVIRONMENT_NAME]: ownerToken
}
}
}
@@ -856,31 +840,8 @@ function validateProfile(profile: OpenCodeLaunchProfile): OpenCodeLaunchProfile
) {
throw identityError('Runtime launch profile is malformed')
}
if (
profile.workMode === 'ask' &&
(
profile.executable !== OPENCODE_BWRAP_EXECUTABLE ||
profile.args.includes(['--die', 'with-parent'].join('-')) ||
!profile.args.includes('--unshare-all') ||
!profile.args.includes('--new-session') ||
!profile.args.includes('--clearenv') ||
!profile.args.includes('--ro-bind') ||
profile.args.filter((argument) => argument === '--').length !== 1
)
) {
throw identityError('Ask Runtime launch profile is not read-only')
}
if (
profile.workMode === 'execute' &&
(
profile.executable === OPENCODE_BWRAP_EXECUTABLE ||
profile.args.includes('--unshare-all') ||
profile.args.includes('--clearenv') ||
profile.args.includes('--ro-bind') ||
profile.args.includes('--bind')
)
) {
throw identityError('Execute Runtime launch profile must run directly')
if (profile.processExecutable !== profile.executable) {
throw identityError('Runtime launch profile must run directly')
}
return {
executable: profile.executable,
@@ -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)
}
+5 -145
View File
@@ -16,12 +16,9 @@ import {
type ModelBridgePolicy
} from '../shared/model-bridge-contracts'
import {
assertAbsoluteManagedPath,
ensurePrivateDirectoryTree
assertAbsoluteManagedPath
} from './managed-paths'
export const OPENCODE_BWRAP_EXECUTABLE = '/usr/bin/bwrap'
export type OpenCodeLaunchProfile = {
executable: string
processExecutable: string
@@ -35,7 +32,6 @@ export function createOpenCodeLaunchProfile(input: {
manifest: RemoteRuntimeBundleManifest
bundleDirectory: string
workspaceDirectory: string
scratchDirectory: string
workMode: 'ask' | 'execute'
modelBridge?: {
agentExecutablePath: string
@@ -113,22 +109,6 @@ export function createOpenCodeLaunchProfile(input: {
'Model bridge socket or Agent executable escapes its managed directory'
)
}
const managedPaths = [
['Runtime bundle', bundleDirectory],
['Workspace', workspaceDirectory],
...(modelBridge === undefined
? []
: [
[
'Agent installation',
modelBridge.agentInstallationDirectory
] as const,
[
'Model bridge',
modelBridge.bridgeDirectory
] as const
])
] as const
const executablePath = resolve(
bundleDirectory,
...manifest.entrypoint.path.split('/')
@@ -168,112 +148,12 @@ export function createOpenCodeLaunchProfile(input: {
]
}
if (input.workMode === 'execute') {
return {
...runtimeCommand,
processExecutable: runtimeCommand.executable,
cwd: workspaceDirectory,
env: executeEnvironment(process.env, workspaceDirectory),
workMode: 'execute'
}
}
const scratchDirectory = assertAbsoluteManagedPath(
resolve(input.scratchDirectory)
)
assertDisjointPaths([
...managedPaths,
['Runtime scratch', scratchDirectory]
])
const scratchPaths = {
home: join(scratchDirectory, 'home'),
temporary: join(scratchDirectory, 'tmp'),
cache: join(scratchDirectory, 'xdg-cache'),
config: join(scratchDirectory, 'xdg-config'),
data: join(scratchDirectory, 'xdg-data'),
state: join(scratchDirectory, 'xdg-state')
}
for (const path of Object.values(scratchPaths)) {
ensurePrivateDirectoryTree(path, scratchDirectory)
}
const environment: Readonly<Record<
(typeof OPENCODE_REMOTE_RUNTIME_ENVIRONMENT_NAMES)[number],
string
>> = {
HOME: scratchPaths.home,
LANG: 'C.UTF-8',
LC_ALL: 'C.UTF-8',
PATH: '/usr/bin:/bin',
TMPDIR: scratchPaths.temporary,
XDG_CACHE_HOME: scratchPaths.cache,
XDG_CONFIG_HOME: scratchPaths.config,
XDG_DATA_HOME: scratchPaths.data,
XDG_STATE_HOME: scratchPaths.state
}
const args = [
'--new-session',
'--unshare-all',
'--clearenv',
'--proc',
'/proc',
'--dev',
'/dev',
'--tmpfs',
'/run',
'--ro-bind',
'/usr',
'/usr',
'--ro-bind-try',
'/bin',
'/bin',
'--ro-bind-try',
'/lib',
'/lib',
'--ro-bind-try',
'/lib64',
'/lib64',
'--ro-bind',
bundleDirectory,
bundleDirectory,
'--ro-bind',
workspaceDirectory,
workspaceDirectory,
'--bind',
scratchDirectory,
scratchDirectory,
...Object.entries(environment).flatMap(([name, value]) => [
'--setenv',
name,
value
]),
'--chdir',
workspaceDirectory,
...(modelBridge === undefined
? []
: [
'--ro-bind',
modelBridge.agentInstallationDirectory,
modelBridge.agentInstallationDirectory,
'--ro-bind',
modelBridge.bridgeDirectory,
modelBridge.bridgeDirectory
]),
'--',
runtimeCommand.executable,
...runtimeCommand.args
] as const
return {
executable: OPENCODE_BWRAP_EXECUTABLE,
...runtimeCommand,
processExecutable: runtimeCommand.executable,
args,
cwd: scratchDirectory,
env: {
PATH: '/usr/bin:/bin',
LANG: 'C.UTF-8'
},
workMode: 'ask'
cwd: workspaceDirectory,
env: executeEnvironment(process.env, workspaceDirectory),
workMode: input.workMode
}
}
@@ -321,26 +201,6 @@ function usableEnvironmentValue(
return value !== undefined && value.length > 0 && !value.includes('\0')
}
function assertDisjointPaths(
paths: readonly (readonly [label: string, path: string])[]
): void {
for (let left = 0; left < paths.length; left += 1) {
for (let right = left + 1; right < paths.length; right += 1) {
const leftEntry = paths[left]!
const rightEntry = paths[right]!
if (
leftEntry[1] === rightEntry[1] ||
isStrictDescendant(leftEntry[1], rightEntry[1]) ||
isStrictDescendant(rightEntry[1], leftEntry[1])
) {
throw new Error(
`${leftEntry[0]} and ${rightEntry[0]} paths must not overlap`
)
}
}
}
}
function isStrictDescendant(parent: string, candidate: string): boolean {
const child = relative(parent, candidate)
return (
-1
View File
@@ -271,7 +271,6 @@ export async function createProductionRuntimeProtocol(
profileInput: {
bundleDirectory: launch.bundle.bundleDirectory,
workspaceDirectory: launch.workspace.workspaceDirectory,
scratchDirectory: launch.scratch,
workMode: launch.workMode,
...(launch.modelBridge === undefined ||
options.agentExecutablePath === undefined