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:
@@ -305,10 +305,10 @@ integrity,以 `--ignore-scripts` 从官方 npm registry 获取归档;bundle
|
||||
再次验证归档 SHA-512、包名、版本和 ELF 架构。
|
||||
|
||||
源码已经实现 Runtime bundle build/import/verify、Daemon 侧
|
||||
manifest/Ed25519/payload/ELF/lock 校验、digest registry、ACP v3、直接进程 ownership、
|
||||
Ask 的固定 `bwrap` 只读 profile 和 `runtime/model-bridge` v1。Agent 通过私有 Unix socket
|
||||
按需 detached 启动,不依赖 systemd、D-Bus 或 Linger。Execute 直接以所选 SSH 账号权限
|
||||
启动签名 Runtime;Ask 才使用只读 bubblewrap。
|
||||
manifest/Ed25519/payload/ELF/lock 校验、digest registry、ACP v3、直接进程 ownership 和
|
||||
`runtime/model-bridge` v1。Agent 通过私有 Unix socket 按需 detached 启动,不依赖
|
||||
systemd、D-Bus、Linger 或 bubblewrap。Ask 与 Execute 都直接启动签名 Runtime;两者的
|
||||
差异由 Agent 工具权限分发边界执行。
|
||||
|
||||
OpenCode 通过已签名 Agent helper 和每次 Prompt 的私有 Unix socket 使用 Main-only 模型
|
||||
网关;Provider URL、API Key 和真实 Provider 认证头不进入远端。helper 的 loopback
|
||||
@@ -317,7 +317,7 @@ HTTP 入口使用每个进程随机生成的路径 capability,Anthropic/OpenAI
|
||||
配置必须禁用 title Agent;工具循环只使用 build Agent 的模型轮次。
|
||||
|
||||
Ask 模式的 ACP 权限中介只可为原生 `read` 选择 `allow_once`;其他工具种类和
|
||||
`allow_always` 请求必须拒绝,Workspace 的真正只读边界仍由 `bwrap` 执行。Unix 模型桥
|
||||
`allow_always` 请求必须拒绝。Unix 模型桥
|
||||
broker 必须按 socket 当前已缓冲字节增量读取长度帧,不能等待 `read(remaining)` 一次返回
|
||||
完整大响应。Agent 传输或权限实现变化后,除了普通测试,还要在原生 Linux 上运行包含
|
||||
至少 256 KiB 响应的模型桥回归。
|
||||
|
||||
@@ -182,22 +182,17 @@ selection,Renderer 会恢复为当前 OpenCode 配置;Main 的远程请求
|
||||
|
||||
### Ask
|
||||
|
||||
Ask 使用系统 `bwrap`:
|
||||
Ask 与 Execute 一样直接启动已签名 Runtime,不要求 Host 安装额外的进程隔离命令:
|
||||
|
||||
- Workspace 和 Runtime bundle 只读 bind;
|
||||
- 独立可写 scratch HOME、TMPDIR 和 XDG 目录;
|
||||
- 固定环境名和固定 Runtime argv;
|
||||
- 通过只读边界阻止 Workspace 修改。
|
||||
- `cwd` 为项目 Workspace,并继承 SSH 账号的正常环境;
|
||||
- ACP 权限请求只有在工具种类为原生 `read` 且 Runtime 提供 `allow_once` 选项时才允许;
|
||||
search、edit、execute、未知工具以及只提供持久授权的请求全部拒绝。
|
||||
|
||||
`allow_once` 只解决 OpenCode 发起原生读取前的 ACP 协商,真正的文件系统边界仍是只读
|
||||
`bwrap` bind。Host 缺少 `bwrap` 时 Ask 启动失败,但不影响 Runtime 能力声明或 Execute
|
||||
直接启动。
|
||||
- Ask 的只读语义由 Agent 持有的工具权限分发边界执行,不对 Runtime 进程增加文件系统
|
||||
confinement。未来审批模式也在同一工具边界决定是否放行,不改变 Runtime 启动方式。
|
||||
|
||||
### Execute
|
||||
|
||||
Execute 不经过 Ask 的 bubblewrap profile:
|
||||
Execute 直接启动已签名 Runtime:
|
||||
|
||||
- 直接启动已签名 Runtime entrypoint;
|
||||
- 使用 Agent 本地模型 gateway 时,签名 Agent launcher 会 `exec` 候选 manifest 锁定的
|
||||
@@ -227,9 +222,9 @@ Execute 不经过 Ask 的 bubblewrap profile:
|
||||
调度、跨 channel 超车、批发送或第二套拥塞控制。
|
||||
- Agent 在把 Main 输入交给 Runtime 前、以及把 Runtime 输出交给 Main 前,先把 ACP frame 写入本机 journal。ACK 只推进 cursor 并裁剪已确认 frame,不表示 channel 已终止。
|
||||
- Main 持久化 binding identity 和单调 cursor;保留中的 connection lease 即使处于 offline/reconnecting,也可以先把新 cursor 落盘。重连提交不能覆盖 resume 过程中并发落盘的更新。
|
||||
- Runtime 事件先经过单事件、单请求输出和每次 Prompt 最多 100 个不同工具调用的边界;
|
||||
通过边界后,Renderer 和 Main 将已接受的工具详情保存到本地 SQLite,不再额外缩短其
|
||||
输入、输出、错误或摘要。
|
||||
- Runtime 事件仍经过单事件和单请求输出字节边界,但不按固定工具调用数中止 Prompt;
|
||||
Renderer 和 Main 将已接受的全部工具活动保存到本地 SQLite,不再按活动数静默截断,
|
||||
单项输入、输出、错误和摘要继续使用各自已有的有界字段。
|
||||
- OpenCode 原生 Task 工具按 `subagent_type`、`description`、`prompt` 和稳定 tool call ID
|
||||
解析为子 Agent 事件。本地 OpenCode SDK 与远端 ACP 增量工具事件复用同一转换;ACP
|
||||
首帧缺少参数时可以先显示普通工具活动,后续参数确认其为 Task 后必须替换为子 Agent
|
||||
@@ -400,9 +395,11 @@ model bridge,以及生命周期和恢复逻辑。单元测试、mock、fixture
|
||||
也未在本地运行 production build、package 或安装探针。
|
||||
- 2026-09-03 使用包含当前 Runtime capability 修复的源码 harness,在共享 Linux x64 Host
|
||||
的唯一 `/tmp/goodbuddy-e2e-runtime-capability-*` 隔离 HOME 中读取签名 Runtime 副本。
|
||||
Agent 成功报告已登记 OpenCode Runtime,Execute 随后直接启动 OpenCode 而未经过
|
||||
`bwrap`;Runtime owner 和隔离目录均已清理,未修改 Host current Agent/Runtime。
|
||||
本次只验证能力声明和真实进程启动,没有发送 Prompt,真实文本模型调用 0 次。
|
||||
Agent 成功报告已登记 OpenCode Runtime;最终源码又在唯一
|
||||
`/tmp/goodbuddy-e2e-direct-modes-*` 隔离 HOME 中分别直接启动 Ask 与 Execute,两个模式
|
||||
都未经过外部进程隔离命令。Runtime owner 和隔离目录均已清理,未修改 Host current
|
||||
Agent/Runtime。本次只验证能力声明和真实进程启动,没有发送 Prompt,真实文本模型调用
|
||||
0 次。
|
||||
- 正常 Host 更新路径把 Linux x64 Host 的 Agent 更新为 `0.11.2-e2e.12`,并确认
|
||||
OpenCode Runtime 已安装版本与所需版本均为 `1.18.9`。
|
||||
- 一条新的 Ask 用户操作只提交一次。OpenCode 先在 build 模型轮次请求一个原生
|
||||
@@ -445,9 +442,8 @@ Renderer 暴露固定方法名、数字 RPC code 和有界 service code,不转
|
||||
identity 已变化且旧 authority 无法恢复,或原 binding 已有 `outcome-unknown` 终态时,显式
|
||||
新 Prompt 才替换旧 binding。新 ACP session 的首次 Prompt 会把 Desktop 已持久化的有界
|
||||
会话历史作为不可信数据一并发送;成功加载或恢复原 ACP session 时不会重复注入历史。
|
||||
Ask 模式的 Linux Runtime 进程由 `bwrap` 启动后 `exec` 到已签名 Runtime 或 Agent
|
||||
model bridge helper;监督器只接受同一 PID/进程组从固定 `bwrap` 路径到预期最终可执行文件
|
||||
的有界转换,再登记为运行中进程。
|
||||
Ask 和 Execute 的 Linux Runtime 进程都直接启动已签名 Runtime 或 Agent model bridge
|
||||
helper;监督器核对启动后的最终 executable,再登记为运行中进程。
|
||||
|
||||
不持久化 T2/T3、consent、approval bridge、confinement 或组件验证结果。数据库 schema
|
||||
v32 保留项目及关联用户数据,并加入 remote-recoverable Task/message/provenance/cursor;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user