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 架构。
|
再次验证归档 SHA-512、包名、版本和 ELF 架构。
|
||||||
|
|
||||||
源码已经实现 Runtime bundle build/import/verify、Daemon 侧
|
源码已经实现 Runtime bundle build/import/verify、Daemon 侧
|
||||||
manifest/Ed25519/payload/ELF/lock 校验、digest registry、ACP v3、直接进程 ownership、
|
manifest/Ed25519/payload/ELF/lock 校验、digest registry、ACP v3、直接进程 ownership 和
|
||||||
Ask 的固定 `bwrap` 只读 profile 和 `runtime/model-bridge` v1。Agent 通过私有 Unix socket
|
`runtime/model-bridge` v1。Agent 通过私有 Unix socket 按需 detached 启动,不依赖
|
||||||
按需 detached 启动,不依赖 systemd、D-Bus 或 Linger。Execute 直接以所选 SSH 账号权限
|
systemd、D-Bus、Linger 或 bubblewrap。Ask 与 Execute 都直接启动签名 Runtime;两者的
|
||||||
启动签名 Runtime;Ask 才使用只读 bubblewrap。
|
差异由 Agent 工具权限分发边界执行。
|
||||||
|
|
||||||
OpenCode 通过已签名 Agent helper 和每次 Prompt 的私有 Unix socket 使用 Main-only 模型
|
OpenCode 通过已签名 Agent helper 和每次 Prompt 的私有 Unix socket 使用 Main-only 模型
|
||||||
网关;Provider URL、API Key 和真实 Provider 认证头不进入远端。helper 的 loopback
|
网关;Provider URL、API Key 和真实 Provider 认证头不进入远端。helper 的 loopback
|
||||||
@@ -317,7 +317,7 @@ HTTP 入口使用每个进程随机生成的路径 capability,Anthropic/OpenAI
|
|||||||
配置必须禁用 title Agent;工具循环只使用 build Agent 的模型轮次。
|
配置必须禁用 title Agent;工具循环只使用 build Agent 的模型轮次。
|
||||||
|
|
||||||
Ask 模式的 ACP 权限中介只可为原生 `read` 选择 `allow_once`;其他工具种类和
|
Ask 模式的 ACP 权限中介只可为原生 `read` 选择 `allow_once`;其他工具种类和
|
||||||
`allow_always` 请求必须拒绝,Workspace 的真正只读边界仍由 `bwrap` 执行。Unix 模型桥
|
`allow_always` 请求必须拒绝。Unix 模型桥
|
||||||
broker 必须按 socket 当前已缓冲字节增量读取长度帧,不能等待 `read(remaining)` 一次返回
|
broker 必须按 socket 当前已缓冲字节增量读取长度帧,不能等待 `read(remaining)` 一次返回
|
||||||
完整大响应。Agent 传输或权限实现变化后,除了普通测试,还要在原生 Linux 上运行包含
|
完整大响应。Agent 传输或权限实现变化后,除了普通测试,还要在原生 Linux 上运行包含
|
||||||
至少 256 KiB 响应的模型桥回归。
|
至少 256 KiB 响应的模型桥回归。
|
||||||
|
|||||||
@@ -182,22 +182,17 @@ selection,Renderer 会恢复为当前 OpenCode 配置;Main 的远程请求
|
|||||||
|
|
||||||
### Ask
|
### Ask
|
||||||
|
|
||||||
Ask 使用系统 `bwrap`:
|
Ask 与 Execute 一样直接启动已签名 Runtime,不要求 Host 安装额外的进程隔离命令:
|
||||||
|
|
||||||
- Workspace 和 Runtime bundle 只读 bind;
|
- `cwd` 为项目 Workspace,并继承 SSH 账号的正常环境;
|
||||||
- 独立可写 scratch HOME、TMPDIR 和 XDG 目录;
|
|
||||||
- 固定环境名和固定 Runtime argv;
|
|
||||||
- 通过只读边界阻止 Workspace 修改。
|
|
||||||
- ACP 权限请求只有在工具种类为原生 `read` 且 Runtime 提供 `allow_once` 选项时才允许;
|
- ACP 权限请求只有在工具种类为原生 `read` 且 Runtime 提供 `allow_once` 选项时才允许;
|
||||||
search、edit、execute、未知工具以及只提供持久授权的请求全部拒绝。
|
search、edit、execute、未知工具以及只提供持久授权的请求全部拒绝。
|
||||||
|
- Ask 的只读语义由 Agent 持有的工具权限分发边界执行,不对 Runtime 进程增加文件系统
|
||||||
`allow_once` 只解决 OpenCode 发起原生读取前的 ACP 协商,真正的文件系统边界仍是只读
|
confinement。未来审批模式也在同一工具边界决定是否放行,不改变 Runtime 启动方式。
|
||||||
`bwrap` bind。Host 缺少 `bwrap` 时 Ask 启动失败,但不影响 Runtime 能力声明或 Execute
|
|
||||||
直接启动。
|
|
||||||
|
|
||||||
### Execute
|
### Execute
|
||||||
|
|
||||||
Execute 不经过 Ask 的 bubblewrap profile:
|
Execute 直接启动已签名 Runtime:
|
||||||
|
|
||||||
- 直接启动已签名 Runtime entrypoint;
|
- 直接启动已签名 Runtime entrypoint;
|
||||||
- 使用 Agent 本地模型 gateway 时,签名 Agent launcher 会 `exec` 候选 manifest 锁定的
|
- 使用 Agent 本地模型 gateway 时,签名 Agent launcher 会 `exec` 候选 manifest 锁定的
|
||||||
@@ -227,9 +222,9 @@ Execute 不经过 Ask 的 bubblewrap profile:
|
|||||||
调度、跨 channel 超车、批发送或第二套拥塞控制。
|
调度、跨 channel 超车、批发送或第二套拥塞控制。
|
||||||
- Agent 在把 Main 输入交给 Runtime 前、以及把 Runtime 输出交给 Main 前,先把 ACP frame 写入本机 journal。ACK 只推进 cursor 并裁剪已确认 frame,不表示 channel 已终止。
|
- Agent 在把 Main 输入交给 Runtime 前、以及把 Runtime 输出交给 Main 前,先把 ACP frame 写入本机 journal。ACK 只推进 cursor 并裁剪已确认 frame,不表示 channel 已终止。
|
||||||
- Main 持久化 binding identity 和单调 cursor;保留中的 connection lease 即使处于 offline/reconnecting,也可以先把新 cursor 落盘。重连提交不能覆盖 resume 过程中并发落盘的更新。
|
- Main 持久化 binding identity 和单调 cursor;保留中的 connection lease 即使处于 offline/reconnecting,也可以先把新 cursor 落盘。重连提交不能覆盖 resume 过程中并发落盘的更新。
|
||||||
- Runtime 事件先经过单事件、单请求输出和每次 Prompt 最多 100 个不同工具调用的边界;
|
- Runtime 事件仍经过单事件和单请求输出字节边界,但不按固定工具调用数中止 Prompt;
|
||||||
通过边界后,Renderer 和 Main 将已接受的工具详情保存到本地 SQLite,不再额外缩短其
|
Renderer 和 Main 将已接受的全部工具活动保存到本地 SQLite,不再按活动数静默截断,
|
||||||
输入、输出、错误或摘要。
|
单项输入、输出、错误和摘要继续使用各自已有的有界字段。
|
||||||
- OpenCode 原生 Task 工具按 `subagent_type`、`description`、`prompt` 和稳定 tool call ID
|
- OpenCode 原生 Task 工具按 `subagent_type`、`description`、`prompt` 和稳定 tool call ID
|
||||||
解析为子 Agent 事件。本地 OpenCode SDK 与远端 ACP 增量工具事件复用同一转换;ACP
|
解析为子 Agent 事件。本地 OpenCode SDK 与远端 ACP 增量工具事件复用同一转换;ACP
|
||||||
首帧缺少参数时可以先显示普通工具活动,后续参数确认其为 Task 后必须替换为子 Agent
|
首帧缺少参数时可以先显示普通工具活动,后续参数确认其为 Task 后必须替换为子 Agent
|
||||||
@@ -400,9 +395,11 @@ model bridge,以及生命周期和恢复逻辑。单元测试、mock、fixture
|
|||||||
也未在本地运行 production build、package 或安装探针。
|
也未在本地运行 production build、package 或安装探针。
|
||||||
- 2026-09-03 使用包含当前 Runtime capability 修复的源码 harness,在共享 Linux x64 Host
|
- 2026-09-03 使用包含当前 Runtime capability 修复的源码 harness,在共享 Linux x64 Host
|
||||||
的唯一 `/tmp/goodbuddy-e2e-runtime-capability-*` 隔离 HOME 中读取签名 Runtime 副本。
|
的唯一 `/tmp/goodbuddy-e2e-runtime-capability-*` 隔离 HOME 中读取签名 Runtime 副本。
|
||||||
Agent 成功报告已登记 OpenCode Runtime,Execute 随后直接启动 OpenCode 而未经过
|
Agent 成功报告已登记 OpenCode Runtime;最终源码又在唯一
|
||||||
`bwrap`;Runtime owner 和隔离目录均已清理,未修改 Host current Agent/Runtime。
|
`/tmp/goodbuddy-e2e-direct-modes-*` 隔离 HOME 中分别直接启动 Ask 与 Execute,两个模式
|
||||||
本次只验证能力声明和真实进程启动,没有发送 Prompt,真实文本模型调用 0 次。
|
都未经过外部进程隔离命令。Runtime owner 和隔离目录均已清理,未修改 Host current
|
||||||
|
Agent/Runtime。本次只验证能力声明和真实进程启动,没有发送 Prompt,真实文本模型调用
|
||||||
|
0 次。
|
||||||
- 正常 Host 更新路径把 Linux x64 Host 的 Agent 更新为 `0.11.2-e2e.12`,并确认
|
- 正常 Host 更新路径把 Linux x64 Host 的 Agent 更新为 `0.11.2-e2e.12`,并确认
|
||||||
OpenCode Runtime 已安装版本与所需版本均为 `1.18.9`。
|
OpenCode Runtime 已安装版本与所需版本均为 `1.18.9`。
|
||||||
- 一条新的 Ask 用户操作只提交一次。OpenCode 先在 build 模型轮次请求一个原生
|
- 一条新的 Ask 用户操作只提交一次。OpenCode 先在 build 模型轮次请求一个原生
|
||||||
@@ -445,9 +442,8 @@ Renderer 暴露固定方法名、数字 RPC code 和有界 service code,不转
|
|||||||
identity 已变化且旧 authority 无法恢复,或原 binding 已有 `outcome-unknown` 终态时,显式
|
identity 已变化且旧 authority 无法恢复,或原 binding 已有 `outcome-unknown` 终态时,显式
|
||||||
新 Prompt 才替换旧 binding。新 ACP session 的首次 Prompt 会把 Desktop 已持久化的有界
|
新 Prompt 才替换旧 binding。新 ACP session 的首次 Prompt 会把 Desktop 已持久化的有界
|
||||||
会话历史作为不可信数据一并发送;成功加载或恢复原 ACP session 时不会重复注入历史。
|
会话历史作为不可信数据一并发送;成功加载或恢复原 ACP session 时不会重复注入历史。
|
||||||
Ask 模式的 Linux Runtime 进程由 `bwrap` 启动后 `exec` 到已签名 Runtime 或 Agent
|
Ask 和 Execute 的 Linux Runtime 进程都直接启动已签名 Runtime 或 Agent model bridge
|
||||||
model bridge helper;监督器只接受同一 PID/进程组从固定 `bwrap` 路径到预期最终可执行文件
|
helper;监督器核对启动后的最终 executable,再登记为运行中进程。
|
||||||
的有界转换,再登记为运行中进程。
|
|
||||||
|
|
||||||
不持久化 T2/T3、consent、approval bridge、confinement 或组件验证结果。数据库 schema
|
不持久化 T2/T3、consent、approval bridge、confinement 或组件验证结果。数据库 schema
|
||||||
v32 保留项目及关联用户数据,并加入 remote-recoverable Task/message/provenance/cursor;
|
v32 保留项目及关联用户数据,并加入 remote-recoverable Task/message/provenance/cursor;
|
||||||
|
|||||||
@@ -24,24 +24,25 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('direct Linux stdio Runtime ownership', () => {
|
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 registry = createRegistry()
|
||||||
const child = fakeChild()
|
const child = fakeChild()
|
||||||
let launchedIdentity!: LinuxRuntimeProcessIdentity
|
let launchedIdentity!: LinuxRuntimeProcessIdentity
|
||||||
const spawn = vi.fn<DirectLinuxStdioSpawn>((executable, args, options) => {
|
const spawn = vi.fn<DirectLinuxStdioSpawn>((executable, args, options) => {
|
||||||
expect(executable).toBe('/usr/bin/bwrap')
|
expect(executable).toBe('/bundle/bin/opencode')
|
||||||
expect(args).not.toContain('--die-with-parent')
|
expect(args).toEqual(['acp'])
|
||||||
expect(args).toContain('--unshare-all')
|
|
||||||
expect(args).toContain('--new-session')
|
|
||||||
expect(args).toContain('GOODBUDDY_RUNTIME_OWNER_TOKEN')
|
|
||||||
expect(args.join('\0')).not.toMatch(/systemd-run|systemctl|DBUS/iu)
|
expect(args.join('\0')).not.toMatch(/systemd-run|systemctl|DBUS/iu)
|
||||||
expect(options).toEqual({
|
expect(options).toEqual({
|
||||||
shell: false,
|
shell: false,
|
||||||
detached: true,
|
detached: true,
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
cwd: '/state/scratch',
|
cwd: '/workspace',
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
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()
|
launchedIdentity = identity()
|
||||||
queueMicrotask(() => child.emit('spawn'))
|
queueMicrotask(() => child.emit('spawn'))
|
||||||
@@ -122,47 +123,6 @@ describe('direct Linux stdio Runtime ownership', () => {
|
|||||||
registry.close()
|
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 () => {
|
it('rejects PID reuse before sending any signal', async () => {
|
||||||
const registry = createRegistry()
|
const registry = createRegistry()
|
||||||
const child = fakeChild()
|
const child = fakeChild()
|
||||||
@@ -350,24 +310,12 @@ function fakeChild(): DirectLinuxStdioChild & EventEmitter {
|
|||||||
|
|
||||||
function profile() {
|
function profile() {
|
||||||
return {
|
return {
|
||||||
executable: '/usr/bin/bwrap' as const,
|
executable: '/bundle/bin/opencode' as const,
|
||||||
processExecutable: '/usr/bin/bwrap' as const,
|
processExecutable: '/bundle/bin/opencode' as const,
|
||||||
cwd: '/state/scratch',
|
cwd: '/workspace',
|
||||||
env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' },
|
env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' },
|
||||||
workMode: 'ask' as const,
|
workMode: 'ask' as const,
|
||||||
args: [
|
args: ['acp']
|
||||||
'--new-session',
|
|
||||||
'--unshare-all',
|
|
||||||
'--clearenv',
|
|
||||||
'--proc',
|
|
||||||
'/proc',
|
|
||||||
'--ro-bind',
|
|
||||||
'/workspace',
|
|
||||||
'/workspace',
|
|
||||||
'--',
|
|
||||||
'/bundle/bin/opencode',
|
|
||||||
'acp'
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +325,7 @@ function identity(): LinuxRuntimeProcessIdentity {
|
|||||||
pid: 42,
|
pid: 42,
|
||||||
startTimeTicks: 100n,
|
startTimeTicks: 100n,
|
||||||
processGroupId: 42,
|
processGroupId: 42,
|
||||||
executablePath: '/usr/bin/bwrap'
|
executablePath: '/bundle/bin/opencode'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
import type { ModelBridgePolicy } from '../shared/model-bridge-contracts'
|
import type { ModelBridgePolicy } from '../shared/model-bridge-contracts'
|
||||||
import {
|
import {
|
||||||
createOpenCodeLaunchProfile,
|
createOpenCodeLaunchProfile,
|
||||||
OPENCODE_BWRAP_EXECUTABLE,
|
|
||||||
type OpenCodeLaunchProfile
|
type OpenCodeLaunchProfile
|
||||||
} from './opencode-runtime-profile'
|
} from './opencode-runtime-profile'
|
||||||
import {
|
import {
|
||||||
@@ -102,7 +101,6 @@ export type DirectLinuxStdioProcessOwnerOptions = {
|
|||||||
profileInput?: {
|
profileInput?: {
|
||||||
bundleDirectory: string
|
bundleDirectory: string
|
||||||
workspaceDirectory: string
|
workspaceDirectory: string
|
||||||
scratchDirectory: string
|
|
||||||
workMode: 'ask' | 'execute'
|
workMode: 'ask' | 'execute'
|
||||||
modelBridge?: {
|
modelBridge?: {
|
||||||
agentExecutablePath: string
|
agentExecutablePath: string
|
||||||
@@ -771,26 +769,12 @@ export function bindDirectOwnerToken(
|
|||||||
) {
|
) {
|
||||||
throw identityError('Runtime owner token marker is invalid or duplicated')
|
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 {
|
return {
|
||||||
...profile,
|
...profile,
|
||||||
args: [
|
env: {
|
||||||
...profile.args.slice(0, separator),
|
...profile.env,
|
||||||
'--setenv',
|
[OWNER_TOKEN_ENVIRONMENT_NAME]: ownerToken
|
||||||
OWNER_TOKEN_ENVIRONMENT_NAME,
|
}
|
||||||
ownerToken,
|
|
||||||
...profile.args.slice(separator)
|
|
||||||
],
|
|
||||||
env: { ...profile.env }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,31 +840,8 @@ function validateProfile(profile: OpenCodeLaunchProfile): OpenCodeLaunchProfile
|
|||||||
) {
|
) {
|
||||||
throw identityError('Runtime launch profile is malformed')
|
throw identityError('Runtime launch profile is malformed')
|
||||||
}
|
}
|
||||||
if (
|
if (profile.processExecutable !== profile.executable) {
|
||||||
profile.workMode === 'ask' &&
|
throw identityError('Runtime launch profile must run directly')
|
||||||
(
|
|
||||||
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')
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
executable: profile.executable,
|
executable: profile.executable,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
type RemoteRuntimeBundleManifest
|
type RemoteRuntimeBundleManifest
|
||||||
} from '../shared/remote-runtime-launch-contracts'
|
} from '../shared/remote-runtime-launch-contracts'
|
||||||
import {
|
import {
|
||||||
OPENCODE_BWRAP_EXECUTABLE,
|
|
||||||
createOpenCodeLaunchProfile
|
createOpenCodeLaunchProfile
|
||||||
} from './opencode-runtime-profile'
|
} from './opencode-runtime-profile'
|
||||||
|
|
||||||
@@ -24,28 +23,25 @@ afterEach(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('OpenCode bubblewrap launch profile', () => {
|
describe('OpenCode direct launch profile', () => {
|
||||||
it('uses a read-only Workspace bind for Ask', () => {
|
it('runs Ask directly in the Workspace', () => {
|
||||||
const fixture = createFixture()
|
const fixture = createFixture()
|
||||||
const profile = createOpenCodeLaunchProfile({
|
const profile = createOpenCodeLaunchProfile({
|
||||||
...fixture,
|
...fixture,
|
||||||
workMode: 'ask'
|
workMode: 'ask'
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(profile.executable).toBe(OPENCODE_BWRAP_EXECUTABLE)
|
expect(profile.executable).toBe(
|
||||||
expect(profile.cwd).toBe(fixture.scratchDirectory)
|
resolve(fixture.bundleDirectory, 'bin', 'opencode')
|
||||||
expect(profile.args).toContain('--unshare-all')
|
)
|
||||||
expect(profile.args).toContain('--clearenv')
|
expect(profile.processExecutable).toBe(profile.executable)
|
||||||
expectArgumentSequence(profile.args, [
|
expect(profile.args).toEqual(['acp'])
|
||||||
'--ro-bind',
|
expect(profile.cwd).toBe(fixture.workspaceDirectory)
|
||||||
fixture.workspaceDirectory,
|
expect(profile.env).toMatchObject({
|
||||||
fixture.workspaceDirectory
|
HOME:
|
||||||
])
|
process.env.HOME ?? fixture.workspaceDirectory,
|
||||||
expectArgumentSequence(profile.args, [
|
PATH: process.env.PATH || '/usr/bin:/bin'
|
||||||
'--',
|
})
|
||||||
resolve(fixture.bundleDirectory, 'bin', 'opencode'),
|
|
||||||
'acp'
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('runs Execute directly with the SSH account environment', () => {
|
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')
|
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 fixture = createFixture()
|
||||||
const profile = createOpenCodeLaunchProfile({
|
const profile = createOpenCodeLaunchProfile({
|
||||||
...fixture,
|
...fixture,
|
||||||
workMode: 'ask'
|
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(profile.args.join('\0')).not.toContain('ANTHROPIC_API_KEY')
|
||||||
|
|
||||||
expect(() =>
|
expect(() =>
|
||||||
@@ -142,18 +121,9 @@ describe('OpenCode bubblewrap launch profile', () => {
|
|||||||
).toThrow(/normalized absolute/iu)
|
).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()
|
const fixture = createFixture()
|
||||||
for (const conflict of [
|
for (const conflict of [
|
||||||
{
|
|
||||||
scratchDirectory: fixture.workspaceDirectory
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scratchDirectory: resolve(
|
|
||||||
fixture.workspaceDirectory,
|
|
||||||
'scratch'
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
workspaceDirectory: resolve(
|
workspaceDirectory: resolve(
|
||||||
fixture.bundleDirectory,
|
fixture.bundleDirectory,
|
||||||
@@ -167,13 +137,13 @@ describe('OpenCode bubblewrap launch profile', () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
]) {
|
]) {
|
||||||
expect(() =>
|
expect(
|
||||||
createOpenCodeLaunchProfile({
|
createOpenCodeLaunchProfile({
|
||||||
...fixture,
|
...fixture,
|
||||||
...conflict,
|
...conflict,
|
||||||
workMode: 'ask'
|
workMode: 'ask'
|
||||||
})
|
})
|
||||||
).toThrow(/must not overlap/iu)
|
).toMatchObject({ workMode: 'ask' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -215,19 +185,10 @@ describe('OpenCode bubblewrap launch profile', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
expectArgumentSequence(profile.args, [
|
expect(profile.executable).toBe(
|
||||||
'--ro-bind',
|
resolve(agentInstallationDirectory, 'node')
|
||||||
agentInstallationDirectory,
|
)
|
||||||
agentInstallationDirectory
|
expect(profile.args).toEqual([
|
||||||
])
|
|
||||||
expectArgumentSequence(profile.args, [
|
|
||||||
'--ro-bind',
|
|
||||||
bridgeDirectory,
|
|
||||||
bridgeDirectory
|
|
||||||
])
|
|
||||||
expectArgumentSequence(profile.args, [
|
|
||||||
'--',
|
|
||||||
resolve(agentInstallationDirectory, 'node'),
|
|
||||||
resolve(agentInstallationDirectory, 'lib', 'agent.cjs'),
|
resolve(agentInstallationDirectory, 'lib', 'agent.cjs'),
|
||||||
'model-bridge-helper',
|
'model-bridge-helper',
|
||||||
'--socket-path',
|
'--socket-path',
|
||||||
@@ -286,11 +247,6 @@ describe('OpenCode bubblewrap launch profile', () => {
|
|||||||
'runtimes',
|
'runtimes',
|
||||||
'opencode'
|
'opencode'
|
||||||
)
|
)
|
||||||
const scratchDirectory = resolve(
|
|
||||||
managedRoot,
|
|
||||||
'scratch',
|
|
||||||
'prompt'
|
|
||||||
)
|
|
||||||
const agentInstallationDirectory = resolve(
|
const agentInstallationDirectory = resolve(
|
||||||
managedRoot,
|
managedRoot,
|
||||||
'agent',
|
'agent',
|
||||||
@@ -324,7 +280,6 @@ describe('OpenCode bubblewrap launch profile', () => {
|
|||||||
manifest: fixture.manifest,
|
manifest: fixture.manifest,
|
||||||
bundleDirectory,
|
bundleDirectory,
|
||||||
workspaceDirectory,
|
workspaceDirectory,
|
||||||
scratchDirectory,
|
|
||||||
workMode: 'execute',
|
workMode: 'execute',
|
||||||
modelBridge
|
modelBridge
|
||||||
})
|
})
|
||||||
@@ -332,16 +287,18 @@ describe('OpenCode bubblewrap launch profile', () => {
|
|||||||
cwd: workspaceDirectory,
|
cwd: workspaceDirectory,
|
||||||
workMode: 'execute'
|
workMode: 'execute'
|
||||||
})
|
})
|
||||||
expect(() =>
|
expect(
|
||||||
createOpenCodeLaunchProfile({
|
createOpenCodeLaunchProfile({
|
||||||
manifest: fixture.manifest,
|
manifest: fixture.manifest,
|
||||||
bundleDirectory,
|
bundleDirectory,
|
||||||
workspaceDirectory,
|
workspaceDirectory,
|
||||||
scratchDirectory,
|
|
||||||
workMode: 'ask',
|
workMode: 'ask',
|
||||||
modelBridge
|
modelBridge
|
||||||
})
|
})
|
||||||
).toThrow(/paths must not overlap/iu)
|
).toMatchObject({
|
||||||
|
cwd: workspaceDirectory,
|
||||||
|
workMode: 'ask'
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -349,7 +306,6 @@ function createFixture(): {
|
|||||||
manifest: RemoteRuntimeBundleManifest
|
manifest: RemoteRuntimeBundleManifest
|
||||||
bundleDirectory: string
|
bundleDirectory: string
|
||||||
workspaceDirectory: string
|
workspaceDirectory: string
|
||||||
scratchDirectory: string
|
|
||||||
} {
|
} {
|
||||||
const root = resolve(
|
const root = resolve(
|
||||||
mkdtempSync(join(tmpdir(), 'goodbuddy-opencode-profile-'))
|
mkdtempSync(join(tmpdir(), 'goodbuddy-opencode-profile-'))
|
||||||
@@ -357,7 +313,6 @@ function createFixture(): {
|
|||||||
temporaryPaths.push(root)
|
temporaryPaths.push(root)
|
||||||
const bundleDirectory = resolve(root, 'bundle')
|
const bundleDirectory = resolve(root, 'bundle')
|
||||||
const workspaceDirectory = resolve(root, 'workspace')
|
const workspaceDirectory = resolve(root, 'workspace')
|
||||||
const scratchDirectory = resolve(root, 'scratch')
|
|
||||||
for (const directory of [root, bundleDirectory, workspaceDirectory]) {
|
for (const directory of [root, bundleDirectory, workspaceDirectory]) {
|
||||||
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
@@ -367,7 +322,6 @@ function createFixture(): {
|
|||||||
return {
|
return {
|
||||||
bundleDirectory,
|
bundleDirectory,
|
||||||
workspaceDirectory,
|
workspaceDirectory,
|
||||||
scratchDirectory,
|
|
||||||
manifest: remoteRuntimeBundleManifestSchema.parse({
|
manifest: remoteRuntimeBundleManifestSchema.parse({
|
||||||
formatVersion: 2,
|
formatVersion: 2,
|
||||||
product: 'GoodBuddy',
|
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
|
type ModelBridgePolicy
|
||||||
} from '../shared/model-bridge-contracts'
|
} from '../shared/model-bridge-contracts'
|
||||||
import {
|
import {
|
||||||
assertAbsoluteManagedPath,
|
assertAbsoluteManagedPath
|
||||||
ensurePrivateDirectoryTree
|
|
||||||
} from './managed-paths'
|
} from './managed-paths'
|
||||||
|
|
||||||
export const OPENCODE_BWRAP_EXECUTABLE = '/usr/bin/bwrap'
|
|
||||||
|
|
||||||
export type OpenCodeLaunchProfile = {
|
export type OpenCodeLaunchProfile = {
|
||||||
executable: string
|
executable: string
|
||||||
processExecutable: string
|
processExecutable: string
|
||||||
@@ -35,7 +32,6 @@ export function createOpenCodeLaunchProfile(input: {
|
|||||||
manifest: RemoteRuntimeBundleManifest
|
manifest: RemoteRuntimeBundleManifest
|
||||||
bundleDirectory: string
|
bundleDirectory: string
|
||||||
workspaceDirectory: string
|
workspaceDirectory: string
|
||||||
scratchDirectory: string
|
|
||||||
workMode: 'ask' | 'execute'
|
workMode: 'ask' | 'execute'
|
||||||
modelBridge?: {
|
modelBridge?: {
|
||||||
agentExecutablePath: string
|
agentExecutablePath: string
|
||||||
@@ -113,22 +109,6 @@ export function createOpenCodeLaunchProfile(input: {
|
|||||||
'Model bridge socket or Agent executable escapes its managed directory'
|
'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(
|
const executablePath = resolve(
|
||||||
bundleDirectory,
|
bundleDirectory,
|
||||||
...manifest.entrypoint.path.split('/')
|
...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 {
|
return {
|
||||||
executable: OPENCODE_BWRAP_EXECUTABLE,
|
...runtimeCommand,
|
||||||
processExecutable: runtimeCommand.executable,
|
processExecutable: runtimeCommand.executable,
|
||||||
args,
|
cwd: workspaceDirectory,
|
||||||
cwd: scratchDirectory,
|
env: executeEnvironment(process.env, workspaceDirectory),
|
||||||
env: {
|
workMode: input.workMode
|
||||||
PATH: '/usr/bin:/bin',
|
|
||||||
LANG: 'C.UTF-8'
|
|
||||||
},
|
|
||||||
workMode: 'ask'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,26 +201,6 @@ function usableEnvironmentValue(
|
|||||||
return value !== undefined && value.length > 0 && !value.includes('\0')
|
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 {
|
function isStrictDescendant(parent: string, candidate: string): boolean {
|
||||||
const child = relative(parent, candidate)
|
const child = relative(parent, candidate)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -271,7 +271,6 @@ export async function createProductionRuntimeProtocol(
|
|||||||
profileInput: {
|
profileInput: {
|
||||||
bundleDirectory: launch.bundle.bundleDirectory,
|
bundleDirectory: launch.bundle.bundleDirectory,
|
||||||
workspaceDirectory: launch.workspace.workspaceDirectory,
|
workspaceDirectory: launch.workspace.workspaceDirectory,
|
||||||
scratchDirectory: launch.scratch,
|
|
||||||
workMode: launch.workMode,
|
workMode: launch.workMode,
|
||||||
...(launch.modelBridge === undefined ||
|
...(launch.modelBridge === undefined ||
|
||||||
options.agentExecutablePath === undefined
|
options.agentExecutablePath === undefined
|
||||||
|
|||||||
Reference in New Issue
Block a user